Repository: halo-dev/halo Branch: main Commit: 4af3e75a2b05 Files: 2670 Total size: 10.5 MB Directory structure: gitextract_qe2hhq1p/ ├── .dockerignore ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.en.yml │ │ ├── bug_report.zh.yml │ │ ├── config.yml │ │ ├── feature_request.en.yml │ │ └── feature_request.zh.yml │ ├── actions/ │ │ ├── docker-buildx-push/ │ │ │ └── action.yaml │ │ └── setup-env/ │ │ └── action.yaml │ ├── pull_request_template.md │ └── workflows/ │ ├── halo.yaml │ ├── openapi-check.yaml │ ├── packages-preview-release.yaml │ ├── release-ui-packages.yaml │ └── stale-issues.yaml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── OWNERS ├── README.md ├── SECURITY.md ├── api/ │ ├── build.gradle │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── run/ │ │ └── halo/ │ │ └── app/ │ │ ├── content/ │ │ │ ├── ContentWrapper.java │ │ │ ├── ExcerptGenerator.java │ │ │ ├── PatchUtils.java │ │ │ ├── PostContentService.java │ │ │ └── comment/ │ │ │ └── CommentSubject.java │ │ ├── core/ │ │ │ ├── attachment/ │ │ │ │ ├── ThumbnailProvider.java │ │ │ │ └── ThumbnailSize.java │ │ │ ├── endpoint/ │ │ │ │ └── WebSocketEndpoint.java │ │ │ ├── extension/ │ │ │ │ ├── AnnotationSetting.java │ │ │ │ ├── AuthProvider.java │ │ │ │ ├── Counter.java │ │ │ │ ├── Device.java │ │ │ │ ├── Menu.java │ │ │ │ ├── MenuItem.java │ │ │ │ ├── Plugin.java │ │ │ │ ├── RememberMeToken.java │ │ │ │ ├── ReverseProxy.java │ │ │ │ ├── Role.java │ │ │ │ ├── RoleBinding.java │ │ │ │ ├── Setting.java │ │ │ │ ├── Theme.java │ │ │ │ ├── User.java │ │ │ │ ├── UserConnection.java │ │ │ │ ├── attachment/ │ │ │ │ │ ├── Attachment.java │ │ │ │ │ ├── Constant.java │ │ │ │ │ ├── Group.java │ │ │ │ │ ├── Policy.java │ │ │ │ │ ├── PolicyTemplate.java │ │ │ │ │ └── endpoint/ │ │ │ │ │ ├── AttachmentHandler.java │ │ │ │ │ ├── DeleteOption.java │ │ │ │ │ ├── SimpleFilePart.java │ │ │ │ │ └── UploadOption.java │ │ │ │ ├── content/ │ │ │ │ │ ├── Category.java │ │ │ │ │ ├── Comment.java │ │ │ │ │ ├── Constant.java │ │ │ │ │ ├── Post.java │ │ │ │ │ ├── Reply.java │ │ │ │ │ ├── SinglePage.java │ │ │ │ │ ├── Snapshot.java │ │ │ │ │ └── Tag.java │ │ │ │ ├── endpoint/ │ │ │ │ │ ├── CustomEndpoint.java │ │ │ │ │ └── SortResolver.java │ │ │ │ ├── notification/ │ │ │ │ │ ├── Notification.java │ │ │ │ │ ├── NotificationTemplate.java │ │ │ │ │ ├── NotifierDescriptor.java │ │ │ │ │ ├── Reason.java │ │ │ │ │ ├── ReasonType.java │ │ │ │ │ └── Subscription.java │ │ │ │ └── service/ │ │ │ │ └── AttachmentService.java │ │ │ └── user/ │ │ │ └── service/ │ │ │ ├── RoleService.java │ │ │ ├── SignUpData.java │ │ │ ├── UserPostCreatingHandler.java │ │ │ ├── UserPreCreatingHandler.java │ │ │ └── UserService.java │ │ ├── event/ │ │ │ ├── post/ │ │ │ │ ├── PostDeletedEvent.java │ │ │ │ ├── PostEvent.java │ │ │ │ ├── PostPublishedEvent.java │ │ │ │ ├── PostUnpublishedEvent.java │ │ │ │ ├── PostUpdatedEvent.java │ │ │ │ └── PostVisibleChangedEvent.java │ │ │ └── user/ │ │ │ ├── UserConnectionDisconnectedEvent.java │ │ │ ├── UserLoginEvent.java │ │ │ └── UserLogoutEvent.java │ │ ├── extension/ │ │ │ ├── AbstractExtension.java │ │ │ ├── Comparators.java │ │ │ ├── ConfigMap.java │ │ │ ├── DefaultExtensionMatcher.java │ │ │ ├── Extension.java │ │ │ ├── ExtensionClient.java │ │ │ ├── ExtensionMatcher.java │ │ │ ├── ExtensionOperator.java │ │ │ ├── ExtensionUtil.java │ │ │ ├── GVK.java │ │ │ ├── GroupKind.java │ │ │ ├── GroupVersion.java │ │ │ ├── GroupVersionKind.java │ │ │ ├── JsonExtension.java │ │ │ ├── ListOptions.java │ │ │ ├── ListResult.java │ │ │ ├── Metadata.java │ │ │ ├── MetadataOperator.java │ │ │ ├── MetadataUtil.java │ │ │ ├── PageRequest.java │ │ │ ├── PageRequestImpl.java │ │ │ ├── ReactiveExtensionClient.java │ │ │ ├── Ref.java │ │ │ ├── Scheme.java │ │ │ ├── SchemeManager.java │ │ │ ├── Secret.java │ │ │ ├── Unstructured.java │ │ │ ├── Watcher.java │ │ │ ├── WatcherExtensionMatchers.java │ │ │ ├── WatcherPredicates.java │ │ │ ├── controller/ │ │ │ │ ├── Controller.java │ │ │ │ ├── ControllerBuilder.java │ │ │ │ ├── DefaultController.java │ │ │ │ ├── DefaultQueue.java │ │ │ │ ├── ExtensionWatcher.java │ │ │ │ ├── Reconciler.java │ │ │ │ ├── RequestQueue.java │ │ │ │ ├── RequestSynchronizer.java │ │ │ │ ├── RequeueException.java │ │ │ │ └── Synchronizer.java │ │ │ ├── exception/ │ │ │ │ ├── ExtensionException.java │ │ │ │ ├── NotImplementedException.java │ │ │ │ └── SchemeNotFoundException.java │ │ │ ├── index/ │ │ │ │ ├── AbstractValueIndexSpecBuilder.java │ │ │ │ ├── DefaultIndexAttribute.java │ │ │ │ ├── IndexAttribute.java │ │ │ │ ├── IndexAttributeFactory.java │ │ │ │ ├── IndexSpec.java │ │ │ │ ├── IndexSpecBuilder.java │ │ │ │ ├── IndexSpecs.java │ │ │ │ ├── IndexedQueryEngine.java │ │ │ │ ├── KeyComparator.java │ │ │ │ ├── MultiValueBuilder.java │ │ │ │ ├── MultiValueIndexSpec.java │ │ │ │ ├── MultiValueIndexSpecBuilder.java │ │ │ │ ├── SingleValueBuilder.java │ │ │ │ ├── SingleValueIndexSpec.java │ │ │ │ ├── SingleValueIndexSpecBuilder.java │ │ │ │ ├── UnknownKey.java │ │ │ │ ├── ValueIndexSpec.java │ │ │ │ └── query/ │ │ │ │ ├── AllCondition.java │ │ │ │ ├── And.java │ │ │ │ ├── AndCondition.java │ │ │ │ ├── BetweenCondition.java │ │ │ │ ├── Condition.java │ │ │ │ ├── EmptyCondition.java │ │ │ │ ├── EqualCondition.java │ │ │ │ ├── GreaterThanCondition.java │ │ │ │ ├── InCondition.java │ │ │ │ ├── IndexCondition.java │ │ │ │ ├── IsNotNullCondition.java │ │ │ │ ├── IsNullCondition.java │ │ │ │ ├── LabelCondition.java │ │ │ │ ├── LabelEqualsCondition.java │ │ │ │ ├── LabelExistsCondition.java │ │ │ │ ├── LabelInCondition.java │ │ │ │ ├── LabelNotEqualsCondition.java │ │ │ │ ├── LabelNotExistsCondition.java │ │ │ │ ├── LabelNotInCondition.java │ │ │ │ ├── LessThanCondition.java │ │ │ │ ├── NoneCondition.java │ │ │ │ ├── NotBetweenCondition.java │ │ │ │ ├── NotCondition.java │ │ │ │ ├── NotEqualCondition.java │ │ │ │ ├── NotInCondition.java │ │ │ │ ├── OrCondition.java │ │ │ │ ├── Queries.java │ │ │ │ ├── Query.java │ │ │ │ ├── QueryFactory.java │ │ │ │ ├── StringContainsCondition.java │ │ │ │ ├── StringEndsWithCondition.java │ │ │ │ ├── StringNotContainsCondition.java │ │ │ │ ├── StringNotEndsWithCondition.java │ │ │ │ ├── StringNotStartsWithCondition.java │ │ │ │ └── StringStartsWithCondition.java │ │ │ └── router/ │ │ │ ├── IListRequest.java │ │ │ ├── QueryParamBuildUtil.java │ │ │ ├── SortableRequest.java │ │ │ └── selector/ │ │ │ ├── FieldSelector.java │ │ │ ├── FieldSelectorConverter.java │ │ │ ├── LabelSelector.java │ │ │ ├── LabelSelectorConverter.java │ │ │ ├── Operator.java │ │ │ ├── SelectorConverter.java │ │ │ ├── SelectorCriteria.java │ │ │ └── SelectorUtil.java │ │ ├── infra/ │ │ │ ├── AnonymousUserConst.java │ │ │ ├── BackupRootGetter.java │ │ │ ├── Condition.java │ │ │ ├── ConditionList.java │ │ │ ├── ConditionStatus.java │ │ │ ├── ExternalLinkProcessor.java │ │ │ ├── ExternalUrlSupplier.java │ │ │ ├── FileCategoryMatcher.java │ │ │ ├── SystemInfo.java │ │ │ ├── SystemInfoGetter.java │ │ │ ├── SystemSetting.java │ │ │ ├── SystemVersionSupplier.java │ │ │ ├── ValidationUtils.java │ │ │ ├── model/ │ │ │ │ └── License.java │ │ │ └── utils/ │ │ │ ├── FileTypeDetectUtils.java │ │ │ ├── GenericClassUtils.java │ │ │ ├── JsonParseException.java │ │ │ ├── JsonUtils.java │ │ │ └── PathUtils.java │ │ ├── migration/ │ │ │ ├── Backup.java │ │ │ └── Constant.java │ │ ├── notification/ │ │ │ ├── NotificationCenter.java │ │ │ ├── NotificationContext.java │ │ │ ├── NotificationReasonEmitter.java │ │ │ ├── ReactiveNotifier.java │ │ │ ├── ReasonAttributes.java │ │ │ ├── ReasonPayload.java │ │ │ └── UserIdentity.java │ │ ├── plugin/ │ │ │ ├── ApiVersion.java │ │ │ ├── BasePlugin.java │ │ │ ├── PluginConfigUpdatedEvent.java │ │ │ ├── PluginContext.java │ │ │ ├── PluginsRootGetter.java │ │ │ ├── ReactiveSettingFetcher.java │ │ │ ├── SettingFetcher.java │ │ │ ├── SharedEvent.java │ │ │ ├── event/ │ │ │ │ └── PluginStartedEvent.java │ │ │ └── extensionpoint/ │ │ │ └── ExtensionGetter.java │ │ ├── search/ │ │ │ ├── HaloDocument.java │ │ │ ├── HaloDocumentsProvider.java │ │ │ ├── SearchEngine.java │ │ │ ├── SearchOption.java │ │ │ ├── SearchResult.java │ │ │ ├── SearchService.java │ │ │ └── event/ │ │ │ ├── HaloDocumentAddRequestEvent.java │ │ │ ├── HaloDocumentDeleteRequestEvent.java │ │ │ └── HaloDocumentRebuildRequestEvent.java │ │ ├── security/ │ │ │ ├── AdditionalWebFilter.java │ │ │ ├── AfterSecurityWebFilter.java │ │ │ ├── AnonymousAuthenticationSecurityWebFilter.java │ │ │ ├── AuthenticationSecurityWebFilter.java │ │ │ ├── BeforeSecurityWebFilter.java │ │ │ ├── FormLoginSecurityWebFilter.java │ │ │ ├── HttpBasicSecurityWebFilter.java │ │ │ ├── LoginHandlerEnhancer.java │ │ │ ├── OAuth2AuthorizationCodeSecurityWebFilter.java │ │ │ ├── PersonalAccessToken.java │ │ │ ├── authentication/ │ │ │ │ ├── CryptoService.java │ │ │ │ ├── login/ │ │ │ │ │ └── UsernamePasswordAuthenticationManager.java │ │ │ │ └── oauth2/ │ │ │ │ └── HaloOAuth2AuthenticationToken.java │ │ │ └── device/ │ │ │ └── DeviceService.java │ │ └── theme/ │ │ ├── Constant.java │ │ ├── ReactivePostContentHandler.java │ │ ├── ReactiveSinglePageContentHandler.java │ │ ├── TemplateNameResolver.java │ │ ├── dialect/ │ │ │ ├── CommentWidget.java │ │ │ ├── ElementTagPostProcessor.java │ │ │ ├── TemplateFooterProcessor.java │ │ │ └── TemplateHeadProcessor.java │ │ ├── finders/ │ │ │ ├── Finder.java │ │ │ └── vo/ │ │ │ └── ExtensionVoOperator.java │ │ └── router/ │ │ ├── ModelConst.java │ │ ├── PageUrlUtils.java │ │ └── UrlContextListResult.java │ └── test/ │ └── java/ │ └── run/ │ └── halo/ │ └── app/ │ ├── core/ │ │ └── extension/ │ │ ├── content/ │ │ │ └── PostTest.java │ │ └── notification/ │ │ └── SubscriptionTest.java │ ├── extension/ │ │ ├── ExtensionUtilTest.java │ │ ├── FakeExtension.java │ │ ├── ListOptionsTest.java │ │ ├── PageRequestImplTest.java │ │ ├── SecretTest.java │ │ ├── controller/ │ │ │ ├── ControllerBuilderTest.java │ │ │ ├── DefaultControllerTest.java │ │ │ ├── DefaultDelayQueueTest.java │ │ │ ├── DelayedEntryTest.java │ │ │ ├── ExtensionWatcherTest.java │ │ │ └── RequestSynchronizerTest.java │ │ ├── index/ │ │ │ ├── IndexAttributeFactoryTest.java │ │ │ ├── IndexSpecTest.java │ │ │ ├── KeyComparatorTest.java │ │ │ ├── MultiValueBuilderTest.java │ │ │ ├── SingleValueBuilderTest.java │ │ │ ├── UnknownKeyTest.java │ │ │ └── query/ │ │ │ └── QueriesTest.java │ │ ├── indexer/ │ │ │ ├── DefaultIndexEngineTest.java │ │ │ └── LabelIndexImplTest.java │ │ └── router/ │ │ └── selector/ │ │ ├── LabelSelectorTest.java │ │ ├── OperatorTest.java │ │ └── SelectorConverterTest.java │ └── infra/ │ └── utils/ │ ├── GenericClassUtilsTest.java │ ├── JsonUtilsTest.java │ └── PathUtilsTest.java ├── api-docs/ │ └── openapi/ │ └── v3_0/ │ ├── aggregated.json │ ├── apis_console.api_v1alpha1.json │ ├── apis_extension.api_v1alpha1.json │ ├── apis_public.api_v1alpha1.json │ └── apis_uc.api_v1alpha1.json ├── application/ │ ├── build.gradle │ ├── libs/ │ │ ├── thymeleaf-3.1.3.RELEASE.jar │ │ └── thymeleaf-spring6-3.1.3.RELEASE.jar │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── run/ │ │ │ └── halo/ │ │ │ └── app/ │ │ │ ├── Application.java │ │ │ ├── content/ │ │ │ │ ├── AbstractContentService.java │ │ │ │ ├── AbstractEventReconciler.java │ │ │ │ ├── CategoryPostCountUpdater.java │ │ │ │ ├── CategoryService.java │ │ │ │ ├── Content.java │ │ │ │ ├── ContentRequest.java │ │ │ │ ├── ContentUpdateParam.java │ │ │ │ ├── Contributor.java │ │ │ │ ├── ListedPost.java │ │ │ │ ├── ListedSinglePage.java │ │ │ │ ├── ListedSnapshotDto.java │ │ │ │ ├── NotificationReasonConst.java │ │ │ │ ├── PostContentServiceImpl.java │ │ │ │ ├── PostHideFromListStateUpdater.java │ │ │ │ ├── PostQuery.java │ │ │ │ ├── PostRequest.java │ │ │ │ ├── PostService.java │ │ │ │ ├── PostSorter.java │ │ │ │ ├── SinglePageQuery.java │ │ │ │ ├── SinglePageRequest.java │ │ │ │ ├── SinglePageService.java │ │ │ │ ├── SnapshotService.java │ │ │ │ ├── Stats.java │ │ │ │ ├── comment/ │ │ │ │ │ ├── AbstractCommentService.java │ │ │ │ │ ├── CommentEmailOwner.java │ │ │ │ │ ├── CommentNotificationReasonPublisher.java │ │ │ │ │ ├── CommentQuery.java │ │ │ │ │ ├── CommentRequest.java │ │ │ │ │ ├── CommentService.java │ │ │ │ │ ├── CommentServiceImpl.java │ │ │ │ │ ├── CommentStats.java │ │ │ │ │ ├── ListedComment.java │ │ │ │ │ ├── ListedReply.java │ │ │ │ │ ├── OwnerInfo.java │ │ │ │ │ ├── PostCommentSubject.java │ │ │ │ │ ├── ReplyNotificationSubscriptionHelper.java │ │ │ │ │ ├── ReplyQuery.java │ │ │ │ │ ├── ReplyRequest.java │ │ │ │ │ ├── ReplyService.java │ │ │ │ │ ├── ReplyServiceImpl.java │ │ │ │ │ └── SinglePageCommentSubject.java │ │ │ │ ├── impl/ │ │ │ │ │ ├── CategoryServiceImpl.java │ │ │ │ │ ├── PostServiceImpl.java │ │ │ │ │ ├── SinglePageServiceImpl.java │ │ │ │ │ └── SnapshotServiceImpl.java │ │ │ │ ├── permalinks/ │ │ │ │ │ ├── CategoryPermalinkPolicy.java │ │ │ │ │ ├── ExtensionLocator.java │ │ │ │ │ ├── PermalinkPolicy.java │ │ │ │ │ ├── PostPermalinkPolicy.java │ │ │ │ │ └── TagPermalinkPolicy.java │ │ │ │ └── stats/ │ │ │ │ ├── PostStatsUpdater.java │ │ │ │ ├── ReplyEventReconciler.java │ │ │ │ ├── TagPostCountUpdater.java │ │ │ │ ├── VisitedEventReconciler.java │ │ │ │ └── VotedEventReconciler.java │ │ │ ├── core/ │ │ │ │ ├── attachment/ │ │ │ │ │ ├── AttachmentChangedEvent.java │ │ │ │ │ ├── AttachmentLister.java │ │ │ │ │ ├── AttachmentRootGetter.java │ │ │ │ │ ├── PolicyConfigChangeDetector.java │ │ │ │ │ ├── SearchRequest.java │ │ │ │ │ ├── endpoint/ │ │ │ │ │ │ ├── AttachmentEndpoint.java │ │ │ │ │ │ ├── LocalAttachmentUploadHandler.java │ │ │ │ │ │ └── PolicyEndpoint.java │ │ │ │ │ ├── extension/ │ │ │ │ │ │ ├── LocalThumbnail.java │ │ │ │ │ │ └── Thumbnail.java │ │ │ │ │ ├── impl/ │ │ │ │ │ │ ├── AttachmentListerImpl.java │ │ │ │ │ │ └── AttachmentRootGetterImpl.java │ │ │ │ │ ├── reconciler/ │ │ │ │ │ │ ├── AttachmentReconciler.java │ │ │ │ │ │ ├── LocalThumbnailsReconciler.java │ │ │ │ │ │ ├── PolicyReconciler.java │ │ │ │ │ │ └── ThumbnailReconciler.java │ │ │ │ │ └── thumbnail/ │ │ │ │ │ ├── DefaultLocalThumbnailService.java │ │ │ │ │ ├── DefaultThumbnailService.java │ │ │ │ │ ├── LocalThumbnailService.java │ │ │ │ │ ├── ThumbnailImgTagPostProcessor.java │ │ │ │ │ ├── ThumbnailResourceTransformer.java │ │ │ │ │ ├── ThumbnailService.java │ │ │ │ │ └── ThumbnailUtils.java │ │ │ │ ├── counter/ │ │ │ │ │ ├── CounterService.java │ │ │ │ │ ├── CounterServiceImpl.java │ │ │ │ │ └── MeterUtils.java │ │ │ │ ├── endpoint/ │ │ │ │ │ ├── AttachmentHandler.java │ │ │ │ │ ├── WebSocketEndpointManager.java │ │ │ │ │ ├── WebSocketHandlerMapping.java │ │ │ │ │ ├── console/ │ │ │ │ │ │ ├── AttachmentConsoleEndpoint.java │ │ │ │ │ │ ├── AuthProviderEndpoint.java │ │ │ │ │ │ ├── CommentEndpoint.java │ │ │ │ │ │ ├── ConsoleUserEndpoint.java │ │ │ │ │ │ ├── CustomEndpointsBuilder.java │ │ │ │ │ │ ├── PluginEndpoint.java │ │ │ │ │ │ ├── PostEndpoint.java │ │ │ │ │ │ ├── ReplyEndpoint.java │ │ │ │ │ │ ├── SinglePageEndpoint.java │ │ │ │ │ │ ├── StatsEndpoint.java │ │ │ │ │ │ ├── SystemConfigEndpoint.java │ │ │ │ │ │ ├── TagEndpoint.java │ │ │ │ │ │ ├── TrackerEndpoint.java │ │ │ │ │ │ └── UserEndpoint.java │ │ │ │ │ ├── theme/ │ │ │ │ │ │ ├── CategoryQueryEndpoint.java │ │ │ │ │ │ ├── CommentFinderEndpoint.java │ │ │ │ │ │ ├── MenuQueryEndpoint.java │ │ │ │ │ │ ├── PluginQueryEndpoint.java │ │ │ │ │ │ ├── PostPublicQuery.java │ │ │ │ │ │ ├── PostQueryEndpoint.java │ │ │ │ │ │ ├── PublicApiUtils.java │ │ │ │ │ │ ├── SinglePageQueryEndpoint.java │ │ │ │ │ │ ├── SiteStatsQueryEndpoint.java │ │ │ │ │ │ ├── TagQueryEndpoint.java │ │ │ │ │ │ └── ThumbnailEndpoint.java │ │ │ │ │ └── uc/ │ │ │ │ │ ├── AnnotationSettingEndpoint.java │ │ │ │ │ ├── AttachmentUcEndpoint.java │ │ │ │ │ ├── UcPostEndpoint.java │ │ │ │ │ ├── UcSnapshotEndpoint.java │ │ │ │ │ ├── UcUserPreferenceEndpoint.java │ │ │ │ │ └── UserConnectionEndpoint.java │ │ │ │ ├── reconciler/ │ │ │ │ │ ├── AnnotationSettingReconciler.java │ │ │ │ │ ├── AuthProviderReconciler.java │ │ │ │ │ ├── CategoryReconciler.java │ │ │ │ │ ├── CommentReconciler.java │ │ │ │ │ ├── MenuItemReconciler.java │ │ │ │ │ ├── PluginReconciler.java │ │ │ │ │ ├── PostCounterReconciler.java │ │ │ │ │ ├── PostReconciler.java │ │ │ │ │ ├── ReplyReconciler.java │ │ │ │ │ ├── ReverseProxyReconciler.java │ │ │ │ │ ├── RoleReconciler.java │ │ │ │ │ ├── SinglePageReconciler.java │ │ │ │ │ ├── SystemConfigReconciler.java │ │ │ │ │ ├── TagReconciler.java │ │ │ │ │ ├── ThemeReconciler.java │ │ │ │ │ └── UserReconciler.java │ │ │ │ └── user/ │ │ │ │ └── service/ │ │ │ │ ├── DefaultRoleService.java │ │ │ │ ├── EmailPasswordRecoveryService.java │ │ │ │ ├── EmailVerificationService.java │ │ │ │ ├── InMemoryResetTokenRepository.java │ │ │ │ ├── InvalidResetTokenException.java │ │ │ │ ├── PatService.java │ │ │ │ ├── ResetToken.java │ │ │ │ ├── ResetTokenRepository.java │ │ │ │ ├── SettingConfigService.java │ │ │ │ ├── UserConnectionService.java │ │ │ │ ├── UserLoginOrLogoutProcessing.java │ │ │ │ └── impl/ │ │ │ │ ├── DefaultAttachmentService.java │ │ │ │ ├── EmailPasswordRecoveryServiceImpl.java │ │ │ │ ├── EmailVerificationServiceImpl.java │ │ │ │ ├── PatServiceImpl.java │ │ │ │ ├── SettingConfigServiceImpl.java │ │ │ │ ├── UserConnectionServiceImpl.java │ │ │ │ └── UserServiceImpl.java │ │ │ ├── event/ │ │ │ │ ├── post/ │ │ │ │ │ ├── CategoryHiddenStateChangeEvent.java │ │ │ │ │ ├── CommentCreatedEvent.java │ │ │ │ │ ├── CommentUnreadReplyCountChangedEvent.java │ │ │ │ │ ├── DownvotedEvent.java │ │ │ │ │ ├── PostStatsChangedEvent.java │ │ │ │ │ ├── ReplyChangedEvent.java │ │ │ │ │ ├── ReplyCreatedEvent.java │ │ │ │ │ ├── ReplyDeletedEvent.java │ │ │ │ │ ├── ReplyEvent.java │ │ │ │ │ ├── UpvotedEvent.java │ │ │ │ │ ├── VisitedEvent.java │ │ │ │ │ └── VotedEvent.java │ │ │ │ └── user/ │ │ │ │ └── PasswordChangedEvent.java │ │ │ ├── extension/ │ │ │ │ ├── DefaultSchemeManager.java │ │ │ │ ├── DelegateExtensionClient.java │ │ │ │ ├── ExtensionConverter.java │ │ │ │ ├── ExtensionStoreUtil.java │ │ │ │ ├── JSONExtensionConverter.java │ │ │ │ ├── ReactiveExtensionClientImpl.java │ │ │ │ ├── availability/ │ │ │ │ │ └── IndexBuildState.java │ │ │ │ ├── controller/ │ │ │ │ │ └── DefaultControllerManager.java │ │ │ │ ├── event/ │ │ │ │ │ ├── IndexerBuiltEvent.java │ │ │ │ │ ├── SchemeAddedEvent.java │ │ │ │ │ └── SchemeRemovedEvent.java │ │ │ │ ├── exception/ │ │ │ │ │ ├── ExtensionConvertException.java │ │ │ │ │ ├── ExtensionNotFoundException.java │ │ │ │ │ └── SchemaViolationException.java │ │ │ │ ├── gc/ │ │ │ │ │ ├── GcControllerInitializer.java │ │ │ │ │ ├── GcReconciler.java │ │ │ │ │ ├── GcRequest.java │ │ │ │ │ ├── GcSynchronizer.java │ │ │ │ │ └── GcWatcher.java │ │ │ │ ├── index/ │ │ │ │ │ ├── DefaultIndexEngine.java │ │ │ │ │ ├── DefaultIndices.java │ │ │ │ │ ├── DefaultIndicesManager.java │ │ │ │ │ ├── Index.java │ │ │ │ │ ├── IndexEngine.java │ │ │ │ │ ├── Indices.java │ │ │ │ │ ├── IndicesInitializer.java │ │ │ │ │ ├── IndicesManager.java │ │ │ │ │ ├── LabelIndex.java │ │ │ │ │ ├── LabelIndexQuery.java │ │ │ │ │ ├── MultiValueIndex.java │ │ │ │ │ ├── SingleValueIndex.java │ │ │ │ │ ├── StringUnknownKeyConverter.java │ │ │ │ │ ├── TransactionalOperation.java │ │ │ │ │ ├── ValueIndexQuery.java │ │ │ │ │ └── query/ │ │ │ │ │ └── QueryVisitor.java │ │ │ │ ├── indexer/ │ │ │ │ │ └── DefaultIndicesInitializer.java │ │ │ │ ├── router/ │ │ │ │ │ ├── ExtensionCompositeRouterFunction.java │ │ │ │ │ ├── ExtensionCreateHandler.java │ │ │ │ │ ├── ExtensionDeleteHandler.java │ │ │ │ │ ├── ExtensionGetHandler.java │ │ │ │ │ ├── ExtensionListHandler.java │ │ │ │ │ ├── ExtensionPatchHandler.java │ │ │ │ │ ├── ExtensionRouterFunctionFactory.java │ │ │ │ │ ├── ExtensionUpdateHandler.java │ │ │ │ │ └── JsonPatch.java │ │ │ │ └── store/ │ │ │ │ ├── ExtensionStore.java │ │ │ │ ├── ExtensionStoreClient.java │ │ │ │ ├── ExtensionStoreClientJPAImpl.java │ │ │ │ ├── ExtensionStoreRepository.java │ │ │ │ ├── ReactiveExtensionStoreClient.java │ │ │ │ └── ReactiveExtensionStoreClientImpl.java │ │ │ ├── infra/ │ │ │ │ ├── DefaultBackupRootGetter.java │ │ │ │ ├── DefaultExternalLinkProcessor.java │ │ │ │ ├── DefaultInitializationStateGetter.java │ │ │ │ ├── DefaultReactiveUrlDataBufferFetcher.java │ │ │ │ ├── DefaultSystemConfigFetcher.java │ │ │ │ ├── DefaultSystemVersionSupplier.java │ │ │ │ ├── DefaultThemeRootGetter.java │ │ │ │ ├── ExtensionInitializedEvent.java │ │ │ │ ├── ExtensionResourceInitializer.java │ │ │ │ ├── ExternalUrlChangedEvent.java │ │ │ │ ├── InitializationPhase.java │ │ │ │ ├── InitializationStateGetter.java │ │ │ │ ├── ReactiveExtensionPaginatedOperator.java │ │ │ │ ├── ReactiveExtensionPaginatedOperatorImpl.java │ │ │ │ ├── ReactiveUrlDataBufferFetcher.java │ │ │ │ ├── SchemeInitializer.java │ │ │ │ ├── SecureRequestMappingHandlerAdapter.java │ │ │ │ ├── SecureServerRequest.java │ │ │ │ ├── SecureServerWebExchange.java │ │ │ │ ├── SystemConfigChangedEvent.java │ │ │ │ ├── SystemConfigFetcher.java │ │ │ │ ├── SystemConfigFirstExternalUrlSupplier.java │ │ │ │ ├── SystemConfigInitializer.java │ │ │ │ ├── SystemInfoGetterImpl.java │ │ │ │ ├── SystemState.java │ │ │ │ ├── ThemeRootGetter.java │ │ │ │ ├── actuator/ │ │ │ │ │ ├── DatabaseInfoContributor.java │ │ │ │ │ ├── GlobalInfo.java │ │ │ │ │ ├── GlobalInfoEndpoint.java │ │ │ │ │ ├── GlobalInfoService.java │ │ │ │ │ ├── GlobalInfoServiceImpl.java │ │ │ │ │ └── RestartEndpoint.java │ │ │ │ ├── config/ │ │ │ │ │ ├── ExtensionConfiguration.java │ │ │ │ │ ├── HaloConfiguration.java │ │ │ │ │ ├── JacksonAdapterModule.java │ │ │ │ │ ├── R2dbcConfiguration.java │ │ │ │ │ ├── SessionConfiguration.java │ │ │ │ │ ├── SwaggerConfig.java │ │ │ │ │ ├── WebFluxConfig.java │ │ │ │ │ └── WebServerSecurityConfig.java │ │ │ │ ├── exception/ │ │ │ │ │ ├── AccessDeniedException.java │ │ │ │ │ ├── AttachmentAlreadyExistsException.java │ │ │ │ │ ├── DuplicateNameException.java │ │ │ │ │ ├── EmailAlreadyTakenException.java │ │ │ │ │ ├── EmailVerificationFailed.java │ │ │ │ │ ├── Exceptions.java │ │ │ │ │ ├── FileSizeExceededException.java │ │ │ │ │ ├── FileTypeNotAllowedException.java │ │ │ │ │ ├── NotFoundException.java │ │ │ │ │ ├── OAuth2UserAlreadyBoundException.java │ │ │ │ │ ├── PluginAlreadyExistsException.java │ │ │ │ │ ├── PluginDependenciesNotEnabledException.java │ │ │ │ │ ├── PluginDependencyException.java │ │ │ │ │ ├── PluginDependentsNotDisabledException.java │ │ │ │ │ ├── PluginInstallationException.java │ │ │ │ │ ├── PluginRuntimeIncompatibleException.java │ │ │ │ │ ├── RateLimitExceededException.java │ │ │ │ │ ├── RequestBodyValidationException.java │ │ │ │ │ ├── RequestRestrictedException.java │ │ │ │ │ ├── RestrictedNameException.java │ │ │ │ │ ├── ThemeAlreadyExistsException.java │ │ │ │ │ ├── ThemeInstallationException.java │ │ │ │ │ ├── ThemeUninstallException.java │ │ │ │ │ ├── ThemeUpgradeException.java │ │ │ │ │ ├── UnsatisfiedAttributeValueException.java │ │ │ │ │ ├── UserNotFoundException.java │ │ │ │ │ └── handlers/ │ │ │ │ │ ├── HaloErrorConfiguration.java │ │ │ │ │ ├── HaloErrorWebExceptionHandler.java │ │ │ │ │ └── ProblemDetailErrorAttributes.java │ │ │ │ ├── properties/ │ │ │ │ │ ├── AttachmentProperties.java │ │ │ │ │ ├── CacheProperties.java │ │ │ │ │ ├── ExtensionProperties.java │ │ │ │ │ ├── HaloProperties.java │ │ │ │ │ ├── JwtProperties.java │ │ │ │ │ ├── ProxyProperties.java │ │ │ │ │ ├── SecurityProperties.java │ │ │ │ │ ├── ThemeProperties.java │ │ │ │ │ └── UiProperties.java │ │ │ │ ├── ui/ │ │ │ │ │ ├── ProxyFilter.java │ │ │ │ │ ├── WebSocketRequestPredicate.java │ │ │ │ │ ├── WebSocketServerWebExchangeMatcher.java │ │ │ │ │ └── WebSocketUtils.java │ │ │ │ ├── utils/ │ │ │ │ │ ├── Base62Utils.java │ │ │ │ │ ├── FileNameUtils.java │ │ │ │ │ ├── FileUtils.java │ │ │ │ │ ├── HaloUtils.java │ │ │ │ │ ├── IpAddressUtils.java │ │ │ │ │ ├── ReactiveUtils.java │ │ │ │ │ ├── SettingUtils.java │ │ │ │ │ ├── SortUtils.java │ │ │ │ │ ├── SystemConfigUtils.java │ │ │ │ │ ├── VersionUtils.java │ │ │ │ │ └── YamlUnstructuredLoader.java │ │ │ │ └── webfilter/ │ │ │ │ ├── AdditionalWebFilterChainProxy.java │ │ │ │ └── LocaleChangeWebFilter.java │ │ │ ├── migration/ │ │ │ │ ├── BackupFile.java │ │ │ │ ├── BackupReconciler.java │ │ │ │ ├── MigrationEndpoint.java │ │ │ │ ├── MigrationService.java │ │ │ │ └── impl/ │ │ │ │ └── MigrationServiceImpl.java │ │ │ ├── notification/ │ │ │ │ ├── DefaultNotificationCenter.java │ │ │ │ ├── DefaultNotificationReasonEmitter.java │ │ │ │ ├── DefaultNotificationSender.java │ │ │ │ ├── DefaultNotificationService.java │ │ │ │ ├── DefaultNotificationTemplateRender.java │ │ │ │ ├── DefaultNotifierConfigStore.java │ │ │ │ ├── DefaultSubscriberEmailResolver.java │ │ │ │ ├── EmailNotifier.java │ │ │ │ ├── EmailSenderHelper.java │ │ │ │ ├── EmailSenderHelperImpl.java │ │ │ │ ├── LanguageUtils.java │ │ │ │ ├── NotificationSender.java │ │ │ │ ├── NotificationTemplateRender.java │ │ │ │ ├── NotificationTrigger.java │ │ │ │ ├── NotifierConfigStore.java │ │ │ │ ├── ReasonNotificationTemplateSelector.java │ │ │ │ ├── ReasonNotificationTemplateSelectorImpl.java │ │ │ │ ├── RecipientResolver.java │ │ │ │ ├── RecipientResolverImpl.java │ │ │ │ ├── Subscriber.java │ │ │ │ ├── SubscriberEmailResolver.java │ │ │ │ ├── SubscriptionService.java │ │ │ │ ├── SubscriptionServiceImpl.java │ │ │ │ ├── UserNotificationPreference.java │ │ │ │ ├── UserNotificationPreferenceService.java │ │ │ │ ├── UserNotificationPreferenceServiceImpl.java │ │ │ │ ├── UserNotificationQuery.java │ │ │ │ ├── UserNotificationService.java │ │ │ │ └── endpoint/ │ │ │ │ ├── ConsoleNotifierEndpoint.java │ │ │ │ ├── EmailConfigValidationEndpoint.java │ │ │ │ ├── SubscriptionRouter.java │ │ │ │ ├── UserNotificationEndpoint.java │ │ │ │ ├── UserNotificationPreferencesEndpoint.java │ │ │ │ └── UserNotifierEndpoint.java │ │ │ ├── plugin/ │ │ │ │ ├── AggregatedRouterFunction.java │ │ │ │ ├── BuiltInPluginsInitializer.java │ │ │ │ ├── DefaultDevelopmentPluginRepository.java │ │ │ │ ├── DefaultPluginApplicationContextFactory.java │ │ │ │ ├── DefaultPluginGetter.java │ │ │ │ ├── DefaultPluginRouterFunctionRegistry.java │ │ │ │ ├── DefaultReactiveSettingFetcher.java │ │ │ │ ├── DefaultSettingFetcher.java │ │ │ │ ├── DefaultSpringPlugin.java │ │ │ │ ├── DevPluginLoader.java │ │ │ │ ├── HaloPluginManager.java │ │ │ │ ├── HaloSharedEventDelegator.java │ │ │ │ ├── OptionalDependentResolver.java │ │ │ │ ├── PluginApplicationContext.java │ │ │ │ ├── PluginApplicationContextFactory.java │ │ │ │ ├── PluginAutoConfiguration.java │ │ │ │ ├── PluginBeforeStopSyncListener.java │ │ │ │ ├── PluginConst.java │ │ │ │ ├── PluginControllerManager.java │ │ │ │ ├── PluginDevelopmentInitializer.java │ │ │ │ ├── PluginExtensionLoaderUtils.java │ │ │ │ ├── PluginFinder.java │ │ │ │ ├── PluginGetter.java │ │ │ │ ├── PluginNotFoundException.java │ │ │ │ ├── PluginProperties.java │ │ │ │ ├── PluginRequestMappingHandlerMapping.java │ │ │ │ ├── PluginRouterFunctionRegistry.java │ │ │ │ ├── PluginService.java │ │ │ │ ├── PluginServiceImpl.java │ │ │ │ ├── PluginSharedEventDelegator.java │ │ │ │ ├── PluginStartedListener.java │ │ │ │ ├── PluginUtils.java │ │ │ │ ├── PluginsRootGetterImpl.java │ │ │ │ ├── PropertyPluginStatusProvider.java │ │ │ │ ├── SharedApplicationContextFactory.java │ │ │ │ ├── SharedEventDispatcher.java │ │ │ │ ├── SpringComponentsFinder.java │ │ │ │ ├── SpringExtensionFactory.java │ │ │ │ ├── SpringPlugin.java │ │ │ │ ├── SpringPluginFactory.java │ │ │ │ ├── SpringPluginManager.java │ │ │ │ ├── YamlPluginDescriptorFinder.java │ │ │ │ ├── YamlPluginFinder.java │ │ │ │ ├── event/ │ │ │ │ │ ├── HaloPluginBeforeStopEvent.java │ │ │ │ │ ├── HaloPluginStartedEvent.java │ │ │ │ │ ├── HaloPluginStoppedEvent.java │ │ │ │ │ ├── SpringPluginStartedEvent.java │ │ │ │ │ ├── SpringPluginStartingEvent.java │ │ │ │ │ ├── SpringPluginStoppedEvent.java │ │ │ │ │ └── SpringPluginStoppingEvent.java │ │ │ │ ├── extensionpoint/ │ │ │ │ │ ├── AbstractDefinitionGetter.java │ │ │ │ │ ├── DefaultExtensionGetter.java │ │ │ │ │ ├── ExtensionDefinition.java │ │ │ │ │ ├── ExtensionDefinitionGetter.java │ │ │ │ │ ├── ExtensionDefinitionGetterImpl.java │ │ │ │ │ ├── ExtensionPointDefinition.java │ │ │ │ │ ├── ExtensionPointDefinitionGetter.java │ │ │ │ │ └── ExtensionPointDefinitionGetterImpl.java │ │ │ │ └── resources/ │ │ │ │ ├── BundleResourceUtils.java │ │ │ │ ├── ReverseProxyRouterFunctionFactory.java │ │ │ │ └── ReverseProxyRouterFunctionRegistry.java │ │ │ ├── search/ │ │ │ │ ├── HaloDocumentEventsListener.java │ │ │ │ ├── IndexEndpoint.java │ │ │ │ ├── IndicesEndpoint.java │ │ │ │ ├── SearchEngineUnavailableException.java │ │ │ │ ├── SearchServiceImpl.java │ │ │ │ ├── lucene/ │ │ │ │ │ └── LuceneSearchEngine.java │ │ │ │ └── post/ │ │ │ │ ├── PostEventsListener.java │ │ │ │ └── PostHaloDocumentsProvider.java │ │ │ ├── security/ │ │ │ │ ├── AuthProviderService.java │ │ │ │ ├── AuthProviderServiceImpl.java │ │ │ │ ├── CorsConfigurer.java │ │ │ │ ├── CsrfConfigurer.java │ │ │ │ ├── DefaultServerAuthenticationEntryPoint.java │ │ │ │ ├── DefaultSuperAdminInitializer.java │ │ │ │ ├── DefaultUserDetailService.java │ │ │ │ ├── ExceptionSecurityConfigurer.java │ │ │ │ ├── HaloRedirectAuthenticationSuccessHandler.java │ │ │ │ ├── HaloServerRequestCache.java │ │ │ │ ├── HaloUserDetails.java │ │ │ │ ├── InitializeRedirectionWebFilter.java │ │ │ │ ├── ListedAuthProvider.java │ │ │ │ ├── LoginHandlerEnhancerImpl.java │ │ │ │ ├── LogoutSecurityConfigurer.java │ │ │ │ ├── RedirectAccessDeniedHandler.java │ │ │ │ ├── SecurityWebFiltersConfigurer.java │ │ │ │ ├── SuperAdminInitializer.java │ │ │ │ ├── authentication/ │ │ │ │ │ ├── SecurityConfigurer.java │ │ │ │ │ ├── WebExchangeMatchers.java │ │ │ │ │ ├── exception/ │ │ │ │ │ │ ├── TooManyRequestsException.java │ │ │ │ │ │ └── TwoFactorAuthException.java │ │ │ │ │ ├── impl/ │ │ │ │ │ │ └── RsaKeyService.java │ │ │ │ │ ├── login/ │ │ │ │ │ │ ├── HaloUser.java │ │ │ │ │ │ ├── InvalidEncryptedMessageException.java │ │ │ │ │ │ ├── LoginAuthenticationConverter.java │ │ │ │ │ │ ├── LoginSecurityConfigurer.java │ │ │ │ │ │ ├── PublicKeyRouteBuilder.java │ │ │ │ │ │ ├── UsernamePasswordDelegatingAuthenticationManager.java │ │ │ │ │ │ └── UsernamePasswordHandler.java │ │ │ │ │ ├── oauth2/ │ │ │ │ │ │ ├── DefaultOAuth2LoginHandlerEnhancer.java │ │ │ │ │ │ ├── MapOAuth2AuthenticationFilter.java │ │ │ │ │ │ ├── OAuth2AuthenticationTokenCache.java │ │ │ │ │ │ ├── OAuth2LoginHandlerEnhancer.java │ │ │ │ │ │ ├── OAuth2SecurityConfigurer.java │ │ │ │ │ │ └── WebSessionOAuth2AuthenticationTokenCache.java │ │ │ │ │ ├── pat/ │ │ │ │ │ │ ├── PatAuthenticationConverter.java │ │ │ │ │ │ ├── PatAuthenticationManager.java │ │ │ │ │ │ ├── PatEndpoint.java │ │ │ │ │ │ ├── PatSecurityConfigurer.java │ │ │ │ │ │ ├── UserScopedPatHandler.java │ │ │ │ │ │ └── UserScopedPatHandlerImpl.java │ │ │ │ │ ├── rememberme/ │ │ │ │ │ │ ├── CookieSignatureKeyResolver.java │ │ │ │ │ │ ├── DefaultCookieSignatureKeyResolver.java │ │ │ │ │ │ ├── PersistentRememberMeTokenRepository.java │ │ │ │ │ │ ├── PersistentRememberMeTokenRepositoryImpl.java │ │ │ │ │ │ ├── PersistentTokenBasedRememberMeServices.java │ │ │ │ │ │ ├── RememberMeAuthenticationManager.java │ │ │ │ │ │ ├── RememberMeConfigurer.java │ │ │ │ │ │ ├── RememberMeCookieResolver.java │ │ │ │ │ │ ├── RememberMeCookieResolverImpl.java │ │ │ │ │ │ ├── RememberMeRequestCache.java │ │ │ │ │ │ ├── RememberMeServices.java │ │ │ │ │ │ ├── RememberMeTokenRevoker.java │ │ │ │ │ │ ├── RememberTokenCleaner.java │ │ │ │ │ │ ├── TokenBasedRememberMeServices.java │ │ │ │ │ │ └── WebSessionRememberMeRequestCache.java │ │ │ │ │ └── twofactor/ │ │ │ │ │ ├── TotpAuthenticationSuccessHandler.java │ │ │ │ │ ├── TwoFactorAuthEndpoint.java │ │ │ │ │ ├── TwoFactorAuthRequiredException.java │ │ │ │ │ ├── TwoFactorAuthSecurityConfigurer.java │ │ │ │ │ ├── TwoFactorAuthSettings.java │ │ │ │ │ ├── TwoFactorAuthentication.java │ │ │ │ │ ├── TwoFactorAuthenticationEntryPoint.java │ │ │ │ │ ├── TwoFactorUtils.java │ │ │ │ │ └── totp/ │ │ │ │ │ ├── DefaultTotpAuthService.java │ │ │ │ │ ├── TotpAuthService.java │ │ │ │ │ ├── TotpAuthenticationManager.java │ │ │ │ │ ├── TotpAuthenticationToken.java │ │ │ │ │ └── TotpCodeAuthenticationConverter.java │ │ │ │ ├── authorization/ │ │ │ │ │ ├── Attributes.java │ │ │ │ │ ├── AttributesRecord.java │ │ │ │ │ ├── AuthorityUtils.java │ │ │ │ │ ├── AuthorizationExchangeConfigurers.java │ │ │ │ │ ├── AuthorizationRuleResolver.java │ │ │ │ │ ├── AuthorizingVisitor.java │ │ │ │ │ ├── DefaultRuleResolver.java │ │ │ │ │ ├── PolicyRuleList.java │ │ │ │ │ ├── RbacRequestEvaluation.java │ │ │ │ │ ├── RequestInfo.java │ │ │ │ │ ├── RequestInfoAuthorizationManager.java │ │ │ │ │ ├── RequestInfoFactory.java │ │ │ │ │ └── RuleAccumulator.java │ │ │ │ ├── device/ │ │ │ │ │ ├── DeviceCookieResolver.java │ │ │ │ │ ├── DeviceCookieResolverImpl.java │ │ │ │ │ ├── DeviceEndpoint.java │ │ │ │ │ ├── DeviceReconciler.java │ │ │ │ │ ├── DeviceSecurityConfigurer.java │ │ │ │ │ ├── DeviceServiceImpl.java │ │ │ │ │ ├── DeviceSessionFilter.java │ │ │ │ │ ├── NewDeviceLoginEvent.java │ │ │ │ │ └── NewDeviceLoginListener.java │ │ │ │ ├── jackson2/ │ │ │ │ │ ├── HaloOAuth2AuthenticationTokenMixin.java │ │ │ │ │ ├── HaloSecurityJackson2Module.java │ │ │ │ │ ├── HaloUserMixin.java │ │ │ │ │ ├── SwitchUserGrantedAuthorityMixIn.java │ │ │ │ │ └── TwoFactorAuthenticationMixin.java │ │ │ │ ├── preauth/ │ │ │ │ │ ├── DefaultPasswordResetAvailabilityProviders.java │ │ │ │ │ ├── EmailPasswordResetAvailabilityProvider.java │ │ │ │ │ ├── PasswordResetAvailabilityProvider.java │ │ │ │ │ ├── PasswordResetAvailabilityProviders.java │ │ │ │ │ ├── PreAuthEmailPasswordResetEndpoint.java │ │ │ │ │ ├── PreAuthLoginEndpoint.java │ │ │ │ │ ├── PreAuthSignUpEndpoint.java │ │ │ │ │ ├── PreAuthTwoFactorEndpoint.java │ │ │ │ │ └── SystemSetupEndpoint.java │ │ │ │ ├── session/ │ │ │ │ │ ├── InMemoryReactiveIndexedSessionRepository.java │ │ │ │ │ ├── ReactiveIndexedSessionRepository.java │ │ │ │ │ └── SessionInvalidationListener.java │ │ │ │ └── switchuser/ │ │ │ │ └── SwitchUserConfigurer.java │ │ │ └── theme/ │ │ │ ├── CompositeTemplateResolver.java │ │ │ ├── DefaultTemplateEnum.java │ │ │ ├── DefaultTemplateNameResolver.java │ │ │ ├── DefaultViewNameResolver.java │ │ │ ├── HaloViewResolver.java │ │ │ ├── ReactiveSpelVariableExpressionEvaluator.java │ │ │ ├── SiteSettingVariablesAcquirer.java │ │ │ ├── TemplateEngineManager.java │ │ │ ├── ThemeContext.java │ │ │ ├── ThemeContextBasedVariablesAcquirer.java │ │ │ ├── ThemeLinkBuilder.java │ │ │ ├── ThemeLocaleContextResolver.java │ │ │ ├── ThemeResolver.java │ │ │ ├── UserLocaleRequestAttributeWriteFilter.java │ │ │ ├── ViewContextBasedVariablesAcquirer.java │ │ │ ├── ViewNameResolver.java │ │ │ ├── config/ │ │ │ │ ├── ThemeConfiguration.java │ │ │ │ └── ThemeWebFluxConfigurer.java │ │ │ ├── dialect/ │ │ │ │ ├── CommentElementTagProcessor.java │ │ │ │ ├── CommentEnabledVariableProcessor.java │ │ │ │ ├── ContentTemplateHeadProcessor.java │ │ │ │ ├── DefaultFaviconHeadProcessor.java │ │ │ │ ├── DefaultLinkExpressionFactory.java │ │ │ │ ├── DuplicateMetaTagProcessor.java │ │ │ │ ├── EvaluationContextEnhancer.java │ │ │ │ ├── GeneratorMetaProcessor.java │ │ │ │ ├── GlobalHeadInjectionProcessor.java │ │ │ │ ├── GlobalSeoProcessor.java │ │ │ │ ├── HaloExpressionObjectFactory.java │ │ │ │ ├── HaloPostTemplateHandler.java │ │ │ │ ├── HaloProcessorDialect.java │ │ │ │ ├── HaloSpringSecurityDialect.java │ │ │ │ ├── HaloTrackerProcessor.java │ │ │ │ ├── IndexSeoProcessor.java │ │ │ │ ├── InjectionExcluderProcessor.java │ │ │ │ ├── LinkExpressionObjectDialect.java │ │ │ │ ├── SecureTemplateContext.java │ │ │ │ ├── SecureTemplateContextWrapper.java │ │ │ │ ├── SecureTemplateWebContext.java │ │ │ │ ├── TemplateFooterElementTagProcessor.java │ │ │ │ ├── TemplateGlobalHeadProcessor.java │ │ │ │ └── expression/ │ │ │ │ └── Annotations.java │ │ │ ├── endpoint/ │ │ │ │ └── ThemeEndpoint.java │ │ │ ├── engine/ │ │ │ │ ├── DefaultThemeTemplateAvailabilityProvider.java │ │ │ │ ├── HaloTemplateEngine.java │ │ │ │ ├── PluginClassloaderTemplateResolver.java │ │ │ │ └── ThemeTemplateAvailabilityProvider.java │ │ │ ├── finders/ │ │ │ │ ├── CategoryFinder.java │ │ │ │ ├── CommentFinder.java │ │ │ │ ├── CommentPublicQueryService.java │ │ │ │ ├── ContributorFinder.java │ │ │ │ ├── DefaultFinderRegistry.java │ │ │ │ ├── FinderRegistry.java │ │ │ │ ├── MenuFinder.java │ │ │ │ ├── PluginFinder.java │ │ │ │ ├── PostFinder.java │ │ │ │ ├── PostPublicQueryService.java │ │ │ │ ├── SinglePageConversionService.java │ │ │ │ ├── SinglePageFinder.java │ │ │ │ ├── SiteStatsFinder.java │ │ │ │ ├── TagFinder.java │ │ │ │ ├── ThemeFinder.java │ │ │ │ ├── ThumbnailFinder.java │ │ │ │ ├── impl/ │ │ │ │ │ ├── CategoryFinderImpl.java │ │ │ │ │ ├── CommentFinderImpl.java │ │ │ │ │ ├── CommentPublicQueryServiceImpl.java │ │ │ │ │ ├── ContributorFinderImpl.java │ │ │ │ │ ├── MenuFinderImpl.java │ │ │ │ │ ├── PluginFinderImpl.java │ │ │ │ │ ├── PostFinderImpl.java │ │ │ │ │ ├── PostPublicQueryServiceImpl.java │ │ │ │ │ ├── SinglePageConversionServiceImpl.java │ │ │ │ │ ├── SinglePageFinderImpl.java │ │ │ │ │ ├── SiteStatsFinderImpl.java │ │ │ │ │ ├── TagFinderImpl.java │ │ │ │ │ ├── ThemeFinderImpl.java │ │ │ │ │ └── ThumbnailFinderImpl.java │ │ │ │ └── vo/ │ │ │ │ ├── CategoryTreeVo.java │ │ │ │ ├── CategoryVo.java │ │ │ │ ├── CommentStatsVo.java │ │ │ │ ├── CommentVo.java │ │ │ │ ├── CommentWithReplyVo.java │ │ │ │ ├── ContentVo.java │ │ │ │ ├── ContributorVo.java │ │ │ │ ├── ListedPostVo.java │ │ │ │ ├── ListedSinglePageVo.java │ │ │ │ ├── MenuItemVo.java │ │ │ │ ├── MenuVo.java │ │ │ │ ├── NavigationPostVo.java │ │ │ │ ├── PostArchiveVo.java │ │ │ │ ├── PostArchiveYearMonthVo.java │ │ │ │ ├── PostVo.java │ │ │ │ ├── ReplyVo.java │ │ │ │ ├── SinglePageVo.java │ │ │ │ ├── SiteSettingVo.java │ │ │ │ ├── SiteStatsVo.java │ │ │ │ ├── StatsVo.java │ │ │ │ ├── TagVo.java │ │ │ │ ├── ThemeVo.java │ │ │ │ ├── UserVo.java │ │ │ │ └── VisualizableTreeNode.java │ │ │ ├── message/ │ │ │ │ ├── ThemeMessageResolutionUtils.java │ │ │ │ └── ThemeMessageResolver.java │ │ │ ├── router/ │ │ │ │ ├── DefaultQueryPostPredicateResolver.java │ │ │ │ ├── ExtensionPermalinkPatternUpdater.java │ │ │ │ ├── ModelMapUtils.java │ │ │ │ ├── PermalinkRuleChangedEvent.java │ │ │ │ ├── PreviewRouterFunction.java │ │ │ │ ├── ReactiveQueryPostPredicateResolver.java │ │ │ │ ├── SinglePageRoute.java │ │ │ │ ├── ThemeCompositeRouterFunction.java │ │ │ │ ├── TitleVisibilityIdentifyCalculator.java │ │ │ │ └── factories/ │ │ │ │ ├── ArchiveRouteFactory.java │ │ │ │ ├── AuthorPostsRouteFactory.java │ │ │ │ ├── CategoriesRouteFactory.java │ │ │ │ ├── CategoryPostRouteFactory.java │ │ │ │ ├── IndexRouteFactory.java │ │ │ │ ├── PostRouteFactory.java │ │ │ │ ├── RouteFactory.java │ │ │ │ ├── TagPostRouteFactory.java │ │ │ │ └── TagsRouteFactory.java │ │ │ ├── service/ │ │ │ │ ├── ThemeService.java │ │ │ │ ├── ThemeServiceImpl.java │ │ │ │ └── ThemeUtils.java │ │ │ └── utils/ │ │ │ └── PatternUtils.java │ │ └── resources/ │ │ ├── META-INF/ │ │ │ └── spring-devtools.properties │ │ ├── application-dev.yaml │ │ ├── application-doc.yaml │ │ ├── application-mariadb.yaml │ │ ├── application-mysql.yaml │ │ ├── application-postgresql.yaml │ │ ├── application-win.yaml │ │ ├── application.yaml │ │ ├── banner.txt │ │ ├── config/ │ │ │ └── i18n/ │ │ │ ├── messages.properties │ │ │ ├── messages_es.properties │ │ │ └── messages_zh.properties │ │ ├── db/ │ │ │ └── migration/ │ │ │ ├── h2/ │ │ │ │ └── .gitkeep │ │ │ ├── mariadb/ │ │ │ │ └── .gitkeep │ │ │ ├── mysql/ │ │ │ │ └── .gitkeep │ │ │ └── postgresql/ │ │ │ └── .gitkeep │ │ ├── extensions/ │ │ │ ├── attachment-local-policy.yaml │ │ │ ├── authproviders.yaml │ │ │ ├── extension-definitions.yaml │ │ │ ├── extensionpoint-definitions.yaml │ │ │ ├── notification-templates.yaml │ │ │ ├── notification.yaml │ │ │ ├── role-template-actuator.yaml │ │ │ ├── role-template-anonymous.yaml │ │ │ ├── role-template-attachment.yaml │ │ │ ├── role-template-authenticated.yaml │ │ │ ├── role-template-cache.yaml │ │ │ ├── role-template-category.yaml │ │ │ ├── role-template-comment.yaml │ │ │ ├── role-template-configmap.yaml │ │ │ ├── role-template-menu.yaml │ │ │ ├── role-template-migration.yaml │ │ │ ├── role-template-notification.yaml │ │ │ ├── role-template-permissions.yaml │ │ │ ├── role-template-plugin.yaml │ │ │ ├── role-template-post.yaml │ │ │ ├── role-template-role.yaml │ │ │ ├── role-template-setting.yaml │ │ │ ├── role-template-singlepage.yaml │ │ │ ├── role-template-snapshot.yaml │ │ │ ├── role-template-tag.yaml │ │ │ ├── role-template-theme.yaml │ │ │ ├── role-template-uc-attachment.yaml │ │ │ ├── role-template-uc-content.yaml │ │ │ ├── role-template-user.yaml │ │ │ ├── system-configurable-configmap.yaml │ │ │ ├── system-default-role.yaml │ │ │ ├── system-setting.yaml │ │ │ └── user.yaml │ │ ├── initial-data.yaml │ │ ├── schema-h2.sql │ │ ├── schema-mariadb.sql │ │ ├── schema-mysql.sql │ │ ├── schema-postgresql.sql │ │ ├── static/ │ │ │ ├── halo-tracker.js │ │ │ ├── js/ │ │ │ │ └── main.js │ │ │ └── styles/ │ │ │ └── main.css │ │ ├── templates/ │ │ │ ├── challenges/ │ │ │ │ └── two-factor/ │ │ │ │ ├── totp.html │ │ │ │ ├── totp.properties │ │ │ │ ├── totp_en.properties │ │ │ │ ├── totp_es.properties │ │ │ │ └── totp_zh_TW.properties │ │ │ ├── error/ │ │ │ │ └── error.html │ │ │ ├── gateway_fragments/ │ │ │ │ ├── common.html │ │ │ │ ├── common.properties │ │ │ │ ├── common_en.properties │ │ │ │ ├── common_es.properties │ │ │ │ ├── common_zh_TW.properties │ │ │ │ ├── input.html │ │ │ │ ├── layout.html │ │ │ │ ├── login.html │ │ │ │ ├── login.properties │ │ │ │ ├── login_en.properties │ │ │ │ ├── login_es.properties │ │ │ │ ├── login_zh_TW.properties │ │ │ │ ├── logout.html │ │ │ │ ├── logout.properties │ │ │ │ ├── logout_en.properties │ │ │ │ ├── logout_es.properties │ │ │ │ ├── logout_zh_TW.properties │ │ │ │ ├── password_reset_email_reset.html │ │ │ │ ├── password_reset_email_reset.properties │ │ │ │ ├── password_reset_email_reset_en.properties │ │ │ │ ├── password_reset_email_reset_es.properties │ │ │ │ ├── password_reset_email_reset_zh_TW.properties │ │ │ │ ├── password_reset_email_send.html │ │ │ │ ├── password_reset_email_send.properties │ │ │ │ ├── password_reset_email_send_en.properties │ │ │ │ ├── password_reset_email_send_es.properties │ │ │ │ ├── password_reset_email_send_zh_TW.properties │ │ │ │ ├── signup.html │ │ │ │ ├── signup.properties │ │ │ │ ├── signup_en.properties │ │ │ │ ├── signup_es.properties │ │ │ │ ├── signup_zh_TW.properties │ │ │ │ ├── totp.html │ │ │ │ ├── totp.properties │ │ │ │ ├── totp_en.properties │ │ │ │ ├── totp_es.properties │ │ │ │ └── totp_zh_TW.properties │ │ │ ├── login.html │ │ │ ├── login.properties │ │ │ ├── login_en.properties │ │ │ ├── login_es.properties │ │ │ ├── login_local.html │ │ │ ├── login_local.properties │ │ │ ├── login_local_en.properties │ │ │ ├── login_local_es.properties │ │ │ ├── login_local_zh_TW.properties │ │ │ ├── login_zh_TW.properties │ │ │ ├── logout.html │ │ │ ├── logout.properties │ │ │ ├── logout_en.properties │ │ │ ├── logout_es.properties │ │ │ ├── logout_zh_TW.properties │ │ │ ├── password-reset/ │ │ │ │ └── email/ │ │ │ │ ├── reset.html │ │ │ │ ├── reset.properties │ │ │ │ ├── reset_en.properties │ │ │ │ ├── reset_es.properties │ │ │ │ ├── reset_zh_TW.properties │ │ │ │ ├── send.html │ │ │ │ ├── send.properties │ │ │ │ ├── send_en.properties │ │ │ │ ├── send_es.properties │ │ │ │ └── send_zh_TW.properties │ │ │ ├── setup.html │ │ │ ├── setup.properties │ │ │ ├── setup_en.properties │ │ │ ├── setup_es.properties │ │ │ ├── setup_zh_TW.properties │ │ │ ├── signup.html │ │ │ ├── signup.properties │ │ │ ├── signup_en.properties │ │ │ ├── signup_es.properties │ │ │ └── signup_zh_TW.properties │ │ └── thumbnailator.properties │ └── test/ │ ├── java/ │ │ └── run/ │ │ └── halo/ │ │ └── app/ │ │ ├── ApplicationTests.java │ │ ├── PathPrefixPredicateTest.java │ │ ├── XForwardHeaderTest.java │ │ ├── config/ │ │ │ ├── CorsTest.java │ │ │ ├── ExtensionConfigurationTest.java │ │ │ ├── HaloConfigurationTest.java │ │ │ ├── SecurityConfigTest.java │ │ │ ├── ServerCodecTest.java │ │ │ └── WebFluxConfigTest.java │ │ ├── content/ │ │ │ ├── CategoryPostCountUpdaterTest.java │ │ │ ├── ContentRequestTest.java │ │ │ ├── PostIntegrationTests.java │ │ │ ├── TestPost.java │ │ │ ├── comment/ │ │ │ │ ├── CommentEmailOwnerTest.java │ │ │ │ ├── CommentNotificationReasonPublisherTest.java │ │ │ │ ├── CommentRequestTest.java │ │ │ │ ├── CommentServiceImplIntegrationTest.java │ │ │ │ ├── CommentServiceImplTest.java │ │ │ │ ├── PostCommentSubjectTest.java │ │ │ │ ├── ReplyNotificationSubscriptionHelperTest.java │ │ │ │ ├── ReplyServiceImplIntegrationTest.java │ │ │ │ └── SinglePageCommentSubjectTest.java │ │ │ └── permalinks/ │ │ │ ├── CategoryPermalinkPolicyTest.java │ │ │ ├── PostPermalinkPolicyTest.java │ │ │ └── TagPermalinkPolicyTest.java │ │ ├── core/ │ │ │ ├── attachment/ │ │ │ │ ├── PolicyConfigChangeDetectorTest.java │ │ │ │ ├── endpoint/ │ │ │ │ │ ├── LocalAttachmentUploadHandlerTest.java │ │ │ │ │ └── PolicyEndpointTest.java │ │ │ │ ├── impl/ │ │ │ │ │ └── AttachmentRootGetterImplTest.java │ │ │ │ └── thumbnail/ │ │ │ │ ├── DefaultLocalThumbnailServiceTest.java │ │ │ │ ├── DefaultThumbnailServiceTest.java │ │ │ │ ├── ThumbnailImgTagPostProcessorTest.java │ │ │ │ ├── ThumbnailResourceTransformerTest.java │ │ │ │ └── ThumbnailUtilsTest.java │ │ │ ├── counter/ │ │ │ │ └── MeterUtilsTest.java │ │ │ ├── endpoint/ │ │ │ │ ├── WebSocketHandlerMappingTest.java │ │ │ │ ├── console/ │ │ │ │ │ ├── EmailVerificationCodeTest.java │ │ │ │ │ ├── PluginEndpointTest.java │ │ │ │ │ ├── PostEndpointTest.java │ │ │ │ │ ├── SinglePageEndpointTest.java │ │ │ │ │ ├── TagEndpointTest.java │ │ │ │ │ ├── UserEndpointIntegrationTest.java │ │ │ │ │ └── UserEndpointTest.java │ │ │ │ ├── theme/ │ │ │ │ │ ├── CategoryQueryEndpointTest.java │ │ │ │ │ ├── CommentFinderEndpointTest.java │ │ │ │ │ ├── MenuQueryEndpointTest.java │ │ │ │ │ ├── PluginQueryEndpointTest.java │ │ │ │ │ ├── PostQueryEndpointTest.java │ │ │ │ │ ├── PublicApiUtilsTest.java │ │ │ │ │ ├── SinglePageQueryEndpointTest.java │ │ │ │ │ └── ThumbnailEndpointTest.java │ │ │ │ └── uc/ │ │ │ │ ├── AnnotationSettingEndpointTest.java │ │ │ │ └── UcUserPreferenceEndpointTest.java │ │ │ ├── extension/ │ │ │ │ ├── PostTest.java │ │ │ │ ├── RoleBindingTest.java │ │ │ │ ├── SettingTest.java │ │ │ │ ├── TestRole.java │ │ │ │ ├── ThemeTest.java │ │ │ │ └── attachment/ │ │ │ │ └── endpoint/ │ │ │ │ └── AttachmentEndpointTest.java │ │ │ ├── reconciler/ │ │ │ │ ├── CommentReconcilerTest.java │ │ │ │ ├── MenuItemReconcilerTest.java │ │ │ │ ├── PluginReconcilerTest.java │ │ │ │ ├── PostReconcilerTest.java │ │ │ │ ├── ReverseProxyReconcilerTest.java │ │ │ │ ├── SinglePageReconcilerTest.java │ │ │ │ ├── SystemConfigReconcilerTest.java │ │ │ │ ├── TagReconcilerTest.java │ │ │ │ ├── ThemeReconcilerTest.java │ │ │ │ └── UserReconcilerTest.java │ │ │ └── user/ │ │ │ └── service/ │ │ │ ├── DefaultRoleServiceTest.java │ │ │ └── impl/ │ │ │ ├── EmailPasswordRecoveryServiceImplTest.java │ │ │ ├── EmailVerificationServiceImplTest.java │ │ │ └── UserServiceImplTest.java │ │ ├── extension/ │ │ │ ├── AbstractExtensionTest.java │ │ │ ├── ComparatorsTest.java │ │ │ ├── ConfigMapTest.java │ │ │ ├── DefaultSchemeManagerTest.java │ │ │ ├── ExtensionOperatorTest.java │ │ │ ├── ExtensionStoreUtilTest.java │ │ │ ├── FakeExtension.java │ │ │ ├── GroupVersionKindTest.java │ │ │ ├── GroupVersionTest.java │ │ │ ├── JsonExtensionConverterTest.java │ │ │ ├── JsonExtensionTest.java │ │ │ ├── ListResultTest.java │ │ │ ├── MetadataOperatorTest.java │ │ │ ├── ReactiveExtensionClientTest.java │ │ │ ├── RefTest.java │ │ │ ├── SchemeTest.java │ │ │ ├── UnstructuredTest.java │ │ │ ├── gc/ │ │ │ │ ├── GcReconcilerTest.java │ │ │ │ ├── GcSynchronizerTest.java │ │ │ │ └── GcWatcherTest.java │ │ │ ├── index/ │ │ │ │ ├── DefaultIndexEngineTest.java │ │ │ │ ├── DefaultIndicesManagerTest.java │ │ │ │ ├── DefaultIndicesTest.java │ │ │ │ ├── Fake.java │ │ │ │ ├── LabelIndexTest.java │ │ │ │ ├── MultiValueIndexTest.java │ │ │ │ ├── SingleValueIndexTest.java │ │ │ │ └── query/ │ │ │ │ └── QueryVisitorTest.java │ │ │ ├── router/ │ │ │ │ ├── ExtensionCompositeRouterFunctionTest.java │ │ │ │ ├── ExtensionCreateHandlerTest.java │ │ │ │ ├── ExtensionDeleteHandlerTest.java │ │ │ │ ├── ExtensionGetHandlerTest.java │ │ │ │ ├── ExtensionListHandlerTest.java │ │ │ │ ├── ExtensionRouterFunctionFactoryTest.java │ │ │ │ ├── ExtensionUpdateHandlerTest.java │ │ │ │ └── PathPatternGeneratorTest.java │ │ │ └── store/ │ │ │ └── ReactiveExtensionStoreClientImplTest.java │ │ ├── infra/ │ │ │ ├── ConditionListTest.java │ │ │ ├── DefaultBackupRootGetterTest.java │ │ │ ├── DefaultExternalLinkProcessorTest.java │ │ │ ├── DefaultSystemConfigFetcherTest.java │ │ │ ├── DefaultSystemVersionSupplierTest.java │ │ │ ├── ExtensionResourceInitializerTest.java │ │ │ ├── InitializationStateGetterTest.java │ │ │ ├── ReactiveExtensionPaginatedOperatorImplTest.java │ │ │ ├── SystemConfigFirstExternalUrlSupplierTest.java │ │ │ ├── SystemSettingTest.java │ │ │ ├── SystemStateTest.java │ │ │ ├── ValidationUtilsTest.java │ │ │ ├── config/ │ │ │ │ └── SessionConfigurationTest.java │ │ │ ├── exception/ │ │ │ │ └── handlers/ │ │ │ │ └── I18nExceptionTest.java │ │ │ ├── properties/ │ │ │ │ └── HaloPropertiesTest.java │ │ │ └── utils/ │ │ │ ├── Base62UtilsTest.java │ │ │ ├── FileNameUtilsTest.java │ │ │ ├── FileTypeDetectUtilsTest.java │ │ │ ├── FileUtilsTest.java │ │ │ ├── HaloUtilsTest.java │ │ │ ├── IpAddressUtilsTest.java │ │ │ ├── SettingUtilsTest.java │ │ │ ├── SortUtilsTest.java │ │ │ ├── SystemConfigUtilsTest.java │ │ │ ├── VersionUtilsTest.java │ │ │ └── YamlUnstructuredLoaderTest.java │ │ ├── migration/ │ │ │ ├── BackupReconcilerTest.java │ │ │ └── impl/ │ │ │ └── MigrationServiceImplTest.java │ │ ├── notification/ │ │ │ ├── DefaultNotificationCenterTest.java │ │ │ ├── DefaultNotificationReasonEmitterTest.java │ │ │ ├── DefaultNotificationSenderTest.java │ │ │ ├── DefaultNotificationTemplateRenderTest.java │ │ │ ├── DefaultNotifierConfigStoreTest.java │ │ │ ├── DefaultSubscriberEmailResolverTest.java │ │ │ ├── LanguageUtilsTest.java │ │ │ ├── NotificationContextTest.java │ │ │ ├── NotificationTriggerTest.java │ │ │ ├── ReasonNotificationTemplateSelectorImplTest.java │ │ │ ├── ReasonPayloadTest.java │ │ │ ├── RecipientResolverImplTest.java │ │ │ ├── SubscriptionServiceImplTest.java │ │ │ ├── UserIdentityTest.java │ │ │ ├── UserNotificationPreferenceServiceImplTest.java │ │ │ ├── UserNotificationPreferenceTest.java │ │ │ └── endpoint/ │ │ │ ├── SubscriptionRouterTest.java │ │ │ └── UserNotificationPreferencesEndpointTest.java │ │ ├── plugin/ │ │ │ ├── BuiltInPluginsInitializerTest.java │ │ │ ├── DefaultDevelopmentPluginRepositoryTest.java │ │ │ ├── DefaultPluginApplicationContextFactoryTest.java │ │ │ ├── DefaultPluginRouterFunctionRegistryTest.java │ │ │ ├── DefaultSettingFetcherTest.java │ │ │ ├── HaloPluginManagerTest.java │ │ │ ├── OptionalDependentResolverTest.java │ │ │ ├── PluginExtensionLoaderUtilsTest.java │ │ │ ├── PluginRequestMappingHandlerMappingTest.java │ │ │ ├── PluginServiceImplTest.java │ │ │ ├── PluginsRootGetterImplTest.java │ │ │ ├── SharedApplicationContextFactoryTest.java │ │ │ ├── SharedEventDispatcherTest.java │ │ │ ├── SpringComponentsFinderTest.java │ │ │ ├── YamlPluginDescriptorFinderTest.java │ │ │ ├── YamlPluginFinderTest.java │ │ │ ├── extensionpoint/ │ │ │ │ └── DefaultExtensionGetterTest.java │ │ │ └── resources/ │ │ │ ├── BundleResourceUtilsTest.java │ │ │ ├── ReverseProxyRouterFunctionFactoryTest.java │ │ │ └── ReverseProxyRouterFunctionRegistryTest.java │ │ ├── search/ │ │ │ ├── HaloDocumentEventsListenerTest.java │ │ │ ├── IndexEndpointTest.java │ │ │ ├── IndicesEndpointTest.java │ │ │ ├── SearchServiceImplTest.java │ │ │ ├── lucene/ │ │ │ │ ├── LuceneSearchEngineIntegrationTest.java │ │ │ │ └── LuceneSearchEngineTest.java │ │ │ └── post/ │ │ │ ├── PostEventsListenerTest.java │ │ │ └── PostHaloDocumentsProviderTest.java │ │ ├── security/ │ │ │ ├── AuthProviderServiceImplTest.java │ │ │ ├── CsrfSecurityTest.java │ │ │ ├── DefaultServerAuthenticationEntryPointTest.java │ │ │ ├── DefaultSuperAdminInitializerTest.java │ │ │ ├── DefaultUserDetailServiceTest.java │ │ │ ├── HaloServerRequestCacheTest.java │ │ │ ├── InitializeRedirectionWebFilterTest.java │ │ │ ├── ResponseMap.java │ │ │ ├── authentication/ │ │ │ │ ├── WebExchangeMatchersTest.java │ │ │ │ ├── impl/ │ │ │ │ │ └── RsaKeyServiceTest.java │ │ │ │ ├── login/ │ │ │ │ │ ├── LoginAuthenticationConverterTest.java │ │ │ │ │ └── PublicKeyRouteBuilderTest.java │ │ │ │ ├── pat/ │ │ │ │ │ └── PatTest.java │ │ │ │ ├── rememberme/ │ │ │ │ │ ├── PersistentTokenBasedRememberMeServicesTest.java │ │ │ │ │ ├── RememberTokenCleanerTest.java │ │ │ │ │ └── TokenBasedRememberMeServicesTest.java │ │ │ │ └── twofactor/ │ │ │ │ └── TwoFactorAuthSettingsTest.java │ │ │ ├── authorization/ │ │ │ │ ├── AuthorityUtilsTest.java │ │ │ │ ├── AuthorizationTest.java │ │ │ │ ├── DefaultRuleResolverTest.java │ │ │ │ ├── PolicyRuleTest.java │ │ │ │ ├── RbacRequestEvaluationTest.java │ │ │ │ └── RequestInfoResolverTest.java │ │ │ ├── device/ │ │ │ │ └── DeviceServiceImplTest.java │ │ │ ├── jackson2/ │ │ │ │ └── HaloSecurityJacksonModuleTest.java │ │ │ ├── preauth/ │ │ │ │ └── SystemSetupEndpointTest.java │ │ │ ├── session/ │ │ │ │ └── InMemoryReactiveIndexedSessionRepositoryTest.java │ │ │ └── switchuser/ │ │ │ ├── SwitchUserConfigurerTest.java │ │ │ ├── SwitchUserSecurityContextFactory.java │ │ │ └── WithSwitchUser.java │ │ ├── theme/ │ │ │ ├── CompositeTemplateResolverTest.java │ │ │ ├── ReactiveFinderExpressionParserTests.java │ │ │ ├── SiteSettingVariablesAcquirerTest.java │ │ │ ├── ThemeContextTest.java │ │ │ ├── ThemeIntegrationTest.java │ │ │ ├── ThemeLinkBuilderTest.java │ │ │ ├── ThemeLocaleContextResolverTest.java │ │ │ ├── ViewNameResolverTest.java │ │ │ ├── dialect/ │ │ │ │ ├── CommentElementTagProcessorTest.java │ │ │ │ ├── CommentEnabledVariableProcessorTest.java │ │ │ │ ├── ContentTemplateHeadProcessorIntegrationTest.java │ │ │ │ ├── ContentTemplateHeadProcessorTest.java │ │ │ │ ├── DuplicateMetaTagProcessorTest.java │ │ │ │ ├── GeneratorMetaProcessorTest.java │ │ │ │ ├── HaloPostTemplateHandlerTest.java │ │ │ │ ├── HaloProcessorDialectTest.java │ │ │ │ ├── HaloSpringSecurityDialectTest.java │ │ │ │ ├── InjectionExcluderProcessorTest.java │ │ │ │ ├── LinkExpressionObjectDialectTest.java │ │ │ │ └── TemplateFooterElementTagProcessorTest.java │ │ │ ├── endpoint/ │ │ │ │ └── ThemeEndpointTest.java │ │ │ ├── engine/ │ │ │ │ ├── DefaultThemeTemplateAvailabilityProviderTest.java │ │ │ │ └── PluginClassloaderTemplateResolverTest.java │ │ │ ├── finders/ │ │ │ │ ├── FinderRegistryTest.java │ │ │ │ ├── impl/ │ │ │ │ │ ├── CategoryFinderImplTest.java │ │ │ │ │ ├── CommentPublicQueryServiceImplTest.java │ │ │ │ │ ├── CommentPublicQueryServiceIntegrationTest.java │ │ │ │ │ ├── MenuFinderImplTest.java │ │ │ │ │ ├── PluginFinderImplTest.java │ │ │ │ │ ├── PostFinderImplIntegrationTest.java │ │ │ │ │ ├── PostFinderImplTest.java │ │ │ │ │ ├── PostPublicQueryServiceImplTest.java │ │ │ │ │ ├── SinglePageConversionServiceImplTest.java │ │ │ │ │ ├── SinglePageFinderImplTest.java │ │ │ │ │ ├── TagFinderImplTest.java │ │ │ │ │ └── ThumbnailFinderImplTest.java │ │ │ │ └── vo/ │ │ │ │ └── UserVoTest.java │ │ │ ├── message/ │ │ │ │ ├── ThemeMessageResolutionUtilsTest.java │ │ │ │ └── ThemeMessageResolverIntegrationTest.java │ │ │ ├── router/ │ │ │ │ ├── EmptyView.java │ │ │ │ ├── PageUrlUtilsTest.java │ │ │ │ ├── PreviewRouterFunctionTest.java │ │ │ │ ├── ReactiveQueryPostPredicateResolverTest.java │ │ │ │ ├── SinglePageRouteTest.java │ │ │ │ └── factories/ │ │ │ │ ├── ArchiveRouteFactoryTest.java │ │ │ │ ├── AuthorPostsRouteFactoryTest.java │ │ │ │ ├── CategoriesRouteFactoryTest.java │ │ │ │ ├── IndexRouteFactoryTest.java │ │ │ │ ├── PostRouteFactoryTest.java │ │ │ │ ├── RouteFactoryTest.java │ │ │ │ ├── RouteFactoryTestSuite.java │ │ │ │ └── TagPostRouteFactoryTest.java │ │ │ ├── service/ │ │ │ │ └── ThemeServiceImplTest.java │ │ │ └── utils/ │ │ │ └── PatternUtilsTest.java │ │ ├── ui/ │ │ │ ├── WebSocketServerWebExchangeMatcherTest.java │ │ │ └── WebSocketUtilsTest.java │ │ └── webfilter/ │ │ └── LocaleChangeWebFilterTest.java │ └── resources/ │ ├── apiToken.salt │ ├── app.key │ ├── app.pub │ ├── application.yaml │ ├── backups/ │ │ └── backup-for-restoration/ │ │ ├── extensions.data │ │ └── workdir/ │ │ └── fake-file │ ├── categories/ │ │ └── independent-post-count.json │ ├── config/ │ │ └── i18n/ │ │ ├── messages.properties │ │ └── messages_zh.properties │ ├── console/ │ │ ├── assets/ │ │ │ └── fake.txt │ │ └── index.html │ ├── file-type-detect/ │ │ ├── index.html │ │ ├── index.js │ │ ├── other.xlsx │ │ ├── test.docx │ │ └── test.json │ ├── folder-to-zip/ │ │ └── examplefile │ ├── plugin/ │ │ ├── plugin-0.0.1/ │ │ │ ├── extensions/ │ │ │ │ ├── reverseProxy.yaml │ │ │ │ ├── roles.yaml │ │ │ │ ├── setting.yaml │ │ │ │ └── test.yml │ │ │ └── plugin.yaml │ │ ├── plugin-0.0.2/ │ │ │ └── plugin.yaml │ │ ├── plugin-for-finder/ │ │ │ └── META-INF/ │ │ │ └── plugin-components.idx │ │ ├── plugin-for-reverseproxy/ │ │ │ └── static/ │ │ │ └── test.txt │ │ └── plugin.yaml │ ├── presets/ │ │ └── plugins/ │ │ └── fake-plugin.jar │ ├── themes/ │ │ ├── default/ │ │ │ ├── i18n/ │ │ │ │ ├── default.properties │ │ │ │ ├── en.properties │ │ │ │ └── zh.properties │ │ │ ├── templates/ │ │ │ │ ├── index.html │ │ │ │ ├── index.properties │ │ │ │ ├── index_zh.properties │ │ │ │ └── timezone.html │ │ │ └── theme.yaml │ │ ├── invalid-missing-manifest/ │ │ │ ├── i18n/ │ │ │ │ ├── default.properties │ │ │ │ └── en.properties │ │ │ └── templates/ │ │ │ ├── index.html │ │ │ └── timezone.html │ │ └── other/ │ │ ├── i18n/ │ │ │ ├── default.properties │ │ │ └── en.properties │ │ ├── templates/ │ │ │ └── index.html │ │ └── theme.yaml │ └── ui/ │ ├── console.html │ ├── uc.html │ └── ui-assets/ │ └── fake.txt ├── buildSrc/ │ ├── build.gradle │ └── src/ │ └── main/ │ └── groovy/ │ ├── UploadBundleTask.groovy │ └── halo.publish.gradle ├── config/ │ └── checkstyle/ │ └── checkstyle.xml ├── docs/ │ ├── authentication/ │ │ └── README.md │ ├── backup-and-restore.md │ ├── cache/ │ │ └── page.md │ ├── developer-guide/ │ │ ├── custom-endpoint.md │ │ └── plugin-configuration-properties.md │ ├── email-verification/ │ │ └── README.md │ ├── extension-points/ │ │ ├── authentication.md │ │ ├── content.md │ │ └── search-engine.md │ ├── full-text-search/ │ │ └── README.md │ ├── index/ │ │ └── README.md │ ├── notification/ │ │ └── README.md │ └── plugin/ │ ├── shared-event.md │ └── websocket.md ├── e2e/ │ ├── Dockerfile │ ├── Makefile │ ├── README.md │ ├── compose-mysql.yaml │ ├── compose-postgres.yaml │ ├── compose.yaml │ ├── start.sh │ └── testsuite.yaml ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── hack/ │ └── cherry_pick_pull.sh ├── platform/ │ ├── application/ │ │ └── build.gradle │ └── plugin/ │ └── build.gradle ├── settings.gradle └── ui/ ├── .editorconfig ├── .husky/ │ └── pre-commit ├── .npmrc ├── .oxfmtrc.json ├── Makefile ├── build.gradle ├── console-src/ │ ├── App.vue │ ├── components/ │ │ └── snapshots/ │ │ ├── BaseSnapshots.vue │ │ ├── SnapshotContent.vue │ │ ├── SnapshotDiffContent.vue │ │ ├── SnapshotListItem.vue │ │ └── query-keys.ts │ ├── composables/ │ │ ├── use-content-snapshot.ts │ │ ├── use-dashboard-stats.ts │ │ ├── use-entity-extension-points.ts │ │ ├── use-operation-extension-points.ts │ │ ├── use-save-keybinding.ts │ │ └── use-slugify.ts │ ├── layouts/ │ │ ├── BasicLayout.vue │ │ └── BlankLayout.vue │ ├── main.ts │ ├── modules/ │ │ ├── contents/ │ │ │ ├── attachments/ │ │ │ │ ├── AttachmentList.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── AttachmentDetailModal.vue │ │ │ │ │ ├── AttachmentError.vue │ │ │ │ │ ├── AttachmentGroupBadge.vue │ │ │ │ │ ├── AttachmentGroupEditingModal.vue │ │ │ │ │ ├── AttachmentGroupList.vue │ │ │ │ │ ├── AttachmentListItem.vue │ │ │ │ │ ├── AttachmentLoading.vue │ │ │ │ │ ├── AttachmentPoliciesListItem.vue │ │ │ │ │ ├── AttachmentPoliciesModal.vue │ │ │ │ │ ├── AttachmentPolicyBadge.vue │ │ │ │ │ ├── AttachmentPolicyEditingModal.vue │ │ │ │ │ ├── AttachmentSelectorModal.vue │ │ │ │ │ ├── AttachmentUploadArea.vue │ │ │ │ │ ├── AttachmentUploadModal.vue │ │ │ │ │ ├── DisplayNameEditForm.vue │ │ │ │ │ ├── UploadFromUrl.vue │ │ │ │ │ └── selector-providers/ │ │ │ │ │ ├── CoreSelectorProvider.vue │ │ │ │ │ └── components/ │ │ │ │ │ ├── AttachmentSelectorListItem.vue │ │ │ │ │ ├── GroupFilter.vue │ │ │ │ │ └── PolicyFilter.vue │ │ │ │ ├── composables/ │ │ │ │ │ ├── use-attachment-group.ts │ │ │ │ │ ├── use-attachment-policy.ts │ │ │ │ │ └── use-attachment.ts │ │ │ │ └── module.ts │ │ │ ├── comments/ │ │ │ │ ├── CommentList.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── CommentDetailModal.vue │ │ │ │ │ ├── CommentEditor.vue │ │ │ │ │ ├── CommentListItem.vue │ │ │ │ │ ├── DefaultCommentContent.vue │ │ │ │ │ ├── DefaultCommentEditor.vue │ │ │ │ │ ├── OwnerButton.vue │ │ │ │ │ ├── ReplyCreationModal.vue │ │ │ │ │ ├── ReplyDetailModal.vue │ │ │ │ │ ├── ReplyListItem.vue │ │ │ │ │ ├── SubjectQueryCommentList.vue │ │ │ │ │ └── SubjectQueryCommentListModal.vue │ │ │ │ ├── composables/ │ │ │ │ │ ├── use-comment-last-readtime-mutate.ts │ │ │ │ │ ├── use-comments-fetch.ts │ │ │ │ │ ├── use-content-provider-extension-point.ts │ │ │ │ │ └── use-subject-ref.ts │ │ │ │ └── module.ts │ │ │ ├── pages/ │ │ │ │ ├── DeletedSinglePageList.vue │ │ │ │ ├── SinglePageEditor.vue │ │ │ │ ├── SinglePageList.vue │ │ │ │ ├── SinglePageSnapshots.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── SinglePageListItem.vue │ │ │ │ │ ├── SinglePageSettingModal.vue │ │ │ │ │ └── entity-fields/ │ │ │ │ │ ├── ContributorsField.vue │ │ │ │ │ ├── CoverField.vue │ │ │ │ │ ├── PublishStatusField.vue │ │ │ │ │ ├── PublishTimeField.vue │ │ │ │ │ ├── TitleField.vue │ │ │ │ │ └── VisibleField.vue │ │ │ │ ├── composables/ │ │ │ │ │ └── use-page-update-mutate.ts │ │ │ │ └── module.ts │ │ │ └── posts/ │ │ │ ├── DeletedPostList.vue │ │ │ ├── PostEditor.vue │ │ │ ├── PostList.vue │ │ │ ├── PostSnapshots.vue │ │ │ ├── categories/ │ │ │ │ ├── CategoryList.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── CategoryEditingModal.vue │ │ │ │ │ ├── CategoryListItem.vue │ │ │ │ │ └── __tests__/ │ │ │ │ │ └── CategoryEditingModal.spec.ts │ │ │ │ ├── composables/ │ │ │ │ │ └── use-post-category.ts │ │ │ │ └── utils/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ └── index.ts │ │ │ ├── components/ │ │ │ │ ├── PostBatchSettingModal.vue │ │ │ │ ├── PostListItem.vue │ │ │ │ ├── PostSettingModal.vue │ │ │ │ ├── __tests__/ │ │ │ │ │ └── PostSettingModal.spec.ts │ │ │ │ └── entity-fields/ │ │ │ │ ├── ContributorsField.vue │ │ │ │ ├── CoverField.vue │ │ │ │ ├── PublishStatusField.vue │ │ │ │ ├── PublishTimeField.vue │ │ │ │ ├── TitleField.vue │ │ │ │ └── VisibleField.vue │ │ │ ├── composables/ │ │ │ │ └── use-post-update-mutate.ts │ │ │ ├── module.ts │ │ │ └── tags/ │ │ │ ├── TagList.vue │ │ │ ├── components/ │ │ │ │ ├── PostTag.vue │ │ │ │ ├── TagEditingModal.vue │ │ │ │ └── TagListItem.vue │ │ │ └── composables/ │ │ │ └── use-post-tag.ts │ │ ├── dashboard/ │ │ │ ├── Dashboard.vue │ │ │ ├── DashboardDesigner.vue │ │ │ ├── components/ │ │ │ │ ├── ActionButton.vue │ │ │ │ ├── WidgetCard.vue │ │ │ │ ├── WidgetConfigFormModal.vue │ │ │ │ ├── WidgetEditableItem.vue │ │ │ │ ├── WidgetHubModal.vue │ │ │ │ └── WidgetViewItem.vue │ │ │ ├── composables/ │ │ │ │ ├── use-dashboard-extension-point.ts │ │ │ │ └── use-dashboard-widgets-fetch.ts │ │ │ ├── module.ts │ │ │ ├── styles/ │ │ │ │ └── dashboard.css │ │ │ └── widgets/ │ │ │ ├── defaults.ts │ │ │ ├── index.ts │ │ │ └── presets/ │ │ │ ├── comments/ │ │ │ │ ├── CommentItem.vue │ │ │ │ ├── CommentStatsWidget.vue │ │ │ │ └── PendingCommentsWidget.vue │ │ │ ├── core/ │ │ │ │ ├── iframe/ │ │ │ │ │ └── IframeWidget.vue │ │ │ │ ├── quick-action/ │ │ │ │ │ ├── QuickActionItem.vue │ │ │ │ │ ├── QuickActionWidget.vue │ │ │ │ │ ├── ThemePreviewItem.vue │ │ │ │ │ └── composables/ │ │ │ │ │ └── use-dashboard-extension-point.ts │ │ │ │ ├── stack/ │ │ │ │ │ ├── StackWidget.vue │ │ │ │ │ ├── StackWidgetConfigModal.vue │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── IndexIndicator.vue │ │ │ │ │ │ ├── WidgetEditableItem.vue │ │ │ │ │ │ └── WidgetViewItem.vue │ │ │ │ │ └── types.ts │ │ │ │ ├── upvotes-stats/ │ │ │ │ │ └── UpvotesStatsWidget.vue │ │ │ │ └── view-stats/ │ │ │ │ └── ViewsStatsWidget.vue │ │ │ ├── posts/ │ │ │ │ ├── PostStatsWidget.vue │ │ │ │ ├── RecentPublishedWidget.vue │ │ │ │ └── components/ │ │ │ │ └── PostListItem.vue │ │ │ ├── single-pages/ │ │ │ │ └── SinglePageStatsWidget.vue │ │ │ └── users/ │ │ │ ├── NotificationWidget.vue │ │ │ └── UserStatsWidget.vue │ │ ├── index.ts │ │ ├── interface/ │ │ │ ├── menus/ │ │ │ │ ├── Menus.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── MenuEditingModal.vue │ │ │ │ │ ├── MenuItemEditingModal.vue │ │ │ │ │ └── MenuList.vue │ │ │ │ ├── module.ts │ │ │ │ └── utils/ │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── index.spec.ts.snap │ │ │ │ │ └── index.spec.ts │ │ │ │ └── index.ts │ │ │ └── themes/ │ │ │ ├── ThemeDetail.vue │ │ │ ├── ThemeSetting.vue │ │ │ ├── components/ │ │ │ │ ├── ThemeListItem.vue │ │ │ │ ├── ThemeListModal.vue │ │ │ │ ├── list-tabs/ │ │ │ │ │ ├── InstalledThemes.vue │ │ │ │ │ ├── LocalUpload.vue │ │ │ │ │ ├── NotInstalledThemes.vue │ │ │ │ │ └── RemoteDownload.vue │ │ │ │ ├── operation/ │ │ │ │ │ ├── MoreOperationItem.vue │ │ │ │ │ └── UninstallOperationItem.vue │ │ │ │ └── preview/ │ │ │ │ ├── ThemePreviewListItem.vue │ │ │ │ └── ThemePreviewModal.vue │ │ │ ├── composables/ │ │ │ │ └── use-theme.ts │ │ │ ├── constants/ │ │ │ │ └── index.ts │ │ │ ├── layouts/ │ │ │ │ └── ThemeLayout.vue │ │ │ ├── module.ts │ │ │ └── types/ │ │ │ └── index.ts │ │ └── system/ │ │ ├── auth-providers/ │ │ │ ├── AuthProviderDetail.vue │ │ │ ├── AuthProviders.vue │ │ │ ├── components/ │ │ │ │ ├── AuthProviderListItem.vue │ │ │ │ └── AuthProvidersSection.vue │ │ │ └── module.ts │ │ ├── backup/ │ │ │ ├── Backups.vue │ │ │ ├── components/ │ │ │ │ └── BackupListItem.vue │ │ │ ├── composables/ │ │ │ │ └── use-backup.ts │ │ │ ├── module.ts │ │ │ └── tabs/ │ │ │ ├── List.vue │ │ │ └── Restore.vue │ │ ├── overview/ │ │ │ ├── Overview.vue │ │ │ ├── components/ │ │ │ │ ├── ExternalUrlForm.vue │ │ │ │ └── ExternalUrlItem.vue │ │ │ └── module.ts │ │ ├── plugins/ │ │ │ ├── PluginDetail.vue │ │ │ ├── PluginExtensionPointSettings.vue │ │ │ ├── PluginList.vue │ │ │ ├── components/ │ │ │ │ ├── PluginConditionsModal.vue │ │ │ │ ├── PluginDetailModal.vue │ │ │ │ ├── PluginInstallationModal.vue │ │ │ │ ├── PluginListItem.vue │ │ │ │ ├── entity-fields/ │ │ │ │ │ ├── AuthorField.vue │ │ │ │ │ ├── LogoField.vue │ │ │ │ │ ├── ReloadField.vue │ │ │ │ │ ├── SwitchField.vue │ │ │ │ │ └── TitleField.vue │ │ │ │ ├── extension-points/ │ │ │ │ │ ├── ExtensionDefinitionListItem.vue │ │ │ │ │ ├── ExtensionDefinitionMultiInstanceView.vue │ │ │ │ │ └── ExtensionDefinitionSingletonView.vue │ │ │ │ ├── installation-tabs/ │ │ │ │ │ ├── LocalUpload.vue │ │ │ │ │ └── RemoteDownload.vue │ │ │ │ └── tabs/ │ │ │ │ ├── Detail.vue │ │ │ │ └── Setting.vue │ │ │ ├── composables/ │ │ │ │ ├── use-extension-definition-fetch.ts │ │ │ │ └── use-plugin.ts │ │ │ ├── constants/ │ │ │ │ └── index.ts │ │ │ ├── module.ts │ │ │ └── types/ │ │ │ └── index.ts │ │ ├── roles/ │ │ │ ├── RoleDetail.vue │ │ │ ├── RoleList.vue │ │ │ ├── components/ │ │ │ │ └── RoleEditingModal.vue │ │ │ └── module.ts │ │ ├── settings/ │ │ │ ├── SystemSettings.vue │ │ │ ├── module.ts │ │ │ └── tabs/ │ │ │ ├── NotificationSetting.vue │ │ │ ├── Notifications.vue │ │ │ └── Setting.vue │ │ ├── tools/ │ │ │ ├── Tools.vue │ │ │ └── module.ts │ │ └── users/ │ │ ├── UserDetail.vue │ │ ├── UserList.vue │ │ ├── components/ │ │ │ ├── GrantPermissionModal.vue │ │ │ ├── RolesView.vue │ │ │ ├── UserCreationModal.vue │ │ │ ├── UserEditingModal.vue │ │ │ ├── UserListItem.vue │ │ │ └── UserPasswordChangeModal.vue │ │ ├── composables/ │ │ │ ├── use-role.ts │ │ │ └── use-user.ts │ │ ├── module.ts │ │ └── tabs/ │ │ └── Detail.vue │ ├── router/ │ │ ├── constant.ts │ │ ├── guards/ │ │ │ ├── auth-check.ts │ │ │ └── permission.ts │ │ ├── index.ts │ │ └── routes.config.ts │ └── stores/ │ └── theme.ts ├── console.html ├── docs/ │ ├── components/ │ │ └── README.md │ ├── custom-formkit-input/ │ │ └── README.md │ ├── extension-points/ │ │ ├── backup.md │ │ ├── comment-content.md │ │ ├── comment-editor.md │ │ ├── comment-subject-ref.md │ │ ├── dashboard.md │ │ ├── default-editor–extension.md │ │ ├── editor.md │ │ ├── entity-listitem-field.md │ │ ├── entity-listitem-operation.md │ │ ├── plugin-installation-tabs.md │ │ ├── plugin-self-tabs.md │ │ └── theme-list-tabs.md │ ├── project-structure/ │ │ └── README.md │ └── routes-generation/ │ └── README.md ├── env.d.ts ├── eslint.config.ts ├── package.json ├── packages/ │ ├── api-client/ │ │ ├── .openapi_config.yaml │ │ ├── README.md │ │ ├── entry/ │ │ │ ├── api-client.ts │ │ │ ├── index.ts │ │ │ └── utils/ │ │ │ ├── index.ts │ │ │ └── paginate.ts │ │ ├── openapitools.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── .gitignore │ │ │ ├── .npmignore │ │ │ ├── .openapi-generator/ │ │ │ │ ├── FILES │ │ │ │ └── VERSION │ │ │ ├── .openapi-generator-ignore │ │ │ ├── api/ │ │ │ │ ├── annotation-setting-v1-alpha-uc-api.ts │ │ │ │ ├── annotation-setting-v1alpha1-api.ts │ │ │ │ ├── attachment-v1alpha1-api.ts │ │ │ │ ├── attachment-v1alpha1-console-api.ts │ │ │ │ ├── attachment-v1alpha1-uc-api.ts │ │ │ │ ├── auth-provider-v1alpha1-api.ts │ │ │ │ ├── auth-provider-v1alpha1-console-api.ts │ │ │ │ ├── backup-v1alpha1-api.ts │ │ │ │ ├── category-v1alpha1-api.ts │ │ │ │ ├── category-v1alpha1-public-api.ts │ │ │ │ ├── comment-v1alpha1-api.ts │ │ │ │ ├── comment-v1alpha1-console-api.ts │ │ │ │ ├── comment-v1alpha1-public-api.ts │ │ │ │ ├── config-map-v1alpha1-api.ts │ │ │ │ ├── counter-v1alpha1-api.ts │ │ │ │ ├── device-v1alpha1-api.ts │ │ │ │ ├── device-v1alpha1-uc-api.ts │ │ │ │ ├── extension-definition-v1alpha1-api.ts │ │ │ │ ├── extension-point-definition-v1alpha1-api.ts │ │ │ │ ├── group-v1alpha1-api.ts │ │ │ │ ├── index-v1alpha1-public-api.ts │ │ │ │ ├── indices-v1alpha1-console-api.ts │ │ │ │ ├── local-thumbnail-v1alpha1-api.ts │ │ │ │ ├── menu-item-v1alpha1-api.ts │ │ │ │ ├── menu-v1alpha1-api.ts │ │ │ │ ├── menu-v1alpha1-public-api.ts │ │ │ │ ├── metrics-v1alpha1-public-api.ts │ │ │ │ ├── migration-v1alpha1-console-api.ts │ │ │ │ ├── notification-template-v1alpha1-api.ts │ │ │ │ ├── notification-v1alpha1-api.ts │ │ │ │ ├── notification-v1alpha1-public-api.ts │ │ │ │ ├── notification-v1alpha1-uc-api.ts │ │ │ │ ├── notifier-descriptor-v1alpha1-api.ts │ │ │ │ ├── notifier-v1alpha1-console-api.ts │ │ │ │ ├── notifier-v1alpha1-uc-api.ts │ │ │ │ ├── personal-access-token-v1alpha1-api.ts │ │ │ │ ├── personal-access-token-v1alpha1-uc-api.ts │ │ │ │ ├── plugin-v1alpha1-api.ts │ │ │ │ ├── plugin-v1alpha1-console-api.ts │ │ │ │ ├── plugin-v1alpha1-public-api.ts │ │ │ │ ├── policy-alpha1-console-api.ts │ │ │ │ ├── policy-template-v1alpha1-api.ts │ │ │ │ ├── policy-v1alpha1-api.ts │ │ │ │ ├── post-v1alpha1-api.ts │ │ │ │ ├── post-v1alpha1-console-api.ts │ │ │ │ ├── post-v1alpha1-public-api.ts │ │ │ │ ├── post-v1alpha1-uc-api.ts │ │ │ │ ├── reason-type-v1alpha1-api.ts │ │ │ │ ├── reason-v1alpha1-api.ts │ │ │ │ ├── remember-me-token-v1alpha1-api.ts │ │ │ │ ├── reply-v1alpha1-api.ts │ │ │ │ ├── reply-v1alpha1-console-api.ts │ │ │ │ ├── reverse-proxy-v1alpha1-api.ts │ │ │ │ ├── role-binding-v1alpha1-api.ts │ │ │ │ ├── role-v1alpha1-api.ts │ │ │ │ ├── secret-v1alpha1-api.ts │ │ │ │ ├── setting-v1alpha1-api.ts │ │ │ │ ├── single-page-v1alpha1-api.ts │ │ │ │ ├── single-page-v1alpha1-console-api.ts │ │ │ │ ├── single-page-v1alpha1-public-api.ts │ │ │ │ ├── snapshot-v1alpha1-api.ts │ │ │ │ ├── snapshot-v1alpha1-uc-api.ts │ │ │ │ ├── subscription-v1alpha1-api.ts │ │ │ │ ├── system-config-v1alpha1-console-api.ts │ │ │ │ ├── system-v1alpha1-console-api.ts │ │ │ │ ├── system-v1alpha1-public-api.ts │ │ │ │ ├── tag-v1alpha1-api.ts │ │ │ │ ├── tag-v1alpha1-console-api.ts │ │ │ │ ├── tag-v1alpha1-public-api.ts │ │ │ │ ├── theme-v1alpha1-api.ts │ │ │ │ ├── theme-v1alpha1-console-api.ts │ │ │ │ ├── thumbnail-v1alpha1-api.ts │ │ │ │ ├── thumbnail-v1alpha1-public-api.ts │ │ │ │ ├── two-factor-auth-v1alpha1-uc-api.ts │ │ │ │ ├── user-connection-v1alpha1-api.ts │ │ │ │ ├── user-connection-v1alpha1-uc-api.ts │ │ │ │ ├── user-preference-v1alpha1-uc-api.ts │ │ │ │ ├── user-v1alpha1-api.ts │ │ │ │ └── user-v1alpha1-console-api.ts │ │ │ ├── api.ts │ │ │ ├── base.ts │ │ │ ├── common.ts │ │ │ ├── configuration.ts │ │ │ ├── git_push.sh │ │ │ ├── index.ts │ │ │ └── models/ │ │ │ ├── add-operation.ts │ │ │ ├── annotation-setting-list.ts │ │ │ ├── annotation-setting-spec.ts │ │ │ ├── annotation-setting.ts │ │ │ ├── attachment-list.ts │ │ │ ├── attachment-spec.ts │ │ │ ├── attachment-status.ts │ │ │ ├── attachment.ts │ │ │ ├── auth-provider-list.ts │ │ │ ├── auth-provider-spec.ts │ │ │ ├── auth-provider.ts │ │ │ ├── author.ts │ │ │ ├── backup-file.ts │ │ │ ├── backup-list.ts │ │ │ ├── backup-spec.ts │ │ │ ├── backup-status.ts │ │ │ ├── backup.ts │ │ │ ├── category-list.ts │ │ │ ├── category-spec.ts │ │ │ ├── category-status.ts │ │ │ ├── category-vo-list.ts │ │ │ ├── category-vo.ts │ │ │ ├── category.ts │ │ │ ├── change-own-password-request.ts │ │ │ ├── change-password-request.ts │ │ │ ├── comment-email-owner.ts │ │ │ ├── comment-list.ts │ │ │ ├── comment-owner.ts │ │ │ ├── comment-request.ts │ │ │ ├── comment-spec.ts │ │ │ ├── comment-stats-vo.ts │ │ │ ├── comment-stats.ts │ │ │ ├── comment-status.ts │ │ │ ├── comment-vo-list.ts │ │ │ ├── comment-vo.ts │ │ │ ├── comment-with-reply-vo-list.ts │ │ │ ├── comment-with-reply-vo.ts │ │ │ ├── comment.ts │ │ │ ├── condition.ts │ │ │ ├── config-map-list.ts │ │ │ ├── config-map-ref.ts │ │ │ ├── config-map.ts │ │ │ ├── content-update-param.ts │ │ │ ├── content-vo.ts │ │ │ ├── content-wrapper.ts │ │ │ ├── content.ts │ │ │ ├── contributor-vo.ts │ │ │ ├── contributor.ts │ │ │ ├── copy-operation.ts │ │ │ ├── counter-list.ts │ │ │ ├── counter-request.ts │ │ │ ├── counter.ts │ │ │ ├── create-user-request.ts │ │ │ ├── custom-templates.ts │ │ │ ├── dashboard-stats.ts │ │ │ ├── detailed-user.ts │ │ │ ├── device-list.ts │ │ │ ├── device-spec.ts │ │ │ ├── device-status.ts │ │ │ ├── device.ts │ │ │ ├── email-config-validation-request.ts │ │ │ ├── email-verify-request.ts │ │ │ ├── excerpt.ts │ │ │ ├── extension-definition-list.ts │ │ │ ├── extension-definition.ts │ │ │ ├── extension-point-definition-list.ts │ │ │ ├── extension-point-definition.ts │ │ │ ├── extension-point-spec.ts │ │ │ ├── extension-spec.ts │ │ │ ├── extension.ts │ │ │ ├── file-reverse-proxy-provider.ts │ │ │ ├── grant-request.ts │ │ │ ├── group-kind.ts │ │ │ ├── group-list.ts │ │ │ ├── group-spec.ts │ │ │ ├── group-status.ts │ │ │ ├── group.ts │ │ │ ├── halo-document.ts │ │ │ ├── index.ts │ │ │ ├── install-from-uri-request.ts │ │ │ ├── interest-reason-subject.ts │ │ │ ├── interest-reason.ts │ │ │ ├── json-patch-inner.ts │ │ │ ├── license.ts │ │ │ ├── list-result-reply-vo.ts │ │ │ ├── listed-auth-provider.ts │ │ │ ├── listed-comment-list.ts │ │ │ ├── listed-comment.ts │ │ │ ├── listed-post-list.ts │ │ │ ├── listed-post-vo-list.ts │ │ │ ├── listed-post-vo.ts │ │ │ ├── listed-post.ts │ │ │ ├── listed-reply-list.ts │ │ │ ├── listed-reply.ts │ │ │ ├── listed-single-page-list.ts │ │ │ ├── listed-single-page-vo-list.ts │ │ │ ├── listed-single-page-vo.ts │ │ │ ├── listed-single-page.ts │ │ │ ├── listed-snapshot-dto.ts │ │ │ ├── listed-snapshot-spec.ts │ │ │ ├── listed-user.ts │ │ │ ├── local-thumbnail-list.ts │ │ │ ├── local-thumbnail-spec.ts │ │ │ ├── local-thumbnail-status.ts │ │ │ ├── local-thumbnail.ts │ │ │ ├── mark-specified-request.ts │ │ │ ├── menu-item-list.ts │ │ │ ├── menu-item-spec.ts │ │ │ ├── menu-item-status.ts │ │ │ ├── menu-item-vo.ts │ │ │ ├── menu-item.ts │ │ │ ├── menu-list.ts │ │ │ ├── menu-spec.ts │ │ │ ├── menu-vo.ts │ │ │ ├── menu.ts │ │ │ ├── metadata.ts │ │ │ ├── move-operation.ts │ │ │ ├── navigation-post-vo.ts │ │ │ ├── notification-list.ts │ │ │ ├── notification-spec.ts │ │ │ ├── notification-template-list.ts │ │ │ ├── notification-template-spec.ts │ │ │ ├── notification-template.ts │ │ │ ├── notification.ts │ │ │ ├── notifier-descriptor-list.ts │ │ │ ├── notifier-descriptor-spec.ts │ │ │ ├── notifier-descriptor.ts │ │ │ ├── notifier-info.ts │ │ │ ├── notifier-setting-ref.ts │ │ │ ├── owner-info.ts │ │ │ ├── password-request.ts │ │ │ ├── pat-spec.ts │ │ │ ├── personal-access-token-list.ts │ │ │ ├── personal-access-token.ts │ │ │ ├── plugin-author.ts │ │ │ ├── plugin-list.ts │ │ │ ├── plugin-running-state-request.ts │ │ │ ├── plugin-spec.ts │ │ │ ├── plugin-status.ts │ │ │ ├── plugin.ts │ │ │ ├── policy-list.ts │ │ │ ├── policy-rule.ts │ │ │ ├── policy-spec.ts │ │ │ ├── policy-template-list.ts │ │ │ ├── policy-template-spec.ts │ │ │ ├── policy-template.ts │ │ │ ├── policy.ts │ │ │ ├── post-list.ts │ │ │ ├── post-request.ts │ │ │ ├── post-spec.ts │ │ │ ├── post-status.ts │ │ │ ├── post-vo.ts │ │ │ ├── post.ts │ │ │ ├── reason-attributes.ts │ │ │ ├── reason-list.ts │ │ │ ├── reason-property.ts │ │ │ ├── reason-selector.ts │ │ │ ├── reason-spec.ts │ │ │ ├── reason-subject.ts │ │ │ ├── reason-type-info.ts │ │ │ ├── reason-type-list.ts │ │ │ ├── reason-type-notifier-collection-request.ts │ │ │ ├── reason-type-notifier-matrix.ts │ │ │ ├── reason-type-notifier-request.ts │ │ │ ├── reason-type-spec.ts │ │ │ ├── reason-type.ts │ │ │ ├── reason.ts │ │ │ ├── ref.ts │ │ │ ├── remember-me-token-list.ts │ │ │ ├── remember-me-token-spec.ts │ │ │ ├── remember-me-token.ts │ │ │ ├── remove-operation.ts │ │ │ ├── replace-operation.ts │ │ │ ├── reply-list.ts │ │ │ ├── reply-request.ts │ │ │ ├── reply-spec.ts │ │ │ ├── reply-status.ts │ │ │ ├── reply-vo-list.ts │ │ │ ├── reply-vo.ts │ │ │ ├── reply.ts │ │ │ ├── reverse-proxy-list.ts │ │ │ ├── reverse-proxy-rule.ts │ │ │ ├── reverse-proxy.ts │ │ │ ├── revert-snapshot-for-post-param.ts │ │ │ ├── revert-snapshot-for-single-param.ts │ │ │ ├── role-binding-list.ts │ │ │ ├── role-binding.ts │ │ │ ├── role-list.ts │ │ │ ├── role-ref.ts │ │ │ ├── role.ts │ │ │ ├── search-option.ts │ │ │ ├── search-result.ts │ │ │ ├── secret-list.ts │ │ │ ├── secret.ts │ │ │ ├── setting-form.ts │ │ │ ├── setting-list.ts │ │ │ ├── setting-ref.ts │ │ │ ├── setting-spec.ts │ │ │ ├── setting.ts │ │ │ ├── setup-request.ts │ │ │ ├── single-page-list.ts │ │ │ ├── single-page-request.ts │ │ │ ├── single-page-spec.ts │ │ │ ├── single-page-status.ts │ │ │ ├── single-page-vo.ts │ │ │ ├── single-page.ts │ │ │ ├── site-stats-vo.ts │ │ │ ├── snap-shot-spec.ts │ │ │ ├── snapshot-list.ts │ │ │ ├── snapshot.ts │ │ │ ├── stats-vo.ts │ │ │ ├── stats.ts │ │ │ ├── subject.ts │ │ │ ├── subscription-list.ts │ │ │ ├── subscription-spec.ts │ │ │ ├── subscription-subscriber.ts │ │ │ ├── subscription.ts │ │ │ ├── tag-list.ts │ │ │ ├── tag-spec.ts │ │ │ ├── tag-status.ts │ │ │ ├── tag-vo-list.ts │ │ │ ├── tag-vo.ts │ │ │ ├── tag.ts │ │ │ ├── template-content.ts │ │ │ ├── template-descriptor.ts │ │ │ ├── test-operation.ts │ │ │ ├── theme-list.ts │ │ │ ├── theme-spec.ts │ │ │ ├── theme-status.ts │ │ │ ├── theme.ts │ │ │ ├── thumbnail-list.ts │ │ │ ├── thumbnail-spec.ts │ │ │ ├── thumbnail.ts │ │ │ ├── totp-auth-link-response.ts │ │ │ ├── totp-request.ts │ │ │ ├── two-factor-auth-settings.ts │ │ │ ├── uc-upload-from-url-request.ts │ │ │ ├── upgrade-from-uri-request.ts │ │ │ ├── upload-from-url-request.ts │ │ │ ├── user-connection-list.ts │ │ │ ├── user-connection-spec.ts │ │ │ ├── user-connection.ts │ │ │ ├── user-device.ts │ │ │ ├── user-endpoint-listed-user-list.ts │ │ │ ├── user-list.ts │ │ │ ├── user-permission.ts │ │ │ ├── user-spec.ts │ │ │ ├── user-status.ts │ │ │ ├── user.ts │ │ │ ├── verify-code-request.ts │ │ │ └── vote-request.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── components/ │ │ ├── .storybook/ │ │ │ ├── main.ts │ │ │ └── preview.ts │ │ ├── env.d.ts │ │ ├── package.json │ │ ├── postcss.config.cjs │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── alert/ │ │ │ │ │ ├── Alert.stories.ts │ │ │ │ │ ├── Alert.vue │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── Alert.spec.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── avatar/ │ │ │ │ │ ├── Avatar.stories.ts │ │ │ │ │ ├── Avatar.vue │ │ │ │ │ ├── AvatarGroup.stories.ts │ │ │ │ │ ├── AvatarGroup.vue │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── button/ │ │ │ │ │ ├── Button.stories.ts │ │ │ │ │ ├── Button.vue │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── Button.spec.ts │ │ │ │ │ │ └── __snapshots__/ │ │ │ │ │ │ └── Button.spec.ts.snap │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── card/ │ │ │ │ │ ├── Card.stories.ts │ │ │ │ │ ├── Card.vue │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── Card.spec.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── description/ │ │ │ │ │ ├── Description.vue │ │ │ │ │ ├── DescriptionItem.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── dialog/ │ │ │ │ │ ├── Dialog.stories.ts │ │ │ │ │ ├── Dialog.vue │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── Dialog.spec.ts │ │ │ │ │ ├── dialog-manager.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── dropdown/ │ │ │ │ │ ├── Draopdown.stories.ts │ │ │ │ │ ├── DropdownDivider.vue │ │ │ │ │ ├── DropdownItem.vue │ │ │ │ │ ├── index.ts │ │ │ │ │ └── style.scss │ │ │ │ ├── empty/ │ │ │ │ │ ├── Empty.stories.ts │ │ │ │ │ ├── Empty.vue │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── Empty.spec.ts │ │ │ │ │ │ └── __snapshots__/ │ │ │ │ │ │ └── Empty.spec.ts.snap │ │ │ │ │ └── index.ts │ │ │ │ ├── entity/ │ │ │ │ │ ├── Entity.vue │ │ │ │ │ ├── EntityContainer.vue │ │ │ │ │ ├── EntityField.vue │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── Entity.spec.ts │ │ │ │ │ │ └── EntityField.spec.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── header/ │ │ │ │ │ ├── PageHeader.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── loading/ │ │ │ │ │ ├── Loading.stories.ts │ │ │ │ │ ├── Loading.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── menu/ │ │ │ │ │ ├── Menu.stories.ts │ │ │ │ │ ├── Menu.vue │ │ │ │ │ ├── MenuItem.vue │ │ │ │ │ ├── MenuLabel.vue │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── Menu.spec.tsx │ │ │ │ │ │ ├── MenuLabel.spec.ts │ │ │ │ │ │ └── __snapshots__/ │ │ │ │ │ │ ├── Menu.spec.tsx.snap │ │ │ │ │ │ └── MenuLabel.spec.ts.snap │ │ │ │ │ └── index.ts │ │ │ │ ├── modal/ │ │ │ │ │ ├── Modal.stories.ts │ │ │ │ │ ├── Modal.vue │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── Modal.spec.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── pagination/ │ │ │ │ │ ├── Pagination.stories.ts │ │ │ │ │ ├── Pagination.vue │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── Pagination.spec.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── space/ │ │ │ │ │ ├── Space.stories.ts │ │ │ │ │ ├── Space.vue │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── Space.spec.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── status/ │ │ │ │ │ ├── StatusDot.stories.ts │ │ │ │ │ ├── StatusDot.vue │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── StatusDot.spec.ts │ │ │ │ │ │ └── __snapshots__/ │ │ │ │ │ │ └── StatusDot.spec.ts.snap │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── switch/ │ │ │ │ │ ├── Switch.stories.ts │ │ │ │ │ ├── Switch.vue │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── Switch.spec.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── tabs/ │ │ │ │ │ ├── TabItem.vue │ │ │ │ │ ├── Tabbar.stories.ts │ │ │ │ │ ├── Tabbar.vue │ │ │ │ │ ├── Tabs.stories.ts │ │ │ │ │ ├── Tabs.vue │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── TabItem.spec.ts │ │ │ │ │ │ ├── Tabbar.spec.ts │ │ │ │ │ │ └── Tabs.spec.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── tag/ │ │ │ │ │ ├── Tag.stories.ts │ │ │ │ │ ├── Tag.story.vue │ │ │ │ │ ├── Tag.vue │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── Tag.spec.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── toast/ │ │ │ │ │ ├── Toast.story.vue │ │ │ │ │ ├── Toast.vue │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── toast-manager.ts │ │ │ │ │ └── types.ts │ │ │ │ └── tooltip/ │ │ │ │ ├── index.ts │ │ │ │ └── style.css │ │ │ ├── components.ts │ │ │ ├── icons/ │ │ │ │ └── icons.ts │ │ │ ├── index.ts │ │ │ ├── stories/ │ │ │ │ └── Introduction.mdx │ │ │ └── styles/ │ │ │ └── tailwind.css │ │ ├── tailwind.config.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ ├── tsconfig.vitest.json │ │ └── vite.config.ts │ ├── console-shared/ │ │ ├── README.md │ │ ├── index.js │ │ └── package.json │ ├── editor/ │ │ ├── README.md │ │ ├── docs/ │ │ │ └── extension.md │ │ ├── env.d.ts │ │ ├── package.json │ │ ├── postcss.config.cjs │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── Editor.vue │ │ │ │ ├── EditorHeader.vue │ │ │ │ ├── base/ │ │ │ │ │ ├── DropdownItem.vue │ │ │ │ │ ├── Input.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── block/ │ │ │ │ │ ├── BlockActionButton.vue │ │ │ │ │ ├── BlockActionHorizontalSeparator.vue │ │ │ │ │ ├── BlockActionSeparator.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── bubble/ │ │ │ │ │ ├── BubbleButton.vue │ │ │ │ │ ├── BubbleItem.vue │ │ │ │ │ ├── EditorBubbleMenu.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── common/ │ │ │ │ │ └── ColorPickerDropdown.vue │ │ │ │ ├── drag/ │ │ │ │ │ ├── EditorDragButtonItem.vue │ │ │ │ │ ├── EditorDragHandle.vue │ │ │ │ │ ├── EditorDragMenu.vue │ │ │ │ │ ├── default-drag.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── icon/ │ │ │ │ │ └── MingcuteDelete2Line.vue │ │ │ │ ├── index.ts │ │ │ │ ├── toolbar/ │ │ │ │ │ ├── ToolbarItem.vue │ │ │ │ │ ├── ToolbarSubItem.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── toolbox/ │ │ │ │ │ ├── ToolboxItem.vue │ │ │ │ │ └── index.ts │ │ │ │ └── upload/ │ │ │ │ ├── EditorLinkObtain.vue │ │ │ │ ├── ResourceReplaceButton.vue │ │ │ │ └── index.ts │ │ │ ├── composables/ │ │ │ │ └── use-attachment.ts │ │ │ ├── extensions/ │ │ │ │ ├── align/ │ │ │ │ │ └── index.ts │ │ │ │ ├── audio/ │ │ │ │ │ ├── AudioView.vue │ │ │ │ │ ├── BubbleItemAudioLink.vue │ │ │ │ │ ├── BubbleItemAudioPosition.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── block-position/ │ │ │ │ │ └── index.ts │ │ │ │ ├── blockquote/ │ │ │ │ │ └── index.ts │ │ │ │ ├── bold/ │ │ │ │ │ └── index.ts │ │ │ │ ├── bullet-list/ │ │ │ │ │ └── index.ts │ │ │ │ ├── character-count/ │ │ │ │ │ └── index.ts │ │ │ │ ├── clear-format/ │ │ │ │ │ └── index.ts │ │ │ │ ├── code/ │ │ │ │ │ └── index.ts │ │ │ │ ├── code-block/ │ │ │ │ │ ├── CodeBlockSelect.vue │ │ │ │ │ ├── CodeBlockViewRenderer.vue │ │ │ │ │ ├── code-block.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── color/ │ │ │ │ │ ├── ColorBubbleItem.vue │ │ │ │ │ ├── ColorToolbarItem.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── columns/ │ │ │ │ │ ├── column.ts │ │ │ │ │ ├── columns.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── commands-menu/ │ │ │ │ │ ├── CommandsView.vue │ │ │ │ │ ├── commands.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── details/ │ │ │ │ │ └── index.ts │ │ │ │ ├── document/ │ │ │ │ │ └── index.ts │ │ │ │ ├── drop-cursor/ │ │ │ │ │ └── index.ts │ │ │ │ ├── extensions-kit.ts │ │ │ │ ├── figure/ │ │ │ │ │ ├── FigureCaptionView.vue │ │ │ │ │ ├── figure-caption.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── font-size/ │ │ │ │ │ └── index.ts │ │ │ │ ├── format-brush/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── util.ts │ │ │ │ ├── gallery/ │ │ │ │ │ ├── BubbleItemAddImage.vue │ │ │ │ │ ├── BubbleItemGap.vue │ │ │ │ │ ├── BubbleItemGroupSize.vue │ │ │ │ │ ├── BubbleItemLayout.vue │ │ │ │ │ ├── GalleryView.vue │ │ │ │ │ ├── gallery-bubble.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── useGalleryImages.ts │ │ │ │ ├── gap-cursor/ │ │ │ │ │ ├── gap-cursor-selection.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── hard-break/ │ │ │ │ │ └── index.ts │ │ │ │ ├── heading/ │ │ │ │ │ └── index.ts │ │ │ │ ├── highlight/ │ │ │ │ │ ├── HighlightBubbleItem.vue │ │ │ │ │ ├── HighlightToolbarItem.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── history/ │ │ │ │ │ └── index.ts │ │ │ │ ├── horizontal-rule/ │ │ │ │ │ └── index.ts │ │ │ │ ├── iframe/ │ │ │ │ │ ├── BubbleItemIframeAlign.vue │ │ │ │ │ ├── BubbleItemIframeLink.vue │ │ │ │ │ ├── BubbleItemIframeSize.vue │ │ │ │ │ ├── IframeView.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── image/ │ │ │ │ │ ├── BubbleItemImageAlt.vue │ │ │ │ │ ├── BubbleItemImageHref.vue │ │ │ │ │ ├── BubbleItemImageLink.vue │ │ │ │ │ ├── BubbleItemImagePosition.vue │ │ │ │ │ ├── BubbleItemImageSize.vue │ │ │ │ │ ├── ImageView.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── indent/ │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── italic/ │ │ │ │ │ └── index.ts │ │ │ │ ├── link/ │ │ │ │ │ ├── LinkBubbleButton.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── list-extra/ │ │ │ │ │ └── index.ts │ │ │ │ ├── list-keymap/ │ │ │ │ │ └── index.ts │ │ │ │ ├── node-selected/ │ │ │ │ │ └── index.ts │ │ │ │ ├── ordered-list/ │ │ │ │ │ └── index.ts │ │ │ │ ├── paragraph/ │ │ │ │ │ └── index.ts │ │ │ │ ├── placeholder/ │ │ │ │ │ └── index.ts │ │ │ │ ├── range-selection/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── range-selection.ts │ │ │ │ ├── search-and-replace/ │ │ │ │ │ ├── IconButton.vue │ │ │ │ │ ├── MatchToggleButton.vue │ │ │ │ │ ├── SearchAndReplace.vue │ │ │ │ │ ├── SearchAndReplacePlugin.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── smart-scroll/ │ │ │ │ │ └── index.ts │ │ │ │ ├── strike/ │ │ │ │ │ └── index.ts │ │ │ │ ├── subscript/ │ │ │ │ │ └── index.ts │ │ │ │ ├── superscript/ │ │ │ │ │ └── index.ts │ │ │ │ ├── table/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── table-cell.ts │ │ │ │ │ ├── table-header.ts │ │ │ │ │ ├── table-row.ts │ │ │ │ │ └── util.ts │ │ │ │ ├── task-list/ │ │ │ │ │ └── index.ts │ │ │ │ ├── text/ │ │ │ │ │ ├── BubbleItemTextType.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── text-align/ │ │ │ │ │ └── index.ts │ │ │ │ ├── text-style/ │ │ │ │ │ └── index.ts │ │ │ │ ├── trailing-node/ │ │ │ │ │ └── index.ts │ │ │ │ ├── underline/ │ │ │ │ │ └── index.ts │ │ │ │ ├── upload/ │ │ │ │ │ └── index.ts │ │ │ │ └── video/ │ │ │ │ ├── BubbleItemVideoLink.vue │ │ │ │ ├── BubbleItemVideoPosition.vue │ │ │ │ ├── BubbleItemVideoSize.vue │ │ │ │ ├── VideoView.vue │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── locales/ │ │ │ │ ├── en.json │ │ │ │ ├── es.json │ │ │ │ ├── index.ts │ │ │ │ └── zh-CN.json │ │ │ ├── styles/ │ │ │ │ ├── base.scss │ │ │ │ ├── columns.scss │ │ │ │ ├── details.scss │ │ │ │ ├── draggable.scss │ │ │ │ ├── figure.scss │ │ │ │ ├── format-brush.scss │ │ │ │ ├── gap-cursor.scss │ │ │ │ ├── index.scss │ │ │ │ ├── node-select.scss │ │ │ │ ├── range-selection.scss │ │ │ │ ├── resizer.scss │ │ │ │ ├── search.scss │ │ │ │ ├── table.scss │ │ │ │ └── tailwind.css │ │ │ ├── tiptap/ │ │ │ │ ├── core/ │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── pm/ │ │ │ │ │ └── index.ts │ │ │ │ └── vue-3/ │ │ │ │ └── index.ts │ │ │ ├── types/ │ │ │ │ └── index.ts │ │ │ └── utils/ │ │ │ ├── anchor.ts │ │ │ ├── attachment.ts │ │ │ ├── clipboard.ts │ │ │ ├── delete-node.ts │ │ │ ├── filter-duplicate-extensions.ts │ │ │ ├── index.ts │ │ │ ├── is-allowed-uri.ts │ │ │ ├── is-list-active.ts │ │ │ ├── is-node-empty.ts │ │ │ ├── keyboard.ts │ │ │ └── upload.ts │ │ ├── tailwind.config.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ ├── tsconfig.vitest.json │ │ └── vite.config.ts │ ├── shared/ │ │ ├── env.d.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── events/ │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── plugin/ │ │ │ │ ├── index.ts │ │ │ │ └── types/ │ │ │ │ ├── attachment-selector.ts │ │ │ │ ├── backup.ts │ │ │ │ ├── comment.ts │ │ │ │ ├── dashboard-widget.ts │ │ │ │ ├── editor-provider.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-entity-field.ts │ │ │ │ ├── list-operation.ts │ │ │ │ ├── plugin-installation-tab.ts │ │ │ │ ├── plugin-tab.ts │ │ │ │ ├── theme-list-tab.ts │ │ │ │ ├── ui-plugin-entry.ts │ │ │ │ ├── ui-plugin-module.ts │ │ │ │ └── user-tab.ts │ │ │ ├── stores/ │ │ │ │ ├── index.ts │ │ │ │ └── types/ │ │ │ │ ├── actuator.ts │ │ │ │ ├── index.ts │ │ │ │ └── slug.ts │ │ │ ├── types/ │ │ │ │ ├── index.ts │ │ │ │ └── menus.ts │ │ │ └── utils/ │ │ │ ├── attachment.ts │ │ │ ├── date.ts │ │ │ ├── id.ts │ │ │ ├── index.ts │ │ │ └── permission.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── tsdown.config.ts │ └── ui-plugin-bundler-kit/ │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── constants/ │ │ │ ├── build.ts │ │ │ ├── externals.ts │ │ │ └── halo-plugin.ts │ │ ├── index.ts │ │ ├── legacy.ts │ │ ├── rsbuild.ts │ │ ├── utils/ │ │ │ └── halo-plugin.ts │ │ └── vite.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── patches/ │ └── @tiptap__extension-drag-handle@3.17.1.patch ├── pnpm-workspace.yaml ├── postcss.config.cjs ├── scripts/ │ ├── apply_missing_translations.mjs │ ├── find_missing_translations.mjs │ └── fix_translations.mjs ├── src/ │ ├── components/ │ │ ├── alerts/ │ │ │ └── H2WarningAlert.vue │ │ ├── attachment/ │ │ │ ├── AttachmentGridListItem.vue │ │ │ ├── AttachmentImagePreview.vue │ │ │ └── AttachmentPermalinkList.vue │ │ ├── back-to-top/ │ │ │ └── BackToTop.vue │ │ ├── base-app/ │ │ │ └── BaseApp.vue │ │ ├── button/ │ │ │ └── SubmitButton.vue │ │ ├── codemirror/ │ │ │ ├── Codemirror.vue │ │ │ └── supports.ts │ │ ├── common/ │ │ │ └── AppDownloadAlert.vue │ │ ├── dropdown-selector/ │ │ │ └── EditorProviderSelector.vue │ │ ├── editor/ │ │ │ └── DefaultEditor.vue │ │ ├── entity/ │ │ │ └── EntityDropdownItems.vue │ │ ├── entity-fields/ │ │ │ ├── EntityFieldItems.vue │ │ │ └── StatusDotField.vue │ │ ├── filter/ │ │ │ ├── CategoryFilterDropdown.vue │ │ │ ├── FilterCleanButton.vue │ │ │ ├── FilterDropdown.vue │ │ │ ├── FilterTag.vue │ │ │ ├── TagFilterDropdown.vue │ │ │ └── UserFilterDropdown.vue │ │ ├── form/ │ │ │ └── AnnotationsForm.vue │ │ ├── global-search/ │ │ │ └── GlobalSearchModal.vue │ │ ├── icon/ │ │ │ └── AttachmentFileTypeIcon.vue │ │ ├── input/ │ │ │ └── SearchInput.vue │ │ ├── menu/ │ │ │ ├── MenuLoading.vue │ │ │ └── RoutesMenu.tsx │ │ ├── permission/ │ │ │ └── HasPermission.vue │ │ ├── preview/ │ │ │ └── UrlPreviewModal.vue │ │ ├── sticky-block/ │ │ │ └── StickyBlock.vue │ │ ├── upload/ │ │ │ └── UppyUpload.vue │ │ ├── user/ │ │ │ └── PostContributorList.vue │ │ ├── user-avatar/ │ │ │ ├── UserAvatar.vue │ │ │ └── UserAvatarCropper.vue │ │ └── video/ │ │ └── LazyVideo.vue │ ├── composables/ │ │ ├── use-auto-save-content.ts │ │ ├── use-content-cache.ts │ │ ├── use-editor-extension-points.ts │ │ ├── use-role.ts │ │ ├── use-route-menu-generator.ts │ │ ├── use-session-keep-alive.ts │ │ └── use-title.ts │ ├── constants/ │ │ ├── annotations.ts │ │ ├── app.ts │ │ ├── constants.ts │ │ ├── error-types.ts │ │ ├── finalizers.ts │ │ ├── labels.ts │ │ └── regex.ts │ ├── formkit/ │ │ ├── formkit.config.ts │ │ ├── inputs/ │ │ │ ├── array/ │ │ │ │ ├── ArrayFormModal.vue │ │ │ │ ├── ArrayInput.vue │ │ │ │ ├── index.ts │ │ │ │ ├── labels/ │ │ │ │ │ ├── ColorLabel.vue │ │ │ │ │ └── IconifyLabel.vue │ │ │ │ ├── renderers/ │ │ │ │ │ ├── attachment.ts │ │ │ │ │ ├── category-select.ts │ │ │ │ │ ├── checkbox.ts │ │ │ │ │ ├── helpers/ │ │ │ │ │ │ └── findOption.ts │ │ │ │ │ ├── iconify.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── native-select.ts │ │ │ │ │ ├── radio.ts │ │ │ │ │ ├── select.ts │ │ │ │ │ ├── tag-select.ts │ │ │ │ │ ├── toggle.ts │ │ │ │ │ └── types.ts │ │ │ │ └── sections/ │ │ │ │ └── index.ts │ │ │ ├── attachment/ │ │ │ │ ├── AttachmentDropdownItem.vue │ │ │ │ ├── AttachmentInput.vue │ │ │ │ ├── AttachmentPreview.vue │ │ │ │ ├── CustomLinkDropdownItem.vue │ │ │ │ ├── UploadDropdownItem.vue │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── attachment-group-select.ts │ │ │ ├── attachment-input/ │ │ │ │ ├── AttachmentInput.vue │ │ │ │ └── index.ts │ │ │ ├── attachment-policy-select.ts │ │ │ ├── category-checkbox.ts │ │ │ ├── category-select/ │ │ │ │ ├── CategorySelect.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── CategoryListItem.vue │ │ │ │ │ ├── CategoryTag.vue │ │ │ │ │ └── SearchResultListItem.vue │ │ │ │ ├── index.ts │ │ │ │ └── sections/ │ │ │ │ └── index.ts │ │ │ ├── code/ │ │ │ │ ├── CodeInput.vue │ │ │ │ └── index.ts │ │ │ ├── color/ │ │ │ │ ├── ColorInput.vue │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── form.ts │ │ │ ├── group.ts │ │ │ ├── iconify/ │ │ │ │ ├── Collections.vue │ │ │ │ ├── CollectionsView.vue │ │ │ │ ├── Icon.vue │ │ │ │ ├── IconConfirmPanel.vue │ │ │ │ ├── IconifyInput.vue │ │ │ │ ├── IconifyPicker.vue │ │ │ │ ├── Icons.vue │ │ │ │ ├── SearchView.vue │ │ │ │ ├── api.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── list/ │ │ │ │ ├── AddButton.vue │ │ │ │ ├── features/ │ │ │ │ │ └── lists.ts │ │ │ │ ├── index.ts │ │ │ │ ├── listSection.ts │ │ │ │ └── sections/ │ │ │ │ └── index.ts │ │ │ ├── menu-checkbox.ts │ │ │ ├── menu-item-select.ts │ │ │ ├── menu-radio.ts │ │ │ ├── menu-select.ts │ │ │ ├── password/ │ │ │ │ ├── RevealButton.vue │ │ │ │ └── index.ts │ │ │ ├── post-select.ts │ │ │ ├── repeater/ │ │ │ │ ├── AddButton.vue │ │ │ │ ├── features/ │ │ │ │ │ └── repeats.ts │ │ │ │ ├── index.ts │ │ │ │ ├── repeaterSection.ts │ │ │ │ └── sections/ │ │ │ │ └── index.ts │ │ │ ├── role-select.ts │ │ │ ├── secret/ │ │ │ │ ├── SecretSelect.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── SecretCreationModal.vue │ │ │ │ │ ├── SecretEditModal.vue │ │ │ │ │ ├── SecretForm.vue │ │ │ │ │ ├── SecretListItem.vue │ │ │ │ │ └── SecretListModal.vue │ │ │ │ ├── composables/ │ │ │ │ │ └── use-secrets-fetch.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ ├── sections/ │ │ │ │ │ └── index.ts │ │ │ │ └── types/ │ │ │ │ └── index.ts │ │ │ ├── select/ │ │ │ │ ├── MultipleOverflow.vue │ │ │ │ ├── MultipleOverflowItem.vue │ │ │ │ ├── MultipleSelect.vue │ │ │ │ ├── MultipleSelectItem.vue │ │ │ │ ├── MultipleSelectSearchInput.vue │ │ │ │ ├── MultipleSelectSelector.vue │ │ │ │ ├── SelectContainer.vue │ │ │ │ ├── SelectDropdownContainer.vue │ │ │ │ ├── SelectMain.vue │ │ │ │ ├── SelectOption.vue │ │ │ │ ├── SelectOptionItem.vue │ │ │ │ ├── SelectSearchInput.vue │ │ │ │ ├── SelectSelector.vue │ │ │ │ ├── index.ts │ │ │ │ ├── isFalse.ts │ │ │ │ └── sections/ │ │ │ │ └── index.ts │ │ │ ├── singlePage-select.ts │ │ │ ├── switch/ │ │ │ │ ├── SwitchInput.vue │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── tag-checkbox.ts │ │ │ ├── tag-select/ │ │ │ │ ├── TagSelect.vue │ │ │ │ ├── index.ts │ │ │ │ └── sections/ │ │ │ │ └── index.ts │ │ │ ├── toggle/ │ │ │ │ ├── ToggleInput.vue │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── user-select.ts │ │ │ └── verify-form/ │ │ │ ├── VerificationButton.vue │ │ │ ├── features/ │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── plugins/ │ │ │ ├── auto-scroll-to-errors.ts │ │ │ ├── password-prevent-autocomplete.ts │ │ │ ├── radio-alt.ts │ │ │ ├── required-asterisk.ts │ │ │ └── stop-implicit-submission.ts │ │ ├── theme.ts │ │ └── utils/ │ │ └── focus.ts │ ├── layouts/ │ │ ├── MobileMenu.vue │ │ └── UserProfileBanner.vue │ ├── locales/ │ │ ├── en.json │ │ ├── es.json │ │ ├── index.ts │ │ ├── zh-CN.json │ │ └── zh-TW.json │ ├── router/ │ │ └── process-bar.ts │ ├── setup/ │ │ ├── setupApiClient.ts │ │ ├── setupComponents.ts │ │ ├── setupModules.ts │ │ ├── setupStyles.ts │ │ ├── setupUserPermissions.ts │ │ └── setupVueQuery.ts │ ├── stores/ │ │ ├── plugin.ts │ │ └── role.ts │ ├── styles/ │ │ ├── index.css │ │ └── tailwind.css │ ├── utils/ │ │ ├── __tests__/ │ │ │ └── media-type.spec.ts │ │ ├── cookie.ts │ │ ├── device.ts │ │ ├── image.ts │ │ ├── load-style.ts │ │ ├── media-type.ts │ │ ├── modal.ts │ │ └── role.ts │ ├── views/ │ │ └── exceptions/ │ │ ├── Forbidden.vue │ │ ├── NotFound.vue │ │ ├── __tests__/ │ │ │ └── NotFound.spec.ts │ │ └── components/ │ │ └── Exception.vue │ └── vite/ │ ├── library-external.ts │ └── plugin-dev.ts ├── tailwind.config.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.vitest.json ├── uc-src/ │ ├── App.vue │ ├── layouts/ │ │ └── BasicLayout.vue │ ├── main.ts │ ├── modules/ │ │ ├── contents/ │ │ │ ├── attachments/ │ │ │ │ ├── components/ │ │ │ │ │ ├── AttachmentDetailModal.vue │ │ │ │ │ ├── AttachmentSelectorModal.vue │ │ │ │ │ └── selector-providers/ │ │ │ │ │ ├── AttachmentUploadModal.vue │ │ │ │ │ ├── CoreSelectorProvider.vue │ │ │ │ │ └── components/ │ │ │ │ │ └── AttachmentSelectorListItem.vue │ │ │ │ └── module.ts │ │ │ └── posts/ │ │ │ ├── PostEditor.vue │ │ │ ├── PostList.vue │ │ │ ├── components/ │ │ │ │ ├── PostCreationModal.vue │ │ │ │ ├── PostListItem.vue │ │ │ │ ├── PostSettingEditModal.vue │ │ │ │ └── PostSettingForm.vue │ │ │ ├── composables/ │ │ │ │ ├── use-post-publish-mutate.ts │ │ │ │ └── use-post-update-mutate.ts │ │ │ ├── module.ts │ │ │ └── types/ │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── notifications/ │ │ │ ├── Notifications.vue │ │ │ ├── components/ │ │ │ │ ├── NotificationContent.vue │ │ │ │ └── NotificationListItem.vue │ │ │ └── module.ts │ │ └── profile/ │ │ ├── Profile.vue │ │ ├── components/ │ │ │ ├── EmailVerifyModal.vue │ │ │ ├── PasswordChangeModal.vue │ │ │ ├── PersonalAccessTokenCreationModal.vue │ │ │ ├── PersonalAccessTokenListItem.vue │ │ │ └── ProfileEditingModal.vue │ │ ├── module.ts │ │ └── tabs/ │ │ ├── Authentication.vue │ │ ├── Detail.vue │ │ ├── Devices.vue │ │ ├── NotificationPreferences.vue │ │ ├── PersonalAccessTokens.vue │ │ ├── components/ │ │ │ ├── AuthProviders.vue │ │ │ ├── DeviceDetailModal.vue │ │ │ ├── DeviceListItem.vue │ │ │ ├── PasswordValidationForm.vue │ │ │ ├── TotpConfigureModal.vue │ │ │ ├── TotpDeletionModal.vue │ │ │ ├── TwoFactor.vue │ │ │ ├── TwoFactorDisableModal.vue │ │ │ └── TwoFactorEnableModal.vue │ │ └── composables/ │ │ ├── use-user-agent.ts │ │ └── use-user-device.ts │ └── router/ │ ├── constant.ts │ ├── guards/ │ │ ├── auth-check.ts │ │ └── permission.ts │ ├── index.ts │ └── routes.config.ts ├── uc.html └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ ui .github .git ================================================ FILE: .editorconfig ================================================ root=true [*] charset = utf-8 end_of_line = lf indent_size = 4 indent_style = space insert_final_newline = false max_line_length = 120 tab_width = 4 ij_continuation_indent_size = 8 ij_formatter_off_tag = @formatter:off ij_formatter_on_tag = @formatter:on ij_formatter_tags_enabled = true ij_smart_tabs = false ij_wrap_on_typing = false [*.java] max_line_length = 100 ij_continuation_indent_size = 4 ij_java_align_consecutive_assignments = false ij_java_align_consecutive_variable_declarations = false ij_java_align_group_field_declarations = false ij_java_align_multiline_annotation_parameters = false ij_java_align_multiline_array_initializer_expression = false ij_java_align_multiline_assignment = false ij_java_align_multiline_binary_operation = false ij_java_align_multiline_chained_methods = false ij_java_align_multiline_extends_list = false ij_java_align_multiline_for = true ij_java_align_multiline_method_parentheses = false ij_java_align_multiline_parameters = false ij_java_align_multiline_parameters_in_calls = false ij_java_align_multiline_parenthesized_expression = false ij_java_align_multiline_records = true ij_java_align_multiline_resources = true ij_java_align_multiline_ternary_operation = false ij_java_align_multiline_text_blocks = false ij_java_align_multiline_throws_list = false ij_java_align_subsequent_simple_methods = false ij_java_align_throws_keyword = false ij_java_annotation_parameter_wrap = off ij_java_array_initializer_new_line_after_left_brace = false ij_java_array_initializer_right_brace_on_new_line = false ij_java_array_initializer_wrap = normal ij_java_assert_statement_colon_on_next_line = false ij_java_assert_statement_wrap = normal ij_java_assignment_wrap = normal ij_java_binary_operation_sign_on_next_line = true ij_java_binary_operation_wrap = normal ij_java_blank_lines_after_anonymous_class_header = 0 ij_java_blank_lines_after_class_header = 0 ij_java_blank_lines_after_imports = 1 ij_java_blank_lines_after_package = 1 ij_java_blank_lines_around_class = 1 ij_java_blank_lines_around_field = 0 ij_java_blank_lines_around_field_in_interface = 0 ij_java_blank_lines_around_initializer = 1 ij_java_blank_lines_around_method = 1 ij_java_blank_lines_around_method_in_interface = 1 ij_java_blank_lines_before_class_end = 0 ij_java_blank_lines_before_imports = 0 ij_java_blank_lines_before_method_body = 0 ij_java_blank_lines_before_package = 1 ij_java_block_brace_style = end_of_line ij_java_block_comment_at_first_column = false ij_java_call_parameters_new_line_after_left_paren = false ij_java_call_parameters_right_paren_on_new_line = false ij_java_call_parameters_wrap = normal ij_java_case_statement_on_separate_line = true ij_java_catch_on_new_line = false ij_java_class_annotation_wrap = split_into_lines ij_java_class_brace_style = end_of_line ij_java_class_count_to_use_import_on_demand = 999 ij_java_class_names_in_javadoc = 1 ij_java_do_not_indent_top_level_class_members = false ij_java_do_not_wrap_after_single_annotation = false ij_java_do_while_brace_force = always ij_java_doc_add_blank_line_after_description = true ij_java_doc_add_blank_line_after_param_comments = false ij_java_doc_add_blank_line_after_return = false ij_java_doc_add_p_tag_on_empty_lines = true ij_java_doc_align_exception_comments = true ij_java_doc_align_param_comments = false ij_java_doc_do_not_wrap_if_one_line = false ij_java_doc_enable_formatting = true ij_java_doc_enable_leading_asterisks = true ij_java_doc_indent_on_continuation = false ij_java_doc_keep_empty_lines = true ij_java_doc_keep_empty_parameter_tag = true ij_java_doc_keep_empty_return_tag = true ij_java_doc_keep_empty_throws_tag = true ij_java_doc_keep_invalid_tags = true ij_java_doc_param_description_on_new_line = false ij_java_doc_preserve_line_breaks = false ij_java_doc_use_throws_not_exception_tag = true ij_java_else_on_new_line = false ij_java_enum_constants_wrap = normal ij_java_extends_keyword_wrap = normal ij_java_extends_list_wrap = normal ij_java_field_annotation_wrap = split_into_lines ij_java_finally_on_new_line = false ij_java_for_brace_force = always ij_java_for_statement_new_line_after_left_paren = false ij_java_for_statement_right_paren_on_new_line = false ij_java_for_statement_wrap = normal ij_java_generate_final_locals = false ij_java_generate_final_parameters = false ij_java_if_brace_force = always ij_java_imports_layout = $*, |, *, |, * ij_java_indent_case_from_switch = true ij_java_insert_inner_class_imports = false ij_java_insert_override_annotation = true ij_java_keep_blank_lines_before_right_brace = 2 ij_java_keep_blank_lines_between_package_declaration_and_header = 2 ij_java_keep_blank_lines_in_code = 2 ij_java_keep_blank_lines_in_declarations = 2 ij_java_keep_control_statement_in_one_line = true ij_java_keep_first_column_comment = true ij_java_keep_indents_on_empty_lines = false ij_java_keep_line_breaks = true ij_java_keep_multiple_expressions_in_one_line = false ij_java_keep_simple_blocks_in_one_line = false ij_java_keep_simple_classes_in_one_line = false ij_java_keep_simple_lambdas_in_one_line = false ij_java_keep_simple_methods_in_one_line = false ij_java_label_indent_absolute = false ij_java_label_indent_size = 0 ij_java_lambda_brace_style = end_of_line ij_java_layout_static_imports_separately = true ij_java_line_comment_add_space = true ij_java_line_comment_at_first_column = false ij_java_method_annotation_wrap = split_into_lines ij_java_method_brace_style = end_of_line ij_java_method_call_chain_wrap = normal ij_java_method_parameters_new_line_after_left_paren = false ij_java_method_parameters_right_paren_on_new_line = false ij_java_method_parameters_wrap = normal ij_java_modifier_list_wrap = false ij_java_names_count_to_use_import_on_demand = 999 ij_java_new_line_after_lparen_in_record_header = false ij_java_parameter_annotation_wrap = normal ij_java_parentheses_expression_new_line_after_left_paren = false ij_java_parentheses_expression_right_paren_on_new_line = false ij_java_place_assignment_sign_on_next_line = false ij_java_prefer_longer_names = true ij_java_prefer_parameters_wrap = false ij_java_record_components_wrap = normal ij_java_repeat_synchronized = true ij_java_replace_instanceof_and_cast = false ij_java_replace_null_check = true ij_java_replace_sum_lambda_with_method_ref = true ij_java_resource_list_new_line_after_left_paren = false ij_java_resource_list_right_paren_on_new_line = false ij_java_resource_list_wrap = normal ij_java_rparen_on_new_line_in_record_header = false ij_java_space_after_closing_angle_bracket_in_type_argument = false ij_java_space_after_colon = true ij_java_space_after_comma = true ij_java_space_after_comma_in_type_arguments = true ij_java_space_after_for_semicolon = true ij_java_space_after_quest = true ij_java_space_after_type_cast = true ij_java_space_before_annotation_array_initializer_left_brace = false ij_java_space_before_annotation_parameter_list = false ij_java_space_before_array_initializer_left_brace = true ij_java_space_before_catch_keyword = true ij_java_space_before_catch_left_brace = true ij_java_space_before_catch_parentheses = true ij_java_space_before_class_left_brace = true ij_java_space_before_colon = true ij_java_space_before_colon_in_foreach = true ij_java_space_before_comma = false ij_java_space_before_do_left_brace = true ij_java_space_before_else_keyword = true ij_java_space_before_else_left_brace = true ij_java_space_before_finally_keyword = true ij_java_space_before_finally_left_brace = true ij_java_space_before_for_left_brace = true ij_java_space_before_for_parentheses = true ij_java_space_before_for_semicolon = false ij_java_space_before_if_left_brace = true ij_java_space_before_if_parentheses = true ij_java_space_before_method_call_parentheses = false ij_java_space_before_method_left_brace = true ij_java_space_before_method_parentheses = false ij_java_space_before_opening_angle_bracket_in_type_parameter = false ij_java_space_before_quest = true ij_java_space_before_switch_left_brace = true ij_java_space_before_switch_parentheses = true ij_java_space_before_synchronized_left_brace = true ij_java_space_before_synchronized_parentheses = true ij_java_space_before_try_left_brace = true ij_java_space_before_try_parentheses = true ij_java_space_before_type_parameter_list = false ij_java_space_before_while_keyword = true ij_java_space_before_while_left_brace = true ij_java_space_before_while_parentheses = true ij_java_space_inside_one_line_enum_braces = false ij_java_space_within_empty_array_initializer_braces = false ij_java_space_within_empty_method_call_parentheses = false ij_java_space_within_empty_method_parentheses = false ij_java_spaces_around_additive_operators = true ij_java_spaces_around_assignment_operators = true ij_java_spaces_around_bitwise_operators = true ij_java_spaces_around_equality_operators = true ij_java_spaces_around_lambda_arrow = true ij_java_spaces_around_logical_operators = true ij_java_spaces_around_method_ref_dbl_colon = false ij_java_spaces_around_multiplicative_operators = true ij_java_spaces_around_relational_operators = true ij_java_spaces_around_shift_operators = true ij_java_spaces_around_type_bounds_in_type_parameters = true ij_java_spaces_around_unary_operator = false ij_java_spaces_within_angle_brackets = false ij_java_spaces_within_annotation_parentheses = false ij_java_spaces_within_array_initializer_braces = false ij_java_spaces_within_braces = false ij_java_spaces_within_brackets = false ij_java_spaces_within_cast_parentheses = false ij_java_spaces_within_catch_parentheses = false ij_java_spaces_within_for_parentheses = false ij_java_spaces_within_if_parentheses = false ij_java_spaces_within_method_call_parentheses = false ij_java_spaces_within_method_parentheses = false ij_java_spaces_within_parentheses = false ij_java_spaces_within_switch_parentheses = false ij_java_spaces_within_synchronized_parentheses = false ij_java_spaces_within_try_parentheses = false ij_java_spaces_within_while_parentheses = false ij_java_special_else_if_treatment = true ij_java_subclass_name_suffix = Impl ij_java_ternary_operation_signs_on_next_line = true ij_java_ternary_operation_wrap = normal ij_java_test_name_suffix = Test ij_java_throws_keyword_wrap = normal ij_java_throws_list_wrap = normal ij_java_use_external_annotations = false ij_java_use_fq_class_names = false ij_java_use_relative_indents = false ij_java_use_single_class_imports = true ij_java_variable_annotation_wrap = normal ij_java_visibility = public ij_java_while_brace_force = always ij_java_while_on_new_line = false ij_java_wrap_comments = false ij_java_wrap_first_method_in_call_chain = false ij_java_wrap_long_lines = true [*.properties] ij_properties_align_group_field_declarations = false ij_properties_keep_blank_lines = false ij_properties_key_value_delimiter = equals ij_properties_spaces_around_key_value_delimiter = false [.editorconfig] ij_editorconfig_align_group_field_declarations = false ij_editorconfig_space_after_colon = false ij_editorconfig_space_after_comma = true ij_editorconfig_space_before_colon = false ij_editorconfig_space_before_comma = false ij_editorconfig_spaces_around_assignment_operators = true [{*.ant, *.fxml, *.jhm, *.jnlp, *.jrxml, *.jspx, *.pom, *.rng, *.tagx, *.tld, *.wsdl, *.xml, *.xsd, *.xsl, *.xslt, *.xul}] ij_xml_align_attributes = true ij_xml_align_text = false ij_xml_attribute_wrap = normal ij_xml_block_comment_at_first_column = true ij_xml_keep_blank_lines = 2 ij_xml_keep_indents_on_empty_lines = false ij_xml_keep_line_breaks = true ij_xml_keep_line_breaks_in_text = true ij_xml_keep_whitespaces = false ij_xml_keep_whitespaces_around_cdata = preserve ij_xml_keep_whitespaces_inside_cdata = false ij_xml_line_comment_at_first_column = true ij_xml_space_after_tag_name = false ij_xml_space_around_equals_in_attribute = false ij_xml_space_inside_empty_tag = false ij_xml_text_wrap = normal [{*.bash, *.sh, *.zsh}] indent_size = 2 tab_width = 2 ij_shell_binary_ops_start_line = false ij_shell_keep_column_alignment_padding = false ij_shell_minify_program = false ij_shell_redirect_followed_by_space = false ij_shell_switch_cases_indented = false [{*.gant, *.gradle, *.groovy, *.gy}] ij_groovy_align_group_field_declarations = false ij_groovy_align_multiline_array_initializer_expression = false ij_groovy_align_multiline_assignment = false ij_groovy_align_multiline_binary_operation = false ij_groovy_align_multiline_chained_methods = false ij_groovy_align_multiline_extends_list = false ij_groovy_align_multiline_for = true ij_groovy_align_multiline_list_or_map = true ij_groovy_align_multiline_method_parentheses = false ij_groovy_align_multiline_parameters = true ij_groovy_align_multiline_parameters_in_calls = false ij_groovy_align_multiline_resources = true ij_groovy_align_multiline_ternary_operation = false ij_groovy_align_multiline_throws_list = false ij_groovy_align_named_args_in_map = true ij_groovy_align_throws_keyword = false ij_groovy_array_initializer_new_line_after_left_brace = false ij_groovy_array_initializer_right_brace_on_new_line = false ij_groovy_array_initializer_wrap = off ij_groovy_assert_statement_wrap = off ij_groovy_assignment_wrap = off ij_groovy_binary_operation_wrap = off ij_groovy_blank_lines_after_class_header = 0 ij_groovy_blank_lines_after_imports = 1 ij_groovy_blank_lines_after_package = 1 ij_groovy_blank_lines_around_class = 1 ij_groovy_blank_lines_around_field = 0 ij_groovy_blank_lines_around_field_in_interface = 0 ij_groovy_blank_lines_around_method = 1 ij_groovy_blank_lines_around_method_in_interface = 1 ij_groovy_blank_lines_before_imports = 1 ij_groovy_blank_lines_before_method_body = 0 ij_groovy_blank_lines_before_package = 0 ij_groovy_block_brace_style = end_of_line ij_groovy_block_comment_at_first_column = true ij_groovy_call_parameters_new_line_after_left_paren = false ij_groovy_call_parameters_right_paren_on_new_line = false ij_groovy_call_parameters_wrap = off ij_groovy_catch_on_new_line = false ij_groovy_class_annotation_wrap = split_into_lines ij_groovy_class_brace_style = end_of_line ij_groovy_class_count_to_use_import_on_demand = 5 ij_groovy_do_while_brace_force = never ij_groovy_else_on_new_line = false ij_groovy_enum_constants_wrap = off ij_groovy_extends_keyword_wrap = off ij_groovy_extends_list_wrap = off ij_groovy_field_annotation_wrap = split_into_lines ij_groovy_finally_on_new_line = false ij_groovy_for_brace_force = never ij_groovy_for_statement_new_line_after_left_paren = false ij_groovy_for_statement_right_paren_on_new_line = false ij_groovy_for_statement_wrap = off ij_groovy_if_brace_force = never ij_groovy_import_annotation_wrap = 2 ij_groovy_indent_case_from_switch = true ij_groovy_indent_label_blocks = true ij_groovy_insert_inner_class_imports = false ij_groovy_keep_blank_lines_before_right_brace = 2 ij_groovy_keep_blank_lines_in_code = 2 ij_groovy_keep_blank_lines_in_declarations = 2 ij_groovy_keep_control_statement_in_one_line = true ij_groovy_keep_first_column_comment = true ij_groovy_keep_indents_on_empty_lines = false ij_groovy_keep_line_breaks = true ij_groovy_keep_multiple_expressions_in_one_line = false ij_groovy_keep_simple_blocks_in_one_line = false ij_groovy_keep_simple_classes_in_one_line = true ij_groovy_keep_simple_lambdas_in_one_line = true ij_groovy_keep_simple_methods_in_one_line = true ij_groovy_label_indent_absolute = false ij_groovy_label_indent_size = 0 ij_groovy_lambda_brace_style = end_of_line ij_groovy_layout_static_imports_separately = true ij_groovy_line_comment_add_space = false ij_groovy_line_comment_at_first_column = true ij_groovy_method_annotation_wrap = split_into_lines ij_groovy_method_brace_style = end_of_line ij_groovy_method_call_chain_wrap = off ij_groovy_method_parameters_new_line_after_left_paren = false ij_groovy_method_parameters_right_paren_on_new_line = false ij_groovy_method_parameters_wrap = off ij_groovy_modifier_list_wrap = false ij_groovy_names_count_to_use_import_on_demand = 3 ij_groovy_parameter_annotation_wrap = off ij_groovy_parentheses_expression_new_line_after_left_paren = false ij_groovy_parentheses_expression_right_paren_on_new_line = false ij_groovy_prefer_parameters_wrap = false ij_groovy_resource_list_new_line_after_left_paren = false ij_groovy_resource_list_right_paren_on_new_line = false ij_groovy_resource_list_wrap = off ij_groovy_space_after_assert_separator = true ij_groovy_space_after_colon = true ij_groovy_space_after_comma = true ij_groovy_space_after_comma_in_type_arguments = true ij_groovy_space_after_for_semicolon = true ij_groovy_space_after_quest = true ij_groovy_space_after_type_cast = true ij_groovy_space_before_annotation_parameter_list = false ij_groovy_space_before_array_initializer_left_brace = false ij_groovy_space_before_assert_separator = false ij_groovy_space_before_catch_keyword = true ij_groovy_space_before_catch_left_brace = true ij_groovy_space_before_catch_parentheses = true ij_groovy_space_before_class_left_brace = true ij_groovy_space_before_closure_left_brace = true ij_groovy_space_before_colon = true ij_groovy_space_before_comma = false ij_groovy_space_before_do_left_brace = true ij_groovy_space_before_else_keyword = true ij_groovy_space_before_else_left_brace = true ij_groovy_space_before_finally_keyword = true ij_groovy_space_before_finally_left_brace = true ij_groovy_space_before_for_left_brace = true ij_groovy_space_before_for_parentheses = true ij_groovy_space_before_for_semicolon = false ij_groovy_space_before_if_left_brace = true ij_groovy_space_before_if_parentheses = true ij_groovy_space_before_method_call_parentheses = false ij_groovy_space_before_method_left_brace = true ij_groovy_space_before_method_parentheses = false ij_groovy_space_before_quest = true ij_groovy_space_before_switch_left_brace = true ij_groovy_space_before_switch_parentheses = true ij_groovy_space_before_synchronized_left_brace = true ij_groovy_space_before_synchronized_parentheses = true ij_groovy_space_before_try_left_brace = true ij_groovy_space_before_try_parentheses = true ij_groovy_space_before_while_keyword = true ij_groovy_space_before_while_left_brace = true ij_groovy_space_before_while_parentheses = true ij_groovy_space_in_named_argument = true ij_groovy_space_in_named_argument_before_colon = false ij_groovy_space_within_empty_array_initializer_braces = false ij_groovy_space_within_empty_method_call_parentheses = false ij_groovy_spaces_around_additive_operators = true ij_groovy_spaces_around_assignment_operators = true ij_groovy_spaces_around_bitwise_operators = true ij_groovy_spaces_around_equality_operators = true ij_groovy_spaces_around_lambda_arrow = true ij_groovy_spaces_around_logical_operators = true ij_groovy_spaces_around_multiplicative_operators = true ij_groovy_spaces_around_regex_operators = true ij_groovy_spaces_around_relational_operators = true ij_groovy_spaces_around_shift_operators = true ij_groovy_spaces_within_annotation_parentheses = false ij_groovy_spaces_within_array_initializer_braces = false ij_groovy_spaces_within_braces = true ij_groovy_spaces_within_brackets = false ij_groovy_spaces_within_cast_parentheses = false ij_groovy_spaces_within_catch_parentheses = false ij_groovy_spaces_within_for_parentheses = false ij_groovy_spaces_within_gstring_injection_braces = false ij_groovy_spaces_within_if_parentheses = false ij_groovy_spaces_within_list_or_map = false ij_groovy_spaces_within_method_call_parentheses = false ij_groovy_spaces_within_method_parentheses = false ij_groovy_spaces_within_parentheses = false ij_groovy_spaces_within_switch_parentheses = false ij_groovy_spaces_within_synchronized_parentheses = false ij_groovy_spaces_within_try_parentheses = false ij_groovy_spaces_within_tuple_expression = false ij_groovy_spaces_within_while_parentheses = false ij_groovy_special_else_if_treatment = true ij_groovy_ternary_operation_wrap = off ij_groovy_throws_keyword_wrap = off ij_groovy_throws_list_wrap = off ij_groovy_use_flying_geese_braces = false ij_groovy_use_fq_class_names = false ij_groovy_use_fq_class_names_in_javadoc = true ij_groovy_use_relative_indents = false ij_groovy_use_single_class_imports = true ij_groovy_variable_annotation_wrap = off ij_groovy_while_brace_force = never ij_groovy_while_on_new_line = false ij_groovy_wrap_long_lines = false [{*.har, *.json}] indent_size = 2 ij_json_keep_blank_lines_in_code = 0 ij_json_keep_indents_on_empty_lines = false ij_json_keep_line_breaks = true ij_json_space_after_colon = true ij_json_space_after_comma = true ij_json_space_before_colon = true ij_json_space_before_comma = false ij_json_spaces_within_braces = false ij_json_spaces_within_brackets = false ij_json_wrap_long_lines = false [{*.htm, *.html, *.sht, *.shtm, *.shtml}] ij_html_add_new_line_before_tags = body, div, p, form, h1, h2, h3 ij_html_align_attributes = true ij_html_align_text = false ij_html_attribute_wrap = normal ij_html_block_comment_at_first_column = true ij_html_do_not_align_children_of_min_lines = 0 ij_html_do_not_break_if_inline_tags = title, h1, h2, h3, h4, h5, h6, p ij_html_do_not_indent_children_of_tags = html, body, thead, tbody, tfoot ij_html_enforce_quotes = false ij_html_inline_tags = a, abbr, acronym, b, basefont, bdo, big, br, cite, cite, code, dfn, em, font, i, img, input, kbd, label, q, s, samp, select, small, span, strike, strong, sub, sup, textarea, tt, u, var ij_html_keep_blank_lines = 2 ij_html_keep_indents_on_empty_lines = false ij_html_keep_line_breaks = true ij_html_keep_line_breaks_in_text = true ij_html_keep_whitespaces = false ij_html_keep_whitespaces_inside = span, pre, textarea ij_html_line_comment_at_first_column = true ij_html_new_line_after_last_attribute = never ij_html_new_line_before_first_attribute = never ij_html_quote_style = double ij_html_remove_new_line_before_tags = br ij_html_space_after_tag_name = false ij_html_space_around_equality_in_attribute = false ij_html_space_inside_empty_tag = false ij_html_text_wrap = normal [{*.yaml, *.yml}] indent_size = 2 ij_yaml_keep_indents_on_empty_lines = false ij_yaml_keep_line_breaks = true [*.md] indent_size = 2 ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.en.yml ================================================ name: Bug Report description: File a bug report labels: [bug] body: - type: checkboxes id: preface attributes: label: Prerequisites description: Thank you for taking the time to fill out this issue report! Before we begin, we highly recommend reading through the [Open Source Guides](https://opensource.guide/), which will greatly improve our mutual efficiency. options: - label: I have searched for related issues in the [issues](https://github.com/halo-dev/halo/issues) list. required: true - label: "This is an issue with the Halo project itself. If it is not an issue with the project itself(For example: Installation and deployment issues.), it is recommended to submit it in the [Discussions](https://github.com/halo-dev/halo/discussions)." required: true - label: I have tried disabling all plugins to rule out plugins as the cause of the problem. required: true - label: If it is an issue with plugins and themes, please submit it in the respective plugin and theme repositories. required: true - type: markdown id: environment attributes: value: "## Environment" - type: textarea id: system-information attributes: label: "System information" description: "Access the actuator page of the Console, click the copy button in the upper right corner, and paste the information here." placeholder: | - External url: https://demo.halo.run - Start time: 2024-07-21 14:50 - Version: 2.x.x - Build time: 2024-07-15 18:19 - Git Commit: 6d4bedd - Java: IBM Semeru Runtime Open Edition / ... - Database: PostgreSQL / 16.3 ... - Operating system: Linux / 5.15.0-88 ... - Activated theme: ... - Enabled plugins: - ... validations: required: true - type: dropdown id: operation-method validations: required: true attributes: label: "What is the project operation method?" options: - Docker - Docker Compose - Fat Jar - Source Code - type: markdown id: details attributes: value: "## Details" - type: textarea id: what-happened attributes: label: "What happened?" description: "For ease of management, please do not report multiple unrelated issues under the same issue." validations: required: true - type: textarea id: reproduce-steps attributes: label: "Reproduce Steps" description: "If it can be consistently reproduced, please provide detailed steps." placeholder: | 1. Open '...' 2. Click '...' - type: textarea id: logs attributes: label: "Relevant log output" description: "Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks." render: shell - type: textarea id: additional-information attributes: label: "Additional information" description: "If you have other information to note, you can fill it in here (screenshots, videos, etc.)." ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.zh.yml ================================================ name: Bug 反馈 description: 提交 Bug 反馈 labels: [bug] body: - type: checkboxes id: preface attributes: label: 前置条件 description: 感谢你花时间填写此错误报告!在开始之前,我们非常推荐阅读一遍[《开源软件指南》](https://opensource.guide/zh-hans/),这会在很大程度上提高我们彼此的效率。 options: - label: 已经在 [issues](https://github.com/halo-dev/halo/issues) 列表中搜索了相关问题。 required: true - label: 这是 Halo 项目本身存在的问题,如果是非项目本身的问题(如:安装部署问题),建议在 [Discussions](https://github.com/halo-dev/halo/discussions) 提交。 required: true - label: 已经尝试过停用所有的插件,排除是插件导致的问题。 required: true - label: 如果是插件和主题的问题,请在对应的插件和主题仓库提交。 required: true - type: markdown id: environment attributes: value: "## 环境信息" - type: textarea id: system-information attributes: label: "系统信息" description: "访问 Console 的概览页面,点击右上角的复制按钮,将信息粘贴到此处。" placeholder: | - 外部访问地址: https://demo.halo.run - 启动时间: 2024-07-21 14:50 - 版本: 2.x.x - 构建时间: 2024-07-15 18:19 - Git Commit: 6d4bedd - Java: IBM Semeru Runtime Open Edition / ... - 数据库: PostgreSQL / 16.3 ... - 操作系统: Linux / 5.15.0-88 ... - 已激活主题: ... - 已启动插件: - ... validations: required: true - type: dropdown id: operation-method validations: required: true attributes: label: "使用的哪种方式运行?" options: - Docker - Docker Compose - Fat Jar - Source Code - type: markdown id: details attributes: value: "## 详细信息" - type: textarea id: what-happened attributes: label: "发生了什么?" description: "为了方便我们管理,请不要在同一个 issue 下报告多个不相关的问题。" validations: required: true - type: textarea id: reproduce-steps attributes: label: "复现步骤" description: "如果可以稳定复现,请提供详细的步骤。" placeholder: | 1. 打开 '...' 2. 点击 '...' - type: textarea id: logs attributes: label: "相关日志输出" description: "请复制并粘贴任何相关的日志输出。这将自动格式化为代码,因此无需反引号。" render: shell - type: textarea id: additional-information attributes: label: "附加信息" description: "如果你还有其他需要提供的信息,可以在这里填写(可以提供截图、视频等)。" ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 商业产品反馈 url: https://github.com/orgs/lxware-dev/discussions about: Halo 付费版以及应用市场商业应用的问题,建议优先在这里反馈。 - name: 对 Halo 有其他问题 url: https://bbs.halo.run about: 如果你对 Halo 有其他想要提问的,欢迎到官方社区进行提问。 ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.en.yml ================================================ name: Feature Request description: File a feature request body: - type: checkboxes id: preface attributes: label: Prerequisites description: Hello! Thank you for submitting a new feature suggestion for Halo. Before we begin, we highly recommend reading through the [Open Source Guides](https://opensource.guide/), which will greatly improve our mutual efficiency. options: - label: I have searched for related issues in the [Issues](https://github.com/halo-dev/halo/issues) list. required: true - label: This is a feature related to Halo. If it is not an issue with the project itself, it is recommended to submit it in the [Discussions](https://github.com/halo-dev/halo/discussions). required: true - label: If it is a feature suggestion for plugins and themes, please submit it in the respective plugin and theme repositories. required: true - type: markdown id: environment attributes: value: "## Environment" - type: input id: version attributes: label: "Your current Halo version" - type: markdown id: details attributes: value: "## Details" - type: textarea id: description attributes: label: "Describe this feature" description: "For ease of management, please do not submit multiple unrelated features under the same issue." validations: required: true - type: textarea id: additional-information attributes: label: "Additional information" description: "If you have other information to note, you can fill it in here (screenshots, videos, etc.)." ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.zh.yml ================================================ name: 新特性建议 description: 提交新特性建议 body: - type: checkboxes id: preface attributes: label: 前置条件 description: 你好!感谢你为 Halo 提交新特性建议。在开始之前,我们非常推荐阅读一遍[《开源软件指南》](https://opensource.guide/zh-hans/),这会在很大程度上提高我们彼此的效率。 options: - label: 已经在 [Issues](https://github.com/halo-dev/halo/issues) 列表中搜索了相关问题。 required: true - label: 这是和 Halo 相关的特性,如果是非项目本身的问题,建议在 [Discussions](https://github.com/halo-dev/halo/discussions) 提交。 required: true - label: 如果是插件和主题特性建议,请在对应的插件和主题仓库提交。 required: true - type: markdown id: environment attributes: value: "## 环境信息" - type: input id: version attributes: label: "你当前使用的版本" - type: markdown id: details attributes: value: "## 详细信息" - type: textarea id: description attributes: label: "描述一下此特性" description: "为了方便我们管理,请不要在同一个 issue 下提交多个没有相关性的特性。" validations: required: true - type: textarea id: additional-information attributes: label: "附加信息" description: "如果你还有其他需要提供的信息,可以在这里填写(可以提供截图、视频等)。" ================================================ FILE: .github/actions/docker-buildx-push/action.yaml ================================================ name: "Docker buildx and push" description: "Buildx and push the Docker image." inputs: ghcr-token: description: Token of current GitHub account in GitHub container registry. required: false default: "" dockerhub-user: description: "User name for the DockerHub account" required: false default: "" dockerhub-token: description: Token for the DockerHub account required: false default: "" f2c-registry-user: description: "User name of Fit2Cloud Docker Registry." required: false default: "" f2c-registry-token: description: "Token of Fit2Cloud Docker Registry." required: false default: "" push: description: Should push the docker image or not. required: false default: "false" platforms: description: Target platforms for building image required: false default: "linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x" image-name: description: The basic name of docker. required: false default: "halo" runs: using: "composite" steps: - name: Docker meta for Halo id: meta uses: docker/metadata-action@v5 with: images: | ghcr.io/${{ github.repository_owner }}/${{ inputs.image-name }} halohub/${{ inputs.image-name }} registry.fit2cloud.com/halo/${{ inputs.image-name }} tags: | type=schedule,pattern=nightly-{{date 'YYYYMMDD'}},enabled=${{ github.event_name == 'schedule' }} type=ref,event=branch,enabled=${{ github.event_name == 'push' }} type=ref,event=pr,enabled=${{ github.event_name == 'pull_request' }} type=semver,pattern={{major}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{ version }} type=sha,enabled=${{ github.event_name == 'push' }} flavor: | latest=false - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 if: inputs.ghcr-token != '' && github.event_name != 'pull_request' with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ inputs.ghcr-token }} - name: Login to DockerHub if: inputs.dockerhub-token != '' && github.event_name != 'pull_request' uses: docker/login-action@v3 with: username: ${{ inputs.dockerhub-user }} password: ${{ inputs.dockerhub-token }} - name: Login to Fit2Cloud Docker Registry if: inputs.f2c-registry-token != '' && github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: registry.fit2cloud.com username: ${{ inputs.f2c-registry-user }} password: ${{ inputs.f2c-registry-token }} - name: Build and push uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile platforms: ${{ inputs.platforms }} labels: ${{ steps.meta.outputs.labels }} tags: ${{ steps.meta.outputs.tags }} push: ${{ (inputs.ghcr-token != '' || inputs.dockerhub-token != '') && inputs.push == 'true' }} ================================================ FILE: .github/actions/setup-env/action.yaml ================================================ name: Setup Environment description: Setup environment to check and build Halo, including console and core projects. inputs: node-version: description: Node.js version. required: false default: "24" pnpm-version: description: pnpm version. required: false default: "10" java-version: description: Java version. required: false default: "21" runs: using: "composite" steps: - uses: pnpm/action-setup@v3 name: Setup pnpm with: version: ${{ inputs.pnpm-version }} - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} cache: "pnpm" cache-dependency-path: "ui/pnpm-lock.yaml" - name: Setup JDK uses: actions/setup-java@v4 with: distribution: "temurin" cache: "gradle" java-version: ${{ inputs.java-version }} ================================================ FILE: .github/pull_request_template.md ================================================ #### What type of PR is this? #### What this PR does / why we need it: #### Which issue(s) this PR fixes: Fixes # #### Special notes for your reviewer: #### Does this PR introduce a user-facing change? ```release-note ``` ================================================ FILE: .github/workflows/halo.yaml ================================================ name: Halo Workflow on: pull_request: branches: - main - release-* paths: - "**" - "!**.md" push: branches: - main - release-* paths: - "**" - "!**.md" release: types: - published concurrency: group: ${{github.workflow}} - ${{github.ref}} cancel-in-progress: true jobs: test: if: github.event_name == 'pull_request' || github.event_name == 'push' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Environment uses: ./.github/actions/setup-env - name: Check Halo run: ./gradlew clean check --configuration-cache --configuration-cache-problems=warn - name: Upload coverage reports to Codecov if: github.repository == 'halo-dev/halo' uses: codecov/codecov-action@v4 build: runs-on: ubuntu-latest if: always() && (needs.test.result == 'skipped' || needs.test.result == 'success') needs: test steps: - uses: actions/checkout@v4 - name: Setup Environment uses: ./.github/actions/setup-env - name: Reset version of Halo if: github.event_name == 'release' shell: bash run: | # Set the version with tag name when releasing version=${{ github.event.release.tag_name }} version=${version#v} sed -i "s/version=.*-SNAPSHOT$/version=$version/1" gradle.properties - name: Build Halo run: ./gradlew clean downloadPluginPresets build -x check --configuration-cache --configuration-cache-problems=warn - name: Upload Artifacts if: github.repository == 'halo-dev/halo' uses: actions/upload-artifact@v4 with: name: halo-artifacts path: application/build/libs retention-days: 1 github-release: runs-on: ubuntu-latest if: always() && needs.build.result == 'success' && github.event_name == 'release' needs: build steps: - uses: actions/checkout@v4 - name: Download Artifacts uses: actions/download-artifact@v4 with: name: halo-artifacts path: application/build/libs - name: Upload Artifacts if: github.repository == 'halo-dev/halo' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release upload ${{ github.event.release.tag_name }} application/build/libs/* build-and-publish-container-image-with-buildpacks: needs: build if: always() && needs.build.result == 'success' && (github.event_name == 'push' || github.event_name == 'release') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Environment uses: ./.github/actions/setup-env - name: Reset version of Halo if: github.event_name == 'release' shell: bash run: | # Set the version with tag name when releasing version=${{ github.event.release.tag_name }} version=${version#v} sed -i "s/version=.*-SNAPSHOT$/version=$version/1" gradle.properties - name: Publish To Container Registries env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} F2C_USERNAME: ${{ secrets.F2C_REGISTRY_USER }} F2C_TOKEN: ${{ secrets.F2C_REGISTRY_TOKEN }} GHCR_USERNAME: ${{ github.repository_owner }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: ./gradlew publishToAllRegistries -Prelease=${{ github.event_name == 'release' && 'true' || 'false' }} docker-build-and-push: if: always() && needs.build.result == 'success' && (github.event_name == 'push' || github.event_name == 'release') runs-on: ubuntu-latest needs: build steps: - uses: actions/checkout@v4 - name: Download Artifacts uses: actions/download-artifact@v4 with: name: halo-artifacts path: application/build/libs - name: Docker Buildx and Push uses: ./.github/actions/docker-buildx-push with: image-name: ${{ github.event_name == 'release' && 'halo' || 'halo-dev' }} ghcr-token: ${{ secrets.GITHUB_TOKEN }} dockerhub-user: ${{ secrets.DOCKER_USERNAME }} dockerhub-token: ${{ secrets.DOCKER_TOKEN }} f2c-registry-user: ${{ secrets.F2C_REGISTRY_USER }} f2c-registry-token: ${{ secrets.F2C_REGISTRY_TOKEN }} push: true platforms: linux/amd64,linux/arm64/v8,linux/ppc64le,linux/s390x e2e-test: if: always() && needs.build.result == 'success' && (github.event_name == 'pull_request' || github.event_name == 'push') runs-on: ubuntu-latest needs: build steps: - uses: actions/checkout@v4 - name: Download Artifacts uses: actions/download-artifact@v4 with: name: halo-artifacts path: application/build/libs - name: Docker Build uses: docker/build-push-action@v5 with: tags: ghcr.io/halo-dev/halo-dev:main push: false context: . - name: E2E Testing continue-on-error: true run: | sudo curl -L https://github.com/docker/compose/releases/download/v2.23.0/docker-compose-linux-x86_64 -o /usr/local/bin/docker-compose sudo chmod u+x /usr/local/bin/docker-compose cd e2e && make all ================================================ FILE: .github/workflows/openapi-check.yaml ================================================ name: OpenAPI Check on: pull_request: branches: - main - release-* paths: - 'application/src/**' - 'api/src/**' - 'api-docs/openapi/**' - 'ui/packages/api-client/**' push: branches: - main - release-* paths: - 'application/src/**' - 'api/src/**' - 'api-docs/openapi/**' - 'ui/packages/api-client/**' concurrency: group: ${{github.workflow}} - ${{github.ref}} cancel-in-progress: true jobs: openapi-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Environment uses: ./.github/actions/setup-env - name: Install UI dependencies run: ./gradlew pnpmInstall - name: Regenerate OpenAPI docs run: ./gradlew generateOpenApiDocs --no-configuration-cache - name: Regenerate api-client run: cd ui && pnpm run api-client:gen - name: Verify OpenAPI docs and api-client are in sync run: | if ! git diff --exit-code -- api-docs/openapi ui/packages/api-client/src; then echo "::error::OpenAPI docs or api-client generated code is out of sync with the current API. Run './gradlew generateOpenApiDocs --no-configuration-cache' and 'cd ui && pnpm run api-client:gen', then commit the changes under api-docs/openapi and ui/packages/api-client/src." git diff --stat api-docs/openapi ui/packages/api-client/src exit 1 fi ================================================ FILE: .github/workflows/packages-preview-release.yaml ================================================ name: "Packages preview release" on: push: paths: - "ui/packages/**" branches: - main pull_request: paths: - "ui/packages/**" branches: - main jobs: packages-preview-release: runs-on: ubuntu-latest if: github.repository == 'halo-dev/halo' steps: - uses: actions/checkout@v4 - name: Setup Environment uses: ./.github/actions/setup-env - name: Install Dependencies run: ./gradlew pnpmInstall - name: Build Packages run: cd ui && pnpm build:packages - name: Release run: cd ui && pnpx pkg-pr-new publish --compact --pnpm './packages/*' ================================================ FILE: .github/workflows/release-ui-packages.yaml ================================================ name: Release UI Packages on: release: types: - published permissions: contents: write id-token: write jobs: release: runs-on: ubuntu-latest if: github.repository == 'halo-dev/halo' && github.event.release.prerelease == false steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Environment uses: ./.github/actions/setup-env - run: ./gradlew pnpmInstall - name: Publish to NPM run: cd ui && pnpm run publish:packages ================================================ FILE: .github/workflows/stale-issues.yaml ================================================ name: Close Stale Issues on: schedule: - cron: "30 1 * * *" workflow_dispatch: permissions: issues: write jobs: stale: runs-on: ubuntu-latest steps: - uses: actions/stale@v10 with: stale-issue-message: | This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs within the next 2 days. If you believe this issue is still relevant, please provide the requested information. close-issue-message: | This issue has been automatically closed due to inactivity. If you have the requested information, feel free to reopen it or create a new issue. days-before-issue-stale: 60 days-before-issue-close: 2 days-before-pr-stale: -1 days-before-pr-close: -1 stale-issue-label: "lifecycle/stale" any-of-issue-labels: "triage/needs-information,priority/awaiting-more-evidence,help wanted" operations-per-run: 100 ascending: true ================================================ FILE: .gitignore ================================================ ### Maven target/ logs/ !.mvn/wrapper/maven-wrapper.jar ### Gradle .gradle build/ out/ !gradle/wrapper/gradle-wrapper.jar bin/ ### STS ### .apt_generated .classpath .factorypath .project .settings .springBeans .sts4-cache ### IntelliJ IDEA ### .idea *.iws *.iml *.ipr log/ ### NetBeans ### nbproject/private/ build/ nbbuild/ dist/ nbdist/ .nb-gradle/ ### Mac .DS_Store */.DS_Store ### VS Code ### *.project *.factorypath ### Compiled class file *.class ### Log file *.log ### BlueJ files *.ctxt ### Mobile Tools for Java (J2ME) .mtj.tmp/ ### Package Files *.war *.nar *.ear *.zip *.tar.gz *.rar ### VSCode .vscode !.vscode/settings.json !.vscode/extensions.json ### Local file application-local.yml application-local.yaml application-local.properties ### Zip file for test !application/src/test/resources/themes/*.zip !application/src/main/resources/themes/*.zip application/src/main/resources/console/ application/src/main/resources/uc/ application/src/main/resources/presets/ ### Node node_modules npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* ### Frontend dist coverage *.local ### Cypress /cypress/videos/ /cypress/screenshots/ ### Frontend build !src/build storybook-static tsconfig.tsbuildinfo .tgz ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at hi@halo.run. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Guide Thank you for your interest in contributing to Halo. This document explains the recommended workflow for submitting high-quality contributions, including code, tests, and documentation updates. ## Before You Start - For new features or major behavior changes, please open an issue first so we can align on scope and design. - For clear bug fixes, you can submit a pull request directly. - If your report is not about the core project itself (for example, deployment questions), please use Discussions instead of Issues. ## Development Environment This repository mainly contains: - Backend and platform modules built with Gradle. - Frontend code in `ui`, managed with `pnpm` workspaces. ### Prerequisites - Git - JDK (version compatible with the project build) - Node.js and `pnpm` (see `ui/package.json` for the current package manager) - Docker / Docker Compose (required for e2e scenarios) ## Contribution Workflow ### 1. Fork and Clone Fork this repository, then clone your fork: ```bash git clone https://github.com/{YOUR_USERNAME}/{REPOSITORY}.git cd {REPOSITORY} ``` ### 2. Add Upstream Remote ```bash git remote add upstream https://github.com/halo-dev/halo.git git fetch upstream ``` ### 3. Create a Branch Use a focused branch name that reflects your change: ```bash git checkout -b feat/short-description ``` ### 4. Implement and Validate Run relevant checks before opening a PR. Backend and general checks: ```bash ./gradlew clean check ``` Frontend checks (in `ui`): ```bash cd ui pnpm install pnpm build:packages pnpm lint pnpm typecheck pnpm test:unit ``` ### 5. Commit and Push ```bash git push origin ``` ### 6. Open a Pull Request Open a PR from your branch to `main` and fill out the PR template carefully: - Describe what changed and why. - Link related issues (for example, `Fixes #123`). - Add release note content or `NONE` when no user-facing change is introduced. - Add proper `/kind` labels as requested in the template. ## AI-Assisted Contribution Policy AI-assisted development is not prohibited, including code generation and refactoring support. However, you are fully responsible for any code in your PR. If you used AI tools, please follow these rules: - Review all AI-generated content before submission. - Verify correctness, security, performance, and maintainability. - Ensure generated code follows project conventions and architecture. - Remove low-quality or redundant generated code. - Mention AI assistance in your PR description when AI materially contributed to the final changes. In short: AI assistance is allowed, but unreviewed AI output is not acceptable. ## Testing Expectations - Add or update tests whenever you change behavior. - If you add or modify APIs, please include corresponding e2e test cases. - See `e2e/README.md` for e2e workflow and local execution details. ## Coding Standards - Follow the project coding style guide: - Keep changes focused and avoid unrelated refactors in the same PR. - Run formatters and linters before pushing. ## Keep Your Fork Updated Before starting new work, sync your branch with upstream: ```bash git fetch upstream git checkout main git merge upstream/main git push origin main ``` ## Need Help? - Open an issue for confirmed bugs and feature proposals. - Use Discussions for general questions and usage/deployment topics. Thanks again for helping improve Halo. ================================================ FILE: Dockerfile ================================================ FROM eclipse-temurin:21-jre as builder WORKDIR application ARG JAR_FILE=application/build/libs/*.jar COPY ${JAR_FILE} application.jar RUN java -Djarmode=layertools -jar application.jar extract ################################ FROM ibm-semeru-runtimes:open-21-jre LABEL maintainer="johnniang " WORKDIR application COPY --from=builder application/dependencies/ ./ COPY --from=builder application/spring-boot-loader/ ./ COPY --from=builder application/snapshot-dependencies/ ./ COPY --from=builder application/application/ ./ ENV JVM_OPTS="" \ HALO_WORK_DIR="/root/.halo2" \ SPRING_CONFIG_LOCATION="optional:classpath:/;optional:file:/root/.halo2/" \ TZ=Asia/Shanghai RUN ln -sf /usr/share/zoneinfo/$TZ /etc/localtime \ && echo $TZ > /etc/timezone Expose 8090 ENTRYPOINT ["sh", "-c", "java ${JVM_OPTS} org.springframework.boot.loader.launch.JarLauncher ${0} ${@}"] ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: OWNERS ================================================ approvers: - ruibaby - guqing - JohnNiang - LIlGG ================================================ FILE: README.md ================================================

Halo logo

Halo [ˈheɪloʊ],强大易用的开源建站工具。

GitHub release Docker pulls GitHub last commit GitHub Workflow Status Codecov percentage GitCode Stars Halo - Powerful and easy-to-use Open-Source website building tool | Product Hunt
官网 文档 社区 Gitee Telegram 频道

[![Watch the video](https://www.halo.run/upload/halo-github-screenshot.png)](https://www.bilibili.com/video/BV15x4y1U7RU/?share_source=copy_web&vd_source=0ab6cf86ca512a363f04f18b86f55b86) ------------------------------ ## 快速开始 如果你的设备有 Docker 环境,可以使用以下命令快速启动一个 Halo 的体验环境: ```bash docker run -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.22 ``` 或者点击下方按钮使用 [Gitpod](https://gitpod.io/) 或 [ClawCloud Run](https://template.us-west-1.run.claw.cloud/deploy?templateName=halo) 启动一个体验环境: [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/halo-sigs/gitpod-demo) [![Run on ClawCloud](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.us-west-1.run.claw.cloud/deploy?templateName=halo) **以上方式仅作为体验使用,推荐使用开源 Linux 服务器运维管理面板 [1Panel](https://github.com/1Panel-dev/1Panel) 进行部署([查看文档](https://docs.halo.run/getting-started/install/1panel)),轻松搞定反向代理、SSL 证书及升级备份任务。更多部署方式,请[查看文档](https://docs.halo.run/category/%E5%AE%89%E8%A3%85%E6%8C%87%E5%8D%97)。** ## 在线体验 - 环境地址: - 后台地址: - 用户名:`demo` - 密码:`P@ssw0rd123..` ## 付费版 相比于社区版,Halo 付费版为用户提供了大量增强功能及技术支持服务,增强功能包括商城、短信验证码注册登录、全站私有化、LDAP 登录、三方账号登录及自定义 Logo 等。 [点击查看付费版详细介绍](https://www.lxware.cn/halo)。 ## 生态 可访问 [官方应用市场](https://www.halo.run/store/apps) 或 [awesome-halo 仓库](https://github.com/halo-sigs/awesome-halo) 查看适用于 Halo 2.x 的主题和插件。 ## 许可证 [![license](https://img.shields.io/github/license/halo-dev/halo.svg?style=flat-square)](https://github.com/halo-dev/halo/blob/master/LICENSE) Halo 使用 GPL-v3.0 协议开源,请遵守开源协议。 ## 贡献 参考 [CONTRIBUTING](https://github.com/halo-dev/halo/blob/main/CONTRIBUTING.md)。 ## 状态 ![Repobeats analytics](https://repobeats.axiom.co/api/embed/ad008b2151c22e7cf734d2688befaa795d593b95.svg "Repobeats analytics image") ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions Halo currently supports the versions listed below, where as: - :white_check_mark: indicates an active development roadmap, is therefore maintaining, and **will** receive Security Vulnerability Report. - :x: indicates such version has already deprecated and **will not** be receiving Security Vulnerability Report. | Version | Supported | | ------- | ------------------ | | 0.x | :x: | | 1.x | :x: | | 2.x | :white_check_mark: | ## Reporting a Vulnerability We first appreciate and are very thankful that you've found a vulnerability issue in Halo! By disclosing such issue to Halo development team you are helping Halo to become a much more safer project than before! ;) To protect the existing users of Halo, we kindly ask you to not disclose the vulnerability to anyone except the Halo development team before a fix has been rolled out. To Report a Vulnerability, please complete the form below, and send such report by email to `hi@halo.run`. ``` Vulnerability has been observed in... - Docker? [n/y]: if yes for the question above, - `docker -v`: - `docker images halohub/halo`: - by `java -jar halo.jar`? [n/y]: if yes for the question above, - `uname -a`: - `java -version`: - Affected by Halo version(s) [e.g. v2.4.0]: - Vulnerability self-scoring [1-10]: - Would you like to be attributed? (Whether you agree us to appreciate you by putting your name in the CHANGELOG of the next fix release) [n/y]: ``` ================================================ FILE: api/build.gradle ================================================ plugins { id 'checkstyle' id 'java-library' id 'halo.publish' id 'jacoco' alias(libs.plugins.lombok) alias(libs.plugins.versions) } group = 'run.halo.app' description = 'API of halo project, connecting by other projects.' tasks.withType(JavaCompile).configureEach { options.release = 21 options.encoding = 'UTF-8' } tasks.withType(Javadoc).configureEach { options.encoding = 'UTF-8' } java { toolchain { languageVersion = JavaLanguageVersion.of(21) } withJavadocJar() withSourcesJar() } checkstyle { toolVersion = libs.versions.checkstyle.get() showViolations = false ignoreFailures = false } jar { manifest { attributes( 'Implementation-Title': project.name, 'Implementation-Version': project.version, 'Implementation-Vendor': 'Halo Project', ) } } repositories { mavenCentral() } dependencies { api platform(project(':platform:application')) annotationProcessor platform(project(':platform:application')) api 'org.springframework.boot:spring-boot-starter-actuator' api 'org.springframework.boot:spring-boot-starter-mail' api 'org.springframework.boot:spring-boot-starter-thymeleaf' api 'org.springframework.boot:spring-boot-starter-webflux' api 'org.springframework.boot:spring-boot-starter-validation' api 'org.springframework.boot:spring-boot-starter-data-r2dbc' api 'org.springframework.session:spring-session-core' api 'org.springframework.boot:spring-boot-jackson2' api 'org.springframework.boot:spring-boot-integration' api 'org.springframework.boot:spring-boot-session' // Spring Security api 'org.springframework.boot:spring-boot-starter-security' api 'org.springframework.security:spring-security-oauth2-jose' api 'org.springframework.security:spring-security-oauth2-client' api 'org.springframework.security:spring-security-oauth2-resource-server' api 'io.micrometer:context-propagation' // Cache api "org.springframework.boot:spring-boot-starter-cache" api "com.github.ben-manes.caffeine:caffeine" api "org.springdoc:springdoc-openapi-starter-webflux-ui" api 'org.openapi4j:openapi-schema-validator' api "net.bytebuddy:byte-buddy" api "org.bouncycastle:bcpkix-jdk18on" // Apache Lucene api "org.apache.lucene:lucene-core" api "org.apache.lucene:lucene-queryparser" api "org.apache.lucene:lucene-highlighter" api "org.apache.lucene:lucene-backward-codecs" api 'org.apache.lucene:lucene-analysis-common' api "org.apache.commons:commons-lang3" api "io.seruco.encoding:base62" api "org.pf4j:pf4j" api "com.google.guava:guava" api "org.jsoup:jsoup" api "io.github.java-diff-utils:java-diff-utils" api "org.springframework.integration:spring-integration-core" api "com.github.java-json-tools:json-patch" api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6" api 'org.apache.tika:tika-core' api 'net.coobird:thumbnailator' api "io.github.resilience4j:resilience4j-spring-boot3" api "io.github.resilience4j:resilience4j-reactor" api "com.j256.two-factor-auth:two-factor-auth" runtimeOnly 'io.r2dbc:r2dbc-h2' runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:r2dbc-postgresql' runtimeOnly 'org.mariadb:r2dbc-mariadb' runtimeOnly 'io.asyncer:r2dbc-mysql' runtimeOnly 'com.github.therapi:therapi-runtime-javadoc' annotationProcessor "com.github.therapi:therapi-runtime-javadoc-scribe" testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'io.projectreactor:reactor-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } publishing { publications.named('mavenJava', MavenPublication) { from components.java pom { name = 'API library' description = "$project.description" } } } tasks.named('test') { useJUnitPlatform() finalizedBy jacocoTestReport } tasks.named('jacocoTestReport') { reports { xml.required = true html.required = false } } tasks.named('uploadBundle') { mustRunAfter project(':platform:application').tasks.named('uploadBundle') } ================================================ FILE: api/src/main/java/run/halo/app/content/ContentWrapper.java ================================================ package run.halo.app.content; import lombok.Builder; import lombok.Data; import org.apache.commons.lang3.StringUtils; import org.springframework.util.Assert; import run.halo.app.core.extension.content.Snapshot; /** * @author guqing * @since 2.0.0 */ @Data @Builder public class ContentWrapper { private String snapshotName; private String raw; private String content; private String rawType; public static ContentWrapper patchSnapshot(Snapshot patchSnapshot, Snapshot baseSnapshot) { Assert.notNull(baseSnapshot, "The baseSnapshot must not be null."); String baseSnapshotName = baseSnapshot.getMetadata().getName(); if (StringUtils.equals(patchSnapshot.getMetadata().getName(), baseSnapshotName)) { return ContentWrapper.builder() .snapshotName(patchSnapshot.getMetadata().getName()) .raw(patchSnapshot.getSpec().getRawPatch()) .content(patchSnapshot.getSpec().getContentPatch()) .rawType(patchSnapshot.getSpec().getRawType()) .build(); } String patchedContent = PatchUtils.applyPatch(baseSnapshot.getSpec().getContentPatch(), patchSnapshot.getSpec().getContentPatch()); String patchedRaw = PatchUtils.applyPatch(baseSnapshot.getSpec().getRawPatch(), patchSnapshot.getSpec().getRawPatch()); return ContentWrapper.builder() .snapshotName(patchSnapshot.getMetadata().getName()) .raw(patchedRaw) .content(patchedContent) .rawType(patchSnapshot.getSpec().getRawType()) .build(); } } ================================================ FILE: api/src/main/java/run/halo/app/content/ExcerptGenerator.java ================================================ package run.halo.app.content; import java.util.Set; import lombok.Data; import lombok.experimental.Accessors; import org.pf4j.ExtensionPoint; import reactor.core.publisher.Mono; public interface ExcerptGenerator extends ExtensionPoint { Mono generate(ExcerptGenerator.Context context); @Data @Accessors(chain = true) class Context { private String raw; /** * html content. */ private String content; private String rawType; /** * keywords in the content to help the excerpt generation more accurate. */ private Set keywords; /** * Max length of the generated excerpt. */ private int maxLength; } } ================================================ FILE: api/src/main/java/run/halo/app/content/PatchUtils.java ================================================ package run.halo.app.content; import com.fasterxml.jackson.core.type.TypeReference; import com.github.difflib.DiffUtils; import com.github.difflib.patch.AbstractDelta; import com.github.difflib.patch.ChangeDelta; import com.github.difflib.patch.Chunk; import com.github.difflib.patch.DeleteDelta; import com.github.difflib.patch.DeltaType; import com.github.difflib.patch.InsertDelta; import com.github.difflib.patch.Patch; import com.github.difflib.patch.PatchFailedException; import com.google.common.base.Splitter; import java.util.Collections; import java.util.List; import lombok.Data; import org.apache.commons.lang3.StringUtils; import run.halo.app.infra.utils.JsonUtils; /** * @author guqing * @since 2.0.0 */ public class PatchUtils { private static final String DELIMITER = "\n"; private static final Splitter lineSplitter = Splitter.on(DELIMITER); public static Patch create(String deltasJson) { List deltas = JsonUtils.jsonToObject(deltasJson, new TypeReference<>() { }); Patch patch = new Patch<>(); for (Delta delta : deltas) { StringChunk sourceChunk = delta.getSource(); StringChunk targetChunk = delta.getTarget(); Chunk orgChunk = new Chunk<>(sourceChunk.getPosition(), sourceChunk.getLines(), sourceChunk.getChangePosition()); Chunk revChunk = new Chunk<>(targetChunk.getPosition(), targetChunk.getLines(), targetChunk.getChangePosition()); switch (delta.getType()) { case DELETE -> patch.addDelta(new DeleteDelta<>(orgChunk, revChunk)); case INSERT -> patch.addDelta(new InsertDelta<>(orgChunk, revChunk)); case CHANGE -> patch.addDelta(new ChangeDelta<>(orgChunk, revChunk)); default -> throw new IllegalArgumentException("Unsupported delta type."); } } return patch; } public static String patchToJson(Patch patch) { List> deltas = patch.getDeltas(); return JsonUtils.objectToJson(deltas); } public static String applyPatch(String original, String patchJson) { Patch patch = PatchUtils.create(patchJson); try { return String.join(DELIMITER, patch.applyTo(breakLine(original))); } catch (PatchFailedException e) { throw new RuntimeException(e); } } public static String diffToJsonPatch(String original, String revised) { Patch patch = DiffUtils.diff(breakLine(original), breakLine(revised)); return PatchUtils.patchToJson(patch); } public static List breakLine(String content) { if (StringUtils.isBlank(content)) { return Collections.emptyList(); } return lineSplitter.splitToList(content); } @Data public static class Delta { private StringChunk source; private StringChunk target; private DeltaType type; } @Data public static class StringChunk { private int position; private List lines; private List changePosition; } } ================================================ FILE: api/src/main/java/run/halo/app/content/PostContentService.java ================================================ package run.halo.app.content; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public interface PostContentService { Mono getHeadContent(String postName); Mono getReleaseContent(String postName); Mono getSpecifiedContent(String postName, String snapshotName); Flux listSnapshots(String postName); } ================================================ FILE: api/src/main/java/run/halo/app/content/comment/CommentSubject.java ================================================ package run.halo.app.content.comment; import org.pf4j.ExtensionPoint; import reactor.core.publisher.Mono; import run.halo.app.extension.Extension; import run.halo.app.extension.Ref; /** * Comment subject. * * @author guqing * @since 2.0.0 */ public interface CommentSubject extends ExtensionPoint { Mono get(String name); default Mono getSubjectDisplay(String name) { return Mono.empty(); } boolean supports(Ref ref); record SubjectDisplay(String title, String url, String kindName) { } } ================================================ FILE: api/src/main/java/run/halo/app/core/attachment/ThumbnailProvider.java ================================================ package run.halo.app.core.attachment; import java.net.URI; import java.net.URL; import lombok.Builder; import lombok.Data; import org.pf4j.ExtensionPoint; import reactor.core.publisher.Mono; import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler; /** * Thumbnail provider extension. * * @since 2.22.0 * @deprecated Use {@link AttachmentHandler} instead. We are planing to remove this extension * point in future release. */ @Deprecated(forRemoval = true, since = "2.22.0") public interface ThumbnailProvider extends ExtensionPoint { /** * Generate thumbnail URI for given image URL and size. * * @param context Thumbnail context including image URI and size * @return Generated thumbnail URI */ Mono generate(ThumbnailContext context); /** * Delete thumbnail file for given image URL. * * @param imageUrl original image URL */ Mono delete(URL imageUrl); /** * Whether the provider supports the given image URI. * * @return {@code true} if supports, {@code false} otherwise */ Mono supports(ThumbnailContext context); @Data @Builder class ThumbnailContext { private final URL imageUrl; private final ThumbnailSize size; } } ================================================ FILE: api/src/main/java/run/halo/app/core/attachment/ThumbnailSize.java ================================================ package run.halo.app.core.attachment; import java.util.Arrays; import java.util.Optional; import lombok.Getter; @Getter public enum ThumbnailSize { S(400), M(800), L(1200), XL(1600); private final int width; ThumbnailSize(int width) { this.width = width; } /** * Convert width string to {@link ThumbnailSize}. * * @param width width string */ public static ThumbnailSize fromWidth(String width) { for (ThumbnailSize value : values()) { if (String.valueOf(value.getWidth()).equals(width)) { return value; } } return ThumbnailSize.M; } /** * Convert name to {@link ThumbnailSize}. */ public static ThumbnailSize fromName(String name) { for (ThumbnailSize value : values()) { if (value.name().equalsIgnoreCase(name)) { return value; } } throw new IllegalArgumentException("No such thumbnail size: " + name); } public static Optional optionalValueOf(String name) { for (ThumbnailSize value : values()) { if (value.name().equalsIgnoreCase(name)) { return Optional.of(value); } } return Optional.empty(); } public static Integer[] allowedWidths() { return Arrays.stream(ThumbnailSize.values()) .map(ThumbnailSize::getWidth) .toArray(Integer[]::new); } } ================================================ FILE: api/src/main/java/run/halo/app/core/endpoint/WebSocketEndpoint.java ================================================ package run.halo.app.core.endpoint; import org.springframework.web.reactive.socket.WebSocketHandler; import run.halo.app.extension.GroupVersion; /** * Endpoint for WebSocket. * * @author johnniang */ public interface WebSocketEndpoint { /** * Path of the URL after group version. * * @return path of the URL. */ String urlPath(); /** * Group and version parts of the endpoint. * * @return GroupVersion. */ GroupVersion groupVersion(); /** * Real WebSocket handler for the endpoint. * * @return WebSocket handler. */ WebSocketHandler handler(); } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/AnnotationSetting.java ================================================ package run.halo.app.core.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static run.halo.app.core.extension.AnnotationSetting.KIND; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.GroupKind; @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @GVK(group = "", version = "v1alpha1", kind = KIND, plural = "annotationsettings", singular = "annotationsetting") public class AnnotationSetting extends AbstractExtension { public static final String TARGET_REF_LABEL = "halo.run/target-ref"; public static final String KIND = "AnnotationSetting"; @Schema(requiredMode = REQUIRED) private AnnotationSettingSpec spec; @Data public static class AnnotationSettingSpec { @Schema(requiredMode = REQUIRED) private GroupKind targetRef; @Schema(requiredMode = REQUIRED, minLength = 1) private List formSchema; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/AuthProvider.java ================================================ package run.halo.app.core.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; import org.springframework.lang.NonNull; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; /** * Auth provider extension. * * @author guqing * @since 2.4.0 */ @Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) @GVK(group = "auth.halo.run", version = "v1alpha1", kind = "AuthProvider", singular = "authprovider", plural = "authproviders") public class AuthProvider extends AbstractExtension { public static final String AUTH_BINDING_LABEL = "auth.halo.run/auth-binding"; public static final String PRIVILEGED_LABEL = "auth.halo.run/privileged"; @Schema(requiredMode = REQUIRED) private AuthProviderSpec spec; @Data @ToString public static class AuthProviderSpec { @Schema(requiredMode = REQUIRED, description = "Display name of the auth provider") private String displayName; private String description; private String logo; private String website; private String helpPage; @Schema(requiredMode = REQUIRED, description = "Authentication url of the auth provider") private String authenticationUrl; private String method = "GET"; private boolean rememberMeSupport = false; /** * Auth type: form or oauth2. */ @Getter(onMethod_ = @NonNull) @Schema(requiredMode = REQUIRED) private AuthType authType = AuthType.OAUTH2; private String bindingUrl; private String unbindUrl; @Schema(requiredMode = NOT_REQUIRED) private SettingRef settingRef; @Schema(requiredMode = NOT_REQUIRED) private ConfigMapRef configMapRef; public void setAuthType(AuthType authType) { this.authType = (authType == null ? AuthType.OAUTH2 : authType); } } @Data @ToString public static class SettingRef { @Schema(requiredMode = REQUIRED, minLength = 1) private String name; @Schema(requiredMode = REQUIRED, minLength = 1) private String group; } @Data @ToString public static class ConfigMapRef { @Schema(requiredMode = REQUIRED, minLength = 1) private String name; } public enum AuthType { FORM, OAUTH2 } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/Counter.java ================================================ package run.halo.app.core.extension; import lombok.Data; import lombok.EqualsAndHashCode; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.Metadata; /** * A counter for number of requests by extension resource name. * * @author guqing * @since 2.0.0 */ @Data @GVK(group = "metrics.halo.run", version = "v1alpha1", kind = "Counter", plural = "counters", singular = "counter") @EqualsAndHashCode(callSuper = true) public class Counter extends AbstractExtension { private Integer visit; private Integer upvote; private Integer downvote; private Integer totalComment; private Integer approvedComment; public static Counter emptyCounter(String name) { Counter counter = new Counter(); counter.setMetadata(new Metadata()); counter.getMetadata().setName(name); counter.setUpvote(0); counter.setTotalComment(0); counter.setApprovedComment(0); counter.setVisit(0); return counter; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/Device.java ================================================ package run.halo.app.core.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.experimental.Accessors; import org.springframework.lang.NonNull; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @Data @EqualsAndHashCode(callSuper = true) @GVK(group = Device.GROUP, version = Device.VERSION, kind = Device.KIND, plural = "devices", singular = "device") public class Device extends AbstractExtension { public static final String GROUP = "security.halo.run"; public static final String VERSION = "v1alpha1"; public static final String KIND = "Device"; @Schema(requiredMode = REQUIRED) private Spec spec; @Getter(onMethod_ = @NonNull) private Status status = new Status(); public void setStatus(Status status) { this.status = (status == null ? new Status() : status); } @Data @Accessors(chain = true) @Schema(name = "DeviceSpec") public static class Spec { @Schema(requiredMode = REQUIRED, minLength = 1) private String sessionId; @Schema(requiredMode = REQUIRED, minLength = 1) private String principalName; @Schema(requiredMode = REQUIRED, maxLength = 129) private String ipAddress; @Schema(maxLength = 500) private String userAgent; private String rememberMeSeriesId; private Instant lastAccessedTime; private Instant lastAuthenticatedTime; } @Data @Accessors(chain = true) @Schema(name = "DeviceStatus") public static class Status { private String browser; private String os; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/Menu.java ================================================ package run.halo.app.core.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import java.util.LinkedHashSet; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @GVK(group = "", version = "v1alpha1", kind = "Menu", plural = "menus", singular = "menu") public class Menu extends AbstractExtension { @Schema(description = "The spec of menu.", requiredMode = REQUIRED) private Spec spec; @Data @Schema(name = "MenuSpec") public static class Spec { @Schema(description = "The display name of the menu.", requiredMode = REQUIRED) private String displayName; @ArraySchema( uniqueItems = true, arraySchema = @Schema(description = "Menu items of this menu."), schema = @Schema(description = "Name of menu item.") ) private LinkedHashSet menuItems; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/MenuItem.java ================================================ package run.halo.app.core.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import java.util.LinkedHashSet; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.Ref; @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @GVK(group = "", version = "v1alpha1", kind = "MenuItem", plural = "menuitems", singular = "menuitem") public class MenuItem extends AbstractExtension { @Schema(description = "The spec of menu item.", requiredMode = REQUIRED) private MenuItemSpec spec; @Schema(description = "The status of menu item.") private MenuItemStatus status; public enum Target { BLANK("_blank"), SELF("_self"), PARENT("_parent"), TOP("_top"); private final String value; @JsonCreator Target(String value) { this.value = value; } @JsonValue public String getValue() { return value; } } @Data public static class MenuItemSpec { @Schema(description = "The display name of menu item.") private String displayName; @Schema(description = "The href of this menu item.") private String href; @Schema(description = "The target attribute of this menu item.") private Target target; @Schema(description = "The priority is for ordering.") private Integer priority; @ArraySchema( uniqueItems = true, arraySchema = @Schema(description = "Children of this menu item"), schema = @Schema(description = "The name of menu item child")) private LinkedHashSet children; @Schema(description = "Target reference. Like Category, Tag, Post or SinglePage") private Ref targetRef; } @Data public static class MenuItemStatus { @Schema(description = "Calculated Display name of menu item.") private String displayName; @Schema(description = "Calculated href of manu item.") private String href; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/Plugin.java ================================================ package run.halo.app.core.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import java.net.URI; import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import org.pf4j.PluginState; import org.springframework.lang.NonNull; import org.springframework.util.Assert; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.infra.ConditionList; /** * A custom resource for Plugin. * * @author guqing * @since 2.0.0 */ @Data @ToString(callSuper = true) @GVK(group = "plugin.halo.run", version = "v1alpha1", kind = "Plugin", plural = "plugins", singular = "plugin") @EqualsAndHashCode(callSuper = true) public class Plugin extends AbstractExtension { public static final String SYSTEM_RESERVED_LABEL_KEY = "plugin.halo.run/system-reserved"; public static final String BUILT_IN_KEEPER_FINALIZER = "plugin.halo.run/built-in-keeper"; @Schema(requiredMode = REQUIRED) private PluginSpec spec; private PluginStatus status; /** * Gets plugin status. * * @return empty object if status is null. */ @NonNull @JsonIgnore public PluginStatus statusNonNull() { if (this.status == null) { this.status = new PluginStatus(); } return status; } @Data public static class PluginSpec { private String displayName; /** * plugin version. * * @see semantic version */ @Schema(requiredMode = REQUIRED, pattern = "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-(" + "(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\." + "(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\" + ".[0-9a-zA-Z-]+)*))?$") private String version; private PluginAuthor author; private String logo; private Map pluginDependencies = new HashMap<>(4); private String homepage; private String repo; private String issues; private String description; private List license; /** * SemVer format. */ private String requires = "*"; private Boolean enabled = false; private String settingName; private String configMapName; } /** * In the future, we may consider using {@link run.halo.app.infra.model.License} instead of it. * But now, replace it will lead to incompatibility with downstream. */ @Data public static class License { private String name; private String url; } @Data public static class PluginStatus { private Phase phase; private ConditionList conditions; private Instant lastStartTime; private PluginState lastProbeState; private String entry; private String stylesheet; private String logo; @Schema(description = "Load location of the plugin, often a path.") private URI loadLocation; public static ConditionList nullSafeConditions(@NonNull PluginStatus status) { Assert.notNull(status, "The status must not be null."); if (status.getConditions() == null) { status.setConditions(new ConditionList()); } return status.getConditions(); } } public enum Phase { PENDING, STARTING, CREATED, DISABLING, DISABLED, RESOLVED, STARTED, STOPPED, FAILED, UNKNOWN, ; } @Data @ToString public static class PluginAuthor { @Schema(requiredMode = REQUIRED, minLength = 1) private String name; private String website; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/RememberMeToken.java ================================================ package run.halo.app.core.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "security.halo.run", version = "v1alpha1", kind = "RememberMeToken", plural = "remembermetokens", singular = "remembermetoken") public class RememberMeToken extends AbstractExtension { @Schema(requiredMode = REQUIRED) private Spec spec; @Data @Accessors(chain = true) @Schema(name = "RememberMeTokenSpec") public static class Spec { @Schema(requiredMode = REQUIRED, minLength = 1) private String username; @Schema(requiredMode = REQUIRED, minLength = 1) private String series; @Schema(requiredMode = REQUIRED, minLength = 1) private String tokenValue; private Instant lastUsed; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/ReverseProxy.java ================================================ package run.halo.app.core.extension; import java.util.List; import lombok.Data; import lombok.EqualsAndHashCode; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; /** *

The reverse proxy custom resource is used to configure a path to proxy it to a directory or * file.

*

HTTP proxy may be added in the future.

* * @author guqing * @since 2.0.0 */ @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "plugin.halo.run", kind = "ReverseProxy", version = "v1alpha1", plural = "reverseproxies", singular = "reverseproxy") public class ReverseProxy extends AbstractExtension { private List rules; public record ReverseProxyRule(String path, FileReverseProxyProvider file) { } public record FileReverseProxyProvider(String directory, String filename) { } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/Role.java ================================================ package run.halo.app.core.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static java.util.Arrays.compare; import static run.halo.app.core.extension.Role.GROUP; import static run.halo.app.core.extension.Role.KIND; import static run.halo.app.core.extension.Role.VERSION; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; import org.springframework.lang.NonNull; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; /** * @author guqing * @since 2.0.0 */ @Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) @GVK(group = GROUP, version = VERSION, kind = KIND, plural = "roles", singular = "role") public class Role extends AbstractExtension { public static final String ROLE_DEPENDENCY_RULES = "rbac.authorization.halo.run/dependency-rules"; public static final String ROLE_AGGREGATE_LABEL_PREFIX = "rbac.authorization.halo.run/aggregate-to-"; public static final String ROLE_DEPENDENCIES_ANNO = "rbac.authorization.halo.run/dependencies"; public static final String UI_PERMISSIONS_ANNO = "rbac.authorization.halo.run/ui-permissions"; public static final String SYSTEM_RESERVED_LABELS = "rbac.authorization.halo.run/system-reserved"; public static final String HIDDEN_LABEL_NAME = "halo.run/hidden"; public static final String TEMPLATE_LABEL_NAME = "halo.run/role-template"; public static final String UI_PERMISSIONS_AGGREGATED_ANNO = "rbac.authorization.halo.run/ui-permissions-aggregated"; public static final String GROUP = ""; public static final String VERSION = "v1alpha1"; public static final String KIND = "Role"; @Schema(requiredMode = REQUIRED) List rules; /** * PolicyRule holds information that describes a policy rule, but does not contain information * about whom the rule applies to or which namespace the rule applies to. * * @author guqing * @since 2.0.0 */ @Getter @EqualsAndHashCode public static class PolicyRule implements Comparable { /** * APIGroups is the name of the APIGroup that contains the resources. * If multiple API groups are specified, any action requested against one of the enumerated * resources in any API group will be allowed. */ final String[] apiGroups; /** * Resources is a list of resources this rule applies to. '*' represents all resources in * the specified apiGroups. * '*/foo' represents the subresource 'foo' for all resources in the specified * apiGroups. */ final String[] resources; /** * ResourceNames is an optional white list of names that the rule applies to. An empty set * means that everything is allowed. */ final String[] resourceNames; /** * NonResourceURLs is a set of partial urls that a user should have access to. * *s are allowed, but only as the full, final step in the path * If an action is not a resource API request, then the URL is split on '/' and is checked * against the NonResourceURLs to look for a match. * Since non-resource URLs are not namespaced, this field is only applicable for * ClusterRoles referenced from a ClusterRoleBinding. * Rules can either apply to API resources (such as "pods" or "secrets") or non-resource * URL paths (such as "/api"), but not both. */ final String[] nonResourceURLs; /** * about who the rule applies to or which namespace the rule applies to. */ final String[] verbs; public PolicyRule() { this(null, null, null, null, null); } public PolicyRule(String[] apiGroups, String[] resources, String[] resourceNames, String[] nonResourceURLs, String[] verbs) { this.apiGroups = nullElseEmpty(apiGroups); this.resources = nullElseEmpty(resources); this.resourceNames = nullElseEmpty(resourceNames); this.nonResourceURLs = nullElseEmpty(nonResourceURLs); this.verbs = nullElseEmpty(verbs); } String[] nullElseEmpty(String... items) { if (items == null) { return new String[] {}; } return items; } @Override public int compareTo(@NonNull PolicyRule other) { int result = compare(apiGroups, other.apiGroups); if (result != 0) { return result; } result = compare(resources, other.resources); if (result != 0) { return result; } result = compare(resourceNames, other.resourceNames); if (result != 0) { return result; } result = compare(nonResourceURLs, other.nonResourceURLs); if (result != 0) { return result; } result = compare(verbs, other.verbs); return result; } public static class Builder { String[] apiGroups; String[] resources; String[] resourceNames; String[] nonResourceURLs; String[] verbs; public Builder apiGroups(String... apiGroups) { this.apiGroups = apiGroups; return this; } public Builder resources(String... resources) { this.resources = resources; return this; } public Builder resourceNames(String... resourceNames) { this.resourceNames = resourceNames; return this; } public Builder nonResourceURLs(String... nonResourceURLs) { this.nonResourceURLs = nonResourceURLs; return this; } public Builder verbs(String... verbs) { this.verbs = verbs; return this; } public PolicyRule build() { return new PolicyRule(apiGroups, resources, resourceNames, nonResourceURLs, verbs); } } } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/RoleBinding.java ================================================ package run.halo.app.core.extension; import static run.halo.app.core.extension.RoleBinding.GROUP; import static run.halo.app.core.extension.RoleBinding.KIND; import static run.halo.app.core.extension.RoleBinding.VERSION; import java.util.LinkedList; import java.util.List; import java.util.Set; import java.util.function.Predicate; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.ToString; import org.springframework.util.StringUtils; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.ExtensionOperator; import run.halo.app.extension.GVK; import run.halo.app.extension.Metadata; /** * RoleBinding references a role, but does not contain it. * It can reference a Role in the global. * It adds who information via Subjects. * * @author guqing * @since 2.0.0 */ @Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) @GVK(group = GROUP, version = VERSION, kind = KIND, plural = "rolebindings", singular = "rolebinding") public class RoleBinding extends AbstractExtension { public static final String GROUP = ""; public static final String VERSION = "v1alpha1"; public static final String KIND = "RoleBinding"; /** * Subjects holds references to the objects the role applies to. */ List subjects; /** * RoleRef can reference a Role in the current namespace or a ClusterRole in the global * namespace. * If the RoleRef cannot be resolved, the Authorizer must return an error. */ RoleRef roleRef; /** * RoleRef contains information that points to the role being used. * * @author guqing * @since 2.0.0 */ @Data public static class RoleRef { /** * Kind is the type of resource being referenced. */ String kind; /** * Name is the name of resource being referenced. */ String name; /** * APIGroup is the group for the resource being referenced. */ String apiGroup; } /** * @author guqing * @since 2.0.0 */ @Data @NoArgsConstructor @AllArgsConstructor public static class Subject { /** * Kind of object being referenced. Values defined by this API group are "User", "Group", * and "ServiceAccount". * If the Authorizer does not recognize the kind value, the Authorizer should report * an error. */ String kind; /** * Name of the object being referenced. */ String name; /** * APIGroup holds the API group of the referenced subject. * Defaults to "" for ServiceAccount subjects. * Defaults to "rbac.authorization.halo.run" for User and Group subjects. */ String apiGroup; public static Predicate isUser(String username) { return subject -> User.KIND.equals(subject.getKind()) && User.GROUP.equals(subject.getApiGroup()) && username.equals(subject.getName()); } public static Predicate containsUser(Set usernames) { return subject -> User.KIND.equals(subject.getKind()) && User.GROUP.equals(subject.apiGroup) && usernames.contains(subject.getName()); } @Override public String toString() { if (StringUtils.hasText(apiGroup)) { return apiGroup + "/" + kind + "/" + name; } return kind + "/" + name; } } public static RoleBinding create(String username, String roleName) { var metadata = new Metadata(); metadata.setName(String.join("-", username, roleName, "binding")); var roleRef = new RoleRef(); roleRef.setKind(Role.KIND); roleRef.setName(roleName); roleRef.setApiGroup(Role.GROUP); var subject = new Subject(); subject.setKind(User.KIND); subject.setName(username); subject.setApiGroup(User.GROUP); var binding = new RoleBinding(); binding.setMetadata(metadata); binding.setRoleRef(roleRef); // keep the subjects mutable var subjects = new LinkedList(); subjects.add(subject); binding.setSubjects(subjects); return binding; } public static Predicate containsUser(String username) { return ExtensionOperator.isNotDeleted().and( binding -> binding.getSubjects().stream() .anyMatch(Subject.isUser(username))); } public static Predicate containsUser(Set usernames) { return ExtensionOperator.isNotDeleted() .and(binding -> binding.getSubjects().stream() .anyMatch(Subject.containsUser(usernames))); } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/Setting.java ================================================ package run.halo.app.core.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static run.halo.app.extension.GroupVersionKind.fromExtension; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.Data; import lombok.EqualsAndHashCode; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.GroupVersionKind; /** * {@link Setting} is a custom extension to generate forms based on configuration. * * @author guqing * @since 2.0.0 */ @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "", version = "v1alpha1", kind = Setting.KIND, plural = "settings", singular = "setting") public class Setting extends AbstractExtension { public static final String KIND = "Setting"; public static final GroupVersionKind GVK = fromExtension(Setting.class); @Schema(requiredMode = REQUIRED) private SettingSpec spec; @Data public static class SettingSpec { @Schema(requiredMode = REQUIRED, minLength = 1) private List forms; } @Data public static class SettingForm { @Schema(requiredMode = REQUIRED) private String group; private String label; @Schema(requiredMode = REQUIRED) private List formSchema; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/Theme.java ================================================ package run.halo.app.core.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import java.util.Objects; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import org.springframework.util.Assert; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.infra.ConditionList; import run.halo.app.infra.model.License; /** *

Theme extension.

* * @author guqing * @since 2.0.0 */ @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @GVK(group = "theme.halo.run", version = "v1alpha1", kind = Theme.KIND, plural = "themes", singular = "theme") public class Theme extends AbstractExtension { public static final String KIND = "Theme"; public static final String THEME_NAME_LABEL = "theme.halo.run/theme-name"; @Schema(requiredMode = REQUIRED) private ThemeSpec spec; private ThemeStatus status; @Data @ToString public static class ThemeSpec { private static final String WILDCARD = "*"; @Schema(requiredMode = REQUIRED, minLength = 1) private String displayName; @Schema(requiredMode = REQUIRED) private Author author; private String description; private String logo; private String homepage; private String repo; private String issues; private String version = WILDCARD; @Schema(requiredMode = NOT_REQUIRED) private String requires = WILDCARD; private String settingName; private String configMapName; private List license; @Schema private CustomTemplates customTemplates; } @Data public static class ThemeStatus { private ThemePhase phase; private ConditionList conditions; private String location; } /** * Null-safe get {@link ConditionList} from theme status. * * @param theme theme must not be null * @return condition list */ public static ConditionList nullSafeConditionList(Theme theme) { Assert.notNull(theme, "The theme must not be null"); var status = Objects.requireNonNullElseGet(theme.getStatus(), ThemeStatus::new); theme.setStatus(status); var conditions = Objects.requireNonNullElseGet(status.getConditions(), ConditionList::new); status.setConditions(conditions); return conditions; } public enum ThemePhase { READY, FAILED, UNKNOWN, } @Data @ToString public static class Author { @Schema(requiredMode = REQUIRED, minLength = 1) private String name; private String website; } @Data public static class CustomTemplates { private List post; private List category; private List page; } /** * Type used to describe custom template page. * * @author guqing * @since 2.0.0 */ @Data public static class TemplateDescriptor { @Schema(requiredMode = REQUIRED, minLength = 1) private String name; private String description; private String screenshot; @Schema(requiredMode = REQUIRED, minLength = 1) private String file; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/User.java ================================================ package run.halo.app.core.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static run.halo.app.core.extension.User.GROUP; import static run.halo.app.core.extension.User.KIND; import static run.halo.app.core.extension.User.VERSION; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; /** * The extension represents user details of Halo. * * @author johnniang */ @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @GVK(group = GROUP, version = VERSION, kind = KIND, singular = "user", plural = "users") public class User extends AbstractExtension { public static final String GROUP = ""; public static final String VERSION = "v1alpha1"; public static final String KIND = "User"; public static final String USER_RELATED_ROLES_INDEX = "roles"; public static final String ROLE_NAMES_ANNO = "rbac.authorization.halo.run/role-names"; public static final String EMAIL_TO_VERIFY = "halo.run/email-to-verify"; public static final String LAST_AVATAR_ATTACHMENT_NAME_ANNO = "halo.run/last-avatar-attachment-name"; public static final String AVATAR_ATTACHMENT_NAME_ANNO = "halo.run/avatar-attachment-name"; public static final String HIDDEN_USER_LABEL = "halo.run/hidden-user"; public static final String REQUEST_TO_UPDATE = "halo.run/request-to-update"; @Schema(requiredMode = REQUIRED) private UserSpec spec = new UserSpec(); private UserStatus status = new UserStatus(); @Data public static class UserSpec { @Schema(requiredMode = REQUIRED) private String displayName; private String avatar; @Schema(requiredMode = REQUIRED) private String email; private boolean emailVerified; private String phone; private String password; private String bio; private Instant registeredAt; private Boolean twoFactorAuthEnabled; private String totpEncryptedSecret; private Boolean disabled; private Integer loginHistoryLimit; } @Data public static class UserStatus { private String permalink; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/UserConnection.java ================================================ package run.halo.app.core.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import lombok.Data; import lombok.EqualsAndHashCode; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.Metadata; /** * User connection extension. * * @author guqing * @since 2.4.0 */ @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "auth.halo.run", version = "v1alpha1", kind = "UserConnection", singular = "userconnection", plural = "userconnections") public class UserConnection extends AbstractExtension { @Schema(requiredMode = REQUIRED) private UserConnectionSpec spec; @Data public static class UserConnectionSpec { /** * The name of the OAuth provider (e.g. Google, Facebook, Twitter). */ @Schema(requiredMode = REQUIRED) private String registrationId; /** * The {@link Metadata#getName()} of the user associated with the OAuth connection. */ @Schema(requiredMode = REQUIRED) private String username; /** * The unique identifier for the user's connection to the OAuth provider. * for example, the user's GitHub id. */ @Schema(requiredMode = REQUIRED) private String providerUserId; /** * The time when the user connection was last updated. */ private Instant updatedAt; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/attachment/Attachment.java ================================================ package run.halo.app.core.extension.attachment; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static run.halo.app.core.extension.attachment.Attachment.KIND; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import java.util.Map; import java.util.Set; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import org.jspecify.annotations.Nullable; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @GVK(group = Constant.GROUP, version = Constant.VERSION, kind = KIND, plural = "attachments", singular = "attachment") public class Attachment extends AbstractExtension { public static final String KIND = "Attachment"; @Schema(requiredMode = REQUIRED) private AttachmentSpec spec; private AttachmentStatus status; @Data public static class AttachmentSpec { @Schema(description = "Display name of attachment") private String displayName; @Schema(description = "Group name") private String groupName; @Schema(description = "Policy name") private String policyName; @Schema(description = "Name of User who uploads the attachment") private String ownerName; @Schema(description = "Media type of attachment") @Nullable private String mediaType; @Schema(description = "Size of attachment. Unit is Byte", minimum = "0") private Long size; @ArraySchema( arraySchema = @Schema(description = "Tags of attachment"), schema = @Schema(description = "Tag name")) private Set tags; } @Data public static class AttachmentStatus { @Schema(description = """ Permalink of attachment. If it is in local storage, the public URL will be set. If it is in s3 storage, the Object URL will be set. """) private String permalink; private Map thumbnails; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/attachment/Constant.java ================================================ package run.halo.app.core.extension.attachment; import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler; public enum Constant { ; public static final String GROUP = "storage.halo.run"; public static final String VERSION = "v1alpha1"; /** * The relative path starting from attachments folder is for deletion. */ public static final String LOCAL_REL_PATH_ANNO_KEY = GROUP + "/local-relative-path"; /** * The encoded URI is for building external url. */ public static final String URI_ANNO_KEY = GROUP + "/uri"; /** * Do not use this key to set external link. You could implement * {@link AttachmentHandler#getPermalink} by your self. *

*/ public static final String EXTERNAL_LINK_ANNO_KEY = GROUP + "/external-link"; public static final String FINALIZER_NAME = "attachment-manager"; } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/attachment/Group.java ================================================ package run.halo.app.core.extension.attachment; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static run.halo.app.core.extension.attachment.Group.KIND; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @GVK(group = Constant.GROUP, version = Constant.VERSION, kind = KIND, plural = "groups", singular = "group") public class Group extends AbstractExtension { public static final String KIND = "Group"; public static final String HIDDEN_LABEL = "halo.run/hidden"; @Schema(requiredMode = REQUIRED) private GroupSpec spec; private GroupStatus status; @Data public static class GroupSpec { @Schema(requiredMode = REQUIRED, description = "Display name of group") private String displayName; } @Data public static class GroupStatus { @Schema(description = "Update timestamp of the group") private Instant updateTimestamp; @Schema(description = "Total of attachments under the current group", minimum = "0") private Long totalAttachments; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/attachment/Policy.java ================================================ package run.halo.app.core.extension.attachment; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static run.halo.app.core.extension.attachment.Policy.KIND; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @GVK(group = Constant.GROUP, version = Constant.VERSION, kind = KIND, plural = "policies", singular = "policy") public class Policy extends AbstractExtension { public static final String POLICY_OWNER_LABEL = "storage.halo.run/policy-owner"; public static final String KIND = "Policy"; @Schema(requiredMode = REQUIRED) private PolicySpec spec; @Data public static class PolicySpec { @Schema(requiredMode = REQUIRED, description = "Display name of policy") private String displayName; @Schema(requiredMode = REQUIRED, description = "Reference name of PolicyTemplate") private String templateName; @Schema(description = "Reference name of ConfigMap extension") private String configMapName; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/attachment/PolicyTemplate.java ================================================ package run.halo.app.core.extension.attachment; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static run.halo.app.core.extension.attachment.PolicyTemplate.KIND; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @GVK(group = Constant.GROUP, version = Constant.VERSION, kind = KIND, plural = "policytemplates", singular = "policytemplate") public class PolicyTemplate extends AbstractExtension { public static final String KIND = "PolicyTemplate"; private PolicyTemplateSpec spec; @Data public static class PolicyTemplateSpec { private String displayName; @Schema(requiredMode = REQUIRED) private String settingName; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentHandler.java ================================================ package run.halo.app.core.extension.attachment.endpoint; import java.net.URI; import java.time.Duration; import java.util.Map; import org.jspecify.annotations.Nullable; import org.pf4j.ExtensionPoint; import org.springframework.http.codec.multipart.FilePart; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Group; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.extension.ConfigMap; public interface AttachmentHandler extends ExtensionPoint { Mono upload(UploadContext context); Mono delete(DeleteContext context); /** * Gets a shared URL which could be accessed publicly. * 1. If the attachment is in local storage, the permalink will be returned. * 2. If the attachment is in s3 storage, the Presigned URL will be returned. *

* Please note that the default implementation is only for back compatibility. * * @param attachment contains detail of attachment. * @param policy is storage policy. * @param configMap contains configuration needed by handler. * @param ttl indicates how long the URL is alive. * @return shared URL which could be accessed publicly. Might be relative URL. */ default Mono getSharedURL(Attachment attachment, Policy policy, ConfigMap configMap, Duration ttl) { return Mono.empty(); } /** * Gets a permalink representing a unique attachment. * If the attachment is in local storage, the permalink will be returned. * If the attachment is in s3 storage, the Object URL will be returned. *

* Please note that the default implementation is only for back compatibility. * * @param attachment contains detail of attachment. * @param policy is storage policy. * @param configMap contains configuration needed by handler. * @return permalink representing a unique attachment. Might be relative URL. */ default Mono getPermalink(Attachment attachment, Policy policy, ConfigMap configMap) { return Mono.empty(); } /** * Gets thumbnail links for given attachment. * * @param attachment the attachment * @param policy the policy * @param configMap the config map * @return a map of thumbnail sizes to their respective URIs */ default Mono> getThumbnailLinks(Attachment attachment, Policy policy, ConfigMap configMap) { return Mono.empty(); } interface UploadContext { FilePart file(); Policy policy(); ConfigMap configMap(); /** * Gets the group info if available. * * @return the group info, or null if not available */ @Nullable default Group group() { return null; } } interface DeleteContext { Attachment attachment(); Policy policy(); ConfigMap configMap(); } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/attachment/endpoint/DeleteOption.java ================================================ package run.halo.app.core.extension.attachment.endpoint; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.extension.ConfigMap; public record DeleteOption(Attachment attachment, Policy policy, ConfigMap configMap) implements AttachmentHandler.DeleteContext { } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/attachment/endpoint/SimpleFilePart.java ================================================ package run.halo.app.core.extension.attachment.endpoint; import java.nio.file.Path; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** * SimpleFilePart is an adapter of simple data for uploading. * * @param filename is name of the attachment file. * @param content is binary data of the attachment file. * @param mediaType is media type of the attachment file. */ public record SimpleFilePart( String filename, Flux content, MediaType mediaType ) implements FilePart { @Override public Mono transferTo(Path dest) { return DataBufferUtils.write(content(), dest); } @Override public String name() { return filename(); } @Override public HttpHeaders headers() { var headers = new HttpHeaders(); headers.setContentType(mediaType); return HttpHeaders.readOnlyHttpHeaders(headers); } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/attachment/endpoint/UploadOption.java ================================================ package run.halo.app.core.extension.attachment.endpoint; import lombok.Builder; import org.jspecify.annotations.Nullable; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import reactor.core.publisher.Flux; import run.halo.app.core.extension.attachment.Group; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.extension.ConfigMap; @Builder public record UploadOption(FilePart file, Policy policy, ConfigMap configMap, @Nullable Group group) implements AttachmentHandler.UploadContext { public static UploadOption from(String filename, Flux content, MediaType mediaType, Policy policy, ConfigMap configMap) { var filePart = new SimpleFilePart(filename, content, mediaType); return new UploadOption(filePart, policy, configMap, null); } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/content/Category.java ================================================ package run.halo.app.core.extension.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static run.halo.app.core.extension.content.Category.KIND; import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.GroupVersionKind; /** * @author guqing * @see issue#2322 * @since 2.0.0 */ @Data @ToString(callSuper = true) @GVK(group = Constant.GROUP, version = Constant.VERSION, kind = KIND, plural = "categories", singular = "category") @EqualsAndHashCode(callSuper = true) public class Category extends AbstractExtension { public static final String KIND = "Category"; public static final String LAST_HIDDEN_STATE_ANNO = "content.halo.run/last-hidden-state"; public static final GroupVersionKind GVK = GroupVersionKind.fromExtension(Category.class); @Schema(requiredMode = REQUIRED) private CategorySpec spec; @Schema private CategoryStatus status; @JsonIgnore public boolean isDeleted() { return getMetadata().getDeletionTimestamp() != null; } @Data public static class CategorySpec { @Schema(requiredMode = REQUIRED, minLength = 1) private String displayName; @Schema(requiredMode = REQUIRED, minLength = 1) private String slug; private String description; private String cover; @Schema(requiredMode = NOT_REQUIRED, maxLength = 255) private String template; /** *

Used to specify the template for the posts associated with the category.

*

The priority is not as high as that of the post.

*

If the post also specifies a template, the post's template will prevail.

*/ @Schema(requiredMode = NOT_REQUIRED, maxLength = 255) private String postTemplate; @Schema(requiredMode = REQUIRED, defaultValue = "0") private Integer priority; private List children; /** *

if a category is queried for related posts, the default behavior is to * query all posts under the category including its subcategories, but if this field is * set to true, cascade query behavior will be terminated here.

*

For example, if a category has subcategories A and B, and A has subcategories C and * D and C marked this field as true, when querying posts under A category,all posts under A * and B will be queried, but C and D will not be queried.

*/ private boolean preventParentPostCascadeQuery; /** *

Whether to hide the category from the category list.

*

When set to true, the category including its subcategories and related posts will * not be displayed in the category list, but it can still be accessed by permalink.

*

Limitation: It only takes effect on the theme-side categorized list and it only * allows to be set to true on the first level(root node) of categories.

*/ private boolean hideFromList; } @JsonIgnore public CategoryStatus getStatusOrDefault() { if (this.status == null) { this.status = new CategoryStatus(); } return this.status; } @Data public static class CategoryStatus { private String permalink; /** * 包括当前和其下所有层级的文章数量 (depth=max). */ public Integer postCount; /** * 包括当前和其下所有层级的已发布且公开的文章数量 (depth=max). */ public Integer visiblePostCount; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/content/Comment.java ================================================ package run.halo.app.core.extension.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import java.util.List; import java.util.Map; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import org.springframework.lang.Nullable; import org.springframework.util.CollectionUtils; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.Ref; /** * @author guqing * @see issue#2322 * @since 2.0.0 */ @Data @ToString(callSuper = true) @GVK(group = Constant.GROUP, version = Constant.VERSION, kind = Comment.KIND, plural = "comments", singular = "comment") @EqualsAndHashCode(callSuper = true) public class Comment extends AbstractExtension { public static final String KIND = "Comment"; public static final String REQUIRE_SYNC_ON_STARTUP_INDEX_NAME = "requireSyncOnStartup"; @Schema(requiredMode = REQUIRED) private CommentSpec spec; @Schema private CommentStatus status; @JsonIgnore public CommentStatus getStatusOrDefault() { if (this.status == null) { this.status = new CommentStatus(); } return this.status; } @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) public static class CommentSpec extends BaseCommentSpec { @Schema(requiredMode = REQUIRED) private Ref subjectRef; private Instant lastReadTime; } @Data public static class BaseCommentSpec { @Schema(requiredMode = REQUIRED, minLength = 1) private String raw; @Schema(requiredMode = REQUIRED, minLength = 1) private String content; @Schema(requiredMode = REQUIRED) private CommentOwner owner; private String userAgent; private String ipAddress; private Instant approvedTime; /** * The user-defined creation time default is metadata.creationTimestamp. */ private Instant creationTime; @Schema(requiredMode = REQUIRED, defaultValue = "0") private Integer priority; @Schema(requiredMode = REQUIRED, defaultValue = "false") private Boolean top; @Schema(requiredMode = REQUIRED, defaultValue = "true") private Boolean allowNotification; @Schema(requiredMode = REQUIRED, defaultValue = "false") private Boolean approved; @Schema(requiredMode = REQUIRED, defaultValue = "false") private Boolean hidden; } @Data public static class CommentOwner { public static final String KIND_EMAIL = "Email"; public static final String AVATAR_ANNO = "avatar"; public static final String WEBSITE_ANNO = "website"; public static final String EMAIL_HASH_ANNO = "email-hash"; @Schema(requiredMode = REQUIRED, minLength = 1) private String kind; @Schema(requiredMode = REQUIRED, maxLength = 64) private String name; private String displayName; private Map annotations; @Nullable @JsonIgnore public String getAnnotation(String key) { return annotations == null ? null : annotations.get(key); } public static String ownerIdentity(String kind, String name) { return kind + "#" + name; } } @Data public static class CommentStatus { private Instant lastReplyTime; private Integer replyCount; private Integer visibleReplyCount; private Integer unreadReplyCount; private Boolean hasNewReply; private Long observedVersion; } public static String toSubjectRefKey(Ref subjectRef) { return subjectRef.getGroup() + "/" + subjectRef.getKind() + "/" + subjectRef.getName(); } public static int getUnreadReplyCount(List replies, Instant lastReadTime) { if (CollectionUtils.isEmpty(replies)) { return 0; } long unreadReplyCount = replies.stream() .filter(existingReply -> { if (lastReadTime == null) { return true; } Instant creationTime = defaultIfNull(existingReply.getSpec().getCreationTime(), existingReply.getMetadata().getCreationTimestamp()); return creationTime.isAfter(lastReadTime); }) .count(); return (int) unreadReplyCount; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/content/Constant.java ================================================ package run.halo.app.core.extension.content; public enum Constant { ; public static final String GROUP = "content.halo.run"; public static final String VERSION = "v1alpha1"; public static final String LAST_READ_TIME_ANNO = "content.halo.run/last-read-time"; public static final String PERMALINK_PATTERN_ANNO = "content.halo.run/permalink-pattern"; public static final String CHECKSUM_CONFIG_ANNO = "checksum/config"; public static final String CONTENT_CHECKSUM_ANNO = "checksum/content"; } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/content/Post.java ================================================ package run.halo.app.core.extension.content; import static java.lang.Boolean.parseBoolean; import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Objects; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.MetadataOperator; import run.halo.app.infra.ConditionList; /** *

Post extension.

* * @author guqing * @see issue#2322 * @since 2.0.0 */ @Data @ToString(callSuper = true) @GVK(group = Constant.GROUP, version = Constant.VERSION, kind = Post.KIND, plural = "posts", singular = "post") @EqualsAndHashCode(callSuper = true) public class Post extends AbstractExtension { public static final String KIND = "Post"; public static final String REQUIRE_SYNC_ON_STARTUP_INDEX_NAME = "requireSyncOnStartup"; public static final GroupVersionKind GVK = GroupVersionKind.fromExtension(Post.class); public static final String CATEGORIES_ANNO = "content.halo.run/categories"; public static final String LAST_RELEASED_SNAPSHOT_ANNO = "content.halo.run/last-released-snapshot"; public static final String LAST_ASSOCIATED_TAGS_ANNO = "content.halo.run/last-associated-tags"; public static final String LAST_ASSOCIATED_CATEGORIES_ANNO = "content.halo.run/last-associated-categories"; public static final String STATS_ANNO = "content.halo.run/stats"; /** *

The key of the label that indicates that the post is scheduled to be published.

*

Can be used to query posts that are scheduled to be published.

*/ public static final String SCHEDULING_PUBLISH_LABEL = "content.halo.run/scheduling-publish"; public static final String DELETED_LABEL = "content.halo.run/deleted"; public static final String PUBLISHED_LABEL = "content.halo.run/published"; public static final String OWNER_LABEL = "content.halo.run/owner"; public static final String VISIBLE_LABEL = "content.halo.run/visible"; public static final String ARCHIVE_YEAR_LABEL = "content.halo.run/archive-year"; public static final String ARCHIVE_MONTH_LABEL = "content.halo.run/archive-month"; public static final String ARCHIVE_DAY_LABEL = "content.halo.run/archive-day"; @Schema(requiredMode = RequiredMode.REQUIRED) private PostSpec spec; @Schema private PostStatus status; @JsonIgnore public PostStatus getStatusOrDefault() { if (this.status == null) { this.status = new PostStatus(); } return status; } @JsonIgnore public boolean isDeleted() { return Objects.equals(true, spec.getDeleted()) || getMetadata().getDeletionTimestamp() != null; } @JsonIgnore public boolean isPublished() { return isPublished(this.getMetadata()); } public static boolean isPublished(MetadataOperator metadata) { var labels = metadata.getLabels(); return labels != null && parseBoolean(labels.getOrDefault(PUBLISHED_LABEL, "false")); } public static boolean isRecycled(MetadataOperator metadata) { var labels = metadata.getLabels(); return labels != null && parseBoolean(labels.getOrDefault(DELETED_LABEL, "false")); } public static boolean isPublic(PostSpec spec) { return spec.getVisible() == null || VisibleEnum.PUBLIC.equals(spec.getVisible()); } @Data public static class PostSpec { @Schema(requiredMode = RequiredMode.REQUIRED, minLength = 1) private String title; @Schema(requiredMode = RequiredMode.REQUIRED, minLength = 1) private String slug; /** * 文章引用到的已发布的内容,用于主题端显示. */ private String releaseSnapshot; private String headSnapshot; private String baseSnapshot; private String owner; private String template; private String cover; @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "false") private Boolean deleted; @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "false") private Boolean publish; private Instant publishTime; @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "false") private Boolean pinned; @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "true") private Boolean allowComment; @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "PUBLIC") private VisibleEnum visible; @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "0") private Integer priority; @Schema(requiredMode = RequiredMode.REQUIRED) private Excerpt excerpt; private List categories; private List tags; private List> htmlMetas; } @Data public static class PostStatus { private String phase; @Schema private ConditionList conditions; private String permalink; private String excerpt; private Boolean inProgress; private Integer commentsCount; private List contributors; /** * see {@link Category.CategorySpec#isHideFromList()}. */ private Boolean hideFromList; private Instant lastModifyTime; private Long observedVersion; @JsonIgnore public ConditionList getConditionsOrDefault() { if (this.conditions == null) { this.conditions = new ConditionList(); } return conditions; } } @Data public static class Excerpt { @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "true") private Boolean autoGenerate; private String raw; } public enum PostPhase { DRAFT, PENDING_APPROVAL, PUBLISHED, FAILED; /** * Convert string value to {@link PostPhase}. * * @param value enum value string * @return {@link PostPhase} if found, otherwise null */ public static PostPhase from(String value) { for (PostPhase phase : PostPhase.values()) { if (phase.name().equalsIgnoreCase(value)) { return phase; } } return null; } } public enum VisibleEnum { PUBLIC, INTERNAL, PRIVATE; /** * Convert value string to {@link VisibleEnum}. * * @param value enum value string * @return {@link VisibleEnum} if found, otherwise null */ public static VisibleEnum from(String value) { for (VisibleEnum visible : VisibleEnum.values()) { if (visible.name().equalsIgnoreCase(value)) { return visible; } } return null; } } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/content/Reply.java ================================================ package run.halo.app.core.extension.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; import org.springframework.lang.NonNull; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; /** * @author guqing * @see issue#2322 * @since 2.0.0 */ @Data @ToString(callSuper = true) @GVK(group = Constant.GROUP, version = Constant.VERSION, kind = Reply.KIND, plural = "replies", singular = "reply") @EqualsAndHashCode(callSuper = true) public class Reply extends AbstractExtension { public static final String KIND = "Reply"; public static final String REQUIRE_SYNC_ON_STARTUP_INDEX_NAME = "requireSyncOnStartup"; @Schema(requiredMode = REQUIRED) private ReplySpec spec; @Schema @Getter(onMethod_ = @NonNull) private Status status = new Status(); @Data @EqualsAndHashCode(callSuper = true) public static class ReplySpec extends Comment.BaseCommentSpec { @Schema(requiredMode = REQUIRED, minLength = 1) private String commentName; private String quoteReply; } @Data @Schema(name = "ReplyStatus") public static class Status { private Long observedVersion; } public void setStatus(Status status) { this.status = status == null ? new Status() : status; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/content/SinglePage.java ================================================ package run.halo.app.core.extension.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import java.util.List; import java.util.Map; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.MetadataUtil; /** *

Single page extension.

* * @author guqing * @since 2.0.0 */ @Data @ToString(callSuper = true) @GVK(group = Constant.GROUP, version = Constant.VERSION, kind = SinglePage.KIND, plural = "singlepages", singular = "singlepage") @EqualsAndHashCode(callSuper = true) public class SinglePage extends AbstractExtension { public static final String KIND = "SinglePage"; public static final GroupVersionKind GVK = GroupVersionKind.fromExtension(SinglePage.class); public static final String DELETED_LABEL = "content.halo.run/deleted"; public static final String PUBLISHED_LABEL = "content.halo.run/published"; public static final String LAST_RELEASED_SNAPSHOT_ANNO = "content.halo.run/last-released-snapshot"; public static final String OWNER_LABEL = "content.halo.run/owner"; public static final String VISIBLE_LABEL = "content.halo.run/visible"; @Schema(requiredMode = REQUIRED) private SinglePageSpec spec; @Schema private SinglePageStatus status; @JsonIgnore public SinglePageStatus getStatusOrDefault() { if (this.status == null) { this.status = new SinglePageStatus(); } return this.status; } @JsonIgnore public boolean isPublished() { Map labels = getMetadata().getLabels(); return labels != null && labels.getOrDefault(PUBLISHED_LABEL, "false").equals("true"); } @Data public static class SinglePageSpec { @Schema(requiredMode = REQUIRED, minLength = 1) private String title; @Schema(requiredMode = REQUIRED, minLength = 1) private String slug; /** * 引用到的已发布的内容,用于主题端显示. */ private String releaseSnapshot; private String headSnapshot; private String baseSnapshot; private String owner; private String template; private String cover; @Schema(requiredMode = REQUIRED, defaultValue = "false") private Boolean deleted; @Schema(requiredMode = REQUIRED, defaultValue = "false") private Boolean publish; private Instant publishTime; @Schema(requiredMode = REQUIRED, defaultValue = "false") private Boolean pinned; @Schema(requiredMode = REQUIRED, defaultValue = "true") private Boolean allowComment; @Schema(requiredMode = REQUIRED, defaultValue = "PUBLIC") private Post.VisibleEnum visible; @Schema(requiredMode = REQUIRED, defaultValue = "0") private Integer priority; @Schema(requiredMode = REQUIRED) private Post.Excerpt excerpt; private List> htmlMetas; } @Data @EqualsAndHashCode(callSuper = true) public static class SinglePageStatus extends Post.PostStatus { } public static void changePublishedState(SinglePage page, boolean value) { Map labels = MetadataUtil.nullSafeLabels(page); labels.put(PUBLISHED_LABEL, String.valueOf(value)); } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/content/Snapshot.java ================================================ package run.halo.app.core.extension.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import java.util.LinkedHashSet; import java.util.Set; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import org.springframework.lang.NonNull; import org.springframework.util.Assert; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.Ref; /** * @author guqing * @see issue#2322 * @since 2.0.0 */ @Data @ToString(callSuper = true) @GVK(group = Constant.GROUP, version = Constant.VERSION, kind = Snapshot.KIND, plural = "snapshots", singular = "snapshot") @EqualsAndHashCode(callSuper = true) public class Snapshot extends AbstractExtension { public static final String KIND = "Snapshot"; public static final String KEEP_RAW_ANNO = "content.halo.run/keep-raw"; public static final String PATCHED_CONTENT_ANNO = "content.halo.run/patched-content"; public static final String PATCHED_RAW_ANNO = "content.halo.run/patched-raw"; @Schema(requiredMode = REQUIRED) private SnapShotSpec spec; @Data public static class SnapShotSpec { @Schema(requiredMode = REQUIRED) private Ref subjectRef; /** * such as: markdown | html | json | asciidoc | latex. */ @Schema(requiredMode = REQUIRED, minLength = 1, maxLength = 50) private String rawType; private String rawPatch; private String contentPatch; private String parentSnapshotName; private Instant lastModifyTime; @Schema(requiredMode = REQUIRED, minLength = 1) private String owner; private Set contributors; } public static void addContributor(Snapshot snapshot, String name) { Assert.notNull(name, "The username must not be null."); Set contributors = snapshot.getSpec().getContributors(); if (contributors == null) { contributors = new LinkedHashSet<>(); snapshot.getSpec().setContributors(contributors); } contributors.add(name); } /** * Check if the given snapshot is a base snapshot. * * @param snapshot must not be null. * @return true if the given snapshot is a base snapshot; false otherwise. */ public static boolean isBaseSnapshot(@NonNull Snapshot snapshot) { var annotations = snapshot.getMetadata().getAnnotations(); if (annotations == null) { return false; } return Boolean.parseBoolean(annotations.get(Snapshot.KEEP_RAW_ANNO)); } public static String toSubjectRefKey(Ref subjectRef) { return subjectRef.getGroup() + "/" + subjectRef.getKind() + "/" + subjectRef.getName(); } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/content/Tag.java ================================================ package run.halo.app.core.extension.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.GroupVersionKind; /** * @author guqing * @see issue#2322 * @since 2.0.0 */ @Data @ToString(callSuper = true) @GVK(group = Constant.GROUP, version = Constant.VERSION, kind = Tag.KIND, plural = "tags", singular = "tag") @EqualsAndHashCode(callSuper = true) public class Tag extends AbstractExtension { public static final String KIND = "Tag"; public static final GroupVersionKind GVK = GroupVersionKind.fromExtension(Tag.class); public static final String REQUIRE_SYNC_ON_STARTUP_INDEX_NAME = "requireSyncOnStartup"; @Schema(requiredMode = REQUIRED) private TagSpec spec; @Schema private TagStatus status; @Data public static class TagSpec { @Schema(requiredMode = REQUIRED, minLength = 1) private String displayName; @Schema(requiredMode = REQUIRED, minLength = 1) private String slug; private String description; /** * Color regex explanation. *
         * ^                 # start of the line
         * #                 # start with a number sign `#`
         * (                 # start of (group 1)
         *   [a-fA-F0-9]{6}  # support z-f, A-F and 0-9, with a length of 6
         *   |               # or
         *   [a-fA-F0-9]{3}  # support z-f, A-F and 0-9, with a length of 3
         * )                 # end of (group 1)
         * $                 # end of the line
         * 
*/ @Schema(pattern = "^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$") private String color; private String cover; } @JsonIgnore public TagStatus getStatusOrDefault() { if (this.status == null) { this.status = new TagStatus(); } return this.status; } @Data public static class TagStatus { private String permalink; public Integer visiblePostCount; public Integer postCount; private Long observedVersion; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/endpoint/CustomEndpoint.java ================================================ package run.halo.app.core.extension.endpoint; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.extension.GroupVersion; /** * RouterFunction provider for custom endpoints. * * @author johnniang */ public interface CustomEndpoint { RouterFunction endpoint(); default GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("api.console.halo.run/v1alpha1"); } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/endpoint/SortResolver.java ================================================ package run.halo.app.core.extension.endpoint; import org.springframework.core.MethodParameter; import org.springframework.data.domain.Sort; import org.springframework.data.web.ReactiveSortHandlerMethodArgumentResolver; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.web.server.ServerWebExchange; public interface SortResolver { SortResolver defaultInstance = new DefaultSortResolver(); @NonNull Sort resolve(@NonNull ServerWebExchange exchange); class DefaultSortResolver extends ReactiveSortHandlerMethodArgumentResolver implements SortResolver { @Override @NonNull protected Sort getDefaultFromAnnotationOrFallback(@Nullable MethodParameter parameter) { return Sort.unsorted(); } @Override public Sort resolve(ServerWebExchange exchange) { return resolveArgumentValue(null, null, exchange); } } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/notification/Notification.java ================================================ package run.halo.app.core.extension.notification; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import lombok.Data; import lombok.EqualsAndHashCode; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; /** *

{@link Notification} is a custom extension that used to store notification information for * inner use, it's on-site notification.

* *

Supports the following operations:

*
    *
  • Marked as read: {@link NotificationSpec#setUnread(boolean)}
  • *
  • Get the last read time: {@link NotificationSpec#getLastReadAt()}
  • *
  • Filter by recipient: {@link NotificationSpec#getRecipient()}
  • *
* * @author guqing * @see Reason * @see ReasonType * @since 2.10.0 */ @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "notification.halo.run", version = "v1alpha1", kind = "Notification", plural = "notifications", singular = "notification") public class Notification extends AbstractExtension { @Schema private NotificationSpec spec; @Data public static class NotificationSpec { @Schema(requiredMode = REQUIRED, minLength = 1, description = "The name of user") private String recipient; @Schema(requiredMode = REQUIRED, minLength = 1, description = "The name of reason") private String reason; @Schema(requiredMode = REQUIRED, minLength = 1) private String title; @Schema(requiredMode = REQUIRED) private String rawContent; @Schema(requiredMode = REQUIRED) private String htmlContent; private boolean unread; private Instant lastReadAt; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/notification/NotificationTemplate.java ================================================ package run.halo.app.core.extension.notification; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; /** *

{@link NotificationTemplate} is a custom extension that defines a notification template.

*

It describes the notification template's name, description, and the template content.

*

{@link Spec#getReasonSelector()} is used to select the template by reasonType and language, * if multiple templates are matched, the best match will be selected. This is useful when you * want to override the default template.

* * @author guqing * @since 2.10.0 */ @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "notification.halo.run", version = "v1alpha1", kind = "NotificationTemplate", plural = "notificationtemplates", singular = "notificationtemplate") public class NotificationTemplate extends AbstractExtension { @Schema private Spec spec; @Data @Schema(name = "NotificationTemplateSpec") public static class Spec { @Schema private ReasonSelector reasonSelector; @Schema private Template template; } @Data @Schema(name = "TemplateContent") public static class Template { @Schema(requiredMode = REQUIRED, minLength = 1) private String title; private String htmlBody; private String rawBody; } @Data public static class ReasonSelector { @Schema(requiredMode = REQUIRED, minLength = 1) private String reasonType; @Schema(requiredMode = REQUIRED, minLength = 1, defaultValue = "default") private String language; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/notification/NotifierDescriptor.java ================================================ package run.halo.app.core.extension.notification; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; /** *

{@link NotifierDescriptor} is a custom extension that defines a notifier.

*

It describes the notifier's name, description, and the extension name of the notifier to * let the user know what the notifier is and what it can do in the UI and also let the * {@code NotificationCenter} know how to load the notifier and prepare the notifier's settings.

* * @author guqing * @since 2.10.0 */ @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "notification.halo.run", version = "v1alpha1", kind = "NotifierDescriptor", plural = "notifierDescriptors", singular = "notifierDescriptor") public class NotifierDescriptor extends AbstractExtension { @Schema private Spec spec; @Data @Schema(name = "NotifierDescriptorSpec") public static class Spec { @Schema(requiredMode = REQUIRED, minLength = 1) private String displayName; private String description; @Schema(requiredMode = REQUIRED, minLength = 1) private String notifierExtName; private SettingRef senderSettingRef; private SettingRef receiverSettingRef; } @Data @Schema(name = "NotifierSettingRef") public static class SettingRef { @Schema(requiredMode = REQUIRED) private String name; @Schema(requiredMode = REQUIRED) private String group; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/notification/Reason.java ================================================ package run.halo.app.core.extension.notification; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.notification.ReasonAttributes; /** *

{@link Reason} is a custom extension that defines a reason for a notification, It represents * an instance of a {@link ReasonType}.

*

It can be understood as an event that triggers a notification.

* * @author guqing * @since 2.10.0 */ @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "notification.halo.run", version = "v1alpha1", kind = "Reason", plural = "reasons", singular = "reason") public class Reason extends AbstractExtension { @Schema private Spec spec; @Data @Accessors(chain = true) @Schema(name = "ReasonSpec") public static class Spec { @Schema(requiredMode = REQUIRED) private String reasonType; @Schema(requiredMode = REQUIRED) private Subject subject; @Schema(requiredMode = REQUIRED) private String author; @Schema(implementation = ReasonAttributes.class, requiredMode = NOT_REQUIRED, description = "Attributes used to transfer data") private ReasonAttributes attributes; } @Data @Builder @NoArgsConstructor @AllArgsConstructor @Schema(name = "ReasonSubject") public static class Subject { @Schema(requiredMode = REQUIRED) private String apiVersion; @Schema(requiredMode = REQUIRED) private String kind; @Schema(requiredMode = REQUIRED) private String name; @Schema(requiredMode = REQUIRED) private String title; private String url; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/notification/ReasonType.java ================================================ package run.halo.app.core.extension.notification; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.Data; import lombok.EqualsAndHashCode; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; /** *

{@link ReasonType} is a custom extension that defines a type of reason.

*

One {@link ReasonType} can have multiple {@link Reason}s to notify.

* * @author guqing * @see NotificationTemplate * @see Reason * @since 2.10.0 */ @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "notification.halo.run", version = "v1alpha1", kind = "ReasonType", plural = "reasontypes", singular = "reasontype") public class ReasonType extends AbstractExtension { public static final String LOCALIZED_RESOURCE_NAME_ANNO = "notification.halo.run/localized-resource-name"; @Schema private Spec spec; @Data @Schema(name = "ReasonTypeSpec") public static class Spec { @Schema(requiredMode = REQUIRED, minLength = 1) private String displayName; @Schema(requiredMode = REQUIRED, minLength = 1) private String description; private List properties; } @Data public static class ReasonProperty { @Schema(requiredMode = REQUIRED, minLength = 1) private String name; @Schema(requiredMode = REQUIRED, minLength = 1) private String type; private String description; @Schema(defaultValue = "false") private boolean optional; } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/notification/Subscription.java ================================================ package run.halo.app.core.extension.notification; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.apache.commons.lang3.StringUtils.defaultString; import io.swagger.v3.oas.annotations.media.Schema; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; /** *

{@link Subscription} is a custom extension that defines a subscriber to be notified when a * certain {@link Reason} is triggered.

*

It holds a {@link Subscriber} to the user to be notified, a {@link InterestReason} to * subscribe to.

* * @author guqing * @since 2.10.0 */ @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "notification.halo.run", version = "v1alpha1", kind = "Subscription", plural = "subscriptions", singular = "subscription") public class Subscription extends AbstractExtension { @Schema private Spec spec; @Data @Schema(name = "SubscriptionSpec") public static class Spec { @Schema(requiredMode = REQUIRED, description = "The subscriber to be notified") private Subscriber subscriber; @Schema(requiredMode = REQUIRED, description = "The token to unsubscribe") private String unsubscribeToken; @Schema(requiredMode = REQUIRED, description = "The reason to be interested in") private InterestReason reason; @Schema(description = "Perhaps users need to unsubscribe and " + "interact without receiving notifications again") private boolean disabled; } @Data public static class InterestReason { @Schema(requiredMode = REQUIRED, description = "The name of the reason definition to be " + "interested in") private String reasonType; @Schema(requiredMode = REQUIRED, description = "The subject name of reason type to be" + " interested in") private ReasonSubject subject; @Schema(requiredMode = NOT_REQUIRED, description = "The expression to be interested in") private String expression; /** *

Since 2.15.0, we have added a new field expression to the * InterestReason object, so subject can be null.

*

In this particular scenario, when the subject is null, we assign it a * default ReasonSubject object. The properties of this object are set to * specific values that do not occur in actual applications, thus we can consider this as * nonexistent data. * The purpose of this approach is to maintain backward compatibility, even if the * subject can be null in the new version of the code.

*/ public static void ensureSubjectHasValue(InterestReason interestReason) { if (interestReason.getSubject() == null) { interestReason.setSubject(createFallbackSubject()); } } /** * Check if the given reason subject is a fallback subject. */ public static boolean isFallbackSubject(ReasonSubject reasonSubject) { if (reasonSubject == null) { return true; } var fallback = createFallbackSubject(); return fallback.getKind().equals(reasonSubject.getKind()) && fallback.getApiVersion().equals(reasonSubject.getApiVersion()); } static ReasonSubject createFallbackSubject() { return ReasonSubject.builder() .apiVersion("notification.halo.run/v1alpha1") .kind("NonexistentKind") .build(); } } @Data @Builder @AllArgsConstructor @NoArgsConstructor @Schema(name = "InterestReasonSubject") public static class ReasonSubject { @Schema(requiredMode = NOT_REQUIRED, description = "if name is not specified, it presents " + "all subjects of the specified reason type and custom resources") private String name; @Schema(requiredMode = REQUIRED, minLength = 1) private String apiVersion; @Schema(requiredMode = REQUIRED, minLength = 1) private String kind; @Override public String toString() { return kind + "#" + apiVersion + "/" + defaultString(name); } } @Data @Schema(name = "SubscriptionSubscriber") public static class Subscriber { @Schema(requiredMode = REQUIRED, minLength = 1) private String name; @Override public String toString() { return name; } } /** * Generate unsubscribe token for unsubscribe. * * @return unsubscribe token */ public static String generateUnsubscribeToken() { return UUID.randomUUID().toString(); } } ================================================ FILE: api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java ================================================ package run.halo.app.core.extension.service; import java.net.URI; import java.net.URL; import java.time.Duration; import java.util.Map; import java.util.function.Consumer; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.core.extension.attachment.Attachment; /** * AttachmentService. * * @author johnniang * @since 2.5.0 */ public interface AttachmentService { /** * Uploads the given attachment to specific storage using handlers in plugins. *

* If no handler can be found to upload the given attachment, ServerError exception will be * thrown. * * @param policyName is attachment policy name. * @param groupName is group name the attachment belongs. * @param filePart contains filename, content and media type. * @param beforeCreating is an attachment modifier before creating. * @return attachment. */ Mono upload( @NonNull String username, @NonNull String policyName, @Nullable String groupName, @NonNull FilePart filePart, @Nullable Consumer beforeCreating); /** * Uploads the given attachment to specific storage using handlers in plugins. Please note * that we will make sure the request is authenticated, or an unauthorized exception throws. *

* If no handler can be found to upload the given attachment, ServerError exception will be * thrown. * * @param policyName is attachment policy name. * @param groupName is group name the attachment belongs. * @param filename is filename of the attachment. * @param content is binary data of the attachment. * @param mediaType is media type of the attachment. * @return attachment. */ Mono upload(@NonNull String policyName, @Nullable String groupName, @NonNull String filename, @NonNull Flux content, @Nullable MediaType mediaType); /** * Deletes an attachment using handlers in plugins. *

* If no handler can be found to delete the given attachment, Mono.empty() will return. * * @param attachment is to be deleted. * @return deleted attachment. */ Mono delete(Attachment attachment); /** * Gets permalink using handlers in plugins. *

* If no handler can be found to delete the given attachment, Mono.empty() will return. * * @param attachment is created attachment. * @return permalink */ Mono getPermalink(Attachment attachment); /** * Gets shared URL using handlers in plugins. *

* If no handler can be found to delete the given attachment, Mono.empty() will return. * * @param attachment is created attachment. * @param ttl is time to live of the shared URL. * @return time-to-live shared URL. Please note that, if the attachment is stored in local, the * shared URL is equal to permalink. */ Mono getSharedURL(Attachment attachment, Duration ttl); /** * Gets thumbnail links using handlers in plugins. Please make sure the attachment has * permalink. * * @param attachment is created attachment. * @return thumbnail links */ Mono> getThumbnailLinks(Attachment attachment); /** * Transfer external links to attachments. * * @param url external url * @param policyName policy name * @param groupName group name * @param filename filename * @return attachment */ Mono uploadFromUrl(@NonNull URL url, @NonNull String policyName, String groupName, String filename); } ================================================ FILE: api/src/main/java/run/halo/app/core/user/service/RoleService.java ================================================ package run.halo.app.core.user.service; import java.util.Collection; import java.util.Map; import java.util.Set; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.RoleBinding.Subject; /** * @author guqing * @since 2.0.0 */ public interface RoleService { Flux listRoleBindings(Subject subject); Flux getRolesByUsername(String username); Mono>> getRolesByUsernames(Collection usernames); Mono contains(Collection source, Collection candidates); /** * This method lists all role templates as permissions recursively according to given role * name set. * * @param names is role name set. * @return an array of permissions. */ Flux listPermissions(Set names); Flux listDependenciesFlux(Set names); /** * List roles by role names. * * @param roleNames role names * @return roles */ Flux list(Set roleNames); /** * List roles by role names. * * @param roleNames role names * @param excludeHidden should exclude hidden roles * @return roles */ Flux list(Set roleNames, boolean excludeHidden); } ================================================ FILE: api/src/main/java/run/halo/app/core/user/service/SignUpData.java ================================================ package run.halo.app.core.user.service; import jakarta.validation.Constraint; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import jakarta.validation.Payload; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.Objects; import lombok.Data; import run.halo.app.infra.ValidationUtils; /** * Sign up data. * * @author johnniang * @since 2.20.0 */ @Data @SignUpData.SignUpDataConstraint public class SignUpData { @NotBlank @Size(min = 4, max = 63) @Pattern(regexp = ValidationUtils.NAME_REGEX, message = "{validation.error.username.pattern}") private String username; @NotBlank private String displayName; @Email private String email; private String emailCode; @NotBlank @Size(min = 5, max = 257) @Pattern(regexp = ValidationUtils.PASSWORD_REGEX, message = "{validation.error.password.pattern}") private String password; @NotBlank private String confirmPassword; @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = {SignUpDataConstraintValidator.class}) public @interface SignUpDataConstraint { String message() default ""; Class[] groups() default {}; Class[] payload() default {}; } private static class SignUpDataConstraintValidator implements ConstraintValidator { @Override public boolean isValid(SignUpData signUpData, ConstraintValidatorContext context) { var isValid = Objects.equals(signUpData.getPassword(), signUpData.getConfirmPassword()); if (!isValid) { context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate( "{signup.error.confirm-password-not-match}" ) .addPropertyNode("confirmPassword") .addConstraintViolation(); } return isValid; } } } ================================================ FILE: api/src/main/java/run/halo/app/core/user/service/UserPostCreatingHandler.java ================================================ package run.halo.app.core.user.service; import org.pf4j.ExtensionPoint; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; /** * User post-creating handler. * * @author johnniang * @since 2.20.8 */ public interface UserPostCreatingHandler extends ExtensionPoint { /** * Do something after creating user. * * @param user create user. * @return {@code Mono.empty()} if handling successfully. */ Mono postCreating(User user); } ================================================ FILE: api/src/main/java/run/halo/app/core/user/service/UserPreCreatingHandler.java ================================================ package run.halo.app.core.user.service; import org.pf4j.ExtensionPoint; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; /** * User pre-creating handler. * * @author johnniang * @since 2.20.8 */ public interface UserPreCreatingHandler extends ExtensionPoint { /** * Do something before user creating. * * @param user modifiable user detail * @return {@code Mono.empty()} if handling successfully. */ Mono preCreating(User user); } ================================================ FILE: api/src/main/java/run/halo/app/core/user/service/UserService.java ================================================ package run.halo.app.core.user.service; import java.util.Collection; import java.util.Set; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; public interface UserService { String GHOST_USER_NAME = "ghost"; /** * Get user by username. * * @param username username * @return the mono user or user not found error */ Mono getUser(String username); /** * Get user by verified email. * * @param email the verified email * @return the mono user or empty if not found */ Mono findUserByVerifiedEmail(String email); Mono getUserOrGhost(String username); Flux getUsersOrGhosts(Collection names); Mono updatePassword(String username, String newPassword); Mono updateWithRawPassword(String username, String rawPassword); Mono grantRoles(String username, Set roles); /** * Check if the user has sufficient roles. * * @param roles roles to check * @return a Mono that emits true if the user has all the roles, false otherwise */ Mono hasSufficientRoles(Collection roles); Mono signUp(SignUpData signUpData); Mono createUser(User user, Set roles); Mono confirmPassword(String username, String rawPassword); Flux listByEmail(String email); Mono checkEmailAlreadyVerified(String email); String encryptPassword(String rawPassword); Mono disable(String username); Mono enable(String username); } ================================================ FILE: api/src/main/java/run/halo/app/event/post/PostDeletedEvent.java ================================================ package run.halo.app.event.post; import run.halo.app.core.extension.content.Post; import run.halo.app.plugin.SharedEvent; @SharedEvent public class PostDeletedEvent extends PostEvent { private final Post post; public PostDeletedEvent(Object source, Post post) { super(source, post.getMetadata().getName()); this.post = post; } /** * Get original post. * * @return original post. */ public Post getPost() { return post; } } ================================================ FILE: api/src/main/java/run/halo/app/event/post/PostEvent.java ================================================ package run.halo.app.event.post; import org.springframework.context.ApplicationEvent; /** * An abstract class for post events. * * @author johnniang */ public abstract class PostEvent extends ApplicationEvent { private final String name; public PostEvent(Object source, String name) { super(source); this.name = name; } /** * Gets post metadata name. * * @return post metadata name */ public String getName() { return name; } } ================================================ FILE: api/src/main/java/run/halo/app/event/post/PostPublishedEvent.java ================================================ package run.halo.app.event.post; import run.halo.app.plugin.SharedEvent; @SharedEvent public class PostPublishedEvent extends PostEvent { public PostPublishedEvent(Object source, String postName) { super(source, postName); } } ================================================ FILE: api/src/main/java/run/halo/app/event/post/PostUnpublishedEvent.java ================================================ package run.halo.app.event.post; import run.halo.app.plugin.SharedEvent; @SharedEvent public class PostUnpublishedEvent extends PostEvent { public PostUnpublishedEvent(Object source, String postName) { super(source, postName); } } ================================================ FILE: api/src/main/java/run/halo/app/event/post/PostUpdatedEvent.java ================================================ package run.halo.app.event.post; import run.halo.app.plugin.SharedEvent; @SharedEvent public class PostUpdatedEvent extends PostEvent { public PostUpdatedEvent(Object source, String postName) { super(source, postName); } } ================================================ FILE: api/src/main/java/run/halo/app/event/post/PostVisibleChangedEvent.java ================================================ package run.halo.app.event.post; import org.springframework.lang.Nullable; import run.halo.app.core.extension.content.Post; import run.halo.app.plugin.SharedEvent; @SharedEvent public class PostVisibleChangedEvent extends PostEvent { @Nullable private final Post.VisibleEnum oldVisible; private final Post.VisibleEnum newVisible; public PostVisibleChangedEvent(Object source, String postName, @Nullable Post.VisibleEnum oldVisible, Post.VisibleEnum newVisible) { super(source, postName); this.oldVisible = oldVisible; this.newVisible = newVisible; } @Nullable public Post.VisibleEnum getOldVisible() { return oldVisible; } public Post.VisibleEnum getNewVisible() { return newVisible; } } ================================================ FILE: api/src/main/java/run/halo/app/event/user/UserConnectionDisconnectedEvent.java ================================================ package run.halo.app.event.user; import lombok.Getter; import org.springframework.context.ApplicationEvent; import run.halo.app.core.extension.UserConnection; import run.halo.app.plugin.SharedEvent; /** * An event that will be triggered after a user connection is disconnected. * * @author johnniang * @since 2.20.0 */ @SharedEvent public class UserConnectionDisconnectedEvent extends ApplicationEvent { @Getter private final UserConnection userConnection; public UserConnectionDisconnectedEvent(Object source, UserConnection userConnection) { super(source); this.userConnection = userConnection; } } ================================================ FILE: api/src/main/java/run/halo/app/event/user/UserLoginEvent.java ================================================ package run.halo.app.event.user; import lombok.Getter; import org.springframework.context.ApplicationEvent; import run.halo.app.core.extension.User; import run.halo.app.plugin.SharedEvent; /** * User login event. * * @author lywq **/ @SharedEvent public class UserLoginEvent extends ApplicationEvent { @Getter private final User user; public UserLoginEvent(Object source, User user) { super(source); this.user = user; } } ================================================ FILE: api/src/main/java/run/halo/app/event/user/UserLogoutEvent.java ================================================ package run.halo.app.event.user; import lombok.Getter; import org.springframework.context.ApplicationEvent; import run.halo.app.core.extension.User; import run.halo.app.plugin.SharedEvent; /** * User logout event. * * @author lywq **/ @SharedEvent public class UserLogoutEvent extends ApplicationEvent { @Getter private final User user; public UserLogoutEvent(Object source, User user) { super(source); this.user = user; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/AbstractExtension.java ================================================ package run.halo.app.extension; import lombok.Data; /** * AbstractExtension contains basic structure of Extension and implements the Extension interface. * * @author johnniang */ @Data public abstract class AbstractExtension implements Extension { private String apiVersion; private String kind; private MetadataOperator metadata; @Override public String getApiVersion() { var apiVersionFromGvk = Extension.super.getApiVersion(); return apiVersionFromGvk != null ? apiVersionFromGvk : this.apiVersion; } @Override public String getKind() { var kindFromGvk = Extension.super.getKind(); return kindFromGvk != null ? kindFromGvk : this.kind; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/Comparators.java ================================================ package run.halo.app.extension; import java.time.Instant; import java.util.Comparator; public enum Comparators { ; public static Comparator compareCreationTimestamp(boolean asc) { var comparator = Comparator.comparing(e -> e.getMetadata().getCreationTimestamp()); return asc ? comparator : comparator.reversed(); } public static Comparator compareName(boolean asc) { var comparator = Comparator.comparing(e -> e.getMetadata().getName()); return asc ? comparator : comparator.reversed(); } public static Comparator defaultComparator() { Comparator comparator = compareCreationTimestamp(false); comparator = comparator.thenComparing(compareName(true)); return comparator; } /** * Get a nulls comparator. * * @param isAscending is ascending * @return if ascending, return nulls high, else return nulls low */ public static Comparator nullsComparator(boolean isAscending) { return isAscending ? org.springframework.util.comparator.Comparators.nullsHigh() : org.springframework.util.comparator.Comparators.nullsLow(); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/ConfigMap.java ================================================ package run.halo.app.extension; import java.util.LinkedHashMap; import java.util.Map; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import org.jspecify.annotations.Nullable; /** *

ConfigMap holds configuration data to consume.

* * @author guqing * @since 2.0.0 */ @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @GVK(group = "", version = "v1alpha1", kind = ConfigMap.KIND, plural = "configmaps", singular = "configmap") public class ConfigMap extends AbstractExtension { public static final String KIND = "ConfigMap"; @Nullable private Map data; public ConfigMap putDataItem(String key, String dataItem) { if (this.data == null) { this.data = new LinkedHashMap<>(); } this.data.put(key, dataItem); return this; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/DefaultExtensionMatcher.java ================================================ package run.halo.app.extension; import lombok.Builder; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.util.CollectionUtils; import run.halo.app.extension.index.query.Condition; import run.halo.app.extension.index.query.Queries; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.extension.router.selector.LabelSelector; @Getter @RequiredArgsConstructor @Builder(builderMethodName = "internalBuilder") public class DefaultExtensionMatcher implements ExtensionMatcher { private final ExtensionClient client; private final GroupVersionKind gvk; private final LabelSelector labelSelector; private final FieldSelector fieldSelector; public static DefaultExtensionMatcherBuilder builder(ExtensionClient client, GroupVersionKind gvk) { return internalBuilder().client(client).gvk(gvk); } /** * Match the given extension with the current matcher. * * @param extension extension to match * @return true if the extension matches the current matcher */ @Override public boolean match(Extension extension) { if (!gvk.equals(extension.groupVersionKind())) { return false; } if (!hasFieldSelector() && !hasLabelSelector()) { return true; } var listOptions = new ListOptions(); listOptions.setLabelSelector(labelSelector); var fieldQuery = Queries.equal("metadata.name", extension.getMetadata().getName()); if (hasFieldSelector()) { fieldQuery = fieldQuery.and((Condition) fieldSelector.query()); } listOptions.setFieldSelector(new FieldSelector(fieldQuery)); return client.countBy(extension.getClass(), listOptions) > 0; } boolean hasFieldSelector() { return fieldSelector != null && fieldSelector.query() != null; } boolean hasLabelSelector() { return labelSelector != null && !CollectionUtils.isEmpty(labelSelector.getConditions()); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/Extension.java ================================================ package run.halo.app.extension; import java.util.Comparator; import java.util.Objects; /** * Extension is an interface which represents an Extension. It contains setters and getters of * GroupVersionKind and Metadata. */ public interface Extension extends ExtensionOperator, Comparable { @Override default int compareTo(Extension another) { if (another == null || another.getMetadata() == null) { return 1; } if (getMetadata() == null) { return -1; } return Objects.compare(getMetadata().getName(), another.getMetadata().getName(), Comparator.naturalOrder()); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/ExtensionClient.java ================================================ package run.halo.app.extension; import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.function.Predicate; import org.springframework.data.domain.Sort; import run.halo.app.extension.index.IndexedQueryEngine; /** * ExtensionClient is an interface which contains some operations on Extension instead of * ExtensionStore. *

* Please note that this client can only use in non-reactive environment. If you want to * use Extension client in reactive environment, please use {@link ReactiveExtensionClient} instead. * * @author johnniang */ public interface ExtensionClient { /** * Lists Extensions by Extension type, filter and sorter. * * @param type is the class type of Extension. * @param predicate filters the reEnqueue. * @param comparator sorts the reEnqueue. * @param is Extension type. * @return all filtered and sorted Extensions. */ List list(Class type, Predicate predicate, Comparator comparator); /** * Lists Extensions by Extension type, filter, sorter and page info. * * @param type is the class type of Extension. * @param predicate filters the reEnqueue. * @param comparator sorts the reEnqueue. * @param page is page number which starts from 0. * @param size is page size. * @param is Extension type. * @return a list of Extensions. * @deprecated use {@link #listAll(Class, ListOptions, Sort)} instead. */ @Deprecated ListResult list(Class type, Predicate predicate, Comparator comparator, int page, int size); List listAll(Class type, ListOptions options, Sort sort); List listAllNames(Class type, ListOptions options, Sort sort); List listTopNames(Class type, ListOptions options, Sort sort, int topN); ListResult listBy(Class type, ListOptions options, PageRequest page); ListResult listNamesBy(Class type, ListOptions options, PageRequest page); long countBy(Class type, ListOptions options); /** * Fetches Extension by its type and name. * * @param type is Extension type. * @param name is Extension name. * @param is Extension type. * @return an optional Extension. */ Optional fetch(Class type, String name); Optional fetch(GroupVersionKind gvk, String name); /** * Creates an Extension. * * @param extension is fresh Extension to be created. Please make sure the Extension name does * not exist. * @param is Extension type. */ void create(E extension); /** * Updates an Extension. * * @param extension is an Extension to be updated. Please make sure the resource version is * latest. * @param is Extension type. */ void update(E extension); /** * Deletes an Extension. * * @param extension is an Extension to be deleted. Please make sure the resource version is * latest. * @param is Extension type. */ void delete(E extension); @Deprecated(forRemoval = true, since = "2.22.0") IndexedQueryEngine indexedQueryEngine(); void watch(Watcher watcher); } ================================================ FILE: api/src/main/java/run/halo/app/extension/ExtensionMatcher.java ================================================ package run.halo.app.extension; @FunctionalInterface public interface ExtensionMatcher { boolean match(Extension extension); } ================================================ FILE: api/src/main/java/run/halo/app/extension/ExtensionOperator.java ================================================ package run.halo.app.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import java.util.function.Predicate; import org.springframework.util.StringUtils; /** * ExtensionOperator contains some getters and setters for required fields of Extension. * * @author johnniang */ public interface ExtensionOperator { @Schema(requiredMode = REQUIRED) @JsonProperty("apiVersion") default String getApiVersion() { final var gvk = getClass().getAnnotation(GVK.class); if (gvk == null) { // return null if having no GVK annotation return null; } if (StringUtils.hasText(gvk.group())) { return gvk.group() + "/" + gvk.version(); } return gvk.version(); } @Schema(requiredMode = REQUIRED) @JsonProperty("kind") default String getKind() { final var gvk = getClass().getAnnotation(GVK.class); if (gvk == null) { // return null if having no GVK annotation return null; } return gvk.kind(); } @Schema(requiredMode = REQUIRED, implementation = Metadata.class) @JsonProperty("metadata") MetadataOperator getMetadata(); void setApiVersion(String apiVersion); void setKind(String kind); void setMetadata(MetadataOperator metadata); /** * Sets GroupVersionKind of the Extension. * * @param gvk is GroupVersionKind data. */ default void groupVersionKind(GroupVersionKind gvk) { setApiVersion(gvk.groupVersion().toString()); setKind(gvk.kind()); } /** * Gets GroupVersionKind of the Extension. * * @return GroupVersionKind of the Extension. */ @JsonIgnore default GroupVersionKind groupVersionKind() { return GroupVersionKind.fromAPIVersionAndKind(getApiVersion(), getKind()); } static Predicate isNotDeleted() { return ext -> ext.getMetadata().getDeletionTimestamp() == null; } static boolean isDeleted(ExtensionOperator extension) { return ExtensionUtil.isDeleted(extension); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/ExtensionUtil.java ================================================ package run.halo.app.extension; import static org.springframework.data.domain.Sort.Order.asc; import static org.springframework.data.domain.Sort.Order.desc; import java.util.Collections; import java.util.HashSet; import java.util.Set; import org.springframework.data.domain.Sort; import run.halo.app.extension.index.query.Queries; import run.halo.app.extension.index.query.Query; public enum ExtensionUtil { ; /** * Label to mark an extension resource should not be overwritten during initialization. */ public static final String DO_NOT_OVERWRITE_LABEL = "halo.run/do-not-overwrite"; /** * Check if the extension has the do-not-overwrite label. If the label is present and set to * true, it indicates that the extension should not be overwritten during initialization. * * @param extension the extension * @return true if it has the label, false otherwise */ public static boolean hasDoNotOverwriteLabel(ExtensionOperator extension) { if (extension.getMetadata() == null) { return false; } var labels = extension.getMetadata().getLabels(); return labels != null && Boolean.parseBoolean(labels.get(DO_NOT_OVERWRITE_LABEL)); } public static boolean isDeleted(ExtensionOperator extension) { return extension.getMetadata() != null && extension.getMetadata().getDeletionTimestamp() != null; } public static boolean addFinalizers(MetadataOperator metadata, Set finalizers) { var modifiableFinalizers = new HashSet<>( metadata.getFinalizers() == null ? Collections.emptySet() : metadata.getFinalizers()); var added = modifiableFinalizers.addAll(finalizers); if (added) { metadata.setFinalizers(modifiableFinalizers); } return added; } public static boolean removeFinalizers(MetadataOperator metadata, Set finalizers) { if (metadata.getFinalizers() == null) { return false; } var existingFinalizers = new HashSet<>(metadata.getFinalizers()); var removed = existingFinalizers.removeAll(finalizers); if (removed) { metadata.setFinalizers(existingFinalizers); } return removed; } /** * Query for not deleting. * * @return Query */ public static Query notDeleting() { return Queries.isNull("metadata.deletionTimestamp"); } /** * Default sort by creation timestamp desc and name asc. * * @return Sort */ public static Sort defaultSort() { return Sort.by( desc("metadata.creationTimestamp"), asc("metadata.name") ); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/GVK.java ================================================ package run.halo.app.extension; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * GVK is an annotation to specific metadata of Extension. * * @author johnniang */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface GVK { /** * @return group name of Extension. */ String group(); /** * @return version name of Extension. */ String version(); /** * @return kind name of Extension. */ String kind(); /** * @return plural name of Extension. */ String plural(); /** * @return singular name of Extension. */ String singular(); } ================================================ FILE: api/src/main/java/run/halo/app/extension/GroupKind.java ================================================ package run.halo.app.extension; /** * GroupKind contains group and kind data only. * * @param group is group name of Extension. * @param kind is kind name of Extension. * @author johnniang */ public record GroupKind(String group, String kind) { } ================================================ FILE: api/src/main/java/run/halo/app/extension/GroupVersion.java ================================================ package run.halo.app.extension; import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** * GroupVersion contains group and version name of an Extension only. * * @param group is group name of Extension. * @param version is version name of Extension. * @author johnniang */ public record GroupVersion(String group, String version) { @Override public String toString() { return StringUtils.hasText(group) ? group + "/" + version : version; } /** * Parses APIVersion into GroupVersion record. * * @param apiVersion must not be blank. * 1. If the given apiVersion does not contain any "/", we treat the group is empty. * 2. If the given apiVersion contains more than 1 "/", we will throw an * IllegalArgumentException. * @return record contains group and version. */ public static GroupVersion parseAPIVersion(String apiVersion) { Assert.hasText(apiVersion, "API version must not be blank"); var groupVersion = apiVersion.split("/"); return switch (groupVersion.length) { case 1 -> new GroupVersion("", apiVersion); case 2 -> new GroupVersion(groupVersion[0], groupVersion[1]); default -> throw new IllegalArgumentException("Unexpected APIVersion string: " + apiVersion); }; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/GroupVersionKind.java ================================================ package run.halo.app.extension; import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** * GroupVersionKind contains group, version and kind name of an Extension. * * @param group is group name of Extension. * @param version is version name of Extension. * @param kind is kind name of Extension. * @author johnniang */ public record GroupVersionKind(String group, String version, String kind) { public GroupVersionKind { Assert.hasText(version, "Version must not be blank"); Assert.hasText(kind, "Kind must not be blank"); } /** * Gets group and version name of Extension. * * @return group and version name of Extension. */ public GroupVersion groupVersion() { return new GroupVersion(group, version); } public GroupKind groupKind() { return new GroupKind(group, kind); } public boolean hasGroup() { return StringUtils.hasText(group); } /** * Composes GroupVersionKind from API version and kind name. * * @param apiVersion is API version. Like "core.halo.run/v1alpha1" * @param kind is kind name of Extension. * @return GroupVersionKind of an Extension. */ public static GroupVersionKind fromAPIVersionAndKind(String apiVersion, String kind) { Assert.hasText(kind, "Kind must not be blank"); var gv = GroupVersion.parseAPIVersion(apiVersion); return new GroupVersionKind(gv.group(), gv.version(), kind); } public static GroupVersionKind fromExtension(Class extension) { GVK gvk = extension.getAnnotation(GVK.class); return new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind()); } @Override public String toString() { if (hasGroup()) { return group + "/" + version + "/" + kind; } return version + "/" + kind; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/JsonExtension.java ================================================ package run.halo.app.extension; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.node.LongNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import java.io.IOException; import java.time.Instant; import java.util.Map; import java.util.Objects; import java.util.Set; /** * JsonExtension is representation an extension using ObjectNode. This extension is preparing for * patching in the future. * * @author johnniang */ @JsonSerialize(using = JsonExtension.ObjectNodeExtensionSerializer.class) @JsonDeserialize(using = JsonExtension.ObjectNodeExtensionDeSerializer.class) @Deprecated(forRemoval = true, since = "2.23.0") public class JsonExtension implements Extension { private final ObjectMapper objectMapper; private final ObjectNode objectNode; public JsonExtension(ObjectMapper objectMapper) { this(objectMapper, objectMapper.createObjectNode()); } public JsonExtension(ObjectMapper objectMapper, ObjectNode objectNode) { this.objectMapper = objectMapper; this.objectNode = objectNode; } public JsonExtension(ObjectMapper objectMapper, Extension e) { this(objectMapper, (ObjectNode) objectMapper.valueToTree(e)); } @Override public MetadataOperator getMetadata() { var metadataNode = objectNode.get("metadata"); if (metadataNode == null) { return null; } return new ObjectNodeMetadata((ObjectNode) metadataNode); } @Override public String getApiVersion() { var apiVersionNode = objectNode.get("apiVersion"); return apiVersionNode == null ? null : apiVersionNode.asText(); } @Override public String getKind() { return objectNode.get("kind").asText(); } @Override public void setApiVersion(String apiVersion) { objectNode.set("apiVersion", new TextNode(apiVersion)); } @Override public void setKind(String kind) { objectNode.set("kind", new TextNode(kind)); } @Override public void setMetadata(MetadataOperator metadata) { objectNode.set("metadata", objectMapper.valueToTree(metadata)); } public static class ObjectNodeExtensionSerializer extends JsonSerializer { @Override public void serialize(JsonExtension value, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeTree(value.objectNode); } } public static class ObjectNodeExtensionDeSerializer extends JsonDeserializer { @Override public JsonExtension deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { var mapper = (ObjectMapper) p.getCodec(); var treeNode = mapper.readTree(p); return new JsonExtension(mapper, (ObjectNode) treeNode); } } /** * Get internal representation. * * @return internal representation */ public ObjectNode getInternal() { return objectNode; } /** * Get object mapper. * * @return object mapper */ public ObjectMapper getObjectMapper() { return objectMapper; } public MetadataOperator getMetadataOrCreate() { var metadataNode = objectMapper.createObjectNode(); objectNode.set("metadata", metadataNode); return new ObjectNodeMetadata(metadataNode); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } JsonExtension that = (JsonExtension) o; return Objects.equals(objectNode, that.objectNode); } @Override public int hashCode() { return Objects.hash(objectNode); } class ObjectNodeMetadata implements MetadataOperator { private final ObjectNode objectNode; public ObjectNodeMetadata(ObjectNode objectNode) { this.objectNode = objectNode; } @Override public String getName() { var nameNode = objectNode.get("name"); return objectMapper.convertValue(nameNode, String.class); } @Override public String getGenerateName() { var generateNameNode = objectNode.get("generateName"); return objectMapper.convertValue(generateNameNode, String.class); } @Override public Map getLabels() { var labelsNode = objectNode.get("labels"); return objectMapper.convertValue(labelsNode, new TypeReference<>() { }); } @Override public Map getAnnotations() { var annotationsNode = objectNode.get("annotations"); return objectMapper.convertValue(annotationsNode, new TypeReference<>() { }); } @Override public Long getVersion() { JsonNode versionNode = objectNode.get("version"); return objectMapper.convertValue(versionNode, Long.class); } @Override public Instant getCreationTimestamp() { return objectMapper.convertValue(objectNode.get("creationTimestamp"), Instant.class); } @Override public Instant getDeletionTimestamp() { return objectMapper.convertValue(objectNode.get("deletionTimestamp"), Instant.class); } @Override public Set getFinalizers() { return objectMapper.convertValue(objectNode.get("finalizers"), new TypeReference<>() { }); } @Override public void setName(String name) { if (name != null) { objectNode.set("name", TextNode.valueOf(name)); } } @Override public void setGenerateName(String generateName) { if (generateName != null) { objectNode.set("generateName", TextNode.valueOf(generateName)); } } @Override public void setLabels(Map labels) { if (labels != null) { objectNode.set("labels", objectMapper.valueToTree(labels)); } } @Override public void setAnnotations(Map annotations) { if (annotations != null) { objectNode.set("annotations", objectMapper.valueToTree(annotations)); } } @Override public void setVersion(Long version) { if (version != null) { objectNode.set("version", LongNode.valueOf(version)); } } @Override public void setCreationTimestamp(Instant creationTimestamp) { if (creationTimestamp != null) { objectNode.set("creationTimestamp", objectMapper.valueToTree(creationTimestamp)); } } @Override public void setDeletionTimestamp(Instant deletionTimestamp) { if (deletionTimestamp != null) { objectNode.set("deletionTimestamp", objectMapper.valueToTree(deletionTimestamp)); } } @Override public void setFinalizers(Set finalizers) { if (finalizers != null) { objectNode.set("finalizers", objectMapper.valueToTree(finalizers)); } } @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) { return false; } ObjectNodeMetadata that = (ObjectNodeMetadata) o; return Objects.equals(objectNode, that.objectNode); } @Override public int hashCode() { return Objects.hashCode(objectNode); } } } ================================================ FILE: api/src/main/java/run/halo/app/extension/ListOptions.java ================================================ package run.halo.app.extension; import java.util.List; import java.util.function.Function; import lombok.Data; import lombok.experimental.Accessors; import org.springframework.lang.NonNull; import run.halo.app.extension.index.query.Condition; import run.halo.app.extension.index.query.LabelCondition; import run.halo.app.extension.index.query.Query; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.extension.router.selector.LabelSelector; @Data @Accessors(chain = true) public class ListOptions { private LabelSelector labelSelector; private FieldSelector fieldSelector; @Override public String toString() { return toCondition().toString(); } /** * Convert to a single condition. * * @return the condition, never null */ @NonNull public Condition toCondition() { Condition condition = null; var fieldSelector = getFieldSelector(); if (fieldSelector != null && fieldSelector.query() != null) { var query = fieldSelector.query(); if (!(query instanceof Condition fieldCondition)) { throw new IllegalArgumentException("Only support condition query"); } condition = fieldCondition; } var labelSelector = getLabelSelector(); if (labelSelector != null) { var labelCondition = labelSelector.getConditions().stream() .map(Function.identity()) .reduce(Condition::and) .orElse(null); if (labelCondition != null) { if (condition == null) { condition = labelCondition; } else { condition = condition.and(labelCondition); } } } return condition == null ? Condition.empty() : condition; } public static ListOptionsBuilder builder() { return new ListOptionsBuilder(); } public static ListOptionsBuilder builder(ListOptions listOptions) { return new ListOptionsBuilder(listOptions); } public static class ListOptionsBuilder { private LabelSelectorBuilder labelSelectorBuilder; private Query query; public ListOptionsBuilder() { } /** * Create a new list options builder with the given list options. */ public ListOptionsBuilder(ListOptions listOptions) { if (listOptions.getLabelSelector() != null) { this.labelSelectorBuilder = new LabelSelectorBuilder( listOptions.getLabelSelector().getConditions(), this); } if (listOptions.getFieldSelector() != null) { this.query = listOptions.getFieldSelector().query(); } } /** * Create a new label selector builder. */ public LabelSelectorBuilder labelSelector() { if (labelSelectorBuilder == null) { labelSelectorBuilder = new LabelSelectorBuilder(this); } return labelSelectorBuilder; } public ListOptionsBuilder fieldQuery(Query query) { this.query = query; return this; } /** * And the given query to the current query. */ public ListOptionsBuilder andQuery(Query query) { if (!(query instanceof Condition condition)) { throw new IllegalArgumentException("Given query must be an instance of Condition"); } if (this.query == null) { this.query = condition; } else { if (!(this.query instanceof Condition currentCondition)) { throw new IllegalArgumentException( "Current query must be an instance of Condition" ); } this.query = currentCondition.and(condition); } return this; } /** * Or the given query to the current query. */ public ListOptionsBuilder orQuery(Query query) { if (!(query instanceof Condition condition)) { throw new IllegalArgumentException("Given query must be an instance of Condition"); } if (this.query == null) { this.query = condition; } else { if (!(this.query instanceof Condition currentCondition)) { throw new IllegalArgumentException( "Current query must be an instance of Condition" ); } this.query = currentCondition.or(condition); } return this; } /** * Build the list options. */ public ListOptions build() { var listOptions = new ListOptions(); if (labelSelectorBuilder != null) { listOptions.setLabelSelector(labelSelectorBuilder.build()); } if (query != null) { listOptions.setFieldSelector(FieldSelector.of(query)); } return listOptions; } } public static class LabelSelectorBuilder extends LabelSelector.LabelSelectorBuilder { private final ListOptionsBuilder listOptionsBuilder; public LabelSelectorBuilder(List conditions, ListOptionsBuilder listOptionsBuilder) { super(conditions); this.listOptionsBuilder = listOptionsBuilder; } public LabelSelectorBuilder(ListOptionsBuilder listOptionsBuilder) { this.listOptionsBuilder = listOptionsBuilder; } public ListOptionsBuilder end() { return this.listOptionsBuilder; } } } ================================================ FILE: api/src/main/java/run/halo/app/extension/ListResult.java ================================================ package run.halo.app.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Optional; import java.util.function.Supplier; import java.util.stream.Stream; import lombok.Data; import org.springframework.util.Assert; import run.halo.app.infra.utils.GenericClassUtils; @Data public class ListResult implements Iterable, Supplier> { @Schema(description = "Page number, starts from 1. If not set or equal to 0, it means no " + "pagination.", requiredMode = REQUIRED) private final int page; @Schema(description = "Size of each page. If not set or equal to 0, it means no pagination.", requiredMode = REQUIRED) private final int size; @Schema(description = "Total elements.", requiredMode = REQUIRED) private final long total; @Schema(description = "A chunk of items.", requiredMode = REQUIRED) private final List items; @JsonCreator public ListResult( @JsonProperty("page") int page, @JsonProperty("size") int size, @JsonProperty("total") long total, @JsonProperty("items") List items) { Assert.isTrue(total >= 0, "Total elements must be greater than or equal to 0"); if (page < 0) { page = 0; } if (size < 0) { size = 0; } if (items == null) { items = Collections.emptyList(); } this.page = page; this.size = size; this.total = total; this.items = items; } public ListResult(List items) { this(0, 0, items.size(), items); } @Schema(description = "Indicates whether current page is the first page.", requiredMode = REQUIRED) public boolean isFirst() { return !hasPrevious(); } @Schema(description = "Indicates whether current page is the last page.", requiredMode = REQUIRED) public boolean isLast() { return !hasNext(); } @Schema(description = "Indicates whether current page has previous page.", requiredMode = REQUIRED) @JsonProperty("hasNext") public boolean hasNext() { if (page <= 0) { return false; } return page < getTotalPages(); } @Schema(description = "Indicates whether current page has previous page.", requiredMode = REQUIRED) @JsonProperty("hasPrevious") public boolean hasPrevious() { return page > 1; } @Override public Iterator iterator() { return items.iterator(); } @Schema(description = "Indicates total pages.", requiredMode = REQUIRED) @JsonProperty("totalPages") public long getTotalPages() { return size == 0 ? 1 : (total + size - 1) / size; } /** * Generate generic ListResult class. Like {@code ListResult}, {@code ListResult}, * etc. * * @param scheme scheme of the generic type. * @return generic ListResult class. */ public static Class generateGenericClass(Scheme scheme) { return GenericClassUtils.generateConcreteClass(ListResult.class, scheme.type(), () -> { var pkgName = scheme.type().getPackageName(); return pkgName + '.' + scheme.groupVersionKind().kind() + "List"; }); } /** * Generate generic ListResult class. Like {@code ListResult}, {@code ListResult}, * etc. * * @param type the generic type of {@link ListResult}. * @return generic ListResult class. */ public static Class generateGenericClass(Class type) { return GenericClassUtils.generateConcreteClass(ListResult.class, type, () -> type.getName() + "List"); } public static ListResult emptyResult() { return new ListResult<>(List.of()); } /** * Manually paginate the List collection. */ public static List subList(List list, int page, int size) { if (page < 1) { page = 1; } if (size < 1) { return list; } List listSort = new ArrayList<>(); int total = list.size(); int pageStart = page == 1 ? 0 : (page - 1) * size; int pageEnd = Math.min(total, page * size); if (total > pageStart) { listSort = list.subList(pageStart, pageEnd); } return listSort; } /** * Gets the first element of the list result. */ public static Optional first(ListResult listResult) { return Optional.ofNullable(listResult) .map(ListResult::getItems) .map(list -> list.isEmpty() ? null : list.get(0)); } @Override public Stream get() { return items.stream(); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/Metadata.java ================================================ package run.halo.app.extension; import java.time.Instant; import java.util.Map; import java.util.Set; import lombok.Data; import lombok.EqualsAndHashCode; /** * Metadata of Extension. * * @author johnniang */ @Data @EqualsAndHashCode(exclude = "version") public class Metadata implements MetadataOperator { /** * Metadata name. The name is unique globally. */ private String name; /** * Generate name is for generating metadata name automatically. */ private String generateName; /** * Labels are like key-value format. */ private Map labels; /** * Annotations are like key-value format. */ private Map annotations; /** * Current version of the Extension. It will be bumped up every update. */ private Long version; /** * Creation timestamp of the Extension. */ private Instant creationTimestamp; /** * Deletion timestamp of the Extension. */ private Instant deletionTimestamp; private Set finalizers; } ================================================ FILE: api/src/main/java/run/halo/app/extension/MetadataOperator.java ================================================ package run.halo.app.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import java.util.Map; import java.util.Objects; import java.util.Set; /** * MetadataOperator contains some getters and setters for required fields of metadata. * * @author johnniang */ @JsonDeserialize(as = Metadata.class) @tools.jackson.databind.annotation.JsonDeserialize(as = Metadata.class) @Schema(implementation = Metadata.class) public interface MetadataOperator { @Schema(name = "name", description = "Metadata name", requiredMode = REQUIRED) @JsonProperty("name") String getName(); @Schema(name = "generateName", description = "The name field will be generated automatically " + "according to the given generateName field") String getGenerateName(); @Schema(name = "labels") @JsonProperty("labels") Map getLabels(); @Schema(name = "annotations") @JsonProperty("annotations") Map getAnnotations(); @Schema(name = "version", nullable = true) @JsonProperty("version") Long getVersion(); @Schema(name = "creationTimestamp", nullable = true) @JsonProperty("creationTimestamp") Instant getCreationTimestamp(); @Schema(name = "deletionTimestamp", nullable = true) @JsonProperty("deletionTimestamp") Instant getDeletionTimestamp(); @Schema(name = "finalizers", nullable = true) Set getFinalizers(); void setName(String name); void setGenerateName(String generateName); void setLabels(Map labels); void setAnnotations(Map annotations); void setVersion(Long version); void setCreationTimestamp(Instant creationTimestamp); void setDeletionTimestamp(Instant deletionTimestamp); void setFinalizers(Set finalizers); /** * Equals method for metadata. * * @param left metadata * @param right metadata * @return true if equals, false otherwise */ static boolean equals(MetadataOperator left, MetadataOperator right) { if (left == null && right == null) { return true; } if (left == null || right == null) { return false; } return Objects.equals(left.getName(), right.getName()) && Objects.equals(left.getGenerateName(), right.getGenerateName()) && Objects.equals(left.getLabels(), right.getLabels()) && Objects.equals(left.getAnnotations(), right.getAnnotations()) && Objects.equals(left.getCreationTimestamp(), right.getCreationTimestamp()) && Objects.equals(left.getDeletionTimestamp(), right.getDeletionTimestamp()) && Objects.equals(left.getVersion(), right.getVersion()) && Objects.equals(left.getFinalizers(), right.getFinalizers()); } /** * Hash code for metadata. * * @param metadata metadata * @return hash code */ static int hashCode(MetadataOperator metadata) { return Objects.hash( metadata.getName(), metadata.getGenerateName(), metadata.getLabels(), metadata.getAnnotations(), metadata.getCreationTimestamp(), metadata.getDeletionTimestamp(), metadata.getVersion(), metadata.getFinalizers() ); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/MetadataUtil.java ================================================ package run.halo.app.extension; import java.util.HashMap; import java.util.Map; import org.springframework.util.Assert; public enum MetadataUtil { ; public static final String SYSTEM_FINALIZER = "system-protection"; public static final String HIDDEN_LABEL = "halo.run/hidden"; /** * Gets extension metadata labels null safe. * * @param extension extension must not be null * @return extension metadata labels */ public static Map nullSafeLabels(AbstractExtension extension) { Assert.notNull(extension, "The extension must not be null."); Assert.notNull(extension.getMetadata(), "The extension metadata must not be null."); Map labels = extension.getMetadata().getLabels(); if (labels == null) { labels = new HashMap<>(); extension.getMetadata().setLabels(labels); } return labels; } /** * Gets extension metadata annotations null safe. * * @param extension extension must not be null * @return extension metadata annotations */ public static Map nullSafeAnnotations(AbstractExtension extension) { Assert.notNull(extension, "The extension must not be null."); Assert.notNull(extension.getMetadata(), "The extension metadata must not be null."); Map annotations = extension.getMetadata().getAnnotations(); if (annotations == null) { annotations = new HashMap<>(); extension.getMetadata().setAnnotations(annotations); } return annotations; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/PageRequest.java ================================================ package run.halo.app.extension; import org.springframework.data.domain.Sort; import org.springframework.util.Assert; /** *

{@link PageRequest} is an interface for pagination information.

*

Page number starts from 1.

*

if page size is 0, it means no pagination and all results will be returned.

* * @author guqing * @see PageRequestImpl * @since 2.12.0 */ public interface PageRequest { int getPageNumber(); int getPageSize(); PageRequest previous(); PageRequest next(); /** * Returns the previous {@link PageRequest} or the first {@link PageRequest} if the current one * already is the first one. * * @return a new {@link org.springframework.data.domain.PageRequest} with * {@link #getPageNumber()} - 1 as {@link #getPageNumber()} */ PageRequest previousOrFirst(); /** * Returns the {@link PageRequest} requesting the first page. * * @return a new {@link org.springframework.data.domain.PageRequest} with * {@link #getPageNumber()} = 1 as {@link #getPageNumber()} */ PageRequest first(); /** * Creates a new {@link PageRequest} with {@code pageNumber} applied. * * @param pageNumber 1-based page index. * @return a new {@link org.springframework.data.domain.PageRequest} */ PageRequest withPage(int pageNumber); PageRequestImpl withSort(Sort sort); boolean hasPrevious(); Sort getSort(); /** * Returns the current {@link Sort} or the given one if the current one is unsorted. * * @param sort must not be {@literal null}. * @return the current {@link Sort} or the given one if the current one is unsorted. */ default Sort getSortOr(Sort sort) { Assert.notNull(sort, "Fallback Sort must not be null"); return getSort().isSorted() ? getSort() : sort; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/PageRequestImpl.java ================================================ package run.halo.app.extension; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Sort; import org.springframework.util.Assert; @Slf4j public class PageRequestImpl implements PageRequest { public static final int MAX_SIZE = 1_000; private final int pageNumber; private final int pageSize; private final Sort sort; public PageRequestImpl(int pageNumber, int pageSize, Sort sort) { Assert.notNull(sort, "Sort must not be null"); if (pageNumber < 1) { pageNumber = 1; } if (pageSize <= 0) { log.warn("Page size must be greater than 0, reset to default {}", MAX_SIZE); pageSize = MAX_SIZE; } if (pageSize > 1000) { log.warn("Page size must not be greater than {}, reset to {}", MAX_SIZE, MAX_SIZE); pageSize = MAX_SIZE; } this.pageNumber = pageNumber; this.pageSize = pageSize; this.sort = sort; } public static PageRequestImpl of(int pageNumber, int pageSize) { return of(pageNumber, pageSize, Sort.unsorted()); } public static PageRequestImpl of(int pageNumber, int pageSize, Sort sort) { return new PageRequestImpl(pageNumber, pageSize, sort); } public static PageRequestImpl ofSize(int pageSize) { return PageRequestImpl.of(1, pageSize); } @Override public int getPageNumber() { return pageNumber; } @Override public int getPageSize() { return pageSize; } @Override public PageRequest previous() { return getPageNumber() == 0 ? this : new PageRequestImpl(getPageNumber() - 1, getPageSize(), getSort()); } @Override public Sort getSort() { return sort; } @Override public PageRequest next() { return new PageRequestImpl(getPageNumber() + 1, getPageSize(), getSort()); } @Override public PageRequest previousOrFirst() { return hasPrevious() ? previous() : first(); } @Override public PageRequest first() { return new PageRequestImpl(1, getPageSize(), getSort()); } @Override public PageRequest withPage(int pageNumber) { return new PageRequestImpl(pageNumber, getPageSize(), getSort()); } @Override public PageRequestImpl withSort(Sort sort) { return new PageRequestImpl(getPageNumber(), getPageSize(), defaultIfNull(sort, Sort.unsorted())); } @Override public boolean hasPrevious() { return pageNumber > 1; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/ReactiveExtensionClient.java ================================================ package run.halo.app.extension; import java.util.Comparator; import java.util.function.Predicate; import org.springframework.data.domain.Sort; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.extension.index.IndexedQueryEngine; /** * ExtensionClient is an interface which contains some operations on Extension instead of * ExtensionStore. * * @author johnniang */ public interface ReactiveExtensionClient { /** * Lists Extensions by Extension type, filter and sorter. * * @param type is the class type of Extension. * @param predicate filters the reEnqueue. * @param comparator sorts the reEnqueue. * @param is Extension type. * @return all filtered and sorted Extensions. */ Flux list(Class type, Predicate predicate, Comparator comparator); /** * Lists Extensions by Extension type, filter, sorter and page info. * * @param type is the class type of Extension. * @param predicate filters the reEnqueue. * @param comparator sorts the reEnqueue. * @param page is page number which starts from 0. * @param size is page size. * @param is Extension type. * @return a list of Extensions. */ @Deprecated Mono> list(Class type, Predicate predicate, Comparator comparator, int page, int size); Flux listAll(Class type, ListOptions options, Sort sort); Flux listAllNames(Class type, ListOptions options, Sort sort); Flux listTopNames(Class type, ListOptions options, Sort sort, int topN); Mono> listBy(Class type, ListOptions options, PageRequest pageable); Mono> listNamesBy(Class type, ListOptions options, PageRequest pageable); Mono countBy(Class type, ListOptions options); /** * Fetches Extension by its type and name. * * @param type is Extension type. * @param name is Extension name. * @param is Extension type. * @return an optional Extension. */ Mono fetch(Class type, String name); Mono fetch(GroupVersionKind gvk, String name); Mono get(Class type, String name); @Deprecated(forRemoval = true, since = "2.23.0") Mono getJsonExtension(GroupVersionKind gvk, String name); /** * Creates an Extension. * * @param extension is fresh Extension to be created. Please make sure the Extension name does * not exist. * @param is Extension type. */ Mono create(E extension); /** * Updates an Extension. * * @param extension is an Extension to be updated. Please make sure the resource version is * latest. * @param is Extension type. */ Mono update(E extension); /** * Deletes an Extension. * * @param extension is an Extension to be deleted. Please make sure the resource version is * latest. * @param is Extension type. */ Mono delete(E extension); @Deprecated(forRemoval = true, since = "2.22.0") IndexedQueryEngine indexedQueryEngine(); void watch(Watcher watcher); } ================================================ FILE: api/src/main/java/run/halo/app/extension/Ref.java ================================================ package run.halo.app.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.util.Objects; import lombok.Data; import org.jspecify.annotations.Nullable; import org.springframework.lang.NonNull; @Data @Schema(description = "Extension reference object. The name is mandatory") public class Ref { @Schema(description = "Extension group", requiredMode = REQUIRED) private String group; @Schema(description = "Extension version") @Nullable private String version; @Schema(description = "Extension kind", requiredMode = REQUIRED) private String kind; @Schema(requiredMode = REQUIRED, description = "Extension name. This field is mandatory") private String name; public static Ref of(String name) { Ref ref = new Ref(); ref.setName(name); return ref; } public static Ref of(String name, GroupVersionKind gvk) { Ref ref = new Ref(); ref.setName(name); ref.setGroup(gvk.group()); ref.setVersion(gvk.version()); ref.setKind(gvk.kind()); return ref; } public static Ref of(Extension extension) { var metadata = extension.getMetadata(); var gvk = extension.groupVersionKind(); var ref = new Ref(); ref.setName(metadata.getName()); ref.setGroup(gvk.group()); ref.setVersion(gvk.version()); ref.setKind(gvk.kind()); return ref; } /** * Check the ref has the same group and kind. * * @param ref is target reference * @param gvk is group version kind * @return true if they have the same group and kind. */ public static boolean groupKindEquals(Ref ref, GroupVersionKind gvk) { return Objects.equals(ref.getGroup(), gvk.group()) && Objects.equals(ref.getKind(), gvk.kind()); } /** * Check if the extension is equal to the ref. * * @param ref must not be null. * @param extension must not be null. * @return true if they are equal; false otherwise. */ public static boolean equals(@NonNull Ref ref, @NonNull ExtensionOperator extension) { var gvk = extension.groupVersionKind(); var name = extension.getMetadata().getName(); return groupKindEquals(ref, gvk) && Objects.equals(ref.getName(), name); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/Scheme.java ================================================ package run.halo.app.extension; import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.core.converter.ModelConverters; import io.swagger.v3.core.util.Json; import java.util.Map; import org.springframework.lang.NonNull; import org.springframework.util.Assert; import run.halo.app.extension.exception.ExtensionException; /** * This class represents scheme of an Extension. * * @param type is Extension type. * @param groupVersionKind is GroupVersionKind of Extension. * @param plural is plural name of Extension. * @param singular is singular name of Extension. * @param openApiSchema is JSON schema of Extension. * @author johnniang */ public record Scheme(Class type, GroupVersionKind groupVersionKind, String plural, String singular, ObjectNode openApiSchema) { public Scheme { Assert.notNull(type, "Type of Extension must not be null"); Assert.notNull(groupVersionKind, "GroupVersionKind of Extension must not be null"); Assert.hasText(plural, "Plural name of Extension must not be blank"); Assert.hasText(singular, "Singular name of Extension must not be blank"); Assert.notNull(openApiSchema, "Json Schema must not be null"); } /** * Builds Scheme from type with @GVK annotation. * * @param type is Extension type with GVK annotation. * @return Scheme definition. * @throws ExtensionException when the type has not annotated @GVK. */ public static Scheme buildFromType(Class type) { // concrete scheme from annotation var gvk = getGvkFromType(type); // TODO Move the generation logic outside. // generate OpenAPI schema var resolvedSchema = ModelConverters.getInstance().readAllAsResolvedSchema(type); var mapper = Json.mapper(); var schema = (ObjectNode) mapper.valueToTree(resolvedSchema.schema); // for schema validation. schema.set("components", mapper.valueToTree(Map.of("schemas", resolvedSchema.referencedSchemas))); return new Scheme(type, new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind()), gvk.plural(), gvk.singular(), // Deep copy for schema to avoid mutation by others. schema.deepCopy()); } /** * Gets GVK annotation from Extension type. * * @param type is Extension type with GVK annotation. * @return GVK annotation. * @throws ExtensionException when the type has not annotated @GVK. */ @NonNull public static GVK getGvkFromType(@NonNull Class type) { var gvk = type.getAnnotation(GVK.class); Assert.notNull(gvk, "Missing annotation " + GVK.class.getName() + " on type " + type.getName()); return gvk; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } Scheme scheme = (Scheme) o; return groupVersionKind.equals(scheme.groupVersionKind); } @Override public int hashCode() { return groupVersionKind.hashCode(); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/SchemeManager.java ================================================ package run.halo.app.extension; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import run.halo.app.extension.exception.SchemeNotFoundException; import run.halo.app.extension.index.IndexSpecs; public interface SchemeManager { /** * Registers an Extension using its type. * * @param type is Extension type. * @param Extension class. */ default void register(Class type) { register(type, null); } default void register(Scheme scheme) { register(scheme.type(), null); } void register( Class type, @Nullable Consumer> specsConsumer ); void unregister(@NonNull Scheme scheme); default int size() { return schemes().size(); } @NonNull List schemes(); @NonNull default Optional fetch(@NonNull GroupVersionKind gvk) { return schemes().stream() .filter(scheme -> Objects.equals(scheme.groupVersionKind(), gvk)) .findFirst(); } @NonNull default Scheme get(@NonNull GroupVersionKind gvk) { return fetch(gvk).orElseThrow( () -> new SchemeNotFoundException(gvk)); } @NonNull default Scheme get(Class type) { var gvk = Scheme.getGvkFromType(type); return get(new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind())); } @NonNull default Scheme get(Extension ext) { var gvk = ext.groupVersionKind(); return get(gvk); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/Secret.java ================================================ package run.halo.app.extension; import java.util.Map; import lombok.Data; import lombok.EqualsAndHashCode; /** * Secret is a small piece of sensitive data which should be kept secret, such as a password, * a token, or a key. * * @author guqing * @see * kebernetes Secret * @since 2.0.0 */ @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "", version = "v1alpha1", kind = Secret.KIND, plural = "secrets", singular = "secret") public class Secret extends AbstractExtension { public static final String KIND = "Secret"; public static final String SECRET_TYPE_OPAQUE = "Opaque"; public static final int MAX_SECRET_SIZE = 1024 * 1024; /** * Used to facilitate programmatic handling of secret data. * More info: * secret-types */ private String type; /** *

The total bytes of the values in * the Data field must be less than {@link #MAX_SECRET_SIZE} bytes.

*

{@code data} contains the secret data. Each key must consist of alphanumeric * characters, '-', '_' or '.'. The serialized form of the secret data is a * base64 encoded string, representing the arbitrary (possibly non-string) * data value here. Described in * rfc4648#section-4 *

*/ private Map data; /** * {@code stringData} allows specifying non-binary secret data in string form. * It is provided as a write-only input field for convenience. * All keys and values are merged into the data field on write, overwriting any existing * values. * The stringData field is never output when reading from the API. */ private Map stringData; } ================================================ FILE: api/src/main/java/run/halo/app/extension/Unstructured.java ================================================ package run.halo.app.extension; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import io.swagger.v3.core.util.Json; import java.io.IOException; import java.time.Instant; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import org.springframework.lang.NonNull; import tools.jackson.core.JacksonException; import tools.jackson.databind.SerializationContext; import tools.jackson.databind.ValueDeserializer; import tools.jackson.databind.ValueSerializer; /** * Unstructured is a generic Extension, which wraps ObjectNode to maintain the Extension data, like * apiVersion, kind, metadata and others. * * @author johnniang */ @JsonSerialize(using = Unstructured.UnstructuredSerializer.class) @JsonDeserialize(using = Unstructured.UnstructuredDeserializer.class) @tools.jackson.databind.annotation.JsonSerialize( using = Unstructured.UnstructuredValueSerializer.class ) @tools.jackson.databind.annotation.JsonDeserialize( using = Unstructured.UnstructuredValueDeserializer.class ) @SuppressWarnings("rawtypes") public class Unstructured implements Extension { @SuppressWarnings("deprecation") public static final ObjectMapper OBJECT_MAPPER = Json.mapper() // We don't want to change the default mapper // so we copy a new one and configure it .copy() .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS); private final Map data; public Unstructured() { this(new HashMap()); } public Unstructured(Map data) { this.data = data; } public Map getData() { return data; } @Override public String getApiVersion() { return (String) data.get("apiVersion"); } @Override public String getKind() { return (String) data.get("kind"); } @Override public MetadataOperator getMetadata() { return getNestedMap(data, "metadata") .map(UnstructuredMetadata::new) .orElse(null); } static class UnstructuredMetadata implements MetadataOperator { @NonNull private final Map metadata; UnstructuredMetadata(@NonNull Map metadata) { this.metadata = metadata; } @Override public String getName() { return (String) getNestedValue(metadata, "name").orElse(null); } @Override public String getGenerateName() { return (String) getNestedValue(metadata, "generateName").orElse(null); } @Override public Map getLabels() { return getNestedStringStringMap(metadata, "labels").orElse(null); } @Override public Map getAnnotations() { return getNestedStringStringMap(metadata, "annotations").orElse(null); } @Override public Long getVersion() { return getNestedLong(metadata, "version").orElse(null); } @Override public Instant getCreationTimestamp() { return getNestedInstant(metadata, "creationTimestamp").orElse(null); } @Override public Instant getDeletionTimestamp() { return getNestedInstant(metadata, "deletionTimestamp").orElse(null); } @Override public Set getFinalizers() { return getNestedStringSet(metadata, "finalizers").orElse(null); } @Override public void setName(String name) { setNestedValue(metadata, name, "name"); } @Override public void setGenerateName(String generateName) { setNestedValue(metadata, generateName, "generateName"); } @Override public void setLabels(Map labels) { setNestedValue(metadata, labels, "labels"); } @Override public void setAnnotations(Map annotations) { setNestedValue(metadata, annotations, "annotations"); } @Override public void setVersion(Long version) { setNestedValue(metadata, version, "version"); } @Override public void setCreationTimestamp(Instant creationTimestamp) { setNestedValue(metadata, creationTimestamp, "creationTimestamp"); } @Override public void setDeletionTimestamp(Instant deletionTimestamp) { setNestedValue(metadata, deletionTimestamp, "deletionTimestamp"); } @Override public void setFinalizers(Set finalizers) { setNestedValue(metadata, finalizers, "finalizers"); } @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) { return false; } var that = (UnstructuredMetadata) o; return Objects.equals(metadata, that.metadata); } @Override public int hashCode() { return Objects.hashCode(metadata); } } @Override public void setApiVersion(String apiVersion) { setNestedValue(data, apiVersion, "apiVersion"); } @Override public void setKind(String kind) { setNestedValue(data, kind, "kind"); } @Override @SuppressWarnings("unchecked") public void setMetadata(MetadataOperator metadata) { Map metadataMap = OBJECT_MAPPER.convertValue(metadata, Map.class); data.put("metadata", metadataMap); } public static Optional getNestedValue(Map map, String... fields) { if (fields == null || fields.length == 0) { return Optional.of(map); } Map tempMap = map; for (int i = 0; i < fields.length - 1; i++) { Object value = tempMap.get(fields[i]); if (!(value instanceof Map)) { return Optional.empty(); } tempMap = (Map) value; } return Optional.ofNullable(tempMap.get(fields[fields.length - 1])); } @SuppressWarnings("unchecked") public static Optional> getNestedStringList(Map map, String... fields) { return getNestedValue(map, fields).map(value -> (List) value); } public static Optional> getNestedStringSet(Map map, String... fields) { return getNestedValue(map, fields).map(value -> { if (value instanceof Collection collection) { return new LinkedHashSet<>(collection); } throw new IllegalArgumentException( "Incorrect value type: " + value.getClass() + ", expected: " + Set.class); }); } @SuppressWarnings("unchecked") public static void setNestedValue(Map map, Object value, String... fields) { if (fields == null || fields.length == 0) { // do nothing when no fields provided return; } var prevFields = Arrays.stream(fields, 0, fields.length - 1) .toArray(String[]::new); getNestedMap(map, prevFields).ifPresent(m -> { var lastField = fields[fields.length - 1]; m.put(lastField, value); }); } public static Optional getNestedMap(Map map, String... fields) { return getNestedValue(map, fields).map(value -> (Map) value); } @SuppressWarnings("unchecked") public static Optional> getNestedStringStringMap(Map map, String... fields) { return getNestedValue(map, fields) .map(labelsObj -> (Map) labelsObj); } public static Optional getNestedInstant(Map map, String... fields) { return getNestedValue(map, fields) .map(instantValue -> { if (instantValue instanceof Instant instant) { return instant; } return Instant.parse(instantValue.toString()); }); } public static Optional getNestedLong(Map map, String... fields) { return getNestedValue(map, fields) .map(longObj -> { if (longObj instanceof Long l) { return l; } return Long.valueOf(longObj.toString()); }); } public static class UnstructuredSerializer extends JsonSerializer { @Override public void serialize(Unstructured value, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeObject(value.data); } } static class UnstructuredValueSerializer extends ValueSerializer { @Override public void serialize( Unstructured value, tools.jackson.core.JsonGenerator gen, SerializationContext ctxt) throws JacksonException { gen.writePOJO(value.data); } @Override public Class handledType() { return Unstructured.class; } } static class UnstructuredValueDeserializer extends ValueDeserializer { @Override public Unstructured deserialize(tools.jackson.core.JsonParser p, tools.jackson.databind.DeserializationContext ctxt) throws JacksonException { var map = p.readValueAs(Map.class); return new Unstructured(map); } } public static class UnstructuredDeserializer extends JsonDeserializer { @Override public Unstructured deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { Map data = p.getCodec().readValue(p, Map.class); return new Unstructured(data); } } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } Unstructured that = (Unstructured) o; return Objects.equals(data, that.data); } @Override public int hashCode() { return Objects.hash(data); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/Watcher.java ================================================ package run.halo.app.extension; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import reactor.core.Disposable; import run.halo.app.extension.controller.Reconciler; public interface Watcher extends Disposable { default void onAdd(Reconciler.Request request) { // Do nothing here, just for sync all on start. } default void onAdd(Extension extension) { // Do nothing here } default void onUpdate(Extension oldExtension, Extension newExtension) { // Do nothing here } default void onDelete(Extension extension) { // Do nothing here } default void registerDisposeHook(Runnable dispose) { } class WatcherComposite implements Watcher { private final List watchers; private volatile boolean disposed = false; private Runnable disposeHook; public WatcherComposite() { watchers = new CopyOnWriteArrayList<>(); } @Override public void onAdd(Extension extension) { // TODO Deep copy extension and execute onAdd asynchronously watchers.forEach(watcher -> watcher.onAdd(extension)); } @Override public void onUpdate(Extension oldExtension, Extension newExtension) { // TODO Deep copy extension and execute onUpdate asynchronously watchers.forEach(watcher -> watcher.onUpdate(oldExtension, newExtension)); } @Override public void onDelete(Extension extension) { // TODO Deep copy extension and execute onDelete asynchronously watchers.forEach(watcher -> watcher.onDelete(extension)); } public void addWatcher(Watcher watcher) { if (!watcher.isDisposed() && !watchers.contains(watcher)) { watchers.add(watcher); watcher.registerDisposeHook(() -> removeWatcher(watcher)); } } public void removeWatcher(Watcher watcher) { watchers.remove(watcher); } @Override public void registerDisposeHook(Runnable dispose) { this.disposeHook = dispose; } @Override public void dispose() { this.disposed = true; this.watchers.clear(); if (this.disposeHook != null) { this.disposeHook.run(); } } @Override public boolean isDisposed() { return this.disposed; } } } ================================================ FILE: api/src/main/java/run/halo/app/extension/WatcherExtensionMatchers.java ================================================ package run.halo.app.extension; import java.util.Objects; import lombok.Builder; import lombok.Getter; import org.springframework.util.Assert; public class WatcherExtensionMatchers { @Getter private final ExtensionClient client; private final GroupVersionKind gvk; private final ExtensionMatcher onAddMatcher; private final ExtensionMatcher onUpdateMatcher; private final ExtensionMatcher onDeleteMatcher; /** * Constructs a new {@link WatcherExtensionMatchers} with the given * {@link DefaultExtensionMatcher}. */ @Builder(builderMethodName = "internalBuilder") public WatcherExtensionMatchers(ExtensionClient client, GroupVersionKind gvk, ExtensionMatcher onAddMatcher, ExtensionMatcher onUpdateMatcher, ExtensionMatcher onDeleteMatcher) { Assert.notNull(client, "The client must not be null."); Assert.notNull(gvk, "The gvk must not be null."); this.client = client; this.gvk = gvk; this.onAddMatcher = Objects.requireNonNullElseGet(onAddMatcher, () -> emptyMatcher(client, gvk)); this.onUpdateMatcher = Objects.requireNonNullElseGet(onUpdateMatcher, () -> emptyMatcher(client, gvk)); this.onDeleteMatcher = Objects.requireNonNullElseGet(onDeleteMatcher, () -> emptyMatcher(client, gvk)); } public GroupVersionKind getGroupVersionKind() { return this.gvk; } public ExtensionMatcher onAddMatcher() { return delegateExtensionMatcher(this.onAddMatcher); } public ExtensionMatcher onUpdateMatcher() { return delegateExtensionMatcher(this.onUpdateMatcher); } public ExtensionMatcher onDeleteMatcher() { return delegateExtensionMatcher(this.onDeleteMatcher); } public static WatcherExtensionMatchersBuilder builder(ExtensionClient client, GroupVersionKind gvk) { return internalBuilder().gvk(gvk).client(client); } static ExtensionMatcher emptyMatcher(ExtensionClient client, GroupVersionKind gvk) { return DefaultExtensionMatcher.builder(client, gvk).build(); } ExtensionMatcher delegateExtensionMatcher(ExtensionMatcher matcher) { return extension -> extension.groupVersionKind().equals(gvk) && matcher.match(extension); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/WatcherPredicates.java ================================================ package run.halo.app.extension; import java.util.function.BiPredicate; import java.util.function.Predicate; public class WatcherPredicates { static final Predicate EMPTY_PREDICATE = (e) -> true; static final BiPredicate EMPTY_BI_PREDICATE = (oldExt, newExt) -> true; private final Predicate onAddPredicate; private final BiPredicate onUpdatePredicate; private final Predicate onDeletePredicate; public WatcherPredicates(Predicate onAddPredicate, BiPredicate onUpdatePredicate, Predicate onDeletePredicate) { this.onAddPredicate = onAddPredicate; this.onUpdatePredicate = onUpdatePredicate; this.onDeletePredicate = onDeletePredicate; } public Predicate onAddPredicate() { if (onAddPredicate == null) { return EMPTY_PREDICATE; } return onAddPredicate; } public BiPredicate onUpdatePredicate() { if (onUpdatePredicate == null) { return EMPTY_BI_PREDICATE; } return onUpdatePredicate; } public Predicate onDeletePredicate() { if (onDeletePredicate == null) { return EMPTY_PREDICATE; } return onDeletePredicate; } public static final class Builder { private Predicate onAddPredicate; private BiPredicate onUpdatePredicate; private Predicate onDeletePredicate; private GroupVersionKind gvk; public Builder withGroupVersionKind(GroupVersionKind gvk) { this.gvk = gvk; return this; } public Builder onAddPredicate(Predicate onAddPredicate) { this.onAddPredicate = onAddPredicate; return this; } public Builder onUpdatePredicate( BiPredicate onUpdatePredicate) { this.onUpdatePredicate = onUpdatePredicate; return this; } public Builder onDeletePredicate(Predicate onDeletePredicate) { this.onDeletePredicate = onDeletePredicate; return this; } public WatcherPredicates build() { Predicate gvkPredicate = EMPTY_PREDICATE; BiPredicate gvkBiPredicate = EMPTY_BI_PREDICATE; if (gvk != null) { gvkPredicate = e -> gvk.equals(e.groupVersionKind()); gvkBiPredicate = (oldE, newE) -> oldE.groupVersionKind().equals(gvk) && newE.groupVersionKind().equals(gvk); } if (onAddPredicate == null) { onAddPredicate = EMPTY_PREDICATE; } if (onUpdatePredicate == null) { onUpdatePredicate = EMPTY_BI_PREDICATE; } if (onDeletePredicate == null) { onDeletePredicate = EMPTY_PREDICATE; } onAddPredicate = gvkPredicate.and(onAddPredicate); onUpdatePredicate = gvkBiPredicate.and(onUpdatePredicate); onDeletePredicate = gvkPredicate.and(onDeletePredicate); return new WatcherPredicates(onAddPredicate, onUpdatePredicate, onDeletePredicate); } } } ================================================ FILE: api/src/main/java/run/halo/app/extension/controller/Controller.java ================================================ package run.halo.app.extension.controller; import reactor.core.Disposable; public interface Controller extends Disposable { String getName(); void start(); } ================================================ FILE: api/src/main/java/run/halo/app/extension/controller/ControllerBuilder.java ================================================ package run.halo.app.extension.controller; import java.time.Duration; import java.time.Instant; import java.util.function.Supplier; import org.springframework.util.Assert; import run.halo.app.extension.Extension; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionMatcher; import run.halo.app.extension.ListOptions; import run.halo.app.extension.WatcherExtensionMatchers; import run.halo.app.extension.controller.Reconciler.Request; public class ControllerBuilder { private final String name; private Duration minDelay; private Duration maxDelay; private final Reconciler reconciler; private Supplier nowSupplier; private Extension extension; private ExtensionMatcher onAddMatcher; private ExtensionMatcher onDeleteMatcher; private ExtensionMatcher onUpdateMatcher; private ListOptions syncAllListOptions; private final ExtensionClient client; private boolean syncAllOnStart = true; private int workerCount = 1; public ControllerBuilder(Reconciler reconciler, ExtensionClient client) { Assert.notNull(reconciler, "Reconciler must not be null"); Assert.notNull(client, "Extension client must not be null"); this.name = reconciler.getClass().getName(); this.reconciler = reconciler; this.client = client; } public ControllerBuilder minDelay(Duration minDelay) { this.minDelay = minDelay; return this; } public ControllerBuilder maxDelay(Duration maxDelay) { this.maxDelay = maxDelay; return this; } public ControllerBuilder nowSupplier(Supplier nowSupplier) { this.nowSupplier = nowSupplier; return this; } public ControllerBuilder extension(Extension extension) { this.extension = extension; return this; } public ControllerBuilder onAddMatcher(ExtensionMatcher onAddMatcher) { this.onAddMatcher = onAddMatcher; return this; } public ControllerBuilder onDeleteMatcher(ExtensionMatcher onDeleteMatcher) { this.onDeleteMatcher = onDeleteMatcher; return this; } public ControllerBuilder onUpdateMatcher(ExtensionMatcher extensionMatcher) { this.onUpdateMatcher = extensionMatcher; return this; } public ControllerBuilder syncAllOnStart(boolean syncAllAtStart) { this.syncAllOnStart = syncAllAtStart; return this; } public ControllerBuilder syncAllListOptions(ListOptions syncAllListOptions) { this.syncAllListOptions = syncAllListOptions; return this; } public ControllerBuilder workerCount(int workerCount) { this.workerCount = workerCount; return this; } public Controller build() { if (nowSupplier == null) { nowSupplier = Instant::now; } if (minDelay == null || minDelay.isNegative() || minDelay.isZero()) { minDelay = Duration.ofMillis(5); } if (maxDelay == null || maxDelay.isNegative() || maxDelay.isZero()) { maxDelay = Duration.ofSeconds(1000); } Assert.isTrue(minDelay.compareTo(maxDelay) <= 0, "Min delay must be less than or equal to max delay"); Assert.notNull(extension, "Extension must not be null"); Assert.notNull(reconciler, "Reconciler must not be null"); var queue = new DefaultQueue(nowSupplier, minDelay); var extensionMatchers = WatcherExtensionMatchers.builder(client, extension.groupVersionKind()) .onAddMatcher(onAddMatcher) .onUpdateMatcher(onUpdateMatcher) .onDeleteMatcher(onDeleteMatcher) .build(); var watcher = new ExtensionWatcher(queue, extensionMatchers); var synchronizer = new RequestSynchronizer(syncAllOnStart, client, extension, watcher, determineSyncAllListOptions()); return new DefaultController<>(name, reconciler, queue, synchronizer, minDelay, maxDelay, workerCount); } ListOptions determineSyncAllListOptions() { if (syncAllListOptions != null) { return syncAllListOptions; } return new ListOptions(); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/controller/DefaultController.java ================================================ package run.halo.app.extension.controller; import java.time.Duration; import java.time.Instant; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Supplier; import java.util.stream.IntStream; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StopWatch; import run.halo.app.extension.controller.RequestQueue.DelayedEntry; @Slf4j public class DefaultController implements Controller { private final String name; private final Reconciler reconciler; private final Supplier nowSupplier; private final RequestQueue queue; private volatile boolean disposed = false; private volatile boolean started = false; private final Executor executor; @Nullable private final Synchronizer synchronizer; private final Duration minDelay; private final Duration maxDelay; private final int workerCount; private final AtomicLong workerCounter; public DefaultController(String name, Reconciler reconciler, RequestQueue queue, Synchronizer synchronizer, Supplier nowSupplier, Duration minDelay, Duration maxDelay, ExecutorService executor, int workerCount) { this(name, reconciler, queue, synchronizer, nowSupplier, minDelay, maxDelay, (Executor) executor, workerCount); } public DefaultController(String name, Reconciler reconciler, RequestQueue queue, Synchronizer synchronizer, Supplier nowSupplier, Duration minDelay, Duration maxDelay, Executor executor, int workerCount) { Assert.isTrue(workerCount > 0, "Worker count must not be less than 1"); this.name = name; this.reconciler = reconciler; this.nowSupplier = nowSupplier; this.queue = queue; this.synchronizer = synchronizer; this.minDelay = minDelay; this.maxDelay = maxDelay; this.executor = executor; this.workerCount = workerCount; this.workerCounter = new AtomicLong(); } public DefaultController(String name, Reconciler reconciler, RequestQueue queue, Synchronizer synchronizer, Duration minDelay, Duration maxDelay) { this(name, reconciler, queue, synchronizer, Instant::now, minDelay, maxDelay, 1); } public DefaultController(String name, Reconciler reconciler, RequestQueue queue, Synchronizer synchronizer, Duration minDelay, Duration maxDelay, int workerCount) { this(name, reconciler, queue, synchronizer, Instant::now, minDelay, maxDelay, workerCount); } public DefaultController(String name, Reconciler reconciler, RequestQueue queue, Synchronizer synchronizer, Supplier nowSupplier, Duration minDelay, Duration maxDelay, int workerCount) { this(name, reconciler, queue, synchronizer, nowSupplier, minDelay, maxDelay, executor(name), workerCount); } private static Executor executor(String name) { return Executors.newThreadPerTaskExecutor(Thread.ofVirtual() .name(name, 1) .uncaughtExceptionHandler( (t, e) -> log.error("Uncaught exception in thread: {}", t.getName(), e) ) .factory() ); } @Override public String getName() { return name; } public int getWorkerCount() { return workerCount; } @Override public void start() { if (isStarted() || isDisposed()) { log.warn("Controller {} is already started or disposed.", getName()); return; } this.started = true; if (synchronizer != null) { executor.execute(synchronizer::start); } log.info("Starting controller {}", name); IntStream.range(0, getWorkerCount()) .mapToObj(i -> new Worker()) .forEach(executor::execute); } /** * Worker for controller. * * @author johnniang */ class Worker implements Runnable { private final String name; Worker() { this.name = DefaultController.this.getName() + "-worker-" + workerCounter.incrementAndGet(); } public String getName() { return name; } @Override public void run() { log.info("Controller worker {} started", this.name); while (!isDisposed() && !Thread.currentThread().isInterrupted()) { try { var entry = queue.take(); Reconciler.Result result; try { log.debug("{} >>> Reconciling request {} at {}", this.name, entry.getEntry(), nowSupplier.get()); var watch = new StopWatch(this.name + ":reconcile: " + entry.getEntry()); watch.start("reconciliation"); result = reconciler.reconcile(entry.getEntry()); watch.stop(); log.debug("{} >>> Reconciled request: {} with result: {}, usage: {}", this.name, entry.getEntry(), result, watch.getTotalTimeMillis()); } catch (Throwable t) { result = new Reconciler.Result(true, null); if (t instanceof OptimisticLockingFailureException) { log.warn("Optimistic locking failure when reconciling request: {}/{}", this.name, entry.getEntry()); } else if (t instanceof RequeueException re) { log.warn("{}: Requeue {} due to {}", this.name, entry.getEntry(), re.getMessage()); result = re.getResult(); } else { log.error("Reconciler in " + this.name + " aborted with an error, re-enqueuing...", t); } } finally { queue.done(entry.getEntry()); } if (result == null) { result = new Reconciler.Result(false, null); } if (!result.reEnqueue()) { continue; } var retryAfter = result.retryAfter(); if (retryAfter == null) { retryAfter = entry.getRetryAfter(); if (retryAfter == null || retryAfter.isNegative() || retryAfter.isZero() || retryAfter.compareTo(minDelay) < 0) { // set min retry after retryAfter = minDelay; } else { try { // TODO Refactor the retryAfter with ratelimiter retryAfter = retryAfter.multipliedBy(2); } catch (ArithmeticException e) { retryAfter = maxDelay; } } if (retryAfter.compareTo(maxDelay) > 0) { retryAfter = maxDelay; } } queue.add( new DelayedEntry<>(entry.getEntry(), retryAfter, nowSupplier)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.info("Controller worker {} interrupted", name); } } log.info("Controller worker {} is stopped", name); } } @Override public void dispose() { disposed = true; log.info("Disposing controller {}", name); if (synchronizer != null) { synchronizer.dispose(); } try { // we have to check if the executor is an instance of ExecutorService at first. // Because ExecutorService extends AutoCloseable interface in Java 21 if (executor instanceof ExecutorService executorService) { closeExecutorService(executorService); } else if (executor instanceof AutoCloseable closeable) { closeable.close(); if (Thread.currentThread().isInterrupted()) { log.warn("Wait timeout for controller {} shutdown", name); } else { log.info("Controller {} is disposed", name); } } } catch (Exception e) { log.warn("Interrupted while waiting for controller {} shutdown", name); } finally { queue.dispose(); } } @Override public boolean isDisposed() { return disposed; } public boolean isStarted() { return started; } /** * Close executor service. * * @param executorService executor service to be closed */ private static void closeExecutorService(ExecutorService executorService) { boolean terminated = executorService.isTerminated(); if (!terminated) { // Interrupt all running tasks first because of while loop waiting executorService.shutdownNow(); var interrupted = false; while (!terminated) { try { terminated = executorService.awaitTermination(1L, TimeUnit.SECONDS); } catch (InterruptedException ignored) { interrupted = true; } if (!terminated) { executorService.shutdown(); } } if (interrupted) { Thread.currentThread().interrupt(); } } } } ================================================ FILE: api/src/main/java/run/halo/app/extension/controller/DefaultQueue.java ================================================ package run.halo.app.extension.controller; import java.time.Duration; import java.time.Instant; import java.util.HashSet; import java.util.Optional; import java.util.Set; import java.util.concurrent.DelayQueue; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Supplier; import lombok.extern.slf4j.Slf4j; @Slf4j public class DefaultQueue implements RequestQueue { private final Lock lock; private final DelayQueue> queue; private final Supplier nowSupplier; private volatile boolean disposed = false; private final Duration minDelay; private final Set processing; private final Set dirty; public DefaultQueue(Supplier nowSupplier) { this(nowSupplier, Duration.ZERO); } public DefaultQueue(Supplier nowSupplier, Duration minDelay) { this.lock = new ReentrantLock(); this.nowSupplier = nowSupplier; this.minDelay = minDelay; this.processing = new HashSet<>(); this.dirty = new HashSet<>(); this.queue = new DelayQueue<>(); } @Override public boolean addImmediately(R request) { log.debug("Adding request {} immediately", request); var delayedEntry = new DelayedEntry<>(request, minDelay, nowSupplier); return add(delayedEntry); } @Override public boolean add(DelayedEntry entry) { lock.lock(); try { if (isDisposed()) { return false; } log.debug("Adding request {} after {}", entry.getEntry(), entry.getRetryAfter()); if (entry.getRetryAfter().compareTo(minDelay) < 0) { log.warn("Request {} will be retried after {} ms, but minimum delay is {} ms", entry.getEntry(), entry.getRetryAfter().toMillis(), minDelay.toMillis()); entry = new DelayedEntry<>(entry.getEntry(), minDelay, nowSupplier); } if (dirty.contains(entry.getEntry())) { var oldEntry = findOldEntry(entry); if (oldEntry.isEmpty()) { return false; } var oldReadyAt = oldEntry.get().getReadyAt(); var readyAt = entry.getReadyAt(); if (!readyAt.isBefore(oldReadyAt)) { return false; } } dirty.add(entry.getEntry()); if (processing.contains(entry.getEntry())) { return false; } boolean added = queue.add(entry); log.debug("Added request {} after {}", entry.getEntry(), entry.getRetryAfter()); return added; } finally { lock.unlock(); } } @Override public DelayedEntry take() throws InterruptedException { var entry = queue.take(); log.debug("Take request {} at {}", entry.getEntry(), Instant.now()); lock.lockInterruptibly(); try { if (isDisposed()) { throw new InterruptedException( "Queue has been disposed. Cannot take any elements now"); } processing.add(entry.getEntry()); dirty.remove(entry.getEntry()); return entry; } finally { lock.unlock(); } } @Override public void done(R request) { lock.lock(); try { if (isDisposed()) { return; } processing.remove(request); if (dirty.contains(request)) { queue.add(new DelayedEntry<>(request, minDelay, nowSupplier)); } } finally { lock.unlock(); } } @Override public long size() { return queue.size(); } @Override public DelayedEntry peek() { return queue.peek(); } @Override public void dispose() { lock.lock(); try { disposed = true; queue.clear(); processing.clear(); dirty.clear(); } finally { lock.unlock(); } } @Override public boolean isDisposed() { return this.disposed; } private Optional> findOldEntry(DelayedEntry entry) { for (DelayedEntry element : queue) { if (element.equals(entry)) { return Optional.of(element); } } return Optional.empty(); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/controller/ExtensionWatcher.java ================================================ package run.halo.app.extension.controller; import run.halo.app.extension.Extension; import run.halo.app.extension.Watcher; import run.halo.app.extension.WatcherExtensionMatchers; import run.halo.app.extension.controller.Reconciler.Request; public class ExtensionWatcher implements Watcher { private final RequestQueue queue; private volatile boolean disposed = false; private Runnable disposeHook; private final WatcherExtensionMatchers matchers; public ExtensionWatcher(RequestQueue queue, WatcherExtensionMatchers matchers) { this.queue = queue; this.matchers = matchers; } @Override public void onAdd(Request request) { if (isDisposed()) { return; } queue.addImmediately(request); } @Override public void onAdd(Extension extension) { if (isDisposed() || !matchers.onAddMatcher().match(extension)) { return; } // TODO filter the event queue.addImmediately(new Request(extension.getMetadata().getName())); } @Override public void onUpdate(Extension oldExtension, Extension newExtension) { if (isDisposed() || !matchers.onUpdateMatcher().match(newExtension)) { return; } // TODO filter the event queue.addImmediately(new Request(newExtension.getMetadata().getName())); } @Override public void onDelete(Extension extension) { if (isDisposed() || !matchers.onDeleteMatcher().match(extension)) { return; } // TODO filter the event queue.addImmediately(new Request(extension.getMetadata().getName())); } @Override public void registerDisposeHook(Runnable dispose) { this.disposeHook = dispose; } @Override public void dispose() { disposed = true; if (this.disposeHook != null) { this.disposeHook.run(); } } @Override public boolean isDisposed() { return this.disposed; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/controller/Reconciler.java ================================================ package run.halo.app.extension.controller; import java.time.Duration; public interface Reconciler { Result reconcile(R request); Controller setupWith(ControllerBuilder builder); record Request(String name) { } record Result(boolean reEnqueue, Duration retryAfter) { public static Result doNotRetry() { return new Result(false, null); } public static Result requeue(Duration retryAfter) { return new Result(true, retryAfter); } } } ================================================ FILE: api/src/main/java/run/halo/app/extension/controller/RequestQueue.java ================================================ package run.halo.app.extension.controller; import java.time.Duration; import java.time.Instant; import java.util.Objects; import java.util.concurrent.Delayed; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; import reactor.core.Disposable; public interface RequestQueue extends Disposable { boolean addImmediately(E request); boolean add(DelayedEntry entry); DelayedEntry take() throws InterruptedException; void done(E request); long size(); DelayedEntry peek(); class DelayedEntry implements Delayed { private final E entry; private final Instant readyAt; private final Supplier nowSupplier; private final Duration retryAfter; DelayedEntry(E entry, Duration retryAfter, Supplier nowSupplier) { this.entry = entry; this.readyAt = nowSupplier.get().plusMillis(retryAfter.toMillis()); this.nowSupplier = nowSupplier; this.retryAfter = retryAfter; } public DelayedEntry(E entry, Instant readyAt, Supplier nowSupplier) { this.entry = entry; this.readyAt = readyAt; this.nowSupplier = nowSupplier; this.retryAfter = Duration.between(nowSupplier.get(), readyAt); } @Override public long getDelay(TimeUnit unit) { Duration diff = Duration.between(nowSupplier.get(), readyAt); return unit.convert(diff); } public Duration getRetryAfter() { return retryAfter; } public Instant getReadyAt() { return readyAt; } @Override public int compareTo(Delayed o) { return Long.compare(getDelay(TimeUnit.MILLISECONDS), o.getDelay(TimeUnit.MILLISECONDS)); } public E getEntry() { return entry; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } DelayedEntry that = (DelayedEntry) o; return Objects.equals(entry, that.entry); } @Override public int hashCode() { return Objects.hash(entry); } } } ================================================ FILE: api/src/main/java/run/halo/app/extension/controller/RequestSynchronizer.java ================================================ package run.halo.app.extension.controller; import static org.springframework.data.domain.Sort.Direction.ASC; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Sort; import run.halo.app.extension.Extension; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Watcher; import run.halo.app.extension.controller.Reconciler.Request; import run.halo.app.extension.index.query.Queries; @Slf4j public class RequestSynchronizer implements Synchronizer { /** * Default batch size for listing Extension names. */ private static final Integer DEFAULT_BATCH_SIZE = 100; private final ExtensionClient client; private final Class type; private final boolean syncAllOnStart; private volatile boolean disposed = false; private final Watcher watcher; private final ListOptions listOptions; @Getter private volatile boolean started = false; public RequestSynchronizer(boolean syncAllOnStart, ExtensionClient client, Extension extension, Watcher watcher, ListOptions listOptions) { this.syncAllOnStart = syncAllOnStart; this.client = client; this.type = extension.getClass(); this.watcher = watcher; this.listOptions = listOptions; } @Override public void start() { if (isDisposed() || started) { return; } log.info("Starting request({}) synchronizer...", type); started = true; if (syncAllOnStart) { // list all in batch int batchSize = DEFAULT_BATCH_SIZE; var sort = Sort.by(ASC, "metadata.name"); // get the first batch to determine the current name var names = client.listTopNames(type, listOptions, sort, batchSize); names.forEach(name -> watcher.onAdd(new Request(name))); while (names.size() == batchSize) { var lastName = names.getLast(); var augmentedOptions = ListOptions.builder(listOptions) .andQuery(Queries.greaterThan("metadata.name", lastName)) .build(); names = client.listTopNames(type, augmentedOptions, sort, batchSize); names.forEach(name -> watcher.onAdd(new Request(name))); } } client.watch(this.watcher); log.info("Started request({}) synchronizer.", type); } @Override public void dispose() { disposed = true; watcher.dispose(); } @Override public boolean isDisposed() { return this.disposed; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/controller/RequeueException.java ================================================ package run.halo.app.extension.controller; import run.halo.app.extension.controller.Reconciler.Result; /** * Requeue with result data after throwing this exception. * * @author johnniang */ public class RequeueException extends RuntimeException { private final Result result; public RequeueException(Result result) { this(result, null); } public RequeueException(Result result, String reason) { this(result, reason, null); } public RequeueException(Result result, String reason, Throwable t) { super(reason, t); this.result = result; } public Result getResult() { return result; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/controller/Synchronizer.java ================================================ package run.halo.app.extension.controller; import reactor.core.Disposable; public interface Synchronizer extends Disposable { void start(); } ================================================ FILE: api/src/main/java/run/halo/app/extension/exception/ExtensionException.java ================================================ package run.halo.app.extension.exception; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.web.server.ResponseStatusException; /** * ExtensionException is the superclass of those exceptions that can be thrown by Extension module. * * @author johnniang */ public class ExtensionException extends ResponseStatusException { public ExtensionException(String reason) { this(reason, null); } public ExtensionException(String reason, Throwable cause) { this(HttpStatus.INTERNAL_SERVER_ERROR, reason, cause, null, new Object[] {reason}); } protected ExtensionException(HttpStatusCode status, String reason, Throwable cause, String messageDetailCode, Object[] messageDetailArguments) { super(status, reason, cause, messageDetailCode, messageDetailArguments); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/exception/NotImplementedException.java ================================================ package run.halo.app.extension.exception; /** * Exception thrown to indicate that the requested operation is not implemented. * * @author johnniang */ public class NotImplementedException extends UnsupportedOperationException { public NotImplementedException() { this("Not implemented"); } public NotImplementedException(String message) { super(message); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/exception/SchemeNotFoundException.java ================================================ package run.halo.app.extension.exception; import org.springframework.http.HttpStatus; import run.halo.app.extension.GroupVersionKind; /** * SchemeNotFoundException is thrown while we try to get a scheme but not found. * * @author johnniang */ public class SchemeNotFoundException extends ExtensionException { public SchemeNotFoundException(GroupVersionKind gvk) { super(HttpStatus.INTERNAL_SERVER_ERROR, "Scheme not found for " + gvk, null, null, new Object[] {gvk}); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/AbstractValueIndexSpecBuilder.java ================================================ package run.halo.app.extension.index; import org.springframework.util.Assert; import run.halo.app.extension.Extension; /** * Abstract base implementation of {@link IndexSpecBuilder} for value indexes. * * @param the type of extension * @param the type of key * @param the type of builder * @author johnniang * @since 2.22.0 */ abstract class AbstractValueIndexSpecBuilder< E extends Extension, K extends Comparable, B extends IndexSpecBuilder > implements IndexSpecBuilder { protected final String name; protected final Class keyType; protected boolean unique = false; protected boolean nullable = true; protected AbstractValueIndexSpecBuilder(String name, Class keyType) { Assert.hasText(name, "Index name must not be blank"); Assert.notNull(keyType, "Key type must not be null"); this.name = name; this.keyType = keyType; } public B unique(boolean unique) { this.unique = unique; return (B) this; } public B nullable(boolean nullable) { this.nullable = nullable; return (B) this; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/DefaultIndexAttribute.java ================================================ package run.halo.app.extension.index; import java.util.Optional; import java.util.Set; import java.util.function.Function; import org.springframework.util.Assert; import run.halo.app.extension.Extension; @Deprecated(forRemoval = true, since = "2.22.0") class DefaultIndexAttribute> implements IndexAttribute { private final Class objectType; private final Class keyType; private final Function> valuesFunc; private final boolean singleValue; public DefaultIndexAttribute( Function> valuesFunc, Class objectType, Class keyType, boolean singleValue ) { this.singleValue = singleValue; Assert.notNull(valuesFunc, "Values function must not be null"); Assert.notNull(objectType, "Cannot resolve object type"); Assert.notNull(keyType, "Cannot resolve key type"); this.valuesFunc = valuesFunc; this.objectType = objectType; this.keyType = keyType; } @Override public Class getObjectType() { return objectType; } @Override public Class getKeyType() { return keyType; } @Override public Set getValues(E e) { if (!checkType(e)) { throw new IllegalArgumentException("Object type does not match"); } return Optional.ofNullable(this.valuesFunc.apply(e)).orElse(Set.of()); } private boolean checkType(Extension object) { return getObjectType().isInstance(object); } @Override public boolean singleValue() { return this.singleValue; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/IndexAttribute.java ================================================ package run.halo.app.extension.index; import java.util.Set; import run.halo.app.extension.Extension; /** * * An attribute used for indexing extensions. * * @param the type of the extension * @param the type of the key * @deprecated Use {@link ValueIndexSpec} instead. */ @Deprecated(forRemoval = true, since = "2.22.0") public interface IndexAttribute> { /** * Specify this class is belonged to which extension. * * @return the extension class. */ Class getObjectType(); /** * Gets the value type of the attribute. * * @return the value type of the attribute. */ Class getKeyType(); /** * Gets the values of the attribute from the given extension. * * @param e the extension * @return the values of the attribute * @throws IllegalArgumentException if the given extension is not of the expected type */ Set getValues(E e); /** * Indicates whether this attribute is single-valued. * * @return true if single-valued, false otherwise */ boolean singleValue(); } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/IndexAttributeFactory.java ================================================ package run.halo.app.extension.index; import java.util.Optional; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import lombok.experimental.UtilityClass; import run.halo.app.extension.Extension; /** * Factory for creating index attributes. * * @deprecated Use {@link SingleValueIndexSpec} and {@link MultiValueIndexSpec} instead. */ @Deprecated(forRemoval = true, since = "2.22.0") @UtilityClass public class IndexAttributeFactory { public static IndexAttribute simpleAttribute(Class type, Function valueFunc) { return attribute(type, UnknownKey.class, (E e) -> Optional.ofNullable(valueFunc.apply(e)) .map(UnknownKey::new) .orElse(null)); } public static IndexAttribute multiValueAttribute( Class type, Function> valuesFunc) { return attributes(type, UnknownKey.class, (E e) -> Optional.ofNullable(valuesFunc.apply(e)) .map(values -> values.stream() .map(UnknownKey::new) .collect(Collectors.toSet()) ) .orElse(null)); } private static > IndexAttribute attributes( Class objectType, Class keyType, Function> valuesFunc ) { return new DefaultIndexAttribute<>(valuesFunc, objectType, keyType, false); } private static > IndexAttribute attribute( Class objectType, Class keyType, Function valueFunc ) { return new DefaultIndexAttribute<>( e -> Optional.ofNullable(valueFunc.apply(e)) .map(Set::of) .orElse(null), objectType, keyType, true); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/IndexSpec.java ================================================ package run.halo.app.extension.index; import com.google.common.base.Objects; import java.util.Set; import lombok.Data; import lombok.experimental.Accessors; import org.springframework.util.CollectionUtils; import run.halo.app.extension.Extension; /** * An implementation of {@link MultiValueIndexSpec}. * * @param the type of the extension * @param the type of the key * @deprecated Use {@link IndexSpecs#multi(String, Class)} instead. */ @Data @Accessors(chain = true) @Deprecated(forRemoval = true, since = "2.22.0") public class IndexSpec> implements ValueIndexSpec { private String name; private IndexAttribute indexFunc; private OrderType order; private boolean unique; public Set getValues(E extension) { return indexFunc.getValues(extension); } public enum OrderType { ASC, DESC } @Override public boolean isNullable() { return true; } @Override public Class getKeyType() { return indexFunc.getKeyType(); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } IndexSpec indexSpec = (IndexSpec) o; return Objects.equal(name, indexSpec.name); } @Override public int hashCode() { return Objects.hashCode(name); } /** * Normalize to single or multi value index spec. * * @return the normalized index spec */ public ValueIndexSpec normalize() { if (this.indexFunc.singleValue()) { return IndexSpecs.single(name, getKeyType()) .unique(unique) .indexFunc(e -> { var values = getValues(e); return CollectionUtils.isEmpty(values) ? null : values.iterator().next(); }) .build(); } return IndexSpecs.multi(name, getKeyType()) .unique(unique) .indexFunc(this::getValues) .build(); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/IndexSpecBuilder.java ================================================ package run.halo.app.extension.index; import run.halo.app.extension.Extension; /** * Index specification builder. * * @param the type of extension * @param the type of index key * @author johnniang * @since 2.22.0 */ public interface IndexSpecBuilder< E extends Extension, K extends Comparable, B extends IndexSpecBuilder > { /** * Sets whether the index is unique. * * @param unique whether the index is unique, default is false * @return the updated IndexSpecBuilder */ B unique(boolean unique); /** * Sets whether the index allows null values. * * @param nullable whether the index allows null values, default is true * @return the updated IndexSpecBuilder */ B nullable(boolean nullable); /** * Builds the value index specification. * * @return the value index specification */ ValueIndexSpec build(); } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/IndexSpecs.java ================================================ package run.halo.app.extension.index; import java.util.List; import run.halo.app.extension.Extension; /** * An interface that defines a collection of {@link IndexSpec}, and provides methods to add, * remove, and get {@link IndexSpec}. * * @author guqing * @since 2.12.0 */ public interface IndexSpecs { /** * Add a new {@link IndexSpec} to the collection. * * @param indexSpec the index spec to add. * @throws IllegalArgumentException if the index spec with the same name already exists or * the index spec is invalid */ default > void add(IndexSpec indexSpec) { add(indexSpec.normalize()); } > void add(ValueIndexSpec indexSpec); default > void add(IndexSpecBuilder builder) { add(builder.build()); } /** * Get all {@link IndexSpec} in the collection. * * @return all index specs */ List> getIndexSpecs(); /*** * Create a multi-value index spec builder. * * @param name the name of the index spec * @param keyType the type of the keys used in the index spec * @param the type of the extension * @param the type of the key * @return a MultiValueBuilder for the specified index spec */ static > MultiValueIndexSpecBuilder multi( String name, Class keyType ) { return new MultiValueBuilder<>(name, keyType); } /** * Create a single-value index spec builder. * * @param name the name of the index spec * @param keyType the type of the keys used in the index spec * @param the type of the extension * @param the type of the key * @return a SingleValueBuilder for the specified index spec. */ static > SingleValueIndexSpecBuilder single( String name, Class keyType ) { return new SingleValueBuilder<>(name, keyType); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/IndexedQueryEngine.java ================================================ package run.halo.app.extension.index; import java.util.List; import org.springframework.data.domain.Sort; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; /** *

An interface for querying indexed object records from the index store.

*

It provides a way to retrieve the object records by the given {@link GroupVersionKind} and * {@link ListOptions}, the final result will be ordered by the index what {@link ListOptions} * used and specified by the {@link PageRequest#getSort()}.

* * @author guqing * @since 2.12.0 * @deprecated Use {@link ReactiveExtensionClient#listAllNames(Class, ListOptions, Sort)} * or {@link ReactiveExtensionClient#countBy(Class, ListOptions)} * or {@link ReactiveExtensionClient#listTopNames(Class, ListOptions, Sort, int)} instead */ @Deprecated(forRemoval = true, since = "2.22.0") public interface IndexedQueryEngine { /** * Page retrieve the object records by the given {@link GroupVersionKind} and * {@link ListOptions}. * * @param type the type of the object must exist in * {@link run.halo.app.extension.SchemeManager}. * @param options the list options to use for retrieving the object records. * @param page which page to retrieve and how large the page should be. * @return a collection of {@link Metadata#getName()} for the given page. */ ListResult retrieve(GroupVersionKind type, ListOptions options, PageRequest page); /** * Retrieve all the object records by the given {@link GroupVersionKind} and * {@link ListOptions}. * * @param type the type of the object must exist in {@link run.halo.app.extension.SchemeManager} * @param options the list options to use for retrieving the object records * @param sort the sort to use for retrieving the object records * @return a collection of {@link Metadata#getName()} */ List retrieveAll(GroupVersionKind type, ListOptions options, Sort sort); } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/KeyComparator.java ================================================ package run.halo.app.extension.index; import java.util.Comparator; import org.springframework.lang.Nullable; @Deprecated(forRemoval = true, since = "2.22.0") class KeyComparator implements Comparator { public static final KeyComparator INSTANCE = new KeyComparator(); @Override public int compare(@Nullable String a, @Nullable String b) { if (a == null && b == null) { return 0; } else if (a == null) { // null less than everything return 1; } else if (b == null) { // null less than everything return -1; } return compareStrings(a, b); } private int compareStrings(String a, String b) { int i = 0; int j = 0; while (i < a.length() && j < b.length()) { char charA = a.charAt(i); char charB = b.charAt(j); if (Character.isDigit(charA) && Character.isDigit(charB)) { // Both characters are digits, compare as numbers int compareResult = compareNumbers(a, b, i, j); if (compareResult != 0) { return compareResult; } // Move indices past the compared number segments i = moveIndexToNextNonDigit(a, i); j = moveIndexToNextNonDigit(b, j); } else if (charA == charB) { // Characters are the same, continue i++; j++; } else if (Character.isDigit(charA)) { // If charA is digit and charB is not, digit comes first return -1; } else if (Character.isDigit(charB)) { // If charB is digit and charA is not, digit comes first return 1; } else { // Both are non-digits, compare directly return Character.compare(charA, charB); } } return Integer.compare(a.length(), b.length()); } private int compareNumbers(String a, String b, int startA, int startB) { int i = startA; int j = startB; // Compare lengths of remaining digits int lengthA = countDigits(a, i); int lengthB = countDigits(b, j); if (lengthA != lengthB) { return Integer.compare(lengthA, lengthB); } // Compare digits one by one for (int k = 0; k < lengthA && i < a.length() && j < b.length(); k++, i++, j++) { char charA = a.charAt(i); char charB = b.charAt(j); if (charA != charB) { return Character.compare(charA, charB); } } // If both numbers have decimal points, compare decimal parts boolean hasDecimalA = i < a.length() && a.charAt(i) == '.'; boolean hasDecimalB = j < b.length() && b.charAt(j) == '.'; if (hasDecimalA || hasDecimalB) { return compareDecimalNumbers(a, b, i, j); } return 0; } private int compareDecimalNumbers(String a, String b, int startA, int startB) { // Find decimal point positions int pointA = a.indexOf('.', startA); int pointB = b.indexOf('.', startB); // Compare integer parts before the decimal point int integerComparison = compareIntegerPart(a, b, startA, startB, pointA, pointB); if (integerComparison != 0) { return integerComparison; } // Compare fractional parts after the decimal point return compareFractionalPart(a, b, pointA + 1, pointB + 1); } private int compareIntegerPart(String a, String b, int startA, int startB, int pointA, int pointB) { int i = startA; int j = startB; int lengthA = pointA - i; int lengthB = pointB - j; if (lengthA != lengthB) { return Integer.compare(lengthA, lengthB); } while (i < pointA && j < pointB) { char charA = a.charAt(i); char charB = b.charAt(j); if (charA != charB) { return Character.compare(charA, charB); } i++; j++; } return 0; } private int compareFractionalPart(String a, String b, int i, int j) { while (i < a.length() && j < b.length() && Character.isDigit(a.charAt(i)) && Character.isDigit(b.charAt(j))) { if (a.charAt(i) != b.charAt(j)) { return Character.compare(a.charAt(i), b.charAt(j)); } i++; j++; } // If one number has more digits left, and they're not all zeroes, it is larger while (i < a.length() && Character.isDigit(a.charAt(i))) { if (a.charAt(i) != '0') { return 1; } i++; } while (j < b.length() && Character.isDigit(b.charAt(j))) { if (b.charAt(j) != '0') { return -1; } j++; } return 0; } private int countDigits(String s, int start) { int count = 0; while (start < s.length() && Character.isDigit(s.charAt(start))) { count++; start++; } return count; } private int moveIndexToNextNonDigit(String s, int index) { while (index < s.length() && (Character.isDigit(s.charAt(index)) || s.charAt(index) == '.')) { index++; } return index; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/MultiValueBuilder.java ================================================ package run.halo.app.extension.index; import java.util.Set; import java.util.function.Function; import org.springframework.util.Assert; import run.halo.app.extension.Extension; /** * Builder for {@link MultiValueIndexSpec}. * * @param the type of extension * @param the type of index key * @author johnniang * @since 2.22.0 */ class MultiValueBuilder> extends AbstractValueIndexSpecBuilder> implements MultiValueIndexSpecBuilder { private Function> indexFunc; MultiValueBuilder(String name, Class keyType) { super(name, keyType); } @Override public MultiValueIndexSpecBuilder indexFunc(Function> indexFunc) { this.indexFunc = indexFunc; return this; } @Override public ValueIndexSpec build() { Assert.hasText(name, "Index name must not be blank"); Assert.notNull(keyType, "Key type must not be null"); Assert.notNull(indexFunc, "Index function must not be null"); return new MultiValueIndexSpec<>() { @Override public String getName() { return name; } @Override public boolean isUnique() { return unique; } @Override public boolean isNullable() { return nullable; } @Override public Class getKeyType() { return keyType; } @Override public Set getValues(E extension) { return indexFunc.apply(extension); } }; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/MultiValueIndexSpec.java ================================================ package run.halo.app.extension.index; import java.util.Set; import org.springframework.lang.Nullable; import run.halo.app.extension.Extension; /** * Multi value index specification. * * @param the type of extension * @param the type of key * @author johnniang * @since 2.22.0 */ interface MultiValueIndexSpec> extends ValueIndexSpec { @Nullable Set getValues(E extension); static > MultiValueBuilder builder( String name, Class keyType) { return new MultiValueBuilder<>(name, keyType); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/MultiValueIndexSpecBuilder.java ================================================ package run.halo.app.extension.index; import java.util.Set; import java.util.function.Function; import run.halo.app.extension.Extension; /** * Builder for {@link MultiValueIndexSpec}. * * @param the type of extension * @param the type of index key * @author johnniang * @since 2.22.0 */ public interface MultiValueIndexSpecBuilder> extends IndexSpecBuilder> { /** * Sets the index function. * * @param indexFunc the index function * @return the builder itself */ MultiValueIndexSpecBuilder indexFunc(Function> indexFunc); } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/SingleValueBuilder.java ================================================ package run.halo.app.extension.index; import java.util.function.Function; import org.springframework.util.Assert; import run.halo.app.extension.Extension; /** * Single value index specification builder. * * @param the type of extension * @param the type of index key * @author johnniang * @since 2.22.0 */ class SingleValueBuilder> extends AbstractValueIndexSpecBuilder> implements SingleValueIndexSpecBuilder { private Function indexFunc; SingleValueBuilder(String name, Class keyType) { super(name, keyType); } @Override public SingleValueBuilder indexFunc(Function indexFunc) { this.indexFunc = indexFunc; return this; } @Override public ValueIndexSpec build() { Assert.hasText(name, "Index name must not be blank"); Assert.notNull(keyType, "Key type must not be null"); Assert.notNull(indexFunc, "Index function must not be null"); return new SingleValueIndexSpec<>() { @Override public K getValue(E extension) { return indexFunc.apply(extension); } @Override public String getName() { return name; } @Override public boolean isUnique() { return unique; } @Override public boolean isNullable() { return nullable; } @Override public Class getKeyType() { return keyType; } }; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/SingleValueIndexSpec.java ================================================ package run.halo.app.extension.index; import org.springframework.lang.Nullable; import run.halo.app.extension.Extension; /** * Single value index specification. * * @param the type of extension * @param the type of key * @author johnniang * @since 2.22.0 */ interface SingleValueIndexSpec> extends ValueIndexSpec { @Nullable K getValue(E extension); static > SingleValueBuilder builder( String name, Class keyType ) { return new SingleValueBuilder<>(name, keyType); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/SingleValueIndexSpecBuilder.java ================================================ package run.halo.app.extension.index; import java.util.function.Function; import run.halo.app.extension.Extension; /** * Builder for {@link SingleValueIndexSpec}. * * @param the type of extension * @param the type of index key * @author johnniang * @since 2.22.0 */ public interface SingleValueIndexSpecBuilder> extends IndexSpecBuilder> { /** * Sets the index function. * * @param indexFunc the index function * @return the builder itself */ SingleValueIndexSpecBuilder indexFunc(Function indexFunc); } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/UnknownKey.java ================================================ package run.halo.app.extension.index; import java.util.Objects; import org.jetbrains.annotations.NotNull; import org.springframework.lang.Nullable; /** * String key wrapper for nullable string comparison. Only for backward compatibility. May remove * in the future. * * @author johnniang * @since 2.22.0 */ @Deprecated(forRemoval = true, since = "2.22.0") record UnknownKey(@Nullable String value) implements Comparable { @Override public int compareTo(@NotNull UnknownKey o) { return KeyComparator.INSTANCE.compare(this.value, o.value); } @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) { return false; } UnknownKey unknownKey = (UnknownKey) o; return Objects.equals(value, unknownKey.value); } @NotNull @Override public String toString() { return Objects.toString(value); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/ValueIndexSpec.java ================================================ package run.halo.app.extension.index; import run.halo.app.extension.Extension; /** * Specification for a value index on an extension. * * @param the type of the extension * @param the type of the key * @author johnniang * @since 2.22.0 */ public interface ValueIndexSpec> { /** * Gets the name of this index. * * @return the name of this index */ String getName(); /** * Whether this index is unique. * * @return true if this index is unique, false otherwise */ boolean isUnique(); /** * Whether this index allows null values. * * @return true if this index allows null values, false otherwise */ boolean isNullable(); /** * Gets the type of the key. * * @return the type of the key */ Class getKeyType(); } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/AllCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record AllCondition(String indexName) implements Condition { @Override public Condition not() { return new NoneCondition(indexName); } @NotNull @Override public String toString() { return "ALL " + indexName; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/And.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; /** * This condition is only for backward compatibility. * * @param left left condition * @param right right condition * @deprecated Use {@link Condition#and(Condition)} instead. */ @Deprecated(forRemoval = true, since = "2.22.0") public record And(Condition left, Condition right) implements Condition { @Override public Condition not() { return left.not().or(right.not()); } @NotNull @Override public String toString() { return "(" + left + " AND " + right + ")"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/AndCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; import org.springframework.util.Assert; record AndCondition(Condition left, Condition right) implements Condition { public AndCondition { Assert.notNull(left, "Left condition must not be null"); Assert.notNull(right, "Right condition must not be null"); } @Override public Condition not() { return left.not().or(right.not()); } @NotNull @Override public String toString() { return "(" + left + " AND " + right + ")"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/BetweenCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record BetweenCondition( String indexName, Object fromKey, boolean fromInclusive, Object toKey, boolean toInclusive) implements IndexCondition { @Override public Condition not() { return new NotBetweenCondition(indexName, fromKey, !fromInclusive, toKey, !toInclusive); } @NotNull @Override public String toString() { return indexName + " BETWEEN " + (fromInclusive ? "[" : "(") + fromKey + ", " + toKey + (toInclusive ? "]" : ")"); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/Condition.java ================================================ package run.halo.app.extension.index.query; import org.springframework.data.relational.core.sql.Visitable; /** * A condition used in querying index. * e.g.: {@code metadata.name = 'halo' AND status.published = true} * * @author johnniang * @since 2.22.0 */ public interface Condition extends Visitable, Query { /** * Combine with another condition using AND operator. * * @param another another condition * @return the combined condition */ default Condition and(Condition another) { return new AndCondition(this, another); } /** * Combine with another condition using OR operator. * * @param another another condition * @return the combined condition */ default Condition or(Condition another) { return new OrCondition(this, another); } /** * Negate this condition. * * @return the negated condition */ default Condition not() { return new NotCondition(this); } /** * Creates an empty condition. * * @return an empty condition */ static Condition empty() { return new EmptyCondition(); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/EmptyCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record EmptyCondition() implements Condition { @NotNull @Override public String toString() { return "EMPTY"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/EqualCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; import org.springframework.util.Assert; record EqualCondition(String indexName, Object key) implements IndexCondition { public EqualCondition { Assert.notNull(key, "Key of " + indexName + " must not be null"); } @Override public Condition not() { return new NotEqualCondition(indexName, key); } @NotNull @Override public String toString() { return indexName + " = " + key; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/GreaterThanCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record GreaterThanCondition(String indexName, Object lowerBound, boolean inclusive) implements IndexCondition { @Override public Condition not() { return new LessThanCondition(indexName, lowerBound, !inclusive); } @NotNull @Override public String toString() { return indexName + (inclusive ? " >= " : " > ") + lowerBound; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/InCondition.java ================================================ package run.halo.app.extension.index.query; import java.util.Collection; import java.util.stream.Collectors; import org.jetbrains.annotations.NotNull; import org.springframework.util.Assert; record InCondition(String indexName, Collection keys) implements IndexCondition { public InCondition { Assert.notNull(keys, "Keys of " + indexName + " must not be empty"); } @Override public Condition not() { return new NotInCondition(indexName, keys); } @NotNull @Override public String toString() { return indexName + " IN (" + keys.stream().map(Object::toString).collect(Collectors.joining(", ")) + ")"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/IndexCondition.java ================================================ package run.halo.app.extension.index.query; /** * Index condition interface for index-based queries. * * @author johnniang * @since 2.22.0 */ public interface IndexCondition extends Condition { /** * Get the index name. * * @return the index name */ String indexName(); } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/IsNotNullCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record IsNotNullCondition(String indexName) implements IndexCondition { @Override public Condition not() { return new IsNullCondition(indexName); } @NotNull @Override public String toString() { return indexName + " IS NOT NULL"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/IsNullCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record IsNullCondition(String indexName) implements IndexCondition { @Override public Condition not() { return new IsNotNullCondition(indexName); } @NotNull @Override public String toString() { return indexName + " IS NULL"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/LabelCondition.java ================================================ package run.halo.app.extension.index.query; /** * Label condition interface for label-based queries. * * @author johnniang * @since 2.22.0 */ public interface LabelCondition extends Condition { String INDEX_NAME = "metadata.labels"; /** * Get the label key. * * @return the label key */ String labelKey(); @Override LabelCondition not(); } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/LabelEqualsCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record LabelEqualsCondition(String labelKey, String labelValue) implements LabelCondition { @Override public LabelCondition not() { return new LabelNotEqualsCondition(labelKey, labelValue); } @NotNull @Override public String toString() { return INDEX_NAME + "['" + labelKey + "'] = '" + labelValue + "'"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/LabelExistsCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record LabelExistsCondition(String labelKey) implements LabelCondition { @Override public LabelCondition not() { return new LabelNotExistsCondition(labelKey); } @NotNull @Override public String toString() { return "EXISTS " + INDEX_NAME + "['" + labelKey + "']"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/LabelInCondition.java ================================================ package run.halo.app.extension.index.query; import java.util.Collection; import org.jetbrains.annotations.NotNull; import org.springframework.util.Assert; record LabelInCondition(String labelKey, Collection labelValues) implements LabelCondition { public LabelInCondition { Assert.notNull(labelValues, "labelValues of " + labelKey + " must not be null"); } @Override public LabelCondition not() { return new LabelNotInCondition(labelKey, labelValues); } @NotNull @Override public String toString() { return INDEX_NAME + "['" + labelKey + "'] IN (" + String.join(", ", labelValues.stream().map(v -> "'" + v + "'").toList()) + ")"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/LabelNotEqualsCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record LabelNotEqualsCondition(String labelKey, String labelValue) implements LabelCondition { @Override public LabelCondition not() { return new LabelEqualsCondition(labelKey, labelValue); } @NotNull @Override public String toString() { return INDEX_NAME + "['" + labelKey + "'] <> '" + labelValue + "'"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/LabelNotExistsCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record LabelNotExistsCondition(String labelKey) implements LabelCondition { @Override public LabelCondition not() { return new LabelExistsCondition(labelKey); } @NotNull @Override public String toString() { return "NOT EXISTS " + INDEX_NAME + "['" + labelKey + "']"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/LabelNotInCondition.java ================================================ package run.halo.app.extension.index.query; import java.util.Collection; import org.jetbrains.annotations.NotNull; import org.springframework.util.Assert; record LabelNotInCondition(String labelKey, Collection labelValues) implements LabelCondition { public LabelNotInCondition { Assert.notNull(labelValues, "labelValues of " + labelKey + " must not be null"); } @Override public LabelCondition not() { return new LabelInCondition(labelKey, labelValues); } @NotNull @Override public String toString() { return INDEX_NAME + "['" + labelKey + "'] NOT IN (" + String.join(", ", labelValues.stream().map(v -> "'" + v + "'").toList()) + ")"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/LessThanCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record LessThanCondition(String indexName, Object upperBound, boolean inclusive) implements IndexCondition { @Override public Condition not() { return new GreaterThanCondition(indexName, upperBound, !inclusive); } @NotNull @Override public String toString() { return indexName + (inclusive ? " <= " : " < ") + upperBound; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/NoneCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record NoneCondition(String indexName) implements IndexCondition { @Override public Condition not() { return new AllCondition(indexName); } @NotNull @Override public String toString() { return "NONE " + indexName; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/NotBetweenCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record NotBetweenCondition( String indexName, Object fromKey, boolean fromInclusive, Object toKey, boolean toInclusive) implements IndexCondition { @Override public Condition not() { return new BetweenCondition(indexName, fromKey, !fromInclusive, toKey, !toInclusive); } @NotNull @Override public String toString() { return indexName + " NOT BETWEEN " + (fromInclusive ? "[" : "(") + fromKey + ", " + toKey + (toInclusive ? "]" : ")"); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/NotCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; import org.springframework.util.Assert; record NotCondition(Condition condition) implements Condition { public NotCondition { Assert.notNull(condition, "Condition must not be null"); } @Override public Condition not() { return condition; } @NotNull @Override public String toString() { return "NOT (" + condition + ")"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/NotEqualCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; import org.springframework.util.Assert; record NotEqualCondition(String indexName, Object key) implements IndexCondition { public NotEqualCondition { Assert.notNull(key, "Key of " + indexName + " must not be null"); } @Override public Condition not() { return new EqualCondition(indexName, key); } @NotNull @Override public String toString() { return indexName + " != " + key; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/NotInCondition.java ================================================ package run.halo.app.extension.index.query; import java.util.Collection; import java.util.stream.Collectors; import org.jetbrains.annotations.NotNull; import org.springframework.util.Assert; record NotInCondition(String indexName, Collection keys) implements IndexCondition { public NotInCondition { Assert.notNull(keys, "Keys of " + indexName + " must not be empty"); } @Override public Condition not() { return new InCondition(indexName, keys); } @NotNull @Override public String toString() { return indexName + " NOT IN (" + keys.stream().map(Object::toString).collect(Collectors.joining(", ")) + ")"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/OrCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; import org.springframework.util.Assert; record OrCondition(Condition left, Condition right) implements Condition { public OrCondition { Assert.notNull(left, "Left condition must not be null"); Assert.notNull(right, "Right condition must not be null"); } @Override public Condition not() { return left.not().and(right.not()); } @NotNull @Override public String toString() { return "(" + left + " OR " + right + ")"; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/Queries.java ================================================ package run.halo.app.extension.index.query; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import org.springframework.util.Assert; /** * A utility class for building query conditions. * *

* Use {@link Condition#not()} to create negated conditions. * * @author johnniang * @since 2.22.0 */ public enum Queries { ; /** * Combines multiple conditions with a logical AND. * * @param condition the first condition, must not be null * @param additionalConditions additional conditions to combine * @return the combined condition */ public static Condition and(Condition condition, Condition... additionalConditions) { Assert.notNull(condition, "Condition must not be null"); return Arrays.stream(additionalConditions) .reduce(condition, Condition::and); } /** * Combines multiple conditions with a logical OR. * * @param condition the first condition, must not be null * @param additionalConditions additional conditions to combine * @return the combined condition */ public static Condition or(Condition condition, Condition... additionalConditions) { Assert.notNull(condition, "Condition must not be null"); return Arrays.stream(additionalConditions) .reduce(condition, Condition::or); } /** * Negates the specified condition. * * @param condition the condition to negate, must not be null * @return the negated condition */ public static Condition not(Condition condition) { Assert.notNull(condition, "Condition must not be null"); return condition.not(); } /** * Creates a "between" condition for the specified field name and range values. * * @param fieldName the name of the field * @param fromValue the start value of the range * @param fromInclusive whether the start value is inclusive * @param toValue the end value of the range * @param toInclusive whether the end value is inclusive * @return the "between" condition */ public static Condition between(String fieldName, Object fromValue, boolean fromInclusive, Object toValue, boolean toInclusive) { return new BetweenCondition(fieldName, fromValue, fromInclusive, toValue, toInclusive); } /** * Creates an empty condition that matches all records. * * @return the empty condition */ public static Condition empty() { return new EmptyCondition(); } /** * Creates an "all" condition for the specified field name. * * @param fieldName the name of the field * @return the "all" condition */ public static Condition all(String fieldName) { return new AllCondition(fieldName); } /** * Creates an "is null" condition for the specified field name. * * @param fieldName the name of the field * @return the "is null" condition */ public static Condition isNull(String fieldName) { return new IsNullCondition(fieldName); } /** * Creates an "equal" condition for the specified field name and attribute value. * * @param fieldName the name of the field * @param attributeValue the attribute value, must not be null * @return the "equal" condition */ public static Condition equal(String fieldName, Object attributeValue) { Assert.notNull(attributeValue, "Attribute key of field " + fieldName + " must not be null" ); return new EqualCondition(fieldName, attributeValue); } /** * Creates a "greater than" condition for the specified field name and attribute value. * * @param fieldName the name of the field * @param attributeValue the attribute value, must not be null * @param inclusive whether the comparison is inclusive * @return the "greater than" condition */ public static Condition greaterThan( String fieldName, Object attributeValue, boolean inclusive) { return new GreaterThanCondition(fieldName, attributeValue, inclusive); } /** * Creates a "greater than" condition for the specified field name and attribute value. * * @param fieldName the name of the field * @param attributeValue the attribute value, must not be null. Which is not inclusive. * @return the "greater than" condition */ public static Condition greaterThan(String fieldName, Object attributeValue) { return greaterThan(fieldName, attributeValue, false); } /** * Creates an "in" condition for the specified field name and attribute values. * * @param fieldName the name of the field * @param attributeValue the first attribute value, must not be null. If it's a collection, * it will be treated as the collection of values for the "in" condition. * @param additionalValues additional attribute values * @return the "in" condition */ @SuppressWarnings("unchecked") public static Condition in( String fieldName, Object attributeValue, Object... additionalValues) { Assert.notNull(attributeValue, "Attribute key of field " + fieldName + " must not be null" ); if (attributeValue instanceof Collection collection) { // Allow passing a collection directly return in(fieldName, (Collection) collection); } if (additionalValues == null) { return equal(fieldName, attributeValue); } var values = new ArrayList<>(additionalValues.length + 1); values.add(attributeValue); values.addAll(Arrays.asList(additionalValues)); return in(fieldName, values); } /** * Creates an "in" condition for the specified field name and collection of values. * * @param fieldName the name of the field * @param values the collection of values, must not be null * @return the "in" condition */ public static Condition in(String fieldName, Collection values) { Assert.notNull(values, "Values must not be null"); if (values.size() == 1) { var value = values.iterator().next(); return equal(fieldName, value); } return new InCondition(fieldName, values); } /** * Creates a "less than" condition for the specified field name and attribute value. * * @param fieldName the name of the field * @param attributeValue the attribute value, must not be null * @param inclusive whether the comparison is inclusive * @return the "less than" condition */ public static Condition lessThan(String fieldName, Object attributeValue, boolean inclusive) { return new LessThanCondition(fieldName, attributeValue, inclusive); } /** * Creates a "less than" condition for the specified field name and attribute value. * * @param fieldName the name of the field * @param attributeValue the attribute value, must not be null. Which is not inclusive. * @return the "less than" condition */ public static Condition lessThan(String fieldName, Object attributeValue) { return lessThan(fieldName, attributeValue, false); } /** * Creates a "not equal" condition for the specified field name and attribute value. * * @param fieldName the name of the field * @param attributeValue the attribute value, must not be null * @return the "not equal" condition */ public static Condition notEqual(String fieldName, Object attributeValue) { return new NotEqualCondition(fieldName, attributeValue); } /** * Creates a "starts with" condition for the specified field name and prefix value. * * @param fieldName the name of the field * @param prefix the prefix value, must not be null * @return the "starts with" condition */ public static Condition startsWith(String fieldName, String prefix) { return new StringStartsWithCondition(fieldName, prefix); } /** * Creates an "ends with" condition for the specified field name and suffix value. * * @param fieldName the name of the field * @param suffix the suffix value, must not be null * @return the "ends with" condition */ public static Condition endsWith(String fieldName, String suffix) { return new StringEndsWithCondition(fieldName, suffix); } /** * Creates a "contains" condition for the specified field name and substring value. * * @param fieldName the name of the field * @param substring the substring value, must not be null * @return the "contains" condition */ public static Condition contains(String fieldName, String substring) { return new StringContainsCondition(fieldName, substring); } /** * Creates a label condition that checks for the existence of a label with the specified key. * * @param labelKey the label key, must not be null * @return the label existence condition */ public static LabelCondition labelExists(String labelKey) { return new LabelExistsCondition(labelKey); } /** * Creates a label condition that checks for equality of a label with the specified key and * value. * * @param labelKey the label key, must not be null * @param labelValue the label value, must not be null * @return the label equality condition */ public static LabelCondition labelEqual(String labelKey, String labelValue) { return new LabelEqualsCondition(labelKey, labelValue); } /** * Creates a label condition that checks if a label with the specified key has a value within * the given collection of values. * * @param labelKey the label key, must not be null * @param labelValues the collection of label values, must not be null * @return the label "in" condition */ public static LabelCondition labelIn(String labelKey, Collection labelValues) { return new LabelInCondition(labelKey, labelValues); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/Query.java ================================================ package run.halo.app.extension.index.query; /** * A {@link Query} is used to build queries for searching indexed objects. * *

* Keep this interface is only for backward compatibility. * * @author guqing * @since 2.12.0 * @deprecated Use {@link Condition} instead. */ @Deprecated(since = "2.22.0", forRemoval = true) public interface Query { } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/QueryFactory.java ================================================ package run.halo.app.extension.index.query; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import lombok.experimental.UtilityClass; import org.springframework.util.Assert; /** * Query factory utility class. * * @deprecated Use {@link Queries} instead. */ @Deprecated(since = "2.22.0", forRemoval = true) @UtilityClass public class QueryFactory { public static Query all() { return new EmptyCondition(); } public static Query all(String fieldName) { return new AllCondition(fieldName); } public static Query isNull(String fieldName) { return new IsNullCondition(fieldName); } public static Query isNotNull(String fieldName) { return new IsNotNullCondition(fieldName); } public static Query notEqual(String fieldName, String attributeValue) { return Queries.notEqual(fieldName, attributeValue); } public static Query equal(String fieldName, String attributeValue) { return Queries.equal(fieldName, attributeValue); } public static Query lessThan(String fieldName, String attributeValue) { return Queries.lessThan(fieldName, attributeValue); } public static Query lessThanOrEqual(String fieldName, String attributeValue) { return Queries.lessThan(fieldName, attributeValue, true); } public static Query greaterThan(String fieldName, String attributeValue) { return Queries.greaterThan(fieldName, attributeValue); } public static Query greaterThanOrEqual(String fieldName, String attributeValue) { return Queries.greaterThan(fieldName, attributeValue, true); } public static Query in(String fieldName, String... attributeValues) { return Queries.in(fieldName, Set.of(attributeValues)); } public static Query in(String fieldName, Collection values) { var convertedValues = values.stream() .map(v -> (Object) v) .collect(Collectors.toSet()); return Queries.in(fieldName, convertedValues); } public static Query and(Collection queries) { Assert.notEmpty(queries, "Queries must not be empty"); if (queries.size() == 1) { return queries.iterator().next(); } return queries.stream() .peek(query -> { if (!(query instanceof Condition)) { throw new IllegalArgumentException( "Only Condition instances are supported in AND operations"); } }) .map(query -> (Condition) query) .reduce(Condition::and) .orElseThrow(() -> new IllegalArgumentException("No Condition found in queries")); } public static And and(Query left, Query right) { Assert.isInstanceOf(Condition.class, left, "Only Condition instances are supported in AND operations"); Assert.isInstanceOf(Condition.class, right, "Only Condition instances are supported in AND operations"); return new And((Condition) left, (Condition) right); } public static Query and(Query left, Query right, Query... additionalQueries) { var queries = new ArrayList(2 + additionalQueries.length); queries.add(left); queries.add(right); Collections.addAll(queries, additionalQueries); return and(queries); } public static Query and(Query left, Query right, Collection additionalQueries) { var queries = new ArrayList(2 + additionalQueries.size()); queries.add(left); queries.add(right); queries.addAll(additionalQueries); return and(queries); } private static Query or(Collection queries) { Assert.notEmpty(queries, "Queries must not be empty"); if (queries.size() == 1) { return queries.iterator().next(); } return queries.stream() .peek(query -> { if (!(query instanceof Condition)) { throw new IllegalArgumentException( "Only Condition instances are supported in OR operations"); } }) .map(query -> (Condition) query) .reduce(Condition::or) .orElseThrow(() -> new IllegalArgumentException("No Condition found in queries")); } public static Query or(Query left, Query right) { return or(List.of(left, right)); } public static Query or(Query query1, Query query2, Query... additionalQueries) { var queries = new ArrayList(2 + additionalQueries.length); queries.add(query1); queries.add(query2); Collections.addAll(queries, additionalQueries); return or(queries); } public static Query or(Query query1, Query query2, Collection additionalQueries) { var queries = new ArrayList(2 + additionalQueries.size()); queries.add(query1); queries.add(query2); queries.addAll(additionalQueries); return or(queries); } public static Query not(Query query) { Assert.isInstanceOf(Condition.class, query, "Only Condition instances are supported in NOT operations"); return ((Condition) query).not(); } public static Query betweenLowerExclusive(String fieldName, String lowerValue, String upperValue) { return Queries.between(fieldName, lowerValue, false, upperValue, true); } public static Query betweenUpperExclusive(String fieldName, String lowerValue, String upperValue) { return Queries.between(fieldName, lowerValue, true, upperValue, false); } public static Query betweenExclusive(String fieldName, String lowerValue, String upperValue) { return Queries.between(fieldName, lowerValue, false, upperValue, false); } public static Query between(String fieldName, String lowerValue, String upperValue) { return Queries.between(fieldName, lowerValue, true, upperValue, true); } public static Query startsWith(String fieldName, String value) { return Queries.startsWith(fieldName, value); } public static Query endsWith(String fieldName, String value) { return Queries.endsWith(fieldName, value); } public static Query contains(String fieldName, String value) { return Queries.contains(fieldName, value); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/StringContainsCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record StringContainsCondition(String indexName, String keyword) implements IndexCondition { @Override public Condition not() { return new StringNotContainsCondition(indexName, keyword); } @NotNull @Override public String toString() { return indexName + " CONTAINS " + keyword; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/StringEndsWithCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record StringEndsWithCondition(String indexName, String suffix) implements IndexCondition { @Override public Condition not() { return new StringNotEndsWithCondition(indexName, suffix); } @NotNull @Override public String toString() { return indexName + " ENDS WITH " + suffix; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/StringNotContainsCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record StringNotContainsCondition(String indexName, String keyword) implements IndexCondition { @Override public Condition not() { return new StringContainsCondition(indexName, keyword); } @NotNull @Override public String toString() { return indexName + " NOT CONTAINS " + keyword; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/StringNotEndsWithCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record StringNotEndsWithCondition(String indexName, String suffix) implements IndexCondition { @Override public Condition not() { return new StringEndsWithCondition(indexName, suffix); } @NotNull @Override public String toString() { return indexName + " NOT ENDS WITH " + suffix; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/StringNotStartsWithCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record StringNotStartsWithCondition(String indexName, String prefix) implements IndexCondition { @Override public Condition not() { return new StringStartsWithCondition(indexName, prefix); } @NotNull @Override public String toString() { return indexName + " NOT STARTS WITH " + prefix; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/index/query/StringStartsWithCondition.java ================================================ package run.halo.app.extension.index.query; import org.jetbrains.annotations.NotNull; record StringStartsWithCondition(String indexName, String prefix) implements IndexCondition { @Override public Condition not() { return new StringNotStartsWithCondition(indexName, prefix); } @NotNull @Override public String toString() { return indexName + " STARTS WITH " + prefix; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/router/IListRequest.java ================================================ package run.halo.app.extension.router; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import java.util.Collections; import java.util.List; import org.springdoc.core.fn.builders.operation.Builder; import org.springframework.boot.convert.ApplicationConversionService; import org.springframework.core.convert.ConversionService; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; import run.halo.app.extension.PageRequestImpl; public interface IListRequest { @Schema(description = "The page number. Zero indicates no page.") Integer getPage(); @Schema(description = "Size of one page. Zero indicates no limit.") Integer getSize(); @Schema(description = "Label selector for filtering.") List getLabelSelector(); @Schema(description = "Field selector for filtering.") List getFieldSelector(); class QueryListRequest implements IListRequest { protected final MultiValueMap queryParams; private final ConversionService conversionService = ApplicationConversionService.getSharedInstance(); public QueryListRequest(MultiValueMap queryParams) { this.queryParams = queryParams; } @Override public Integer getPage() { var page = queryParams.getFirst("page"); if (StringUtils.hasText(page)) { return conversionService.convert(page, Integer.class); } return 0; } @Override public Integer getSize() { var size = queryParams.getFirst("size"); if (StringUtils.hasText(size)) { return conversionService.convert(size, Integer.class); } return PageRequestImpl.MAX_SIZE; } @Override public List getLabelSelector() { return queryParams.getOrDefault("labelSelector", Collections.emptyList()); } @Override public List getFieldSelector() { return queryParams.getOrDefault("fieldSelector", Collections.emptyList()); } } static void buildParameters(Builder builder) { builder.parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("page") .implementation(Integer.class) .required(false) .description("Page number. Default is 0.")) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("size") .implementation(Integer.class) .required(false) .description("Size number. Default is 0.")) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("labelSelector") .required(false) .description("Label selector. e.g.: hidden!=true") .implementationArray(String.class)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("fieldSelector") .required(false) .description("Field selector. e.g.: metadata.name==halo") .implementationArray(String.class) ); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/router/QueryParamBuildUtil.java ================================================ package run.halo.app.extension.router; import static org.springdoc.core.fn.builders.arrayschema.Builder.arraySchemaBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; @Slf4j @UtilityClass public class QueryParamBuildUtil { public static org.springdoc.core.fn.builders.parameter.Builder sortParameter() { return parameterBuilder() .in(ParameterIn.QUERY) .name("sort") .required(false) .description(""" Sorting criteria in the format: property,(asc|desc). \ Default sort order is ascending. Multiple sort criteria are supported.\ """) .array(arraySchemaBuilder().schema(schemaBuilder().type("string"))); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/router/SortableRequest.java ================================================ package run.halo.app.extension.router; import static run.halo.app.extension.Comparators.compareCreationTimestamp; import static run.halo.app.extension.Comparators.compareName; import static run.halo.app.extension.Comparators.nullsComparator; import static run.halo.app.extension.ExtensionUtil.defaultSort; import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToListOptions; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import java.util.Comparator; import java.util.function.Function; import java.util.stream.Stream; import org.springdoc.core.fn.builders.operation.Builder; import org.springframework.beans.BeanWrapper; import org.springframework.beans.BeanWrapperImpl; import org.springframework.data.domain.Sort; import org.springframework.web.server.ServerWebExchange; import run.halo.app.core.extension.endpoint.SortResolver; import run.halo.app.extension.Extension; import run.halo.app.extension.ListOptions; import run.halo.app.extension.PageRequest; import run.halo.app.extension.PageRequestImpl; public class SortableRequest extends IListRequest.QueryListRequest { protected final ServerWebExchange exchange; public SortableRequest(ServerWebExchange exchange) { super(exchange.getRequest().getQueryParams()); this.exchange = exchange; } @ArraySchema(uniqueItems = true, arraySchema = @Schema(name = "sort", description = "Sort property and direction of the list result. Support sorting based " + "on attribute name path."), schema = @Schema(description = "like field,asc or field,desc", implementation = String.class, example = "metadata.creationTimestamp,desc")) public Sort getSort() { return SortResolver.defaultInstance.resolve(exchange) .and(defaultSort()); } /** * Build {@link ListOptions} from query params. * * @return a list options. */ public ListOptions toListOptions() { return labelAndFieldSelectorToListOptions(getLabelSelector(), getFieldSelector()); } public PageRequest toPageRequest() { return PageRequestImpl.of(getPage(), getSize(), getSort()); } /** * Build comparator from sort. * * @param Extension type * @return comparator */ public Comparator toComparator() { var sort = getSort(); var fallbackComparator = Stream.>of( compareCreationTimestamp(false), compareName(true) ); var comparatorStream = sort.stream().map(order -> { var property = order.getProperty(); var direction = order.getDirection(); Function function = extension -> { BeanWrapper beanWrapper = new BeanWrapperImpl(extension); return beanWrapper.getPropertyValue(property); }; var comparator = Comparator.comparing(function, nullsComparator(direction.isAscending())); if (direction.isDescending()) { comparator = comparator.reversed(); } return comparator; }); return Stream.concat(comparatorStream, fallbackComparator) .reduce(Comparator::thenComparing) .orElse(null); } public static void buildParameters(Builder builder) { IListRequest.buildParameters(builder); builder.parameter(QueryParamBuildUtil.sortParameter()); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/router/selector/FieldSelector.java ================================================ package run.halo.app.extension.router.selector; import java.util.Objects; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import run.halo.app.extension.index.query.Condition; import run.halo.app.extension.index.query.Queries; import run.halo.app.extension.index.query.Query; public record FieldSelector(@Nullable Query query) { public FieldSelector(Query query) { this.query = Objects.requireNonNullElseGet(query, Queries::empty); } public static FieldSelector of(Query query) { return new FieldSelector(query); } public static FieldSelector all() { return new FieldSelector(Queries.empty()); } public FieldSelector andQuery(Query other) { Assert.isInstanceOf(Condition.class, other, "Only Condition query is supported"); Assert.isInstanceOf(Condition.class, query, "Only Condition query is supported"); return of(((Condition) query).and((Condition) other)); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/router/selector/FieldSelectorConverter.java ================================================ package run.halo.app.extension.router.selector; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import java.util.Set; import org.springframework.core.convert.converter.Converter; import org.springframework.lang.NonNull; import org.springframework.util.CollectionUtils; import run.halo.app.extension.index.query.Condition; import run.halo.app.extension.index.query.Queries; public class FieldSelectorConverter implements Converter { @NonNull @Override public Condition convert(@NonNull SelectorCriteria criteria) { var key = criteria.key(); // compatible with old field selector if ("name".equals(key)) { key = "metadata.name"; } switch (criteria.operator()) { case Equals -> { return Queries.equal(key, getSingleValue(criteria)); } case NotEquals -> { return Queries.notEqual(key, getSingleValue(criteria)); } // compatible with old field selector case IN -> { Set valueArr = defaultIfNull(criteria.values(), Set.of()); return Queries.in(key, valueArr); } default -> throw new IllegalArgumentException( "Unsupported operator: " + criteria.operator()); } } String getSingleValue(SelectorCriteria criteria) { if (CollectionUtils.isEmpty(criteria.values())) { throw new IllegalArgumentException("No value present for label key: " + criteria.key()); } return criteria.values().iterator().next(); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/router/selector/LabelSelector.java ================================================ package run.halo.app.extension.router.selector; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import lombok.Data; import lombok.experimental.Accessors; import org.springframework.util.CollectionUtils; import run.halo.app.extension.index.query.Condition; import run.halo.app.extension.index.query.LabelCondition; import run.halo.app.extension.index.query.Queries; @Data @Accessors(chain = true) public class LabelSelector { private List conditions; @Override public String toString() { if (CollectionUtils.isEmpty(conditions)) { return Condition.empty().toString(); } return conditions.stream() .map(c -> (Condition) c) .reduce(Condition::and) .orElseGet(Condition::empty) .toString(); } /** * Returns a new label selector that is the result of ANDing the current selector with the * given selector. * * @param other the selector to AND with * @return a new label selector */ public LabelSelector and(LabelSelector other) { var labelSelector = new LabelSelector(); var newConditions = new ArrayList(); newConditions.addAll(this.conditions); newConditions.addAll(other.conditions); labelSelector.setConditions(newConditions); return labelSelector; } public static LabelSelectorBuilder builder() { return new LabelSelectorBuilder<>(); } public static class LabelSelectorBuilder> { private final List conditions = new ArrayList<>(); public LabelSelectorBuilder() { } /** * Create a new label selector builder with the given matchers. */ public LabelSelectorBuilder(List conditions) { if (conditions != null) { this.conditions.addAll(conditions); } } @SuppressWarnings("unchecked") private T self() { return (T) this; } public T eq(String key, String value) { conditions.add(Queries.labelEqual(key, value)); return self(); } public T notEq(String key, String value) { conditions.add(Queries.labelEqual(key, value).not()); return self(); } public T in(String key, String... values) { conditions.add(Queries.labelIn(key, Arrays.asList(values))); return self(); } public T notIn(String key, String... values) { conditions.add(Queries.labelIn(key, Arrays.asList(values)).not()); return self(); } public T exists(String key) { conditions.add(Queries.labelExists(key)); return self(); } public T notExists(String key) { conditions.add(Queries.labelExists(key).not()); return self(); } /** * Build the label selector. */ public LabelSelector build() { var labelSelector = new LabelSelector(); labelSelector.setConditions(conditions); return labelSelector; } } } ================================================ FILE: api/src/main/java/run/halo/app/extension/router/selector/LabelSelectorConverter.java ================================================ package run.halo.app.extension.router.selector; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import java.util.Set; import org.springframework.core.convert.converter.Converter; import org.springframework.lang.NonNull; import org.springframework.util.CollectionUtils; import run.halo.app.extension.index.query.LabelCondition; import run.halo.app.extension.index.query.Queries; public class LabelSelectorConverter implements Converter { @NonNull @Override public LabelCondition convert(@NonNull SelectorCriteria criteria) { switch (criteria.operator()) { case Equals -> { return Queries.labelEqual(criteria.key(), getSingleValue(criteria)); } case NotEquals -> { return Queries.labelEqual(criteria.key(), getSingleValue(criteria)).not(); } case NotExist -> { return Queries.labelExists(criteria.key()).not(); } case Exist -> { return Queries.labelExists(criteria.key()); } case IN -> { return Queries.labelIn(criteria.key(), defaultIfNull(criteria.values(), Set.of())); } default -> throw new IllegalArgumentException("Unsupported operator: " + criteria.operator()); } } String getSingleValue(SelectorCriteria criteria) { if (CollectionUtils.isEmpty(criteria.values())) { throw new IllegalArgumentException("No value present for label key: " + criteria.key()); } return criteria.values().iterator().next(); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/router/selector/Operator.java ================================================ package run.halo.app.extension.router.selector; import java.util.Set; import org.springframework.core.convert.converter.Converter; import org.springframework.lang.Nullable; public enum Operator implements Converter { Equals("=", 3) { @Override @Nullable public SelectorCriteria convert(@Nullable String selector) { if (preFlightCheck(selector, 3)) { var i = selector.indexOf(getOperator()); if (i > 0 && (i + getOperator().length()) <= selector.length() - 1) { String key = selector.substring(0, i); String value = selector.substring(i + getOperator().length()); return new SelectorCriteria(key, this, Set.of(value)); } } return null; } }, IN("=(", 2) { @Override public SelectorCriteria convert(String selector) { if (preFlightCheck(selector, 5)) { var idx = selector.indexOf(getOperator()); if (idx > 0 && (idx + getOperator().length()) < selector.length() - 2 && selector.charAt(selector.length() - 1) == ')') { var key = selector.substring(0, idx); var valuesString = selector.substring(idx + getOperator().length(), selector.length() - 1); String[] values = valuesString.split(","); return new SelectorCriteria(key, this, Set.of(values)); } } return null; } }, NotEquals("!=", 1) { @Override @Nullable public SelectorCriteria convert(@Nullable String selector) { if (preFlightCheck(selector, 4)) { var i = selector.indexOf(getOperator()); if (i > 0 && (i + getOperator().length()) < selector.length()) { String key = selector.substring(0, i); String value = selector.substring(i + getOperator().length()); return new SelectorCriteria(key, this, Set.of(value)); } } return null; } }, NotExist("!", 0) { @Override @Nullable public SelectorCriteria convert(@Nullable String selector) { if (preFlightCheck(selector, 2)) { if (selector.startsWith(getOperator())) { return new SelectorCriteria(selector.substring(1), this, Set.of()); } } return null; } }, Exist("", Integer.MAX_VALUE) { @Override public SelectorCriteria convert(String selector) { if (preFlightCheck(selector, 1)) { // TODO validate the source with regex in the future return new SelectorCriteria(selector, this, Set.of()); } return null; } }; private final String operator; /** * Parse order. */ private final int order; Operator(String operator, int order) { this.operator = operator; this.order = order; } public String getOperator() { return operator; } public int getOrder() { return order; } protected boolean preFlightCheck(String selector, int minLength) { return selector != null && selector.length() >= minLength; } } ================================================ FILE: api/src/main/java/run/halo/app/extension/router/selector/SelectorConverter.java ================================================ package run.halo.app.extension.router.selector; import java.util.Arrays; import java.util.Comparator; import java.util.Objects; import lombok.extern.slf4j.Slf4j; import org.springframework.core.convert.converter.Converter; import org.springframework.lang.Nullable; @Slf4j public class SelectorConverter implements Converter { @Override @Nullable public SelectorCriteria convert(@Nullable String selector) { return Arrays.stream(Operator.values()) .sorted(Comparator.comparing(Operator::getOrder)) .map(operator -> { log.debug("Resolving selector: {} with operator: {}", selector, operator); return operator.convert(selector); }) .filter(Objects::nonNull) .findFirst() .orElse(null); } } ================================================ FILE: api/src/main/java/run/halo/app/extension/router/selector/SelectorCriteria.java ================================================ package run.halo.app.extension.router.selector; import java.util.Set; public record SelectorCriteria(String key, Operator operator, Set values) { } ================================================ FILE: api/src/main/java/run/halo/app/extension/router/selector/SelectorUtil.java ================================================ package run.halo.app.extension.router.selector; import java.util.List; import java.util.Objects; import java.util.Optional; import run.halo.app.extension.ListOptions; import run.halo.app.extension.index.query.Condition; public final class SelectorUtil { private SelectorUtil() { } /** * Convert label and field selector expressions to {@link ListOptions}. * * @param labelSelectorTerms label selector expressions * @param fieldSelectorTerms field selector expressions * @return list options(never null) */ public static ListOptions labelAndFieldSelectorToListOptions( List labelSelectorTerms, List fieldSelectorTerms) { var selectorConverter = new SelectorConverter(); var labelConverter = new LabelSelectorConverter(); var labelConditions = Optional.ofNullable(labelSelectorTerms) .map(selectors -> selectors.stream() .map(selectorConverter::convert) .filter(Objects::nonNull) .map(labelConverter::convert) .toList()) .orElse(List.of()); var fieldConverter = new FieldSelectorConverter(); var fieldCondition = Optional.ofNullable(fieldSelectorTerms) .map(selectors -> selectors.stream() .map(selectorConverter::convert) .filter(Objects::nonNull) .map(fieldConverter::convert) .reduce(Condition::and) .orElse(null) ) .orElse(null); var listOptions = new ListOptions(); listOptions.setLabelSelector(new LabelSelector().setConditions(labelConditions)); if (fieldCondition != null) { listOptions.setFieldSelector(FieldSelector.of(fieldCondition)); } else { listOptions.setFieldSelector(FieldSelector.all()); } return listOptions; } } ================================================ FILE: api/src/main/java/run/halo/app/infra/AnonymousUserConst.java ================================================ package run.halo.app.infra; public interface AnonymousUserConst { String PRINCIPAL = "anonymousUser"; String Role = "anonymous"; static boolean isAnonymousUser(String principal) { return PRINCIPAL.equals(principal); } } ================================================ FILE: api/src/main/java/run/halo/app/infra/BackupRootGetter.java ================================================ package run.halo.app.infra; import java.nio.file.Path; import java.util.function.Supplier; /** * Utility of getting backup root path. * * @author johnniang * @since 2.9.0 */ public interface BackupRootGetter extends Supplier { } ================================================ FILE: api/src/main/java/run/halo/app/infra/Condition.java ================================================ package run.halo.app.infra; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; /** * EqualsAndHashCode 排除了lastTransitionTime否则失败时,lastTransitionTime 会被更新 * 导致 equals 为 false,一直被加入队列. * * @author guqing * @see * pod-conditions * @since 2.0.0 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode(exclude = "lastTransitionTime") public class Condition { /** * type of condition in CamelCase or in foo.example.com/CamelCase. * example: Ready, Initialized. * maxLength: 316. */ @Schema(requiredMode = REQUIRED, maxLength = 316, pattern = "^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(" + "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$") private String type; /** * Status is the status of the condition. Can be True, False, Unknown. */ @Schema(requiredMode = REQUIRED) private ConditionStatus status; /** * Last time the condition transitioned from one status to another. */ @Schema(requiredMode = REQUIRED) private Instant lastTransitionTime; /** * Human-readable message indicating details about last transition. * This may be an empty string. */ @Schema(maxLength = 32768) @Builder.Default private String message = ""; /** * Unique, one-word, CamelCase reason for the condition's last transition. */ @Schema(maxLength = 1024, pattern = "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$") @Builder.Default private String reason = ""; } ================================================ FILE: api/src/main/java/run/halo/app/infra/ConditionList.java ================================================ package run.halo.app.infra; import java.util.AbstractCollection; import java.util.Deque; import java.util.Iterator; import java.util.LinkedList; import java.util.Objects; import java.util.function.Consumer; import org.springframework.lang.NonNull; /** *

This {@link ConditionList} to stores multiple {@link Condition}.

*

The element added after is always the first, the first to be removed is always the first to * be added.

*

The queue head is the one whose element index is 0

* Note that: this class is not thread-safe. * * @author guqing * @since 2.0.0 */ public class ConditionList extends AbstractCollection { private static final int EVICT_THRESHOLD = 20; private final Deque conditions = new LinkedList<>(); @Override public boolean add(@NonNull Condition condition) { if (isSame(conditions.peekFirst(), condition)) { return false; } return conditions.add(condition); } public boolean addFirst(@NonNull Condition condition) { if (isSame(conditions.peekFirst(), condition)) { return false; } conditions.addFirst(condition); return true; } /** * Add {@param #condition} and evict the first item if the size of conditions is greater than * {@link #EVICT_THRESHOLD}. * * @param condition item to add */ public boolean addAndEvictFIFO(@NonNull Condition condition) { return addAndEvictFIFO(condition, EVICT_THRESHOLD); } /** * Add {@param #condition} and evict the first item if the size of conditions is greater than * {@param evictThreshold}. * * @param condition item to add */ public boolean addAndEvictFIFO(@NonNull Condition condition, int evictThreshold) { var current = getCondition(condition.getType()); if (current != null) { // do not update last transition time if status is not changed if (Objects.equals(condition.getStatus(), current.getStatus())) { condition.setLastTransitionTime(current.getLastTransitionTime()); } } conditions.remove(current); conditions.addFirst(condition); while (conditions.size() > evictThreshold) { removeLast(); } return true; } private Condition getCondition(String type) { for (Condition condition : conditions) { if (condition.getType().equals(type)) { return condition; } } return null; } public void remove(Condition condition) { conditions.remove(condition); } /** * Retrieves, but does not remove, the head of the queue represented by * this deque (in other words, the first element of this deque), or * returns {@code null} if this deque is empty. * *

This method is equivalent to {@link #peekFirst()}. * * @return the head of the queue represented by this deque, or * {@code null} if this deque is empty */ public Condition peek() { return peekFirst(); } public Condition peekFirst() { return conditions.peekFirst(); } public Condition removeLast() { return conditions.removeLast(); } @Override public void clear() { conditions.clear(); } public int size() { return conditions.size(); } private boolean isSame(Condition a, Condition b) { if (a == null || b == null) { return false; } return Objects.equals(a.getType(), b.getType()) && Objects.equals(a.getStatus(), b.getStatus()) && Objects.equals(a.getReason(), b.getReason()) && Objects.equals(a.getMessage(), b.getMessage()); } @Override public Iterator iterator() { return conditions.iterator(); } @Override public void forEach(Consumer action) { conditions.forEach(action); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } ConditionList that = (ConditionList) o; return Objects.equals(conditions, that.conditions); } @Override public int hashCode() { return Objects.hash(conditions); } } ================================================ FILE: api/src/main/java/run/halo/app/infra/ConditionStatus.java ================================================ package run.halo.app.infra; /** * @author guqing * @since 2.0.0 */ public enum ConditionStatus { TRUE, FALSE, UNKNOWN } ================================================ FILE: api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java ================================================ package run.halo.app.infra; import java.net.URI; import org.springframework.http.HttpRequest; import reactor.core.publisher.Mono; /** * {@link ExternalLinkProcessor} to process an in-site link to an external link. * * @author guqing * @see ExternalUrlSupplier * @since 2.9.0 */ public interface ExternalLinkProcessor { /** * If the link is in-site link, then process it to an external link with * {@link ExternalUrlSupplier#getRaw()}, otherwise return the original link. * * @param link link to process * @return processed link or original link */ String processLink(String link); /** * Process the URI to an external URL. *

* If the URI is an in-site link, then process it to an external link with * {@link ExternalUrlSupplier#getRaw()} or {@link ExternalUrlSupplier#getURL(HttpRequest)}, * otherwise return the original URI. *

* * @param uri uri to process * @return processed URI or original URI */ Mono processLink(URI uri); } ================================================ FILE: api/src/main/java/run/halo/app/infra/ExternalUrlSupplier.java ================================================ package run.halo.app.infra; import java.net.URI; import java.net.URL; import java.util.function.Supplier; import javax.annotation.Nullable; import org.springframework.http.HttpRequest; /** * Represents a supplier of external url configuration. * * @author johnniang */ public interface ExternalUrlSupplier extends Supplier { /** * Gets URI according to external URL and use-absolute-permalink properties. * * @return URI "/" returned if use-absolute-permalink is false. Or external URL will be * returned.(never null) */ @Override URI get(); /** * Gets URL according to external URL and server request URL. * * @param request represents an HTTP request message, consisting of a method and a URI. * @return External URL will be return if it is provided, or request URI will be returned. * (never null) */ URL getURL(HttpRequest request); /** * Gets user-configured external URL from HaloProperties#getExternalUrl(). * * @return user-configured external URL or null if it is not provided. */ @Nullable URL getRaw(); } ================================================ FILE: api/src/main/java/run/halo/app/infra/FileCategoryMatcher.java ================================================ package run.halo.app.infra; import java.util.Set; /** *

Classifies files based on their MIME types.

*

It provides different categories such as IMAGE, SVG, AUDIO, VIDEO, ARCHIVE, and DOCUMENT. * Each category has a match method that checks if a given MIME type belongs to that * category.

*

The categories are defined as follows:

*
 * - IMAGE: Matches all image MIME types except for SVG.
 * - SVG: Specifically matches the SVG image MIME type.
 * - AUDIO: Matches all audio MIME types.
 * - VIDEO: Matches all video MIME types.
 * - ARCHIVE: Matches common archive MIME types like zip, rar, tar, etc.
 * - DOCUMENT: Matches common document MIME types like plain text, PDF, Word, Excel, etc.
 * 
* * @author guqing * @since 2.18.0 */ public enum FileCategoryMatcher { ALL { @Override public boolean match(String mimeType) { return true; } }, IMAGE { @Override public boolean match(String mimeType) { return mimeType.startsWith("image/") && !mimeType.equals("image/svg+xml"); } }, SVG { @Override public boolean match(String mimeType) { return mimeType.equals("image/svg+xml"); } }, AUDIO { @Override public boolean match(String mimeType) { return mimeType.startsWith("audio/"); } }, VIDEO { @Override public boolean match(String mimeType) { return mimeType.startsWith("video/"); } }, ARCHIVE { static final Set ARCHIVE_MIME_TYPES = Set.of( "application/zip", "application/x-rar-compressed", "application/x-tar", "application/gzip", "application/x-bzip2", "application/x-xz", "application/x-7z-compressed" ); @Override public boolean match(String mimeType) { return ARCHIVE_MIME_TYPES.contains(mimeType); } }, DOCUMENT { static final Set DOCUMENT_MIME_TYPES = Set.of( "text/plain", "application/rtf", "text/csv", "text/xml", "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.text", "application/vnd.oasis.opendocument.spreadsheet", "application/vnd.oasis.opendocument.presentation" ); @Override public boolean match(String mimeType) { return DOCUMENT_MIME_TYPES.contains(mimeType); } }; public abstract boolean match(String mimeType); /** * Get the file category matcher by name. */ public static FileCategoryMatcher of(String name) { for (var matcher : values()) { if (matcher.name().equalsIgnoreCase(name)) { return matcher; } } throw new IllegalArgumentException("Unsupported file category matcher for name: " + name); } } ================================================ FILE: api/src/main/java/run/halo/app/infra/SystemInfo.java ================================================ package run.halo.app.infra; import com.github.zafarkhaja.semver.Version; import java.net.URL; import java.util.Locale; import java.util.TimeZone; import lombok.Data; import lombok.experimental.Accessors; @Data @Accessors(chain = true) public class SystemInfo { private String title; private String subtitle; private String logo; private String favicon; private URL url; private Version version; private SeoProp seo; private Locale locale; private TimeZone timeZone; private String activatedThemeName; @Data @Accessors(chain = true) public static class SeoProp { private boolean blockSpiders; private String keywords; private String description; } } ================================================ FILE: api/src/main/java/run/halo/app/infra/SystemInfoGetter.java ================================================ package run.halo.app.infra; import java.util.function.Supplier; import reactor.core.publisher.Mono; public interface SystemInfoGetter extends Supplier> { } ================================================ FILE: api/src/main/java/run/halo/app/infra/SystemSetting.java ================================================ package run.halo.app.infra; import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.validation.constraints.NotBlank; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; import lombok.Builder; import lombok.Data; import lombok.experimental.Accessors; import org.apache.commons.lang3.StringUtils; import org.jspecify.annotations.Nullable; import org.springframework.boot.convert.ApplicationConversionService; import run.halo.app.infra.utils.JsonUtils; /** * TODO Optimization value acquisition. * * @author guqing * @since 2.0.0 */ public class SystemSetting { public static final String SYSTEM_CONFIG_DEFAULT = "system-default"; public static final String SYSTEM_CONFIG = "system"; @Builder public record Attachment( @Nullable UploadOptions console, @Nullable UploadOptions uc, @Nullable UploadOptions comment, @Nullable UploadOptions avatar ) { public static final String GROUP = "attachment"; @Builder public record UploadOptions( @Nullable String groupName, @NotBlank String policyName ) { } } @Data public static class Theme { public static final String GROUP = "theme"; private String active; } @Data public static class ThemeRouteRules { public static final String GROUP = "routeRules"; private boolean disableThemePreview; private String categories; private String archives; private String post; private String tags; public static ThemeRouteRules empty() { ThemeRouteRules rules = new ThemeRouteRules(); rules.setPost("/archives/{slug}"); rules.setArchives("/archives"); rules.setTags("/tags"); rules.setCategories("/categories"); return rules; } } @Data public static class CodeInjection { public static final String GROUP = "codeInjection"; private String globalHead; private String contentHead; private String footer; } @Data public static class Basic { public static final String GROUP = "basic"; String title; String subtitle; String logo; String favicon; String language; String externalUrl; @JsonIgnore public Optional useSystemLocale() { return Optional.ofNullable(language) .filter(StringUtils::isNotBlank) .map(Locale::forLanguageTag); } } @Data public static class User { public static final String GROUP = "user"; boolean allowRegistration; boolean mustVerifyEmailOnRegistration; String defaultRole; /** * @deprecated since 2.22.0, use {@link Attachment} instead. */ @Deprecated(since = "2.22.0") String avatarPolicy; /** * @deprecated since 2.22.0, use {@link Attachment} instead. */ @Deprecated(since = "2.22.0") String ucAttachmentPolicy; String protectedUsernames; } @Data public static class Post { public static final String GROUP = "post"; Integer postPageSize; Integer archivePageSize; Integer categoryPageSize; Integer tagPageSize; Integer authorPageSize; Boolean review; String slugGenerationStrategy; String attachmentPolicyName; String attachmentGroupName; } @Data public static class Seo { public static final String GROUP = "seo"; Boolean blockSpiders; String keywords; String description; } @Data public static class Comment { public static final String GROUP = "comment"; Boolean enable; Boolean requireReviewForNew; Boolean systemUserOnly; } @Data public static class Menu { public static final String GROUP = "menu"; public String primary; } @Data public static class AuthProvider { public static final String GROUP = "authProvider"; private List states; } @Data @Accessors(chain = true) public static class AuthProviderState { private String name; private boolean enabled; private int priority; } /** * ExtensionPointEnabled key is metadata name of extension point and value is a list of * extension definition names. */ public static class ExtensionPointEnabled extends LinkedHashMap> { public static final String GROUP = "extensionPointEnabled"; } @Nullable public static T get(Map data, String key, Class type) { var valueString = data.get(key); if (valueString == null) { return null; } var conversionService = ApplicationConversionService.getSharedInstance(); if (conversionService.canConvert(String.class, type)) { return conversionService.convert(valueString, type); } return JsonUtils.jsonToObject(valueString, type); } } ================================================ FILE: api/src/main/java/run/halo/app/infra/SystemVersionSupplier.java ================================================ package run.halo.app.infra; import com.github.zafarkhaja.semver.Version; import java.util.function.Supplier; /** * The supplier to gets the project version. * If it cannot be obtained, return 0.0.0. * * @author guqing * @see Semantic Versioning 2.0.0 * @since 2.0.0 */ public interface SystemVersionSupplier extends Supplier { } ================================================ FILE: api/src/main/java/run/halo/app/infra/ValidationUtils.java ================================================ package run.halo.app.infra; import java.util.regex.Pattern; import lombok.experimental.UtilityClass; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.BindingResult; import org.springframework.validation.Validator; import org.springframework.web.server.ServerWebExchange; @UtilityClass public class ValidationUtils { public static final String NAME_REGEX = "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$"; public static final Pattern NAME_PATTERN = Pattern.compile(NAME_REGEX); /** * {@code A-Z, a-z, 0-9, !@#$%^&*.?} are allowed. */ public static final String PASSWORD_REGEX = "^[A-Za-z0-9!@#$%^&*.?]+$"; public static final Pattern PASSWORD_PATTERN = Pattern.compile(PASSWORD_REGEX); /** * Validate the target object with given locale context. */ public static BindingResult validate(Object target, String objectName, Validator validator, ServerWebExchange exchange) { BindingResult bindingResult = new BeanPropertyBindingResult(target, objectName); try { LocaleContextHolder.setLocaleContext(exchange.getLocaleContext()); validator.validate(target, bindingResult); return bindingResult; } finally { LocaleContextHolder.resetLocaleContext(); } } public static BindingResult validate(Object target, Validator validator, ServerWebExchange exchange) { return validate(target, "form", validator, exchange); } } ================================================ FILE: api/src/main/java/run/halo/app/infra/model/License.java ================================================ package run.halo.app.infra.model; import lombok.Data; /** * Common data objects for license. */ @Data public class License { private String name; private String url; } ================================================ FILE: api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java ================================================ package run.halo.app.infra.utils; import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; import java.util.List; import lombok.experimental.UtilityClass; import org.apache.tika.detect.DefaultDetector; import org.apache.tika.detect.Detector; import org.apache.tika.metadata.Metadata; import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.mime.MimeTypeException; import org.apache.tika.mime.MimeTypes; import org.springframework.lang.NonNull; import org.springframework.util.Assert; @UtilityClass public class FileTypeDetectUtils { private static final Detector detector = new DefaultDetector(); /** *

Detects the media type of the given document.

*

The type detection is based on the content of the given document stream and the name of * the document.

* * @param inputStream the document stream must not be null * @throws IOException if the stream can not be read */ public static String detectMimeType(InputStream inputStream, String name) throws IOException { Assert.notNull(name, "The name of the document must not be null"); var metadata = new Metadata(); metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, name); return doDetectMimeType(inputStream, metadata); } /** * Detect mime type. * * @param inputStream input stream will be closed after detection, must not be null */ public static String detectMimeType(InputStream inputStream) throws IOException { return doDetectMimeType(inputStream, new Metadata()); } private static String doDetectMimeType(InputStream inputStream, Metadata metadata) throws IOException { Assert.notNull(inputStream, "The inputStream must not be null"); try (var stream = (!inputStream.markSupported() ? new BufferedInputStream(inputStream) : inputStream)) { return detector.detect(stream, metadata).toString(); } } public static String detectFileExtension(String mimeType) throws MimeTypeException { MimeTypes mimeTypes = MimeTypes.getDefaultMimeTypes(); return mimeTypes.forName(mimeType).getExtension(); } public static List detectFileExtensions(String mimeType) throws MimeTypeException { MimeTypes mimeTypes = MimeTypes.getDefaultMimeTypes(); return mimeTypes.forName(mimeType).getExtensions(); } /** *

Get file extension from file name.

*

The obtained file extension is in lowercase and includes the dot, such as ".jpg".

*/ @NonNull public static String getFileExtension(String fileName) { Assert.notNull(fileName, "The fileName must not be null"); int lastDot = fileName.lastIndexOf("."); if (lastDot > 0) { return fileName.substring(lastDot).toLowerCase(); } return ""; } /** *

Recommend to use this method to verify whether the file extension matches the file type * after matching the file type to avoid XSS attacks such as bypassing detection by polyglot * file.

* * @param mimeType file mime type,such as "image/png" * @param fileName file name,such as "test.png" * @see * CVE Stored XSS * @see gh-7149 */ public boolean isValidExtensionForMime(String mimeType, String fileName) { Assert.notNull(mimeType, "The mimeType must not be null"); Assert.notNull(fileName, "The fileName must not be null"); String fileExtension = getFileExtension(fileName); try { List detectedExtByMime = detectFileExtensions(mimeType); return detectedExtByMime.stream().anyMatch(ext -> ext.equalsIgnoreCase(fileExtension)); } catch (MimeTypeException e) { return false; } } } ================================================ FILE: api/src/main/java/run/halo/app/infra/utils/GenericClassUtils.java ================================================ package run.halo.app.infra.utils; import java.util.function.Supplier; import net.bytebuddy.ByteBuddy; import net.bytebuddy.description.type.TypeDescription; public enum GenericClassUtils { ; /** * Generate concrete class of generic class. e.g.: {@code List} * * @param rawClass is generic class, like {@code List.class} * @param parameterType is parameter type of generic class * @param parameter type * @return generated class */ public static Class generateConcreteClass(Class rawClass, Class parameterType) { return generateConcreteClass(rawClass, parameterType, () -> parameterType.getName() + rawClass.getSimpleName()); } /** * Generate concrete class of generic class. e.g.: {@code List} * * @param rawClass is generic class, like {@code List.class} * @param parameterType is parameter type of generic class * @param nameGenerator is generated class name * @param parameter type * @return generated class */ public static Class generateConcreteClass(Class rawClass, Class parameterType, Supplier nameGenerator) { var concreteType = TypeDescription.Generic.Builder.parameterizedType(rawClass, parameterType).build(); try (var unloaded = new ByteBuddy() .subclass(concreteType) .name(nameGenerator.get()) .make()) { return unloaded.load(parameterType.getClassLoader()).getLoaded(); } } } ================================================ FILE: api/src/main/java/run/halo/app/infra/utils/JsonParseException.java ================================================ package run.halo.app.infra.utils; /** * {@link JsonParseException} thrown when source JSON is invalid. * * @author guqing * @since 2.0.0 */ public class JsonParseException extends RuntimeException { public JsonParseException() { super(); } public JsonParseException(String message) { super(message); } public JsonParseException(String message, Throwable cause) { super(message, cause); } public JsonParseException(Throwable cause) { super(cause); } protected JsonParseException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); } } ================================================ FILE: api/src/main/java/run/halo/app/infra/utils/JsonUtils.java ================================================ package run.halo.app.infra.utils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import io.swagger.v3.core.util.Json; import java.util.Map; import org.springframework.lang.NonNull; import org.springframework.util.Assert; /** * Json utilities. * * @author guqing * @see JavaTimeModule * @since 2.0.0 * @deprecated Use {@link tools.jackson.databind.json.JsonMapper} directly instead. */ @Deprecated(forRemoval = true, since = "2.23.0") public class JsonUtils { public static final ObjectMapper DEFAULT_JSON_MAPPER = Json.mapper(); private JsonUtils() { } public static ObjectMapper mapper() { return DEFAULT_JSON_MAPPER; } /** * Converts a map to the object specified type. * * @param sourceMap source map must not be empty * @param type object type must not be null * @param target object type * @return the object specified type */ @NonNull public static T mapToObject(@NonNull Map sourceMap, @NonNull Class type) { return DEFAULT_JSON_MAPPER.convertValue(sourceMap, type); } /** * Converts object to json format. * * @param source source object must not be null * @return json format of the source object */ @NonNull public static String objectToJson(@NonNull Object source) { Assert.notNull(source, "Source object must not be null"); try { return DEFAULT_JSON_MAPPER.writeValueAsString(source); } catch (JsonProcessingException e) { throw new JsonParseException(e); } } /** * Method to deserialize JSON content from given JSON content String. * * @param json json content * @param toValueType object type to convert * @param real type to convert * @return converted object */ public static T jsonToObject(String json, Class toValueType) { try { return DEFAULT_JSON_MAPPER.readValue(json, toValueType); } catch (Exception e) { throw new JsonParseException(e); } } /** * Method to deserialize JSON content from given JSON content String. * * @param json json content * @param typeReference type reference to convert * @param real type to convert * @return converted object */ public static T jsonToObject(String json, TypeReference typeReference) { try { return DEFAULT_JSON_MAPPER.readValue(json, typeReference); } catch (Exception e) { throw new JsonParseException(e); } } /** * Method to deserialize JSON content and serialize back from given Object. * * @param source source object to copy * @param real type to deep copy * @return deep copy of the source object */ @SuppressWarnings("unchecked") public static T deepCopy(T source) { try { return (T) DEFAULT_JSON_MAPPER.readValue(objectToJson(source), source.getClass()); } catch (JsonProcessingException e) { throw new JsonParseException(e); } } } ================================================ FILE: api/src/main/java/run/halo/app/infra/utils/PathUtils.java ================================================ package run.halo.app.infra.utils; import java.net.URI; import java.net.URISyntaxException; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; /** * Http path manipulation tool class. * * @author guqing * @since 2.0.0 */ @Slf4j @UtilityClass public class PathUtils { /** * Every HTTP URL conforms to the syntax of a generic URI. The URI generic syntax consists of * components organized hierarchically in order of decreasing significance from left to * right: *
     * URI = scheme ":" ["//" authority] path ["?" query] ["#" fragment]
     * 
* The authority component consists of subcomponents: *
     * authority = [userinfo "@"] host [":" port]
     * 
* Examples of popular schemes include http, https, ftp, mailto, file, data and irc. URI * schemes should be registered with the * Internet Assigned Numbers Authority (IANA), although * non-registered schemes are used in practice. * * @param uriString url or path * @return true if the linkBase is absolute, otherwise false * @see URL */ public static boolean isAbsoluteUri(final String uriString) { if (StringUtils.isBlank(uriString)) { return false; } try { URI uri = new URI(uriString); return uri.isAbsolute(); } catch (URISyntaxException e) { log.debug("Failed to parse uri: " + uriString, e); // ignore this exception return false; } } /** * Combine paths based on the passed in path segments parameters. *

* This method doesn't work for Windows system currently. * * @param pathSegments Path segments to be combined * @return the combined path */ public static String combinePath(String... pathSegments) { StringBuilder sb = new StringBuilder(); for (String path : pathSegments) { if (path == null) { continue; } String s = path.startsWith("/") ? path : "/" + path; String segment = s.endsWith("/") ? s.substring(0, s.length() - 1) : s; sb.append(segment); } return sb.toString(); } /** *

Append a {@code '/'} if the path does not end with a {@code '/'}.

* Examples are as follows: *
     *     PathUtils.appendPathSeparatorIfMissing("hello") -> hello/
     *     PathUtils.appendPathSeparatorIfMissing("some-path/") -> some-path/
     *     PathUtils.appendPathSeparatorIfMissing(null) -> null
     * 
* * @param path a path * @return A new String if suffix was appended, the same string otherwise. */ public static String appendPathSeparatorIfMissing(String path) { return StringUtils.appendIfMissing(path, "/", "/"); } /** *

Remove the regex in the path pattern placeholder.

*

For example:

*
    *
  • '{@code /{year:\d{4}}/{month:\d{2}}}' → '{@code /{year}/{month}}'
  • *
  • '{@code /archives/{year:\d{4}}/{month:\d{2}}}' → '{@code /archives/{year}/{month} * }'
  • *
  • '{@code /archives/{year:\d{4}}/{slug}}' → '{@code /archives/{year}/{slug}}'
  • *
* * @param pattern path pattern * @return Simplified path pattern */ public static String simplifyPathPattern(String pattern) { if (StringUtils.isBlank(pattern)) { return StringUtils.EMPTY; } String[] parts = StringUtils.split(pattern, '/'); for (int i = 0; i < parts.length; i++) { String part = parts[i]; if (part.startsWith("{") && part.endsWith("}")) { int colonIdx = part.indexOf(':'); if (colonIdx != -1) { parts[i] = part.substring(0, colonIdx) + part.charAt(part.length() - 1); } } } return combinePath(parts); } } ================================================ FILE: api/src/main/java/run/halo/app/migration/Backup.java ================================================ package run.halo.app.migration; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @GVK(group = "migration.halo.run", version = "v1alpha1", kind = "Backup", plural = "backups", singular = "backup") public class Backup extends AbstractExtension { private Spec spec = new Spec(); private Status status = new Status(); @Data @Schema(name = "BackupSpec") public static class Spec { @Schema(description = "Backup file format. Currently, only zip format is supported.") private String format; private Instant expiresAt; } @Data @Schema(name = "BackupStatus") public static class Status { private Phase phase = Phase.PENDING; private Instant startTimestamp; private Instant completionTimestamp; private String failureReason; private String failureMessage; /** * Size of backup file. Data unit: byte */ private Long size; /** * Name of backup file. */ private String filename; } public enum Phase { PENDING, RUNNING, SUCCEEDED, FAILED, } } ================================================ FILE: api/src/main/java/run/halo/app/migration/Constant.java ================================================ package run.halo.app.migration; public enum Constant { ; public static final String GROUP = "migration.halo.run"; public static final String VERSION = "v1alpha1"; public static final String HOUSE_KEEPER_FINALIZER = "housekeeper"; } ================================================ FILE: api/src/main/java/run/halo/app/notification/NotificationCenter.java ================================================ package run.halo.app.notification; import reactor.core.publisher.Mono; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.Subscription; /** * Notification center to notify and manage notifications. * * @author guqing * @since 2.10.0 */ public interface NotificationCenter { /** * Notifies the subscriber with the given reason. * * @param reason reason to notify */ Mono notify(Reason reason); /** * Subscribes to the given subject with the given reason. * * @param subscriber subscriber to subscribe to * @param reason interest reason to subscribe * @return a subscription */ Mono subscribe(Subscription.Subscriber subscriber, Subscription.InterestReason reason); /** * Unsubscribes by the given subject. * * @param subscriber subscriber to unsubscribe */ Mono unsubscribe(Subscription.Subscriber subscriber); /** * Unsubscribes by the given subject and reason. * * @param subscriber subscriber to unsubscribe * @param reason reason to unsubscribe */ Mono unsubscribe(Subscription.Subscriber subscriber, Subscription.InterestReason reason); } ================================================ FILE: api/src/main/java/run/halo/app/notification/NotificationContext.java ================================================ package run.halo.app.notification; import com.fasterxml.jackson.databind.node.ObjectNode; import java.time.Instant; import lombok.Builder; import lombok.Data; @Data public class NotificationContext { private Message message; private ObjectNode receiverConfig; private ObjectNode senderConfig; @Data public static class Message { private MessagePayload payload; private Subject subject; private String recipient; private Instant timestamp; } @Data @Builder public static class Subject { private String apiVersion; private String kind; private String name; private String title; private String url; } @Data public static class MessagePayload { private String title; private String rawBody; private String htmlBody; private ReasonAttributes attributes; } } ================================================ FILE: api/src/main/java/run/halo/app/notification/NotificationReasonEmitter.java ================================================ package run.halo.app.notification; import java.util.function.Consumer; import reactor.core.publisher.Mono; import run.halo.app.core.extension.notification.Reason; /** * {@link NotificationReasonEmitter} to emit notification reason. * * @author guqing * @since 2.10.0 */ public interface NotificationReasonEmitter { /** * Emit a {@link Reason} with {@link ReasonPayload}. * * @param reasonType reason type to emitter must not be blank * @param reasonData reason data must not be null */ Mono emit(String reasonType, Consumer reasonData); } ================================================ FILE: api/src/main/java/run/halo/app/notification/ReactiveNotifier.java ================================================ package run.halo.app.notification; import org.pf4j.ExtensionPoint; import reactor.core.publisher.Mono; /** * Notifier to notify user. * * @author guqing * @since 2.10.0 */ public interface ReactiveNotifier extends ExtensionPoint { /** * Notify user. * * @param context notification context must not be null */ Mono notify(NotificationContext context); } ================================================ FILE: api/src/main/java/run/halo/app/notification/ReasonAttributes.java ================================================ package run.halo.app.notification; import java.util.HashMap; /** *

{@link ReasonAttributes} is a map that stores the attributes of the reason.

* * @author guqing * @since 2.10.0 */ public class ReasonAttributes extends HashMap { } ================================================ FILE: api/src/main/java/run/halo/app/notification/ReasonPayload.java ================================================ package run.halo.app.notification; import java.util.HashMap; import java.util.Map; import lombok.AllArgsConstructor; import lombok.Data; import run.halo.app.core.extension.notification.Reason; /** * A value object to hold reason payload. * * @author guqing * @see Reason * @since 2.10.0 */ @Data @AllArgsConstructor public class ReasonPayload { private Reason.Subject subject; private final UserIdentity author; private Map attributes; public static ReasonPayloadBuilder builder() { return new ReasonPayloadBuilder(); } public static class ReasonPayloadBuilder { private Reason.Subject subject; private UserIdentity author; private final Map attributes; ReasonPayloadBuilder() { this.attributes = new HashMap<>(); } public ReasonPayloadBuilder subject(Reason.Subject subject) { this.subject = subject; return this; } public ReasonPayloadBuilder attribute(String key, Object value) { this.attributes.put(key, value); return this; } public ReasonPayloadBuilder attributes(Map attributes) { this.attributes.putAll(attributes); return this; } public ReasonPayloadBuilder author(UserIdentity author) { this.author = author; return this; } public ReasonPayload build() { return new ReasonPayload(subject, author, attributes); } } } ================================================ FILE: api/src/main/java/run/halo/app/notification/UserIdentity.java ================================================ package run.halo.app.notification; import java.util.Optional; import org.apache.commons.lang3.StringUtils; import org.springframework.util.Assert; import run.halo.app.infra.AnonymousUserConst; /** * Identity for user. * * @author guqing * @since 2.10.0 */ public record UserIdentity(String name) { public static final String SEPARATOR = "#"; /** * Create identity with username to identify a user. * * @param username username * @return identity */ public static UserIdentity of(String username) { return new UserIdentity(username); } /** *

Create identity with email to identify a user, * the name will be {@code anonymousUser#email}.

*

An anonymous user can not be identified by username so we use email to identify it.

* * @param email email * @return identity */ public static UserIdentity anonymousWithEmail(String email) { Assert.notNull(email, "Email must not be null"); String name = AnonymousUserConst.PRINCIPAL + SEPARATOR + email; return of(name); } public boolean isAnonymous() { return name().startsWith(AnonymousUserConst.PRINCIPAL + SEPARATOR); } /** * Gets email if the identity is an anonymous user. * * @return email if the identity is an anonymous user, otherwise empty */ public Optional getEmail() { if (isAnonymous()) { return Optional.of(name().substring(name().indexOf(SEPARATOR) + 1)) .filter(StringUtils::isNotBlank); } return Optional.empty(); } } ================================================ FILE: api/src/main/java/run/halo/app/plugin/ApiVersion.java ================================================ package run.halo.app.plugin; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Api version. * * @author guqing * @since 2.0.0 */ @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ApiVersion { /** * Api version value. * * @return api version string */ String value(); } ================================================ FILE: api/src/main/java/run/halo/app/plugin/BasePlugin.java ================================================ package run.halo.app.plugin; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.pf4j.Plugin; /** * This class will be extended by all plugins and serve as the common class between a plugin and * the application. * * @author guqing * @since 2.0.0 */ @Getter @Slf4j public class BasePlugin extends Plugin { protected PluginContext context; /** * Constructor a plugin with the given plugin context. * * @param pluginContext plugin context must not be null. */ public BasePlugin(PluginContext pluginContext) { this.context = pluginContext; } public BasePlugin() { } } ================================================ FILE: api/src/main/java/run/halo/app/plugin/PluginConfigUpdatedEvent.java ================================================ package run.halo.app.plugin; import com.fasterxml.jackson.databind.JsonNode; import java.util.Map; import lombok.Builder; import lombok.Getter; import org.springframework.context.ApplicationEvent; import run.halo.app.core.extension.Plugin; import run.halo.app.extension.ConfigMap; /** *

Event that is triggered when the {@link ConfigMap } represented by * {@link Plugin.PluginSpec#getConfigMapName()} in the {@link Plugin} is updated.

*

has two properties, oldConfig and newConfig, which represent the {@link ConfigMap#getData()} * property value of the {@link ConfigMap}.

* * @author guqing * @since 2.17.0 */ @Getter public class PluginConfigUpdatedEvent extends ApplicationEvent { /** * Old configuration data. * * @deprecated Use {@link #oldSettingValues} and {@link #newSettingValues} instead. */ @Deprecated(forRemoval = true, since = "2.23.0") private final Map oldConfig; /** * New configuration data. * * @deprecated Use {@link #oldSettingValues} and {@link #newSettingValues} instead. */ @Deprecated(forRemoval = true, since = "2.23.0") private final Map newConfig; /** * Old setting values. */ private final Map oldSettingValues; /** * New setting values. */ private final Map newSettingValues; @Builder public PluginConfigUpdatedEvent( Object source, Map oldConfig, Map newConfig, Map oldSettingValues, Map newSettingValues ) { super(source); this.oldConfig = oldConfig; this.newConfig = newConfig; this.oldSettingValues = oldSettingValues; this.newSettingValues = newSettingValues; } } ================================================ FILE: api/src/main/java/run/halo/app/plugin/PluginContext.java ================================================ package run.halo.app.plugin; import lombok.Builder; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.pf4j.RuntimeMode; /** *

This class will provide a context for the plugin, which will be used to store some * information about the plugin.

*

An instance of this class is provided to plugins in their constructor.

*

It's safe for plugins to keep a reference to the instance for later use.

*

This class facilitates communication with application and plugin manager.

*

Pf4j recommends that you use a custom PluginContext instead of PluginWrapper.

* Use application custom PluginContext instead of PluginWrapper * * @author guqing * @since 2.10.0 */ @Getter @Builder @RequiredArgsConstructor public class PluginContext { private final String name; private final String configMapName; private final String version; private final RuntimeMode runtimeMode; } ================================================ FILE: api/src/main/java/run/halo/app/plugin/PluginsRootGetter.java ================================================ package run.halo.app.plugin; import java.nio.file.Path; import java.util.function.Supplier; /** * An interface to get the root path of plugins. * * @author johnniang * @since 2.18.0 */ public interface PluginsRootGetter extends Supplier { } ================================================ FILE: api/src/main/java/run/halo/app/plugin/ReactiveSettingFetcher.java ================================================ package run.halo.app.plugin; import java.util.Map; import reactor.core.publisher.Mono; import tools.jackson.databind.JsonNode; /** * The {@link ReactiveSettingFetcher} to help plugin fetch own setting configuration. * * @author guqing * @since 2.4.0 */ public interface ReactiveSettingFetcher { Mono fetch(String group, Class clazz); @Deprecated(forRemoval = true, since = "2.23.0") Mono get(String group); /** * Get setting value by group. * * @param group the setting group * @return the setting value or empty if not found */ Mono getSettingValue(String group); @Deprecated(forRemoval = true, since = "2.23.0") Mono> getValues(); /** * Get all setting values. * * @return all setting values, never empty */ Mono> getSettingValues(); } ================================================ FILE: api/src/main/java/run/halo/app/plugin/SettingFetcher.java ================================================ package run.halo.app.plugin; import java.util.Map; import java.util.Optional; import run.halo.app.extension.ConfigMap; import tools.jackson.databind.JsonNode; /** * SettingFetcher must be a class instead of an interface due to backward compatibility. * * @author johnniang */ public interface SettingFetcher { Optional fetch(String group, Class clazz); /** * Get values from {@link ConfigMap} by group. * * @param group the setting group * @return the setting value(non-null) * @deprecated use {@link #getSettingValue(String)} instead */ @Deprecated(forRemoval = true, since = "2.23.0") com.fasterxml.jackson.databind.JsonNode get(String group); /** * Get setting value by group. * * @param group the setting group * @return the setting value(non-null) */ JsonNode getSettingValue(String group); /** * Get values from {@link ConfigMap}. * * @return a unmodifiable map of values(non-null) * @deprecated use {@link #getSettingValues()} instead */ @Deprecated(forRemoval = true, since = "2.23.0") Map getValues(); /** * Get all setting values. * * @return all setting values, never null */ Map getSettingValues(); } ================================================ FILE: api/src/main/java/run/halo/app/plugin/SharedEvent.java ================================================ package run.halo.app.plugin; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** *

It is a symbolic annotation.

*

When the event marked with {@link SharedEvent} annotation is published, it will be * broadcast to the application context of the plugin. * * @author guqing * @since 2.0.0 */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface SharedEvent { } ================================================ FILE: api/src/main/java/run/halo/app/plugin/event/PluginStartedEvent.java ================================================ package run.halo.app.plugin.event; import org.springframework.context.ApplicationEvent; /** * The event that is published when a plugin is really started, and is only for plugin internal use. * * @author johnniang * @since 2.17.0 */ public class PluginStartedEvent extends ApplicationEvent { public PluginStartedEvent(Object source) { super(source); } } ================================================ FILE: api/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionGetter.java ================================================ package run.halo.app.plugin.extensionpoint; import java.util.List; import org.pf4j.ExtensionPoint; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public interface ExtensionGetter { /** * Get only one enabled extension from system configuration. * * @param extensionPoint is extension point class. * @return implementation of the corresponding extension point. If no configuration is found, * we will use the default implementation from application context instead. */ Mono getEnabledExtension(Class extensionPoint); /** * Get the extension(s) according to the {@code ExtensionPointDefinition} queried * by incoming extension point class. * * @param extensionPoint extension point class * @return implementations of the corresponding extension point. * @throws IllegalArgumentException if the incoming extension point class does not have * the {@code ExtensionPointDefinition}. */ Flux getEnabledExtensions(Class extensionPoint); /** * Get all extensions according to extension point class. * * @param extensionPointClass extension point class * @param type of extension point * @return a bunch of extension points. */ Flux getExtensions(Class extensionPointClass); /** * Get all extensions according to extension point class. * * @param extensionPointClass extension point class * @param type of extension point * @return a bunch of extension points. */ List getExtensionList(Class extensionPointClass); } ================================================ FILE: api/src/main/java/run/halo/app/search/HaloDocument.java ================================================ package run.halo.app.search; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.PastOrPresent; import java.time.Instant; import java.util.List; import java.util.Map; import lombok.Data; /** * Document for search. */ @Data public final class HaloDocument { /** * Document ID. It should be unique globally. */ @NotBlank private String id; /** * Metadata name of the corresponding extension. */ @NotBlank private String metadataName; /** * Custom metadata. Make sure the map is serializable. */ private Map annotations; /** * Document title. */ @NotBlank private String title; /** * Document description. */ private String description; /** * Document content. Safety content, without HTML tag. */ @NotBlank private String content; /** * Document categories. The item in the list is the category metadata name. */ private List categories; /** * Document tags. The item in the list is the tag metadata name. */ private List tags; /** * Whether the document is published. */ private boolean published; /** * Whether the document is recycled. */ private boolean recycled; /** * Whether the document is exposed to the public. */ private boolean exposed; /** * Document owner metadata name. */ @NotBlank private String ownerName; /** * Document creation timestamp. */ @PastOrPresent private Instant creationTimestamp; /** * Document update timestamp. */ @PastOrPresent private Instant updateTimestamp; /** * Document permalink. */ @NotBlank private String permalink; /** * Document type. e.g.: post.content.halo.run, singlepage.content.halo.run, moment.moment * .halo.run, doc.doc.halo.run. */ @NotBlank private String type; } ================================================ FILE: api/src/main/java/run/halo/app/search/HaloDocumentsProvider.java ================================================ package run.halo.app.search; import org.pf4j.ExtensionPoint; import reactor.core.publisher.Flux; /** * Halo documents provider. This interface is used to rebuild the search index. * * @author johnniang */ public interface HaloDocumentsProvider extends ExtensionPoint { /** * Fetch all halo documents. * * @return all halo documents */ Flux fetchAll(); /** * Get type of documents. * * @return type of documents */ String getType(); } ================================================ FILE: api/src/main/java/run/halo/app/search/SearchEngine.java ================================================ package run.halo.app.search; import org.pf4j.ExtensionPoint; /** * Search engine is used to index and search halo documents. Meanwhile, it is also an extension * point for adding different search engine implementations. * * @author johnniang */ public interface SearchEngine extends ExtensionPoint { /** * Whether the search engine is available. * * @return true if available, false otherwise */ boolean available(); /** * Add or update halo documents. * * @param haloDocuments halo documents */ void addOrUpdate(Iterable haloDocuments); /** * Delete halo documents by ids. * * @param haloDocIds halo document ids */ void deleteDocument(Iterable haloDocIds); /** * Delete all halo documents. */ void deleteAll(); /** * Search halo documents. * * @param option search option * @return search result of halo documents */ SearchResult search(SearchOption option); } ================================================ FILE: api/src/main/java/run/halo/app/search/SearchOption.java ================================================ package run.halo.app.search; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import java.util.List; import java.util.Map; import lombok.Data; /** * Search option. It is used to control search behavior. * * @author johnniang */ @Data public class SearchOption { /** * Search keyword. */ @NotBlank private String keyword; /** * Limit of result. */ @Min(1) @Max(1000) private int limit = 10; /** * Pre HTML tag of highlighted fragment. */ private String highlightPreTag = ""; /** * Post HTML tag of highlighted fragment. */ private String highlightPostTag = ""; /** * Whether to filter exposed content. If null, it will not filter. */ private Boolean filterExposed; /** * Whether to filter recycled content. If null, it will not filter. */ private Boolean filterRecycled; /** * Whether to filter published content. If null, it will not filter. */ private Boolean filterPublished; /** * Types to include(or). If null, it will include all types. */ private List includeTypes; /** * Owner names to include(or). If null, it will include all owners. */ private List includeOwnerNames; /** * Category names to include(and). If null, it will include all categories. */ private List includeCategoryNames; /** * Tag names to include(and). If null, it will include all tags. */ private List includeTagNames; /** * Additional annotations for extending search option by other search engines. */ private Map annotations; } ================================================ FILE: api/src/main/java/run/halo/app/search/SearchResult.java ================================================ package run.halo.app.search; import java.util.List; import lombok.Data; @Data public class SearchResult { private List hits; private String keyword; private Long total; private int limit; private long processingTimeMillis; } ================================================ FILE: api/src/main/java/run/halo/app/search/SearchService.java ================================================ package run.halo.app.search; import reactor.core.publisher.Mono; /** * Search service is used to search content. * * @author johnniang * @since 2.17.0 */ public interface SearchService { /** * Perform search. * * @param option search option must not be null * @return search result */ Mono search(SearchOption option); } ================================================ FILE: api/src/main/java/run/halo/app/search/event/HaloDocumentAddRequestEvent.java ================================================ package run.halo.app.search.event; import org.springframework.context.ApplicationEvent; import run.halo.app.plugin.SharedEvent; import run.halo.app.search.HaloDocument; @SharedEvent public class HaloDocumentAddRequestEvent extends ApplicationEvent { private final Iterable documents; public HaloDocumentAddRequestEvent(Object source, Iterable documents) { super(source); this.documents = documents; } public Iterable getDocuments() { return documents; } } ================================================ FILE: api/src/main/java/run/halo/app/search/event/HaloDocumentDeleteRequestEvent.java ================================================ package run.halo.app.search.event; import org.springframework.context.ApplicationEvent; import org.springframework.lang.Nullable; import run.halo.app.plugin.SharedEvent; @SharedEvent public class HaloDocumentDeleteRequestEvent extends ApplicationEvent { private final Iterable docIds; /** * Construct a new {@code HaloDocumentDeleteRequestEvent} instance. * * @param source The source of the event. * @param docIds If the document IDs are not provided, all documents will be deleted. */ public HaloDocumentDeleteRequestEvent(Object source, @Nullable Iterable docIds) { super(source); this.docIds = docIds; } public Iterable getDocIds() { return docIds; } } ================================================ FILE: api/src/main/java/run/halo/app/search/event/HaloDocumentRebuildRequestEvent.java ================================================ package run.halo.app.search.event; import org.springframework.context.ApplicationEvent; import run.halo.app.plugin.SharedEvent; @SharedEvent public class HaloDocumentRebuildRequestEvent extends ApplicationEvent { public HaloDocumentRebuildRequestEvent(Object source) { super(source); } } ================================================ FILE: api/src/main/java/run/halo/app/security/AdditionalWebFilter.java ================================================ package run.halo.app.security; import org.pf4j.ExtensionPoint; import org.springframework.core.Ordered; import org.springframework.web.server.WebFilter; /** * Contract for interception-style, chained processing of Web requests that may be used to * implement cross-cutting, application-agnostic requirements such as security, timeouts, and * others. * * @author guqing * @since 2.4.0 */ public interface AdditionalWebFilter extends WebFilter, ExtensionPoint, Ordered { /** * Gets the order value of the object. * * @return the order value */ default int getOrder() { return Ordered.LOWEST_PRECEDENCE; } } ================================================ FILE: api/src/main/java/run/halo/app/security/AfterSecurityWebFilter.java ================================================ package run.halo.app.security; import org.pf4j.ExtensionPoint; import org.springframework.web.server.WebFilter; /** * Security web filter for after security. * * @author johnniang * @since 2.18 */ public interface AfterSecurityWebFilter extends WebFilter, ExtensionPoint { } ================================================ FILE: api/src/main/java/run/halo/app/security/AnonymousAuthenticationSecurityWebFilter.java ================================================ package run.halo.app.security; import org.pf4j.ExtensionPoint; import org.springframework.web.server.WebFilter; /** * Security web filter for anonymous authentication. * * @author johnniang */ public interface AnonymousAuthenticationSecurityWebFilter extends WebFilter, ExtensionPoint { } ================================================ FILE: api/src/main/java/run/halo/app/security/AuthenticationSecurityWebFilter.java ================================================ package run.halo.app.security; import org.pf4j.ExtensionPoint; import org.springframework.web.server.WebFilter; /** * Security web filter for normal authentication. * * @author johnniang */ public interface AuthenticationSecurityWebFilter extends WebFilter, ExtensionPoint { } ================================================ FILE: api/src/main/java/run/halo/app/security/BeforeSecurityWebFilter.java ================================================ package run.halo.app.security; import org.pf4j.ExtensionPoint; import org.springframework.web.server.WebFilter; /** * Security web filter for before security. * * @author johnniang * @since 2.18 */ public interface BeforeSecurityWebFilter extends WebFilter, ExtensionPoint { } ================================================ FILE: api/src/main/java/run/halo/app/security/FormLoginSecurityWebFilter.java ================================================ package run.halo.app.security; import org.pf4j.ExtensionPoint; import org.springframework.web.server.WebFilter; /** * Security web filter for form login. * * @author johnniang */ public interface FormLoginSecurityWebFilter extends WebFilter, ExtensionPoint { } ================================================ FILE: api/src/main/java/run/halo/app/security/HttpBasicSecurityWebFilter.java ================================================ package run.halo.app.security; import org.pf4j.ExtensionPoint; import org.springframework.web.server.WebFilter; /** * Security web filter for HTTP basic. * * @author johnniang * @since 2.20.0 */ public interface HttpBasicSecurityWebFilter extends WebFilter, ExtensionPoint { } ================================================ FILE: api/src/main/java/run/halo/app/security/LoginHandlerEnhancer.java ================================================ package run.halo.app.security; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; /** *

Halo uses this interface to enhance the processing of login success, such as device management * and remember me, etc. The login method of the plugin extension needs to call this interface in * the processing method of login success to ensure the normal operation of some enhanced * functions.

* * @author guqing * @since 2.17.0 */ public interface LoginHandlerEnhancer { /** * Invoked when login success. * * @param exchange The exchange. * @param successfulAuthentication The successful authentication. */ Mono onLoginSuccess(ServerWebExchange exchange, Authentication successfulAuthentication); /** * Invoked when login fails. * * @param exchange The exchange. * @param exception the reason authentication failed */ Mono onLoginFailure(ServerWebExchange exchange, AuthenticationException exception); } ================================================ FILE: api/src/main/java/run/halo/app/security/OAuth2AuthorizationCodeSecurityWebFilter.java ================================================ package run.halo.app.security; import org.pf4j.ExtensionPoint; import org.springframework.web.server.WebFilter; /** * Security web filter for OAuth2 authorization code. * * @author johnniang * @since 2.20.0 */ public interface OAuth2AuthorizationCodeSecurityWebFilter extends WebFilter, ExtensionPoint { } ================================================ FILE: api/src/main/java/run/halo/app/security/PersonalAccessToken.java ================================================ package run.halo.app.security; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import java.util.List; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @GVK(group = "security.halo.run", version = "v1alpha1", kind = PersonalAccessToken.KIND, plural = "personalaccesstokens", singular = "personalaccesstoken") public class PersonalAccessToken extends AbstractExtension { public static final String KIND = "PersonalAccessToken"; public static final String PAT_TOKEN_PREFIX = "pat_"; private Spec spec = new Spec(); @Data @Schema(name = "PatSpec") public static class Spec { @Schema(requiredMode = REQUIRED) private String name; private String description; private Instant expiresAt; private List roles; private List scopes; @Schema(requiredMode = REQUIRED) private String username; private boolean revoked; private Instant revokesAt; private Instant lastUsed; @Schema(requiredMode = REQUIRED) private String tokenId; } } ================================================ FILE: api/src/main/java/run/halo/app/security/authentication/CryptoService.java ================================================ package run.halo.app.security.authentication; import com.nimbusds.jose.jwk.JWK; import reactor.core.publisher.Mono; public interface CryptoService { /** * Decrypts message with Base64 format. * * @param encryptedMessage is a byte array containing encrypted message. * @return decrypted message. */ Mono decrypt(byte[] encryptedMessage); /** * Reads public key. * * @return byte array of public key */ Mono readPublicKey(); /** * Gets key ID of private key. * * @return key ID of private key. */ String getKeyId(); /** * Gets JSON Web Keys. * * @return JSON Web Keys */ JWK getJwk(); } ================================================ FILE: api/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordAuthenticationManager.java ================================================ package run.halo.app.security.authentication.login; import org.pf4j.ExtensionPoint; import org.springframework.security.authentication.ReactiveAuthenticationManager; /** * An extension point for username password authentication. * Any non-authentication exception occurs, the default authentication will be used. * If you want to skip authentication, please return Mono.empty() directly, the default * authentication will be used. * * @author johnniang * @since 2.8 */ public interface UsernamePasswordAuthenticationManager extends ReactiveAuthenticationManager, ExtensionPoint { } ================================================ FILE: api/src/main/java/run/halo/app/security/authentication/oauth2/HaloOAuth2AuthenticationToken.java ================================================ package run.halo.app.security.authentication.oauth2; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import lombok.Getter; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.user.OAuth2User; /** * Halo OAuth2 authentication token which combines {@link UserDetails} and original * {@link OAuth2AuthenticationToken}. * * @author johnniang * @since 2.20.0 */ public class HaloOAuth2AuthenticationToken extends AbstractAuthenticationToken { @Getter private final UserDetails userDetails; @Getter private final OAuth2AuthenticationToken original; /** * Constructs an {@code HaloOAuth2AuthenticationToken} using {@link UserDetails} and original * {@link OAuth2AuthenticationToken}. * * @param userDetails the {@link UserDetails} * @param original the original {@link OAuth2AuthenticationToken} */ public HaloOAuth2AuthenticationToken(UserDetails userDetails, OAuth2AuthenticationToken original) { super(combineAuthorities(userDetails, original)); this.userDetails = userDetails; this.original = original; setAuthenticated(true); } @Override public String getName() { return userDetails.getUsername(); } @Override public Collection getAuthorities() { var originalAuthorities = super.getAuthorities(); var userDetailsAuthorities = getUserDetails().getAuthorities(); var authorities = new ArrayList( originalAuthorities.size() + userDetailsAuthorities.size() ); authorities.addAll(originalAuthorities); authorities.addAll(userDetailsAuthorities); return Collections.unmodifiableList(authorities); } @Override public Object getCredentials() { return ""; } @Override public OAuth2User getPrincipal() { return original.getPrincipal(); } /** * Creates an authenticated {@link HaloOAuth2AuthenticationToken} using {@link UserDetails} and * original {@link OAuth2AuthenticationToken}. * * @param userDetails the {@link UserDetails} * @param original the original {@link OAuth2AuthenticationToken} * @return an authenticated {@link HaloOAuth2AuthenticationToken} */ public static HaloOAuth2AuthenticationToken authenticated( UserDetails userDetails, OAuth2AuthenticationToken original ) { return new HaloOAuth2AuthenticationToken(userDetails, original); } private static Collection combineAuthorities( UserDetails userDetails, OAuth2AuthenticationToken original) { var userDetailsAuthorities = userDetails.getAuthorities(); var originalAuthorities = original.getAuthorities(); var authorities = new ArrayList( originalAuthorities.size() + userDetailsAuthorities.size() ); authorities.addAll(originalAuthorities); authorities.addAll(userDetailsAuthorities); return Collections.unmodifiableList(authorities); } } ================================================ FILE: api/src/main/java/run/halo/app/security/device/DeviceService.java ================================================ package run.halo.app.security.device; import org.springframework.security.core.Authentication; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; public interface DeviceService { Mono loginSuccess(ServerWebExchange exchange, Authentication successfullAuthentication); Mono changeSessionId(ServerWebExchange exchange); Mono revoke(String principalName, String deviceId); Mono revoke(String username); } ================================================ FILE: api/src/main/java/run/halo/app/theme/Constant.java ================================================ package run.halo.app.theme; /** * This class holds constants related to the theme. * * @author johnniang */ public enum Constant { ; /** * The name of the variable that holds the SEO meta description. */ public static final String META_DESCRIPTION_VARIABLE_NAME = "seoMetaDescription"; } ================================================ FILE: api/src/main/java/run/halo/app/theme/ReactivePostContentHandler.java ================================================ package run.halo.app.theme; import lombok.Builder; import lombok.Data; import org.pf4j.ExtensionPoint; import org.springframework.lang.NonNull; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Post; /** *

{@link ReactivePostContentHandler} provides a way to extend the content to be displayed in * the theme.

* Plugins can implement this interface to extend the content to be displayed in the theme, * including but not limited to adding specific styles, JS libraries, inserting specific content, * and intercepting content. * * @author guqing * @since 2.7.0 */ public interface ReactivePostContentHandler extends ExtensionPoint { /** *

Methods for handling {@link run.halo.app.core.extension.content.Post} content.

*

For example, you can use this method to change the content for a better display in * theme-side.

* * @param postContent content to be handled * @return handled content */ Mono handle(@NonNull PostContentContext postContent); @Data @Builder class PostContentContext { private Post post; private String content; private String raw; private String rawType; } } ================================================ FILE: api/src/main/java/run/halo/app/theme/ReactiveSinglePageContentHandler.java ================================================ package run.halo.app.theme; import lombok.Builder; import lombok.Data; import org.pf4j.ExtensionPoint; import org.springframework.lang.NonNull; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.SinglePage; /** *

{@link ReactiveSinglePageContentHandler} provides a way to extend the content to be * displayed in the theme.

* * @author guqing * @see ReactivePostContentHandler * @since 2.7.0 */ public interface ReactiveSinglePageContentHandler extends ExtensionPoint { /** *

Methods for handling {@link run.halo.app.core.extension.content.SinglePage} content.

*

For example, you can use this method to change the content for a better display in * theme-side.

* * @param singlePageContent content to be handled * @return handled content */ Mono handle(@NonNull SinglePageContentContext singlePageContent); @Data @Builder class SinglePageContentContext { private SinglePage singlePage; private String content; private String raw; private String rawType; } } ================================================ FILE: api/src/main/java/run/halo/app/theme/TemplateNameResolver.java ================================================ package run.halo.app.theme; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; /** *

The {@link TemplateNameResolver} is used to resolve template name.

* Halo has a theme mechanism, template files are provided by different themes, so * we need a method to determine whether the template file exists in the activated theme and if * it does not exist, provide a default template name. * * @author guqing * @since 2.11.0 */ public interface TemplateNameResolver { /** * Resolve template name if exists or default template name in classpath. * * @param exchange exchange to resolve theme to use * @param name template * @return template name if exists or default template name in classpath */ Mono resolveTemplateNameOrDefault(ServerWebExchange exchange, String name); /** * Resolve template name if exists or default template given. * * @param exchange exchange to resolve theme to use * @param name template name * @param defaultName default template name to use if given template name not exists * @return template name if exists or default template name given */ Mono resolveTemplateNameOrDefault(ServerWebExchange exchange, String name, String defaultName); /** * Determine whether the template file exists in the current theme. * * @param exchange exchange to resolve theme to use * @param name template name * @return true if the template file exists in the current theme, false otherwise */ Mono isTemplateAvailableInTheme(ServerWebExchange exchange, String name); } ================================================ FILE: api/src/main/java/run/halo/app/theme/dialect/CommentWidget.java ================================================ package run.halo.app.theme.dialect; import org.pf4j.ExtensionPoint; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IProcessableElementTag; import org.thymeleaf.processor.element.IElementTagStructureHandler; /** * Comment widget extension point to extend the <halo:comment /> tag of the theme-side. * * @author guqing * @since 2.0.0 */ public interface CommentWidget extends ExtensionPoint { String ENABLE_COMMENT_ATTRIBUTE = CommentWidget.class.getName() + ".ENABLE"; void render(ITemplateContext context, IProcessableElementTag tag, IElementTagStructureHandler structureHandler); } ================================================ FILE: api/src/main/java/run/halo/app/theme/dialect/ElementTagPostProcessor.java ================================================ package run.halo.app.theme.dialect; import org.pf4j.ExtensionPoint; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IProcessableElementTag; import reactor.core.publisher.Mono; /** * An extension point for post-processing element tag. * * @author johnniang * @since 2.20.0 */ public interface ElementTagPostProcessor extends ExtensionPoint { /** *

* Execute the processor. *

*

* The {@link IProcessableElementTag} object argument is immutable, so all modifications to * this object or any * instructions to be given to the engine should be done through the specified * {@link org.thymeleaf.model.IModelFactory} model factory in context. *

*

* Don't forget to return the new tag after processing or * {@link reactor.core.publisher.Mono#empty()} if not processable. *

* * @param context the template context. * @param tag the event this processor is executing on. * @return a {@link reactor.core.publisher.Mono} that will complete when processing finishes * or empty mono if not support. */ Mono process( ITemplateContext context, final IProcessableElementTag tag ); } ================================================ FILE: api/src/main/java/run/halo/app/theme/dialect/TemplateFooterProcessor.java ================================================ package run.halo.app.theme.dialect; import org.pf4j.ExtensionPoint; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IModel; import org.thymeleaf.model.IProcessableElementTag; import org.thymeleaf.processor.element.IElementTagStructureHandler; import reactor.core.publisher.Mono; /** * Theme template footer tag snippet injection processor. * * @author guqing * @since 2.17.0 */ public interface TemplateFooterProcessor extends ExtensionPoint { Mono process(ITemplateContext context, IProcessableElementTag tag, IElementTagStructureHandler structureHandler, IModel model); } ================================================ FILE: api/src/main/java/run/halo/app/theme/dialect/TemplateHeadProcessor.java ================================================ package run.halo.app.theme.dialect; import org.pf4j.ExtensionPoint; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IModel; import org.thymeleaf.processor.element.IElementModelStructureHandler; import reactor.core.publisher.Mono; /** * Theme template head tag snippet injection processor. *

Head processor is processed order by {@link org.springframework.core.annotation.Order} * annotation, Higher order will be processed first and so that low-priority processor can be * overwritten head tag written by high-priority processor.

* * @author guqing * @since 2.0.0 */ @FunctionalInterface public interface TemplateHeadProcessor extends ExtensionPoint { Mono process(ITemplateContext context, IModel model, IElementModelStructureHandler structureHandler); } ================================================ FILE: api/src/main/java/run/halo/app/theme/finders/Finder.java ================================================ package run.halo.app.theme.finders; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import org.springframework.core.annotation.AliasFor; import org.springframework.stereotype.Service; /** * Template model data finder for theme. * * @author guqing * @since 2.0.0 */ @Service @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface Finder { /** * The name of the theme model variable. * * @return variable name, class simple name if not specified */ @AliasFor(annotation = Service.class) String value() default ""; } ================================================ FILE: api/src/main/java/run/halo/app/theme/finders/vo/ExtensionVoOperator.java ================================================ package run.halo.app.theme.finders.vo; import org.springframework.lang.NonNull; import run.halo.app.extension.MetadataOperator; /** * An operator for extension value object. * * @author guqing * @since 2.0.0 */ public interface ExtensionVoOperator { @NonNull MetadataOperator getMetadata(); } ================================================ FILE: api/src/main/java/run/halo/app/theme/router/ModelConst.java ================================================ package run.halo.app.theme.router; /** * Static variable keys for view model. * * @author guqing * @since 2.0.0 */ public enum ModelConst { ; public static final String TEMPLATE_ID = "_templateId"; public static final String POWERED_BY_HALO_TEMPLATE_ENGINE = "poweredByHaloTemplateEngine"; /** * This key is used to prevent caching from cache plugins. */ public static final String NO_CACHE = "HALO_TEMPLATE_ENGINE.NO_CACHE"; public static final Integer DEFAULT_PAGE_SIZE = 10; } ================================================ FILE: api/src/main/java/run/halo/app/theme/router/PageUrlUtils.java ================================================ package run.halo.app.theme.router; import java.util.Objects; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.math.NumberUtils; import org.springframework.web.reactive.function.server.ServerRequest; import run.halo.app.extension.ListResult; import run.halo.app.infra.utils.PathUtils; /** * A utility class for template page url. * * @author guqing * @since 2.0.0 */ public class PageUrlUtils { public static final String PAGE_PART = "page"; public static int pageNum(ServerRequest request) { if (isPageUrl(request.path())) { String pageNum = StringUtils.substringAfterLast(request.path(), "/page/"); return NumberUtils.toInt(pageNum, 1); } return 1; } public static boolean isPageUrl(String path) { String[] split = StringUtils.split(path, "/"); if (split.length > 1) { return PAGE_PART.equals(split[split.length - 2]) && NumberUtils.isDigits(split[split.length - 1]); } return false; } public static long totalPage(ListResult list) { return (list.getTotal() - 1) / list.getSize() + 1; } /** * Gets next page url with path. * * @param path request path * @return request path with next page part */ public static String nextPageUrl(String path, long total) { String[] segments = StringUtils.split(path, "/"); long defaultPage = Math.min(2, Math.max(total, 1)); if (segments.length > 1) { String pagePart = segments[segments.length - 2]; if (PAGE_PART.equals(pagePart)) { int pageNumIndex = segments.length - 1; String pageNum = segments[pageNumIndex]; segments[pageNumIndex] = toNextPage(pageNum, total); return PathUtils.combinePath(segments); } return appendPagePart(PathUtils.combinePath(segments), defaultPage); } return appendPagePart(PathUtils.combinePath(segments), defaultPage); } /** * Gets previous page url with path. * * @param path request path * @return request path with previous page part */ public static String prevPageUrl(String path) { String[] segments = StringUtils.split(path, "/"); if (segments.length > 1) { String pagePart = segments[segments.length - 2]; if (PAGE_PART.equals(pagePart)) { int pageNumIndex = segments.length - 1; String pageNum = segments[pageNumIndex]; int prevPage = toPrevPage(pageNum); segments[pageNumIndex] = String.valueOf(prevPage); if (prevPage == 1) { segments = ArrayUtils.subarray(segments, 0, pageNumIndex - 1); } if (segments.length == 0) { return "/"; } return PathUtils.combinePath(segments); } } return Objects.toString(path, "/"); } private static String appendPagePart(String path, long page) { return PathUtils.combinePath(path, PAGE_PART, String.valueOf(page)); } private static String toNextPage(String pageStr, long total) { long page = Math.min(parseInt(pageStr) + 1, Math.max(total, 1)); return String.valueOf(page); } private static int toPrevPage(String pageStr) { return Math.max(parseInt(pageStr) - 1, 1); } private static int parseInt(String pageStr) { if (!NumberUtils.isParsable(pageStr)) { throw new IllegalArgumentException("Page number must be a number"); } return NumberUtils.toInt(pageStr, 1); } } ================================================ FILE: api/src/main/java/run/halo/app/theme/router/UrlContextListResult.java ================================================ package run.halo.app.theme.router; import java.util.List; import lombok.Getter; import lombok.ToString; import run.halo.app.extension.ListResult; /** * Page wrapper with next and previous url. * * @param the type of the list item. * @author guqing * @since 2.0.0 */ @Getter @ToString(callSuper = true) public class UrlContextListResult extends ListResult { private final String nextUrl; private final String prevUrl; public UrlContextListResult(int page, int size, long total, List items, String nextUrl, String prevUrl) { super(page, size, total, items); this.nextUrl = nextUrl; this.prevUrl = prevUrl; } public static class Builder { private int page; private int size; private long total; private List items; private String nextUrl; private String prevUrl; public Builder page(int page) { this.page = page; return this; } public Builder size(int size) { this.size = size; return this; } public Builder total(long total) { this.total = total; return this; } public Builder items(List items) { this.items = items; return this; } public Builder nextUrl(String nextUrl) { this.nextUrl = nextUrl; return this; } public Builder prevUrl(String prevUrl) { this.prevUrl = prevUrl; return this; } /** * Assign value with list result. * * @param listResult list result * @return builder */ public Builder listResult(ListResult listResult) { this.page = listResult.getPage(); this.size = listResult.getSize(); this.total = listResult.getTotal(); this.items = listResult.getItems(); return this; } public UrlContextListResult build() { return new UrlContextListResult<>(page, size, total, items, nextUrl, prevUrl); } } } ================================================ FILE: api/src/test/java/run/halo/app/core/extension/content/PostTest.java ================================================ package run.halo.app.core.extension.content; import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Stream; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import run.halo.app.extension.Metadata; class PostTest { @ParameterizedTest @MethodSource("isRecycledProvider") void isRecycledTest(Metadata metadata, boolean expected) { assertEquals(expected, Post.isRecycled(metadata)); } static Stream isRecycledProvider() { Function, Metadata> metadataCreator = metadataConsumer -> { var metadata = new Metadata(); metadataConsumer.accept(metadata); return metadata; }; return Stream.of( Arguments.of(metadataCreator.apply(metadata -> { }), false), Arguments.of(metadataCreator.apply(metadata -> metadata.setLabels(Map.of(Post.DELETED_LABEL, "false"))), false), Arguments.of(metadataCreator.apply(metadata -> metadata.setLabels(Map.of(Post.DELETED_LABEL, "invalid"))), false), Arguments.of(metadataCreator.apply(metadata -> { metadata.setLabels(Map.of(Post.DELETED_LABEL, "true")); }), true) ); } } ================================================ FILE: api/src/test/java/run/halo/app/core/extension/notification/SubscriptionTest.java ================================================ package run.halo.app.core.extension.notification; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; /** * Tests for {@link Subscription}. * * @author guqing * @since 2.13.0 */ class SubscriptionTest { @Test void reasonSubjectToStringTest() { Subscription.ReasonSubject subject = new Subscription.ReasonSubject(); subject.setApiVersion("v1"); subject.setKind("Kind"); subject.setName("Name"); String expected = "Kind#v1/Name"; String actual = subject.toString(); assertThat(actual).isEqualTo(expected); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/ExtensionUtilTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.time.Instant; import java.util.HashSet; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; class ExtensionUtilTest { @Test void testIsNotDeleted() { var ext = mock(ExtensionOperator.class); when(ext.getMetadata()).thenReturn(null); assertFalse(ExtensionUtil.isDeleted(ext)); var metadata = mock(Metadata.class); when(ext.getMetadata()).thenReturn(metadata); when(metadata.getDeletionTimestamp()).thenReturn(null); assertFalse(ExtensionUtil.isDeleted(ext)); when(metadata.getDeletionTimestamp()).thenReturn(Instant.now()); assertTrue(ExtensionUtil.isDeleted(ext)); } @Test void addFinalizers() { var metadata = new Metadata(); assertNull(metadata.getFinalizers()); assertTrue(ExtensionUtil.addFinalizers(metadata, Set.of("fake"))); assertEquals(Set.of("fake"), metadata.getFinalizers()); assertFalse(ExtensionUtil.addFinalizers(metadata, Set.of("fake"))); assertEquals(Set.of("fake"), metadata.getFinalizers()); assertTrue(ExtensionUtil.addFinalizers(metadata, Set.of("another-fake"))); assertEquals(Set.of("fake", "another-fake"), metadata.getFinalizers()); } @Test void removeFinalizers() { var metadata = new Metadata(); assertFalse(ExtensionUtil.removeFinalizers(metadata, Set.of("fake"))); assertNull(metadata.getFinalizers()); metadata.setFinalizers(new HashSet<>(Set.of("fake"))); assertTrue(ExtensionUtil.removeFinalizers(metadata, Set.of("fake"))); assertEquals(Set.of(), metadata.getFinalizers()); } @Test void hasDoNotOverwriteLabelTests() { var extension = mock(ExtensionOperator.class); when(extension.getMetadata()).thenReturn(null); assertFalse(ExtensionUtil.hasDoNotOverwriteLabel(extension)); var metadata = mock(Metadata.class); when(extension.getMetadata()).thenReturn(metadata); when(metadata.getLabels()).thenReturn(null); assertFalse(ExtensionUtil.hasDoNotOverwriteLabel(extension)); when(metadata.getLabels()).thenReturn( Map.of(ExtensionUtil.DO_NOT_OVERWRITE_LABEL, "false") ); assertFalse(ExtensionUtil.hasDoNotOverwriteLabel(extension)); when(metadata.getLabels()).thenReturn( Map.of(ExtensionUtil.DO_NOT_OVERWRITE_LABEL, "true") ); assertTrue(ExtensionUtil.hasDoNotOverwriteLabel(extension)); when(metadata.getLabels()).thenReturn( Map.of(ExtensionUtil.DO_NOT_OVERWRITE_LABEL, "TrUe") ); assertTrue(ExtensionUtil.hasDoNotOverwriteLabel(extension)); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/FakeExtension.java ================================================ package run.halo.app.extension; @GVK(group = "fake.halo.run", version = "v1alpha1", kind = "Fake", plural = "fakes", singular = "fake") public class FakeExtension extends AbstractExtension { public static FakeExtension createFake(String name) { var metadata = new Metadata(); metadata.setName(name); var fake = new FakeExtension(); fake.setMetadata(metadata); return fake; } } ================================================ FILE: api/src/test/java/run/halo/app/extension/ListOptionsTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import static run.halo.app.extension.index.query.Queries.equal; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; /** * Test for {@link ListOptions}. * * @author guqing * @since 2.17.0 */ class ListOptionsTest { @Nested class ListOptionsBuilderTest { @Test void shouldBuildWithFieldAndLabelSelectors() { var listOptions = ListOptions.builder() .labelSelector() .eq("key-1", "value-1") .notEq("key-2", "value-1") .exists("key-3") .end() .andQuery(equal("spec.slug", "fake-slug")) .orQuery(equal("spec.slug", "test")) .build(); assertEquals(""" ((spec.slug = fake-slug OR spec.slug = test) \ AND ((metadata.labels['key-1'] = 'value-1' \ AND metadata.labels['key-2'] <> 'value-1') \ AND EXISTS metadata.labels['key-3']))\ """, listOptions.toCondition().toString()); } @Test void shouldBuildLabelSelectorOnly() { var listOptions = ListOptions.builder() .labelSelector() .notEq("key-2", "value-1") .end() .build(); assertEquals(""" metadata.labels['key-2'] <> 'value-1'\ """, listOptions.toCondition().toString()); } @Test void shouldBuildFieldSelectorOnly() { var listOptions = ListOptions.builder() .andQuery(equal("spec.slug", "fake-slug")) .orQuery(equal("spec.slug", "test")) .build(); assertEquals(""" (spec.slug = fake-slug OR spec.slug = test)\ """, listOptions.toCondition().toString()); } } } ================================================ FILE: api/src/test/java/run/halo/app/extension/PageRequestImplTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.data.domain.Sort; class PageRequestImplTest { @ParameterizedTest @ValueSource( ints = {0, -1, -10, -100, -1000, -10000} ) void shouldBeCompatibleZeroAndNegativePageNumber(int pageNumber) { var page = new PageRequestImpl(pageNumber, 10, Sort.unsorted()); assertEquals(1, page.getPageNumber()); assertEquals(10, page.getPageSize()); } @ParameterizedTest @ValueSource( ints = {0, -1, -10, -100, -1000, -10000} ) void shouldBeCompatibleNegativePageSize(int size) { var page = new PageRequestImpl(10, size, Sort.unsorted()); assertEquals(10, page.getPageNumber()); assertEquals(PageRequestImpl.MAX_SIZE, page.getPageSize()); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/SecretTest.java ================================================ package run.halo.app.extension; import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; import java.util.Map; import org.json.JSONException; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link Secret}. * * @author guqing * @since 2.4.0 */ class SecretTest { @Test void serialize() throws JSONException { Secret secret = new Secret(); secret.setMetadata(new Metadata()); secret.getMetadata().setName("test-secret"); secret.setType(Secret.SECRET_TYPE_OPAQUE); secret.setData(Map.of("password", "admin".getBytes())); String s = JsonUtils.objectToJson(secret); JSONAssert.assertEquals(testJsonString(), s, true); } @Test void deserialize() { String s = testJsonString(); Secret secret = JsonUtils.jsonToObject(s, Secret.class); assertThat(secret).isNotNull(); assertThat(secret.getMetadata().getName()).isEqualTo("test-secret"); assertThat(secret.getType()).isEqualTo(Secret.SECRET_TYPE_OPAQUE); assertThat(secret.getData()).containsEntry("password", "admin".getBytes()); } @Test void deserializeWithUnstructured() throws JsonProcessingException { Secret secret = Unstructured.OBJECT_MAPPER.readValue(testJsonString(), Secret.class); assertThat(secret.getMetadata().getName()).isEqualTo("test-secret"); assertThat(secret.getType()).isEqualTo(Secret.SECRET_TYPE_OPAQUE); assertThat(secret.getData()).containsEntry("password", "admin".getBytes()); } @Test void deserializeYamlWithStringData() throws JsonProcessingException { String s = """ apiVersion: v1alpha1 kind: Secret metadata: name: secret-basic-auth type: halo.run/basic-auth stringData: username: admin password: t0p-Secret """; Secret secret = new YAMLMapper().readValue(s, Secret.class); assertThat(secret.getMetadata().getName()).isEqualTo("secret-basic-auth"); assertThat(secret.getType()).isEqualTo("halo.run/basic-auth"); assertThat(secret.getStringData()).containsEntry("username", "admin"); assertThat(secret.getStringData()).containsEntry("password", "t0p-Secret"); } private String testJsonString() { return """ { "apiVersion": "v1alpha1", "kind": "Secret", "metadata": { "name": "test-secret" }, "type": "Opaque", "data": { "password": "YWRtaW4=" } } """; } } ================================================ FILE: api/src/test/java/run/halo/app/extension/controller/ControllerBuilderTest.java ================================================ package run.halo.app.extension.controller; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import java.time.Duration; import java.time.Instant; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.FakeExtension; @ExtendWith(MockitoExtension.class) class ControllerBuilderTest { @Mock ExtensionClient client; @Test void buildWithNullReconciler() { assertThrows(IllegalArgumentException.class, () -> new ControllerBuilder(null, client).build(), "Reconciler must not be null"); } @Test void buildWithNullClient() { assertThrows(IllegalArgumentException.class, () -> new ControllerBuilder(new FakeReconciler(), null).build()); } @Test void buildTest() { assertThrows(IllegalArgumentException.class, () -> new ControllerBuilder(new FakeReconciler(), client) .build(), "Extension must not be null"); assertNotNull(fakeBuilder().build()); assertNotNull(fakeBuilder() .syncAllOnStart(true) .nowSupplier(Instant::now) .minDelay(Duration.ofMillis(5)) .maxDelay(Duration.ofSeconds(1000)) .build()); assertNotNull(fakeBuilder() .syncAllOnStart(true) .minDelay(Duration.ofMillis(5)) .maxDelay(Duration.ofSeconds(1000)) .onAddMatcher(null) .onUpdateMatcher(null) .onDeleteMatcher(null) .build() ); } @Test void invalidMinDelayAndMaxDelay() { assertThrows(IllegalArgumentException.class, () -> fakeBuilder() .minDelay(Duration.ofSeconds(2)) .maxDelay(Duration.ofSeconds(1)) .build(), "Min delay must be less than or equal to max delay"); assertNotNull(fakeBuilder() .minDelay(null) .maxDelay(Duration.ofSeconds(1)) .build()); assertNotNull(fakeBuilder() .minDelay(Duration.ofSeconds(1)) .maxDelay(null) .build()); assertNotNull(fakeBuilder() .minDelay(Duration.ofSeconds(-1)) .build()); assertNotNull(fakeBuilder() .maxDelay(Duration.ofSeconds(-1)) .build()); } ControllerBuilder fakeBuilder() { return new ControllerBuilder(new FakeReconciler(), client) .extension(new FakeExtension()); } static class FakeReconciler implements Reconciler { @Override public Result reconcile(Request request) { return new Reconciler.Result(false, null); } @Override public Controller setupWith(ControllerBuilder builder) { return null; } } } ================================================ FILE: api/src/test/java/run/halo/app/extension/controller/DefaultControllerTest.java ================================================ package run.halo.app.extension.controller; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Duration; import java.time.Instant; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.extension.controller.Reconciler.Request; import run.halo.app.extension.controller.Reconciler.Result; import run.halo.app.extension.controller.RequestQueue.DelayedEntry; @ExtendWith(MockitoExtension.class) class DefaultControllerTest { @Mock RequestQueue queue; @Mock Reconciler reconciler; @Mock RequestSynchronizer synchronizer; @Mock ExecutorService executor; Instant now = Instant.now(); Duration minRetryAfter = Duration.ofMillis(100); Duration maxRetryAfter = Duration.ofSeconds(10); DefaultController controller; @BeforeEach void setUp() { controller = createController(1); assertFalse(controller.isDisposed()); assertFalse(controller.isStarted()); } DefaultController createController(int workerCount) { return new DefaultController<>("fake-controller", reconciler, queue, synchronizer, () -> now, minRetryAfter, maxRetryAfter, executor, workerCount); } @Test void shouldReturnRightName() { assertEquals("fake-controller", controller.getName()); } @Nested class WorkerTest { @Test void shouldCreateCorrectName() { var worker = controller.new Worker(); assertEquals("fake-controller-worker-1", worker.getName()); worker = controller.new Worker(); assertEquals("fake-controller-worker-2", worker.getName()); worker = controller.new Worker(); assertEquals("fake-controller-worker-3", worker.getName()); } @Test void shouldRunCorrectlyIfReconcilerReturnsNoReEnqueue() throws InterruptedException { when(queue.take()).thenReturn(new DelayedEntry<>( new Request("fake-request"), Duration.ofSeconds(1), () -> now )) .thenThrow(InterruptedException.class); when(reconciler.reconcile(any(Request.class))).thenReturn(new Result(false, null)); controller.new Worker().run(); verify(synchronizer, never()).start(); verify(queue, times(2)).take(); verify(queue, times(0)).add(any()); verify(queue, times(1)).done(any()); verify(reconciler, times(1)).reconcile(eq(new Request("fake-request"))); } @Test void shouldRunCorrectlyIfReconcilerReturnsReEnqueue() throws InterruptedException { when(queue.take()).thenReturn(new DelayedEntry<>( new Request("fake-request"), Duration.ofSeconds(1), () -> now )) .thenThrow(InterruptedException.class); when(queue.add(any())).thenReturn(true); when(reconciler.reconcile(any(Request.class))).thenReturn(new Result(true, null)); controller.new Worker().run(); verify(synchronizer, never()).start(); verify(queue, times(2)).take(); verify(queue, times(1)).done(any()); verify(queue, times(1)).add(argThat(de -> de.getEntry().name().equals("fake-request") && de.getRetryAfter().equals(Duration.ofSeconds(2)))); verify(reconciler, times(1)).reconcile(any(Request.class)); } @Test void shouldReRunIfReconcilerThrowException() throws InterruptedException { when(queue.take()).thenReturn(new DelayedEntry<>( new Request("fake-request"), Duration.ofSeconds(1), () -> now )) .thenThrow(InterruptedException.class); when(queue.add(any())).thenReturn(true); when(reconciler.reconcile(any(Request.class))).thenThrow(RuntimeException.class); controller.new Worker().run(); verify(synchronizer, never()).start(); verify(queue, times(2)).take(); verify(queue, times(1)).done(any()); verify(queue, times(1)).add(argThat(de -> de.getEntry().name().equals("fake-request") && de.getRetryAfter().equals(Duration.ofSeconds(2)))); verify(reconciler, times(1)).reconcile(any(Request.class)); } @Test void canReRunIfReconcilerThrowRequeueException() throws InterruptedException { when(queue.take()).thenReturn(new DelayedEntry<>( new Request("fake-request"), Duration.ofSeconds(1), () -> now )) .thenThrow(InterruptedException.class); when(queue.add(any())).thenReturn(true); var expectException = new RequeueException(Result.requeue(Duration.ofSeconds(2))); when(reconciler.reconcile(any(Request.class))).thenThrow(expectException); controller.new Worker().run(); verify(synchronizer, never()).start(); verify(queue, times(2)).take(); verify(queue).done(any()); verify(queue).add(argThat(de -> de.getEntry().name().equals("fake-request") && de.getRetryAfter().equals(Duration.ofSeconds(2)))); verify(reconciler).reconcile(any(Request.class)); } @Test void doNotReRunIfReconcilerThrowsRequeueExceptionWithoutRequeue() throws InterruptedException { when(queue.take()).thenReturn(new DelayedEntry<>( new Request("fake-request"), Duration.ofSeconds(1), () -> now )) .thenThrow(InterruptedException.class); var expectException = new RequeueException(Result.doNotRetry()); when(reconciler.reconcile(any(Request.class))).thenThrow(expectException); controller.new Worker().run(); verify(synchronizer, never()).start(); verify(queue, times(2)).take(); verify(queue).done(any()); verify(queue, never()).add(any()); verify(reconciler).reconcile(any(Request.class)); } @Test void shouldSetMinRetryAfterWhenTakeZeroDelayedEntry() throws InterruptedException { when(queue.take()).thenReturn(new DelayedEntry<>( new Request("fake-request"), minRetryAfter.minusMillis(1), () -> now )) .thenThrow(InterruptedException.class); when(queue.add(any())).thenReturn(true); when(reconciler.reconcile(any(Request.class))).thenReturn(new Result(true, null)); controller.new Worker().run(); verify(synchronizer, never()).start(); verify(queue, times(2)).take(); verify(queue, times(1)).done(any()); verify(queue, times(1)).add(argThat(de -> de.getEntry().name().equals("fake-request") && de.getRetryAfter().equals(minRetryAfter))); verify(reconciler, times(1)).reconcile(any(Request.class)); } @Test void shouldSetMaxRetryAfterWhenTakeGreaterThanMaxRetryAfterDelayedEntry() throws InterruptedException { when(queue.take()).thenReturn(new DelayedEntry<>( new Request("fake-request"), maxRetryAfter.plusMillis(1), () -> now )) .thenThrow(InterruptedException.class); when(queue.add(any())).thenReturn(true); when(reconciler.reconcile(any(Request.class))).thenReturn(new Result(true, null)); controller.new Worker().run(); verify(synchronizer, never()).start(); verify(queue, times(2)).take(); verify(queue, times(1)).done(any()); verify(queue, times(1)).add(argThat(de -> de.getEntry().name().equals("fake-request") && de.getRetryAfter().equals(maxRetryAfter))); verify(reconciler, times(1)).reconcile(any(Request.class)); } } @Test void shouldDisposeCorrectlyIfShutdownInTime() throws InterruptedException { when(executor.awaitTermination(anyLong(), any())).thenReturn(true); controller.dispose(); assertTrue(controller.isDisposed()); assertFalse(controller.isStarted()); verify(synchronizer, times(1)).dispose(); verify(queue, times(1)).dispose(); verify(executor).shutdownNow(); verify(executor, never()).shutdown(); verify(executor, times(1)).awaitTermination(anyLong(), any()); } @Test void shouldDisposeCorrectlyIfNotShutdownInTime() throws InterruptedException { when(executor.awaitTermination(anyLong(), any())) .thenReturn(false) .thenReturn(true); controller.dispose(); assertTrue(controller.isDisposed()); assertFalse(controller.isStarted()); verify(synchronizer).dispose(); verify(queue).dispose(); verify(executor).shutdown(); verify(executor).shutdownNow(); verify(executor, times(2)).awaitTermination(anyLong(), any()); } @Test void shouldDisposeCorrectlyEvenIfTimeoutAwaitTermination() throws InterruptedException { when(executor.awaitTermination(anyLong(), any())) .thenThrow(InterruptedException.class) .thenReturn(true); controller.dispose(); assertTrue(controller.isDisposed()); assertFalse(controller.isStarted()); verify(synchronizer, times(1)).dispose(); verify(queue, times(1)).dispose(); verify(executor).shutdown(); verify(executor, times(1)).shutdownNow(); verify(executor, times(2)).awaitTermination(anyLong(), any()); } @Test void shouldStartCorrectly() throws InterruptedException { doAnswer(invocation -> { // simulate starting synchronizer invocation.getArgument(0, Runnable.class).run(); return null; }).doAnswer(invocation -> { // simulate starting worker return null; }).when(executor).execute(any(Runnable.class)); controller.start(); assertTrue(controller.isStarted()); assertFalse(controller.isDisposed()); verify(synchronizer).start(); verify(executor, times(2)).execute(any(Runnable.class)); } @Test void shouldNotStartWhenDisposed() throws InterruptedException { when(executor.awaitTermination(1, TimeUnit.MINUTES)).thenReturn(true); controller.dispose(); controller.start(); assertFalse(controller.isStarted()); assertTrue(controller.isDisposed()); verify(executor, times(0)).execute(any(Runnable.class)); } @Test void shouldCreateMultiWorkers() { controller = createController(5); controller.start(); verify(executor, times(5)).execute(any(DefaultController.Worker.class)); } @Test void shouldFailToCreateControllerDueToInvalidWorkerCount() { assertThrows(IllegalArgumentException.class, () -> createController(0)); assertThrows(IllegalArgumentException.class, () -> createController(-1)); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/controller/DefaultDelayQueueTest.java ================================================ package run.halo.app.extension.controller; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.Duration; import java.time.Instant; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import run.halo.app.extension.controller.Reconciler.Request; import run.halo.app.extension.controller.RequestQueue.DelayedEntry; class DefaultDelayQueueTest { Instant now = Instant.now(); DefaultQueue queue; final Duration minDelay = Duration.ofMillis(1); @BeforeEach void setUp() { queue = new DefaultQueue<>(() -> now, minDelay); } @Test void addImmediatelyTest() { var request = newRequest("fake-name"); var added = queue.addImmediately(request); assertTrue(added); assertEquals(1, queue.size()); var delayedEntry = queue.peek(); assertNotNull(delayedEntry); assertEquals(newRequest("fake-name"), delayedEntry.getEntry()); assertEquals(minDelay, delayedEntry.getRetryAfter()); assertEquals(minDelay.toMillis(), delayedEntry.getDelay(TimeUnit.MILLISECONDS)); } @Test void addWithDelaySmallerThanMinDelay() { var request = newRequest("fake-name"); var added = queue.add(new DelayedEntry<>(request, Duration.ofNanos(1), () -> now)); assertTrue(added); assertEquals(1, queue.size()); var delayedEntry = queue.peek(); assertNotNull(delayedEntry); assertEquals(newRequest("fake-name"), delayedEntry.getEntry()); assertEquals(minDelay, delayedEntry.getRetryAfter()); assertEquals(minDelay.toMillis(), delayedEntry.getDelay(TimeUnit.MILLISECONDS)); } @Test void addWithDelayGreaterThanMinDelay() { var request = newRequest("fake-name"); var added = queue.add(new DelayedEntry<>(request, minDelay.plusMillis(1), () -> now)); assertTrue(added); assertEquals(1, queue.size()); var delayedEntry = queue.peek(); assertNotNull(delayedEntry); assertEquals(newRequest("fake-name"), delayedEntry.getEntry()); assertEquals(minDelay.plusMillis(1), delayedEntry.getRetryAfter()); assertEquals(minDelay.plusMillis(1).toMillis(), delayedEntry.getDelay(TimeUnit.MILLISECONDS)); } @Test void shouldNotAddAfterDisposing() { assertFalse(queue.isDisposed()); queue.dispose(); assertTrue(queue.isDisposed()); var request = newRequest("fake-name"); var added = queue.add(new DelayedEntry<>(request, minDelay, () -> now)); assertFalse(added); assertEquals(0, queue.size()); } @Test void shouldNotAddRepeatedlyIfNotDone() throws InterruptedException { queue = new DefaultQueue<>(() -> now, Duration.ZERO); var fakeEntry = new DelayedEntry<>(newRequest("fake-name"), Duration.ZERO, () -> this.now); queue.add(fakeEntry); assertEquals(1, queue.size()); assertEquals(fakeEntry, queue.peek()); queue.take(); assertEquals(0, queue.size()); queue.add(fakeEntry); assertEquals(0, queue.size()); queue.done(newRequest("fake-name")); queue.add(fakeEntry); assertEquals(1, queue.size()); assertEquals(fakeEntry, queue.peek()); } @Test void shouldNotAddIfHavingEarlierEntryInQueue() { queue = new DefaultQueue<>(() -> now, Duration.ZERO); var fakeEntry = new DelayedEntry<>(newRequest("fake-name"), Duration.ZERO, () -> this.now); assertTrue(queue.add(fakeEntry)); assertEquals(1, queue.size()); assertEquals(fakeEntry, queue.peek()); assertFalse(queue.add(fakeEntry)); var laterEntry = new DelayedEntry<>(newRequest("fake-name"), Duration.ofMillis(100), () -> this.now); assertFalse(queue.add(laterEntry)); } @Test void shouldAddIfHavingLaterEntryInQueue() { queue = new DefaultQueue<>(() -> now, Duration.ZERO); var fakeEntry = new DelayedEntry<>(newRequest("fake-name"), Duration.ofMillis(100), () -> this.now); assertTrue(queue.add(fakeEntry)); assertEquals(1, queue.size()); assertEquals(fakeEntry, queue.peek()); assertFalse(queue.add(fakeEntry)); var laterEntry = new DelayedEntry<>(newRequest("fake-name"), Duration.ofMillis(99), () -> this.now); assertTrue(queue.add(laterEntry)); } Request newRequest(String name) { return new Request(name); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/controller/DelayedEntryTest.java ================================================ package run.halo.app.extension.controller; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.Duration; import java.time.Instant; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import run.halo.app.extension.controller.RequestQueue.DelayedEntry; class DelayedEntryTest { Instant now = Instant.now(); @Test void createDelayedEntry() { var delayedEntry = new DelayedEntry<>("fake", Duration.ofMillis(100), () -> now); assertEquals(100, delayedEntry.getDelay(TimeUnit.MILLISECONDS)); assertEquals(Duration.ofMillis(100), delayedEntry.getRetryAfter()); assertEquals(now.plusMillis(100), delayedEntry.getReadyAt()); assertEquals("fake", delayedEntry.getEntry()); delayedEntry = new DelayedEntry<>("fake", now.plus(Duration.ofSeconds(1)), () -> now); assertEquals(1000, delayedEntry.getDelay(TimeUnit.MILLISECONDS)); assertEquals(Duration.ofMillis(1000), delayedEntry.getRetryAfter()); } @Test void compareWithGreaterDelay() { var firstDelayEntry = new DelayedEntry<>("fake", Duration.ofMillis(100), () -> now); var secondDelayEntry = new DelayedEntry<>("fake", Duration.ofMillis(200), () -> now); assertTrue(firstDelayEntry.compareTo(secondDelayEntry) < 0); } @Test void compareWithSameDelay() { var firstDelayEntry = new DelayedEntry<>("fake", Duration.ofMillis(100), () -> now); var secondDelayEntry = new DelayedEntry<>("fake", Duration.ofMillis(100), () -> now); assertEquals(0, firstDelayEntry.compareTo(secondDelayEntry)); } @Test void compareWithLessDelay() { var firstDelayEntry = new DelayedEntry<>("fake", Duration.ofMillis(200), () -> now); var secondDelayEntry = new DelayedEntry<>("fake", Duration.ofMillis(100), () -> now); assertTrue(firstDelayEntry.compareTo(secondDelayEntry) > 0); } @Test void shouldBeEqualWithNameOnly() { var firstDelayEntry = new DelayedEntry<>("fake", Duration.ofMillis(200), () -> now); var secondDelayEntry = new DelayedEntry<>("fake", Duration.ofMillis(100), Instant::now); assertEquals(firstDelayEntry, secondDelayEntry); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/controller/ExtensionWatcherTest.java ================================================ package run.halo.app.extension.controller; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static run.halo.app.extension.FakeExtension.createFake; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.extension.DefaultExtensionMatcher; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.WatcherExtensionMatchers; import run.halo.app.extension.controller.Reconciler.Request; @ExtendWith(MockitoExtension.class) class ExtensionWatcherTest { @Mock RequestQueue queue; @Mock ExtensionClient client; @Mock WatcherExtensionMatchers matchers; @InjectMocks ExtensionWatcher watcher; private DefaultExtensionMatcher getEmptyMatcher() { return DefaultExtensionMatcher.builder(client, GroupVersionKind.fromExtension(FakeExtension.class)) .build(); } @Test void shouldAddExtensionWhenAddPredicateAlwaysTrue() { when(matchers.onAddMatcher()).thenReturn(getEmptyMatcher()); watcher.onAdd(createFake("fake-name")); verify(matchers, times(1)).onAddMatcher(); verify(queue, times(1)).addImmediately( argThat(request -> request.name().equals("fake-name"))); verify(queue, times(0)).add(any()); } @Test void shouldNotAddExtensionWhenAddPredicateAlwaysFalse() { var type = GroupVersionKind.fromAPIVersionAndKind("v1alpha1", "User"); when(matchers.onAddMatcher()).thenReturn( DefaultExtensionMatcher.builder(client, type).build()); watcher.onAdd(createFake("fake-name")); verify(matchers, times(1)).onAddMatcher(); verify(queue, times(0)).add(any()); verify(queue, times(0)).addImmediately(any()); } @Test void shouldNotAddExtensionWhenWatcherIsDisposed() { watcher.dispose(); watcher.onAdd(createFake("fake-name")); verify(matchers, times(0)).onAddMatcher(); verify(queue, times(0)).addImmediately(any()); verify(queue, times(0)).add(any()); } @Test void shouldUpdateExtensionWhenUpdatePredicateAlwaysTrue() { when(matchers.onUpdateMatcher()).thenReturn(getEmptyMatcher()); watcher.onUpdate(createFake("old-fake-name"), createFake("new-fake-name")); verify(matchers, times(1)).onUpdateMatcher(); verify(queue, times(1)).addImmediately( argThat(request -> request.name().equals("new-fake-name"))); verify(queue, times(0)).add(any()); } @Test void shouldUpdateExtensionWhenUpdatePredicateAlwaysFalse() { var type = GroupVersionKind.fromAPIVersionAndKind("v1alpha1", "User"); when(matchers.onUpdateMatcher()).thenReturn( DefaultExtensionMatcher.builder(client, type).build()); watcher.onUpdate(createFake("old-fake-name"), createFake("new-fake-name")); verify(matchers, times(1)).onUpdateMatcher(); verify(queue, times(0)).add(any()); verify(queue, times(0)).addImmediately(any()); } @Test void shouldNotUpdateExtensionWhenWatcherIsDisposed() { watcher.dispose(); watcher.onUpdate(createFake("old-fake-name"), createFake("new-fake-name")); verify(matchers, times(0)).onUpdateMatcher(); verify(queue, times(0)).add(any()); verify(queue, times(0)).addImmediately(any()); } @Test void shouldDeleteExtensionWhenDeletePredicateAlwaysTrue() { when(matchers.onDeleteMatcher()).thenReturn(getEmptyMatcher()); watcher.onDelete(createFake("fake-name")); verify(matchers, times(1)).onDeleteMatcher(); verify(queue, times(1)).addImmediately( argThat(request -> request.name().equals("fake-name"))); verify(queue, times(0)).add(any()); } @Test void shouldDeleteExtensionWhenDeletePredicateAlwaysFalse() { var type = GroupVersionKind.fromAPIVersionAndKind("v1alpha1", "User"); when(matchers.onDeleteMatcher()).thenReturn( DefaultExtensionMatcher.builder(client, type).build()); watcher.onDelete(createFake("fake-name")); verify(matchers, times(1)).onDeleteMatcher(); verify(queue, times(0)).add(any()); verify(queue, times(0)).addImmediately(any()); } @Test void shouldNotDeleteExtensionWhenWatcherIsDisposed() { watcher.dispose(); watcher.onDelete(createFake("fake-name")); verify(matchers, times(0)).onDeleteMatcher(); verify(queue, times(0)).add(any()); verify(queue, times(0)).addImmediately(any()); } @Test void shouldInvokeDisposeHookIfRegistered() { var mockHook = mock(Runnable.class); watcher.registerDisposeHook(mockHook); verify(mockHook, times(0)).run(); watcher.dispose(); verify(mockHook, times(1)).run(); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/controller/RequestSynchronizerTest.java ================================================ package run.halo.app.extension.controller; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Sort; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Watcher; @ExtendWith(MockitoExtension.class) class RequestSynchronizerTest { @Mock ExtensionClient client; @Mock Watcher watcher; RequestSynchronizer synchronizer; @BeforeEach void setUp() { synchronizer = new RequestSynchronizer(true, client, new FakeExtension(), watcher, new ListOptions()); assertFalse(synchronizer.isDisposed()); assertFalse(synchronizer.isStarted()); } @Test void shouldStartCorrectlyWhenSyncingAllOnStart() { when(client.listTopNames( eq(FakeExtension.class), isA(ListOptions.class), isA(Sort.class), any(Integer.class)) ).thenReturn(List.of("fake-01", "fake-02")); synchronizer.start(); assertTrue(synchronizer.isStarted()); assertFalse(synchronizer.isDisposed()); verify(watcher, times(2)).onAdd(isA(Reconciler.Request.class)); verify(client, times(1)).watch(same(watcher)); } @Test void shouldStartCorrectlyWhenNotSyncingAllOnStart() { synchronizer = new RequestSynchronizer(false, client, new FakeExtension(), watcher, new ListOptions()); assertFalse(synchronizer.isDisposed()); assertFalse(synchronizer.isStarted()); synchronizer.start(); assertTrue(synchronizer.isStarted()); assertFalse(synchronizer.isDisposed()); verify(client, times(0)).list(any(), any(), any()); verify(watcher, times(0)).onAdd(isA(Reconciler.Request.class)); verify(client, times(1)).watch(any(Watcher.class)); } @Test void shouldDisposeCorrectly() { synchronizer.start(); assertFalse(synchronizer.isDisposed()); assertTrue(synchronizer.isStarted()); synchronizer.dispose(); assertTrue(synchronizer.isDisposed()); assertTrue(synchronizer.isStarted()); verify(watcher, times(1)).dispose(); } @Test void shouldNotStartAfterDisposing() { synchronizer.dispose(); synchronizer.start(); verify(client, times(0)).list(any(), any(), any()); verify(watcher, times(0)).onAdd(isA(Reconciler.Request.class)); verify(client, times(0)).watch(any()); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/index/IndexAttributeFactoryTest.java ================================================ package run.halo.app.extension.index; import static org.assertj.core.api.Assertions.assertThat; import java.util.Set; import lombok.Data; import lombok.EqualsAndHashCode; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.Metadata; /** * Tests for {@link IndexAttributeFactory}. * * @author guqing * @since 2.12.0 */ class IndexAttributeFactoryTest { @Test void shouldCreateMultiValueAttribute() { var attribute = IndexAttributeFactory.multiValueAttribute(FakeExtension.class, FakeExtension::getTags); assertThat(attribute).isNotNull(); assertThat(attribute.getObjectType()).isEqualTo(FakeExtension.class); var extension = new FakeExtension(); extension.setMetadata(new Metadata()); extension.getMetadata().setName("fake-name-1"); extension.setTags(Set.of("tag1", "tag2")); Assertions.assertEquals( Set.of(new UnknownKey("tag1"), new UnknownKey("tag2")), attribute.getValues(extension) ); } @Test void shouldCreateSingleValueAttribute() { var attribute = IndexAttributeFactory.simpleAttribute(FakeExtension.class, FakeExtension::getName); assertThat(attribute).isNotNull(); assertThat(attribute.getObjectType()).isEqualTo(FakeExtension.class); var extension = new FakeExtension(); extension.setMetadata(new Metadata()); extension.getMetadata().setName("fake-name-2"); extension.setName("Fake Extension Name"); Assertions.assertEquals( Set.of(new UnknownKey("Fake Extension Name")), attribute.getValues(extension) ); } @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "test", version = "v1", kind = "FakeExtension", plural = "fakes", singular = "fake") static class FakeExtension extends AbstractExtension { private Set tags; private String name; } } ================================================ FILE: api/src/test/java/run/halo/app/extension/index/IndexSpecTest.java ================================================ package run.halo.app.extension.index; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Set; import lombok.Data; import lombok.EqualsAndHashCode; import org.junit.jupiter.api.Test; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; /** * Tests for {@link IndexSpec}. * * @author guqing * @since 2.12.0 */ class IndexSpecTest { @Test void equalsVerifier() { var spec1 = new IndexSpec() .setName("metadata.name") .setOrder(IndexSpec.OrderType.ASC) .setIndexFunc(IndexAttributeFactory.simpleAttribute(FakeExtension.class, e -> e.getMetadata().getName()) ) .setUnique(true); var spec2 = new IndexSpec() .setName("metadata.name") .setOrder(IndexSpec.OrderType.ASC) .setIndexFunc(IndexAttributeFactory.simpleAttribute(FakeExtension.class, e -> e.getMetadata().getName()) ) .setUnique(true); assertThat(spec1).isEqualTo(spec2); assertThat(spec1.equals(spec2)).isTrue(); assertThat(spec1.hashCode()).isEqualTo(spec2.hashCode()); var spec3 = new IndexSpec() .setName("metadata.name") .setOrder(IndexSpec.OrderType.DESC) .setIndexFunc(IndexAttributeFactory.simpleAttribute(FakeExtension.class, e -> e.getMetadata().getName()) ) .setUnique(false); assertThat(spec1).isEqualTo(spec3); assertThat(spec1.equals(spec3)).isTrue(); assertThat(spec1.hashCode()).isEqualTo(spec3.hashCode()); var spec4 = new IndexSpec() .setName("slug") .setOrder(IndexSpec.OrderType.ASC) .setIndexFunc(IndexAttributeFactory.simpleAttribute(FakeExtension.class, e -> e.getMetadata().getName()) ) .setUnique(true); assertThat(spec1.equals(spec4)).isFalse(); assertThat(spec1).isNotEqualTo(spec4); } @Test void equalAnotherObject() { var spec3 = new IndexSpec() .setName("metadata.name"); assertThat(spec3.equals(new Object())).isFalse(); } @Test void shouldNormalizeToSingleValueIndexSpec() { var spec = new IndexSpec() .setName("metadata.name") .setOrder(IndexSpec.OrderType.ASC) .setIndexFunc(IndexAttributeFactory.simpleAttribute(FakeExtension.class, e -> e.getMetadata().getName()) ) .setUnique(true); var normalized = spec.normalize(); assertEquals("metadata.name", normalized.getName()); assertTrue(normalized.isUnique()); assertTrue(normalized.isNullable()); assertInstanceOf(SingleValueIndexSpec.class, normalized); } @Test void shouldNormalizeToMultiValueIndexSpec() { var spec = new IndexSpec() .setName("slug") .setOrder(IndexSpec.OrderType.ASC) .setIndexFunc(IndexAttributeFactory.multiValueAttribute(FakeExtension.class, e -> Set.of(e.getSlug()))) .setUnique(false); var normalized = spec.normalize(); assertEquals("slug", normalized.getName()); assertFalse(normalized.isUnique()); assertTrue(normalized.isNullable()); assertInstanceOf(MultiValueIndexSpec.class, normalized); } @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "test", version = "v1", kind = "Fake", plural = "fakes", singular = "fake") static class FakeExtension extends AbstractExtension { private String slug; } } ================================================ FILE: api/src/test/java/run/halo/app/extension/index/KeyComparatorTest.java ================================================ package run.halo.app.extension.index; import static org.assertj.core.api.Assertions.assertThat; import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.TreeMap; import java.util.TreeSet; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; /** * Tests for {@link KeyComparator}. * * @author guqing * @since 2.12.0 */ class KeyComparatorTest { private final KeyComparator comparator = KeyComparator.INSTANCE; @Test void keyComparator() { String[] strings = {"103", "101", "102", "1011", "1013", "1021", "1022", "1012", "1023"}; Arrays.sort(strings, comparator); assertThat(strings).isEqualTo( new String[] {"101", "102", "103", "1011", "1012", "1013", "1021", "1022", "1023"}); Arrays.sort(strings, comparator.reversed()); assertThat(strings).isEqualTo( new String[] {"1023", "1022", "1021", "1013", "1012", "1011", "103", "102", "101"}); // but if we use natural order, the result is: Arrays.sort(strings, Comparator.naturalOrder()); assertThat(strings).isEqualTo( new String[] {"101", "1011", "1012", "1013", "102", "1021", "1022", "1023", "103"}); } @Test void keyComparator2() { String[] strings = {"moment-101", "moment-102", "moment-103", "moment-1011", "moment-1013", "moment-1021", "moment-1022", "moment-1012", "moment-1023"}; Arrays.sort(strings, comparator); assertThat(strings).isEqualTo(new String[] {"moment-101", "moment-102", "moment-103", "moment-1011", "moment-1012", "moment-1013", "moment-1021", "moment-1022", "moment-1023"}); // date sort strings = new String[] {"2022-01-15", "2022-02-01", "2021-12-25", "2022-01-01", "2022-01-02"}; Arrays.sort(strings, comparator); assertThat(strings).isEqualTo( new String[] {"2021-12-25", "2022-01-01", "2022-01-02", "2022-01-15", "2022-02-01"}); // alphabet and number sort strings = new String[] {"abc123", "abc45", "abc9", "abc100", "abc20"}; Arrays.sort(strings, comparator); assertThat(strings).isEqualTo( new String[] {"abc9", "abc20", "abc45", "abc100", "abc123"}); // test for pure alphabet sort strings = new String[] {"xyz", "abc", "def", "abcde", "xyzabc"}; Arrays.sort(strings, comparator); assertThat(strings).isEqualTo(new String[] {"abc", "abcde", "def", "xyz", "xyzabc"}); // test for empty string strings = new String[] {"", "abc", "123", "xyz"}; Arrays.sort(strings, comparator); assertThat(strings).isEqualTo(new String[] {"", "123", "abc", "xyz"}); // test for the same string strings = new String[] {"abc", "abc", "abc", "abc"}; Arrays.sort(strings, comparator); assertThat(strings).isEqualTo(new String[] {"abc", "abc", "abc", "abc"}); // test for null element strings = new String[] {null, "abc", "123", "xyz"}; Arrays.sort(strings, comparator); assertThat(strings).isEqualTo(new String[] {"123", "abc", "xyz", null}); } @Test void complexStringTest() { var strings = new String[] { "1719560085223", "1719564195757", "AJHQ9JKT", "1719565849173", "5InykKCe", "123123123", "adJhTqEo", "123123", "Ahvcq7Wn", "asda", "b5jHcxfe" }; Arrays.sort(strings, comparator); assertThat(strings).containsExactly( "5InykKCe", "123123", "123123123", "1719560085223", "1719564195757", "1719565849173", "AJHQ9JKT", "Ahvcq7Wn", "adJhTqEo", "asda", "b5jHcxfe" ); } @Test void complexButSkewedStringTest() { var strings = new String[] { "chu-shi-hua-gong-neng-you-hua-halo-2.9.0-fa-bu", "cxcc", "d", "dddd", "ddddd", "de-dao", "dENMr6tX", "dian-shang-ke-fu", "dong-tai-she-ji-shi-xi-25jie", "eeeeeeee", "ejqRrTp4", "Fh8Jd09T", "g5gZaGvS", }; Arrays.sort(strings, comparator); assertThat(strings).containsExactly( "Fh8Jd09T", "chu-shi-hua-gong-neng-you-hua-halo-2.9.0-fa-bu", "cxcc", "d", "dENMr6tX", "dddd", "ddddd", "de-dao", "dian-shang-ke-fu", "dong-tai-she-ji-shi-xi-25jie", "eeeeeeee", "ejqRrTp4", "g5gZaGvS" ); } @Test void mixLetterCaseStringTest() { var strings = new String[] { "VpLBxBJ7", "AJHQ9JKT", "asda", "Tq5EgH2V", "Fh8Jd09T", "J7KMLQeK", "adJhTqEo", "Ahvcq7Wn", }; Arrays.sort(strings, comparator); assertThat(strings).containsExactly( "AJHQ9JKT", "Ahvcq7Wn", "Fh8Jd09T", "J7KMLQeK", "Tq5EgH2V", "VpLBxBJ7", "adJhTqEo", "asda" ); } @Test void mixLetterCaseAndNumberTest() { var strings = new String[] { "1719565849173", "1719564195757", "1703040584263", "AJHQ9JKT", "Ahvcq7Wn", "Fh8Jd09T", "adJhTqEo", "asda", "1703053590063", "1702955288482", "zhi-chi-bei-fen-hui-fu-halo-2.8.0-fa-bu", "zhi-chi-ge-ren-zhong-xin-halo-2.11.0-fa-bu", "J7KMLQeK", "Tq5EgH2V", "VpLBxBJ7", "b5jHcxfe", "cao-ni-ma-a-huang-jian-ming", "chu-ji-ying-jian-kai-fa", "ddddd", "de-dao", "dian-shang-ke-fu", "eeeeeeee", "ejqRrTp4", "halo-maintainer-2023-nian-du-bang-dan", "hello-halo", "hello-world", "dong-tai-she-ji-shi-xi-25jie", "halo-nuan-dong-li-yu-quan-chang-qi-zhe-qi", "hello", "kai-fang-gong-gong-api-halo-2.5.0-fa-bu", "xing-neng-you-hua-yu-gong-neng-gai-jin-halo-2.13-fa-bu", "ye-wu-tuo-zhan-jing-li", "ying-qu-jing-mei-zhou-bian-halo-ying-yong-shi-chang-zhu-ti-you-jiang-zheng-ji", "zhi-chi-bao-chi-deng-lu-hui-hua-halo-2.16.0-fa-bu", }; Arrays.sort(strings, comparator); assertThat(strings).containsExactly( "1702955288482", "1703040584263", "1703053590063", "1719564195757", "1719565849173", "AJHQ9JKT", "Ahvcq7Wn", "Fh8Jd09T", "J7KMLQeK", "Tq5EgH2V", "VpLBxBJ7", "adJhTqEo", "asda", "b5jHcxfe", "cao-ni-ma-a-huang-jian-ming", "chu-ji-ying-jian-kai-fa", "ddddd", "de-dao", "dian-shang-ke-fu", "dong-tai-she-ji-shi-xi-25jie", "eeeeeeee", "ejqRrTp4", "halo-maintainer-2023-nian-du-bang-dan", "halo-nuan-dong-li-yu-quan-chang-qi-zhe-qi", "hello", "hello-halo", "hello-world", "kai-fang-gong-gong-api-halo-2.5.0-fa-bu", "xing-neng-you-hua-yu-gong-neng-gai-jin-halo-2.13-fa-bu", "ye-wu-tuo-zhan-jing-li", "ying-qu-jing-mei-zhou-bian-halo-ying-yong-shi-chang-zhu-ti-you-jiang-zheng-ji", "zhi-chi-bao-chi-deng-lu-hui-hua-halo-2.16.0-fa-bu", "zhi-chi-bei-fen-hui-fu-halo-2.8.0-fa-bu", "zhi-chi-ge-ren-zhong-xin-halo-2.11.0-fa-bu"); } @Test public void sortingWithComplexStringsTest() { List strings = Arrays.asList("abc10", "abc2", "abc1", "abc20", "abc100"); strings.sort(comparator); assertThat(strings).containsExactly("abc1", "abc2", "abc10", "abc20", "abc100"); } @Test public void sortingWithDecimalStringsTest() { List strings = Arrays.asList("1.2", "1.10", "1.1", "1.20", "1.02", "1.22", "1.001", "1.002"); strings.sort(comparator); assertThat(strings).containsExactly("1.001", "1.002", "1.02", "1.1", "1.10", "1.2", "1.20", "1.22"); } @Test public void treeSetWithComparatorTest() { TreeSet set = new TreeSet<>(comparator); set.add("abc123"); set.add("abc1"); set.add("abc12"); set.add("abc2"); assertThat(set).containsExactly("abc1", "abc2", "abc12", "abc123"); } @Test public void testTreeMap_WithComparator() { TreeMap map = new TreeMap<>(comparator); map.put("2024-08-29", "date1"); map.put("2024-08-28", "date2"); map.put("2024-08-30", "date3"); assertThat(map.keySet()).containsExactly("2024-08-28", "2024-08-29", "2024-08-30"); assertThat(map.get("2024-08-29")).isEqualTo("date1"); } @Test public void integerPartDifferentTest() { // Create strings with different integer parts to cover the compareIntegerPart code // block String[] strings = {"abc10", "abc2", "abc1", "abc20", "abc10022229"}; Arrays.sort(strings, comparator); String[] expectedOrder = {"abc1", "abc2", "abc10", "abc20", "abc10022229"}; assertThat(strings).containsExactly(expectedOrder); } @Test public void integerPartDifferentWithDecimalTest() { // To specifically test integer part comparison String str1 = "abc12.5"; String str2 = "abc11.5"; // Compare should return a positive number since "12" > "11" assertThat(comparator.compare(str1, str2)).isPositive(); String str3 = "abc11.5"; String str4 = "abc12.5"; // Compare should return a negative number since "11" < "12" assertThat(comparator.compare(str3, str4)).isNegative(); // Test for multiple decimal points assertThat(comparator.compare("1.23.4", "1.23")).isGreaterThan(0); assertThat(comparator.compare("1.23", "1.23.4")).isLessThan(0); assertThat(comparator.compare("1..23", "1.23")).isLessThan(0); assertThat(comparator.compare("1.23..", "1.23")).isGreaterThan(0); assertThat(comparator.compare("", "1.23")).isLessThan(0); assertThat(comparator.compare("1.23", "")).isGreaterThan(0); assertThat(comparator.compare("1.23", "1.23")).isZero(); } @Nested class ComparatorCharacteristicTest { @Test public void reflexiveTest() { // Reflexive: a == a should always return 0 assertThat(comparator.compare("test", "test")).isZero(); assertThat(comparator.compare("", "")).isZero(); assertThat(comparator.compare("123", "123")).isZero(); assertThat(comparator.compare(null, null)).isZero(); } @Test public void symmetricTest() { // Symmetric: a > b implies b < a assertThat(comparator.compare("123", "test")).isNegative(); assertThat(comparator.compare("test", "123")).isPositive(); assertThat(comparator.compare("1.023", "1.23")).isNegative(); assertThat(comparator.compare("1.23", "1.023")).isPositive(); } @Test public void transitiveTest() { // Transitive: a > b and b > c implies a > c assertThat(comparator.compare("test2", "test1")).isPositive(); assertThat(comparator.compare("test1", "test0")).isPositive(); assertThat(comparator.compare("test2", "test0")).isPositive(); } @RepeatedTest(50) public void consistencyTest() { // Consistency: a == b should always return 0 if not changed assertThat(comparator.compare("123abc", "123abc")).isZero(); assertThat(comparator.compare("test", "test")).isZero(); assertThat(comparator.compare("123abc", "123abc")) .isEqualTo(comparator.compare("123abc", "123abc")); } @Test public void withNumbersTest() { // Numbers should be compared numerically assertThat(comparator.compare("item2", "item10")).isNegative(); assertThat(comparator.compare("item10", "item2")).isPositive(); assertThat(comparator.compare("item10", "item10")).isZero(); } @Test public void mixedContentTest() { // Mixed content comparison assertThat(comparator.compare("abc123", "abc124")).isNegative(); assertThat(comparator.compare("abc124", "abc123")).isPositive(); assertThat(comparator.compare("abc123", "abc123")).isZero(); } @Test public void nullHandlingTest() { // Null handling assertThat(comparator.compare(null, "test")).isPositive(); assertThat(comparator.compare("test", null)).isNegative(); assertThat(comparator.compare(null, null)).isZero(); } @Test public void lengthDifferenceTest() { // Length difference should affect comparison assertThat(comparator.compare("test", "testa")).isNegative(); assertThat(comparator.compare("testa", "test")).isPositive(); } @Test public void specialCharactersTest() { // Special character comparison assertThat(comparator.compare("a#1", "a#2")).isNegative(); assertThat(comparator.compare("a#2", "a#1")).isPositive(); assertThat(comparator.compare("a#1", "a#1")).isZero(); } @Test public void emptyStringsTest() { // Empty string comparison assertThat(comparator.compare("", "test")).isNegative(); assertThat(comparator.compare("test", "")).isPositive(); assertThat(comparator.compare("", "")).isZero(); } } @Nested class ComparatorEdgeTest { @Test public void pureNumbersTest() { assertThat(comparator.compare("123", "123")).isEqualTo(0); assertThat(comparator.compare("123", "124")).isLessThan(0); assertThat(comparator.compare("124", "123")).isGreaterThan(0); // Leading zeros assertThat(comparator.compare("00123", "123") > 0).isTrue(); assertThat(comparator.compare("0", "0")).isEqualTo(0); assertThat(comparator.compare("0", "0000")).isLessThan(0); assertThat(comparator.compare("0x", "0")).isGreaterThan(0); assertThat(comparator.compare("0", "1")).isLessThan(0); assertThat(comparator.compare("1", "0")).isGreaterThan(0); assertThat(comparator.compare("001", "0")).isGreaterThan(0); assertThat(comparator.compare("0x5e", "0000")).isLessThan(0); } @Test public void mumbersWithOverflowTest() { // Max long value String largeNumber1 = "9223372036854775807"; // One more than max long value String largeNumber2 = "9223372036854775808"; assertThat(comparator.compare(largeNumber1, largeNumber2)).isLessThan(0); assertThat(comparator.compare(largeNumber2, largeNumber1)).isGreaterThan(0); // large number str comparison assertThat(comparator.compare("123456789012345678901234567890", "123456789012345678901234567891")).isLessThan(0); assertThat(comparator.compare("123456789012345678901234567890", "123456789012345678901234567891")).isNotPositive(); assertThat(comparator.compare("999999999999999999999999999999", "999999999999999999999999999998")).isGreaterThan(0); assertThat(comparator.compare("999999999999999999999999999999", "999999999999999999999999999998")).isNotNegative(); assertThat(comparator.compare("9999999999999999999999999999999999999999999999", "9999999999999999999999999999999999999999999998")).isGreaterThan(0); assertThat(comparator.compare("100000000000000000000000000000", "100000000000000000000000000000")).isEqualTo(0); // This specific case is to test the overflow for a real-world scenario assertThat(comparator.compare("5InykKCe", "1710683457874") < 0).isTrue(); assertThat(comparator.compare("5InykKce", "1717477435943") > 0).isFalse(); assertThat(comparator.compare("0", "9999999999999999999999999999999999999999999998")).isLessThan(0); assertThat(comparator.compare("9999999999999999999999999999999999999999999998", "0")).isGreaterThan(0); } @Test public void decimalStringsTest() { assertThat(comparator.compare("123.45", "123.45")).isEqualTo(0); assertThat(comparator.compare("123.45", "123.46")).isLessThan(0); assertThat(comparator.compare("123.46", "123.45")).isGreaterThan(0); // Decimal equivalence assertThat(comparator.compare("123.5", "123.50")).isLessThan(0); assertThat(comparator.compare("123.0005", "123.00050")).isLessThan(0); } @Test public void lettersAndNumbersTest() { assertThat(comparator.compare("abc123", "abc123")).isEqualTo(0); assertThat(comparator.compare("abc123", "abc124")).isLessThan(0); assertThat(comparator.compare("abc124", "abc123")).isGreaterThan(0); assertThat(comparator.compare("abc123", "abcd123")).isLessThan(0); } @Test public void pureLettersTest() { assertThat(comparator.compare("abc", "abc")).isEqualTo(0); assertThat(comparator.compare("abc", "abcd")).isLessThan(0); assertThat(comparator.compare("abcd", "abc")).isGreaterThan(0); // Case sensitivity assertThat(comparator.compare("ABC", "abc")).isLessThan(0); } @Test public void dateStringsTest() { assertThat(comparator.compare("2024-08-29", "2024-08-29")).isEqualTo(0); assertThat(comparator.compare("2024-08-29", "2024-08-30")).isLessThan(0); assertThat(comparator.compare("2024-08-30", "2024-08-29")).isGreaterThan(0); // Time comparison assertThat(comparator.compare("2024-08-29T12:00:00.000Z", "2024-08-29T12:00:00.001Z")) .isLessThan(0); assertThat(comparator.compare("2024-08-29T12:00:00.001Z", "2024-08-29T12:00:00.000Z")) .isGreaterThan(0); assertThat(comparator.compare("2024-08-29T12:00:00.000Z", "2024-08-29T12:00:01.000Z")) .isLessThan(0); assertThat(comparator.compare("2024-08-29T12:00:01.000Z", "2024-08-29T12:00:00.000Z")) .isGreaterThan(0); assertThat(comparator.compare("2024-08-29T12:00:00.000Z", "2024-08-29T12:01:00.000Z")) .isLessThan(0); assertThat(comparator.compare("2024-08-29T12:01:00.000Z", "2024-08-29T12:00:00.000Z")) .isGreaterThan(0); assertThat(comparator.compare("2024-08-29T12:00:00.000Z", "2024-08-29T13:00:00.000Z")) .isLessThan(0); assertThat(comparator.compare("2024-08-29T13:00:00.000Z", "2024-08-29T12:00:00.000Z")) .isGreaterThan(0); assertThat(comparator.compare("2024-08-29T12:00:00.000Z", "2024-08-30T12:00:00.000Z")) .isLessThan(0); assertThat(comparator.compare("2024-08-30T12:00:00.000Z", "2024-08-29T12:00:00.000Z")) .isGreaterThan(0); } @Test public void booleanStringsTest() { assertThat(comparator.compare("true", "false")).isGreaterThan(0); assertThat(comparator.compare("false", "true")).isLessThan(0); assertThat(comparator.compare("true", "true")).isEqualTo(0); assertThat(comparator.compare("false", "false")).isEqualTo(0); } @Test public void complexMixedStringsTest() { assertThat(comparator.compare("abc123xyz456", "abc123xyz456")).isEqualTo(0); assertThat(comparator.compare("abc123xyz456", "abc124xyz456")).isLessThan(0); assertThat(comparator.compare("abc124xyz456", "abc123xyz456")).isGreaterThan(0); assertThat(comparator.compare("abc123xyz456", "abc123xyz457")).isLessThan(0); } } } ================================================ FILE: api/src/test/java/run/halo/app/extension/index/MultiValueBuilderTest.java ================================================ package run.halo.app.extension.index; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Collections; import org.junit.jupiter.api.Test; import run.halo.app.extension.FakeExtension; class MultiValueBuilderTest { @Test void throwIfNoNameProvided() { assertThrows(IllegalArgumentException.class, () -> new MultiValueBuilder(null, String.class) ); } @Test void throwIfNoKeyTypeProvided() { assertThrows(IllegalArgumentException.class, () -> new MultiValueBuilder("metadata.name", null) ); } @Test void throwIfNoIndexFuncProvided() { var builder = new MultiValueBuilder("metadata.name", String.class); assertThrows(IllegalArgumentException.class, builder::build); } @Test void shouldBuildCorrectly() { var builder = new MultiValueBuilder("metadata.name", String.class) .indexFunc(e -> Collections.singleton(e.getMetadata().getName())); var indexSpec = builder.build(); assertNotNull(indexSpec); assertInstanceOf(MultiValueIndexSpec.class, indexSpec); assertEquals(String.class, indexSpec.getKeyType()); assertEquals("metadata.name", indexSpec.getName()); assertFalse(indexSpec.isUnique()); assertTrue(indexSpec.isNullable()); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/index/SingleValueBuilderTest.java ================================================ package run.halo.app.extension.index; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; import run.halo.app.extension.FakeExtension; class SingleValueBuilderTest { @Test void throwIfNoNameProvided() { assertThrows(IllegalArgumentException.class, () -> new SingleValueBuilder(null, String.class) ); } @Test void throwIfNoKeyTypeProvided() { assertThrows(IllegalArgumentException.class, () -> new SingleValueBuilder("metadata.name", null) ); } @Test void throwIfNoIndexFuncProvided() { var builder = new SingleValueBuilder("metadata.name", String.class); assertThrows(IllegalArgumentException.class, builder::build); } @Test void shouldBuildCorrectly() { var builder = new SingleValueBuilder("metadata.name", String.class) .indexFunc(e -> e.getMetadata().getName()); var indexSpec = builder.build(); assertNotNull(indexSpec); assertInstanceOf(SingleValueIndexSpec.class, indexSpec); assertEquals(String.class, indexSpec.getKeyType()); assertEquals("metadata.name", indexSpec.getName()); assertFalse(indexSpec.isUnique()); assertTrue(indexSpec.isNullable()); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/index/UnknownKeyTest.java ================================================ package run.halo.app.extension.index; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Objects; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; class UnknownKeyTest { @ParameterizedTest @CsvSource({ "a, a", "b, b", ",", "abc, abc", "2025, 2025", "true, true", "2025-10-20, 2025-10-20", "特殊字符, 特殊字符" }) void shouldEqualsWorkCorrectly(String key1, String key2) { assertEquals(new UnknownKey(key1), new UnknownKey(key2)); assertEquals(new UnknownKey(null), new UnknownKey(null)); } @ParameterizedTest @CsvSource( { "a, a, 0", "a, b, -1", "b, a, 1", "abc, abd, -1", "abd, abc, 1", "0.1, 0.1, 0", "0.1, 0.2, -1", "0.2, 0.1, 1", "true, true, 0", "false, true, -1", "true, false, 1", "-0.1, -0.1, 0", "-1, -1, 0", "2025, 2025, 0", "2025, 2026, -1", "2026, 2025, 1", "2025-10-20, 2025-10-20, 0", "2025-10-20, 2025-10-21, -1", "2025-10-21, 2025-10-20, 1", "特殊字符A, 特殊字符B, -1", "特殊字符B, 特殊字符A, 1" } ) void shouldCompareCorrectly(String key1, String key2, int expected) { var compare = new UnknownKey(key1).compareTo(new UnknownKey(key2)); assertTrue(Objects.equals(expected, compare) || expected * compare > 0); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/index/query/QueriesTest.java ================================================ package run.halo.app.extension.index.query; import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.List; import org.junit.jupiter.api.Test; class QueriesTest { @Test void shouldBuildAndConditionStaticMethod() { var condition = Queries.and( Queries.equal("status", "active"), Queries.greaterThan("age", 18) ); assertEquals("(status = active AND age > 18)", condition.toString()); } @Test void shouldBuildOrConditionStaticMethod() { var condition = Queries.or( Queries.lessThan("price", 50, false), Queries.equal("category", "Books") ); assertEquals("(price < 50 OR category = Books)", condition.toString()); } @Test void shouldBuildNotConditionStaticMethod() { var condition = Queries.not(Queries.equal("role", "Admin")); assertEquals("role != Admin", condition.toString()); } @Test void shouldBuildBetweenCondition() { var condition = Queries.between("age", 18, true, 30, false); assertEquals("age BETWEEN [18, 30)", condition.toString()); } @Test void shouldBuildEqualCondition() { var condition = Queries.equal("name", "John"); assertEquals("name = John", condition.toString()); } @Test void shouldBuildInCondition() { var condition = Queries.in("status", "active", "pending"); assertEquals("status IN (active, pending)", condition.toString()); condition = Queries.in("status", List.of("active", "pending")); assertEquals("status IN (active, pending)", condition.toString()); } @Test void shouldRefineInConditionWithSingleValueToEqualCondition() { var condition = Queries.in("status", "active"); assertEquals("status = active", condition.toString()); } @Test void shouldBuildGreaterThanCondition() { var condition = Queries.greaterThan("score", 85); assertEquals("score > 85", condition.toString()); } @Test void shouldBuildLessThanCondition() { var condition = Queries.lessThan("price", 100, true); assertEquals("price <= 100", condition.toString()); } @Test void shouldBuildLessThanConditionExclusive() { var condition = Queries.lessThan("price", 100); assertEquals("price < 100", condition.toString()); } @Test void shouldBuildEmptyCondition() { var condition = Queries.empty(); assertEquals("EMPTY", condition.toString()); } @Test void shouldBuildAllCondition() { var condition = Queries.all("tags"); assertEquals("ALL tags", condition.toString()); } @Test void shouldBuildNotEqualCondition() { var condition = Queries.notEqual("name", "Alice"); assertEquals("name != Alice", condition.toString()); } @Test void shouldBuildNotBetweenCondition() { var condition = Queries.between("age", 20, false, 40, true).not(); assertEquals("age NOT BETWEEN [20, 40)", condition.toString()); } @Test void shouldBuildNotInCondition() { var condition = Queries.in("status", "inactive", "banned").not(); assertEquals("status NOT IN (inactive, banned)", condition.toString()); } @Test void shouldBuildNotAllCondition() { var condition = Queries.all("categories").not(); assertEquals("NONE categories", condition.toString()); } @Test void shouldBuildStartsWithCondition() { var condition = Queries.startsWith("username", "admin"); assertEquals("username STARTS WITH admin", condition.toString()); } @Test void shouldBuildNotStartsWithCondition() { var condition = Queries.startsWith("username", "guest").not(); assertEquals("username NOT STARTS WITH guest", condition.toString()); } @Test void shouldBuildEndsWithCondition() { var condition = Queries.endsWith("email", "@example.com"); assertEquals("email ENDS WITH @example.com", condition.toString()); } @Test void shouldBuildNotEndsWithCondition() { var condition = Queries.endsWith("email", "@spam.com").not(); assertEquals("email NOT ENDS WITH @spam.com", condition.toString()); } @Test void shouldBuildContainsCondition() { var condition = Queries.contains("description", "important"); assertEquals("description CONTAINS important", condition.toString()); } @Test void shouldBuildNotContainsCondition() { var condition = Queries.contains("notes", "confidential").not(); assertEquals("notes NOT CONTAINS confidential", condition.toString()); } @Test void shouldBuildAndCondition() { var condition = Queries.equal("status", "active") .and(Queries.greaterThan("age", 18)); assertEquals("(status = active AND age > 18)", condition.toString()); } @Test void shouldBuildOrCondition() { var condition = Queries.lessThan("price", 50, false) .or(Queries.equal("category", "Books")); assertEquals("(price < 50 OR category = Books)", condition.toString()); } @Test void shouldBuildNotCondition() { var condition = Queries.equal("role", "Admin").not(); assertEquals("role != Admin", condition.toString()); } @Test void shouldBuildLabelExistsCondition() { var condition = Queries.labelExists("premiumUser"); assertEquals("EXISTS metadata.labels['premiumUser']", condition.toString()); } @Test void shouldBuildLabelNotExistsCondition() { var condition = Queries.labelExists("betaTester").not(); assertEquals("NOT EXISTS metadata.labels['betaTester']", condition.toString()); } @Test void shouldBuildLabelEqualCondition() { var condition = Queries.labelEqual("region", "CN"); assertEquals("metadata.labels['region'] = 'CN'", condition.toString()); } @Test void shouldBuildLabelNotEqualCondition() { var condition = Queries.labelEqual("tier", "gold").not(); assertEquals("metadata.labels['tier'] <> 'gold'", condition.toString()); } @Test void shouldBuildLabelInCondition() { var condition = Queries.labelIn("env", List.of("prod", "staging")); assertEquals("metadata.labels['env'] IN ('prod', 'staging')", condition.toString()); } @Test void shouldBuildLabelNotInCondition() { var condition = Queries.labelIn("version", List.of("v1", "v2")).not(); assertEquals("metadata.labels['version'] NOT IN ('v1', 'v2')", condition.toString()); } @Test void shouldBuildChainedConditions() { var condition = Queries.equal("name", "Bob") .and(Queries.greaterThan("age", 25)) .or(Queries.in("status", "active", "pending")); assertEquals( "((name = Bob AND age > 25) OR status IN (active, pending))", condition.toString() ); } @Test void shouldBuildComplexCondition() { var condition = Queries.between("salary", 50000, true, 100000, false) .and(Queries.equal("department", "Engineering").not()) .or(Queries.in("role", "Manager", "Director")); assertEquals(""" ((salary BETWEEN [50000, 100000) AND department != Engineering) OR role IN (Manager,\ Director))\ """, condition.toString() ); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/indexer/DefaultIndexEngineTest.java ================================================ package run.halo.app.extension.indexer; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.PriorityQueue; import org.junit.jupiter.api.Test; class DefaultIndexEngineTest { @Test void priorityQueueTest() { int n = 3; var pq = new PriorityQueue<>(n, Comparator.naturalOrder().reversed()); List.of(5, 4, 3, 2, 1).forEach(i -> { pq.offer(i); if (pq.size() > n) { pq.poll(); } }); var result = new ArrayList<>(); while (!pq.isEmpty()) { result.addFirst(pq.poll()); } result.forEach(System.out::println); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/indexer/LabelIndexImplTest.java ================================================ package run.halo.app.extension.indexer; import java.util.concurrent.ConcurrentSkipListMap; import org.junit.jupiter.api.Test; class LabelIndexImplTest { @Test void stringPrefixTest() { var map = new ConcurrentSkipListMap(); map.put("a@b", "1"); map.put("a@c", "2"); map.put("a@cdefg", "2"); map.put("a@d", "3"); map.put("b@d", "4"); var subMap = map.subMap("a@", "a@" + Character.MAX_VALUE); subMap.sequencedKeySet().forEach(System.out::println); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/router/selector/LabelSelectorTest.java ================================================ package run.halo.app.extension.router.selector; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; /** * Tests for {@link LabelSelector}. * * @author guqing * @since 2.17.0 */ class LabelSelectorTest { @Test void builderTest() { var labelSelector = LabelSelector.builder() .eq("a", "v1") .in("b", "v2", "v3") .build(); assertThat(labelSelector.toString()) .isEqualTo(""" (metadata.labels['a'] = 'v1' AND metadata.labels['b'] IN ('v2', 'v3'))\ """); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/router/selector/OperatorTest.java ================================================ package run.halo.app.extension.router.selector; import static org.junit.jupiter.api.Assertions.assertEquals; import static run.halo.app.extension.router.selector.Operator.Equals; import static run.halo.app.extension.router.selector.Operator.Exist; import static run.halo.app.extension.router.selector.Operator.IN; import static run.halo.app.extension.router.selector.Operator.NotEquals; import static run.halo.app.extension.router.selector.Operator.NotExist; import java.util.List; import java.util.Set; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; @Slf4j class OperatorTest { @Test void shouldConvertCorrectly() { record TestCase(String source, Operator converter, SelectorCriteria expected) { } List.of( new TestCase("", Equals, null), new TestCase("=", Equals, null), new TestCase("=value", Equals, null), new TestCase("name=", Equals, null), new TestCase("name=value", Equals, new SelectorCriteria("name", Equals, Set.of("value"))), new TestCase("name=v", Equals, new SelectorCriteria("name", Equals, Set.of("v"))), new TestCase("", NotEquals, null), new TestCase("=", NotEquals, null), new TestCase("!", NotEquals, null), new TestCase("!=", NotEquals, null), new TestCase("!=value", NotEquals, null), new TestCase("name!=", NotEquals, null), new TestCase("name!=value", NotEquals, new SelectorCriteria("name", NotEquals, Set.of("value"))), new TestCase("", NotExist, null), new TestCase("!", NotExist, null), new TestCase("!name", NotExist, new SelectorCriteria("name", NotExist, Set.of())), new TestCase("name", NotExist, null), new TestCase("na!me", NotExist, null), new TestCase("name!", NotExist, null), new TestCase("name!=1", NotEquals, new SelectorCriteria("name", NotEquals, Set.of("1"))), new TestCase("name!=12", NotEquals, new SelectorCriteria("name", NotEquals, Set.of("12"))), new TestCase("name", Exist, new SelectorCriteria("name", Exist, Set.of())), new TestCase("", Exist, null), new TestCase("!", Exist, new SelectorCriteria("!", Exist, Set.of())), new TestCase("a", Exist, new SelectorCriteria("a", Exist, Set.of())), new TestCase("name", IN, null), new TestCase("name=(fake-name)", IN, new SelectorCriteria("name", IN, Set.of("fake-name"))), new TestCase("name=(first-name,second-name)", IN, new SelectorCriteria("name", IN, Set.of("first-name", "second-name"))) ).forEach(testCase -> { log.debug("Testing: {}", testCase); assertEquals(testCase.expected(), testCase.converter().convert(testCase.source())); }); } } ================================================ FILE: api/src/test/java/run/halo/app/extension/router/selector/SelectorConverterTest.java ================================================ package run.halo.app.extension.router.selector; import static org.junit.jupiter.api.Assertions.assertEquals; import static run.halo.app.extension.router.selector.Operator.Equals; import java.util.List; import java.util.Set; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; @Slf4j class SelectorConverterTest { SelectorConverter converter = new SelectorConverter(); @Test void shouldConvertCorrectly() { record TestCase(String selector, SelectorCriteria expected) { } List.of( new TestCase("", null), new TestCase("name=value", new SelectorCriteria("name", Equals, Set.of("value"))), new TestCase("name!=value", new SelectorCriteria("name", Operator.NotEquals, Set.of("value"))), new TestCase("name", new SelectorCriteria("name", Operator.Exist, Set.of())), new TestCase("!name", new SelectorCriteria("name", Operator.NotExist, Set.of())), new TestCase("name", new SelectorCriteria("name", Operator.Exist, Set.of())), new TestCase("name!=", new SelectorCriteria("name!=", Operator.Exist, Set.of())), new TestCase("==", new SelectorCriteria("==", Operator.Exist, Set.of())) ).forEach(testCase -> { log.debug("Testing: {}", testCase); assertEquals(testCase.expected, converter.convert(testCase.selector)); }); } } ================================================ FILE: api/src/test/java/run/halo/app/infra/utils/GenericClassUtilsTest.java ================================================ package run.halo.app.infra.utils; import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListResult; class GenericClassUtilsTest { @Test void generateConcreteClass() { var clazz = GenericClassUtils.generateConcreteClass(ListResult.class, Post.class, () -> Post.class.getName() + "List"); assertEquals("run.halo.app.core.extension.content.PostList", clazz.getName()); assertEquals("run.halo.app.core.extension.content", clazz.getPackageName()); } } ================================================ FILE: api/src/test/java/run/halo/app/infra/utils/JsonUtilsTest.java ================================================ package run.halo.app.infra.utils; import static org.assertj.core.api.Assertions.assertThat; import java.time.Instant; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.Test; /** * Tests for {@link JsonUtils}. * * @author guqing * @since 2.0.0 */ public class JsonUtilsTest { @Test public void serializerTime() { Instant now = Instant.now(); String instantStr = JsonUtils.objectToJson(now); assertThat(instantStr).isNotNull(); String localDateTimeStr = JsonUtils.objectToJson(LocalDateTime.now()); assertThat(localDateTimeStr).isNotNull(); } @Test @SuppressWarnings("rawtypes") public void deserializerArrayString() { String s = "[\"hello\", \"world\"]"; List list = JsonUtils.jsonToObject(s, List.class); assertThat(list).isEqualTo(List.of("hello", "world")); } } ================================================ FILE: api/src/test/java/run/halo/app/infra/utils/PathUtilsTest.java ================================================ package run.halo.app.infra.utils; import static org.assertj.core.api.Assertions.assertThat; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Test; /** * Tests for {@link PathUtils}. * * @author guqing * @since 2.0.0 */ class PathUtilsTest { @Test void combinePath() { Map combinePathCases = getCombinePathCases(); combinePathCases.forEach((segments, expected) -> { String s = PathUtils.combinePath(segments.split(",")); assertThat(s).isEqualTo(expected); }); String s = PathUtils.combinePath("a", "", "c"); assertThat(s).isEqualTo("/a/c"); } private Map getCombinePathCases() { Map combinePathCases = new HashMap<>(); combinePathCases.put("a,b,c", "/a/b/c"); combinePathCases.put("/a,b,c", "/a/b/c"); combinePathCases.put("/a,b/,c", "/a/b/c"); combinePathCases.put("/a,/b/,c", "/a/b/c"); return combinePathCases; } @Test void appendPathSeparatorIfMissing() { String s = PathUtils.appendPathSeparatorIfMissing("a"); assertThat(s).isEqualTo("a/"); s = PathUtils.appendPathSeparatorIfMissing("a/"); assertThat(s).isEqualTo("a/"); s = PathUtils.appendPathSeparatorIfMissing(null); assertThat(s).isEqualTo(null); } @Test void simplifyPathPattern() { assertThat(PathUtils.simplifyPathPattern("/a/b/c")).isEqualTo("/a/b/c"); assertThat(PathUtils.simplifyPathPattern("/a/{b}/c")).isEqualTo("/a/{b}/c"); assertThat(PathUtils.simplifyPathPattern("/a/{b}/*")).isEqualTo("/a/{b}/*"); assertThat(PathUtils.simplifyPathPattern("/archives/{year:\\d{4}}/{month:\\d{2}}")) .isEqualTo("/archives/{year}/{month}"); assertThat(PathUtils.simplifyPathPattern("/archives/{year:\\d{4}}/{slug}")) .isEqualTo("/archives/{year}/{slug}"); assertThat(PathUtils.simplifyPathPattern("/archives/{year:\\d{4}}/page/{page:\\d+}")) .isEqualTo("/archives/{year}/page/{page}"); } @Test void isAbsoluteUri() { String[] absoluteUris = new String[] { "ftp://ftp.is.co.za/rfc/rfc1808.txt", "http://www.ietf.org/rfc/rfc2396.txt", "ldap://[2001:db8::7]/c=GB?objectClass?one", "mailto:John.Doe@example.com", "news:comp.infosystems.www.servers.unix", "tel:+1-816-555-1212", "telnet://192.0.2.16:80/", "urn:oasis:names:specification:docbook:dtd:xml:4.1.2", "data:text/vnd-example+xyz;foo=bar;base64,R0lGODdh", "irc://irc.example.com:6667/#some-channel", "ircs://irc.example.com:6667/#some-channel", "irc6://irc.example.com:6667/#some-channel" }; for (String uri : absoluteUris) { assertThat(PathUtils.isAbsoluteUri(uri)).isTrue(); } String[] paths = new String[] { "//example.com/path/resource.txt", "/path/resource.txt", "path/resource.txt", "../resource.txt", "./resource.txt", "resource.txt", "#fragment", "", null }; for (String path : paths) { assertThat(PathUtils.isAbsoluteUri(path)).isFalse(); } } } ================================================ FILE: api-docs/openapi/v3_0/aggregated.json ================================================ { "openapi": "3.0.1", "info": { "title": "Halo", "version": "2.23.0-SNAPSHOT" }, "servers": [ { "url": "http://localhost:8091", "description": "Generated server url" } ], "security": [ { "basicAuth": [], "bearerAuth": [] } ], "paths": { "/api/v1alpha1/annotationsettings": { "get": { "description": "List AnnotationSetting", "operationId": "listAnnotationSetting", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AnnotationSettingList" } } }, "description": "Response annotationsettings" } }, "tags": [ "AnnotationSettingV1alpha1" ] }, "post": { "description": "Create AnnotationSetting", "operationId": "createAnnotationSetting", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AnnotationSetting" } } }, "description": "Fresh annotationsetting" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AnnotationSetting" } } }, "description": "Response annotationsettings created just now" } }, "tags": [ "AnnotationSettingV1alpha1" ] } }, "/api/v1alpha1/annotationsettings/{name}": { "delete": { "description": "Delete AnnotationSetting", "operationId": "deleteAnnotationSetting", "parameters": [ { "description": "Name of annotationsetting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response annotationsetting deleted just now" } }, "tags": [ "AnnotationSettingV1alpha1" ] }, "get": { "description": "Get AnnotationSetting", "operationId": "getAnnotationSetting", "parameters": [ { "description": "Name of annotationsetting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AnnotationSetting" } } }, "description": "Response single annotationsetting" } }, "tags": [ "AnnotationSettingV1alpha1" ] }, "patch": { "description": "Patch AnnotationSetting", "operationId": "patchAnnotationSetting", "parameters": [ { "description": "Name of annotationsetting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AnnotationSetting" } } }, "description": "Response annotationsetting patched just now" } }, "tags": [ "AnnotationSettingV1alpha1" ] }, "put": { "description": "Update AnnotationSetting", "operationId": "updateAnnotationSetting", "parameters": [ { "description": "Name of annotationsetting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AnnotationSetting" } } }, "description": "Updated annotationsetting" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AnnotationSetting" } } }, "description": "Response annotationsettings updated just now" } }, "tags": [ "AnnotationSettingV1alpha1" ] } }, "/api/v1alpha1/configmaps": { "get": { "description": "List ConfigMap", "operationId": "listConfigMap", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMapList" } } }, "description": "Response configmaps" } }, "tags": [ "ConfigMapV1alpha1" ] }, "post": { "description": "Create ConfigMap", "operationId": "createConfigMap", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "Fresh configmap" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "Response configmaps created just now" } }, "tags": [ "ConfigMapV1alpha1" ] } }, "/api/v1alpha1/configmaps/{name}": { "delete": { "description": "Delete ConfigMap", "operationId": "deleteConfigMap", "parameters": [ { "description": "Name of configmap", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response configmap deleted just now" } }, "tags": [ "ConfigMapV1alpha1" ] }, "get": { "description": "Get ConfigMap", "operationId": "getConfigMap", "parameters": [ { "description": "Name of configmap", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "Response single configmap" } }, "tags": [ "ConfigMapV1alpha1" ] }, "patch": { "description": "Patch ConfigMap", "operationId": "patchConfigMap", "parameters": [ { "description": "Name of configmap", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "Response configmap patched just now" } }, "tags": [ "ConfigMapV1alpha1" ] }, "put": { "description": "Update ConfigMap", "operationId": "updateConfigMap", "parameters": [ { "description": "Name of configmap", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "Updated configmap" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "Response configmaps updated just now" } }, "tags": [ "ConfigMapV1alpha1" ] } }, "/api/v1alpha1/menuitems": { "get": { "description": "List MenuItem", "operationId": "listMenuItem", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuItemList" } } }, "description": "Response menuitems" } }, "tags": [ "MenuItemV1alpha1" ] }, "post": { "description": "Create MenuItem", "operationId": "createMenuItem", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuItem" } } }, "description": "Fresh menuitem" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuItem" } } }, "description": "Response menuitems created just now" } }, "tags": [ "MenuItemV1alpha1" ] } }, "/api/v1alpha1/menuitems/{name}": { "delete": { "description": "Delete MenuItem", "operationId": "deleteMenuItem", "parameters": [ { "description": "Name of menuitem", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response menuitem deleted just now" } }, "tags": [ "MenuItemV1alpha1" ] }, "get": { "description": "Get MenuItem", "operationId": "getMenuItem", "parameters": [ { "description": "Name of menuitem", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuItem" } } }, "description": "Response single menuitem" } }, "tags": [ "MenuItemV1alpha1" ] }, "patch": { "description": "Patch MenuItem", "operationId": "patchMenuItem", "parameters": [ { "description": "Name of menuitem", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuItem" } } }, "description": "Response menuitem patched just now" } }, "tags": [ "MenuItemV1alpha1" ] }, "put": { "description": "Update MenuItem", "operationId": "updateMenuItem", "parameters": [ { "description": "Name of menuitem", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuItem" } } }, "description": "Updated menuitem" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuItem" } } }, "description": "Response menuitems updated just now" } }, "tags": [ "MenuItemV1alpha1" ] } }, "/api/v1alpha1/menus": { "get": { "description": "List Menu", "operationId": "listMenu", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuList" } } }, "description": "Response menus" } }, "tags": [ "MenuV1alpha1" ] }, "post": { "description": "Create Menu", "operationId": "createMenu", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Menu" } } }, "description": "Fresh menu" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Menu" } } }, "description": "Response menus created just now" } }, "tags": [ "MenuV1alpha1" ] } }, "/api/v1alpha1/menus/{name}": { "delete": { "description": "Delete Menu", "operationId": "deleteMenu", "parameters": [ { "description": "Name of menu", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response menu deleted just now" } }, "tags": [ "MenuV1alpha1" ] }, "get": { "description": "Get Menu", "operationId": "getMenu", "parameters": [ { "description": "Name of menu", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Menu" } } }, "description": "Response single menu" } }, "tags": [ "MenuV1alpha1" ] }, "patch": { "description": "Patch Menu", "operationId": "patchMenu", "parameters": [ { "description": "Name of menu", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Menu" } } }, "description": "Response menu patched just now" } }, "tags": [ "MenuV1alpha1" ] }, "put": { "description": "Update Menu", "operationId": "updateMenu", "parameters": [ { "description": "Name of menu", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Menu" } } }, "description": "Updated menu" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Menu" } } }, "description": "Response menus updated just now" } }, "tags": [ "MenuV1alpha1" ] } }, "/api/v1alpha1/rolebindings": { "get": { "description": "List RoleBinding", "operationId": "listRoleBinding", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleBindingList" } } }, "description": "Response rolebindings" } }, "tags": [ "RoleBindingV1alpha1" ] }, "post": { "description": "Create RoleBinding", "operationId": "createRoleBinding", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleBinding" } } }, "description": "Fresh rolebinding" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleBinding" } } }, "description": "Response rolebindings created just now" } }, "tags": [ "RoleBindingV1alpha1" ] } }, "/api/v1alpha1/rolebindings/{name}": { "delete": { "description": "Delete RoleBinding", "operationId": "deleteRoleBinding", "parameters": [ { "description": "Name of rolebinding", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response rolebinding deleted just now" } }, "tags": [ "RoleBindingV1alpha1" ] }, "get": { "description": "Get RoleBinding", "operationId": "getRoleBinding", "parameters": [ { "description": "Name of rolebinding", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleBinding" } } }, "description": "Response single rolebinding" } }, "tags": [ "RoleBindingV1alpha1" ] }, "patch": { "description": "Patch RoleBinding", "operationId": "patchRoleBinding", "parameters": [ { "description": "Name of rolebinding", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleBinding" } } }, "description": "Response rolebinding patched just now" } }, "tags": [ "RoleBindingV1alpha1" ] }, "put": { "description": "Update RoleBinding", "operationId": "updateRoleBinding", "parameters": [ { "description": "Name of rolebinding", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleBinding" } } }, "description": "Updated rolebinding" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleBinding" } } }, "description": "Response rolebindings updated just now" } }, "tags": [ "RoleBindingV1alpha1" ] } }, "/api/v1alpha1/roles": { "get": { "description": "List Role", "operationId": "listRole", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleList" } } }, "description": "Response roles" } }, "tags": [ "RoleV1alpha1" ] }, "post": { "description": "Create Role", "operationId": "createRole", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Role" } } }, "description": "Fresh role" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Role" } } }, "description": "Response roles created just now" } }, "tags": [ "RoleV1alpha1" ] } }, "/api/v1alpha1/roles/{name}": { "delete": { "description": "Delete Role", "operationId": "deleteRole", "parameters": [ { "description": "Name of role", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response role deleted just now" } }, "tags": [ "RoleV1alpha1" ] }, "get": { "description": "Get Role", "operationId": "getRole", "parameters": [ { "description": "Name of role", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Role" } } }, "description": "Response single role" } }, "tags": [ "RoleV1alpha1" ] }, "patch": { "description": "Patch Role", "operationId": "patchRole", "parameters": [ { "description": "Name of role", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Role" } } }, "description": "Response role patched just now" } }, "tags": [ "RoleV1alpha1" ] }, "put": { "description": "Update Role", "operationId": "updateRole", "parameters": [ { "description": "Name of role", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Role" } } }, "description": "Updated role" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Role" } } }, "description": "Response roles updated just now" } }, "tags": [ "RoleV1alpha1" ] } }, "/api/v1alpha1/secrets": { "get": { "description": "List Secret", "operationId": "listSecret", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SecretList" } } }, "description": "Response secrets" } }, "tags": [ "SecretV1alpha1" ] }, "post": { "description": "Create Secret", "operationId": "createSecret", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Secret" } } }, "description": "Fresh secret" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Secret" } } }, "description": "Response secrets created just now" } }, "tags": [ "SecretV1alpha1" ] } }, "/api/v1alpha1/secrets/{name}": { "delete": { "description": "Delete Secret", "operationId": "deleteSecret", "parameters": [ { "description": "Name of secret", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response secret deleted just now" } }, "tags": [ "SecretV1alpha1" ] }, "get": { "description": "Get Secret", "operationId": "getSecret", "parameters": [ { "description": "Name of secret", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Secret" } } }, "description": "Response single secret" } }, "tags": [ "SecretV1alpha1" ] }, "patch": { "description": "Patch Secret", "operationId": "patchSecret", "parameters": [ { "description": "Name of secret", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Secret" } } }, "description": "Response secret patched just now" } }, "tags": [ "SecretV1alpha1" ] }, "put": { "description": "Update Secret", "operationId": "updateSecret", "parameters": [ { "description": "Name of secret", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Secret" } } }, "description": "Updated secret" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Secret" } } }, "description": "Response secrets updated just now" } }, "tags": [ "SecretV1alpha1" ] } }, "/api/v1alpha1/settings": { "get": { "description": "List Setting", "operationId": "listSetting", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SettingList" } } }, "description": "Response settings" } }, "tags": [ "SettingV1alpha1" ] }, "post": { "description": "Create Setting", "operationId": "createSetting", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "Fresh setting" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "Response settings created just now" } }, "tags": [ "SettingV1alpha1" ] } }, "/api/v1alpha1/settings/{name}": { "delete": { "description": "Delete Setting", "operationId": "deleteSetting", "parameters": [ { "description": "Name of setting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response setting deleted just now" } }, "tags": [ "SettingV1alpha1" ] }, "get": { "description": "Get Setting", "operationId": "getSetting", "parameters": [ { "description": "Name of setting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "Response single setting" } }, "tags": [ "SettingV1alpha1" ] }, "patch": { "description": "Patch Setting", "operationId": "patchSetting", "parameters": [ { "description": "Name of setting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "Response setting patched just now" } }, "tags": [ "SettingV1alpha1" ] }, "put": { "description": "Update Setting", "operationId": "updateSetting", "parameters": [ { "description": "Name of setting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "Updated setting" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "Response settings updated just now" } }, "tags": [ "SettingV1alpha1" ] } }, "/api/v1alpha1/users": { "get": { "description": "List User", "operationId": "listUser", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserList" } } }, "description": "Response users" } }, "tags": [ "UserV1alpha1" ] }, "post": { "description": "Create User", "operationId": "createUser", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "Fresh user" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "Response users created just now" } }, "tags": [ "UserV1alpha1" ] } }, "/api/v1alpha1/users/{name}": { "delete": { "description": "Delete User", "operationId": "deleteUser", "parameters": [ { "description": "Name of user", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response user deleted just now" } }, "tags": [ "UserV1alpha1" ] }, "get": { "description": "Get User", "operationId": "getUser", "parameters": [ { "description": "Name of user", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "Response single user" } }, "tags": [ "UserV1alpha1" ] }, "patch": { "description": "Patch User", "operationId": "patchUser", "parameters": [ { "description": "Name of user", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "Response user patched just now" } }, "tags": [ "UserV1alpha1" ] }, "put": { "description": "Update User", "operationId": "updateUser", "parameters": [ { "description": "Name of user", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "Updated user" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "Response users updated just now" } }, "tags": [ "UserV1alpha1" ] } }, "/apis/api.console.halo.run/v1alpha1/attachments": { "get": { "operationId": "SearchAttachments", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Filter attachments without group. This parameter will ignore group parameter.", "in": "query", "name": "ungrouped", "schema": { "type": "boolean" } }, { "description": "Keyword for searching.", "in": "query", "name": "keyword", "schema": { "type": "string" } }, { "description": "Acceptable media types.", "in": "query", "name": "accepts", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AttachmentList" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/attachments/-/upload-from-url": { "post": { "operationId": "ExternalTransferAttachment", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UploadFromUrlRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/attachments/upload": { "post": { "operationId": "UploadAttachment", "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/IUploadRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/auth-providers": { "get": { "description": "Lists all auth providers", "operationId": "listAuthProviders", "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ListedAuthProvider" } } } }, "description": "default response" } }, "tags": [ "AuthProviderV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/auth-providers/{name}/disable": { "put": { "description": "Disables an auth provider", "operationId": "disableAuthProvider", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "default response" } }, "tags": [ "AuthProviderV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/auth-providers/{name}/enable": { "put": { "description": "Enables an auth provider", "operationId": "enableAuthProvider", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "default response" } }, "tags": [ "AuthProviderV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/comments": { "get": { "description": "List comments.", "operationId": "ListComments", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Comments filtered by keyword.", "in": "query", "name": "keyword", "schema": { "type": "string" } }, { "description": "Commenter kind.", "in": "query", "name": "ownerKind", "schema": { "type": "string" } }, { "description": "Commenter name.", "in": "query", "name": "ownerName", "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedCommentList" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Console" ] }, "post": { "description": "Create a comment.", "operationId": "CreateComment", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommentRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/comments/{name}/reply": { "post": { "description": "Create a reply.", "operationId": "CreateReply", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReplyRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/indices/-/rebuild": { "post": { "description": "Rebuild all indices", "operationId": "RebuildAllIndices", "responses": {}, "tags": [ "IndicesV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/notifiers/{name}/sender-config": { "get": { "description": "Fetch sender config of notifier", "operationId": "FetchSenderConfig", "parameters": [ { "description": "Notifier name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "object" } } }, "description": "default response" } }, "tags": [ "NotifierV1alpha1Console" ] }, "post": { "description": "Save sender config of notifier", "operationId": "SaveSenderConfig", "parameters": [ { "description": "Notifier name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "NotifierV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins": { "get": { "description": "List plugins using query criteria and sort params", "operationId": "ListPlugins", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Keyword of plugin name or description", "in": "query", "name": "keyword", "schema": { "type": "string" } }, { "description": "Whether the plugin is enabled", "in": "query", "name": "enabled", "schema": { "type": "boolean" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PluginList" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.css": { "get": { "description": "Merge all CSS bundles of enabled plugins into one.", "operationId": "fetchCssBundle", "responses": { "default": { "content": { "*/*": { "schema": { "type": "string" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js": { "get": { "description": "Merge all JS bundles of enabled plugins into one.", "operationId": "fetchJsBundle", "responses": { "default": { "content": { "*/*": { "schema": { "type": "string" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/-/install-from-uri": { "post": { "description": "Install a plugin from uri.", "operationId": "InstallPluginFromUri", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InstallFromUriRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/install": { "post": { "description": "Install a plugin by uploading a Jar file.", "operationId": "InstallPlugin", "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PluginInstallRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/json-config": { "get": { "description": "Fetch converted json config of plugin by configured configMapName.", "operationId": "fetchPluginJsonConfig", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "object" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] }, "put": { "description": "Update the config of plugin setting.", "operationId": "updatePluginJsonConfig", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object" } } }, "required": true }, "responses": { "204": { "content": {}, "description": "No Content" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/plugin-state": { "put": { "description": "Change the running state of a plugin by name.", "operationId": "ChangePluginRunningState", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PluginRunningStateRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/reload": { "put": { "description": "Reload a plugin by name.", "operationId": "reloadPlugin", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/reset-config": { "put": { "description": "Reset the configMap of plugin setting.", "operationId": "ResetPluginConfig", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/setting": { "get": { "description": "Fetch setting of plugin.", "operationId": "fetchPluginSetting", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/upgrade": { "post": { "description": "Upgrade a plugin by uploading a Jar file", "operationId": "UpgradePlugin", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PluginInstallRequest" } } }, "required": true }, "responses": {}, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/upgrade-from-uri": { "post": { "description": "Upgrade a plugin from uri.", "operationId": "UpgradePluginFromUri", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpgradeFromUriRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts": { "get": { "description": "List posts.", "operationId": "ListPosts", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Posts filtered by publish phase.", "in": "query", "name": "publishPhase", "schema": { "type": "string", "enum": [ "DRAFT", "PENDING_APPROVAL", "PUBLISHED", "FAILED" ] } }, { "description": "Posts filtered by keyword.", "in": "query", "name": "keyword", "schema": { "type": "string" } }, { "description": "Posts filtered by category including sub-categories.", "in": "query", "name": "categoryWithChildren", "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedPostList" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] }, "post": { "description": "Draft a post.", "operationId": "DraftPost", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}": { "put": { "description": "Update a post.", "operationId": "UpdateDraftPost", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/content": { "delete": { "description": "Delete a content for post.", "operationId": "deletePostContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "in": "query", "name": "snapshotName", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] }, "get": { "description": "Fetch content of post.", "operationId": "fetchPostContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "in": "query", "name": "snapshotName", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] }, "put": { "description": "Update a post\u0027s content.", "operationId": "UpdatePostContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Content" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/head-content": { "get": { "description": "Fetch head content of post.", "operationId": "fetchPostHeadContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/publish": { "put": { "description": "Publish a post.", "operationId": "PublishPost", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Head snapshot name of content.", "in": "query", "name": "headSnapshot", "schema": { "type": "string" } }, { "in": "query", "name": "async", "schema": { "type": "boolean" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/recycle": { "put": { "description": "Recycle a post.", "operationId": "RecyclePost", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": {}, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/release-content": { "get": { "description": "Fetch release content of post.", "operationId": "fetchPostReleaseContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/revert-content": { "put": { "description": "Revert to specified snapshot for post content.", "operationId": "revertToSpecifiedSnapshotForPost", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RevertSnapshotForPostParam" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/snapshot": { "get": { "description": "List all snapshots for post content.", "operationId": "listPostSnapshots", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ListedSnapshotDto" } } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/unpublish": { "put": { "description": "UnPublish a post.", "operationId": "UnpublishPost", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/replies": { "get": { "description": "List replies.", "operationId": "ListReplies", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Replies filtered by commentName.", "in": "query", "name": "commentName", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedReplyList" } } }, "description": "default response" } }, "tags": [ "ReplyV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages": { "get": { "description": "List single pages.", "operationId": "ListSinglePages", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "SinglePages filtered by contributor.", "in": "query", "name": "contributor", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "SinglePages filtered by publish phase.", "in": "query", "name": "publishPhase", "schema": { "type": "string", "enum": [ "DRAFT", "PENDING_APPROVAL", "PUBLISHED", "FAILED" ] } }, { "description": "SinglePages filtered by visibility.", "in": "query", "name": "visible", "schema": { "type": "string", "enum": [ "PUBLIC", "INTERNAL", "PRIVATE" ] } }, { "description": "SinglePages filtered by keyword.", "in": "query", "name": "keyword", "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedSinglePageList" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] }, "post": { "description": "Draft a single page.", "operationId": "DraftSinglePage", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SinglePageRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages/{name}": { "put": { "description": "Update a single page.", "operationId": "UpdateDraftSinglePage", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SinglePageRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages/{name}/content": { "delete": { "description": "Delete a content for post.", "operationId": "deleteSinglePageContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "in": "query", "name": "snapshotName", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] }, "get": { "description": "Fetch content of single page.", "operationId": "fetchSinglePageContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "in": "query", "name": "snapshotName", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] }, "put": { "description": "Update a single page\u0027s content.", "operationId": "UpdateSinglePageContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Content" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages/{name}/head-content": { "get": { "description": "Fetch head content of single page.", "operationId": "fetchSinglePageHeadContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages/{name}/publish": { "put": { "description": "Publish a single page.", "operationId": "PublishSinglePage", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages/{name}/release-content": { "get": { "description": "Fetch release content of single page.", "operationId": "fetchSinglePageReleaseContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages/{name}/revert-content": { "put": { "description": "Revert to specified snapshot for single page content.", "operationId": "revertToSpecifiedSnapshotForSinglePage", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RevertSnapshotForSingleParam" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages/{name}/snapshot": { "get": { "description": "List all snapshots for single page content.", "operationId": "listSinglePageSnapshots", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ListedSnapshotDto" } } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/stats": { "get": { "description": "Get stats.", "operationId": "getStats", "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/DashboardStats" } } }, "description": "default response" } }, "tags": [ "SystemV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/tags": { "get": { "description": "List Post Tags.", "operationId": "ListPostTags", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Post tags filtered by keyword.", "in": "query", "name": "keyword", "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TagList" } } }, "description": "default response" } }, "tags": [ "TagV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes": { "get": { "description": "List themes.", "operationId": "ListThemes", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Whether to list uninstalled themes.", "in": "query", "name": "uninstalled", "schema": { "type": "boolean" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ThemeList" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/-/activation": { "get": { "description": "Fetch the activated theme.", "operationId": "fetchActivatedTheme", "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/-/install-from-uri": { "post": { "description": "Install a theme from uri.", "operationId": "InstallThemeFromUri", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InstallFromUriRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/install": { "post": { "description": "Install a theme by uploading a zip file.", "operationId": "InstallTheme", "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/ThemeInstallRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/activation": { "put": { "description": "Activate a theme by name.", "operationId": "activateTheme", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/invalidate-cache": { "put": { "description": "Invalidate theme template cache.", "operationId": "InvalidateCache", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "204": { "description": "No Content" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/json-config": { "get": { "description": "Fetch converted json config of theme by configured configMapName.", "operationId": "fetchThemeJsonConfig", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "object" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] }, "put": { "description": "Update the configMap of theme setting.", "operationId": "updateThemeJsonConfig", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object" } } }, "required": true }, "responses": { "204": { "content": {}, "description": "No Content" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/reload": { "put": { "description": "Reload theme setting.", "operationId": "Reload", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/reset-config": { "put": { "description": "Reset the configMap of theme setting.", "operationId": "ResetThemeConfig", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/setting": { "get": { "description": "Fetch setting of theme.", "operationId": "fetchThemeSetting", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/upgrade": { "post": { "description": "Upgrade theme", "operationId": "UpgradeTheme", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/UpgradeRequest" } } }, "required": true }, "responses": {}, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/upgrade-from-uri": { "post": { "description": "Upgrade a theme from uri.", "operationId": "UpgradeThemeFromUri", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpgradeFromUriRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users": { "get": { "description": "List users", "operationId": "ListUsers", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Keyword to search", "in": "query", "name": "keyword", "schema": { "type": "string" } }, { "description": "Role name", "in": "query", "name": "role", "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserEndpoint.ListedUserList" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] }, "post": { "description": "Creates a new user.", "operationId": "CreateUser", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CreateUserRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/-": { "get": { "description": "Get current user detail", "operationId": "GetCurrentUserDetail", "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/DetailedUser" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] }, "put": { "description": "Update current user profile, but password.", "operationId": "UpdateCurrentUser", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/-/password": { "put": { "description": "Change own password of user.", "operationId": "ChangeOwnPassword", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ChangeOwnPasswordRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/-/send-email-verification-code": { "post": { "description": "Send email verification code for user", "operationId": "SendEmailVerificationCode", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/EmailVerifyRequest" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/-/verify-email": { "post": { "description": "Verify email for user by code.", "operationId": "VerifyEmail", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/VerifyCodeRequest" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/{name}": { "get": { "description": "Get user detail by name", "operationId": "GetUserDetail", "parameters": [ { "description": "User name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/DetailedUser" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/{name}/avatar": { "delete": { "description": "delete user avatar", "operationId": "DeleteUserAvatar", "parameters": [ { "description": "User name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] }, "post": { "description": "upload user avatar", "operationId": "UploadUserAvatar", "parameters": [ { "description": "User name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/IAvatarUploadRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/{name}/password": { "put": { "description": "Change anyone password of user for admin.", "operationId": "ChangeAnyonePassword", "parameters": [ { "description": "Name of user. If the name is equal to \u0027-\u0027, it will change the password of current user.", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ChangePasswordRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/{name}/permissions": { "get": { "description": "Get permissions of user", "operationId": "GetPermissions", "parameters": [ { "description": "User name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserPermission" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] }, "post": { "description": "Grant permissions to user", "operationId": "GrantPermission", "parameters": [ { "description": "User name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/GrantRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.content.halo.run/v1alpha1/categories": { "get": { "description": "Lists categories.", "operationId": "queryCategories", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CategoryVoList" } } }, "description": "default response" } }, "tags": [ "CategoryV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/categories/{name}": { "get": { "description": "Gets category by name.", "operationId": "queryCategoryByName", "parameters": [ { "description": "Category name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CategoryVo" } } }, "description": "default response" } }, "tags": [ "CategoryV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/categories/{name}/posts": { "get": { "description": "Lists posts by category name.", "operationId": "queryPostsByCategoryName", "parameters": [ { "description": "Category name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedPostVoList" } } }, "description": "default response" } }, "tags": [ "CategoryV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/posts": { "get": { "description": "Lists posts.", "operationId": "queryPosts", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedPostVoList" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/posts/{name}": { "get": { "description": "Gets a post by name.", "operationId": "queryPostByName", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PostVo" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/posts/{name}/navigation": { "get": { "description": "Gets a post navigation by name.", "operationId": "queryPostNavigationByName", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NavigationPostVo" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/singlepages": { "get": { "description": "Lists single pages", "operationId": "querySinglePages", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedSinglePageVoList" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/singlepages/{name}": { "get": { "description": "Gets single page by name", "operationId": "querySinglePageByName", "parameters": [ { "description": "SinglePage name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePageVo" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/tags": { "get": { "description": "Lists tags", "operationId": "queryTags", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TagVoList" } } }, "description": "default response" } }, "tags": [ "TagV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/tags/{name}": { "get": { "description": "Gets tag by name", "operationId": "queryTagByName", "parameters": [ { "description": "Tag name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TagVo" } } }, "description": "default response" } }, "tags": [ "TagV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/tags/{name}/posts": { "get": { "description": "Lists posts by tag name", "operationId": "queryPostsByTagName", "parameters": [ { "description": "Tag name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedPostVo" } } }, "description": "default response" } }, "tags": [ "TagV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/comments": { "get": { "description": "List comments.", "operationId": "ListComments_1", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "The comment subject group.", "in": "query", "name": "group", "schema": { "type": "string" } }, { "description": "The comment subject version.", "in": "query", "name": "version", "required": true, "schema": { "type": "string" } }, { "description": "The comment subject kind.", "in": "query", "name": "kind", "required": true, "schema": { "type": "string" } }, { "description": "The comment subject name.", "in": "query", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Whether to include replies. Default is false.", "in": "query", "name": "withReplies", "schema": { "type": "boolean" } }, { "description": "Reply size of the comment, default is 10, only works when withReplies is true.", "in": "query", "name": "replySize", "schema": { "type": "integer", "format": "int32" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CommentWithReplyVoList" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Public" ] }, "post": { "description": "Create a comment.", "operationId": "CreateComment_1", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommentRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/comments/{name}": { "get": { "description": "Get a comment.", "operationId": "GetComment", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CommentVoList" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/comments/{name}/reply": { "get": { "description": "List comment replies.", "operationId": "ListCommentReplies", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReplyVoList" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Public" ] }, "post": { "description": "Create a reply.", "operationId": "CreateReply_1", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReplyRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/indices/-/search": { "post": { "description": "Search indices.", "operationId": "IndicesSearch", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SearchOption" } } }, "description": "Please note that the \"filterPublished\", \"filterExposed\" and \"filterRecycled\" fields are ignored in this endpoint." }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SearchResult" } } }, "description": "default response" } }, "tags": [ "IndexV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/menus/-": { "get": { "description": "Gets primary menu.", "operationId": "queryPrimaryMenu", "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuVo" } } }, "description": "default response" } }, "tags": [ "MenuV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/menus/{name}": { "get": { "description": "Gets menu by name.", "operationId": "queryMenuByName", "parameters": [ { "description": "Menu name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuVo" } } }, "description": "default response" } }, "tags": [ "MenuV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/stats/-": { "get": { "description": "Gets site stats", "operationId": "queryStats", "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SiteStatsVo" } } }, "description": "default response" } }, "tags": [ "SystemV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/trackers/counter": { "post": { "description": "Count an extension resource visits.", "operationId": "count", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CounterRequest" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "MetricsV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/trackers/downvote": { "post": { "description": "Downvote an extension resource.", "operationId": "downvote", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/VoteRequest" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "MetricsV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/trackers/upvote": { "post": { "description": "Upvote an extension resource.", "operationId": "upvote", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/VoteRequest" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "MetricsV1alpha1Public" ] } }, "/apis/api.notification.halo.run/v1alpha1/notifiers/{name}/receiver-config": { "get": { "description": "Fetch receiver config of notifier", "operationId": "FetchReceiverConfig", "parameters": [ { "description": "Notifier name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "object" } } }, "description": "default response" } }, "tags": [ "NotifierV1alpha1Uc" ] }, "post": { "description": "Save receiver config of notifier", "operationId": "SaveReceiverConfig", "parameters": [ { "description": "Notifier name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "NotifierV1alpha1Uc" ] } }, "/apis/api.notification.halo.run/v1alpha1/subscriptions/{name}/unsubscribe": { "get": { "description": "Unsubscribe a subscription", "operationId": "Unsubscribe", "parameters": [ { "description": "Subscription name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Unsubscribe token", "in": "query", "name": "token", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "string" } } }, "description": "default response" } }, "tags": [ "NotificationV1alpha1Public" ] } }, "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notification-preferences": { "get": { "description": "List notification preferences for the authenticated user.", "operationId": "ListUserNotificationPreferences", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonTypeNotifierMatrix" } } }, "description": "default response" } }, "tags": [ "NotificationV1alpha1Uc" ] }, "post": { "description": "Save notification preferences for the authenticated user.", "operationId": "SaveUserNotificationPreferences", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonTypeNotifierCollectionRequest" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonTypeNotifierMatrix" } } }, "description": "default response" } }, "tags": [ "NotificationV1alpha1Uc" ] } }, "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications": { "get": { "description": "List notifications for the authenticated user.", "operationId": "ListUserNotifications", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } }, { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationList" } } }, "description": "default response" } }, "tags": [ "NotificationV1alpha1Uc" ] } }, "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/-/mark-specified-as-read": { "put": { "description": "Mark the specified notifications as read.", "operationId": "MarkNotificationsAsRead", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MarkSpecifiedRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "type": "string" } } } }, "description": "default response" } }, "tags": [ "NotificationV1alpha1Uc" ] } }, "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/{name}": { "delete": { "description": "Delete the specified notification.", "operationId": "DeleteSpecifiedNotification", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } }, { "description": "Notification name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "default response" } }, "tags": [ "NotificationV1alpha1Uc" ] } }, "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/{name}/mark-as-read": { "put": { "description": "Mark the specified notification as read.", "operationId": "MarkNotificationAsRead", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } }, { "description": "Notification name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "default response" } }, "tags": [ "NotificationV1alpha1Uc" ] } }, "/apis/api.plugin.halo.run/v1alpha1/plugins/{name}/available": { "get": { "description": "Gets plugin available by name.", "operationId": "queryPluginAvailableByName", "parameters": [ { "description": "Plugin name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "boolean" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Public" ] } }, "/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri": { "get": { "description": "Get thumbnail by URI", "operationId": "GetThumbnailByUri", "parameters": [ { "description": "The URI of the image", "in": "query", "name": "uri", "required": true, "schema": { "type": "string" } }, { "description": "The size of the thumbnail", "in": "query", "name": "size", "required": true, "schema": { "type": "string", "enum": [ "S", "M", "L", "XL" ] } }, { "description": "The width of the thumbnail, if \u0027size\u0027 is not provided, this parameter will be used to determine the size", "in": "query", "name": "width", "schema": { "type": "integer", "enum": [ "400", "800", "1200", "1600" ] } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "string", "format": "binary" } } }, "description": "default response" } }, "tags": [ "ThumbnailV1alpha1Public" ] } }, "/apis/auth.halo.run/v1alpha1/authproviders": { "get": { "description": "List AuthProvider", "operationId": "listAuthProvider", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProviderList" } } }, "description": "Response authproviders" } }, "tags": [ "AuthProviderV1alpha1" ] }, "post": { "description": "Create AuthProvider", "operationId": "createAuthProvider", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "Fresh authprovider" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "Response authproviders created just now" } }, "tags": [ "AuthProviderV1alpha1" ] } }, "/apis/auth.halo.run/v1alpha1/authproviders/{name}": { "delete": { "description": "Delete AuthProvider", "operationId": "deleteAuthProvider", "parameters": [ { "description": "Name of authprovider", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response authprovider deleted just now" } }, "tags": [ "AuthProviderV1alpha1" ] }, "get": { "description": "Get AuthProvider", "operationId": "getAuthProvider", "parameters": [ { "description": "Name of authprovider", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "Response single authprovider" } }, "tags": [ "AuthProviderV1alpha1" ] }, "patch": { "description": "Patch AuthProvider", "operationId": "patchAuthProvider", "parameters": [ { "description": "Name of authprovider", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "Response authprovider patched just now" } }, "tags": [ "AuthProviderV1alpha1" ] }, "put": { "description": "Update AuthProvider", "operationId": "updateAuthProvider", "parameters": [ { "description": "Name of authprovider", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "Updated authprovider" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "Response authproviders updated just now" } }, "tags": [ "AuthProviderV1alpha1" ] } }, "/apis/auth.halo.run/v1alpha1/userconnections": { "get": { "description": "List UserConnection", "operationId": "listUserConnection", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserConnectionList" } } }, "description": "Response userconnections" } }, "tags": [ "UserConnectionV1alpha1" ] }, "post": { "description": "Create UserConnection", "operationId": "createUserConnection", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserConnection" } } }, "description": "Fresh userconnection" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserConnection" } } }, "description": "Response userconnections created just now" } }, "tags": [ "UserConnectionV1alpha1" ] } }, "/apis/auth.halo.run/v1alpha1/userconnections/{name}": { "delete": { "description": "Delete UserConnection", "operationId": "deleteUserConnection", "parameters": [ { "description": "Name of userconnection", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response userconnection deleted just now" } }, "tags": [ "UserConnectionV1alpha1" ] }, "get": { "description": "Get UserConnection", "operationId": "getUserConnection", "parameters": [ { "description": "Name of userconnection", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserConnection" } } }, "description": "Response single userconnection" } }, "tags": [ "UserConnectionV1alpha1" ] }, "patch": { "description": "Patch UserConnection", "operationId": "patchUserConnection", "parameters": [ { "description": "Name of userconnection", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserConnection" } } }, "description": "Response userconnection patched just now" } }, "tags": [ "UserConnectionV1alpha1" ] }, "put": { "description": "Update UserConnection", "operationId": "updateUserConnection", "parameters": [ { "description": "Name of userconnection", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserConnection" } } }, "description": "Updated userconnection" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserConnection" } } }, "description": "Response userconnections updated just now" } }, "tags": [ "UserConnectionV1alpha1" ] } }, "/apis/console.api.halo.run/v1alpha1/systemconfigs/{group}": { "get": { "description": "Get system config by group", "operationId": "getSystemConfigByGroup", "parameters": [ { "description": "Group of the system config", "in": "path", "name": "group", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "object" } }, "application/json": {} }, "description": "default response" } }, "tags": [ "SystemConfigV1alpha1Console" ] }, "put": { "description": "Update system config by group", "operationId": "updateSystemConfigByGroup", "parameters": [ { "description": "Group of the system config", "in": "path", "name": "group", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "type": "object" } } } }, "responses": { "204 NO_CONTENT": { "content": {}, "description": "default response" } }, "tags": [ "SystemConfigV1alpha1Console" ] } }, "/apis/console.api.migration.halo.run/v1alpha1/backup-files": { "get": { "description": "Get backup files from backup root.", "operationId": "getBackupFiles", "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/BackupFile" } } } }, "description": "default response" } }, "tags": [ "MigrationV1alpha1Console" ] } }, "/apis/console.api.migration.halo.run/v1alpha1/backups/{name}/files/{filename}": { "get": { "operationId": "DownloadBackups", "parameters": [ { "description": "Backup name.", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Backup filename.", "in": "path", "name": "filename", "required": true, "schema": { "type": "string" } } ], "responses": {}, "tags": [ "MigrationV1alpha1Console" ] } }, "/apis/console.api.migration.halo.run/v1alpha1/restorations": { "post": { "description": "Restore backup by uploading file or providing download link or backup name.", "operationId": "RestoreBackup", "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/RestoreRequest" } } }, "required": true }, "responses": {}, "tags": [ "MigrationV1alpha1Console" ] } }, "/apis/console.api.notification.halo.run/v1alpha1/notifiers/default-email-notifier/verify-connection": { "post": { "description": "Verify email sender config.", "operationId": "VerifyEmailSenderConfig", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/EmailConfigValidationRequest" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "NotifierV1alpha1Console" ] } }, "/apis/console.api.security.halo.run/v1alpha1/users/{username}/disable": { "post": { "description": "Disable user by username", "operationId": "DisableUser", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "The user has been disabled." } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/console.api.security.halo.run/v1alpha1/users/{username}/enable": { "post": { "description": "Enable user by username", "operationId": "EnableUser", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "The user has been enabled." } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/console.api.storage.halo.run/v1alpha1/attachments/-/upload": { "post": { "description": "Upload attachment endpoint for console.", "operationId": "uploadAttachmentForConsole", "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/UploadForm" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Console" ] } }, "/apis/console.api.storage.halo.run/v1alpha1/policies/{name}/configs/{group}": { "get": { "description": "Get policy config by group", "operationId": "getPolicyConfigByGroup", "parameters": [ { "description": "Name of the policy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Name of the group", "in": "path", "name": "group", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "object" } } }, "description": "default response" } }, "tags": [ "PolicyAlpha1Console" ] }, "put": { "description": "Update policy config by group", "operationId": "updatePolicyConfigByGroup", "parameters": [ { "description": "Name of the policy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Name of the group", "in": "path", "name": "group", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object" } } }, "required": true }, "responses": { "204": { "description": "No Content" } }, "tags": [ "PolicyAlpha1Console" ] } }, "/apis/content.halo.run/v1alpha1/categories": { "get": { "description": "List Category", "operationId": "listCategory", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CategoryList" } } }, "description": "Response categories" } }, "tags": [ "CategoryV1alpha1" ] }, "post": { "description": "Create Category", "operationId": "createCategory", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Category" } } }, "description": "Fresh category" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Category" } } }, "description": "Response categories created just now" } }, "tags": [ "CategoryV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/categories/{name}": { "delete": { "description": "Delete Category", "operationId": "deleteCategory", "parameters": [ { "description": "Name of category", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response category deleted just now" } }, "tags": [ "CategoryV1alpha1" ] }, "get": { "description": "Get Category", "operationId": "getCategory", "parameters": [ { "description": "Name of category", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Category" } } }, "description": "Response single category" } }, "tags": [ "CategoryV1alpha1" ] }, "patch": { "description": "Patch Category", "operationId": "patchCategory", "parameters": [ { "description": "Name of category", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Category" } } }, "description": "Response category patched just now" } }, "tags": [ "CategoryV1alpha1" ] }, "put": { "description": "Update Category", "operationId": "updateCategory", "parameters": [ { "description": "Name of category", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Category" } } }, "description": "Updated category" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Category" } } }, "description": "Response categories updated just now" } }, "tags": [ "CategoryV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/comments": { "get": { "description": "List Comment", "operationId": "listComment", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CommentList" } } }, "description": "Response comments" } }, "tags": [ "CommentV1alpha1" ] }, "post": { "description": "Create Comment", "operationId": "createComment", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "Fresh comment" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "Response comments created just now" } }, "tags": [ "CommentV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/comments/{name}": { "delete": { "description": "Delete Comment", "operationId": "deleteComment", "parameters": [ { "description": "Name of comment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response comment deleted just now" } }, "tags": [ "CommentV1alpha1" ] }, "get": { "description": "Get Comment", "operationId": "getComment", "parameters": [ { "description": "Name of comment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "Response single comment" } }, "tags": [ "CommentV1alpha1" ] }, "patch": { "description": "Patch Comment", "operationId": "patchComment", "parameters": [ { "description": "Name of comment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "Response comment patched just now" } }, "tags": [ "CommentV1alpha1" ] }, "put": { "description": "Update Comment", "operationId": "updateComment", "parameters": [ { "description": "Name of comment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "Updated comment" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "Response comments updated just now" } }, "tags": [ "CommentV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/posts": { "get": { "description": "List Post", "operationId": "listPost", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PostList" } } }, "description": "Response posts" } }, "tags": [ "PostV1alpha1" ] }, "post": { "description": "Create Post", "operationId": "createPost", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "Fresh post" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "Response posts created just now" } }, "tags": [ "PostV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/posts/{name}": { "delete": { "description": "Delete Post", "operationId": "deletePost", "parameters": [ { "description": "Name of post", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response post deleted just now" } }, "tags": [ "PostV1alpha1" ] }, "get": { "description": "Get Post", "operationId": "getPost", "parameters": [ { "description": "Name of post", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "Response single post" } }, "tags": [ "PostV1alpha1" ] }, "patch": { "description": "Patch Post", "operationId": "patchPost", "parameters": [ { "description": "Name of post", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "Response post patched just now" } }, "tags": [ "PostV1alpha1" ] }, "put": { "description": "Update Post", "operationId": "updatePost", "parameters": [ { "description": "Name of post", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "Updated post" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "Response posts updated just now" } }, "tags": [ "PostV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/replies": { "get": { "description": "List Reply", "operationId": "listReply", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReplyList" } } }, "description": "Response replies" } }, "tags": [ "ReplyV1alpha1" ] }, "post": { "description": "Create Reply", "operationId": "createReply", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "Fresh reply" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "Response replies created just now" } }, "tags": [ "ReplyV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/replies/{name}": { "delete": { "description": "Delete Reply", "operationId": "deleteReply", "parameters": [ { "description": "Name of reply", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response reply deleted just now" } }, "tags": [ "ReplyV1alpha1" ] }, "get": { "description": "Get Reply", "operationId": "getReply", "parameters": [ { "description": "Name of reply", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "Response single reply" } }, "tags": [ "ReplyV1alpha1" ] }, "patch": { "description": "Patch Reply", "operationId": "patchReply", "parameters": [ { "description": "Name of reply", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "Response reply patched just now" } }, "tags": [ "ReplyV1alpha1" ] }, "put": { "description": "Update Reply", "operationId": "updateReply", "parameters": [ { "description": "Name of reply", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "Updated reply" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "Response replies updated just now" } }, "tags": [ "ReplyV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/singlepages": { "get": { "description": "List SinglePage", "operationId": "listSinglePage", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePageList" } } }, "description": "Response singlepages" } }, "tags": [ "SinglePageV1alpha1" ] }, "post": { "description": "Create SinglePage", "operationId": "createSinglePage", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "Fresh singlepage" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "Response singlepages created just now" } }, "tags": [ "SinglePageV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/singlepages/{name}": { "delete": { "description": "Delete SinglePage", "operationId": "deleteSinglePage", "parameters": [ { "description": "Name of singlepage", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response singlepage deleted just now" } }, "tags": [ "SinglePageV1alpha1" ] }, "get": { "description": "Get SinglePage", "operationId": "getSinglePage", "parameters": [ { "description": "Name of singlepage", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "Response single singlepage" } }, "tags": [ "SinglePageV1alpha1" ] }, "patch": { "description": "Patch SinglePage", "operationId": "patchSinglePage", "parameters": [ { "description": "Name of singlepage", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "Response singlepage patched just now" } }, "tags": [ "SinglePageV1alpha1" ] }, "put": { "description": "Update SinglePage", "operationId": "updateSinglePage", "parameters": [ { "description": "Name of singlepage", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "Updated singlepage" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "Response singlepages updated just now" } }, "tags": [ "SinglePageV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/snapshots": { "get": { "description": "List Snapshot", "operationId": "listSnapshot", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SnapshotList" } } }, "description": "Response snapshots" } }, "tags": [ "SnapshotV1alpha1" ] }, "post": { "description": "Create Snapshot", "operationId": "createSnapshot", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "Fresh snapshot" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "Response snapshots created just now" } }, "tags": [ "SnapshotV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/snapshots/{name}": { "delete": { "description": "Delete Snapshot", "operationId": "deleteSnapshot", "parameters": [ { "description": "Name of snapshot", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response snapshot deleted just now" } }, "tags": [ "SnapshotV1alpha1" ] }, "get": { "description": "Get Snapshot", "operationId": "getSnapshot", "parameters": [ { "description": "Name of snapshot", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "Response single snapshot" } }, "tags": [ "SnapshotV1alpha1" ] }, "patch": { "description": "Patch Snapshot", "operationId": "patchSnapshot", "parameters": [ { "description": "Name of snapshot", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "Response snapshot patched just now" } }, "tags": [ "SnapshotV1alpha1" ] }, "put": { "description": "Update Snapshot", "operationId": "updateSnapshot", "parameters": [ { "description": "Name of snapshot", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "Updated snapshot" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "Response snapshots updated just now" } }, "tags": [ "SnapshotV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/tags": { "get": { "description": "List Tag", "operationId": "listTag", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TagList" } } }, "description": "Response tags" } }, "tags": [ "TagV1alpha1" ] }, "post": { "description": "Create Tag", "operationId": "createTag", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "Fresh tag" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "Response tags created just now" } }, "tags": [ "TagV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/tags/{name}": { "delete": { "description": "Delete Tag", "operationId": "deleteTag", "parameters": [ { "description": "Name of tag", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response tag deleted just now" } }, "tags": [ "TagV1alpha1" ] }, "get": { "description": "Get Tag", "operationId": "getTag", "parameters": [ { "description": "Name of tag", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "Response single tag" } }, "tags": [ "TagV1alpha1" ] }, "patch": { "description": "Patch Tag", "operationId": "patchTag", "parameters": [ { "description": "Name of tag", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "Response tag patched just now" } }, "tags": [ "TagV1alpha1" ] }, "put": { "description": "Update Tag", "operationId": "updateTag", "parameters": [ { "description": "Name of tag", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "Updated tag" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "Response tags updated just now" } }, "tags": [ "TagV1alpha1" ] } }, "/apis/metrics.halo.run/v1alpha1/counters": { "get": { "description": "List Counter", "operationId": "listCounter", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CounterList" } } }, "description": "Response counters" } }, "tags": [ "CounterV1alpha1" ] }, "post": { "description": "Create Counter", "operationId": "createCounter", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Counter" } } }, "description": "Fresh counter" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Counter" } } }, "description": "Response counters created just now" } }, "tags": [ "CounterV1alpha1" ] } }, "/apis/metrics.halo.run/v1alpha1/counters/{name}": { "delete": { "description": "Delete Counter", "operationId": "deleteCounter", "parameters": [ { "description": "Name of counter", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response counter deleted just now" } }, "tags": [ "CounterV1alpha1" ] }, "get": { "description": "Get Counter", "operationId": "getCounter", "parameters": [ { "description": "Name of counter", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Counter" } } }, "description": "Response single counter" } }, "tags": [ "CounterV1alpha1" ] }, "patch": { "description": "Patch Counter", "operationId": "patchCounter", "parameters": [ { "description": "Name of counter", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Counter" } } }, "description": "Response counter patched just now" } }, "tags": [ "CounterV1alpha1" ] }, "put": { "description": "Update Counter", "operationId": "updateCounter", "parameters": [ { "description": "Name of counter", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Counter" } } }, "description": "Updated counter" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Counter" } } }, "description": "Response counters updated just now" } }, "tags": [ "CounterV1alpha1" ] } }, "/apis/migration.halo.run/v1alpha1/backups": { "get": { "description": "List Backup", "operationId": "listBackup", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/BackupList" } } }, "description": "Response backups" } }, "tags": [ "BackupV1alpha1" ] }, "post": { "description": "Create Backup", "operationId": "createBackup", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Backup" } } }, "description": "Fresh backup" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Backup" } } }, "description": "Response backups created just now" } }, "tags": [ "BackupV1alpha1" ] } }, "/apis/migration.halo.run/v1alpha1/backups/{name}": { "delete": { "description": "Delete Backup", "operationId": "deleteBackup", "parameters": [ { "description": "Name of backup", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response backup deleted just now" } }, "tags": [ "BackupV1alpha1" ] }, "get": { "description": "Get Backup", "operationId": "getBackup", "parameters": [ { "description": "Name of backup", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Backup" } } }, "description": "Response single backup" } }, "tags": [ "BackupV1alpha1" ] }, "patch": { "description": "Patch Backup", "operationId": "patchBackup", "parameters": [ { "description": "Name of backup", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Backup" } } }, "description": "Response backup patched just now" } }, "tags": [ "BackupV1alpha1" ] }, "put": { "description": "Update Backup", "operationId": "updateBackup", "parameters": [ { "description": "Name of backup", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Backup" } } }, "description": "Updated backup" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Backup" } } }, "description": "Response backups updated just now" } }, "tags": [ "BackupV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/notifications": { "get": { "description": "List Notification", "operationId": "listNotification", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationList" } } }, "description": "Response notifications" } }, "tags": [ "NotificationV1alpha1" ] }, "post": { "description": "Create Notification", "operationId": "createNotification", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "Fresh notification" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "Response notifications created just now" } }, "tags": [ "NotificationV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/notifications/{name}": { "delete": { "description": "Delete Notification", "operationId": "deleteNotification", "parameters": [ { "description": "Name of notification", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response notification deleted just now" } }, "tags": [ "NotificationV1alpha1" ] }, "get": { "description": "Get Notification", "operationId": "getNotification", "parameters": [ { "description": "Name of notification", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "Response single notification" } }, "tags": [ "NotificationV1alpha1" ] }, "patch": { "description": "Patch Notification", "operationId": "patchNotification", "parameters": [ { "description": "Name of notification", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "Response notification patched just now" } }, "tags": [ "NotificationV1alpha1" ] }, "put": { "description": "Update Notification", "operationId": "updateNotification", "parameters": [ { "description": "Name of notification", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "Updated notification" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "Response notifications updated just now" } }, "tags": [ "NotificationV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/notificationtemplates": { "get": { "description": "List NotificationTemplate", "operationId": "listNotificationTemplate", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationTemplateList" } } }, "description": "Response notificationtemplates" } }, "tags": [ "NotificationTemplateV1alpha1" ] }, "post": { "description": "Create NotificationTemplate", "operationId": "createNotificationTemplate", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationTemplate" } } }, "description": "Fresh notificationtemplate" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationTemplate" } } }, "description": "Response notificationtemplates created just now" } }, "tags": [ "NotificationTemplateV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/notificationtemplates/{name}": { "delete": { "description": "Delete NotificationTemplate", "operationId": "deleteNotificationTemplate", "parameters": [ { "description": "Name of notificationtemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response notificationtemplate deleted just now" } }, "tags": [ "NotificationTemplateV1alpha1" ] }, "get": { "description": "Get NotificationTemplate", "operationId": "getNotificationTemplate", "parameters": [ { "description": "Name of notificationtemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationTemplate" } } }, "description": "Response single notificationtemplate" } }, "tags": [ "NotificationTemplateV1alpha1" ] }, "patch": { "description": "Patch NotificationTemplate", "operationId": "patchNotificationTemplate", "parameters": [ { "description": "Name of notificationtemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationTemplate" } } }, "description": "Response notificationtemplate patched just now" } }, "tags": [ "NotificationTemplateV1alpha1" ] }, "put": { "description": "Update NotificationTemplate", "operationId": "updateNotificationTemplate", "parameters": [ { "description": "Name of notificationtemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationTemplate" } } }, "description": "Updated notificationtemplate" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationTemplate" } } }, "description": "Response notificationtemplates updated just now" } }, "tags": [ "NotificationTemplateV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/notifierDescriptors": { "get": { "description": "List NotifierDescriptor", "operationId": "listNotifierDescriptor", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotifierDescriptorList" } } }, "description": "Response notifierDescriptors" } }, "tags": [ "NotifierDescriptorV1alpha1" ] }, "post": { "description": "Create NotifierDescriptor", "operationId": "createNotifierDescriptor", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotifierDescriptor" } } }, "description": "Fresh notifierDescriptor" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotifierDescriptor" } } }, "description": "Response notifierDescriptors created just now" } }, "tags": [ "NotifierDescriptorV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/notifierDescriptors/{name}": { "delete": { "description": "Delete NotifierDescriptor", "operationId": "deleteNotifierDescriptor", "parameters": [ { "description": "Name of notifierDescriptor", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response notifierDescriptor deleted just now" } }, "tags": [ "NotifierDescriptorV1alpha1" ] }, "get": { "description": "Get NotifierDescriptor", "operationId": "getNotifierDescriptor", "parameters": [ { "description": "Name of notifierDescriptor", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotifierDescriptor" } } }, "description": "Response single notifierDescriptor" } }, "tags": [ "NotifierDescriptorV1alpha1" ] }, "patch": { "description": "Patch NotifierDescriptor", "operationId": "patchNotifierDescriptor", "parameters": [ { "description": "Name of notifierDescriptor", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotifierDescriptor" } } }, "description": "Response notifierDescriptor patched just now" } }, "tags": [ "NotifierDescriptorV1alpha1" ] }, "put": { "description": "Update NotifierDescriptor", "operationId": "updateNotifierDescriptor", "parameters": [ { "description": "Name of notifierDescriptor", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotifierDescriptor" } } }, "description": "Updated notifierDescriptor" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotifierDescriptor" } } }, "description": "Response notifierDescriptors updated just now" } }, "tags": [ "NotifierDescriptorV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/reasons": { "get": { "description": "List Reason", "operationId": "listReason", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonList" } } }, "description": "Response reasons" } }, "tags": [ "ReasonV1alpha1" ] }, "post": { "description": "Create Reason", "operationId": "createReason", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reason" } } }, "description": "Fresh reason" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reason" } } }, "description": "Response reasons created just now" } }, "tags": [ "ReasonV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/reasons/{name}": { "delete": { "description": "Delete Reason", "operationId": "deleteReason", "parameters": [ { "description": "Name of reason", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response reason deleted just now" } }, "tags": [ "ReasonV1alpha1" ] }, "get": { "description": "Get Reason", "operationId": "getReason", "parameters": [ { "description": "Name of reason", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reason" } } }, "description": "Response single reason" } }, "tags": [ "ReasonV1alpha1" ] }, "patch": { "description": "Patch Reason", "operationId": "patchReason", "parameters": [ { "description": "Name of reason", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reason" } } }, "description": "Response reason patched just now" } }, "tags": [ "ReasonV1alpha1" ] }, "put": { "description": "Update Reason", "operationId": "updateReason", "parameters": [ { "description": "Name of reason", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reason" } } }, "description": "Updated reason" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reason" } } }, "description": "Response reasons updated just now" } }, "tags": [ "ReasonV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/reasontypes": { "get": { "description": "List ReasonType", "operationId": "listReasonType", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonTypeList" } } }, "description": "Response reasontypes" } }, "tags": [ "ReasonTypeV1alpha1" ] }, "post": { "description": "Create ReasonType", "operationId": "createReasonType", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonType" } } }, "description": "Fresh reasontype" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonType" } } }, "description": "Response reasontypes created just now" } }, "tags": [ "ReasonTypeV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/reasontypes/{name}": { "delete": { "description": "Delete ReasonType", "operationId": "deleteReasonType", "parameters": [ { "description": "Name of reasontype", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response reasontype deleted just now" } }, "tags": [ "ReasonTypeV1alpha1" ] }, "get": { "description": "Get ReasonType", "operationId": "getReasonType", "parameters": [ { "description": "Name of reasontype", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonType" } } }, "description": "Response single reasontype" } }, "tags": [ "ReasonTypeV1alpha1" ] }, "patch": { "description": "Patch ReasonType", "operationId": "patchReasonType", "parameters": [ { "description": "Name of reasontype", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonType" } } }, "description": "Response reasontype patched just now" } }, "tags": [ "ReasonTypeV1alpha1" ] }, "put": { "description": "Update ReasonType", "operationId": "updateReasonType", "parameters": [ { "description": "Name of reasontype", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonType" } } }, "description": "Updated reasontype" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonType" } } }, "description": "Response reasontypes updated just now" } }, "tags": [ "ReasonTypeV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/subscriptions": { "get": { "description": "List Subscription", "operationId": "listSubscription", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SubscriptionList" } } }, "description": "Response subscriptions" } }, "tags": [ "SubscriptionV1alpha1" ] }, "post": { "description": "Create Subscription", "operationId": "createSubscription", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Subscription" } } }, "description": "Fresh subscription" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Subscription" } } }, "description": "Response subscriptions created just now" } }, "tags": [ "SubscriptionV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/subscriptions/{name}": { "delete": { "description": "Delete Subscription", "operationId": "deleteSubscription", "parameters": [ { "description": "Name of subscription", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response subscription deleted just now" } }, "tags": [ "SubscriptionV1alpha1" ] }, "get": { "description": "Get Subscription", "operationId": "getSubscription", "parameters": [ { "description": "Name of subscription", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Subscription" } } }, "description": "Response single subscription" } }, "tags": [ "SubscriptionV1alpha1" ] }, "patch": { "description": "Patch Subscription", "operationId": "patchSubscription", "parameters": [ { "description": "Name of subscription", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Subscription" } } }, "description": "Response subscription patched just now" } }, "tags": [ "SubscriptionV1alpha1" ] }, "put": { "description": "Update Subscription", "operationId": "updateSubscription", "parameters": [ { "description": "Name of subscription", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Subscription" } } }, "description": "Updated subscription" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Subscription" } } }, "description": "Response subscriptions updated just now" } }, "tags": [ "SubscriptionV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/extensiondefinitions": { "get": { "description": "List ExtensionDefinition", "operationId": "listExtensionDefinition", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionDefinitionList" } } }, "description": "Response extensiondefinitions" } }, "tags": [ "ExtensionDefinitionV1alpha1" ] }, "post": { "description": "Create ExtensionDefinition", "operationId": "createExtensionDefinition", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionDefinition" } } }, "description": "Fresh extensiondefinition" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionDefinition" } } }, "description": "Response extensiondefinitions created just now" } }, "tags": [ "ExtensionDefinitionV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/extensiondefinitions/{name}": { "delete": { "description": "Delete ExtensionDefinition", "operationId": "deleteExtensionDefinition", "parameters": [ { "description": "Name of extensiondefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response extensiondefinition deleted just now" } }, "tags": [ "ExtensionDefinitionV1alpha1" ] }, "get": { "description": "Get ExtensionDefinition", "operationId": "getExtensionDefinition", "parameters": [ { "description": "Name of extensiondefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionDefinition" } } }, "description": "Response single extensiondefinition" } }, "tags": [ "ExtensionDefinitionV1alpha1" ] }, "patch": { "description": "Patch ExtensionDefinition", "operationId": "patchExtensionDefinition", "parameters": [ { "description": "Name of extensiondefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionDefinition" } } }, "description": "Response extensiondefinition patched just now" } }, "tags": [ "ExtensionDefinitionV1alpha1" ] }, "put": { "description": "Update ExtensionDefinition", "operationId": "updateExtensionDefinition", "parameters": [ { "description": "Name of extensiondefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionDefinition" } } }, "description": "Updated extensiondefinition" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionDefinition" } } }, "description": "Response extensiondefinitions updated just now" } }, "tags": [ "ExtensionDefinitionV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/extensionpointdefinitions": { "get": { "description": "List ExtensionPointDefinition", "operationId": "listExtensionPointDefinition", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionPointDefinitionList" } } }, "description": "Response extensionpointdefinitions" } }, "tags": [ "ExtensionPointDefinitionV1alpha1" ] }, "post": { "description": "Create ExtensionPointDefinition", "operationId": "createExtensionPointDefinition", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, "description": "Fresh extensionpointdefinition" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, "description": "Response extensionpointdefinitions created just now" } }, "tags": [ "ExtensionPointDefinitionV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/extensionpointdefinitions/{name}": { "delete": { "description": "Delete ExtensionPointDefinition", "operationId": "deleteExtensionPointDefinition", "parameters": [ { "description": "Name of extensionpointdefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response extensionpointdefinition deleted just now" } }, "tags": [ "ExtensionPointDefinitionV1alpha1" ] }, "get": { "description": "Get ExtensionPointDefinition", "operationId": "getExtensionPointDefinition", "parameters": [ { "description": "Name of extensionpointdefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, "description": "Response single extensionpointdefinition" } }, "tags": [ "ExtensionPointDefinitionV1alpha1" ] }, "patch": { "description": "Patch ExtensionPointDefinition", "operationId": "patchExtensionPointDefinition", "parameters": [ { "description": "Name of extensionpointdefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, "description": "Response extensionpointdefinition patched just now" } }, "tags": [ "ExtensionPointDefinitionV1alpha1" ] }, "put": { "description": "Update ExtensionPointDefinition", "operationId": "updateExtensionPointDefinition", "parameters": [ { "description": "Name of extensionpointdefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, "description": "Updated extensionpointdefinition" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, "description": "Response extensionpointdefinitions updated just now" } }, "tags": [ "ExtensionPointDefinitionV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/plugins": { "get": { "description": "List Plugin", "operationId": "listPlugin", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PluginList" } } }, "description": "Response plugins" } }, "tags": [ "PluginV1alpha1" ] }, "post": { "description": "Create Plugin", "operationId": "createPlugin", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "Fresh plugin" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "Response plugins created just now" } }, "tags": [ "PluginV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/plugins/{name}": { "delete": { "description": "Delete Plugin", "operationId": "deletePlugin", "parameters": [ { "description": "Name of plugin", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response plugin deleted just now" } }, "tags": [ "PluginV1alpha1" ] }, "get": { "description": "Get Plugin", "operationId": "getPlugin", "parameters": [ { "description": "Name of plugin", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "Response single plugin" } }, "tags": [ "PluginV1alpha1" ] }, "patch": { "description": "Patch Plugin", "operationId": "patchPlugin", "parameters": [ { "description": "Name of plugin", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "Response plugin patched just now" } }, "tags": [ "PluginV1alpha1" ] }, "put": { "description": "Update Plugin", "operationId": "updatePlugin", "parameters": [ { "description": "Name of plugin", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "Updated plugin" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "Response plugins updated just now" } }, "tags": [ "PluginV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/reverseproxies": { "get": { "description": "List ReverseProxy", "operationId": "listReverseProxy", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReverseProxyList" } } }, "description": "Response reverseproxies" } }, "tags": [ "ReverseProxyV1alpha1" ] }, "post": { "description": "Create ReverseProxy", "operationId": "createReverseProxy", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReverseProxy" } } }, "description": "Fresh reverseproxy" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReverseProxy" } } }, "description": "Response reverseproxies created just now" } }, "tags": [ "ReverseProxyV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/reverseproxies/{name}": { "delete": { "description": "Delete ReverseProxy", "operationId": "deleteReverseProxy", "parameters": [ { "description": "Name of reverseproxy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response reverseproxy deleted just now" } }, "tags": [ "ReverseProxyV1alpha1" ] }, "get": { "description": "Get ReverseProxy", "operationId": "getReverseProxy", "parameters": [ { "description": "Name of reverseproxy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReverseProxy" } } }, "description": "Response single reverseproxy" } }, "tags": [ "ReverseProxyV1alpha1" ] }, "patch": { "description": "Patch ReverseProxy", "operationId": "patchReverseProxy", "parameters": [ { "description": "Name of reverseproxy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReverseProxy" } } }, "description": "Response reverseproxy patched just now" } }, "tags": [ "ReverseProxyV1alpha1" ] }, "put": { "description": "Update ReverseProxy", "operationId": "updateReverseProxy", "parameters": [ { "description": "Name of reverseproxy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReverseProxy" } } }, "description": "Updated reverseproxy" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReverseProxy" } } }, "description": "Response reverseproxies updated just now" } }, "tags": [ "ReverseProxyV1alpha1" ] } }, "/apis/security.halo.run/v1alpha1/devices": { "get": { "description": "List Device", "operationId": "listDevice", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/DeviceList" } } }, "description": "Response devices" } }, "tags": [ "DeviceV1alpha1" ] }, "post": { "description": "Create Device", "operationId": "createDevice", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Device" } } }, "description": "Fresh device" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Device" } } }, "description": "Response devices created just now" } }, "tags": [ "DeviceV1alpha1" ] } }, "/apis/security.halo.run/v1alpha1/devices/{name}": { "delete": { "description": "Delete Device", "operationId": "deleteDevice", "parameters": [ { "description": "Name of device", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response device deleted just now" } }, "tags": [ "DeviceV1alpha1" ] }, "get": { "description": "Get Device", "operationId": "getDevice", "parameters": [ { "description": "Name of device", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Device" } } }, "description": "Response single device" } }, "tags": [ "DeviceV1alpha1" ] }, "patch": { "description": "Patch Device", "operationId": "patchDevice", "parameters": [ { "description": "Name of device", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Device" } } }, "description": "Response device patched just now" } }, "tags": [ "DeviceV1alpha1" ] }, "put": { "description": "Update Device", "operationId": "updateDevice", "parameters": [ { "description": "Name of device", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Device" } } }, "description": "Updated device" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Device" } } }, "description": "Response devices updated just now" } }, "tags": [ "DeviceV1alpha1" ] } }, "/apis/security.halo.run/v1alpha1/personalaccesstokens": { "get": { "description": "List PersonalAccessToken", "operationId": "listPersonalAccessToken", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessTokenList" } } }, "description": "Response personalaccesstokens" } }, "tags": [ "PersonalAccessTokenV1alpha1" ] }, "post": { "description": "Create PersonalAccessToken", "operationId": "createPersonalAccessToken", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "description": "Fresh personalaccesstoken" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "description": "Response personalaccesstokens created just now" } }, "tags": [ "PersonalAccessTokenV1alpha1" ] } }, "/apis/security.halo.run/v1alpha1/personalaccesstokens/{name}": { "delete": { "description": "Delete PersonalAccessToken", "operationId": "deletePersonalAccessToken", "parameters": [ { "description": "Name of personalaccesstoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response personalaccesstoken deleted just now" } }, "tags": [ "PersonalAccessTokenV1alpha1" ] }, "get": { "description": "Get PersonalAccessToken", "operationId": "getPersonalAccessToken", "parameters": [ { "description": "Name of personalaccesstoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "description": "Response single personalaccesstoken" } }, "tags": [ "PersonalAccessTokenV1alpha1" ] }, "patch": { "description": "Patch PersonalAccessToken", "operationId": "patchPersonalAccessToken", "parameters": [ { "description": "Name of personalaccesstoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "description": "Response personalaccesstoken patched just now" } }, "tags": [ "PersonalAccessTokenV1alpha1" ] }, "put": { "description": "Update PersonalAccessToken", "operationId": "updatePersonalAccessToken", "parameters": [ { "description": "Name of personalaccesstoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "description": "Updated personalaccesstoken" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "description": "Response personalaccesstokens updated just now" } }, "tags": [ "PersonalAccessTokenV1alpha1" ] } }, "/apis/security.halo.run/v1alpha1/remembermetokens": { "get": { "description": "List RememberMeToken", "operationId": "listRememberMeToken", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RememberMeTokenList" } } }, "description": "Response remembermetokens" } }, "tags": [ "RememberMeTokenV1alpha1" ] }, "post": { "description": "Create RememberMeToken", "operationId": "createRememberMeToken", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RememberMeToken" } } }, "description": "Fresh remembermetoken" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RememberMeToken" } } }, "description": "Response remembermetokens created just now" } }, "tags": [ "RememberMeTokenV1alpha1" ] } }, "/apis/security.halo.run/v1alpha1/remembermetokens/{name}": { "delete": { "description": "Delete RememberMeToken", "operationId": "deleteRememberMeToken", "parameters": [ { "description": "Name of remembermetoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response remembermetoken deleted just now" } }, "tags": [ "RememberMeTokenV1alpha1" ] }, "get": { "description": "Get RememberMeToken", "operationId": "getRememberMeToken", "parameters": [ { "description": "Name of remembermetoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RememberMeToken" } } }, "description": "Response single remembermetoken" } }, "tags": [ "RememberMeTokenV1alpha1" ] }, "patch": { "description": "Patch RememberMeToken", "operationId": "patchRememberMeToken", "parameters": [ { "description": "Name of remembermetoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RememberMeToken" } } }, "description": "Response remembermetoken patched just now" } }, "tags": [ "RememberMeTokenV1alpha1" ] }, "put": { "description": "Update RememberMeToken", "operationId": "updateRememberMeToken", "parameters": [ { "description": "Name of remembermetoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RememberMeToken" } } }, "description": "Updated remembermetoken" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RememberMeToken" } } }, "description": "Response remembermetokens updated just now" } }, "tags": [ "RememberMeTokenV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/attachments": { "get": { "description": "List Attachment", "operationId": "listAttachment", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AttachmentList" } } }, "description": "Response attachments" } }, "tags": [ "AttachmentV1alpha1" ] }, "post": { "description": "Create Attachment", "operationId": "createAttachment", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "Fresh attachment" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "Response attachments created just now" } }, "tags": [ "AttachmentV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/attachments/{name}": { "delete": { "description": "Delete Attachment", "operationId": "deleteAttachment", "parameters": [ { "description": "Name of attachment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response attachment deleted just now" } }, "tags": [ "AttachmentV1alpha1" ] }, "get": { "description": "Get Attachment", "operationId": "getAttachment", "parameters": [ { "description": "Name of attachment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "Response single attachment" } }, "tags": [ "AttachmentV1alpha1" ] }, "patch": { "description": "Patch Attachment", "operationId": "patchAttachment", "parameters": [ { "description": "Name of attachment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "Response attachment patched just now" } }, "tags": [ "AttachmentV1alpha1" ] }, "put": { "description": "Update Attachment", "operationId": "updateAttachment", "parameters": [ { "description": "Name of attachment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "Updated attachment" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "Response attachments updated just now" } }, "tags": [ "AttachmentV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/groups": { "get": { "description": "List Group", "operationId": "listGroup", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/GroupList" } } }, "description": "Response groups" } }, "tags": [ "GroupV1alpha1" ] }, "post": { "description": "Create Group", "operationId": "createGroup", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "Fresh group" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "Response groups created just now" } }, "tags": [ "GroupV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/groups/{name}": { "delete": { "description": "Delete Group", "operationId": "deleteGroup", "parameters": [ { "description": "Name of group", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response group deleted just now" } }, "tags": [ "GroupV1alpha1" ] }, "get": { "description": "Get Group", "operationId": "getGroup", "parameters": [ { "description": "Name of group", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "Response single group" } }, "tags": [ "GroupV1alpha1" ] }, "patch": { "description": "Patch Group", "operationId": "patchGroup", "parameters": [ { "description": "Name of group", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "Response group patched just now" } }, "tags": [ "GroupV1alpha1" ] }, "put": { "description": "Update Group", "operationId": "updateGroup", "parameters": [ { "description": "Name of group", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "Updated group" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "Response groups updated just now" } }, "tags": [ "GroupV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/localthumbnails": { "get": { "description": "List LocalThumbnail", "operationId": "listLocalThumbnail", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/LocalThumbnailList" } } }, "description": "Response localthumbnails" } }, "tags": [ "LocalThumbnailV1alpha1" ] }, "post": { "description": "Create LocalThumbnail", "operationId": "createLocalThumbnail", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/LocalThumbnail" } } }, "description": "Fresh localthumbnail" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/LocalThumbnail" } } }, "description": "Response localthumbnails created just now" } }, "tags": [ "LocalThumbnailV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/localthumbnails/{name}": { "delete": { "description": "Delete LocalThumbnail", "operationId": "deleteLocalThumbnail", "parameters": [ { "description": "Name of localthumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response localthumbnail deleted just now" } }, "tags": [ "LocalThumbnailV1alpha1" ] }, "get": { "description": "Get LocalThumbnail", "operationId": "getLocalThumbnail", "parameters": [ { "description": "Name of localthumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/LocalThumbnail" } } }, "description": "Response single localthumbnail" } }, "tags": [ "LocalThumbnailV1alpha1" ] }, "patch": { "description": "Patch LocalThumbnail", "operationId": "patchLocalThumbnail", "parameters": [ { "description": "Name of localthumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/LocalThumbnail" } } }, "description": "Response localthumbnail patched just now" } }, "tags": [ "LocalThumbnailV1alpha1" ] }, "put": { "description": "Update LocalThumbnail", "operationId": "updateLocalThumbnail", "parameters": [ { "description": "Name of localthumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/LocalThumbnail" } } }, "description": "Updated localthumbnail" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/LocalThumbnail" } } }, "description": "Response localthumbnails updated just now" } }, "tags": [ "LocalThumbnailV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/policies": { "get": { "description": "List Policy", "operationId": "listPolicy", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyList" } } }, "description": "Response policies" } }, "tags": [ "PolicyV1alpha1" ] }, "post": { "description": "Create Policy", "operationId": "createPolicy", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Policy" } } }, "description": "Fresh policy" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Policy" } } }, "description": "Response policies created just now" } }, "tags": [ "PolicyV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/policies/{name}": { "delete": { "description": "Delete Policy", "operationId": "deletePolicy", "parameters": [ { "description": "Name of policy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response policy deleted just now" } }, "tags": [ "PolicyV1alpha1" ] }, "get": { "description": "Get Policy", "operationId": "getPolicy", "parameters": [ { "description": "Name of policy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Policy" } } }, "description": "Response single policy" } }, "tags": [ "PolicyV1alpha1" ] }, "patch": { "description": "Patch Policy", "operationId": "patchPolicy", "parameters": [ { "description": "Name of policy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Policy" } } }, "description": "Response policy patched just now" } }, "tags": [ "PolicyV1alpha1" ] }, "put": { "description": "Update Policy", "operationId": "updatePolicy", "parameters": [ { "description": "Name of policy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Policy" } } }, "description": "Updated policy" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Policy" } } }, "description": "Response policies updated just now" } }, "tags": [ "PolicyV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/policytemplates": { "get": { "description": "List PolicyTemplate", "operationId": "listPolicyTemplate", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyTemplateList" } } }, "description": "Response policytemplates" } }, "tags": [ "PolicyTemplateV1alpha1" ] }, "post": { "description": "Create PolicyTemplate", "operationId": "createPolicyTemplate", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyTemplate" } } }, "description": "Fresh policytemplate" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyTemplate" } } }, "description": "Response policytemplates created just now" } }, "tags": [ "PolicyTemplateV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/policytemplates/{name}": { "delete": { "description": "Delete PolicyTemplate", "operationId": "deletePolicyTemplate", "parameters": [ { "description": "Name of policytemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response policytemplate deleted just now" } }, "tags": [ "PolicyTemplateV1alpha1" ] }, "get": { "description": "Get PolicyTemplate", "operationId": "getPolicyTemplate", "parameters": [ { "description": "Name of policytemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyTemplate" } } }, "description": "Response single policytemplate" } }, "tags": [ "PolicyTemplateV1alpha1" ] }, "patch": { "description": "Patch PolicyTemplate", "operationId": "patchPolicyTemplate", "parameters": [ { "description": "Name of policytemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyTemplate" } } }, "description": "Response policytemplate patched just now" } }, "tags": [ "PolicyTemplateV1alpha1" ] }, "put": { "description": "Update PolicyTemplate", "operationId": "updatePolicyTemplate", "parameters": [ { "description": "Name of policytemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyTemplate" } } }, "description": "Updated policytemplate" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyTemplate" } } }, "description": "Response policytemplates updated just now" } }, "tags": [ "PolicyTemplateV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/thumbnails": { "get": { "description": "List Thumbnail", "operationId": "listThumbnail", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ThumbnailList" } } }, "description": "Response thumbnails" } }, "tags": [ "ThumbnailV1alpha1" ] }, "post": { "description": "Create Thumbnail", "operationId": "createThumbnail", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Thumbnail" } } }, "description": "Fresh thumbnail" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Thumbnail" } } }, "description": "Response thumbnails created just now" } }, "tags": [ "ThumbnailV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/thumbnails/{name}": { "delete": { "description": "Delete Thumbnail", "operationId": "deleteThumbnail", "parameters": [ { "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response thumbnail deleted just now" } }, "tags": [ "ThumbnailV1alpha1" ] }, "get": { "description": "Get Thumbnail", "operationId": "getThumbnail", "parameters": [ { "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Thumbnail" } } }, "description": "Response single thumbnail" } }, "tags": [ "ThumbnailV1alpha1" ] }, "patch": { "description": "Patch Thumbnail", "operationId": "patchThumbnail", "parameters": [ { "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Thumbnail" } } }, "description": "Response thumbnail patched just now" } }, "tags": [ "ThumbnailV1alpha1" ] }, "put": { "description": "Update Thumbnail", "operationId": "updateThumbnail", "parameters": [ { "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Thumbnail" } } }, "description": "Updated thumbnail" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Thumbnail" } } }, "description": "Response thumbnails updated just now" } }, "tags": [ "ThumbnailV1alpha1" ] } }, "/apis/theme.halo.run/v1alpha1/themes": { "get": { "description": "List Theme", "operationId": "listTheme", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ThemeList" } } }, "description": "Response themes" } }, "tags": [ "ThemeV1alpha1" ] }, "post": { "description": "Create Theme", "operationId": "createTheme", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "Fresh theme" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "Response themes created just now" } }, "tags": [ "ThemeV1alpha1" ] } }, "/apis/theme.halo.run/v1alpha1/themes/{name}": { "delete": { "description": "Delete Theme", "operationId": "deleteTheme", "parameters": [ { "description": "Name of theme", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response theme deleted just now" } }, "tags": [ "ThemeV1alpha1" ] }, "get": { "description": "Get Theme", "operationId": "getTheme", "parameters": [ { "description": "Name of theme", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "Response single theme" } }, "tags": [ "ThemeV1alpha1" ] }, "patch": { "description": "Patch Theme", "operationId": "patchTheme", "parameters": [ { "description": "Name of theme", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "Response theme patched just now" } }, "tags": [ "ThemeV1alpha1" ] }, "put": { "description": "Update Theme", "operationId": "updateTheme", "parameters": [ { "description": "Name of theme", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "Updated theme" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "Response themes updated just now" } }, "tags": [ "ThemeV1alpha1" ] } }, "/apis/uc.api.auth.halo.run/v1alpha1/user-connections/{registerId}/disconnect": { "put": { "description": "Disconnect my connection from a third-party platform.", "operationId": "DisconnectMyConnection", "parameters": [ { "description": "The registration ID of the third-party platform.", "in": "path", "name": "registerId", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/UserConnection" } } } }, "description": "default response" } }, "tags": [ "UserConnectionV1alpha1Uc" ] } }, "/apis/uc.api.content.halo.run/v1alpha1/posts": { "get": { "description": "List posts owned by the current user.", "operationId": "ListMyPosts", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Posts filtered by publish phase.", "in": "query", "name": "publishPhase", "schema": { "type": "string", "enum": [ "DRAFT", "PENDING_APPROVAL", "PUBLISHED", "FAILED" ] } }, { "description": "Posts filtered by keyword.", "in": "query", "name": "keyword", "schema": { "type": "string" } }, { "description": "Posts filtered by category including sub-categories.", "in": "query", "name": "categoryWithChildren", "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedPostList" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] }, "post": { "description": "Create my post. If you want to create a post with content, please set\n annotation: \"content.halo.run/content-json\" into annotations and refer\n to Content for corresponding data type.\n", "operationId": "CreateMyPost", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] } }, "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}": { "get": { "description": "Get post that belongs to the current user.", "operationId": "GetMyPost", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] }, "put": { "description": "Update my post.", "operationId": "UpdateMyPost", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] } }, "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}/draft": { "get": { "description": "Get my post draft.", "operationId": "GetMyPostDraft", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Should include patched content and raw or not.", "in": "query", "name": "patched", "schema": { "type": "boolean" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] }, "put": { "description": "Update draft of my post. Please make sure set annotation:\n\"content.halo.run/content-json\" into annotations and refer to\nContent for corresponding data type.\n", "operationId": "UpdateMyPostDraft", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] } }, "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}/publish": { "put": { "description": "Publish my post.", "operationId": "PublishMyPost", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] } }, "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}/recycle": { "delete": { "description": "Move my post to recycle bin.", "operationId": "RecycleMyPost", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] } }, "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}/unpublish": { "put": { "description": "Unpublish my post.", "operationId": "UnpublishMyPost", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] } }, "/apis/uc.api.content.halo.run/v1alpha1/snapshots/{name}": { "get": { "description": "Get snapshot for one post.", "operationId": "GetSnapshotForPost", "parameters": [ { "description": "Snapshot name.", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Post name.", "in": "query", "name": "postName", "required": true, "schema": { "type": "string" } }, { "description": "Should include patched content and raw or not.", "in": "query", "name": "patched", "schema": { "type": "boolean" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "default response" } }, "tags": [ "SnapshotV1alpha1Uc" ] } }, "/apis/uc.api.halo.run/v1alpha1/annotationsettings": { "get": { "description": "List available AnnotationSettings for the given targetRef. The available AnnotationSettings are determined by the currently activated theme and started plugins.", "operationId": "listAvailableAnnotationSettings", "parameters": [ { "description": "The targetRef of the AnnotationSetting. e.g.: \u0027content.halo.run/Post", "in": "query", "name": "targetRef", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/AnnotationSetting" } } } }, "description": "default response" } }, "tags": [ "AnnotationSettingV1AlphaUc" ] } }, "/apis/uc.api.halo.run/v1alpha1/user-preferences/{group}": { "get": { "description": "Get my preference by group.", "operationId": "getMyPreference", "parameters": [ { "description": "Group of user preference, e.g. `notification`.", "in": "path", "name": "group", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "object" } } }, "description": "default response" } }, "tags": [ "UserPreferenceV1alpha1Uc" ] }, "put": { "description": "Create or update my preference by group.", "operationId": "updateMyPreference", "parameters": [ { "description": "Group of user preference, e.g. `notification`.", "in": "path", "name": "group", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "type": "object" } } }, "required": true }, "responses": { "204": { "description": "No content, preference updated successfully." } }, "tags": [ "UserPreferenceV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/authentications/two-factor/settings": { "get": { "description": "Get Two-factor authentication settings.", "operationId": "GetTwoFactorAuthenticationSettings", "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TwoFactorAuthSettings" } } }, "description": "default response" } }, "tags": [ "TwoFactorAuthV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/authentications/two-factor/settings/disabled": { "put": { "description": "Disable Two-factor authentication", "operationId": "DisableTwoFactor", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PasswordRequest" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TwoFactorAuthSettings" } } }, "description": "default response" } }, "tags": [ "TwoFactorAuthV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/authentications/two-factor/settings/enabled": { "put": { "description": "Enable Two-factor authentication", "operationId": "EnableTwoFactor", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PasswordRequest" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TwoFactorAuthSettings" } } }, "description": "default response" } }, "tags": [ "TwoFactorAuthV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/authentications/two-factor/totp": { "post": { "description": "Configure a TOTP", "operationId": "ConfigurerTotp", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TotpRequest" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TwoFactorAuthSettings" } } }, "description": "default response" } }, "tags": [ "TwoFactorAuthV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/authentications/two-factor/totp/-": { "delete": { "operationId": "DeleteTotp", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PasswordRequest" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TwoFactorAuthSettings" } } }, "description": "default response" } }, "tags": [ "TwoFactorAuthV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/authentications/two-factor/totp/auth-link": { "get": { "description": "Get TOTP auth link, including secret", "operationId": "GetTotpAuthLink", "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TotpAuthLinkResponse" } } }, "description": "default response" } }, "tags": [ "TwoFactorAuthV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/devices": { "get": { "description": "List all user devices", "operationId": "ListDevices", "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/UserDevice" } } } }, "description": "default response" } }, "tags": [ "DeviceV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/devices/{deviceId}": { "delete": { "description": "Revoke a own device", "operationId": "RevokeDevice", "parameters": [ { "description": "Device ID", "in": "path", "name": "deviceId", "required": true, "schema": { "type": "string" } } ], "responses": { "204 NO_CONTENT": { "description": "default response" } }, "tags": [ "DeviceV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens": { "get": { "description": "Obtain PAT list.", "operationId": "ObtainPats", "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/PersonalAccessToken" } } } }, "description": "default response" } }, "tags": [ "PersonalAccessTokenV1alpha1Uc" ] }, "post": { "description": "Generate a PAT.", "operationId": "GeneratePat", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "description": "default response" } }, "tags": [ "PersonalAccessTokenV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens/{name}": { "delete": { "description": "Delete a PAT", "operationId": "DeletePat", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": {}, "tags": [ "PersonalAccessTokenV1alpha1Uc" ] }, "get": { "description": "Obtain a PAT.", "operationId": "ObtainPat", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": {}, "tags": [ "PersonalAccessTokenV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens/{name}/actions/restoration": { "put": { "description": "Restore a PAT.", "operationId": "RestorePat", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": {}, "tags": [ "PersonalAccessTokenV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens/{name}/actions/revocation": { "put": { "description": "Revoke a PAT", "operationId": "RevokePat", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": {}, "tags": [ "PersonalAccessTokenV1alpha1Uc" ] } }, "/apis/uc.api.storage.halo.run/v1alpha1/attachments": { "get": { "description": "List attachments of the current user uploaded.", "operationId": "ListMyAttachments", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Filter attachments without group. This parameter will ignore group parameter.", "in": "query", "name": "ungrouped", "schema": { "type": "boolean" } }, { "description": "Keyword for searching.", "in": "query", "name": "keyword", "schema": { "type": "string" } }, { "description": "Acceptable media types.", "in": "query", "name": "accepts", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AttachmentList" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Uc" ] }, "post": { "deprecated": true, "description": "Create attachment for the given post. Deprecated in favor of /attachments/-/upload.", "operationId": "CreateAttachmentForPost", "parameters": [ { "description": "Wait for permalink.", "in": "query", "name": "waitForPermalink", "schema": { "type": "boolean" } } ], "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PostAttachmentRequest" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Uc" ] } }, "/apis/uc.api.storage.halo.run/v1alpha1/attachments/-/upload": { "post": { "description": "Upload attachment to user center storage.", "operationId": "UploadAttachmentForUc", "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/UploadForm" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Uc" ] } }, "/apis/uc.api.storage.halo.run/v1alpha1/attachments/-/upload-from-url": { "post": { "deprecated": true, "description": "Upload attachment from the given URL.\nDeprecated in favor of /attachments/-/upload.", "operationId": "ExternalTransferAttachment_1", "parameters": [ { "description": "Wait for permalink.", "in": "query", "name": "waitForPermalink", "schema": { "type": "boolean" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UcUploadFromUrlRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Uc" ] } }, "/system/setup": { "get": { "description": "Jump to setup page", "operationId": "JumpToSetupPage", "responses": { "default": { "content": { "text/html": { "schema": { "type": "string" } } }, "description": "default response" } }, "tags": [ "SystemV1alpha1Public" ] }, "post": { "description": "Setup system", "operationId": "SetupSystem", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SetupRequest" } } }, "required": true }, "responses": { "204": { "content": {}, "description": "No Content" } }, "tags": [ "SystemV1alpha1Public" ] } } }, "components": { "schemas": { "AddOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "add" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "AnnotationSetting": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/AnnotationSettingSpec" } } }, "AnnotationSettingList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/AnnotationSetting" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "AnnotationSettingSpec": { "required": [ "formSchema", "targetRef" ], "type": "object", "properties": { "formSchema": { "minLength": 1, "type": "array", "items": { "minLength": 1, "type": "object" } }, "targetRef": { "$ref": "#/components/schemas/GroupKind" } } }, "Attachment": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/AttachmentSpec" }, "status": { "$ref": "#/components/schemas/AttachmentStatus" } } }, "AttachmentList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Attachment" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "AttachmentSpec": { "type": "object", "properties": { "displayName": { "type": "string", "description": "Display name of attachment" }, "groupName": { "type": "string", "description": "Group name" }, "mediaType": { "type": "string", "description": "Media type of attachment" }, "ownerName": { "type": "string", "description": "Name of User who uploads the attachment" }, "policyName": { "type": "string", "description": "Policy name" }, "size": { "minimum": 0, "type": "integer", "description": "Size of attachment. Unit is Byte", "format": "int64" }, "tags": { "uniqueItems": true, "type": "array", "description": "Tags of attachment", "items": { "type": "string", "description": "Tag name" } } } }, "AttachmentStatus": { "type": "object", "properties": { "permalink": { "type": "string", "description": "Permalink of attachment.\nIf it is in local storage, the public URL will be set.\nIf it is in s3 storage, the Object URL will be set.\n" }, "thumbnails": { "type": "object", "additionalProperties": { "type": "string" } } } }, "AuthProvider": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/AuthProviderSpec" } }, "description": "Auth provider extension." }, "AuthProviderList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/AuthProvider" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "AuthProviderSpec": { "required": [ "authType", "authenticationUrl", "displayName" ], "type": "object", "properties": { "authType": { "type": "string", "description": "Auth type: form or oauth2.", "enum": [ "FORM", "OAUTH2" ] }, "authenticationUrl": { "type": "string", "description": "Authentication url of the auth provider" }, "bindingUrl": { "type": "string" }, "configMapRef": { "$ref": "#/components/schemas/ConfigMapRef" }, "description": { "type": "string" }, "displayName": { "type": "string", "description": "Display name of the auth provider" }, "helpPage": { "type": "string" }, "logo": { "type": "string" }, "method": { "type": "string" }, "rememberMeSupport": { "type": "boolean" }, "settingRef": { "$ref": "#/components/schemas/SettingRef" }, "unbindUrl": { "type": "string" }, "website": { "type": "string" } } }, "Author": { "required": [ "name" ], "type": "object", "properties": { "name": { "minLength": 1, "type": "string" }, "website": { "type": "string" } } }, "Backup": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/BackupSpec" }, "status": { "$ref": "#/components/schemas/BackupStatus" } } }, "BackupFile": { "type": "object", "properties": { "filename": { "type": "string", "description": "Filename of backup file." }, "lastModifiedTime": { "type": "string", "description": "Last modified time of backup file.", "format": "date-time" }, "size": { "type": "integer", "description": "Size of backup file.", "format": "int64" } }, "description": "Backup file." }, "BackupList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Backup" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "BackupSpec": { "type": "object", "properties": { "expiresAt": { "type": "string", "format": "date-time" }, "format": { "type": "string", "description": "Backup file format. Currently, only zip format is supported." } } }, "BackupStatus": { "type": "object", "properties": { "completionTimestamp": { "type": "string", "format": "date-time" }, "failureMessage": { "type": "string" }, "failureReason": { "type": "string" }, "filename": { "type": "string", "description": "Name of backup file." }, "phase": { "type": "string", "enum": [ "PENDING", "RUNNING", "SUCCEEDED", "FAILED" ] }, "size": { "type": "integer", "description": "Size of backup file. Data unit: byte", "format": "int64" }, "startTimestamp": { "type": "string", "format": "date-time" } } }, "Category": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/CategorySpec" }, "status": { "$ref": "#/components/schemas/CategoryStatus" } } }, "CategoryList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Category" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "CategorySpec": { "required": [ "displayName", "priority", "slug" ], "type": "object", "properties": { "children": { "type": "array", "items": { "type": "string" } }, "cover": { "type": "string" }, "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "hideFromList": { "type": "boolean", "description": "\u003cp\u003eWhether to hide the category from the category list.\u003c/p\u003e\n \u003cp\u003eWhen set to true, the category including its subcategories and related posts will\n not be displayed in the category list, but it can still be accessed by permalink.\u003c/p\u003e\n \u003cp\u003eLimitation: It only takes effect on the theme-side categorized list and it only\n allows to be set to true on the first level(root node) of categories.\u003c/p\u003e" }, "postTemplate": { "maxLength": 255, "type": "string", "description": "\u003cp\u003eUsed to specify the template for the posts associated with the category.\u003c/p\u003e\n \u003cp\u003eThe priority is not as high as that of the post.\u003c/p\u003e\n \u003cp\u003eIf the post also specifies a template, the post\u0027s template will prevail.\u003c/p\u003e" }, "preventParentPostCascadeQuery": { "type": "boolean", "description": "\u003cp\u003eif a category is queried for related posts, the default behavior is to\n query all posts under the category including its subcategories, but if this field is\n set to true, cascade query behavior will be terminated here.\u003c/p\u003e\n \u003cp\u003eFor example, if a category has subcategories A and B, and A has subcategories C and\n D and C marked this field as true, when querying posts under A category,all posts under A\n and B will be queried, but C and D will not be queried.\u003c/p\u003e" }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "slug": { "minLength": 1, "type": "string" }, "template": { "maxLength": 255, "type": "string" } } }, "CategoryStatus": { "type": "object", "properties": { "permalink": { "type": "string" }, "postCount": { "type": "integer", "description": "包括当前和其下所有层级的文章数量 (depth\u003dmax).", "format": "int32" }, "visiblePostCount": { "type": "integer", "description": "包括当前和其下所有层级的已发布且公开的文章数量 (depth\u003dmax).", "format": "int32" } } }, "CategoryVo": { "required": [ "metadata" ], "type": "object", "properties": { "metadata": { "$ref": "#/components/schemas/Metadata" }, "postCount": { "type": "integer", "format": "int32" }, "spec": { "$ref": "#/components/schemas/CategorySpec" }, "status": { "$ref": "#/components/schemas/CategoryStatus" } }, "description": "A value object for {@link Category Category}." }, "CategoryVoList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/CategoryVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ChangeOwnPasswordRequest": { "required": [ "oldPassword", "password" ], "type": "object", "properties": { "oldPassword": { "type": "string", "description": "Old password." }, "password": { "minLength": 5, "type": "string", "description": "New password." } } }, "ChangePasswordRequest": { "required": [ "password" ], "type": "object", "properties": { "password": { "minLength": 5, "type": "string", "description": "New password." } } }, "Comment": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/CommentSpec" }, "status": { "$ref": "#/components/schemas/CommentStatus" } } }, "CommentEmailOwner": { "type": "object", "properties": { "avatar": { "type": "string", "description": "avatar for comment owner" }, "displayName": { "type": "string", "description": "display name for comment owner" }, "email": { "type": "string", "description": "email for comment owner" }, "website": { "type": "string", "description": "website for comment owner" } }, "description": "\u003cp\u003eThe creator info of the comment.\u003c/p\u003e\n This {@link CommentEmailOwner CommentEmailOwner} is only applicable to the user who is allowed to comment\n without logging in." }, "CommentList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Comment" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "CommentOwner": { "required": [ "kind", "name" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" } }, "displayName": { "type": "string" }, "kind": { "minLength": 1, "type": "string" }, "name": { "maxLength": 64, "type": "string" } } }, "CommentRequest": { "required": [ "content", "raw", "subjectRef" ], "type": "object", "properties": { "allowNotification": { "type": "boolean", "default": false }, "content": { "minLength": 1, "type": "string" }, "hidden": { "type": "boolean", "default": false }, "owner": { "$ref": "#/components/schemas/CommentEmailOwner" }, "raw": { "minLength": 1, "type": "string" }, "subjectRef": { "$ref": "#/components/schemas/Ref" } }, "description": "Request parameter object for {@link Comment Comment}." }, "CommentSpec": { "required": [ "allowNotification", "approved", "content", "hidden", "owner", "priority", "raw", "subjectRef", "top" ], "type": "object", "properties": { "allowNotification": { "type": "boolean", "default": true }, "approved": { "type": "boolean", "default": false }, "approvedTime": { "type": "string", "format": "date-time" }, "content": { "minLength": 1, "type": "string" }, "creationTime": { "type": "string", "description": "The user-defined creation time default is \u003ccode\u003emetadata.creationTimestamp\u003c/code\u003e.", "format": "date-time" }, "hidden": { "type": "boolean", "default": false }, "ipAddress": { "type": "string" }, "lastReadTime": { "type": "string", "format": "date-time" }, "owner": { "$ref": "#/components/schemas/CommentOwner" }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "raw": { "minLength": 1, "type": "string" }, "subjectRef": { "$ref": "#/components/schemas/Ref" }, "top": { "type": "boolean", "default": false }, "userAgent": { "type": "string" } } }, "CommentStats": { "type": "object", "properties": { "upvote": { "type": "integer", "format": "int32" } }, "description": "comment stats value object." }, "CommentStatsVo": { "type": "object", "properties": { "upvote": { "type": "integer", "format": "int32" } }, "description": "comment stats value object." }, "CommentStatus": { "type": "object", "properties": { "hasNewReply": { "type": "boolean" }, "lastReplyTime": { "type": "string", "format": "date-time" }, "observedVersion": { "type": "integer", "format": "int64" }, "replyCount": { "type": "integer", "format": "int32" }, "unreadReplyCount": { "type": "integer", "format": "int32" }, "visibleReplyCount": { "type": "integer", "format": "int32" } } }, "CommentVo": { "required": [ "metadata", "owner", "spec", "stats" ], "type": "object", "properties": { "metadata": { "$ref": "#/components/schemas/Metadata" }, "owner": { "$ref": "#/components/schemas/OwnerInfo" }, "spec": { "$ref": "#/components/schemas/CommentSpec" }, "stats": { "$ref": "#/components/schemas/CommentStatsVo" }, "status": { "$ref": "#/components/schemas/CommentStatus" } }, "description": "A chunk of items." }, "CommentVoList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/CommentVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "CommentWithReplyVo": { "required": [ "metadata", "owner", "spec", "stats" ], "type": "object", "properties": { "metadata": { "$ref": "#/components/schemas/Metadata" }, "owner": { "$ref": "#/components/schemas/OwnerInfo" }, "replies": { "$ref": "#/components/schemas/ListResultReplyVo" }, "spec": { "$ref": "#/components/schemas/CommentSpec" }, "stats": { "$ref": "#/components/schemas/CommentStatsVo" }, "status": { "$ref": "#/components/schemas/CommentStatus" } }, "description": "A chunk of items." }, "CommentWithReplyVoList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/CommentWithReplyVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "Condition": { "required": [ "lastTransitionTime", "status", "type" ], "type": "object", "properties": { "lastTransitionTime": { "type": "string", "description": "Last time the condition transitioned from one status to another.", "format": "date-time" }, "message": { "maxLength": 32768, "type": "string", "description": "Human-readable message indicating details about last transition.\n This may be an empty string." }, "reason": { "maxLength": 1024, "pattern": "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$", "type": "string", "description": "Unique, one-word, CamelCase reason for the condition\u0027s last transition." }, "status": { "type": "string", "description": "Status is the status of the condition. Can be True, False, Unknown.", "enum": [ "TRUE", "FALSE", "UNKNOWN" ] }, "type": { "maxLength": 316, "pattern": "^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$", "type": "string", "description": "type of condition in CamelCase or in foo.example.com/CamelCase.\n example: Ready, Initialized.\n maxLength: 316." } }, "description": "EqualsAndHashCode 排除了lastTransitionTime否则失败时,lastTransitionTime 会被更新\n 导致 equals 为 false,一直被加入队列." }, "ConfigMap": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "data": { "type": "object", "additionalProperties": { "type": "string" } }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" } }, "description": "\u003cp\u003eConfigMap holds configuration data to consume.\u003c/p\u003e" }, "ConfigMapList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ConfigMap" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ConfigMapRef": { "required": [ "name" ], "type": "object", "properties": { "name": { "minLength": 1, "type": "string" } } }, "Content": { "required": [ "content", "raw", "rawType" ], "type": "object", "properties": { "content": { "type": "string" }, "raw": { "type": "string" }, "rawType": { "type": "string" } } }, "ContentUpdateParam": { "required": [ "content", "raw", "rawType" ], "type": "object", "properties": { "content": { "type": "string" }, "raw": { "type": "string" }, "rawType": { "type": "string" }, "version": { "type": "integer", "format": "int64" } } }, "ContentVo": { "type": "object", "properties": { "content": { "type": "string" }, "raw": { "type": "string" } }, "description": "A value object for Content from {@link Snapshot Snapshot}." }, "ContentWrapper": { "type": "object", "properties": { "content": { "type": "string" }, "raw": { "type": "string" }, "rawType": { "type": "string" }, "snapshotName": { "type": "string" } } }, "Contributor": { "type": "object", "properties": { "avatar": { "type": "string" }, "displayName": { "type": "string" }, "name": { "type": "string" } }, "description": "Contributor from user." }, "ContributorVo": { "required": [ "metadata" ], "type": "object", "properties": { "avatar": { "type": "string" }, "bio": { "type": "string" }, "displayName": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "name": { "type": "string" }, "permalink": { "type": "string" } }, "description": "A value object for {@link run.halo.app.core.extension.User run.halo.app.core.extension.User}." }, "CopyOperation": { "required": [ "op", "from", "path" ], "type": "object", "properties": { "from": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "op": { "type": "string", "enum": [ "copy" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "Counter": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "approvedComment": { "type": "integer", "format": "int32" }, "downvote": { "type": "integer", "format": "int32" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "totalComment": { "type": "integer", "format": "int32" }, "upvote": { "type": "integer", "format": "int32" }, "visit": { "type": "integer", "format": "int32" } }, "description": "A counter for number of requests by extension resource name." }, "CounterList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Counter" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "CounterRequest": { "type": "object", "properties": { "group": { "type": "string" }, "hostname": { "type": "string" }, "language": { "type": "string" }, "name": { "type": "string" }, "plural": { "type": "string" }, "referrer": { "type": "string" }, "screen": { "type": "string" } } }, "CreateUserRequest": { "required": [ "email", "name" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" } }, "avatar": { "type": "string" }, "bio": { "type": "string" }, "displayName": { "type": "string" }, "email": { "type": "string" }, "name": { "type": "string" }, "password": { "type": "string" }, "phone": { "type": "string" }, "roles": { "uniqueItems": true, "type": "array", "items": { "type": "string" } } } }, "CustomTemplates": { "type": "object", "properties": { "category": { "type": "array", "items": { "$ref": "#/components/schemas/TemplateDescriptor" } }, "page": { "type": "array", "items": { "$ref": "#/components/schemas/TemplateDescriptor" } }, "post": { "type": "array", "items": { "$ref": "#/components/schemas/TemplateDescriptor" } } } }, "DashboardStats": { "type": "object", "properties": { "approvedComments": { "type": "integer", "format": "int64" }, "comments": { "type": "integer", "format": "int64" }, "posts": { "type": "integer", "format": "int64" }, "upvotes": { "type": "integer", "format": "int64" }, "users": { "type": "integer", "format": "int64" }, "visits": { "type": "integer", "format": "int64" } } }, "DetailedUser": { "required": [ "roles", "user" ], "type": "object", "properties": { "roles": { "type": "array", "items": { "$ref": "#/components/schemas/Role" } }, "user": { "$ref": "#/components/schemas/User" } } }, "Device": { "required": [ "apiVersion", "kind", "metadata", "spec", "status" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/DeviceSpec" }, "status": { "$ref": "#/components/schemas/DeviceStatus" } } }, "DeviceList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Device" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "DeviceSpec": { "required": [ "ipAddress", "principalName", "sessionId" ], "type": "object", "properties": { "ipAddress": { "maxLength": 129, "type": "string" }, "lastAccessedTime": { "type": "string", "format": "date-time" }, "lastAuthenticatedTime": { "type": "string", "format": "date-time" }, "principalName": { "minLength": 1, "type": "string" }, "rememberMeSeriesId": { "type": "string" }, "sessionId": { "minLength": 1, "type": "string" }, "userAgent": { "maxLength": 500, "type": "string" } } }, "DeviceStatus": { "type": "object", "properties": { "browser": { "type": "string" }, "os": { "type": "string" } } }, "EmailConfigValidationRequest": { "type": "object", "properties": { "displayName": { "type": "string", "description": "Gets email display name." }, "enable": { "type": "boolean" }, "encryption": { "type": "string" }, "host": { "type": "string" }, "password": { "type": "string" }, "port": { "type": "integer", "format": "int32" }, "sender": { "type": "string", "description": "Gets email sender address." }, "username": { "type": "string" } } }, "EmailVerifyRequest": { "required": [ "email" ], "type": "object", "properties": { "email": { "type": "string", "format": "email" } } }, "Excerpt": { "required": [ "autoGenerate" ], "type": "object", "properties": { "autoGenerate": { "type": "boolean", "default": true }, "raw": { "type": "string" } } }, "Extension": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" } }, "description": "Extension is an interface which represents an Extension. It contains setters and getters of\n GroupVersionKind and Metadata." }, "ExtensionDefinition": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ExtensionSpec" } }, "description": "Extension definition.\n An {@link ExtensionDefinition ExtensionDefinition} is a type of metadata that provides additional information about\n an extension. An extension is a way to add new functionality to an existing class, structure,\n enumeration, or protocol type without needing to subclass it." }, "ExtensionDefinitionList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ExtensionDefinition" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ExtensionPointDefinition": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ExtensionPointSpec" } }, "description": "Extension point definition.\n An {@link ExtensionPointDefinition ExtensionPointDefinition} is a concept used in \u003ccode\u003eHalo\u003c/code\u003e to allow for the\n dynamic extension of system. It defines a location within \u003ccode\u003eHalo\u003c/code\u003e where\n additional functionality can be added through the use of plugins or extensions." }, "ExtensionPointDefinitionList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ExtensionPointDefinition" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ExtensionPointSpec": { "required": [ "className", "displayName", "type" ], "type": "object", "properties": { "className": { "type": "string" }, "description": { "type": "string" }, "displayName": { "type": "string" }, "icon": { "type": "string" }, "type": { "type": "string", "enum": [ "SINGLETON", "MULTI_INSTANCE" ] } } }, "ExtensionSpec": { "required": [ "className", "displayName", "extensionPointName" ], "type": "object", "properties": { "className": { "type": "string" }, "description": { "type": "string" }, "displayName": { "type": "string" }, "extensionPointName": { "type": "string" }, "icon": { "type": "string" } } }, "FileReverseProxyProvider": { "type": "object", "properties": { "directory": { "type": "string" }, "filename": { "type": "string" } } }, "GrantRequest": { "type": "object", "properties": { "roles": { "uniqueItems": true, "type": "array", "items": { "type": "string" } } } }, "Group": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/GroupSpec" }, "status": { "$ref": "#/components/schemas/GroupStatus" } } }, "GroupKind": { "type": "object", "properties": { "group": { "type": "string", "description": "is group name of Extension." }, "kind": { "type": "string", "description": "is kind name of Extension." } }, "description": "GroupKind contains group and kind data only." }, "GroupList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Group" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "GroupSpec": { "required": [ "displayName" ], "type": "object", "properties": { "displayName": { "type": "string", "description": "Display name of group" } } }, "GroupStatus": { "type": "object", "properties": { "totalAttachments": { "minimum": 0, "type": "integer", "description": "Total of attachments under the current group", "format": "int64" }, "updateTimestamp": { "type": "string", "description": "Update timestamp of the group", "format": "date-time" } } }, "HaloDocument": { "required": [ "content", "id", "metadataName", "ownerName", "permalink", "title", "type" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Custom metadata. Make sure the map is serializable." }, "categories": { "type": "array", "description": "Document categories. The item in the list is the category metadata name.", "items": { "type": "string" } }, "content": { "minLength": 1, "type": "string", "description": "Document content. Safety content, without HTML tag." }, "creationTimestamp": { "type": "string", "description": "Document creation timestamp.", "format": "date-time" }, "description": { "type": "string", "description": "Document description." }, "exposed": { "type": "boolean", "description": "Whether the document is exposed to the public." }, "id": { "minLength": 1, "type": "string", "description": "Document ID. It should be unique globally." }, "metadataName": { "minLength": 1, "type": "string", "description": "Metadata name of the corresponding extension." }, "ownerName": { "minLength": 1, "type": "string", "description": "Document owner metadata name." }, "permalink": { "minLength": 1, "type": "string", "description": "Document permalink." }, "published": { "type": "boolean", "description": "Whether the document is published." }, "recycled": { "type": "boolean", "description": "Whether the document is recycled." }, "tags": { "type": "array", "description": "Document tags. The item in the list is the tag metadata name.", "items": { "type": "string" } }, "title": { "minLength": 1, "type": "string", "description": "Document title." }, "type": { "minLength": 1, "type": "string", "description": "Document type. e.g.: post.content.halo.run, singlepage.content.halo.run, moment.moment\n .halo.run, doc.doc.halo.run." }, "updateTimestamp": { "type": "string", "description": "Document update timestamp.", "format": "date-time" } }, "description": "Document for search." }, "IAvatarUploadRequest": { "required": [ "file" ], "type": "object", "properties": { "file": { "type": "string", "format": "binary" } } }, "IUploadRequest": { "required": [ "file", "policyName" ], "type": "object", "properties": { "file": { "type": "string", "format": "binary" }, "groupName": { "type": "string", "description": "The name of the group to which the attachment belongs" }, "policyName": { "type": "string", "description": "Storage policy name" } } }, "InstallFromUriRequest": { "required": [ "uri" ], "type": "object", "properties": { "uri": { "type": "string", "format": "uri" } } }, "InterestReason": { "required": [ "reasonType", "subject" ], "type": "object", "properties": { "expression": { "type": "string", "description": "The expression to be interested in" }, "reasonType": { "type": "string", "description": "The name of the reason definition to be interested in" }, "subject": { "$ref": "#/components/schemas/InterestReasonSubject" } }, "description": "The reason to be interested in" }, "InterestReasonSubject": { "required": [ "apiVersion", "kind" ], "type": "object", "properties": { "apiVersion": { "minLength": 1, "type": "string" }, "kind": { "minLength": 1, "type": "string" }, "name": { "type": "string", "description": "if name is not specified, it presents all subjects of the specified reason type and custom resources" } }, "description": "The subject name of reason type to be interested in" }, "JsonPatch": { "minItems": 1, "uniqueItems": true, "type": "array", "description": "JSON schema for JSONPatch operations", "items": { "oneOf": [ { "$ref": "#/components/schemas/AddOperation" }, { "$ref": "#/components/schemas/ReplaceOperation" }, { "$ref": "#/components/schemas/TestOperation" }, { "$ref": "#/components/schemas/RemoveOperation" }, { "$ref": "#/components/schemas/MoveOperation" }, { "$ref": "#/components/schemas/CopyOperation" } ] } }, "License": { "type": "object", "properties": { "name": { "type": "string" }, "url": { "type": "string" } }, "description": "Common data objects for license." }, "ListResultReplyVo": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ReplyVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ListedAuthProvider": { "required": [ "displayName", "name" ], "type": "object", "properties": { "authType": { "type": "string", "enum": [ "FORM", "OAUTH2" ] }, "authenticationUrl": { "type": "string" }, "bindingUrl": { "type": "string" }, "description": { "type": "string" }, "displayName": { "type": "string" }, "enabled": { "type": "boolean" }, "helpPage": { "type": "string" }, "isBound": { "type": "boolean" }, "logo": { "type": "string" }, "name": { "type": "string" }, "priority": { "type": "integer", "format": "int32" }, "privileged": { "type": "boolean" }, "supportsBinding": { "type": "boolean" }, "unbindingUrl": { "type": "string" }, "website": { "type": "string" } }, "description": "A listed value object for {@link run.halo.app.core.extension.AuthProvider run.halo.app.core.extension.AuthProvider}." }, "ListedComment": { "required": [ "comment", "owner", "stats" ], "type": "object", "properties": { "comment": { "$ref": "#/components/schemas/Comment" }, "owner": { "$ref": "#/components/schemas/OwnerInfo" }, "stats": { "$ref": "#/components/schemas/CommentStats" }, "subject": { "$ref": "#/components/schemas/Extension" } }, "description": "A chunk of items." }, "ListedCommentList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedComment" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ListedPost": { "required": [ "categories", "contributors", "owner", "post", "stats", "tags" ], "type": "object", "properties": { "categories": { "type": "array", "items": { "$ref": "#/components/schemas/Category" } }, "contributors": { "type": "array", "items": { "$ref": "#/components/schemas/Contributor" } }, "owner": { "$ref": "#/components/schemas/Contributor" }, "post": { "$ref": "#/components/schemas/Post" }, "stats": { "$ref": "#/components/schemas/Stats" }, "tags": { "type": "array", "items": { "$ref": "#/components/schemas/Tag" } } }, "description": "A chunk of items." }, "ListedPostList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedPost" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ListedPostVo": { "required": [ "metadata" ], "type": "object", "properties": { "categories": { "type": "array", "items": { "$ref": "#/components/schemas/CategoryVo" } }, "contributors": { "type": "array", "items": { "$ref": "#/components/schemas/ContributorVo" } }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "owner": { "$ref": "#/components/schemas/ContributorVo" }, "spec": { "$ref": "#/components/schemas/PostSpec" }, "stats": { "$ref": "#/components/schemas/StatsVo" }, "status": { "$ref": "#/components/schemas/PostStatus" }, "tags": { "type": "array", "items": { "$ref": "#/components/schemas/TagVo" } } }, "description": "A value object for {@link Post Post}." }, "ListedPostVoList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedPostVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ListedReply": { "required": [ "owner", "reply", "stats" ], "type": "object", "properties": { "owner": { "$ref": "#/components/schemas/OwnerInfo" }, "reply": { "$ref": "#/components/schemas/Reply" }, "stats": { "$ref": "#/components/schemas/CommentStats" } }, "description": "A chunk of items." }, "ListedReplyList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedReply" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ListedSinglePage": { "required": [ "contributors", "owner", "page", "stats" ], "type": "object", "properties": { "contributors": { "type": "array", "items": { "$ref": "#/components/schemas/Contributor" } }, "owner": { "$ref": "#/components/schemas/Contributor" }, "page": { "$ref": "#/components/schemas/SinglePage" }, "stats": { "$ref": "#/components/schemas/Stats" } }, "description": "A chunk of items." }, "ListedSinglePageList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedSinglePage" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ListedSinglePageVo": { "required": [ "metadata" ], "type": "object", "properties": { "contributors": { "type": "array", "items": { "$ref": "#/components/schemas/ContributorVo" } }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "owner": { "$ref": "#/components/schemas/ContributorVo" }, "spec": { "$ref": "#/components/schemas/SinglePageSpec" }, "stats": { "$ref": "#/components/schemas/StatsVo" }, "status": { "$ref": "#/components/schemas/SinglePageStatus" } }, "description": "A chunk of items." }, "ListedSinglePageVoList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedSinglePageVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ListedSnapshotDto": { "required": [ "metadata", "spec" ], "type": "object", "properties": { "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ListedSnapshotSpec" } } }, "ListedSnapshotSpec": { "required": [ "owner" ], "type": "object", "properties": { "modifyTime": { "type": "string", "format": "date-time" }, "owner": { "type": "string" } } }, "ListedUser": { "required": [ "roles", "user" ], "type": "object", "properties": { "roles": { "type": "array", "items": { "$ref": "#/components/schemas/Role" } }, "user": { "$ref": "#/components/schemas/User" } }, "description": "A chunk of items." }, "LocalThumbnail": { "required": [ "apiVersion", "kind", "metadata", "spec", "status" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/LocalThumbnailSpec" }, "status": { "$ref": "#/components/schemas/LocalThumbnailStatus" } } }, "LocalThumbnailList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/LocalThumbnail" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "LocalThumbnailSpec": { "required": [ "filePath", "imageSignature", "imageUri", "size", "thumbSignature", "thumbnailUri" ], "type": "object", "properties": { "filePath": { "type": "string", "description": "Consider the compatibility of the system and migration, use unix-style relative paths\n here." }, "imageSignature": { "minLength": 1, "type": "string", "description": "A hash signature for the image uri." }, "imageUri": { "minLength": 1, "type": "string" }, "size": { "type": "string", "enum": [ "S", "M", "L", "XL" ] }, "thumbSignature": { "minLength": 1, "type": "string", "description": "A hash signature for the thumbnail uri." }, "thumbnailUri": { "minLength": 1, "type": "string" } } }, "LocalThumbnailStatus": { "type": "object", "properties": { "phase": { "type": "string", "enum": [ "PENDING", "SUCCEEDED", "FAILED" ] } } }, "MarkSpecifiedRequest": { "type": "object", "properties": { "names": { "type": "array", "items": { "type": "string" } } } }, "Menu": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/MenuSpec" } } }, "MenuItem": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/MenuItemSpec" }, "status": { "$ref": "#/components/schemas/MenuItemStatus" } } }, "MenuItemList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/MenuItem" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "MenuItemSpec": { "type": "object", "properties": { "children": { "uniqueItems": true, "type": "array", "description": "Children of this menu item", "items": { "type": "string", "description": "The name of menu item child" } }, "displayName": { "type": "string", "description": "The display name of menu item." }, "href": { "type": "string", "description": "The href of this menu item." }, "priority": { "type": "integer", "description": "The priority is for ordering.", "format": "int32" }, "target": { "type": "string", "description": "The \u003ca\u003e target attribute of this menu item.", "enum": [ "_blank", "_self", "_parent", "_top" ] }, "targetRef": { "$ref": "#/components/schemas/Ref" } } }, "MenuItemStatus": { "type": "object", "properties": { "displayName": { "type": "string", "description": "Calculated Display name of menu item." }, "href": { "type": "string", "description": "Calculated href of manu item." } } }, "MenuItemVo": { "required": [ "metadata" ], "type": "object", "properties": { "children": { "type": "array", "items": { "$ref": "#/components/schemas/MenuItemVo" } }, "displayName": { "type": "string", "description": "Gets menu item\u0027s display name." }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "parentName": { "type": "string" }, "spec": { "$ref": "#/components/schemas/MenuItemSpec" }, "status": { "$ref": "#/components/schemas/MenuItemStatus" } }, "description": "A value object for {@link MenuItem MenuItem}." }, "MenuList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Menu" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "MenuSpec": { "required": [ "displayName" ], "type": "object", "properties": { "displayName": { "type": "string", "description": "The display name of the menu." }, "menuItems": { "uniqueItems": true, "type": "array", "description": "Menu items of this menu.", "items": { "type": "string", "description": "Name of menu item." } } } }, "MenuVo": { "required": [ "metadata" ], "type": "object", "properties": { "menuItems": { "type": "array", "items": { "$ref": "#/components/schemas/MenuItemVo" } }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/MenuSpec" } }, "description": "A value object for {@link Menu Menu}." }, "Metadata": { "required": [ "name" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Annotations are like key-value format." }, "creationTimestamp": { "type": "string", "description": "Creation timestamp of the Extension.", "format": "date-time", "nullable": true }, "deletionTimestamp": { "type": "string", "description": "Deletion timestamp of the Extension.", "format": "date-time", "nullable": true }, "finalizers": { "uniqueItems": true, "type": "array", "nullable": true, "items": { "type": "string", "nullable": true } }, "generateName": { "type": "string", "description": "The name field will be generated automatically according to the given generateName field" }, "labels": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Labels are like key-value format." }, "name": { "type": "string", "description": "Metadata name" }, "version": { "type": "integer", "description": "Current version of the Extension. It will be bumped up every update.", "format": "int64", "nullable": true } }, "description": "Metadata of Extension." }, "MoveOperation": { "required": [ "op", "from", "path" ], "type": "object", "properties": { "from": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "op": { "type": "string", "enum": [ "move" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "NavigationPostVo": { "type": "object", "properties": { "next": { "$ref": "#/components/schemas/ListedPostVo" }, "previous": { "$ref": "#/components/schemas/ListedPostVo" } }, "description": "Post navigation vo to hold previous and next item." }, "Notification": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/NotificationSpec" } }, "description": "\u003cp\u003e{@link Notification Notification} is a custom extension that used to store notification information for\n inner use, it\u0027s on-site notification.\u003c/p\u003e\n\n \u003cp\u003eSupports the following operations:\u003c/p\u003e\n \u003cul\u003e\n \u003cli\u003eMarked as read: {@link NotificationSpec#setUnread(boolean) NotificationSpec#setUnread(boolean)}\u003c/li\u003e\n \u003cli\u003eGet the last read time: {@link NotificationSpec#getLastReadAt NotificationSpec#getLastReadAt()}\u003c/li\u003e\n \u003cli\u003eFilter by recipient: {@link NotificationSpec#getRecipient NotificationSpec#getRecipient()}\u003c/li\u003e\n \u003c/ul\u003e" }, "NotificationList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Notification" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "NotificationSpec": { "required": [ "htmlContent", "rawContent", "reason", "recipient", "title" ], "type": "object", "properties": { "htmlContent": { "type": "string" }, "lastReadAt": { "type": "string", "format": "date-time" }, "rawContent": { "type": "string" }, "reason": { "minLength": 1, "type": "string", "description": "The name of reason" }, "recipient": { "minLength": 1, "type": "string", "description": "The name of user" }, "title": { "minLength": 1, "type": "string" }, "unread": { "type": "boolean" } } }, "NotificationTemplate": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/NotificationTemplateSpec" } }, "description": "\u003cp\u003e{@link NotificationTemplate NotificationTemplate} is a custom extension that defines a notification template.\u003c/p\u003e\n \u003cp\u003eIt describes the notification template\u0027s name, description, and the template content.\u003c/p\u003e\n \u003cp\u003e{@link Spec#getReasonSelector Spec#getReasonSelector()} is used to select the template by reasonType and language,\n if multiple templates are matched, the best match will be selected. This is useful when you\n want to override the default template.\u003c/p\u003e" }, "NotificationTemplateList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/NotificationTemplate" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "NotificationTemplateSpec": { "type": "object", "properties": { "reasonSelector": { "$ref": "#/components/schemas/ReasonSelector" }, "template": { "$ref": "#/components/schemas/TemplateContent" } } }, "NotifierDescriptor": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/NotifierDescriptorSpec" } }, "description": "\u003cp\u003e{@link NotifierDescriptor NotifierDescriptor} is a custom extension that defines a notifier.\u003c/p\u003e\n \u003cp\u003eIt describes the notifier\u0027s name, description, and the extension name of the notifier to\n let the user know what the notifier is and what it can do in the UI and also let the\n \u003ccode\u003eNotificationCenter\u003c/code\u003e know how to load the notifier and prepare the notifier\u0027s settings.\u003c/p\u003e" }, "NotifierDescriptorList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/NotifierDescriptor" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "NotifierDescriptorSpec": { "required": [ "displayName", "notifierExtName" ], "type": "object", "properties": { "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "notifierExtName": { "minLength": 1, "type": "string" }, "receiverSettingRef": { "$ref": "#/components/schemas/NotifierSettingRef" }, "senderSettingRef": { "$ref": "#/components/schemas/NotifierSettingRef" } } }, "NotifierInfo": { "type": "object", "properties": { "description": { "type": "string" }, "displayName": { "type": "string" }, "name": { "type": "string" } } }, "NotifierSettingRef": { "required": [ "group", "name" ], "type": "object", "properties": { "group": { "type": "string" }, "name": { "type": "string" } } }, "OwnerInfo": { "type": "object", "properties": { "avatar": { "type": "string" }, "displayName": { "type": "string" }, "email": { "type": "string" }, "kind": { "type": "string" }, "name": { "type": "string" } }, "description": "Comment owner info." }, "PasswordRequest": { "required": [ "password" ], "type": "object", "properties": { "password": { "minLength": 1, "type": "string" } } }, "PatSpec": { "required": [ "name", "tokenId", "username" ], "type": "object", "properties": { "description": { "type": "string" }, "expiresAt": { "type": "string", "format": "date-time" }, "lastUsed": { "type": "string", "format": "date-time" }, "name": { "type": "string" }, "revoked": { "type": "boolean" }, "revokesAt": { "type": "string", "format": "date-time" }, "roles": { "type": "array", "items": { "type": "string" } }, "scopes": { "type": "array", "items": { "type": "string" } }, "tokenId": { "type": "string" }, "username": { "type": "string" } } }, "PersonalAccessToken": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/PatSpec" } } }, "PersonalAccessTokenList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/PersonalAccessToken" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "Plugin": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/PluginSpec" }, "status": { "$ref": "#/components/schemas/PluginStatus" } }, "description": "A custom resource for Plugin." }, "PluginAuthor": { "required": [ "name" ], "type": "object", "properties": { "name": { "minLength": 1, "type": "string" }, "website": { "type": "string" } } }, "PluginInstallRequest": { "type": "object", "properties": { "file": { "type": "string", "format": "binary" }, "presetName": { "type": "string", "description": "Plugin preset name. We will find the plugin from plugin presets" }, "source": { "type": "string", "description": "Install source. Default is file.", "enum": [ "FILE", "PRESET", "URL" ] } } }, "PluginList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Plugin" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "PluginRunningStateRequest": { "type": "object", "properties": { "async": { "type": "boolean" }, "enable": { "type": "boolean" } } }, "PluginSpec": { "required": [ "version" ], "type": "object", "properties": { "author": { "$ref": "#/components/schemas/PluginAuthor" }, "configMapName": { "type": "string" }, "description": { "type": "string" }, "displayName": { "type": "string" }, "enabled": { "type": "boolean" }, "homepage": { "type": "string" }, "issues": { "type": "string" }, "license": { "type": "array", "items": { "$ref": "#/components/schemas/License" } }, "logo": { "type": "string" }, "pluginDependencies": { "type": "object", "additionalProperties": { "type": "string" } }, "repo": { "type": "string" }, "requires": { "type": "string", "description": "SemVer format." }, "settingName": { "type": "string" }, "version": { "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", "type": "string", "description": "plugin version." } } }, "PluginStatus": { "type": "object", "properties": { "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "entry": { "type": "string" }, "lastProbeState": { "type": "string", "enum": [ "CREATED", "DISABLED", "RESOLVED", "STARTED", "STOPPED", "FAILED", "UNLOADED" ] }, "lastStartTime": { "type": "string", "format": "date-time" }, "loadLocation": { "type": "string", "description": "Load location of the plugin, often a path.", "format": "uri" }, "logo": { "type": "string" }, "phase": { "type": "string", "enum": [ "PENDING", "STARTING", "CREATED", "DISABLING", "DISABLED", "RESOLVED", "STARTED", "STOPPED", "FAILED", "UNKNOWN" ] }, "stylesheet": { "type": "string" } } }, "Policy": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/PolicySpec" } } }, "PolicyList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Policy" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "PolicyRule": { "type": "object", "properties": { "apiGroups": { "type": "array", "description": "APIGroups is the name of the APIGroup that contains the resources.\n If multiple API groups are specified, any action requested against one of the enumerated\n resources in any API group will be allowed.", "items": { "type": "string" } }, "nonResourceURLs": { "type": "array", "description": "NonResourceURLs is a set of partial urls that a user should have access to.\n *s are allowed, but only as the full, final step in the path\n If an action is not a resource API request, then the URL is split on \u0027/\u0027 and is checked\n against the NonResourceURLs to look for a match.\n Since non-resource URLs are not namespaced, this field is only applicable for\n ClusterRoles referenced from a ClusterRoleBinding.\n Rules can either apply to API resources (such as \"pods\" or \"secrets\") or non-resource\n URL paths (such as \"/api\"), but not both.", "items": { "type": "string" } }, "resourceNames": { "type": "array", "description": "ResourceNames is an optional white list of names that the rule applies to. An empty set\n means that everything is allowed.", "items": { "type": "string" } }, "resources": { "type": "array", "description": "Resources is a list of resources this rule applies to. \u0027*\u0027 represents all resources in\n the specified apiGroups.\n \u0027*\u0026#47;foo\u0027 represents the subresource \u0027foo\u0027 for all resources in the specified\n apiGroups.", "items": { "type": "string" } }, "verbs": { "type": "array", "description": "about who the rule applies to or which namespace the rule applies to.", "items": { "type": "string" } } }, "description": "PolicyRule holds information that describes a policy rule, but does not contain information\n about whom the rule applies to or which namespace the rule applies to." }, "PolicySpec": { "required": [ "displayName", "templateName" ], "type": "object", "properties": { "configMapName": { "type": "string", "description": "Reference name of ConfigMap extension" }, "displayName": { "type": "string", "description": "Display name of policy" }, "templateName": { "type": "string", "description": "Reference name of PolicyTemplate" } } }, "PolicyTemplate": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/PolicyTemplateSpec" } } }, "PolicyTemplateList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/PolicyTemplate" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "PolicyTemplateSpec": { "required": [ "settingName" ], "type": "object", "properties": { "displayName": { "type": "string" }, "settingName": { "type": "string" } } }, "Post": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/PostSpec" }, "status": { "$ref": "#/components/schemas/PostStatus" } }, "description": "\u003cp\u003ePost extension.\u003c/p\u003e" }, "PostAttachmentRequest": { "required": [ "file" ], "type": "object", "properties": { "file": { "type": "string", "format": "binary" }, "postName": { "type": "string", "description": "Post name." }, "singlePageName": { "type": "string", "description": "Single page name." } } }, "PostList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Post" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "PostRequest": { "required": [ "content", "post" ], "type": "object", "properties": { "content": { "$ref": "#/components/schemas/ContentUpdateParam" }, "post": { "$ref": "#/components/schemas/Post" } }, "description": "Post and content data for creating and updating post." }, "PostSpec": { "required": [ "allowComment", "deleted", "excerpt", "pinned", "priority", "publish", "slug", "title", "visible" ], "type": "object", "properties": { "allowComment": { "type": "boolean", "default": true }, "baseSnapshot": { "type": "string" }, "categories": { "type": "array", "items": { "type": "string" } }, "cover": { "type": "string" }, "deleted": { "type": "boolean", "default": false }, "excerpt": { "$ref": "#/components/schemas/Excerpt" }, "headSnapshot": { "type": "string" }, "htmlMetas": { "type": "array", "items": { "type": "object", "additionalProperties": { "type": "string" } } }, "owner": { "type": "string" }, "pinned": { "type": "boolean", "default": false }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "publish": { "type": "boolean", "default": false }, "publishTime": { "type": "string", "format": "date-time" }, "releaseSnapshot": { "type": "string", "description": "文章引用到的已发布的内容,用于主题端显示." }, "slug": { "minLength": 1, "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } }, "template": { "type": "string" }, "title": { "minLength": 1, "type": "string" }, "visible": { "type": "string", "enum": [ "PUBLIC", "INTERNAL", "PRIVATE" ], "default": "PUBLIC" } } }, "PostStatus": { "type": "object", "properties": { "commentsCount": { "type": "integer", "format": "int32" }, "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "contributors": { "type": "array", "items": { "type": "string" } }, "excerpt": { "type": "string" }, "hideFromList": { "type": "boolean", "description": "see {@link Category.CategorySpec#isHideFromList Category.CategorySpec#isHideFromList()}." }, "inProgress": { "type": "boolean" }, "lastModifyTime": { "type": "string", "format": "date-time" }, "observedVersion": { "type": "integer", "format": "int64" }, "permalink": { "type": "string" }, "phase": { "type": "string" } } }, "PostVo": { "required": [ "metadata" ], "type": "object", "properties": { "categories": { "type": "array", "items": { "$ref": "#/components/schemas/CategoryVo" } }, "content": { "$ref": "#/components/schemas/ContentVo" }, "contributors": { "type": "array", "items": { "$ref": "#/components/schemas/ContributorVo" } }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "owner": { "$ref": "#/components/schemas/ContributorVo" }, "spec": { "$ref": "#/components/schemas/PostSpec" }, "stats": { "$ref": "#/components/schemas/StatsVo" }, "status": { "$ref": "#/components/schemas/PostStatus" }, "tags": { "type": "array", "items": { "$ref": "#/components/schemas/TagVo" } } }, "description": "A value object for {@link Post Post}." }, "Reason": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ReasonSpec" } }, "description": "\u003cp\u003e{@link Reason Reason} is a custom extension that defines a reason for a notification, It represents\n an instance of a {@link ReasonType ReasonType}.\u003c/p\u003e\n \u003cp\u003eIt can be understood as an event that triggers a notification.\u003c/p\u003e" }, "ReasonAttributes": { "type": "object", "properties": { "empty": { "type": "boolean" } }, "description": "Attributes used to transfer data" }, "ReasonList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Reason" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ReasonProperty": { "required": [ "name", "type" ], "type": "object", "properties": { "description": { "type": "string" }, "name": { "minLength": 1, "type": "string" }, "optional": { "type": "boolean", "default": false }, "type": { "minLength": 1, "type": "string" } } }, "ReasonSelector": { "required": [ "language", "reasonType" ], "type": "object", "properties": { "language": { "minLength": 1, "type": "string", "default": "default" }, "reasonType": { "minLength": 1, "type": "string" } } }, "ReasonSpec": { "required": [ "author", "reasonType", "subject" ], "type": "object", "properties": { "attributes": { "$ref": "#/components/schemas/ReasonAttributes" }, "author": { "type": "string" }, "reasonType": { "type": "string" }, "subject": { "$ref": "#/components/schemas/ReasonSubject" } } }, "ReasonSubject": { "required": [ "apiVersion", "kind", "name", "title" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "name": { "type": "string" }, "title": { "type": "string" }, "url": { "type": "string" } } }, "ReasonType": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ReasonTypeSpec" } }, "description": "\u003cp\u003e{@link ReasonType ReasonType} is a custom extension that defines a type of reason.\u003c/p\u003e\n \u003cp\u003eOne {@link ReasonType ReasonType} can have multiple {@link Reason Reason}s to notify.\u003c/p\u003e" }, "ReasonTypeInfo": { "type": "object", "properties": { "description": { "type": "string" }, "displayName": { "type": "string" }, "name": { "type": "string" }, "uiPermissions": { "uniqueItems": true, "type": "array", "items": { "type": "string" } } } }, "ReasonTypeList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ReasonType" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ReasonTypeNotifierCollectionRequest": { "required": [ "reasonTypeNotifiers" ], "type": "object", "properties": { "reasonTypeNotifiers": { "type": "array", "items": { "$ref": "#/components/schemas/ReasonTypeNotifierRequest" } } } }, "ReasonTypeNotifierMatrix": { "type": "object", "properties": { "notifiers": { "type": "array", "items": { "$ref": "#/components/schemas/NotifierInfo" } }, "reasonTypes": { "type": "array", "items": { "$ref": "#/components/schemas/ReasonTypeInfo" } }, "stateMatrix": { "type": "array", "items": { "type": "array", "items": { "type": "boolean" } } } } }, "ReasonTypeNotifierRequest": { "type": "object", "properties": { "notifiers": { "type": "array", "items": { "type": "string" } }, "reasonType": { "type": "string" } } }, "ReasonTypeSpec": { "required": [ "description", "displayName" ], "type": "object", "properties": { "description": { "minLength": 1, "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "properties": { "type": "array", "items": { "$ref": "#/components/schemas/ReasonProperty" } } } }, "Ref": { "required": [ "group", "kind", "name" ], "type": "object", "properties": { "group": { "type": "string", "description": "Extension group" }, "kind": { "type": "string", "description": "Extension kind" }, "name": { "type": "string", "description": "Extension name. This field is mandatory" }, "version": { "type": "string", "description": "Extension version" } }, "description": "Extension reference object. The name is mandatory" }, "RememberMeToken": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/RememberMeTokenSpec" } } }, "RememberMeTokenList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/RememberMeToken" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "RememberMeTokenSpec": { "required": [ "series", "tokenValue", "username" ], "type": "object", "properties": { "lastUsed": { "type": "string", "format": "date-time" }, "series": { "minLength": 1, "type": "string" }, "tokenValue": { "minLength": 1, "type": "string" }, "username": { "minLength": 1, "type": "string" } } }, "RemoveOperation": { "required": [ "op", "path" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "remove" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "ReplaceOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "replace" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "Reply": { "required": [ "apiVersion", "kind", "metadata", "spec", "status" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ReplySpec" }, "status": { "$ref": "#/components/schemas/ReplyStatus" } } }, "ReplyList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Reply" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ReplyRequest": { "required": [ "content", "raw" ], "type": "object", "properties": { "allowNotification": { "type": "boolean", "default": false }, "content": { "minLength": 1, "type": "string" }, "hidden": { "type": "boolean", "default": false }, "owner": { "$ref": "#/components/schemas/CommentEmailOwner" }, "quoteReply": { "type": "string" }, "raw": { "minLength": 1, "type": "string" } }, "description": "A request parameter object for {@link Reply Reply}." }, "ReplySpec": { "required": [ "allowNotification", "approved", "commentName", "content", "hidden", "owner", "priority", "raw", "top" ], "type": "object", "properties": { "allowNotification": { "type": "boolean", "default": true }, "approved": { "type": "boolean", "default": false }, "approvedTime": { "type": "string", "format": "date-time" }, "commentName": { "minLength": 1, "type": "string" }, "content": { "minLength": 1, "type": "string" }, "creationTime": { "type": "string", "description": "The user-defined creation time default is \u003ccode\u003emetadata.creationTimestamp\u003c/code\u003e.", "format": "date-time" }, "hidden": { "type": "boolean", "default": false }, "ipAddress": { "type": "string" }, "owner": { "$ref": "#/components/schemas/CommentOwner" }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "quoteReply": { "type": "string" }, "raw": { "minLength": 1, "type": "string" }, "top": { "type": "boolean", "default": false }, "userAgent": { "type": "string" } } }, "ReplyStatus": { "type": "object", "properties": { "observedVersion": { "type": "integer", "format": "int64" } } }, "ReplyVo": { "required": [ "metadata", "owner", "spec", "stats" ], "type": "object", "properties": { "metadata": { "$ref": "#/components/schemas/Metadata" }, "owner": { "$ref": "#/components/schemas/OwnerInfo" }, "spec": { "$ref": "#/components/schemas/ReplySpec" }, "stats": { "$ref": "#/components/schemas/CommentStatsVo" } }, "description": "A chunk of items." }, "ReplyVoList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ReplyVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "RestoreRequest": { "type": "object", "properties": { "backupName": { "type": "string", "description": "Backup metadata name." }, "downloadUrl": { "type": "string", "description": "Remote backup HTTP URL." }, "file": { "type": "string", "format": "binary" }, "filename": { "type": "string", "description": "Filename of backup file in backups root." } } }, "ReverseProxy": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "rules": { "type": "array", "items": { "$ref": "#/components/schemas/ReverseProxyRule" } } }, "description": "\u003cp\u003eThe reverse proxy custom resource is used to configure a path to proxy it to a directory or\n file.\u003c/p\u003e\n \u003cp\u003eHTTP proxy may be added in the future.\u003c/p\u003e" }, "ReverseProxyList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ReverseProxy" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ReverseProxyRule": { "type": "object", "properties": { "file": { "$ref": "#/components/schemas/FileReverseProxyProvider" }, "path": { "type": "string" } } }, "RevertSnapshotForPostParam": { "required": [ "snapshotName" ], "type": "object", "properties": { "snapshotName": { "minLength": 1, "type": "string" } } }, "RevertSnapshotForSingleParam": { "required": [ "snapshotName" ], "type": "object", "properties": { "snapshotName": { "minLength": 1, "type": "string" } } }, "Role": { "required": [ "apiVersion", "kind", "metadata", "rules" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "rules": { "type": "array", "items": { "$ref": "#/components/schemas/PolicyRule" } } } }, "RoleBinding": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "roleRef": { "$ref": "#/components/schemas/RoleRef" }, "subjects": { "type": "array", "description": "Subjects holds references to the objects the role applies to.", "items": { "$ref": "#/components/schemas/Subject" } } }, "description": "RoleBinding references a role, but does not contain it.\n It can reference a Role in the global.\n It adds who information via Subjects." }, "RoleBindingList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/RoleBinding" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "RoleList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Role" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "RoleRef": { "type": "object", "properties": { "apiGroup": { "type": "string", "description": "APIGroup is the group for the resource being referenced." }, "kind": { "type": "string", "description": "Kind is the type of resource being referenced." }, "name": { "type": "string", "description": "Name is the name of resource being referenced." } }, "description": "RoleRef contains information that points to the role being used." }, "SearchOption": { "required": [ "keyword" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Additional annotations for extending search option by other search engines." }, "filterExposed": { "type": "boolean", "description": "Whether to filter exposed content. If null, it will not filter." }, "filterPublished": { "type": "boolean", "description": "Whether to filter published content. If null, it will not filter." }, "filterRecycled": { "type": "boolean", "description": "Whether to filter recycled content. If null, it will not filter." }, "highlightPostTag": { "type": "string", "description": "Post HTML tag of highlighted fragment." }, "highlightPreTag": { "type": "string", "description": "Pre HTML tag of highlighted fragment." }, "includeCategoryNames": { "type": "array", "description": "Category names to include(and). If null, it will include all categories.", "items": { "type": "string" } }, "includeOwnerNames": { "type": "array", "description": "Owner names to include(or). If null, it will include all owners.", "items": { "type": "string" } }, "includeTagNames": { "type": "array", "description": "Tag names to include(and). If null, it will include all tags.", "items": { "type": "string" } }, "includeTypes": { "type": "array", "description": "Types to include(or). If null, it will include all types.", "items": { "type": "string" } }, "keyword": { "minLength": 1, "type": "string", "description": "Search keyword." }, "limit": { "maximum": 1000, "minimum": 1, "type": "integer", "description": "Limit of result.", "format": "int32" } }, "description": "Search option. It is used to control search behavior." }, "SearchResult": { "type": "object", "properties": { "hits": { "type": "array", "items": { "$ref": "#/components/schemas/HaloDocument" } }, "keyword": { "type": "string" }, "limit": { "type": "integer", "format": "int32" }, "processingTimeMillis": { "type": "integer", "format": "int64" }, "total": { "type": "integer", "format": "int64" } } }, "Secret": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "data": { "type": "object", "additionalProperties": { "type": "string", "format": "byte" }, "description": "\u003cp\u003eThe total bytes of the values in\n the Data field must be less than {@link run.halo.app.extension.Secret#MAX_SECRET_SIZE #MAX_SECRET_SIZE} bytes.\u003c/p\u003e\n \u003cp\u003e\u003ccode\u003edata\u003c/code\u003e contains the secret data. Each key must consist of alphanumeric\n characters, \u0027-\u0027, \u0027_\u0027 or \u0027.\u0027. The serialized form of the secret data is a\n base64 encoded string, representing the arbitrary (possibly non-string)\n data value here. Described in\n \u003ca href\u003d\"https://tools.ietf.org/html/rfc4648#section-4\"\u003erfc4648#section-4\u003c/a\u003e\n \u003c/p\u003e" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "stringData": { "type": "object", "additionalProperties": { "type": "string" }, "description": "\u003ccode\u003estringData\u003c/code\u003e allows specifying non-binary secret data in string form.\n It is provided as a write-only input field for convenience.\n All keys and values are merged into the data field on write, overwriting any existing\n values.\n The stringData field is never output when reading from the API." }, "type": { "type": "string", "description": "Used to facilitate programmatic handling of secret data.\n More info:\n \u003ca href\u003d\"https://kubernetes.io/docs/concepts/configuration/secret/#secret-types\"\u003esecret-types\u003c/a\u003e" } }, "description": "Secret is a small piece of sensitive data which should be kept secret, such as a password,\n a token, or a key." }, "SecretList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Secret" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "Setting": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/SettingSpec" } }, "description": "{@link Setting Setting} is a custom extension to generate forms based on configuration." }, "SettingForm": { "minLength": 1, "required": [ "formSchema", "group" ], "type": "object", "properties": { "formSchema": { "type": "array", "items": { "type": "object" } }, "group": { "type": "string" }, "label": { "type": "string" } } }, "SettingList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Setting" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "SettingRef": { "required": [ "group", "name" ], "type": "object", "properties": { "group": { "minLength": 1, "type": "string" }, "name": { "minLength": 1, "type": "string" } } }, "SettingSpec": { "required": [ "forms" ], "type": "object", "properties": { "forms": { "minLength": 1, "type": "array", "items": { "$ref": "#/components/schemas/SettingForm" } } } }, "SetupRequest": { "required": [ "externalUrl", "password", "siteTitle", "username" ], "type": "object", "properties": { "email": { "type": "string", "format": "email" }, "externalUrl": { "type": "string" }, "language": { "pattern": "^(zh-CN|zh-TW|en|es)$", "type": "string" }, "password": { "maxLength": 257, "minLength": 5, "pattern": "^[A-Za-z0-9!@#$%^\u0026*.?]+$", "type": "string" }, "siteTitle": { "maxLength": 80, "minLength": 0, "type": "string" }, "username": { "maxLength": 63, "minLength": 4, "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$", "type": "string" } } }, "SinglePage": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/SinglePageSpec" }, "status": { "$ref": "#/components/schemas/SinglePageStatus" } }, "description": "\u003cp\u003eSingle page extension.\u003c/p\u003e" }, "SinglePageList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/SinglePage" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "SinglePageRequest": { "required": [ "content", "page" ], "type": "object", "properties": { "content": { "$ref": "#/components/schemas/ContentUpdateParam" }, "page": { "$ref": "#/components/schemas/SinglePage" } }, "description": "A request parameter for {@link SinglePage SinglePage}." }, "SinglePageSpec": { "required": [ "allowComment", "deleted", "excerpt", "pinned", "priority", "publish", "slug", "title", "visible" ], "type": "object", "properties": { "allowComment": { "type": "boolean", "default": true }, "baseSnapshot": { "type": "string" }, "cover": { "type": "string" }, "deleted": { "type": "boolean", "default": false }, "excerpt": { "$ref": "#/components/schemas/Excerpt" }, "headSnapshot": { "type": "string" }, "htmlMetas": { "type": "array", "items": { "type": "object", "additionalProperties": { "type": "string" } } }, "owner": { "type": "string" }, "pinned": { "type": "boolean", "default": false }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "publish": { "type": "boolean", "default": false }, "publishTime": { "type": "string", "format": "date-time" }, "releaseSnapshot": { "type": "string", "description": "引用到的已发布的内容,用于主题端显示." }, "slug": { "minLength": 1, "type": "string" }, "template": { "type": "string" }, "title": { "minLength": 1, "type": "string" }, "visible": { "type": "string", "enum": [ "PUBLIC", "INTERNAL", "PRIVATE" ], "default": "PUBLIC" } } }, "SinglePageStatus": { "type": "object", "properties": { "commentsCount": { "type": "integer", "format": "int32" }, "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "contributors": { "type": "array", "items": { "type": "string" } }, "excerpt": { "type": "string" }, "hideFromList": { "type": "boolean", "description": "see {@link Category.CategorySpec#isHideFromList Category.CategorySpec#isHideFromList()}." }, "inProgress": { "type": "boolean" }, "lastModifyTime": { "type": "string", "format": "date-time" }, "observedVersion": { "type": "integer", "format": "int64" }, "permalink": { "type": "string" }, "phase": { "type": "string" } } }, "SinglePageVo": { "required": [ "metadata" ], "type": "object", "properties": { "content": { "$ref": "#/components/schemas/ContentVo" }, "contributors": { "type": "array", "items": { "$ref": "#/components/schemas/ContributorVo" } }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "owner": { "$ref": "#/components/schemas/ContributorVo" }, "spec": { "$ref": "#/components/schemas/SinglePageSpec" }, "stats": { "$ref": "#/components/schemas/StatsVo" }, "status": { "$ref": "#/components/schemas/SinglePageStatus" } }, "description": "A value object for {@link SinglePage SinglePage}." }, "SiteStatsVo": { "type": "object", "properties": { "category": { "type": "integer", "format": "int32" }, "comment": { "type": "integer", "format": "int32" }, "post": { "type": "integer", "format": "int32" }, "upvote": { "type": "integer", "format": "int32" }, "visit": { "type": "integer", "format": "int32" } }, "description": "A value object for site stats." }, "SnapShotSpec": { "required": [ "owner", "rawType", "subjectRef" ], "type": "object", "properties": { "contentPatch": { "type": "string" }, "contributors": { "uniqueItems": true, "type": "array", "items": { "type": "string" } }, "lastModifyTime": { "type": "string", "format": "date-time" }, "owner": { "minLength": 1, "type": "string" }, "parentSnapshotName": { "type": "string" }, "rawPatch": { "type": "string" }, "rawType": { "maxLength": 50, "minLength": 1, "type": "string", "description": "such as: markdown | html | json | asciidoc | latex." }, "subjectRef": { "$ref": "#/components/schemas/Ref" } } }, "Snapshot": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/SnapShotSpec" } } }, "SnapshotList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Snapshot" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "Stats": { "type": "object", "properties": { "approvedComment": { "type": "integer", "format": "int32" }, "totalComment": { "type": "integer", "format": "int32" }, "upvote": { "type": "integer", "format": "int32" }, "visit": { "type": "integer", "format": "int32" } }, "description": "Stats value object." }, "StatsVo": { "type": "object", "properties": { "comment": { "type": "integer", "format": "int32" }, "upvote": { "type": "integer", "format": "int32" }, "visit": { "type": "integer", "format": "int32" } }, "description": "Stats value object." }, "Subject": { "type": "object", "properties": { "apiGroup": { "type": "string", "description": "APIGroup holds the API group of the referenced subject.\n Defaults to \"\" for ServiceAccount subjects.\n Defaults to \"rbac.authorization.halo.run\" for User and Group subjects." }, "kind": { "type": "string", "description": "Kind of object being referenced. Values defined by this API group are \"User\", \"Group\",\n and \"ServiceAccount\".\n If the Authorizer does not recognize the kind value, the Authorizer should report\n an error." }, "name": { "type": "string", "description": "Name of the object being referenced." } } }, "Subscription": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/SubscriptionSpec" } }, "description": "\u003cp\u003e{@link Subscription Subscription} is a custom extension that defines a subscriber to be notified when a\n certain {@link Reason Reason} is triggered.\u003c/p\u003e\n \u003cp\u003eIt holds a {@link Subscriber Subscriber} to the user to be notified, a {@link InterestReason InterestReason} to\n subscribe to.\u003c/p\u003e" }, "SubscriptionList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Subscription" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "SubscriptionSpec": { "required": [ "reason", "subscriber", "unsubscribeToken" ], "type": "object", "properties": { "disabled": { "type": "boolean", "description": "Perhaps users need to unsubscribe and interact without receiving notifications again" }, "reason": { "$ref": "#/components/schemas/InterestReason" }, "subscriber": { "$ref": "#/components/schemas/SubscriptionSubscriber" }, "unsubscribeToken": { "type": "string", "description": "The token to unsubscribe" } } }, "SubscriptionSubscriber": { "required": [ "name" ], "type": "object", "properties": { "name": { "minLength": 1, "type": "string" } }, "description": "The subscriber to be notified" }, "Tag": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/TagSpec" }, "status": { "$ref": "#/components/schemas/TagStatus" } } }, "TagList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Tag" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "TagSpec": { "required": [ "displayName", "slug" ], "type": "object", "properties": { "color": { "pattern": "^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$", "type": "string", "description": "Color regex explanation.\n \u003cpre\u003e\n ^ # start of the line\n # # start with a number sign `#`\n ( # start of (group 1)\n [a-fA-F0-9]{6} # support z-f, A-F and 0-9, with a length of 6\n | # or\n [a-fA-F0-9]{3} # support z-f, A-F and 0-9, with a length of 3\n ) # end of (group 1)\n $ # end of the line\n \u003c/pre\u003e" }, "cover": { "type": "string" }, "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "slug": { "minLength": 1, "type": "string" } } }, "TagStatus": { "type": "object", "properties": { "observedVersion": { "type": "integer", "format": "int64" }, "permalink": { "type": "string" }, "postCount": { "type": "integer", "format": "int32" }, "visiblePostCount": { "type": "integer", "format": "int32" } } }, "TagVo": { "required": [ "metadata" ], "type": "object", "properties": { "metadata": { "$ref": "#/components/schemas/Metadata" }, "postCount": { "type": "integer", "format": "int32" }, "spec": { "$ref": "#/components/schemas/TagSpec" }, "status": { "$ref": "#/components/schemas/TagStatus" } }, "description": "A value object for {@link Tag Tag}." }, "TagVoList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/TagVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "TemplateContent": { "required": [ "title" ], "type": "object", "properties": { "htmlBody": { "type": "string" }, "rawBody": { "type": "string" }, "title": { "minLength": 1, "type": "string" } } }, "TemplateDescriptor": { "required": [ "file", "name" ], "type": "object", "properties": { "description": { "type": "string" }, "file": { "minLength": 1, "type": "string" }, "name": { "minLength": 1, "type": "string" }, "screenshot": { "type": "string" } }, "description": "Type used to describe custom template page." }, "TestOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "test" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "Theme": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ThemeSpec" }, "status": { "$ref": "#/components/schemas/ThemeStatus" } }, "description": "\u003cp\u003eTheme extension.\u003c/p\u003e" }, "ThemeInstallRequest": { "type": "object" }, "ThemeList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Theme" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ThemeSpec": { "required": [ "author", "displayName" ], "type": "object", "properties": { "author": { "$ref": "#/components/schemas/Author" }, "configMapName": { "type": "string" }, "customTemplates": { "$ref": "#/components/schemas/CustomTemplates" }, "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "homepage": { "type": "string" }, "issues": { "type": "string" }, "license": { "type": "array", "items": { "$ref": "#/components/schemas/License" } }, "logo": { "type": "string" }, "repo": { "type": "string" }, "requires": { "type": "string" }, "settingName": { "type": "string" }, "version": { "type": "string" } } }, "ThemeStatus": { "type": "object", "properties": { "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "location": { "type": "string" }, "phase": { "type": "string", "enum": [ "READY", "FAILED", "UNKNOWN" ] } } }, "Thumbnail": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ThumbnailSpec" } } }, "ThumbnailList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Thumbnail" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ThumbnailSpec": { "required": [ "imageSignature", "imageUri", "size", "thumbnailUri" ], "type": "object", "properties": { "imageSignature": { "minLength": 1, "type": "string" }, "imageUri": { "minLength": 1, "type": "string" }, "size": { "type": "string", "enum": [ "S", "M", "L", "XL" ] }, "thumbnailUri": { "minLength": 1, "type": "string" } } }, "TotpAuthLinkResponse": { "type": "object", "properties": { "authLink": { "type": "string", "description": "QR Code with base64 encoded.", "format": "uri" }, "rawSecret": { "type": "string" } } }, "TotpRequest": { "required": [ "code", "password", "secret" ], "type": "object", "properties": { "code": { "type": "string" }, "password": { "minLength": 1, "type": "string" }, "secret": { "minLength": 1, "type": "string" } } }, "TwoFactorAuthSettings": { "type": "object", "properties": { "available": { "type": "boolean", "description": "Check if 2FA is available." }, "emailVerified": { "type": "boolean" }, "enabled": { "type": "boolean" }, "totpConfigured": { "type": "boolean" } } }, "UcUploadFromUrlRequest": { "required": [ "url" ], "type": "object", "properties": { "filename": { "type": "string", "description": "Custom file name" }, "url": { "type": "string", "format": "url" } } }, "UpgradeFromUriRequest": { "required": [ "uri" ], "type": "object", "properties": { "uri": { "type": "string", "format": "uri" } } }, "UpgradeRequest": { "required": [ "file" ], "type": "object", "properties": { "file": { "type": "string", "format": "binary" } } }, "UploadForm": { "type": "object", "properties": { "file": { "type": "string", "description": "The file to upload. If not provided, the url will be used.", "format": "binary" }, "filename": { "type": "string", "description": "The filename to use when uploading from url. If not provided, the filename will be\n extracted from the url." }, "url": { "type": "string", "description": "The url to upload from. If not provided, the file will be used." } }, "description": "Upload form from console. The file and url are mutually exclusive. If both are provided,\n the file will be used." }, "UploadFromUrlRequest": { "required": [ "policyName", "url" ], "type": "object", "properties": { "filename": { "type": "string", "description": "Custom file name" }, "groupName": { "type": "string", "description": "The name of the group to which the attachment belongs" }, "policyName": { "type": "string", "description": "Storage policy name" }, "url": { "type": "string", "format": "url" } } }, "User": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/UserSpec" }, "status": { "$ref": "#/components/schemas/UserStatus" } }, "description": "The extension represents user details of Halo." }, "UserConnection": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/UserConnectionSpec" } }, "description": "User connection extension." }, "UserConnectionList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/UserConnection" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "UserConnectionSpec": { "required": [ "providerUserId", "registrationId", "username" ], "type": "object", "properties": { "providerUserId": { "type": "string", "description": "The unique identifier for the user\u0027s connection to the OAuth provider.\n for example, the user\u0027s GitHub id." }, "registrationId": { "type": "string", "description": "The name of the OAuth provider (e.g. Google, Facebook, Twitter)." }, "updatedAt": { "type": "string", "description": "The time when the user connection was last updated.", "format": "date-time" }, "username": { "type": "string", "description": "The {@link Metadata#getName Metadata#getName()} of the user associated with the OAuth connection." } } }, "UserDevice": { "required": [ "active", "currentDevice", "device" ], "type": "object", "properties": { "active": { "type": "boolean" }, "currentDevice": { "type": "boolean" }, "device": { "$ref": "#/components/schemas/Device" } } }, "UserEndpoint.ListedUserList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedUser" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "UserList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/User" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "UserPermission": { "required": [ "permissions", "roles", "uiPermissions" ], "type": "object", "properties": { "permissions": { "type": "array", "items": { "$ref": "#/components/schemas/Role" } }, "roles": { "type": "array", "items": { "$ref": "#/components/schemas/Role" } }, "uiPermissions": { "type": "array", "items": { "type": "string" } } } }, "UserSpec": { "required": [ "displayName", "email" ], "type": "object", "properties": { "avatar": { "type": "string" }, "bio": { "type": "string" }, "disabled": { "type": "boolean" }, "displayName": { "type": "string" }, "email": { "type": "string" }, "emailVerified": { "type": "boolean" }, "loginHistoryLimit": { "type": "integer", "format": "int32" }, "password": { "type": "string" }, "phone": { "type": "string" }, "registeredAt": { "type": "string", "format": "date-time" }, "totpEncryptedSecret": { "type": "string" }, "twoFactorAuthEnabled": { "type": "boolean" } } }, "UserStatus": { "type": "object", "properties": { "permalink": { "type": "string" } } }, "VerifyCodeRequest": { "required": [ "code", "password" ], "type": "object", "properties": { "code": { "minLength": 1, "type": "string" }, "password": { "type": "string" } } }, "VoteRequest": { "type": "object", "properties": { "group": { "type": "string" }, "name": { "type": "string" }, "plural": { "type": "string" } } } }, "securitySchemes": { "basicAuth": { "scheme": "basic", "type": "http" }, "bearerAuth": { "bearerFormat": "JWT", "scheme": "bearer", "type": "http" } } } } ================================================ FILE: api-docs/openapi/v3_0/apis_console.api_v1alpha1.json ================================================ { "openapi": "3.0.1", "info": { "title": "Halo", "version": "2.23.0-SNAPSHOT" }, "servers": [ { "url": "http://localhost:8091", "description": "Generated server url" } ], "security": [ { "basicAuth": [], "bearerAuth": [] } ], "paths": { "/apis/api.console.halo.run/v1alpha1/attachments": { "get": { "operationId": "SearchAttachments", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Filter attachments without group. This parameter will ignore group parameter.", "in": "query", "name": "ungrouped", "schema": { "type": "boolean" } }, { "description": "Keyword for searching.", "in": "query", "name": "keyword", "schema": { "type": "string" } }, { "description": "Acceptable media types.", "in": "query", "name": "accepts", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AttachmentList" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/attachments/-/upload-from-url": { "post": { "operationId": "ExternalTransferAttachment", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UploadFromUrlRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/attachments/upload": { "post": { "operationId": "UploadAttachment", "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/IUploadRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/auth-providers": { "get": { "description": "Lists all auth providers", "operationId": "listAuthProviders", "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ListedAuthProvider" } } } }, "description": "default response" } }, "tags": [ "AuthProviderV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/auth-providers/{name}/disable": { "put": { "description": "Disables an auth provider", "operationId": "disableAuthProvider", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "default response" } }, "tags": [ "AuthProviderV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/auth-providers/{name}/enable": { "put": { "description": "Enables an auth provider", "operationId": "enableAuthProvider", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "default response" } }, "tags": [ "AuthProviderV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/comments": { "get": { "description": "List comments.", "operationId": "ListComments", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Comments filtered by keyword.", "in": "query", "name": "keyword", "schema": { "type": "string" } }, { "description": "Commenter kind.", "in": "query", "name": "ownerKind", "schema": { "type": "string" } }, { "description": "Commenter name.", "in": "query", "name": "ownerName", "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedCommentList" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Console" ] }, "post": { "description": "Create a comment.", "operationId": "CreateComment", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommentRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/comments/{name}/reply": { "post": { "description": "Create a reply.", "operationId": "CreateReply", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReplyRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/indices/-/rebuild": { "post": { "description": "Rebuild all indices", "operationId": "RebuildAllIndices", "responses": {}, "tags": [ "IndicesV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/notifiers/{name}/sender-config": { "get": { "description": "Fetch sender config of notifier", "operationId": "FetchSenderConfig", "parameters": [ { "description": "Notifier name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "object" } } }, "description": "default response" } }, "tags": [ "NotifierV1alpha1Console" ] }, "post": { "description": "Save sender config of notifier", "operationId": "SaveSenderConfig", "parameters": [ { "description": "Notifier name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "NotifierV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins": { "get": { "description": "List plugins using query criteria and sort params", "operationId": "ListPlugins", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Keyword of plugin name or description", "in": "query", "name": "keyword", "schema": { "type": "string" } }, { "description": "Whether the plugin is enabled", "in": "query", "name": "enabled", "schema": { "type": "boolean" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PluginList" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.css": { "get": { "description": "Merge all CSS bundles of enabled plugins into one.", "operationId": "fetchCssBundle", "responses": { "default": { "content": { "*/*": { "schema": { "type": "string" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js": { "get": { "description": "Merge all JS bundles of enabled plugins into one.", "operationId": "fetchJsBundle", "responses": { "default": { "content": { "*/*": { "schema": { "type": "string" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/-/install-from-uri": { "post": { "description": "Install a plugin from uri.", "operationId": "InstallPluginFromUri", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InstallFromUriRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/install": { "post": { "description": "Install a plugin by uploading a Jar file.", "operationId": "InstallPlugin", "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PluginInstallRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/json-config": { "get": { "description": "Fetch converted json config of plugin by configured configMapName.", "operationId": "fetchPluginJsonConfig", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "object" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] }, "put": { "description": "Update the config of plugin setting.", "operationId": "updatePluginJsonConfig", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object" } } }, "required": true }, "responses": { "204": { "content": {}, "description": "No Content" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/plugin-state": { "put": { "description": "Change the running state of a plugin by name.", "operationId": "ChangePluginRunningState", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PluginRunningStateRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/reload": { "put": { "description": "Reload a plugin by name.", "operationId": "reloadPlugin", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/reset-config": { "put": { "description": "Reset the configMap of plugin setting.", "operationId": "ResetPluginConfig", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/setting": { "get": { "description": "Fetch setting of plugin.", "operationId": "fetchPluginSetting", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/upgrade": { "post": { "description": "Upgrade a plugin by uploading a Jar file", "operationId": "UpgradePlugin", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PluginInstallRequest" } } }, "required": true }, "responses": {}, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/upgrade-from-uri": { "post": { "description": "Upgrade a plugin from uri.", "operationId": "UpgradePluginFromUri", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpgradeFromUriRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts": { "get": { "description": "List posts.", "operationId": "ListPosts", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Posts filtered by publish phase.", "in": "query", "name": "publishPhase", "schema": { "type": "string", "enum": [ "DRAFT", "PENDING_APPROVAL", "PUBLISHED", "FAILED" ] } }, { "description": "Posts filtered by keyword.", "in": "query", "name": "keyword", "schema": { "type": "string" } }, { "description": "Posts filtered by category including sub-categories.", "in": "query", "name": "categoryWithChildren", "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedPostList" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] }, "post": { "description": "Draft a post.", "operationId": "DraftPost", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}": { "put": { "description": "Update a post.", "operationId": "UpdateDraftPost", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/content": { "delete": { "description": "Delete a content for post.", "operationId": "deletePostContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "in": "query", "name": "snapshotName", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] }, "get": { "description": "Fetch content of post.", "operationId": "fetchPostContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "in": "query", "name": "snapshotName", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] }, "put": { "description": "Update a post\u0027s content.", "operationId": "UpdatePostContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Content" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/head-content": { "get": { "description": "Fetch head content of post.", "operationId": "fetchPostHeadContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/publish": { "put": { "description": "Publish a post.", "operationId": "PublishPost", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Head snapshot name of content.", "in": "query", "name": "headSnapshot", "schema": { "type": "string" } }, { "in": "query", "name": "async", "schema": { "type": "boolean" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/recycle": { "put": { "description": "Recycle a post.", "operationId": "RecyclePost", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": {}, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/release-content": { "get": { "description": "Fetch release content of post.", "operationId": "fetchPostReleaseContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/revert-content": { "put": { "description": "Revert to specified snapshot for post content.", "operationId": "revertToSpecifiedSnapshotForPost", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RevertSnapshotForPostParam" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/snapshot": { "get": { "description": "List all snapshots for post content.", "operationId": "listPostSnapshots", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ListedSnapshotDto" } } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/posts/{name}/unpublish": { "put": { "description": "UnPublish a post.", "operationId": "UnpublishPost", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/replies": { "get": { "description": "List replies.", "operationId": "ListReplies", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Replies filtered by commentName.", "in": "query", "name": "commentName", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedReplyList" } } }, "description": "default response" } }, "tags": [ "ReplyV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages": { "get": { "description": "List single pages.", "operationId": "ListSinglePages", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "SinglePages filtered by contributor.", "in": "query", "name": "contributor", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "SinglePages filtered by publish phase.", "in": "query", "name": "publishPhase", "schema": { "type": "string", "enum": [ "DRAFT", "PENDING_APPROVAL", "PUBLISHED", "FAILED" ] } }, { "description": "SinglePages filtered by visibility.", "in": "query", "name": "visible", "schema": { "type": "string", "enum": [ "PUBLIC", "INTERNAL", "PRIVATE" ] } }, { "description": "SinglePages filtered by keyword.", "in": "query", "name": "keyword", "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedSinglePageList" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] }, "post": { "description": "Draft a single page.", "operationId": "DraftSinglePage", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SinglePageRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages/{name}": { "put": { "description": "Update a single page.", "operationId": "UpdateDraftSinglePage", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SinglePageRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages/{name}/content": { "delete": { "description": "Delete a content for post.", "operationId": "deleteSinglePageContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "in": "query", "name": "snapshotName", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] }, "get": { "description": "Fetch content of single page.", "operationId": "fetchSinglePageContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "in": "query", "name": "snapshotName", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] }, "put": { "description": "Update a single page\u0027s content.", "operationId": "UpdateSinglePageContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Content" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages/{name}/head-content": { "get": { "description": "Fetch head content of single page.", "operationId": "fetchSinglePageHeadContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages/{name}/publish": { "put": { "description": "Publish a single page.", "operationId": "PublishSinglePage", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages/{name}/release-content": { "get": { "description": "Fetch release content of single page.", "operationId": "fetchSinglePageReleaseContent", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ContentWrapper" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages/{name}/revert-content": { "put": { "description": "Revert to specified snapshot for single page content.", "operationId": "revertToSpecifiedSnapshotForSinglePage", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RevertSnapshotForSingleParam" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/singlepages/{name}/snapshot": { "get": { "description": "List all snapshots for single page content.", "operationId": "listSinglePageSnapshots", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ListedSnapshotDto" } } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/stats": { "get": { "description": "Get stats.", "operationId": "getStats", "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/DashboardStats" } } }, "description": "default response" } }, "tags": [ "SystemV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/tags": { "get": { "description": "List Post Tags.", "operationId": "ListPostTags", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Post tags filtered by keyword.", "in": "query", "name": "keyword", "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TagList" } } }, "description": "default response" } }, "tags": [ "TagV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes": { "get": { "description": "List themes.", "operationId": "ListThemes", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Whether to list uninstalled themes.", "in": "query", "name": "uninstalled", "schema": { "type": "boolean" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ThemeList" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/-/activation": { "get": { "description": "Fetch the activated theme.", "operationId": "fetchActivatedTheme", "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/-/install-from-uri": { "post": { "description": "Install a theme from uri.", "operationId": "InstallThemeFromUri", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InstallFromUriRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/install": { "post": { "description": "Install a theme by uploading a zip file.", "operationId": "InstallTheme", "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/ThemeInstallRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/activation": { "put": { "description": "Activate a theme by name.", "operationId": "activateTheme", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/invalidate-cache": { "put": { "description": "Invalidate theme template cache.", "operationId": "InvalidateCache", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "204": { "description": "No Content" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/json-config": { "get": { "description": "Fetch converted json config of theme by configured configMapName.", "operationId": "fetchThemeJsonConfig", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "object" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] }, "put": { "description": "Update the configMap of theme setting.", "operationId": "updateThemeJsonConfig", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object" } } }, "required": true }, "responses": { "204": { "content": {}, "description": "No Content" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/reload": { "put": { "description": "Reload theme setting.", "operationId": "Reload", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/reset-config": { "put": { "description": "Reset the configMap of theme setting.", "operationId": "ResetThemeConfig", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/setting": { "get": { "description": "Fetch setting of theme.", "operationId": "fetchThemeSetting", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/upgrade": { "post": { "description": "Upgrade theme", "operationId": "UpgradeTheme", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/UpgradeRequest" } } }, "required": true }, "responses": {}, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/upgrade-from-uri": { "post": { "description": "Upgrade a theme from uri.", "operationId": "UpgradeThemeFromUri", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpgradeFromUriRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "default response" } }, "tags": [ "ThemeV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users": { "get": { "description": "List users", "operationId": "ListUsers", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Keyword to search", "in": "query", "name": "keyword", "schema": { "type": "string" } }, { "description": "Role name", "in": "query", "name": "role", "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserEndpoint.ListedUserList" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] }, "post": { "description": "Creates a new user.", "operationId": "CreateUser", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CreateUserRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/-": { "get": { "description": "Get current user detail", "operationId": "GetCurrentUserDetail", "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/DetailedUser" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] }, "put": { "description": "Update current user profile, but password.", "operationId": "UpdateCurrentUser", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/-/password": { "put": { "description": "Change own password of user.", "operationId": "ChangeOwnPassword", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ChangeOwnPasswordRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/-/send-email-verification-code": { "post": { "description": "Send email verification code for user", "operationId": "SendEmailVerificationCode", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/EmailVerifyRequest" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/-/verify-email": { "post": { "description": "Verify email for user by code.", "operationId": "VerifyEmail", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/VerifyCodeRequest" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/{name}": { "get": { "description": "Get user detail by name", "operationId": "GetUserDetail", "parameters": [ { "description": "User name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/DetailedUser" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/{name}/avatar": { "delete": { "description": "delete user avatar", "operationId": "DeleteUserAvatar", "parameters": [ { "description": "User name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] }, "post": { "description": "upload user avatar", "operationId": "UploadUserAvatar", "parameters": [ { "description": "User name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/IAvatarUploadRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/{name}/password": { "put": { "description": "Change anyone password of user for admin.", "operationId": "ChangeAnyonePassword", "parameters": [ { "description": "Name of user. If the name is equal to \u0027-\u0027, it will change the password of current user.", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ChangePasswordRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/api.console.halo.run/v1alpha1/users/{name}/permissions": { "get": { "description": "Get permissions of user", "operationId": "GetPermissions", "parameters": [ { "description": "User name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserPermission" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] }, "post": { "description": "Grant permissions to user", "operationId": "GrantPermission", "parameters": [ { "description": "User name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/GrantRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "default response" } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/console.api.halo.run/v1alpha1/systemconfigs/{group}": { "get": { "description": "Get system config by group", "operationId": "getSystemConfigByGroup", "parameters": [ { "description": "Group of the system config", "in": "path", "name": "group", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "object" } }, "application/json": {} }, "description": "default response" } }, "tags": [ "SystemConfigV1alpha1Console" ] }, "put": { "description": "Update system config by group", "operationId": "updateSystemConfigByGroup", "parameters": [ { "description": "Group of the system config", "in": "path", "name": "group", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "type": "object" } } } }, "responses": { "204 NO_CONTENT": { "content": {}, "description": "default response" } }, "tags": [ "SystemConfigV1alpha1Console" ] } }, "/apis/console.api.migration.halo.run/v1alpha1/backup-files": { "get": { "description": "Get backup files from backup root.", "operationId": "getBackupFiles", "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/BackupFile" } } } }, "description": "default response" } }, "tags": [ "MigrationV1alpha1Console" ] } }, "/apis/console.api.migration.halo.run/v1alpha1/backups/{name}/files/{filename}": { "get": { "operationId": "DownloadBackups", "parameters": [ { "description": "Backup name.", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Backup filename.", "in": "path", "name": "filename", "required": true, "schema": { "type": "string" } } ], "responses": {}, "tags": [ "MigrationV1alpha1Console" ] } }, "/apis/console.api.migration.halo.run/v1alpha1/restorations": { "post": { "description": "Restore backup by uploading file or providing download link or backup name.", "operationId": "RestoreBackup", "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/RestoreRequest" } } }, "required": true }, "responses": {}, "tags": [ "MigrationV1alpha1Console" ] } }, "/apis/console.api.notification.halo.run/v1alpha1/notifiers/default-email-notifier/verify-connection": { "post": { "description": "Verify email sender config.", "operationId": "VerifyEmailSenderConfig", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/EmailConfigValidationRequest" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "NotifierV1alpha1Console" ] } }, "/apis/console.api.security.halo.run/v1alpha1/users/{username}/disable": { "post": { "description": "Disable user by username", "operationId": "DisableUser", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "The user has been disabled." } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/console.api.security.halo.run/v1alpha1/users/{username}/enable": { "post": { "description": "Enable user by username", "operationId": "EnableUser", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "The user has been enabled." } }, "tags": [ "UserV1alpha1Console" ] } }, "/apis/console.api.storage.halo.run/v1alpha1/attachments/-/upload": { "post": { "description": "Upload attachment endpoint for console.", "operationId": "uploadAttachmentForConsole", "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/UploadForm" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Console" ] } }, "/apis/console.api.storage.halo.run/v1alpha1/policies/{name}/configs/{group}": { "get": { "description": "Get policy config by group", "operationId": "getPolicyConfigByGroup", "parameters": [ { "description": "Name of the policy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Name of the group", "in": "path", "name": "group", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "object" } } }, "description": "default response" } }, "tags": [ "PolicyAlpha1Console" ] }, "put": { "description": "Update policy config by group", "operationId": "updatePolicyConfigByGroup", "parameters": [ { "description": "Name of the policy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Name of the group", "in": "path", "name": "group", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object" } } }, "required": true }, "responses": { "204": { "description": "No Content" } }, "tags": [ "PolicyAlpha1Console" ] } } }, "components": { "schemas": { "AddOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "add" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "Attachment": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/AttachmentSpec" }, "status": { "$ref": "#/components/schemas/AttachmentStatus" } } }, "AttachmentList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Attachment" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "AttachmentSpec": { "type": "object", "properties": { "displayName": { "type": "string", "description": "Display name of attachment" }, "groupName": { "type": "string", "description": "Group name" }, "mediaType": { "type": "string", "description": "Media type of attachment" }, "ownerName": { "type": "string", "description": "Name of User who uploads the attachment" }, "policyName": { "type": "string", "description": "Policy name" }, "size": { "minimum": 0, "type": "integer", "description": "Size of attachment. Unit is Byte", "format": "int64" }, "tags": { "uniqueItems": true, "type": "array", "description": "Tags of attachment", "items": { "type": "string", "description": "Tag name" } } } }, "AttachmentStatus": { "type": "object", "properties": { "permalink": { "type": "string", "description": "Permalink of attachment.\nIf it is in local storage, the public URL will be set.\nIf it is in s3 storage, the Object URL will be set.\n" }, "thumbnails": { "type": "object", "additionalProperties": { "type": "string" } } } }, "AuthProvider": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/AuthProviderSpec" } }, "description": "Auth provider extension." }, "AuthProviderSpec": { "required": [ "authType", "authenticationUrl", "displayName" ], "type": "object", "properties": { "authType": { "type": "string", "description": "Auth type: form or oauth2.", "enum": [ "FORM", "OAUTH2" ] }, "authenticationUrl": { "type": "string", "description": "Authentication url of the auth provider" }, "bindingUrl": { "type": "string" }, "configMapRef": { "$ref": "#/components/schemas/ConfigMapRef" }, "description": { "type": "string" }, "displayName": { "type": "string", "description": "Display name of the auth provider" }, "helpPage": { "type": "string" }, "logo": { "type": "string" }, "method": { "type": "string" }, "rememberMeSupport": { "type": "boolean" }, "settingRef": { "$ref": "#/components/schemas/SettingRef" }, "unbindUrl": { "type": "string" }, "website": { "type": "string" } } }, "Author": { "required": [ "name" ], "type": "object", "properties": { "name": { "minLength": 1, "type": "string" }, "website": { "type": "string" } } }, "BackupFile": { "type": "object", "properties": { "filename": { "type": "string", "description": "Filename of backup file." }, "lastModifiedTime": { "type": "string", "description": "Last modified time of backup file.", "format": "date-time" }, "size": { "type": "integer", "description": "Size of backup file.", "format": "int64" } }, "description": "Backup file." }, "Category": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/CategorySpec" }, "status": { "$ref": "#/components/schemas/CategoryStatus" } } }, "CategorySpec": { "required": [ "displayName", "priority", "slug" ], "type": "object", "properties": { "children": { "type": "array", "items": { "type": "string" } }, "cover": { "type": "string" }, "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "hideFromList": { "type": "boolean", "description": "\u003cp\u003eWhether to hide the category from the category list.\u003c/p\u003e\n \u003cp\u003eWhen set to true, the category including its subcategories and related posts will\n not be displayed in the category list, but it can still be accessed by permalink.\u003c/p\u003e\n \u003cp\u003eLimitation: It only takes effect on the theme-side categorized list and it only\n allows to be set to true on the first level(root node) of categories.\u003c/p\u003e" }, "postTemplate": { "maxLength": 255, "type": "string", "description": "\u003cp\u003eUsed to specify the template for the posts associated with the category.\u003c/p\u003e\n \u003cp\u003eThe priority is not as high as that of the post.\u003c/p\u003e\n \u003cp\u003eIf the post also specifies a template, the post\u0027s template will prevail.\u003c/p\u003e" }, "preventParentPostCascadeQuery": { "type": "boolean", "description": "\u003cp\u003eif a category is queried for related posts, the default behavior is to\n query all posts under the category including its subcategories, but if this field is\n set to true, cascade query behavior will be terminated here.\u003c/p\u003e\n \u003cp\u003eFor example, if a category has subcategories A and B, and A has subcategories C and\n D and C marked this field as true, when querying posts under A category,all posts under A\n and B will be queried, but C and D will not be queried.\u003c/p\u003e" }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "slug": { "minLength": 1, "type": "string" }, "template": { "maxLength": 255, "type": "string" } } }, "CategoryStatus": { "type": "object", "properties": { "permalink": { "type": "string" }, "postCount": { "type": "integer", "description": "包括当前和其下所有层级的文章数量 (depth\u003dmax).", "format": "int32" }, "visiblePostCount": { "type": "integer", "description": "包括当前和其下所有层级的已发布且公开的文章数量 (depth\u003dmax).", "format": "int32" } } }, "ChangeOwnPasswordRequest": { "required": [ "oldPassword", "password" ], "type": "object", "properties": { "oldPassword": { "type": "string", "description": "Old password." }, "password": { "minLength": 5, "type": "string", "description": "New password." } } }, "ChangePasswordRequest": { "required": [ "password" ], "type": "object", "properties": { "password": { "minLength": 5, "type": "string", "description": "New password." } } }, "Comment": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/CommentSpec" }, "status": { "$ref": "#/components/schemas/CommentStatus" } } }, "CommentEmailOwner": { "type": "object", "properties": { "avatar": { "type": "string", "description": "avatar for comment owner" }, "displayName": { "type": "string", "description": "display name for comment owner" }, "email": { "type": "string", "description": "email for comment owner" }, "website": { "type": "string", "description": "website for comment owner" } }, "description": "\u003cp\u003eThe creator info of the comment.\u003c/p\u003e\n This {@link CommentEmailOwner CommentEmailOwner} is only applicable to the user who is allowed to comment\n without logging in." }, "CommentOwner": { "required": [ "kind", "name" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" } }, "displayName": { "type": "string" }, "kind": { "minLength": 1, "type": "string" }, "name": { "maxLength": 64, "type": "string" } } }, "CommentRequest": { "required": [ "content", "raw", "subjectRef" ], "type": "object", "properties": { "allowNotification": { "type": "boolean", "default": false }, "content": { "minLength": 1, "type": "string" }, "hidden": { "type": "boolean", "default": false }, "owner": { "$ref": "#/components/schemas/CommentEmailOwner" }, "raw": { "minLength": 1, "type": "string" }, "subjectRef": { "$ref": "#/components/schemas/Ref" } }, "description": "Request parameter object for {@link Comment Comment}." }, "CommentSpec": { "required": [ "allowNotification", "approved", "content", "hidden", "owner", "priority", "raw", "subjectRef", "top" ], "type": "object", "properties": { "allowNotification": { "type": "boolean", "default": true }, "approved": { "type": "boolean", "default": false }, "approvedTime": { "type": "string", "format": "date-time" }, "content": { "minLength": 1, "type": "string" }, "creationTime": { "type": "string", "description": "The user-defined creation time default is \u003ccode\u003emetadata.creationTimestamp\u003c/code\u003e.", "format": "date-time" }, "hidden": { "type": "boolean", "default": false }, "ipAddress": { "type": "string" }, "lastReadTime": { "type": "string", "format": "date-time" }, "owner": { "$ref": "#/components/schemas/CommentOwner" }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "raw": { "minLength": 1, "type": "string" }, "subjectRef": { "$ref": "#/components/schemas/Ref" }, "top": { "type": "boolean", "default": false }, "userAgent": { "type": "string" } } }, "CommentStats": { "type": "object", "properties": { "upvote": { "type": "integer", "format": "int32" } }, "description": "comment stats value object." }, "CommentStatus": { "type": "object", "properties": { "hasNewReply": { "type": "boolean" }, "lastReplyTime": { "type": "string", "format": "date-time" }, "observedVersion": { "type": "integer", "format": "int64" }, "replyCount": { "type": "integer", "format": "int32" }, "unreadReplyCount": { "type": "integer", "format": "int32" }, "visibleReplyCount": { "type": "integer", "format": "int32" } } }, "Condition": { "required": [ "lastTransitionTime", "status", "type" ], "type": "object", "properties": { "lastTransitionTime": { "type": "string", "description": "Last time the condition transitioned from one status to another.", "format": "date-time" }, "message": { "maxLength": 32768, "type": "string", "description": "Human-readable message indicating details about last transition.\n This may be an empty string." }, "reason": { "maxLength": 1024, "pattern": "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$", "type": "string", "description": "Unique, one-word, CamelCase reason for the condition\u0027s last transition." }, "status": { "type": "string", "description": "Status is the status of the condition. Can be True, False, Unknown.", "enum": [ "TRUE", "FALSE", "UNKNOWN" ] }, "type": { "maxLength": 316, "pattern": "^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$", "type": "string", "description": "type of condition in CamelCase or in foo.example.com/CamelCase.\n example: Ready, Initialized.\n maxLength: 316." } }, "description": "EqualsAndHashCode 排除了lastTransitionTime否则失败时,lastTransitionTime 会被更新\n 导致 equals 为 false,一直被加入队列." }, "ConfigMap": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "data": { "type": "object", "additionalProperties": { "type": "string" } }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" } }, "description": "\u003cp\u003eConfigMap holds configuration data to consume.\u003c/p\u003e" }, "ConfigMapRef": { "required": [ "name" ], "type": "object", "properties": { "name": { "minLength": 1, "type": "string" } } }, "Content": { "required": [ "content", "raw", "rawType" ], "type": "object", "properties": { "content": { "type": "string" }, "raw": { "type": "string" }, "rawType": { "type": "string" } } }, "ContentUpdateParam": { "required": [ "content", "raw", "rawType" ], "type": "object", "properties": { "content": { "type": "string" }, "raw": { "type": "string" }, "rawType": { "type": "string" }, "version": { "type": "integer", "format": "int64" } } }, "ContentWrapper": { "type": "object", "properties": { "content": { "type": "string" }, "raw": { "type": "string" }, "rawType": { "type": "string" }, "snapshotName": { "type": "string" } } }, "Contributor": { "type": "object", "properties": { "avatar": { "type": "string" }, "displayName": { "type": "string" }, "name": { "type": "string" } }, "description": "Contributor from user." }, "CopyOperation": { "required": [ "op", "from", "path" ], "type": "object", "properties": { "from": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "op": { "type": "string", "enum": [ "copy" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "CreateUserRequest": { "required": [ "email", "name" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" } }, "avatar": { "type": "string" }, "bio": { "type": "string" }, "displayName": { "type": "string" }, "email": { "type": "string" }, "name": { "type": "string" }, "password": { "type": "string" }, "phone": { "type": "string" }, "roles": { "uniqueItems": true, "type": "array", "items": { "type": "string" } } } }, "CustomTemplates": { "type": "object", "properties": { "category": { "type": "array", "items": { "$ref": "#/components/schemas/TemplateDescriptor" } }, "page": { "type": "array", "items": { "$ref": "#/components/schemas/TemplateDescriptor" } }, "post": { "type": "array", "items": { "$ref": "#/components/schemas/TemplateDescriptor" } } } }, "DashboardStats": { "type": "object", "properties": { "approvedComments": { "type": "integer", "format": "int64" }, "comments": { "type": "integer", "format": "int64" }, "posts": { "type": "integer", "format": "int64" }, "upvotes": { "type": "integer", "format": "int64" }, "users": { "type": "integer", "format": "int64" }, "visits": { "type": "integer", "format": "int64" } } }, "DetailedUser": { "required": [ "roles", "user" ], "type": "object", "properties": { "roles": { "type": "array", "items": { "$ref": "#/components/schemas/Role" } }, "user": { "$ref": "#/components/schemas/User" } } }, "EmailConfigValidationRequest": { "type": "object", "properties": { "displayName": { "type": "string", "description": "Gets email display name." }, "enable": { "type": "boolean" }, "encryption": { "type": "string" }, "host": { "type": "string" }, "password": { "type": "string" }, "port": { "type": "integer", "format": "int32" }, "sender": { "type": "string", "description": "Gets email sender address." }, "username": { "type": "string" } } }, "EmailVerifyRequest": { "required": [ "email" ], "type": "object", "properties": { "email": { "type": "string", "format": "email" } } }, "Excerpt": { "required": [ "autoGenerate" ], "type": "object", "properties": { "autoGenerate": { "type": "boolean", "default": true }, "raw": { "type": "string" } } }, "Extension": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" } }, "description": "Extension is an interface which represents an Extension. It contains setters and getters of\n GroupVersionKind and Metadata." }, "GrantRequest": { "type": "object", "properties": { "roles": { "uniqueItems": true, "type": "array", "items": { "type": "string" } } } }, "IAvatarUploadRequest": { "required": [ "file" ], "type": "object", "properties": { "file": { "type": "string", "format": "binary" } } }, "IUploadRequest": { "required": [ "file", "policyName" ], "type": "object", "properties": { "file": { "type": "string", "format": "binary" }, "groupName": { "type": "string", "description": "The name of the group to which the attachment belongs" }, "policyName": { "type": "string", "description": "Storage policy name" } } }, "InstallFromUriRequest": { "required": [ "uri" ], "type": "object", "properties": { "uri": { "type": "string", "format": "uri" } } }, "JsonPatch": { "minItems": 1, "uniqueItems": true, "type": "array", "description": "JSON schema for JSONPatch operations", "items": { "oneOf": [ { "$ref": "#/components/schemas/AddOperation" }, { "$ref": "#/components/schemas/ReplaceOperation" }, { "$ref": "#/components/schemas/TestOperation" }, { "$ref": "#/components/schemas/RemoveOperation" }, { "$ref": "#/components/schemas/MoveOperation" }, { "$ref": "#/components/schemas/CopyOperation" } ] } }, "License": { "type": "object", "properties": { "name": { "type": "string" }, "url": { "type": "string" } }, "description": "Common data objects for license." }, "ListedAuthProvider": { "required": [ "displayName", "name" ], "type": "object", "properties": { "authType": { "type": "string", "enum": [ "FORM", "OAUTH2" ] }, "authenticationUrl": { "type": "string" }, "bindingUrl": { "type": "string" }, "description": { "type": "string" }, "displayName": { "type": "string" }, "enabled": { "type": "boolean" }, "helpPage": { "type": "string" }, "isBound": { "type": "boolean" }, "logo": { "type": "string" }, "name": { "type": "string" }, "priority": { "type": "integer", "format": "int32" }, "privileged": { "type": "boolean" }, "supportsBinding": { "type": "boolean" }, "unbindingUrl": { "type": "string" }, "website": { "type": "string" } }, "description": "A listed value object for {@link run.halo.app.core.extension.AuthProvider run.halo.app.core.extension.AuthProvider}." }, "ListedComment": { "required": [ "comment", "owner", "stats" ], "type": "object", "properties": { "comment": { "$ref": "#/components/schemas/Comment" }, "owner": { "$ref": "#/components/schemas/OwnerInfo" }, "stats": { "$ref": "#/components/schemas/CommentStats" }, "subject": { "$ref": "#/components/schemas/Extension" } }, "description": "A chunk of items." }, "ListedCommentList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedComment" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ListedPost": { "required": [ "categories", "contributors", "owner", "post", "stats", "tags" ], "type": "object", "properties": { "categories": { "type": "array", "items": { "$ref": "#/components/schemas/Category" } }, "contributors": { "type": "array", "items": { "$ref": "#/components/schemas/Contributor" } }, "owner": { "$ref": "#/components/schemas/Contributor" }, "post": { "$ref": "#/components/schemas/Post" }, "stats": { "$ref": "#/components/schemas/Stats" }, "tags": { "type": "array", "items": { "$ref": "#/components/schemas/Tag" } } }, "description": "A chunk of items." }, "ListedPostList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedPost" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ListedReply": { "required": [ "owner", "reply", "stats" ], "type": "object", "properties": { "owner": { "$ref": "#/components/schemas/OwnerInfo" }, "reply": { "$ref": "#/components/schemas/Reply" }, "stats": { "$ref": "#/components/schemas/CommentStats" } }, "description": "A chunk of items." }, "ListedReplyList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedReply" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ListedSinglePage": { "required": [ "contributors", "owner", "page", "stats" ], "type": "object", "properties": { "contributors": { "type": "array", "items": { "$ref": "#/components/schemas/Contributor" } }, "owner": { "$ref": "#/components/schemas/Contributor" }, "page": { "$ref": "#/components/schemas/SinglePage" }, "stats": { "$ref": "#/components/schemas/Stats" } }, "description": "A chunk of items." }, "ListedSinglePageList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedSinglePage" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ListedSnapshotDto": { "required": [ "metadata", "spec" ], "type": "object", "properties": { "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ListedSnapshotSpec" } } }, "ListedSnapshotSpec": { "required": [ "owner" ], "type": "object", "properties": { "modifyTime": { "type": "string", "format": "date-time" }, "owner": { "type": "string" } } }, "ListedUser": { "required": [ "roles", "user" ], "type": "object", "properties": { "roles": { "type": "array", "items": { "$ref": "#/components/schemas/Role" } }, "user": { "$ref": "#/components/schemas/User" } }, "description": "A chunk of items." }, "Metadata": { "required": [ "name" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Annotations are like key-value format." }, "creationTimestamp": { "type": "string", "description": "Creation timestamp of the Extension.", "format": "date-time", "nullable": true }, "deletionTimestamp": { "type": "string", "description": "Deletion timestamp of the Extension.", "format": "date-time", "nullable": true }, "finalizers": { "uniqueItems": true, "type": "array", "nullable": true, "items": { "type": "string", "nullable": true } }, "generateName": { "type": "string", "description": "The name field will be generated automatically according to the given generateName field" }, "labels": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Labels are like key-value format." }, "name": { "type": "string", "description": "Metadata name" }, "version": { "type": "integer", "description": "Current version of the Extension. It will be bumped up every update.", "format": "int64", "nullable": true } }, "description": "Metadata of Extension." }, "MoveOperation": { "required": [ "op", "from", "path" ], "type": "object", "properties": { "from": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "op": { "type": "string", "enum": [ "move" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "OwnerInfo": { "type": "object", "properties": { "avatar": { "type": "string" }, "displayName": { "type": "string" }, "email": { "type": "string" }, "kind": { "type": "string" }, "name": { "type": "string" } }, "description": "Comment owner info." }, "Plugin": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/PluginSpec" }, "status": { "$ref": "#/components/schemas/PluginStatus" } }, "description": "A custom resource for Plugin." }, "PluginAuthor": { "required": [ "name" ], "type": "object", "properties": { "name": { "minLength": 1, "type": "string" }, "website": { "type": "string" } } }, "PluginInstallRequest": { "type": "object", "properties": { "file": { "type": "string", "format": "binary" }, "presetName": { "type": "string", "description": "Plugin preset name. We will find the plugin from plugin presets" }, "source": { "type": "string", "description": "Install source. Default is file.", "enum": [ "FILE", "PRESET", "URL" ] } } }, "PluginList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Plugin" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "PluginRunningStateRequest": { "type": "object", "properties": { "async": { "type": "boolean" }, "enable": { "type": "boolean" } } }, "PluginSpec": { "required": [ "version" ], "type": "object", "properties": { "author": { "$ref": "#/components/schemas/PluginAuthor" }, "configMapName": { "type": "string" }, "description": { "type": "string" }, "displayName": { "type": "string" }, "enabled": { "type": "boolean" }, "homepage": { "type": "string" }, "issues": { "type": "string" }, "license": { "type": "array", "items": { "$ref": "#/components/schemas/License" } }, "logo": { "type": "string" }, "pluginDependencies": { "type": "object", "additionalProperties": { "type": "string" } }, "repo": { "type": "string" }, "requires": { "type": "string", "description": "SemVer format." }, "settingName": { "type": "string" }, "version": { "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", "type": "string", "description": "plugin version." } } }, "PluginStatus": { "type": "object", "properties": { "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "entry": { "type": "string" }, "lastProbeState": { "type": "string", "enum": [ "CREATED", "DISABLED", "RESOLVED", "STARTED", "STOPPED", "FAILED", "UNLOADED" ] }, "lastStartTime": { "type": "string", "format": "date-time" }, "loadLocation": { "type": "string", "description": "Load location of the plugin, often a path.", "format": "uri" }, "logo": { "type": "string" }, "phase": { "type": "string", "enum": [ "PENDING", "STARTING", "CREATED", "DISABLING", "DISABLED", "RESOLVED", "STARTED", "STOPPED", "FAILED", "UNKNOWN" ] }, "stylesheet": { "type": "string" } } }, "PolicyRule": { "type": "object", "properties": { "apiGroups": { "type": "array", "description": "APIGroups is the name of the APIGroup that contains the resources.\n If multiple API groups are specified, any action requested against one of the enumerated\n resources in any API group will be allowed.", "items": { "type": "string" } }, "nonResourceURLs": { "type": "array", "description": "NonResourceURLs is a set of partial urls that a user should have access to.\n *s are allowed, but only as the full, final step in the path\n If an action is not a resource API request, then the URL is split on \u0027/\u0027 and is checked\n against the NonResourceURLs to look for a match.\n Since non-resource URLs are not namespaced, this field is only applicable for\n ClusterRoles referenced from a ClusterRoleBinding.\n Rules can either apply to API resources (such as \"pods\" or \"secrets\") or non-resource\n URL paths (such as \"/api\"), but not both.", "items": { "type": "string" } }, "resourceNames": { "type": "array", "description": "ResourceNames is an optional white list of names that the rule applies to. An empty set\n means that everything is allowed.", "items": { "type": "string" } }, "resources": { "type": "array", "description": "Resources is a list of resources this rule applies to. \u0027*\u0027 represents all resources in\n the specified apiGroups.\n \u0027*\u0026#47;foo\u0027 represents the subresource \u0027foo\u0027 for all resources in the specified\n apiGroups.", "items": { "type": "string" } }, "verbs": { "type": "array", "description": "about who the rule applies to or which namespace the rule applies to.", "items": { "type": "string" } } }, "description": "PolicyRule holds information that describes a policy rule, but does not contain information\n about whom the rule applies to or which namespace the rule applies to." }, "Post": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/PostSpec" }, "status": { "$ref": "#/components/schemas/PostStatus" } }, "description": "\u003cp\u003ePost extension.\u003c/p\u003e" }, "PostRequest": { "required": [ "content", "post" ], "type": "object", "properties": { "content": { "$ref": "#/components/schemas/ContentUpdateParam" }, "post": { "$ref": "#/components/schemas/Post" } }, "description": "Post and content data for creating and updating post." }, "PostSpec": { "required": [ "allowComment", "deleted", "excerpt", "pinned", "priority", "publish", "slug", "title", "visible" ], "type": "object", "properties": { "allowComment": { "type": "boolean", "default": true }, "baseSnapshot": { "type": "string" }, "categories": { "type": "array", "items": { "type": "string" } }, "cover": { "type": "string" }, "deleted": { "type": "boolean", "default": false }, "excerpt": { "$ref": "#/components/schemas/Excerpt" }, "headSnapshot": { "type": "string" }, "htmlMetas": { "type": "array", "items": { "type": "object", "additionalProperties": { "type": "string" } } }, "owner": { "type": "string" }, "pinned": { "type": "boolean", "default": false }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "publish": { "type": "boolean", "default": false }, "publishTime": { "type": "string", "format": "date-time" }, "releaseSnapshot": { "type": "string", "description": "文章引用到的已发布的内容,用于主题端显示." }, "slug": { "minLength": 1, "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } }, "template": { "type": "string" }, "title": { "minLength": 1, "type": "string" }, "visible": { "type": "string", "enum": [ "PUBLIC", "INTERNAL", "PRIVATE" ], "default": "PUBLIC" } } }, "PostStatus": { "type": "object", "properties": { "commentsCount": { "type": "integer", "format": "int32" }, "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "contributors": { "type": "array", "items": { "type": "string" } }, "excerpt": { "type": "string" }, "hideFromList": { "type": "boolean", "description": "see {@link Category.CategorySpec#isHideFromList Category.CategorySpec#isHideFromList()}." }, "inProgress": { "type": "boolean" }, "lastModifyTime": { "type": "string", "format": "date-time" }, "observedVersion": { "type": "integer", "format": "int64" }, "permalink": { "type": "string" }, "phase": { "type": "string" } } }, "Ref": { "required": [ "group", "kind", "name" ], "type": "object", "properties": { "group": { "type": "string", "description": "Extension group" }, "kind": { "type": "string", "description": "Extension kind" }, "name": { "type": "string", "description": "Extension name. This field is mandatory" }, "version": { "type": "string", "description": "Extension version" } }, "description": "Extension reference object. The name is mandatory" }, "RemoveOperation": { "required": [ "op", "path" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "remove" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "ReplaceOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "replace" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "Reply": { "required": [ "apiVersion", "kind", "metadata", "spec", "status" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ReplySpec" }, "status": { "$ref": "#/components/schemas/ReplyStatus" } } }, "ReplyRequest": { "required": [ "content", "raw" ], "type": "object", "properties": { "allowNotification": { "type": "boolean", "default": false }, "content": { "minLength": 1, "type": "string" }, "hidden": { "type": "boolean", "default": false }, "owner": { "$ref": "#/components/schemas/CommentEmailOwner" }, "quoteReply": { "type": "string" }, "raw": { "minLength": 1, "type": "string" } }, "description": "A request parameter object for {@link Reply Reply}." }, "ReplySpec": { "required": [ "allowNotification", "approved", "commentName", "content", "hidden", "owner", "priority", "raw", "top" ], "type": "object", "properties": { "allowNotification": { "type": "boolean", "default": true }, "approved": { "type": "boolean", "default": false }, "approvedTime": { "type": "string", "format": "date-time" }, "commentName": { "minLength": 1, "type": "string" }, "content": { "minLength": 1, "type": "string" }, "creationTime": { "type": "string", "description": "The user-defined creation time default is \u003ccode\u003emetadata.creationTimestamp\u003c/code\u003e.", "format": "date-time" }, "hidden": { "type": "boolean", "default": false }, "ipAddress": { "type": "string" }, "owner": { "$ref": "#/components/schemas/CommentOwner" }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "quoteReply": { "type": "string" }, "raw": { "minLength": 1, "type": "string" }, "top": { "type": "boolean", "default": false }, "userAgent": { "type": "string" } } }, "ReplyStatus": { "type": "object", "properties": { "observedVersion": { "type": "integer", "format": "int64" } } }, "RestoreRequest": { "type": "object", "properties": { "backupName": { "type": "string", "description": "Backup metadata name." }, "downloadUrl": { "type": "string", "description": "Remote backup HTTP URL." }, "file": { "type": "string", "format": "binary" }, "filename": { "type": "string", "description": "Filename of backup file in backups root." } } }, "RevertSnapshotForPostParam": { "required": [ "snapshotName" ], "type": "object", "properties": { "snapshotName": { "minLength": 1, "type": "string" } } }, "RevertSnapshotForSingleParam": { "required": [ "snapshotName" ], "type": "object", "properties": { "snapshotName": { "minLength": 1, "type": "string" } } }, "Role": { "required": [ "apiVersion", "kind", "metadata", "rules" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "rules": { "type": "array", "items": { "$ref": "#/components/schemas/PolicyRule" } } } }, "Setting": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/SettingSpec" } }, "description": "{@link Setting Setting} is a custom extension to generate forms based on configuration." }, "SettingForm": { "minLength": 1, "required": [ "formSchema", "group" ], "type": "object", "properties": { "formSchema": { "type": "array", "items": { "type": "object" } }, "group": { "type": "string" }, "label": { "type": "string" } } }, "SettingRef": { "required": [ "group", "name" ], "type": "object", "properties": { "group": { "minLength": 1, "type": "string" }, "name": { "minLength": 1, "type": "string" } } }, "SettingSpec": { "required": [ "forms" ], "type": "object", "properties": { "forms": { "minLength": 1, "type": "array", "items": { "$ref": "#/components/schemas/SettingForm" } } } }, "SinglePage": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/SinglePageSpec" }, "status": { "$ref": "#/components/schemas/SinglePageStatus" } }, "description": "\u003cp\u003eSingle page extension.\u003c/p\u003e" }, "SinglePageRequest": { "required": [ "content", "page" ], "type": "object", "properties": { "content": { "$ref": "#/components/schemas/ContentUpdateParam" }, "page": { "$ref": "#/components/schemas/SinglePage" } }, "description": "A request parameter for {@link SinglePage SinglePage}." }, "SinglePageSpec": { "required": [ "allowComment", "deleted", "excerpt", "pinned", "priority", "publish", "slug", "title", "visible" ], "type": "object", "properties": { "allowComment": { "type": "boolean", "default": true }, "baseSnapshot": { "type": "string" }, "cover": { "type": "string" }, "deleted": { "type": "boolean", "default": false }, "excerpt": { "$ref": "#/components/schemas/Excerpt" }, "headSnapshot": { "type": "string" }, "htmlMetas": { "type": "array", "items": { "type": "object", "additionalProperties": { "type": "string" } } }, "owner": { "type": "string" }, "pinned": { "type": "boolean", "default": false }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "publish": { "type": "boolean", "default": false }, "publishTime": { "type": "string", "format": "date-time" }, "releaseSnapshot": { "type": "string", "description": "引用到的已发布的内容,用于主题端显示." }, "slug": { "minLength": 1, "type": "string" }, "template": { "type": "string" }, "title": { "minLength": 1, "type": "string" }, "visible": { "type": "string", "enum": [ "PUBLIC", "INTERNAL", "PRIVATE" ], "default": "PUBLIC" } } }, "SinglePageStatus": { "type": "object", "properties": { "commentsCount": { "type": "integer", "format": "int32" }, "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "contributors": { "type": "array", "items": { "type": "string" } }, "excerpt": { "type": "string" }, "hideFromList": { "type": "boolean", "description": "see {@link Category.CategorySpec#isHideFromList Category.CategorySpec#isHideFromList()}." }, "inProgress": { "type": "boolean" }, "lastModifyTime": { "type": "string", "format": "date-time" }, "observedVersion": { "type": "integer", "format": "int64" }, "permalink": { "type": "string" }, "phase": { "type": "string" } } }, "Stats": { "type": "object", "properties": { "approvedComment": { "type": "integer", "format": "int32" }, "totalComment": { "type": "integer", "format": "int32" }, "upvote": { "type": "integer", "format": "int32" }, "visit": { "type": "integer", "format": "int32" } }, "description": "Stats value object." }, "Tag": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/TagSpec" }, "status": { "$ref": "#/components/schemas/TagStatus" } }, "description": "A chunk of items." }, "TagList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Tag" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "TagSpec": { "required": [ "displayName", "slug" ], "type": "object", "properties": { "color": { "pattern": "^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$", "type": "string", "description": "Color regex explanation.\n \u003cpre\u003e\n ^ # start of the line\n # # start with a number sign `#`\n ( # start of (group 1)\n [a-fA-F0-9]{6} # support z-f, A-F and 0-9, with a length of 6\n | # or\n [a-fA-F0-9]{3} # support z-f, A-F and 0-9, with a length of 3\n ) # end of (group 1)\n $ # end of the line\n \u003c/pre\u003e" }, "cover": { "type": "string" }, "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "slug": { "minLength": 1, "type": "string" } } }, "TagStatus": { "type": "object", "properties": { "observedVersion": { "type": "integer", "format": "int64" }, "permalink": { "type": "string" }, "postCount": { "type": "integer", "format": "int32" }, "visiblePostCount": { "type": "integer", "format": "int32" } } }, "TemplateDescriptor": { "required": [ "file", "name" ], "type": "object", "properties": { "description": { "type": "string" }, "file": { "minLength": 1, "type": "string" }, "name": { "minLength": 1, "type": "string" }, "screenshot": { "type": "string" } }, "description": "Type used to describe custom template page." }, "TestOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "test" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "Theme": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ThemeSpec" }, "status": { "$ref": "#/components/schemas/ThemeStatus" } }, "description": "\u003cp\u003eTheme extension.\u003c/p\u003e" }, "ThemeInstallRequest": { "type": "object" }, "ThemeList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Theme" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ThemeSpec": { "required": [ "author", "displayName" ], "type": "object", "properties": { "author": { "$ref": "#/components/schemas/Author" }, "configMapName": { "type": "string" }, "customTemplates": { "$ref": "#/components/schemas/CustomTemplates" }, "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "homepage": { "type": "string" }, "issues": { "type": "string" }, "license": { "type": "array", "items": { "$ref": "#/components/schemas/License" } }, "logo": { "type": "string" }, "repo": { "type": "string" }, "requires": { "type": "string" }, "settingName": { "type": "string" }, "version": { "type": "string" } } }, "ThemeStatus": { "type": "object", "properties": { "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "location": { "type": "string" }, "phase": { "type": "string", "enum": [ "READY", "FAILED", "UNKNOWN" ] } } }, "UpgradeFromUriRequest": { "required": [ "uri" ], "type": "object", "properties": { "uri": { "type": "string", "format": "uri" } } }, "UpgradeRequest": { "required": [ "file" ], "type": "object", "properties": { "file": { "type": "string", "format": "binary" } } }, "UploadForm": { "type": "object", "properties": { "file": { "type": "string", "description": "The file to upload. If not provided, the url will be used.", "format": "binary" }, "filename": { "type": "string", "description": "The filename to use when uploading from url. If not provided, the filename will be\n extracted from the url." }, "url": { "type": "string", "description": "The url to upload from. If not provided, the file will be used." } }, "description": "Upload form from console. The file and url are mutually exclusive. If both are provided,\n the file will be used." }, "UploadFromUrlRequest": { "required": [ "policyName", "url" ], "type": "object", "properties": { "filename": { "type": "string", "description": "Custom file name" }, "groupName": { "type": "string", "description": "The name of the group to which the attachment belongs" }, "policyName": { "type": "string", "description": "Storage policy name" }, "url": { "type": "string", "format": "url" } } }, "User": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/UserSpec" }, "status": { "$ref": "#/components/schemas/UserStatus" } }, "description": "The extension represents user details of Halo." }, "UserEndpoint.ListedUserList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedUser" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "UserPermission": { "required": [ "permissions", "roles", "uiPermissions" ], "type": "object", "properties": { "permissions": { "type": "array", "items": { "$ref": "#/components/schemas/Role" } }, "roles": { "type": "array", "items": { "$ref": "#/components/schemas/Role" } }, "uiPermissions": { "type": "array", "items": { "type": "string" } } } }, "UserSpec": { "required": [ "displayName", "email" ], "type": "object", "properties": { "avatar": { "type": "string" }, "bio": { "type": "string" }, "disabled": { "type": "boolean" }, "displayName": { "type": "string" }, "email": { "type": "string" }, "emailVerified": { "type": "boolean" }, "loginHistoryLimit": { "type": "integer", "format": "int32" }, "password": { "type": "string" }, "phone": { "type": "string" }, "registeredAt": { "type": "string", "format": "date-time" }, "totpEncryptedSecret": { "type": "string" }, "twoFactorAuthEnabled": { "type": "boolean" } } }, "UserStatus": { "type": "object", "properties": { "permalink": { "type": "string" } } }, "VerifyCodeRequest": { "required": [ "code", "password" ], "type": "object", "properties": { "code": { "minLength": 1, "type": "string" }, "password": { "type": "string" } } } }, "securitySchemes": { "basicAuth": { "scheme": "basic", "type": "http" }, "bearerAuth": { "bearerFormat": "JWT", "scheme": "bearer", "type": "http" } } } } ================================================ FILE: api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json ================================================ { "openapi": "3.0.1", "info": { "title": "Halo", "version": "2.23.0-SNAPSHOT" }, "servers": [ { "url": "http://localhost:8091", "description": "Generated server url" } ], "security": [ { "basicAuth": [], "bearerAuth": [] } ], "paths": { "/api/v1alpha1/annotationsettings": { "get": { "description": "List AnnotationSetting", "operationId": "listAnnotationSetting", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AnnotationSettingList" } } }, "description": "Response annotationsettings" } }, "tags": [ "AnnotationSettingV1alpha1" ] }, "post": { "description": "Create AnnotationSetting", "operationId": "createAnnotationSetting", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AnnotationSetting" } } }, "description": "Fresh annotationsetting" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AnnotationSetting" } } }, "description": "Response annotationsettings created just now" } }, "tags": [ "AnnotationSettingV1alpha1" ] } }, "/api/v1alpha1/annotationsettings/{name}": { "delete": { "description": "Delete AnnotationSetting", "operationId": "deleteAnnotationSetting", "parameters": [ { "description": "Name of annotationsetting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response annotationsetting deleted just now" } }, "tags": [ "AnnotationSettingV1alpha1" ] }, "get": { "description": "Get AnnotationSetting", "operationId": "getAnnotationSetting", "parameters": [ { "description": "Name of annotationsetting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AnnotationSetting" } } }, "description": "Response single annotationsetting" } }, "tags": [ "AnnotationSettingV1alpha1" ] }, "patch": { "description": "Patch AnnotationSetting", "operationId": "patchAnnotationSetting", "parameters": [ { "description": "Name of annotationsetting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AnnotationSetting" } } }, "description": "Response annotationsetting patched just now" } }, "tags": [ "AnnotationSettingV1alpha1" ] }, "put": { "description": "Update AnnotationSetting", "operationId": "updateAnnotationSetting", "parameters": [ { "description": "Name of annotationsetting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AnnotationSetting" } } }, "description": "Updated annotationsetting" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AnnotationSetting" } } }, "description": "Response annotationsettings updated just now" } }, "tags": [ "AnnotationSettingV1alpha1" ] } }, "/api/v1alpha1/configmaps": { "get": { "description": "List ConfigMap", "operationId": "listConfigMap", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMapList" } } }, "description": "Response configmaps" } }, "tags": [ "ConfigMapV1alpha1" ] }, "post": { "description": "Create ConfigMap", "operationId": "createConfigMap", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "Fresh configmap" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "Response configmaps created just now" } }, "tags": [ "ConfigMapV1alpha1" ] } }, "/api/v1alpha1/configmaps/{name}": { "delete": { "description": "Delete ConfigMap", "operationId": "deleteConfigMap", "parameters": [ { "description": "Name of configmap", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response configmap deleted just now" } }, "tags": [ "ConfigMapV1alpha1" ] }, "get": { "description": "Get ConfigMap", "operationId": "getConfigMap", "parameters": [ { "description": "Name of configmap", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "Response single configmap" } }, "tags": [ "ConfigMapV1alpha1" ] }, "patch": { "description": "Patch ConfigMap", "operationId": "patchConfigMap", "parameters": [ { "description": "Name of configmap", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "Response configmap patched just now" } }, "tags": [ "ConfigMapV1alpha1" ] }, "put": { "description": "Update ConfigMap", "operationId": "updateConfigMap", "parameters": [ { "description": "Name of configmap", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "Updated configmap" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ConfigMap" } } }, "description": "Response configmaps updated just now" } }, "tags": [ "ConfigMapV1alpha1" ] } }, "/api/v1alpha1/menuitems": { "get": { "description": "List MenuItem", "operationId": "listMenuItem", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuItemList" } } }, "description": "Response menuitems" } }, "tags": [ "MenuItemV1alpha1" ] }, "post": { "description": "Create MenuItem", "operationId": "createMenuItem", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuItem" } } }, "description": "Fresh menuitem" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuItem" } } }, "description": "Response menuitems created just now" } }, "tags": [ "MenuItemV1alpha1" ] } }, "/api/v1alpha1/menuitems/{name}": { "delete": { "description": "Delete MenuItem", "operationId": "deleteMenuItem", "parameters": [ { "description": "Name of menuitem", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response menuitem deleted just now" } }, "tags": [ "MenuItemV1alpha1" ] }, "get": { "description": "Get MenuItem", "operationId": "getMenuItem", "parameters": [ { "description": "Name of menuitem", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuItem" } } }, "description": "Response single menuitem" } }, "tags": [ "MenuItemV1alpha1" ] }, "patch": { "description": "Patch MenuItem", "operationId": "patchMenuItem", "parameters": [ { "description": "Name of menuitem", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuItem" } } }, "description": "Response menuitem patched just now" } }, "tags": [ "MenuItemV1alpha1" ] }, "put": { "description": "Update MenuItem", "operationId": "updateMenuItem", "parameters": [ { "description": "Name of menuitem", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuItem" } } }, "description": "Updated menuitem" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuItem" } } }, "description": "Response menuitems updated just now" } }, "tags": [ "MenuItemV1alpha1" ] } }, "/api/v1alpha1/menus": { "get": { "description": "List Menu", "operationId": "listMenu", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuList" } } }, "description": "Response menus" } }, "tags": [ "MenuV1alpha1" ] }, "post": { "description": "Create Menu", "operationId": "createMenu", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Menu" } } }, "description": "Fresh menu" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Menu" } } }, "description": "Response menus created just now" } }, "tags": [ "MenuV1alpha1" ] } }, "/api/v1alpha1/menus/{name}": { "delete": { "description": "Delete Menu", "operationId": "deleteMenu", "parameters": [ { "description": "Name of menu", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response menu deleted just now" } }, "tags": [ "MenuV1alpha1" ] }, "get": { "description": "Get Menu", "operationId": "getMenu", "parameters": [ { "description": "Name of menu", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Menu" } } }, "description": "Response single menu" } }, "tags": [ "MenuV1alpha1" ] }, "patch": { "description": "Patch Menu", "operationId": "patchMenu", "parameters": [ { "description": "Name of menu", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Menu" } } }, "description": "Response menu patched just now" } }, "tags": [ "MenuV1alpha1" ] }, "put": { "description": "Update Menu", "operationId": "updateMenu", "parameters": [ { "description": "Name of menu", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Menu" } } }, "description": "Updated menu" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Menu" } } }, "description": "Response menus updated just now" } }, "tags": [ "MenuV1alpha1" ] } }, "/api/v1alpha1/rolebindings": { "get": { "description": "List RoleBinding", "operationId": "listRoleBinding", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleBindingList" } } }, "description": "Response rolebindings" } }, "tags": [ "RoleBindingV1alpha1" ] }, "post": { "description": "Create RoleBinding", "operationId": "createRoleBinding", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleBinding" } } }, "description": "Fresh rolebinding" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleBinding" } } }, "description": "Response rolebindings created just now" } }, "tags": [ "RoleBindingV1alpha1" ] } }, "/api/v1alpha1/rolebindings/{name}": { "delete": { "description": "Delete RoleBinding", "operationId": "deleteRoleBinding", "parameters": [ { "description": "Name of rolebinding", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response rolebinding deleted just now" } }, "tags": [ "RoleBindingV1alpha1" ] }, "get": { "description": "Get RoleBinding", "operationId": "getRoleBinding", "parameters": [ { "description": "Name of rolebinding", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleBinding" } } }, "description": "Response single rolebinding" } }, "tags": [ "RoleBindingV1alpha1" ] }, "patch": { "description": "Patch RoleBinding", "operationId": "patchRoleBinding", "parameters": [ { "description": "Name of rolebinding", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleBinding" } } }, "description": "Response rolebinding patched just now" } }, "tags": [ "RoleBindingV1alpha1" ] }, "put": { "description": "Update RoleBinding", "operationId": "updateRoleBinding", "parameters": [ { "description": "Name of rolebinding", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleBinding" } } }, "description": "Updated rolebinding" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleBinding" } } }, "description": "Response rolebindings updated just now" } }, "tags": [ "RoleBindingV1alpha1" ] } }, "/api/v1alpha1/roles": { "get": { "description": "List Role", "operationId": "listRole", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RoleList" } } }, "description": "Response roles" } }, "tags": [ "RoleV1alpha1" ] }, "post": { "description": "Create Role", "operationId": "createRole", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Role" } } }, "description": "Fresh role" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Role" } } }, "description": "Response roles created just now" } }, "tags": [ "RoleV1alpha1" ] } }, "/api/v1alpha1/roles/{name}": { "delete": { "description": "Delete Role", "operationId": "deleteRole", "parameters": [ { "description": "Name of role", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response role deleted just now" } }, "tags": [ "RoleV1alpha1" ] }, "get": { "description": "Get Role", "operationId": "getRole", "parameters": [ { "description": "Name of role", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Role" } } }, "description": "Response single role" } }, "tags": [ "RoleV1alpha1" ] }, "patch": { "description": "Patch Role", "operationId": "patchRole", "parameters": [ { "description": "Name of role", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Role" } } }, "description": "Response role patched just now" } }, "tags": [ "RoleV1alpha1" ] }, "put": { "description": "Update Role", "operationId": "updateRole", "parameters": [ { "description": "Name of role", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Role" } } }, "description": "Updated role" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Role" } } }, "description": "Response roles updated just now" } }, "tags": [ "RoleV1alpha1" ] } }, "/api/v1alpha1/secrets": { "get": { "description": "List Secret", "operationId": "listSecret", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SecretList" } } }, "description": "Response secrets" } }, "tags": [ "SecretV1alpha1" ] }, "post": { "description": "Create Secret", "operationId": "createSecret", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Secret" } } }, "description": "Fresh secret" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Secret" } } }, "description": "Response secrets created just now" } }, "tags": [ "SecretV1alpha1" ] } }, "/api/v1alpha1/secrets/{name}": { "delete": { "description": "Delete Secret", "operationId": "deleteSecret", "parameters": [ { "description": "Name of secret", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response secret deleted just now" } }, "tags": [ "SecretV1alpha1" ] }, "get": { "description": "Get Secret", "operationId": "getSecret", "parameters": [ { "description": "Name of secret", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Secret" } } }, "description": "Response single secret" } }, "tags": [ "SecretV1alpha1" ] }, "patch": { "description": "Patch Secret", "operationId": "patchSecret", "parameters": [ { "description": "Name of secret", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Secret" } } }, "description": "Response secret patched just now" } }, "tags": [ "SecretV1alpha1" ] }, "put": { "description": "Update Secret", "operationId": "updateSecret", "parameters": [ { "description": "Name of secret", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Secret" } } }, "description": "Updated secret" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Secret" } } }, "description": "Response secrets updated just now" } }, "tags": [ "SecretV1alpha1" ] } }, "/api/v1alpha1/settings": { "get": { "description": "List Setting", "operationId": "listSetting", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SettingList" } } }, "description": "Response settings" } }, "tags": [ "SettingV1alpha1" ] }, "post": { "description": "Create Setting", "operationId": "createSetting", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "Fresh setting" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "Response settings created just now" } }, "tags": [ "SettingV1alpha1" ] } }, "/api/v1alpha1/settings/{name}": { "delete": { "description": "Delete Setting", "operationId": "deleteSetting", "parameters": [ { "description": "Name of setting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response setting deleted just now" } }, "tags": [ "SettingV1alpha1" ] }, "get": { "description": "Get Setting", "operationId": "getSetting", "parameters": [ { "description": "Name of setting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "Response single setting" } }, "tags": [ "SettingV1alpha1" ] }, "patch": { "description": "Patch Setting", "operationId": "patchSetting", "parameters": [ { "description": "Name of setting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "Response setting patched just now" } }, "tags": [ "SettingV1alpha1" ] }, "put": { "description": "Update Setting", "operationId": "updateSetting", "parameters": [ { "description": "Name of setting", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "Updated setting" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Setting" } } }, "description": "Response settings updated just now" } }, "tags": [ "SettingV1alpha1" ] } }, "/api/v1alpha1/users": { "get": { "description": "List User", "operationId": "listUser", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserList" } } }, "description": "Response users" } }, "tags": [ "UserV1alpha1" ] }, "post": { "description": "Create User", "operationId": "createUser", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "Fresh user" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "Response users created just now" } }, "tags": [ "UserV1alpha1" ] } }, "/api/v1alpha1/users/{name}": { "delete": { "description": "Delete User", "operationId": "deleteUser", "parameters": [ { "description": "Name of user", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response user deleted just now" } }, "tags": [ "UserV1alpha1" ] }, "get": { "description": "Get User", "operationId": "getUser", "parameters": [ { "description": "Name of user", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "Response single user" } }, "tags": [ "UserV1alpha1" ] }, "patch": { "description": "Patch User", "operationId": "patchUser", "parameters": [ { "description": "Name of user", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "Response user patched just now" } }, "tags": [ "UserV1alpha1" ] }, "put": { "description": "Update User", "operationId": "updateUser", "parameters": [ { "description": "Name of user", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "Updated user" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "Response users updated just now" } }, "tags": [ "UserV1alpha1" ] } }, "/apis/auth.halo.run/v1alpha1/authproviders": { "get": { "description": "List AuthProvider", "operationId": "listAuthProvider", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProviderList" } } }, "description": "Response authproviders" } }, "tags": [ "AuthProviderV1alpha1" ] }, "post": { "description": "Create AuthProvider", "operationId": "createAuthProvider", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "Fresh authprovider" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "Response authproviders created just now" } }, "tags": [ "AuthProviderV1alpha1" ] } }, "/apis/auth.halo.run/v1alpha1/authproviders/{name}": { "delete": { "description": "Delete AuthProvider", "operationId": "deleteAuthProvider", "parameters": [ { "description": "Name of authprovider", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response authprovider deleted just now" } }, "tags": [ "AuthProviderV1alpha1" ] }, "get": { "description": "Get AuthProvider", "operationId": "getAuthProvider", "parameters": [ { "description": "Name of authprovider", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "Response single authprovider" } }, "tags": [ "AuthProviderV1alpha1" ] }, "patch": { "description": "Patch AuthProvider", "operationId": "patchAuthProvider", "parameters": [ { "description": "Name of authprovider", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "Response authprovider patched just now" } }, "tags": [ "AuthProviderV1alpha1" ] }, "put": { "description": "Update AuthProvider", "operationId": "updateAuthProvider", "parameters": [ { "description": "Name of authprovider", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "Updated authprovider" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AuthProvider" } } }, "description": "Response authproviders updated just now" } }, "tags": [ "AuthProviderV1alpha1" ] } }, "/apis/auth.halo.run/v1alpha1/userconnections": { "get": { "description": "List UserConnection", "operationId": "listUserConnection", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserConnectionList" } } }, "description": "Response userconnections" } }, "tags": [ "UserConnectionV1alpha1" ] }, "post": { "description": "Create UserConnection", "operationId": "createUserConnection", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserConnection" } } }, "description": "Fresh userconnection" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserConnection" } } }, "description": "Response userconnections created just now" } }, "tags": [ "UserConnectionV1alpha1" ] } }, "/apis/auth.halo.run/v1alpha1/userconnections/{name}": { "delete": { "description": "Delete UserConnection", "operationId": "deleteUserConnection", "parameters": [ { "description": "Name of userconnection", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response userconnection deleted just now" } }, "tags": [ "UserConnectionV1alpha1" ] }, "get": { "description": "Get UserConnection", "operationId": "getUserConnection", "parameters": [ { "description": "Name of userconnection", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserConnection" } } }, "description": "Response single userconnection" } }, "tags": [ "UserConnectionV1alpha1" ] }, "patch": { "description": "Patch UserConnection", "operationId": "patchUserConnection", "parameters": [ { "description": "Name of userconnection", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserConnection" } } }, "description": "Response userconnection patched just now" } }, "tags": [ "UserConnectionV1alpha1" ] }, "put": { "description": "Update UserConnection", "operationId": "updateUserConnection", "parameters": [ { "description": "Name of userconnection", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserConnection" } } }, "description": "Updated userconnection" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/UserConnection" } } }, "description": "Response userconnections updated just now" } }, "tags": [ "UserConnectionV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/categories": { "get": { "description": "List Category", "operationId": "listCategory", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CategoryList" } } }, "description": "Response categories" } }, "tags": [ "CategoryV1alpha1" ] }, "post": { "description": "Create Category", "operationId": "createCategory", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Category" } } }, "description": "Fresh category" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Category" } } }, "description": "Response categories created just now" } }, "tags": [ "CategoryV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/categories/{name}": { "delete": { "description": "Delete Category", "operationId": "deleteCategory", "parameters": [ { "description": "Name of category", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response category deleted just now" } }, "tags": [ "CategoryV1alpha1" ] }, "get": { "description": "Get Category", "operationId": "getCategory", "parameters": [ { "description": "Name of category", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Category" } } }, "description": "Response single category" } }, "tags": [ "CategoryV1alpha1" ] }, "patch": { "description": "Patch Category", "operationId": "patchCategory", "parameters": [ { "description": "Name of category", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Category" } } }, "description": "Response category patched just now" } }, "tags": [ "CategoryV1alpha1" ] }, "put": { "description": "Update Category", "operationId": "updateCategory", "parameters": [ { "description": "Name of category", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Category" } } }, "description": "Updated category" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Category" } } }, "description": "Response categories updated just now" } }, "tags": [ "CategoryV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/comments": { "get": { "description": "List Comment", "operationId": "listComment", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CommentList" } } }, "description": "Response comments" } }, "tags": [ "CommentV1alpha1" ] }, "post": { "description": "Create Comment", "operationId": "createComment", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "Fresh comment" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "Response comments created just now" } }, "tags": [ "CommentV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/comments/{name}": { "delete": { "description": "Delete Comment", "operationId": "deleteComment", "parameters": [ { "description": "Name of comment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response comment deleted just now" } }, "tags": [ "CommentV1alpha1" ] }, "get": { "description": "Get Comment", "operationId": "getComment", "parameters": [ { "description": "Name of comment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "Response single comment" } }, "tags": [ "CommentV1alpha1" ] }, "patch": { "description": "Patch Comment", "operationId": "patchComment", "parameters": [ { "description": "Name of comment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "Response comment patched just now" } }, "tags": [ "CommentV1alpha1" ] }, "put": { "description": "Update Comment", "operationId": "updateComment", "parameters": [ { "description": "Name of comment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "Updated comment" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "Response comments updated just now" } }, "tags": [ "CommentV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/posts": { "get": { "description": "List Post", "operationId": "listPost", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PostList" } } }, "description": "Response posts" } }, "tags": [ "PostV1alpha1" ] }, "post": { "description": "Create Post", "operationId": "createPost", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "Fresh post" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "Response posts created just now" } }, "tags": [ "PostV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/posts/{name}": { "delete": { "description": "Delete Post", "operationId": "deletePost", "parameters": [ { "description": "Name of post", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response post deleted just now" } }, "tags": [ "PostV1alpha1" ] }, "get": { "description": "Get Post", "operationId": "getPost", "parameters": [ { "description": "Name of post", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "Response single post" } }, "tags": [ "PostV1alpha1" ] }, "patch": { "description": "Patch Post", "operationId": "patchPost", "parameters": [ { "description": "Name of post", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "Response post patched just now" } }, "tags": [ "PostV1alpha1" ] }, "put": { "description": "Update Post", "operationId": "updatePost", "parameters": [ { "description": "Name of post", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "Updated post" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "Response posts updated just now" } }, "tags": [ "PostV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/replies": { "get": { "description": "List Reply", "operationId": "listReply", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReplyList" } } }, "description": "Response replies" } }, "tags": [ "ReplyV1alpha1" ] }, "post": { "description": "Create Reply", "operationId": "createReply", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "Fresh reply" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "Response replies created just now" } }, "tags": [ "ReplyV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/replies/{name}": { "delete": { "description": "Delete Reply", "operationId": "deleteReply", "parameters": [ { "description": "Name of reply", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response reply deleted just now" } }, "tags": [ "ReplyV1alpha1" ] }, "get": { "description": "Get Reply", "operationId": "getReply", "parameters": [ { "description": "Name of reply", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "Response single reply" } }, "tags": [ "ReplyV1alpha1" ] }, "patch": { "description": "Patch Reply", "operationId": "patchReply", "parameters": [ { "description": "Name of reply", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "Response reply patched just now" } }, "tags": [ "ReplyV1alpha1" ] }, "put": { "description": "Update Reply", "operationId": "updateReply", "parameters": [ { "description": "Name of reply", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "Updated reply" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "Response replies updated just now" } }, "tags": [ "ReplyV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/singlepages": { "get": { "description": "List SinglePage", "operationId": "listSinglePage", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePageList" } } }, "description": "Response singlepages" } }, "tags": [ "SinglePageV1alpha1" ] }, "post": { "description": "Create SinglePage", "operationId": "createSinglePage", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "Fresh singlepage" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "Response singlepages created just now" } }, "tags": [ "SinglePageV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/singlepages/{name}": { "delete": { "description": "Delete SinglePage", "operationId": "deleteSinglePage", "parameters": [ { "description": "Name of singlepage", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response singlepage deleted just now" } }, "tags": [ "SinglePageV1alpha1" ] }, "get": { "description": "Get SinglePage", "operationId": "getSinglePage", "parameters": [ { "description": "Name of singlepage", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "Response single singlepage" } }, "tags": [ "SinglePageV1alpha1" ] }, "patch": { "description": "Patch SinglePage", "operationId": "patchSinglePage", "parameters": [ { "description": "Name of singlepage", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "Response singlepage patched just now" } }, "tags": [ "SinglePageV1alpha1" ] }, "put": { "description": "Update SinglePage", "operationId": "updateSinglePage", "parameters": [ { "description": "Name of singlepage", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "Updated singlepage" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePage" } } }, "description": "Response singlepages updated just now" } }, "tags": [ "SinglePageV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/snapshots": { "get": { "description": "List Snapshot", "operationId": "listSnapshot", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SnapshotList" } } }, "description": "Response snapshots" } }, "tags": [ "SnapshotV1alpha1" ] }, "post": { "description": "Create Snapshot", "operationId": "createSnapshot", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "Fresh snapshot" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "Response snapshots created just now" } }, "tags": [ "SnapshotV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/snapshots/{name}": { "delete": { "description": "Delete Snapshot", "operationId": "deleteSnapshot", "parameters": [ { "description": "Name of snapshot", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response snapshot deleted just now" } }, "tags": [ "SnapshotV1alpha1" ] }, "get": { "description": "Get Snapshot", "operationId": "getSnapshot", "parameters": [ { "description": "Name of snapshot", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "Response single snapshot" } }, "tags": [ "SnapshotV1alpha1" ] }, "patch": { "description": "Patch Snapshot", "operationId": "patchSnapshot", "parameters": [ { "description": "Name of snapshot", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "Response snapshot patched just now" } }, "tags": [ "SnapshotV1alpha1" ] }, "put": { "description": "Update Snapshot", "operationId": "updateSnapshot", "parameters": [ { "description": "Name of snapshot", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "Updated snapshot" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "Response snapshots updated just now" } }, "tags": [ "SnapshotV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/tags": { "get": { "description": "List Tag", "operationId": "listTag", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TagList" } } }, "description": "Response tags" } }, "tags": [ "TagV1alpha1" ] }, "post": { "description": "Create Tag", "operationId": "createTag", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "Fresh tag" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "Response tags created just now" } }, "tags": [ "TagV1alpha1" ] } }, "/apis/content.halo.run/v1alpha1/tags/{name}": { "delete": { "description": "Delete Tag", "operationId": "deleteTag", "parameters": [ { "description": "Name of tag", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response tag deleted just now" } }, "tags": [ "TagV1alpha1" ] }, "get": { "description": "Get Tag", "operationId": "getTag", "parameters": [ { "description": "Name of tag", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "Response single tag" } }, "tags": [ "TagV1alpha1" ] }, "patch": { "description": "Patch Tag", "operationId": "patchTag", "parameters": [ { "description": "Name of tag", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "Response tag patched just now" } }, "tags": [ "TagV1alpha1" ] }, "put": { "description": "Update Tag", "operationId": "updateTag", "parameters": [ { "description": "Name of tag", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "Updated tag" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "Response tags updated just now" } }, "tags": [ "TagV1alpha1" ] } }, "/apis/metrics.halo.run/v1alpha1/counters": { "get": { "description": "List Counter", "operationId": "listCounter", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CounterList" } } }, "description": "Response counters" } }, "tags": [ "CounterV1alpha1" ] }, "post": { "description": "Create Counter", "operationId": "createCounter", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Counter" } } }, "description": "Fresh counter" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Counter" } } }, "description": "Response counters created just now" } }, "tags": [ "CounterV1alpha1" ] } }, "/apis/metrics.halo.run/v1alpha1/counters/{name}": { "delete": { "description": "Delete Counter", "operationId": "deleteCounter", "parameters": [ { "description": "Name of counter", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response counter deleted just now" } }, "tags": [ "CounterV1alpha1" ] }, "get": { "description": "Get Counter", "operationId": "getCounter", "parameters": [ { "description": "Name of counter", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Counter" } } }, "description": "Response single counter" } }, "tags": [ "CounterV1alpha1" ] }, "patch": { "description": "Patch Counter", "operationId": "patchCounter", "parameters": [ { "description": "Name of counter", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Counter" } } }, "description": "Response counter patched just now" } }, "tags": [ "CounterV1alpha1" ] }, "put": { "description": "Update Counter", "operationId": "updateCounter", "parameters": [ { "description": "Name of counter", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Counter" } } }, "description": "Updated counter" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Counter" } } }, "description": "Response counters updated just now" } }, "tags": [ "CounterV1alpha1" ] } }, "/apis/migration.halo.run/v1alpha1/backups": { "get": { "description": "List Backup", "operationId": "listBackup", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/BackupList" } } }, "description": "Response backups" } }, "tags": [ "BackupV1alpha1" ] }, "post": { "description": "Create Backup", "operationId": "createBackup", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Backup" } } }, "description": "Fresh backup" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Backup" } } }, "description": "Response backups created just now" } }, "tags": [ "BackupV1alpha1" ] } }, "/apis/migration.halo.run/v1alpha1/backups/{name}": { "delete": { "description": "Delete Backup", "operationId": "deleteBackup", "parameters": [ { "description": "Name of backup", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response backup deleted just now" } }, "tags": [ "BackupV1alpha1" ] }, "get": { "description": "Get Backup", "operationId": "getBackup", "parameters": [ { "description": "Name of backup", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Backup" } } }, "description": "Response single backup" } }, "tags": [ "BackupV1alpha1" ] }, "patch": { "description": "Patch Backup", "operationId": "patchBackup", "parameters": [ { "description": "Name of backup", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Backup" } } }, "description": "Response backup patched just now" } }, "tags": [ "BackupV1alpha1" ] }, "put": { "description": "Update Backup", "operationId": "updateBackup", "parameters": [ { "description": "Name of backup", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Backup" } } }, "description": "Updated backup" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Backup" } } }, "description": "Response backups updated just now" } }, "tags": [ "BackupV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/notifications": { "get": { "description": "List Notification", "operationId": "listNotification", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationList" } } }, "description": "Response notifications" } }, "tags": [ "NotificationV1alpha1" ] }, "post": { "description": "Create Notification", "operationId": "createNotification", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "Fresh notification" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "Response notifications created just now" } }, "tags": [ "NotificationV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/notifications/{name}": { "delete": { "description": "Delete Notification", "operationId": "deleteNotification", "parameters": [ { "description": "Name of notification", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response notification deleted just now" } }, "tags": [ "NotificationV1alpha1" ] }, "get": { "description": "Get Notification", "operationId": "getNotification", "parameters": [ { "description": "Name of notification", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "Response single notification" } }, "tags": [ "NotificationV1alpha1" ] }, "patch": { "description": "Patch Notification", "operationId": "patchNotification", "parameters": [ { "description": "Name of notification", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "Response notification patched just now" } }, "tags": [ "NotificationV1alpha1" ] }, "put": { "description": "Update Notification", "operationId": "updateNotification", "parameters": [ { "description": "Name of notification", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "Updated notification" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "Response notifications updated just now" } }, "tags": [ "NotificationV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/notificationtemplates": { "get": { "description": "List NotificationTemplate", "operationId": "listNotificationTemplate", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationTemplateList" } } }, "description": "Response notificationtemplates" } }, "tags": [ "NotificationTemplateV1alpha1" ] }, "post": { "description": "Create NotificationTemplate", "operationId": "createNotificationTemplate", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationTemplate" } } }, "description": "Fresh notificationtemplate" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationTemplate" } } }, "description": "Response notificationtemplates created just now" } }, "tags": [ "NotificationTemplateV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/notificationtemplates/{name}": { "delete": { "description": "Delete NotificationTemplate", "operationId": "deleteNotificationTemplate", "parameters": [ { "description": "Name of notificationtemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response notificationtemplate deleted just now" } }, "tags": [ "NotificationTemplateV1alpha1" ] }, "get": { "description": "Get NotificationTemplate", "operationId": "getNotificationTemplate", "parameters": [ { "description": "Name of notificationtemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationTemplate" } } }, "description": "Response single notificationtemplate" } }, "tags": [ "NotificationTemplateV1alpha1" ] }, "patch": { "description": "Patch NotificationTemplate", "operationId": "patchNotificationTemplate", "parameters": [ { "description": "Name of notificationtemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationTemplate" } } }, "description": "Response notificationtemplate patched just now" } }, "tags": [ "NotificationTemplateV1alpha1" ] }, "put": { "description": "Update NotificationTemplate", "operationId": "updateNotificationTemplate", "parameters": [ { "description": "Name of notificationtemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationTemplate" } } }, "description": "Updated notificationtemplate" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationTemplate" } } }, "description": "Response notificationtemplates updated just now" } }, "tags": [ "NotificationTemplateV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/notifierDescriptors": { "get": { "description": "List NotifierDescriptor", "operationId": "listNotifierDescriptor", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotifierDescriptorList" } } }, "description": "Response notifierDescriptors" } }, "tags": [ "NotifierDescriptorV1alpha1" ] }, "post": { "description": "Create NotifierDescriptor", "operationId": "createNotifierDescriptor", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotifierDescriptor" } } }, "description": "Fresh notifierDescriptor" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotifierDescriptor" } } }, "description": "Response notifierDescriptors created just now" } }, "tags": [ "NotifierDescriptorV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/notifierDescriptors/{name}": { "delete": { "description": "Delete NotifierDescriptor", "operationId": "deleteNotifierDescriptor", "parameters": [ { "description": "Name of notifierDescriptor", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response notifierDescriptor deleted just now" } }, "tags": [ "NotifierDescriptorV1alpha1" ] }, "get": { "description": "Get NotifierDescriptor", "operationId": "getNotifierDescriptor", "parameters": [ { "description": "Name of notifierDescriptor", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotifierDescriptor" } } }, "description": "Response single notifierDescriptor" } }, "tags": [ "NotifierDescriptorV1alpha1" ] }, "patch": { "description": "Patch NotifierDescriptor", "operationId": "patchNotifierDescriptor", "parameters": [ { "description": "Name of notifierDescriptor", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotifierDescriptor" } } }, "description": "Response notifierDescriptor patched just now" } }, "tags": [ "NotifierDescriptorV1alpha1" ] }, "put": { "description": "Update NotifierDescriptor", "operationId": "updateNotifierDescriptor", "parameters": [ { "description": "Name of notifierDescriptor", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotifierDescriptor" } } }, "description": "Updated notifierDescriptor" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotifierDescriptor" } } }, "description": "Response notifierDescriptors updated just now" } }, "tags": [ "NotifierDescriptorV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/reasons": { "get": { "description": "List Reason", "operationId": "listReason", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonList" } } }, "description": "Response reasons" } }, "tags": [ "ReasonV1alpha1" ] }, "post": { "description": "Create Reason", "operationId": "createReason", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reason" } } }, "description": "Fresh reason" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reason" } } }, "description": "Response reasons created just now" } }, "tags": [ "ReasonV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/reasons/{name}": { "delete": { "description": "Delete Reason", "operationId": "deleteReason", "parameters": [ { "description": "Name of reason", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response reason deleted just now" } }, "tags": [ "ReasonV1alpha1" ] }, "get": { "description": "Get Reason", "operationId": "getReason", "parameters": [ { "description": "Name of reason", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reason" } } }, "description": "Response single reason" } }, "tags": [ "ReasonV1alpha1" ] }, "patch": { "description": "Patch Reason", "operationId": "patchReason", "parameters": [ { "description": "Name of reason", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reason" } } }, "description": "Response reason patched just now" } }, "tags": [ "ReasonV1alpha1" ] }, "put": { "description": "Update Reason", "operationId": "updateReason", "parameters": [ { "description": "Name of reason", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reason" } } }, "description": "Updated reason" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reason" } } }, "description": "Response reasons updated just now" } }, "tags": [ "ReasonV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/reasontypes": { "get": { "description": "List ReasonType", "operationId": "listReasonType", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonTypeList" } } }, "description": "Response reasontypes" } }, "tags": [ "ReasonTypeV1alpha1" ] }, "post": { "description": "Create ReasonType", "operationId": "createReasonType", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonType" } } }, "description": "Fresh reasontype" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonType" } } }, "description": "Response reasontypes created just now" } }, "tags": [ "ReasonTypeV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/reasontypes/{name}": { "delete": { "description": "Delete ReasonType", "operationId": "deleteReasonType", "parameters": [ { "description": "Name of reasontype", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response reasontype deleted just now" } }, "tags": [ "ReasonTypeV1alpha1" ] }, "get": { "description": "Get ReasonType", "operationId": "getReasonType", "parameters": [ { "description": "Name of reasontype", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonType" } } }, "description": "Response single reasontype" } }, "tags": [ "ReasonTypeV1alpha1" ] }, "patch": { "description": "Patch ReasonType", "operationId": "patchReasonType", "parameters": [ { "description": "Name of reasontype", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonType" } } }, "description": "Response reasontype patched just now" } }, "tags": [ "ReasonTypeV1alpha1" ] }, "put": { "description": "Update ReasonType", "operationId": "updateReasonType", "parameters": [ { "description": "Name of reasontype", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonType" } } }, "description": "Updated reasontype" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonType" } } }, "description": "Response reasontypes updated just now" } }, "tags": [ "ReasonTypeV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/subscriptions": { "get": { "description": "List Subscription", "operationId": "listSubscription", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SubscriptionList" } } }, "description": "Response subscriptions" } }, "tags": [ "SubscriptionV1alpha1" ] }, "post": { "description": "Create Subscription", "operationId": "createSubscription", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Subscription" } } }, "description": "Fresh subscription" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Subscription" } } }, "description": "Response subscriptions created just now" } }, "tags": [ "SubscriptionV1alpha1" ] } }, "/apis/notification.halo.run/v1alpha1/subscriptions/{name}": { "delete": { "description": "Delete Subscription", "operationId": "deleteSubscription", "parameters": [ { "description": "Name of subscription", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response subscription deleted just now" } }, "tags": [ "SubscriptionV1alpha1" ] }, "get": { "description": "Get Subscription", "operationId": "getSubscription", "parameters": [ { "description": "Name of subscription", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Subscription" } } }, "description": "Response single subscription" } }, "tags": [ "SubscriptionV1alpha1" ] }, "patch": { "description": "Patch Subscription", "operationId": "patchSubscription", "parameters": [ { "description": "Name of subscription", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Subscription" } } }, "description": "Response subscription patched just now" } }, "tags": [ "SubscriptionV1alpha1" ] }, "put": { "description": "Update Subscription", "operationId": "updateSubscription", "parameters": [ { "description": "Name of subscription", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Subscription" } } }, "description": "Updated subscription" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Subscription" } } }, "description": "Response subscriptions updated just now" } }, "tags": [ "SubscriptionV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/extensiondefinitions": { "get": { "description": "List ExtensionDefinition", "operationId": "listExtensionDefinition", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionDefinitionList" } } }, "description": "Response extensiondefinitions" } }, "tags": [ "ExtensionDefinitionV1alpha1" ] }, "post": { "description": "Create ExtensionDefinition", "operationId": "createExtensionDefinition", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionDefinition" } } }, "description": "Fresh extensiondefinition" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionDefinition" } } }, "description": "Response extensiondefinitions created just now" } }, "tags": [ "ExtensionDefinitionV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/extensiondefinitions/{name}": { "delete": { "description": "Delete ExtensionDefinition", "operationId": "deleteExtensionDefinition", "parameters": [ { "description": "Name of extensiondefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response extensiondefinition deleted just now" } }, "tags": [ "ExtensionDefinitionV1alpha1" ] }, "get": { "description": "Get ExtensionDefinition", "operationId": "getExtensionDefinition", "parameters": [ { "description": "Name of extensiondefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionDefinition" } } }, "description": "Response single extensiondefinition" } }, "tags": [ "ExtensionDefinitionV1alpha1" ] }, "patch": { "description": "Patch ExtensionDefinition", "operationId": "patchExtensionDefinition", "parameters": [ { "description": "Name of extensiondefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionDefinition" } } }, "description": "Response extensiondefinition patched just now" } }, "tags": [ "ExtensionDefinitionV1alpha1" ] }, "put": { "description": "Update ExtensionDefinition", "operationId": "updateExtensionDefinition", "parameters": [ { "description": "Name of extensiondefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionDefinition" } } }, "description": "Updated extensiondefinition" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionDefinition" } } }, "description": "Response extensiondefinitions updated just now" } }, "tags": [ "ExtensionDefinitionV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/extensionpointdefinitions": { "get": { "description": "List ExtensionPointDefinition", "operationId": "listExtensionPointDefinition", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionPointDefinitionList" } } }, "description": "Response extensionpointdefinitions" } }, "tags": [ "ExtensionPointDefinitionV1alpha1" ] }, "post": { "description": "Create ExtensionPointDefinition", "operationId": "createExtensionPointDefinition", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, "description": "Fresh extensionpointdefinition" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, "description": "Response extensionpointdefinitions created just now" } }, "tags": [ "ExtensionPointDefinitionV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/extensionpointdefinitions/{name}": { "delete": { "description": "Delete ExtensionPointDefinition", "operationId": "deleteExtensionPointDefinition", "parameters": [ { "description": "Name of extensionpointdefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response extensionpointdefinition deleted just now" } }, "tags": [ "ExtensionPointDefinitionV1alpha1" ] }, "get": { "description": "Get ExtensionPointDefinition", "operationId": "getExtensionPointDefinition", "parameters": [ { "description": "Name of extensionpointdefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, "description": "Response single extensionpointdefinition" } }, "tags": [ "ExtensionPointDefinitionV1alpha1" ] }, "patch": { "description": "Patch ExtensionPointDefinition", "operationId": "patchExtensionPointDefinition", "parameters": [ { "description": "Name of extensionpointdefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, "description": "Response extensionpointdefinition patched just now" } }, "tags": [ "ExtensionPointDefinitionV1alpha1" ] }, "put": { "description": "Update ExtensionPointDefinition", "operationId": "updateExtensionPointDefinition", "parameters": [ { "description": "Name of extensionpointdefinition", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, "description": "Updated extensionpointdefinition" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, "description": "Response extensionpointdefinitions updated just now" } }, "tags": [ "ExtensionPointDefinitionV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/plugins": { "get": { "description": "List Plugin", "operationId": "listPlugin", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PluginList" } } }, "description": "Response plugins" } }, "tags": [ "PluginV1alpha1" ] }, "post": { "description": "Create Plugin", "operationId": "createPlugin", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "Fresh plugin" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "Response plugins created just now" } }, "tags": [ "PluginV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/plugins/{name}": { "delete": { "description": "Delete Plugin", "operationId": "deletePlugin", "parameters": [ { "description": "Name of plugin", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response plugin deleted just now" } }, "tags": [ "PluginV1alpha1" ] }, "get": { "description": "Get Plugin", "operationId": "getPlugin", "parameters": [ { "description": "Name of plugin", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "Response single plugin" } }, "tags": [ "PluginV1alpha1" ] }, "patch": { "description": "Patch Plugin", "operationId": "patchPlugin", "parameters": [ { "description": "Name of plugin", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "Response plugin patched just now" } }, "tags": [ "PluginV1alpha1" ] }, "put": { "description": "Update Plugin", "operationId": "updatePlugin", "parameters": [ { "description": "Name of plugin", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "Updated plugin" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Plugin" } } }, "description": "Response plugins updated just now" } }, "tags": [ "PluginV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/reverseproxies": { "get": { "description": "List ReverseProxy", "operationId": "listReverseProxy", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReverseProxyList" } } }, "description": "Response reverseproxies" } }, "tags": [ "ReverseProxyV1alpha1" ] }, "post": { "description": "Create ReverseProxy", "operationId": "createReverseProxy", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReverseProxy" } } }, "description": "Fresh reverseproxy" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReverseProxy" } } }, "description": "Response reverseproxies created just now" } }, "tags": [ "ReverseProxyV1alpha1" ] } }, "/apis/plugin.halo.run/v1alpha1/reverseproxies/{name}": { "delete": { "description": "Delete ReverseProxy", "operationId": "deleteReverseProxy", "parameters": [ { "description": "Name of reverseproxy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response reverseproxy deleted just now" } }, "tags": [ "ReverseProxyV1alpha1" ] }, "get": { "description": "Get ReverseProxy", "operationId": "getReverseProxy", "parameters": [ { "description": "Name of reverseproxy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReverseProxy" } } }, "description": "Response single reverseproxy" } }, "tags": [ "ReverseProxyV1alpha1" ] }, "patch": { "description": "Patch ReverseProxy", "operationId": "patchReverseProxy", "parameters": [ { "description": "Name of reverseproxy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReverseProxy" } } }, "description": "Response reverseproxy patched just now" } }, "tags": [ "ReverseProxyV1alpha1" ] }, "put": { "description": "Update ReverseProxy", "operationId": "updateReverseProxy", "parameters": [ { "description": "Name of reverseproxy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReverseProxy" } } }, "description": "Updated reverseproxy" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReverseProxy" } } }, "description": "Response reverseproxies updated just now" } }, "tags": [ "ReverseProxyV1alpha1" ] } }, "/apis/security.halo.run/v1alpha1/devices": { "get": { "description": "List Device", "operationId": "listDevice", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/DeviceList" } } }, "description": "Response devices" } }, "tags": [ "DeviceV1alpha1" ] }, "post": { "description": "Create Device", "operationId": "createDevice", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Device" } } }, "description": "Fresh device" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Device" } } }, "description": "Response devices created just now" } }, "tags": [ "DeviceV1alpha1" ] } }, "/apis/security.halo.run/v1alpha1/devices/{name}": { "delete": { "description": "Delete Device", "operationId": "deleteDevice", "parameters": [ { "description": "Name of device", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response device deleted just now" } }, "tags": [ "DeviceV1alpha1" ] }, "get": { "description": "Get Device", "operationId": "getDevice", "parameters": [ { "description": "Name of device", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Device" } } }, "description": "Response single device" } }, "tags": [ "DeviceV1alpha1" ] }, "patch": { "description": "Patch Device", "operationId": "patchDevice", "parameters": [ { "description": "Name of device", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Device" } } }, "description": "Response device patched just now" } }, "tags": [ "DeviceV1alpha1" ] }, "put": { "description": "Update Device", "operationId": "updateDevice", "parameters": [ { "description": "Name of device", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Device" } } }, "description": "Updated device" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Device" } } }, "description": "Response devices updated just now" } }, "tags": [ "DeviceV1alpha1" ] } }, "/apis/security.halo.run/v1alpha1/personalaccesstokens": { "get": { "description": "List PersonalAccessToken", "operationId": "listPersonalAccessToken", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessTokenList" } } }, "description": "Response personalaccesstokens" } }, "tags": [ "PersonalAccessTokenV1alpha1" ] }, "post": { "description": "Create PersonalAccessToken", "operationId": "createPersonalAccessToken", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "description": "Fresh personalaccesstoken" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "description": "Response personalaccesstokens created just now" } }, "tags": [ "PersonalAccessTokenV1alpha1" ] } }, "/apis/security.halo.run/v1alpha1/personalaccesstokens/{name}": { "delete": { "description": "Delete PersonalAccessToken", "operationId": "deletePersonalAccessToken", "parameters": [ { "description": "Name of personalaccesstoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response personalaccesstoken deleted just now" } }, "tags": [ "PersonalAccessTokenV1alpha1" ] }, "get": { "description": "Get PersonalAccessToken", "operationId": "getPersonalAccessToken", "parameters": [ { "description": "Name of personalaccesstoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "description": "Response single personalaccesstoken" } }, "tags": [ "PersonalAccessTokenV1alpha1" ] }, "patch": { "description": "Patch PersonalAccessToken", "operationId": "patchPersonalAccessToken", "parameters": [ { "description": "Name of personalaccesstoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "description": "Response personalaccesstoken patched just now" } }, "tags": [ "PersonalAccessTokenV1alpha1" ] }, "put": { "description": "Update PersonalAccessToken", "operationId": "updatePersonalAccessToken", "parameters": [ { "description": "Name of personalaccesstoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "description": "Updated personalaccesstoken" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "description": "Response personalaccesstokens updated just now" } }, "tags": [ "PersonalAccessTokenV1alpha1" ] } }, "/apis/security.halo.run/v1alpha1/remembermetokens": { "get": { "description": "List RememberMeToken", "operationId": "listRememberMeToken", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RememberMeTokenList" } } }, "description": "Response remembermetokens" } }, "tags": [ "RememberMeTokenV1alpha1" ] }, "post": { "description": "Create RememberMeToken", "operationId": "createRememberMeToken", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RememberMeToken" } } }, "description": "Fresh remembermetoken" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RememberMeToken" } } }, "description": "Response remembermetokens created just now" } }, "tags": [ "RememberMeTokenV1alpha1" ] } }, "/apis/security.halo.run/v1alpha1/remembermetokens/{name}": { "delete": { "description": "Delete RememberMeToken", "operationId": "deleteRememberMeToken", "parameters": [ { "description": "Name of remembermetoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response remembermetoken deleted just now" } }, "tags": [ "RememberMeTokenV1alpha1" ] }, "get": { "description": "Get RememberMeToken", "operationId": "getRememberMeToken", "parameters": [ { "description": "Name of remembermetoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RememberMeToken" } } }, "description": "Response single remembermetoken" } }, "tags": [ "RememberMeTokenV1alpha1" ] }, "patch": { "description": "Patch RememberMeToken", "operationId": "patchRememberMeToken", "parameters": [ { "description": "Name of remembermetoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RememberMeToken" } } }, "description": "Response remembermetoken patched just now" } }, "tags": [ "RememberMeTokenV1alpha1" ] }, "put": { "description": "Update RememberMeToken", "operationId": "updateRememberMeToken", "parameters": [ { "description": "Name of remembermetoken", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RememberMeToken" } } }, "description": "Updated remembermetoken" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/RememberMeToken" } } }, "description": "Response remembermetokens updated just now" } }, "tags": [ "RememberMeTokenV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/attachments": { "get": { "description": "List Attachment", "operationId": "listAttachment", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AttachmentList" } } }, "description": "Response attachments" } }, "tags": [ "AttachmentV1alpha1" ] }, "post": { "description": "Create Attachment", "operationId": "createAttachment", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "Fresh attachment" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "Response attachments created just now" } }, "tags": [ "AttachmentV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/attachments/{name}": { "delete": { "description": "Delete Attachment", "operationId": "deleteAttachment", "parameters": [ { "description": "Name of attachment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response attachment deleted just now" } }, "tags": [ "AttachmentV1alpha1" ] }, "get": { "description": "Get Attachment", "operationId": "getAttachment", "parameters": [ { "description": "Name of attachment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "Response single attachment" } }, "tags": [ "AttachmentV1alpha1" ] }, "patch": { "description": "Patch Attachment", "operationId": "patchAttachment", "parameters": [ { "description": "Name of attachment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "Response attachment patched just now" } }, "tags": [ "AttachmentV1alpha1" ] }, "put": { "description": "Update Attachment", "operationId": "updateAttachment", "parameters": [ { "description": "Name of attachment", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "Updated attachment" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "Response attachments updated just now" } }, "tags": [ "AttachmentV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/groups": { "get": { "description": "List Group", "operationId": "listGroup", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/GroupList" } } }, "description": "Response groups" } }, "tags": [ "GroupV1alpha1" ] }, "post": { "description": "Create Group", "operationId": "createGroup", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "Fresh group" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "Response groups created just now" } }, "tags": [ "GroupV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/groups/{name}": { "delete": { "description": "Delete Group", "operationId": "deleteGroup", "parameters": [ { "description": "Name of group", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response group deleted just now" } }, "tags": [ "GroupV1alpha1" ] }, "get": { "description": "Get Group", "operationId": "getGroup", "parameters": [ { "description": "Name of group", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "Response single group" } }, "tags": [ "GroupV1alpha1" ] }, "patch": { "description": "Patch Group", "operationId": "patchGroup", "parameters": [ { "description": "Name of group", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "Response group patched just now" } }, "tags": [ "GroupV1alpha1" ] }, "put": { "description": "Update Group", "operationId": "updateGroup", "parameters": [ { "description": "Name of group", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "Updated group" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "Response groups updated just now" } }, "tags": [ "GroupV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/localthumbnails": { "get": { "description": "List LocalThumbnail", "operationId": "listLocalThumbnail", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/LocalThumbnailList" } } }, "description": "Response localthumbnails" } }, "tags": [ "LocalThumbnailV1alpha1" ] }, "post": { "description": "Create LocalThumbnail", "operationId": "createLocalThumbnail", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/LocalThumbnail" } } }, "description": "Fresh localthumbnail" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/LocalThumbnail" } } }, "description": "Response localthumbnails created just now" } }, "tags": [ "LocalThumbnailV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/localthumbnails/{name}": { "delete": { "description": "Delete LocalThumbnail", "operationId": "deleteLocalThumbnail", "parameters": [ { "description": "Name of localthumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response localthumbnail deleted just now" } }, "tags": [ "LocalThumbnailV1alpha1" ] }, "get": { "description": "Get LocalThumbnail", "operationId": "getLocalThumbnail", "parameters": [ { "description": "Name of localthumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/LocalThumbnail" } } }, "description": "Response single localthumbnail" } }, "tags": [ "LocalThumbnailV1alpha1" ] }, "patch": { "description": "Patch LocalThumbnail", "operationId": "patchLocalThumbnail", "parameters": [ { "description": "Name of localthumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/LocalThumbnail" } } }, "description": "Response localthumbnail patched just now" } }, "tags": [ "LocalThumbnailV1alpha1" ] }, "put": { "description": "Update LocalThumbnail", "operationId": "updateLocalThumbnail", "parameters": [ { "description": "Name of localthumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/LocalThumbnail" } } }, "description": "Updated localthumbnail" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/LocalThumbnail" } } }, "description": "Response localthumbnails updated just now" } }, "tags": [ "LocalThumbnailV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/policies": { "get": { "description": "List Policy", "operationId": "listPolicy", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyList" } } }, "description": "Response policies" } }, "tags": [ "PolicyV1alpha1" ] }, "post": { "description": "Create Policy", "operationId": "createPolicy", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Policy" } } }, "description": "Fresh policy" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Policy" } } }, "description": "Response policies created just now" } }, "tags": [ "PolicyV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/policies/{name}": { "delete": { "description": "Delete Policy", "operationId": "deletePolicy", "parameters": [ { "description": "Name of policy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response policy deleted just now" } }, "tags": [ "PolicyV1alpha1" ] }, "get": { "description": "Get Policy", "operationId": "getPolicy", "parameters": [ { "description": "Name of policy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Policy" } } }, "description": "Response single policy" } }, "tags": [ "PolicyV1alpha1" ] }, "patch": { "description": "Patch Policy", "operationId": "patchPolicy", "parameters": [ { "description": "Name of policy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Policy" } } }, "description": "Response policy patched just now" } }, "tags": [ "PolicyV1alpha1" ] }, "put": { "description": "Update Policy", "operationId": "updatePolicy", "parameters": [ { "description": "Name of policy", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Policy" } } }, "description": "Updated policy" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Policy" } } }, "description": "Response policies updated just now" } }, "tags": [ "PolicyV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/policytemplates": { "get": { "description": "List PolicyTemplate", "operationId": "listPolicyTemplate", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyTemplateList" } } }, "description": "Response policytemplates" } }, "tags": [ "PolicyTemplateV1alpha1" ] }, "post": { "description": "Create PolicyTemplate", "operationId": "createPolicyTemplate", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyTemplate" } } }, "description": "Fresh policytemplate" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyTemplate" } } }, "description": "Response policytemplates created just now" } }, "tags": [ "PolicyTemplateV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/policytemplates/{name}": { "delete": { "description": "Delete PolicyTemplate", "operationId": "deletePolicyTemplate", "parameters": [ { "description": "Name of policytemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response policytemplate deleted just now" } }, "tags": [ "PolicyTemplateV1alpha1" ] }, "get": { "description": "Get PolicyTemplate", "operationId": "getPolicyTemplate", "parameters": [ { "description": "Name of policytemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyTemplate" } } }, "description": "Response single policytemplate" } }, "tags": [ "PolicyTemplateV1alpha1" ] }, "patch": { "description": "Patch PolicyTemplate", "operationId": "patchPolicyTemplate", "parameters": [ { "description": "Name of policytemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyTemplate" } } }, "description": "Response policytemplate patched just now" } }, "tags": [ "PolicyTemplateV1alpha1" ] }, "put": { "description": "Update PolicyTemplate", "operationId": "updatePolicyTemplate", "parameters": [ { "description": "Name of policytemplate", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyTemplate" } } }, "description": "Updated policytemplate" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PolicyTemplate" } } }, "description": "Response policytemplates updated just now" } }, "tags": [ "PolicyTemplateV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/thumbnails": { "get": { "description": "List Thumbnail", "operationId": "listThumbnail", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ThumbnailList" } } }, "description": "Response thumbnails" } }, "tags": [ "ThumbnailV1alpha1" ] }, "post": { "description": "Create Thumbnail", "operationId": "createThumbnail", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Thumbnail" } } }, "description": "Fresh thumbnail" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Thumbnail" } } }, "description": "Response thumbnails created just now" } }, "tags": [ "ThumbnailV1alpha1" ] } }, "/apis/storage.halo.run/v1alpha1/thumbnails/{name}": { "delete": { "description": "Delete Thumbnail", "operationId": "deleteThumbnail", "parameters": [ { "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response thumbnail deleted just now" } }, "tags": [ "ThumbnailV1alpha1" ] }, "get": { "description": "Get Thumbnail", "operationId": "getThumbnail", "parameters": [ { "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Thumbnail" } } }, "description": "Response single thumbnail" } }, "tags": [ "ThumbnailV1alpha1" ] }, "patch": { "description": "Patch Thumbnail", "operationId": "patchThumbnail", "parameters": [ { "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Thumbnail" } } }, "description": "Response thumbnail patched just now" } }, "tags": [ "ThumbnailV1alpha1" ] }, "put": { "description": "Update Thumbnail", "operationId": "updateThumbnail", "parameters": [ { "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Thumbnail" } } }, "description": "Updated thumbnail" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Thumbnail" } } }, "description": "Response thumbnails updated just now" } }, "tags": [ "ThumbnailV1alpha1" ] } }, "/apis/theme.halo.run/v1alpha1/themes": { "get": { "description": "List Theme", "operationId": "listTheme", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ThemeList" } } }, "description": "Response themes" } }, "tags": [ "ThemeV1alpha1" ] }, "post": { "description": "Create Theme", "operationId": "createTheme", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "Fresh theme" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "Response themes created just now" } }, "tags": [ "ThemeV1alpha1" ] } }, "/apis/theme.halo.run/v1alpha1/themes/{name}": { "delete": { "description": "Delete Theme", "operationId": "deleteTheme", "parameters": [ { "description": "Name of theme", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Response theme deleted just now" } }, "tags": [ "ThemeV1alpha1" ] }, "get": { "description": "Get Theme", "operationId": "getTheme", "parameters": [ { "description": "Name of theme", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "Response single theme" } }, "tags": [ "ThemeV1alpha1" ] }, "patch": { "description": "Patch Theme", "operationId": "patchTheme", "parameters": [ { "description": "Name of theme", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json-patch+json": { "schema": { "$ref": "#/components/schemas/JsonPatch" } } } }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "Response theme patched just now" } }, "tags": [ "ThemeV1alpha1" ] }, "put": { "description": "Update Theme", "operationId": "updateTheme", "parameters": [ { "description": "Name of theme", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "Updated theme" }, "responses": { "200": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Theme" } } }, "description": "Response themes updated just now" } }, "tags": [ "ThemeV1alpha1" ] } } }, "components": { "schemas": { "AddOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "add" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "AnnotationSetting": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/AnnotationSettingSpec" } } }, "AnnotationSettingList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/AnnotationSetting" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "AnnotationSettingSpec": { "required": [ "formSchema", "targetRef" ], "type": "object", "properties": { "formSchema": { "minLength": 1, "type": "array", "items": { "minLength": 1, "type": "object" } }, "targetRef": { "$ref": "#/components/schemas/GroupKind" } } }, "Attachment": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/AttachmentSpec" }, "status": { "$ref": "#/components/schemas/AttachmentStatus" } } }, "AttachmentList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Attachment" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "AttachmentSpec": { "type": "object", "properties": { "displayName": { "type": "string", "description": "Display name of attachment" }, "groupName": { "type": "string", "description": "Group name" }, "mediaType": { "type": "string", "description": "Media type of attachment" }, "ownerName": { "type": "string", "description": "Name of User who uploads the attachment" }, "policyName": { "type": "string", "description": "Policy name" }, "size": { "minimum": 0, "type": "integer", "description": "Size of attachment. Unit is Byte", "format": "int64" }, "tags": { "uniqueItems": true, "type": "array", "description": "Tags of attachment", "items": { "type": "string", "description": "Tag name" } } } }, "AttachmentStatus": { "type": "object", "properties": { "permalink": { "type": "string", "description": "Permalink of attachment.\nIf it is in local storage, the public URL will be set.\nIf it is in s3 storage, the Object URL will be set.\n" }, "thumbnails": { "type": "object", "additionalProperties": { "type": "string" } } } }, "AuthProvider": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/AuthProviderSpec" } }, "description": "Auth provider extension." }, "AuthProviderList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/AuthProvider" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "AuthProviderSpec": { "required": [ "authType", "authenticationUrl", "displayName" ], "type": "object", "properties": { "authType": { "type": "string", "description": "Auth type: form or oauth2.", "enum": [ "FORM", "OAUTH2" ] }, "authenticationUrl": { "type": "string", "description": "Authentication url of the auth provider" }, "bindingUrl": { "type": "string" }, "configMapRef": { "$ref": "#/components/schemas/ConfigMapRef" }, "description": { "type": "string" }, "displayName": { "type": "string", "description": "Display name of the auth provider" }, "helpPage": { "type": "string" }, "logo": { "type": "string" }, "method": { "type": "string" }, "rememberMeSupport": { "type": "boolean" }, "settingRef": { "$ref": "#/components/schemas/SettingRef" }, "unbindUrl": { "type": "string" }, "website": { "type": "string" } } }, "Author": { "required": [ "name" ], "type": "object", "properties": { "name": { "minLength": 1, "type": "string" }, "website": { "type": "string" } } }, "Backup": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/BackupSpec" }, "status": { "$ref": "#/components/schemas/BackupStatus" } } }, "BackupList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Backup" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "BackupSpec": { "type": "object", "properties": { "expiresAt": { "type": "string", "format": "date-time" }, "format": { "type": "string", "description": "Backup file format. Currently, only zip format is supported." } } }, "BackupStatus": { "type": "object", "properties": { "completionTimestamp": { "type": "string", "format": "date-time" }, "failureMessage": { "type": "string" }, "failureReason": { "type": "string" }, "filename": { "type": "string", "description": "Name of backup file." }, "phase": { "type": "string", "enum": [ "PENDING", "RUNNING", "SUCCEEDED", "FAILED" ] }, "size": { "type": "integer", "description": "Size of backup file. Data unit: byte", "format": "int64" }, "startTimestamp": { "type": "string", "format": "date-time" } } }, "Category": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/CategorySpec" }, "status": { "$ref": "#/components/schemas/CategoryStatus" } } }, "CategoryList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Category" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "CategorySpec": { "required": [ "displayName", "priority", "slug" ], "type": "object", "properties": { "children": { "type": "array", "items": { "type": "string" } }, "cover": { "type": "string" }, "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "hideFromList": { "type": "boolean", "description": "\u003cp\u003eWhether to hide the category from the category list.\u003c/p\u003e\n \u003cp\u003eWhen set to true, the category including its subcategories and related posts will\n not be displayed in the category list, but it can still be accessed by permalink.\u003c/p\u003e\n \u003cp\u003eLimitation: It only takes effect on the theme-side categorized list and it only\n allows to be set to true on the first level(root node) of categories.\u003c/p\u003e" }, "postTemplate": { "maxLength": 255, "type": "string", "description": "\u003cp\u003eUsed to specify the template for the posts associated with the category.\u003c/p\u003e\n \u003cp\u003eThe priority is not as high as that of the post.\u003c/p\u003e\n \u003cp\u003eIf the post also specifies a template, the post\u0027s template will prevail.\u003c/p\u003e" }, "preventParentPostCascadeQuery": { "type": "boolean", "description": "\u003cp\u003eif a category is queried for related posts, the default behavior is to\n query all posts under the category including its subcategories, but if this field is\n set to true, cascade query behavior will be terminated here.\u003c/p\u003e\n \u003cp\u003eFor example, if a category has subcategories A and B, and A has subcategories C and\n D and C marked this field as true, when querying posts under A category,all posts under A\n and B will be queried, but C and D will not be queried.\u003c/p\u003e" }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "slug": { "minLength": 1, "type": "string" }, "template": { "maxLength": 255, "type": "string" } } }, "CategoryStatus": { "type": "object", "properties": { "permalink": { "type": "string" }, "postCount": { "type": "integer", "description": "包括当前和其下所有层级的文章数量 (depth\u003dmax).", "format": "int32" }, "visiblePostCount": { "type": "integer", "description": "包括当前和其下所有层级的已发布且公开的文章数量 (depth\u003dmax).", "format": "int32" } } }, "Comment": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/CommentSpec" }, "status": { "$ref": "#/components/schemas/CommentStatus" } } }, "CommentList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Comment" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "CommentOwner": { "required": [ "kind", "name" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" } }, "displayName": { "type": "string" }, "kind": { "minLength": 1, "type": "string" }, "name": { "maxLength": 64, "type": "string" } } }, "CommentSpec": { "required": [ "allowNotification", "approved", "content", "hidden", "owner", "priority", "raw", "subjectRef", "top" ], "type": "object", "properties": { "allowNotification": { "type": "boolean", "default": true }, "approved": { "type": "boolean", "default": false }, "approvedTime": { "type": "string", "format": "date-time" }, "content": { "minLength": 1, "type": "string" }, "creationTime": { "type": "string", "description": "The user-defined creation time default is \u003ccode\u003emetadata.creationTimestamp\u003c/code\u003e.", "format": "date-time" }, "hidden": { "type": "boolean", "default": false }, "ipAddress": { "type": "string" }, "lastReadTime": { "type": "string", "format": "date-time" }, "owner": { "$ref": "#/components/schemas/CommentOwner" }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "raw": { "minLength": 1, "type": "string" }, "subjectRef": { "$ref": "#/components/schemas/Ref" }, "top": { "type": "boolean", "default": false }, "userAgent": { "type": "string" } } }, "CommentStatus": { "type": "object", "properties": { "hasNewReply": { "type": "boolean" }, "lastReplyTime": { "type": "string", "format": "date-time" }, "observedVersion": { "type": "integer", "format": "int64" }, "replyCount": { "type": "integer", "format": "int32" }, "unreadReplyCount": { "type": "integer", "format": "int32" }, "visibleReplyCount": { "type": "integer", "format": "int32" } } }, "Condition": { "required": [ "lastTransitionTime", "status", "type" ], "type": "object", "properties": { "lastTransitionTime": { "type": "string", "description": "Last time the condition transitioned from one status to another.", "format": "date-time" }, "message": { "maxLength": 32768, "type": "string", "description": "Human-readable message indicating details about last transition.\n This may be an empty string." }, "reason": { "maxLength": 1024, "pattern": "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$", "type": "string", "description": "Unique, one-word, CamelCase reason for the condition\u0027s last transition." }, "status": { "type": "string", "description": "Status is the status of the condition. Can be True, False, Unknown.", "enum": [ "TRUE", "FALSE", "UNKNOWN" ] }, "type": { "maxLength": 316, "pattern": "^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$", "type": "string", "description": "type of condition in CamelCase or in foo.example.com/CamelCase.\n example: Ready, Initialized.\n maxLength: 316." } }, "description": "EqualsAndHashCode 排除了lastTransitionTime否则失败时,lastTransitionTime 会被更新\n 导致 equals 为 false,一直被加入队列." }, "ConfigMap": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "data": { "type": "object", "additionalProperties": { "type": "string" } }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" } }, "description": "\u003cp\u003eConfigMap holds configuration data to consume.\u003c/p\u003e" }, "ConfigMapList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ConfigMap" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ConfigMapRef": { "required": [ "name" ], "type": "object", "properties": { "name": { "minLength": 1, "type": "string" } } }, "CopyOperation": { "required": [ "op", "from", "path" ], "type": "object", "properties": { "from": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "op": { "type": "string", "enum": [ "copy" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "Counter": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "approvedComment": { "type": "integer", "format": "int32" }, "downvote": { "type": "integer", "format": "int32" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "totalComment": { "type": "integer", "format": "int32" }, "upvote": { "type": "integer", "format": "int32" }, "visit": { "type": "integer", "format": "int32" } }, "description": "A counter for number of requests by extension resource name." }, "CounterList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Counter" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "CustomTemplates": { "type": "object", "properties": { "category": { "type": "array", "items": { "$ref": "#/components/schemas/TemplateDescriptor" } }, "page": { "type": "array", "items": { "$ref": "#/components/schemas/TemplateDescriptor" } }, "post": { "type": "array", "items": { "$ref": "#/components/schemas/TemplateDescriptor" } } } }, "Device": { "required": [ "apiVersion", "kind", "metadata", "spec", "status" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/DeviceSpec" }, "status": { "$ref": "#/components/schemas/DeviceStatus" } } }, "DeviceList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Device" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "DeviceSpec": { "required": [ "ipAddress", "principalName", "sessionId" ], "type": "object", "properties": { "ipAddress": { "maxLength": 129, "type": "string" }, "lastAccessedTime": { "type": "string", "format": "date-time" }, "lastAuthenticatedTime": { "type": "string", "format": "date-time" }, "principalName": { "minLength": 1, "type": "string" }, "rememberMeSeriesId": { "type": "string" }, "sessionId": { "minLength": 1, "type": "string" }, "userAgent": { "maxLength": 500, "type": "string" } } }, "DeviceStatus": { "type": "object", "properties": { "browser": { "type": "string" }, "os": { "type": "string" } } }, "Excerpt": { "required": [ "autoGenerate" ], "type": "object", "properties": { "autoGenerate": { "type": "boolean", "default": true }, "raw": { "type": "string" } } }, "ExtensionDefinition": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ExtensionSpec" } }, "description": "Extension definition.\n An {@link ExtensionDefinition ExtensionDefinition} is a type of metadata that provides additional information about\n an extension. An extension is a way to add new functionality to an existing class, structure,\n enumeration, or protocol type without needing to subclass it." }, "ExtensionDefinitionList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ExtensionDefinition" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ExtensionPointDefinition": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ExtensionPointSpec" } }, "description": "Extension point definition.\n An {@link ExtensionPointDefinition ExtensionPointDefinition} is a concept used in \u003ccode\u003eHalo\u003c/code\u003e to allow for the\n dynamic extension of system. It defines a location within \u003ccode\u003eHalo\u003c/code\u003e where\n additional functionality can be added through the use of plugins or extensions." }, "ExtensionPointDefinitionList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ExtensionPointDefinition" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ExtensionPointSpec": { "required": [ "className", "displayName", "type" ], "type": "object", "properties": { "className": { "type": "string" }, "description": { "type": "string" }, "displayName": { "type": "string" }, "icon": { "type": "string" }, "type": { "type": "string", "enum": [ "SINGLETON", "MULTI_INSTANCE" ] } } }, "ExtensionSpec": { "required": [ "className", "displayName", "extensionPointName" ], "type": "object", "properties": { "className": { "type": "string" }, "description": { "type": "string" }, "displayName": { "type": "string" }, "extensionPointName": { "type": "string" }, "icon": { "type": "string" } } }, "FileReverseProxyProvider": { "type": "object", "properties": { "directory": { "type": "string" }, "filename": { "type": "string" } } }, "Group": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/GroupSpec" }, "status": { "$ref": "#/components/schemas/GroupStatus" } } }, "GroupKind": { "type": "object", "properties": { "group": { "type": "string", "description": "is group name of Extension." }, "kind": { "type": "string", "description": "is kind name of Extension." } }, "description": "GroupKind contains group and kind data only." }, "GroupList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Group" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "GroupSpec": { "required": [ "displayName" ], "type": "object", "properties": { "displayName": { "type": "string", "description": "Display name of group" } } }, "GroupStatus": { "type": "object", "properties": { "totalAttachments": { "minimum": 0, "type": "integer", "description": "Total of attachments under the current group", "format": "int64" }, "updateTimestamp": { "type": "string", "description": "Update timestamp of the group", "format": "date-time" } } }, "InterestReason": { "required": [ "reasonType", "subject" ], "type": "object", "properties": { "expression": { "type": "string", "description": "The expression to be interested in" }, "reasonType": { "type": "string", "description": "The name of the reason definition to be interested in" }, "subject": { "$ref": "#/components/schemas/InterestReasonSubject" } }, "description": "The reason to be interested in" }, "InterestReasonSubject": { "required": [ "apiVersion", "kind" ], "type": "object", "properties": { "apiVersion": { "minLength": 1, "type": "string" }, "kind": { "minLength": 1, "type": "string" }, "name": { "type": "string", "description": "if name is not specified, it presents all subjects of the specified reason type and custom resources" } }, "description": "The subject name of reason type to be interested in" }, "JsonPatch": { "minItems": 1, "uniqueItems": true, "type": "array", "description": "JSON schema for JSONPatch operations", "items": { "oneOf": [ { "$ref": "#/components/schemas/AddOperation" }, { "$ref": "#/components/schemas/ReplaceOperation" }, { "$ref": "#/components/schemas/TestOperation" }, { "$ref": "#/components/schemas/RemoveOperation" }, { "$ref": "#/components/schemas/MoveOperation" }, { "$ref": "#/components/schemas/CopyOperation" } ] } }, "License": { "type": "object", "properties": { "name": { "type": "string" }, "url": { "type": "string" } }, "description": "Common data objects for license." }, "LocalThumbnail": { "required": [ "apiVersion", "kind", "metadata", "spec", "status" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/LocalThumbnailSpec" }, "status": { "$ref": "#/components/schemas/LocalThumbnailStatus" } } }, "LocalThumbnailList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/LocalThumbnail" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "LocalThumbnailSpec": { "required": [ "filePath", "imageSignature", "imageUri", "size", "thumbSignature", "thumbnailUri" ], "type": "object", "properties": { "filePath": { "type": "string", "description": "Consider the compatibility of the system and migration, use unix-style relative paths\n here." }, "imageSignature": { "minLength": 1, "type": "string", "description": "A hash signature for the image uri." }, "imageUri": { "minLength": 1, "type": "string" }, "size": { "type": "string", "enum": [ "S", "M", "L", "XL" ] }, "thumbSignature": { "minLength": 1, "type": "string", "description": "A hash signature for the thumbnail uri." }, "thumbnailUri": { "minLength": 1, "type": "string" } } }, "LocalThumbnailStatus": { "type": "object", "properties": { "phase": { "type": "string", "enum": [ "PENDING", "SUCCEEDED", "FAILED" ] } } }, "Menu": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/MenuSpec" } } }, "MenuItem": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/MenuItemSpec" }, "status": { "$ref": "#/components/schemas/MenuItemStatus" } } }, "MenuItemList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/MenuItem" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "MenuItemSpec": { "type": "object", "properties": { "children": { "uniqueItems": true, "type": "array", "description": "Children of this menu item", "items": { "type": "string", "description": "The name of menu item child" } }, "displayName": { "type": "string", "description": "The display name of menu item." }, "href": { "type": "string", "description": "The href of this menu item." }, "priority": { "type": "integer", "description": "The priority is for ordering.", "format": "int32" }, "target": { "type": "string", "description": "The \u003ca\u003e target attribute of this menu item.", "enum": [ "_blank", "_self", "_parent", "_top" ] }, "targetRef": { "$ref": "#/components/schemas/Ref" } }, "description": "The spec of menu item." }, "MenuItemStatus": { "type": "object", "properties": { "displayName": { "type": "string", "description": "Calculated Display name of menu item." }, "href": { "type": "string", "description": "Calculated href of manu item." } }, "description": "The status of menu item." }, "MenuList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Menu" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "MenuSpec": { "required": [ "displayName" ], "type": "object", "properties": { "displayName": { "type": "string", "description": "The display name of the menu." }, "menuItems": { "uniqueItems": true, "type": "array", "description": "Menu items of this menu.", "items": { "type": "string", "description": "Name of menu item." } } }, "description": "The spec of menu." }, "Metadata": { "required": [ "name" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Annotations are like key-value format." }, "creationTimestamp": { "type": "string", "description": "Creation timestamp of the Extension.", "format": "date-time", "nullable": true }, "deletionTimestamp": { "type": "string", "description": "Deletion timestamp of the Extension.", "format": "date-time", "nullable": true }, "finalizers": { "uniqueItems": true, "type": "array", "nullable": true, "items": { "type": "string", "nullable": true } }, "generateName": { "type": "string", "description": "The name field will be generated automatically according to the given generateName field" }, "labels": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Labels are like key-value format." }, "name": { "type": "string", "description": "Metadata name" }, "version": { "type": "integer", "description": "Current version of the Extension. It will be bumped up every update.", "format": "int64", "nullable": true } }, "description": "Metadata of Extension." }, "MoveOperation": { "required": [ "op", "from", "path" ], "type": "object", "properties": { "from": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "op": { "type": "string", "enum": [ "move" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "Notification": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/NotificationSpec" } }, "description": "\u003cp\u003e{@link Notification Notification} is a custom extension that used to store notification information for\n inner use, it\u0027s on-site notification.\u003c/p\u003e\n\n \u003cp\u003eSupports the following operations:\u003c/p\u003e\n \u003cul\u003e\n \u003cli\u003eMarked as read: {@link NotificationSpec#setUnread(boolean) NotificationSpec#setUnread(boolean)}\u003c/li\u003e\n \u003cli\u003eGet the last read time: {@link NotificationSpec#getLastReadAt NotificationSpec#getLastReadAt()}\u003c/li\u003e\n \u003cli\u003eFilter by recipient: {@link NotificationSpec#getRecipient NotificationSpec#getRecipient()}\u003c/li\u003e\n \u003c/ul\u003e" }, "NotificationList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Notification" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "NotificationSpec": { "required": [ "htmlContent", "rawContent", "reason", "recipient", "title" ], "type": "object", "properties": { "htmlContent": { "type": "string" }, "lastReadAt": { "type": "string", "format": "date-time" }, "rawContent": { "type": "string" }, "reason": { "minLength": 1, "type": "string", "description": "The name of reason" }, "recipient": { "minLength": 1, "type": "string", "description": "The name of user" }, "title": { "minLength": 1, "type": "string" }, "unread": { "type": "boolean" } } }, "NotificationTemplate": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/NotificationTemplateSpec" } }, "description": "\u003cp\u003e{@link NotificationTemplate NotificationTemplate} is a custom extension that defines a notification template.\u003c/p\u003e\n \u003cp\u003eIt describes the notification template\u0027s name, description, and the template content.\u003c/p\u003e\n \u003cp\u003e{@link Spec#getReasonSelector Spec#getReasonSelector()} is used to select the template by reasonType and language,\n if multiple templates are matched, the best match will be selected. This is useful when you\n want to override the default template.\u003c/p\u003e" }, "NotificationTemplateList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/NotificationTemplate" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "NotificationTemplateSpec": { "type": "object", "properties": { "reasonSelector": { "$ref": "#/components/schemas/ReasonSelector" }, "template": { "$ref": "#/components/schemas/TemplateContent" } } }, "NotifierDescriptor": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/NotifierDescriptorSpec" } }, "description": "\u003cp\u003e{@link NotifierDescriptor NotifierDescriptor} is a custom extension that defines a notifier.\u003c/p\u003e\n \u003cp\u003eIt describes the notifier\u0027s name, description, and the extension name of the notifier to\n let the user know what the notifier is and what it can do in the UI and also let the\n \u003ccode\u003eNotificationCenter\u003c/code\u003e know how to load the notifier and prepare the notifier\u0027s settings.\u003c/p\u003e" }, "NotifierDescriptorList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/NotifierDescriptor" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "NotifierDescriptorSpec": { "required": [ "displayName", "notifierExtName" ], "type": "object", "properties": { "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "notifierExtName": { "minLength": 1, "type": "string" }, "receiverSettingRef": { "$ref": "#/components/schemas/NotifierSettingRef" }, "senderSettingRef": { "$ref": "#/components/schemas/NotifierSettingRef" } } }, "NotifierSettingRef": { "required": [ "group", "name" ], "type": "object", "properties": { "group": { "type": "string" }, "name": { "type": "string" } } }, "PatSpec": { "required": [ "name", "tokenId", "username" ], "type": "object", "properties": { "description": { "type": "string" }, "expiresAt": { "type": "string", "format": "date-time" }, "lastUsed": { "type": "string", "format": "date-time" }, "name": { "type": "string" }, "revoked": { "type": "boolean" }, "revokesAt": { "type": "string", "format": "date-time" }, "roles": { "type": "array", "items": { "type": "string" } }, "scopes": { "type": "array", "items": { "type": "string" } }, "tokenId": { "type": "string" }, "username": { "type": "string" } } }, "PersonalAccessToken": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/PatSpec" } } }, "PersonalAccessTokenList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/PersonalAccessToken" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "Plugin": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/PluginSpec" }, "status": { "$ref": "#/components/schemas/PluginStatus" } }, "description": "A custom resource for Plugin." }, "PluginAuthor": { "required": [ "name" ], "type": "object", "properties": { "name": { "minLength": 1, "type": "string" }, "website": { "type": "string" } } }, "PluginList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Plugin" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "PluginSpec": { "required": [ "version" ], "type": "object", "properties": { "author": { "$ref": "#/components/schemas/PluginAuthor" }, "configMapName": { "type": "string" }, "description": { "type": "string" }, "displayName": { "type": "string" }, "enabled": { "type": "boolean" }, "homepage": { "type": "string" }, "issues": { "type": "string" }, "license": { "type": "array", "items": { "$ref": "#/components/schemas/License" } }, "logo": { "type": "string" }, "pluginDependencies": { "type": "object", "additionalProperties": { "type": "string" } }, "repo": { "type": "string" }, "requires": { "type": "string", "description": "SemVer format." }, "settingName": { "type": "string" }, "version": { "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", "type": "string", "description": "plugin version." } } }, "PluginStatus": { "type": "object", "properties": { "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "entry": { "type": "string" }, "lastProbeState": { "type": "string", "enum": [ "CREATED", "DISABLED", "RESOLVED", "STARTED", "STOPPED", "FAILED", "UNLOADED" ] }, "lastStartTime": { "type": "string", "format": "date-time" }, "loadLocation": { "type": "string", "description": "Load location of the plugin, often a path.", "format": "uri" }, "logo": { "type": "string" }, "phase": { "type": "string", "enum": [ "PENDING", "STARTING", "CREATED", "DISABLING", "DISABLED", "RESOLVED", "STARTED", "STOPPED", "FAILED", "UNKNOWN" ] }, "stylesheet": { "type": "string" } } }, "Policy": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/PolicySpec" } } }, "PolicyList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Policy" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "PolicyRule": { "type": "object", "properties": { "apiGroups": { "type": "array", "description": "APIGroups is the name of the APIGroup that contains the resources.\n If multiple API groups are specified, any action requested against one of the enumerated\n resources in any API group will be allowed.", "items": { "type": "string" } }, "nonResourceURLs": { "type": "array", "description": "NonResourceURLs is a set of partial urls that a user should have access to.\n *s are allowed, but only as the full, final step in the path\n If an action is not a resource API request, then the URL is split on \u0027/\u0027 and is checked\n against the NonResourceURLs to look for a match.\n Since non-resource URLs are not namespaced, this field is only applicable for\n ClusterRoles referenced from a ClusterRoleBinding.\n Rules can either apply to API resources (such as \"pods\" or \"secrets\") or non-resource\n URL paths (such as \"/api\"), but not both.", "items": { "type": "string" } }, "resourceNames": { "type": "array", "description": "ResourceNames is an optional white list of names that the rule applies to. An empty set\n means that everything is allowed.", "items": { "type": "string" } }, "resources": { "type": "array", "description": "Resources is a list of resources this rule applies to. \u0027*\u0027 represents all resources in\n the specified apiGroups.\n \u0027*\u0026#47;foo\u0027 represents the subresource \u0027foo\u0027 for all resources in the specified\n apiGroups.", "items": { "type": "string" } }, "verbs": { "type": "array", "description": "about who the rule applies to or which namespace the rule applies to.", "items": { "type": "string" } } }, "description": "PolicyRule holds information that describes a policy rule, but does not contain information\n about whom the rule applies to or which namespace the rule applies to." }, "PolicySpec": { "required": [ "displayName", "templateName" ], "type": "object", "properties": { "configMapName": { "type": "string", "description": "Reference name of ConfigMap extension" }, "displayName": { "type": "string", "description": "Display name of policy" }, "templateName": { "type": "string", "description": "Reference name of PolicyTemplate" } } }, "PolicyTemplate": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/PolicyTemplateSpec" } } }, "PolicyTemplateList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/PolicyTemplate" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "PolicyTemplateSpec": { "required": [ "settingName" ], "type": "object", "properties": { "displayName": { "type": "string" }, "settingName": { "type": "string" } } }, "Post": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/PostSpec" }, "status": { "$ref": "#/components/schemas/PostStatus" } }, "description": "\u003cp\u003ePost extension.\u003c/p\u003e" }, "PostList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Post" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "PostSpec": { "required": [ "allowComment", "deleted", "excerpt", "pinned", "priority", "publish", "slug", "title", "visible" ], "type": "object", "properties": { "allowComment": { "type": "boolean", "default": true }, "baseSnapshot": { "type": "string" }, "categories": { "type": "array", "items": { "type": "string" } }, "cover": { "type": "string" }, "deleted": { "type": "boolean", "default": false }, "excerpt": { "$ref": "#/components/schemas/Excerpt" }, "headSnapshot": { "type": "string" }, "htmlMetas": { "type": "array", "items": { "type": "object", "additionalProperties": { "type": "string" } } }, "owner": { "type": "string" }, "pinned": { "type": "boolean", "default": false }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "publish": { "type": "boolean", "default": false }, "publishTime": { "type": "string", "format": "date-time" }, "releaseSnapshot": { "type": "string", "description": "文章引用到的已发布的内容,用于主题端显示." }, "slug": { "minLength": 1, "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } }, "template": { "type": "string" }, "title": { "minLength": 1, "type": "string" }, "visible": { "type": "string", "enum": [ "PUBLIC", "INTERNAL", "PRIVATE" ], "default": "PUBLIC" } } }, "PostStatus": { "type": "object", "properties": { "commentsCount": { "type": "integer", "format": "int32" }, "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "contributors": { "type": "array", "items": { "type": "string" } }, "excerpt": { "type": "string" }, "hideFromList": { "type": "boolean", "description": "see {@link Category.CategorySpec#isHideFromList Category.CategorySpec#isHideFromList()}." }, "inProgress": { "type": "boolean" }, "lastModifyTime": { "type": "string", "format": "date-time" }, "observedVersion": { "type": "integer", "format": "int64" }, "permalink": { "type": "string" }, "phase": { "type": "string" } } }, "Reason": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ReasonSpec" } }, "description": "\u003cp\u003e{@link Reason Reason} is a custom extension that defines a reason for a notification, It represents\n an instance of a {@link ReasonType ReasonType}.\u003c/p\u003e\n \u003cp\u003eIt can be understood as an event that triggers a notification.\u003c/p\u003e" }, "ReasonAttributes": { "type": "object", "properties": { "empty": { "type": "boolean" } }, "description": "Attributes used to transfer data" }, "ReasonList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Reason" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ReasonProperty": { "required": [ "name", "type" ], "type": "object", "properties": { "description": { "type": "string" }, "name": { "minLength": 1, "type": "string" }, "optional": { "type": "boolean", "default": false }, "type": { "minLength": 1, "type": "string" } } }, "ReasonSelector": { "required": [ "language", "reasonType" ], "type": "object", "properties": { "language": { "minLength": 1, "type": "string", "default": "default" }, "reasonType": { "minLength": 1, "type": "string" } } }, "ReasonSpec": { "required": [ "author", "reasonType", "subject" ], "type": "object", "properties": { "attributes": { "$ref": "#/components/schemas/ReasonAttributes" }, "author": { "type": "string" }, "reasonType": { "type": "string" }, "subject": { "$ref": "#/components/schemas/ReasonSubject" } } }, "ReasonSubject": { "required": [ "apiVersion", "kind", "name", "title" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "name": { "type": "string" }, "title": { "type": "string" }, "url": { "type": "string" } } }, "ReasonType": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ReasonTypeSpec" } }, "description": "\u003cp\u003e{@link ReasonType ReasonType} is a custom extension that defines a type of reason.\u003c/p\u003e\n \u003cp\u003eOne {@link ReasonType ReasonType} can have multiple {@link Reason Reason}s to notify.\u003c/p\u003e" }, "ReasonTypeList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ReasonType" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ReasonTypeSpec": { "required": [ "description", "displayName" ], "type": "object", "properties": { "description": { "minLength": 1, "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "properties": { "type": "array", "items": { "$ref": "#/components/schemas/ReasonProperty" } } } }, "Ref": { "required": [ "group", "kind", "name" ], "type": "object", "properties": { "group": { "type": "string", "description": "Extension group" }, "kind": { "type": "string", "description": "Extension kind" }, "name": { "type": "string", "description": "Extension name. This field is mandatory" }, "version": { "type": "string", "description": "Extension version" } }, "description": "Extension reference object. The name is mandatory" }, "RememberMeToken": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/RememberMeTokenSpec" } } }, "RememberMeTokenList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/RememberMeToken" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "RememberMeTokenSpec": { "required": [ "series", "tokenValue", "username" ], "type": "object", "properties": { "lastUsed": { "type": "string", "format": "date-time" }, "series": { "minLength": 1, "type": "string" }, "tokenValue": { "minLength": 1, "type": "string" }, "username": { "minLength": 1, "type": "string" } } }, "RemoveOperation": { "required": [ "op", "path" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "remove" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "ReplaceOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "replace" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "Reply": { "required": [ "apiVersion", "kind", "metadata", "spec", "status" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ReplySpec" }, "status": { "$ref": "#/components/schemas/ReplyStatus" } } }, "ReplyList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Reply" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ReplySpec": { "required": [ "allowNotification", "approved", "commentName", "content", "hidden", "owner", "priority", "raw", "top" ], "type": "object", "properties": { "allowNotification": { "type": "boolean", "default": true }, "approved": { "type": "boolean", "default": false }, "approvedTime": { "type": "string", "format": "date-time" }, "commentName": { "minLength": 1, "type": "string" }, "content": { "minLength": 1, "type": "string" }, "creationTime": { "type": "string", "description": "The user-defined creation time default is \u003ccode\u003emetadata.creationTimestamp\u003c/code\u003e.", "format": "date-time" }, "hidden": { "type": "boolean", "default": false }, "ipAddress": { "type": "string" }, "owner": { "$ref": "#/components/schemas/CommentOwner" }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "quoteReply": { "type": "string" }, "raw": { "minLength": 1, "type": "string" }, "top": { "type": "boolean", "default": false }, "userAgent": { "type": "string" } } }, "ReplyStatus": { "type": "object", "properties": { "observedVersion": { "type": "integer", "format": "int64" } } }, "ReverseProxy": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "rules": { "type": "array", "items": { "$ref": "#/components/schemas/ReverseProxyRule" } } }, "description": "\u003cp\u003eThe reverse proxy custom resource is used to configure a path to proxy it to a directory or\n file.\u003c/p\u003e\n \u003cp\u003eHTTP proxy may be added in the future.\u003c/p\u003e" }, "ReverseProxyList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ReverseProxy" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ReverseProxyRule": { "type": "object", "properties": { "file": { "$ref": "#/components/schemas/FileReverseProxyProvider" }, "path": { "type": "string" } } }, "Role": { "required": [ "apiVersion", "kind", "metadata", "rules" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "rules": { "type": "array", "items": { "$ref": "#/components/schemas/PolicyRule" } } } }, "RoleBinding": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "roleRef": { "$ref": "#/components/schemas/RoleRef" }, "subjects": { "type": "array", "description": "Subjects holds references to the objects the role applies to.", "items": { "$ref": "#/components/schemas/Subject" } } }, "description": "RoleBinding references a role, but does not contain it.\n It can reference a Role in the global.\n It adds who information via Subjects." }, "RoleBindingList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/RoleBinding" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "RoleList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Role" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "RoleRef": { "type": "object", "properties": { "apiGroup": { "type": "string", "description": "APIGroup is the group for the resource being referenced." }, "kind": { "type": "string", "description": "Kind is the type of resource being referenced." }, "name": { "type": "string", "description": "Name is the name of resource being referenced." } }, "description": "RoleRef contains information that points to the role being used." }, "Secret": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "data": { "type": "object", "additionalProperties": { "type": "string", "format": "byte" }, "description": "\u003cp\u003eThe total bytes of the values in\n the Data field must be less than {@link run.halo.app.extension.Secret#MAX_SECRET_SIZE #MAX_SECRET_SIZE} bytes.\u003c/p\u003e\n \u003cp\u003e\u003ccode\u003edata\u003c/code\u003e contains the secret data. Each key must consist of alphanumeric\n characters, \u0027-\u0027, \u0027_\u0027 or \u0027.\u0027. The serialized form of the secret data is a\n base64 encoded string, representing the arbitrary (possibly non-string)\n data value here. Described in\n \u003ca href\u003d\"https://tools.ietf.org/html/rfc4648#section-4\"\u003erfc4648#section-4\u003c/a\u003e\n \u003c/p\u003e" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "stringData": { "type": "object", "additionalProperties": { "type": "string" }, "description": "\u003ccode\u003estringData\u003c/code\u003e allows specifying non-binary secret data in string form.\n It is provided as a write-only input field for convenience.\n All keys and values are merged into the data field on write, overwriting any existing\n values.\n The stringData field is never output when reading from the API." }, "type": { "type": "string", "description": "Used to facilitate programmatic handling of secret data.\n More info:\n \u003ca href\u003d\"https://kubernetes.io/docs/concepts/configuration/secret/#secret-types\"\u003esecret-types\u003c/a\u003e" } }, "description": "Secret is a small piece of sensitive data which should be kept secret, such as a password,\n a token, or a key." }, "SecretList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Secret" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "Setting": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/SettingSpec" } }, "description": "{@link Setting Setting} is a custom extension to generate forms based on configuration." }, "SettingForm": { "minLength": 1, "required": [ "formSchema", "group" ], "type": "object", "properties": { "formSchema": { "type": "array", "items": { "type": "object" } }, "group": { "type": "string" }, "label": { "type": "string" } } }, "SettingList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Setting" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "SettingRef": { "required": [ "group", "name" ], "type": "object", "properties": { "group": { "minLength": 1, "type": "string" }, "name": { "minLength": 1, "type": "string" } } }, "SettingSpec": { "required": [ "forms" ], "type": "object", "properties": { "forms": { "minLength": 1, "type": "array", "items": { "$ref": "#/components/schemas/SettingForm" } } } }, "SinglePage": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/SinglePageSpec" }, "status": { "$ref": "#/components/schemas/SinglePageStatus" } }, "description": "\u003cp\u003eSingle page extension.\u003c/p\u003e" }, "SinglePageList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/SinglePage" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "SinglePageSpec": { "required": [ "allowComment", "deleted", "excerpt", "pinned", "priority", "publish", "slug", "title", "visible" ], "type": "object", "properties": { "allowComment": { "type": "boolean", "default": true }, "baseSnapshot": { "type": "string" }, "cover": { "type": "string" }, "deleted": { "type": "boolean", "default": false }, "excerpt": { "$ref": "#/components/schemas/Excerpt" }, "headSnapshot": { "type": "string" }, "htmlMetas": { "type": "array", "items": { "type": "object", "additionalProperties": { "type": "string" } } }, "owner": { "type": "string" }, "pinned": { "type": "boolean", "default": false }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "publish": { "type": "boolean", "default": false }, "publishTime": { "type": "string", "format": "date-time" }, "releaseSnapshot": { "type": "string", "description": "引用到的已发布的内容,用于主题端显示." }, "slug": { "minLength": 1, "type": "string" }, "template": { "type": "string" }, "title": { "minLength": 1, "type": "string" }, "visible": { "type": "string", "enum": [ "PUBLIC", "INTERNAL", "PRIVATE" ], "default": "PUBLIC" } } }, "SinglePageStatus": { "type": "object", "properties": { "commentsCount": { "type": "integer", "format": "int32" }, "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "contributors": { "type": "array", "items": { "type": "string" } }, "excerpt": { "type": "string" }, "hideFromList": { "type": "boolean", "description": "see {@link Category.CategorySpec#isHideFromList Category.CategorySpec#isHideFromList()}." }, "inProgress": { "type": "boolean" }, "lastModifyTime": { "type": "string", "format": "date-time" }, "observedVersion": { "type": "integer", "format": "int64" }, "permalink": { "type": "string" }, "phase": { "type": "string" } } }, "SnapShotSpec": { "required": [ "owner", "rawType", "subjectRef" ], "type": "object", "properties": { "contentPatch": { "type": "string" }, "contributors": { "uniqueItems": true, "type": "array", "items": { "type": "string" } }, "lastModifyTime": { "type": "string", "format": "date-time" }, "owner": { "minLength": 1, "type": "string" }, "parentSnapshotName": { "type": "string" }, "rawPatch": { "type": "string" }, "rawType": { "maxLength": 50, "minLength": 1, "type": "string", "description": "such as: markdown | html | json | asciidoc | latex." }, "subjectRef": { "$ref": "#/components/schemas/Ref" } } }, "Snapshot": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/SnapShotSpec" } } }, "SnapshotList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Snapshot" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "Subject": { "type": "object", "properties": { "apiGroup": { "type": "string", "description": "APIGroup holds the API group of the referenced subject.\n Defaults to \"\" for ServiceAccount subjects.\n Defaults to \"rbac.authorization.halo.run\" for User and Group subjects." }, "kind": { "type": "string", "description": "Kind of object being referenced. Values defined by this API group are \"User\", \"Group\",\n and \"ServiceAccount\".\n If the Authorizer does not recognize the kind value, the Authorizer should report\n an error." }, "name": { "type": "string", "description": "Name of the object being referenced." } } }, "Subscription": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/SubscriptionSpec" } }, "description": "\u003cp\u003e{@link Subscription Subscription} is a custom extension that defines a subscriber to be notified when a\n certain {@link Reason Reason} is triggered.\u003c/p\u003e\n \u003cp\u003eIt holds a {@link Subscriber Subscriber} to the user to be notified, a {@link InterestReason InterestReason} to\n subscribe to.\u003c/p\u003e" }, "SubscriptionList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Subscription" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "SubscriptionSpec": { "required": [ "reason", "subscriber", "unsubscribeToken" ], "type": "object", "properties": { "disabled": { "type": "boolean", "description": "Perhaps users need to unsubscribe and interact without receiving notifications again" }, "reason": { "$ref": "#/components/schemas/InterestReason" }, "subscriber": { "$ref": "#/components/schemas/SubscriptionSubscriber" }, "unsubscribeToken": { "type": "string", "description": "The token to unsubscribe" } } }, "SubscriptionSubscriber": { "required": [ "name" ], "type": "object", "properties": { "name": { "minLength": 1, "type": "string" } }, "description": "The subscriber to be notified" }, "Tag": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/TagSpec" }, "status": { "$ref": "#/components/schemas/TagStatus" } } }, "TagList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Tag" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "TagSpec": { "required": [ "displayName", "slug" ], "type": "object", "properties": { "color": { "pattern": "^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$", "type": "string", "description": "Color regex explanation.\n \u003cpre\u003e\n ^ # start of the line\n # # start with a number sign `#`\n ( # start of (group 1)\n [a-fA-F0-9]{6} # support z-f, A-F and 0-9, with a length of 6\n | # or\n [a-fA-F0-9]{3} # support z-f, A-F and 0-9, with a length of 3\n ) # end of (group 1)\n $ # end of the line\n \u003c/pre\u003e" }, "cover": { "type": "string" }, "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "slug": { "minLength": 1, "type": "string" } } }, "TagStatus": { "type": "object", "properties": { "observedVersion": { "type": "integer", "format": "int64" }, "permalink": { "type": "string" }, "postCount": { "type": "integer", "format": "int32" }, "visiblePostCount": { "type": "integer", "format": "int32" } } }, "TemplateContent": { "required": [ "title" ], "type": "object", "properties": { "htmlBody": { "type": "string" }, "rawBody": { "type": "string" }, "title": { "minLength": 1, "type": "string" } } }, "TemplateDescriptor": { "required": [ "file", "name" ], "type": "object", "properties": { "description": { "type": "string" }, "file": { "minLength": 1, "type": "string" }, "name": { "minLength": 1, "type": "string" }, "screenshot": { "type": "string" } }, "description": "Type used to describe custom template page." }, "TestOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "test" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "Theme": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ThemeSpec" }, "status": { "$ref": "#/components/schemas/ThemeStatus" } }, "description": "\u003cp\u003eTheme extension.\u003c/p\u003e" }, "ThemeList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Theme" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ThemeSpec": { "required": [ "author", "displayName" ], "type": "object", "properties": { "author": { "$ref": "#/components/schemas/Author" }, "configMapName": { "type": "string" }, "customTemplates": { "$ref": "#/components/schemas/CustomTemplates" }, "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "homepage": { "type": "string" }, "issues": { "type": "string" }, "license": { "type": "array", "items": { "$ref": "#/components/schemas/License" } }, "logo": { "type": "string" }, "repo": { "type": "string" }, "requires": { "type": "string" }, "settingName": { "type": "string" }, "version": { "type": "string" } } }, "ThemeStatus": { "type": "object", "properties": { "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "location": { "type": "string" }, "phase": { "type": "string", "enum": [ "READY", "FAILED", "UNKNOWN" ] } } }, "Thumbnail": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ThumbnailSpec" } } }, "ThumbnailList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Thumbnail" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ThumbnailSpec": { "required": [ "imageSignature", "imageUri", "size", "thumbnailUri" ], "type": "object", "properties": { "imageSignature": { "minLength": 1, "type": "string" }, "imageUri": { "minLength": 1, "type": "string" }, "size": { "type": "string", "enum": [ "S", "M", "L", "XL" ] }, "thumbnailUri": { "minLength": 1, "type": "string" } } }, "User": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/UserSpec" }, "status": { "$ref": "#/components/schemas/UserStatus" } }, "description": "The extension represents user details of Halo." }, "UserConnection": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/UserConnectionSpec" } }, "description": "User connection extension." }, "UserConnectionList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/UserConnection" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "UserConnectionSpec": { "required": [ "providerUserId", "registrationId", "username" ], "type": "object", "properties": { "providerUserId": { "type": "string", "description": "The unique identifier for the user\u0027s connection to the OAuth provider.\n for example, the user\u0027s GitHub id." }, "registrationId": { "type": "string", "description": "The name of the OAuth provider (e.g. Google, Facebook, Twitter)." }, "updatedAt": { "type": "string", "description": "The time when the user connection was last updated.", "format": "date-time" }, "username": { "type": "string", "description": "The {@link Metadata#getName Metadata#getName()} of the user associated with the OAuth connection." } } }, "UserList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/User" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "UserSpec": { "required": [ "displayName", "email" ], "type": "object", "properties": { "avatar": { "type": "string" }, "bio": { "type": "string" }, "disabled": { "type": "boolean" }, "displayName": { "type": "string" }, "email": { "type": "string" }, "emailVerified": { "type": "boolean" }, "loginHistoryLimit": { "type": "integer", "format": "int32" }, "password": { "type": "string" }, "phone": { "type": "string" }, "registeredAt": { "type": "string", "format": "date-time" }, "totpEncryptedSecret": { "type": "string" }, "twoFactorAuthEnabled": { "type": "boolean" } } }, "UserStatus": { "type": "object", "properties": { "permalink": { "type": "string" } } } }, "securitySchemes": { "basicAuth": { "scheme": "basic", "type": "http" }, "bearerAuth": { "bearerFormat": "JWT", "scheme": "bearer", "type": "http" } } } } ================================================ FILE: api-docs/openapi/v3_0/apis_public.api_v1alpha1.json ================================================ { "openapi": "3.0.1", "info": { "title": "Halo", "version": "2.23.0-SNAPSHOT" }, "servers": [ { "url": "http://localhost:8091", "description": "Generated server url" } ], "security": [ { "basicAuth": [], "bearerAuth": [] } ], "paths": { "/apis/api.content.halo.run/v1alpha1/categories": { "get": { "description": "Lists categories.", "operationId": "queryCategories", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CategoryVoList" } } }, "description": "default response" } }, "tags": [ "CategoryV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/categories/{name}": { "get": { "description": "Gets category by name.", "operationId": "queryCategoryByName", "parameters": [ { "description": "Category name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CategoryVo" } } }, "description": "default response" } }, "tags": [ "CategoryV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/categories/{name}/posts": { "get": { "description": "Lists posts by category name.", "operationId": "queryPostsByCategoryName", "parameters": [ { "description": "Category name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedPostVoList" } } }, "description": "default response" } }, "tags": [ "CategoryV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/posts": { "get": { "description": "Lists posts.", "operationId": "queryPosts", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedPostVoList" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/posts/{name}": { "get": { "description": "Gets a post by name.", "operationId": "queryPostByName", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PostVo" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/posts/{name}/navigation": { "get": { "description": "Gets a post navigation by name.", "operationId": "queryPostNavigationByName", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NavigationPostVo" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/singlepages": { "get": { "description": "Lists single pages", "operationId": "querySinglePages", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedSinglePageVoList" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/singlepages/{name}": { "get": { "description": "Gets single page by name", "operationId": "querySinglePageByName", "parameters": [ { "description": "SinglePage name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SinglePageVo" } } }, "description": "default response" } }, "tags": [ "SinglePageV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/tags": { "get": { "description": "Lists tags", "operationId": "queryTags", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TagVoList" } } }, "description": "default response" } }, "tags": [ "TagV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/tags/{name}": { "get": { "description": "Gets tag by name", "operationId": "queryTagByName", "parameters": [ { "description": "Tag name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TagVo" } } }, "description": "default response" } }, "tags": [ "TagV1alpha1Public" ] } }, "/apis/api.content.halo.run/v1alpha1/tags/{name}/posts": { "get": { "description": "Lists posts by tag name", "operationId": "queryPostsByTagName", "parameters": [ { "description": "Tag name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedPostVo" } } }, "description": "default response" } }, "tags": [ "TagV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/comments": { "get": { "description": "List comments.", "operationId": "ListComments_1", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "The comment subject group.", "in": "query", "name": "group", "schema": { "type": "string" } }, { "description": "The comment subject version.", "in": "query", "name": "version", "required": true, "schema": { "type": "string" } }, { "description": "The comment subject kind.", "in": "query", "name": "kind", "required": true, "schema": { "type": "string" } }, { "description": "The comment subject name.", "in": "query", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Whether to include replies. Default is false.", "in": "query", "name": "withReplies", "schema": { "type": "boolean" } }, { "description": "Reply size of the comment, default is 10, only works when withReplies is true.", "in": "query", "name": "replySize", "schema": { "type": "integer", "format": "int32" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CommentWithReplyVoList" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Public" ] }, "post": { "description": "Create a comment.", "operationId": "CreateComment_1", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommentRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Comment" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/comments/{name}": { "get": { "description": "Get a comment.", "operationId": "GetComment", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/CommentVoList" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/comments/{name}/reply": { "get": { "description": "List comment replies.", "operationId": "ListCommentReplies", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReplyVoList" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Public" ] }, "post": { "description": "Create a reply.", "operationId": "CreateReply_1", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReplyRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Reply" } } }, "description": "default response" } }, "tags": [ "CommentV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/indices/-/search": { "post": { "description": "Search indices.", "operationId": "IndicesSearch", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SearchOption" } } }, "description": "Please note that the \"filterPublished\", \"filterExposed\" and \"filterRecycled\" fields are ignored in this endpoint." }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SearchResult" } } }, "description": "default response" } }, "tags": [ "IndexV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/menus/-": { "get": { "description": "Gets primary menu.", "operationId": "queryPrimaryMenu", "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuVo" } } }, "description": "default response" } }, "tags": [ "MenuV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/menus/{name}": { "get": { "description": "Gets menu by name.", "operationId": "queryMenuByName", "parameters": [ { "description": "Menu name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/MenuVo" } } }, "description": "default response" } }, "tags": [ "MenuV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/stats/-": { "get": { "description": "Gets site stats", "operationId": "queryStats", "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/SiteStatsVo" } } }, "description": "default response" } }, "tags": [ "SystemV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/trackers/counter": { "post": { "description": "Count an extension resource visits.", "operationId": "count", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CounterRequest" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "MetricsV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/trackers/downvote": { "post": { "description": "Downvote an extension resource.", "operationId": "downvote", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/VoteRequest" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "MetricsV1alpha1Public" ] } }, "/apis/api.halo.run/v1alpha1/trackers/upvote": { "post": { "description": "Upvote an extension resource.", "operationId": "upvote", "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/VoteRequest" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "MetricsV1alpha1Public" ] } }, "/apis/api.notification.halo.run/v1alpha1/subscriptions/{name}/unsubscribe": { "get": { "description": "Unsubscribe a subscription", "operationId": "Unsubscribe", "parameters": [ { "description": "Subscription name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Unsubscribe token", "in": "query", "name": "token", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "string" } } }, "description": "default response" } }, "tags": [ "NotificationV1alpha1Public" ] } }, "/apis/api.plugin.halo.run/v1alpha1/plugins/{name}/available": { "get": { "description": "Gets plugin available by name.", "operationId": "queryPluginAvailableByName", "parameters": [ { "description": "Plugin name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "boolean" } } }, "description": "default response" } }, "tags": [ "PluginV1alpha1Public" ] } }, "/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri": { "get": { "description": "Get thumbnail by URI", "operationId": "GetThumbnailByUri", "parameters": [ { "description": "The URI of the image", "in": "query", "name": "uri", "required": true, "schema": { "type": "string" } }, { "description": "The size of the thumbnail", "in": "query", "name": "size", "required": true, "schema": { "type": "string", "enum": [ "S", "M", "L", "XL" ] } }, { "description": "The width of the thumbnail, if \u0027size\u0027 is not provided, this parameter will be used to determine the size", "in": "query", "name": "width", "schema": { "type": "integer", "enum": [ "400", "800", "1200", "1600" ] } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "string", "format": "binary" } } }, "description": "default response" } }, "tags": [ "ThumbnailV1alpha1Public" ] } } }, "components": { "schemas": { "AddOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "add" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "CategorySpec": { "required": [ "displayName", "priority", "slug" ], "type": "object", "properties": { "children": { "type": "array", "items": { "type": "string" } }, "cover": { "type": "string" }, "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "hideFromList": { "type": "boolean", "description": "\u003cp\u003eWhether to hide the category from the category list.\u003c/p\u003e\n \u003cp\u003eWhen set to true, the category including its subcategories and related posts will\n not be displayed in the category list, but it can still be accessed by permalink.\u003c/p\u003e\n \u003cp\u003eLimitation: It only takes effect on the theme-side categorized list and it only\n allows to be set to true on the first level(root node) of categories.\u003c/p\u003e" }, "postTemplate": { "maxLength": 255, "type": "string", "description": "\u003cp\u003eUsed to specify the template for the posts associated with the category.\u003c/p\u003e\n \u003cp\u003eThe priority is not as high as that of the post.\u003c/p\u003e\n \u003cp\u003eIf the post also specifies a template, the post\u0027s template will prevail.\u003c/p\u003e" }, "preventParentPostCascadeQuery": { "type": "boolean", "description": "\u003cp\u003eif a category is queried for related posts, the default behavior is to\n query all posts under the category including its subcategories, but if this field is\n set to true, cascade query behavior will be terminated here.\u003c/p\u003e\n \u003cp\u003eFor example, if a category has subcategories A and B, and A has subcategories C and\n D and C marked this field as true, when querying posts under A category,all posts under A\n and B will be queried, but C and D will not be queried.\u003c/p\u003e" }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "slug": { "minLength": 1, "type": "string" }, "template": { "maxLength": 255, "type": "string" } } }, "CategoryStatus": { "type": "object", "properties": { "permalink": { "type": "string" }, "postCount": { "type": "integer", "description": "包括当前和其下所有层级的文章数量 (depth\u003dmax).", "format": "int32" }, "visiblePostCount": { "type": "integer", "description": "包括当前和其下所有层级的已发布且公开的文章数量 (depth\u003dmax).", "format": "int32" } } }, "CategoryVo": { "required": [ "metadata" ], "type": "object", "properties": { "metadata": { "$ref": "#/components/schemas/Metadata" }, "postCount": { "type": "integer", "format": "int32" }, "spec": { "$ref": "#/components/schemas/CategorySpec" }, "status": { "$ref": "#/components/schemas/CategoryStatus" } }, "description": "A value object for {@link Category Category}." }, "CategoryVoList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/CategoryVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "Comment": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/CommentSpec" }, "status": { "$ref": "#/components/schemas/CommentStatus" } } }, "CommentEmailOwner": { "type": "object", "properties": { "avatar": { "type": "string", "description": "avatar for comment owner" }, "displayName": { "type": "string", "description": "display name for comment owner" }, "email": { "type": "string", "description": "email for comment owner" }, "website": { "type": "string", "description": "website for comment owner" } }, "description": "\u003cp\u003eThe creator info of the comment.\u003c/p\u003e\n This {@link CommentEmailOwner CommentEmailOwner} is only applicable to the user who is allowed to comment\n without logging in." }, "CommentOwner": { "required": [ "kind", "name" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" } }, "displayName": { "type": "string" }, "kind": { "minLength": 1, "type": "string" }, "name": { "maxLength": 64, "type": "string" } } }, "CommentRequest": { "required": [ "content", "raw", "subjectRef" ], "type": "object", "properties": { "allowNotification": { "type": "boolean", "default": false }, "content": { "minLength": 1, "type": "string" }, "hidden": { "type": "boolean", "default": false }, "owner": { "$ref": "#/components/schemas/CommentEmailOwner" }, "raw": { "minLength": 1, "type": "string" }, "subjectRef": { "$ref": "#/components/schemas/Ref" } }, "description": "Request parameter object for {@link Comment Comment}." }, "CommentSpec": { "required": [ "allowNotification", "approved", "content", "hidden", "owner", "priority", "raw", "subjectRef", "top" ], "type": "object", "properties": { "allowNotification": { "type": "boolean", "default": true }, "approved": { "type": "boolean", "default": false }, "approvedTime": { "type": "string", "format": "date-time" }, "content": { "minLength": 1, "type": "string" }, "creationTime": { "type": "string", "description": "The user-defined creation time default is \u003ccode\u003emetadata.creationTimestamp\u003c/code\u003e.", "format": "date-time" }, "hidden": { "type": "boolean", "default": false }, "ipAddress": { "type": "string" }, "lastReadTime": { "type": "string", "format": "date-time" }, "owner": { "$ref": "#/components/schemas/CommentOwner" }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "raw": { "minLength": 1, "type": "string" }, "subjectRef": { "$ref": "#/components/schemas/Ref" }, "top": { "type": "boolean", "default": false }, "userAgent": { "type": "string" } } }, "CommentStatsVo": { "type": "object", "properties": { "upvote": { "type": "integer", "format": "int32" } }, "description": "comment stats value object." }, "CommentStatus": { "type": "object", "properties": { "hasNewReply": { "type": "boolean" }, "lastReplyTime": { "type": "string", "format": "date-time" }, "observedVersion": { "type": "integer", "format": "int64" }, "replyCount": { "type": "integer", "format": "int32" }, "unreadReplyCount": { "type": "integer", "format": "int32" }, "visibleReplyCount": { "type": "integer", "format": "int32" } } }, "CommentVo": { "required": [ "metadata", "owner", "spec", "stats" ], "type": "object", "properties": { "metadata": { "$ref": "#/components/schemas/Metadata" }, "owner": { "$ref": "#/components/schemas/OwnerInfo" }, "spec": { "$ref": "#/components/schemas/CommentSpec" }, "stats": { "$ref": "#/components/schemas/CommentStatsVo" }, "status": { "$ref": "#/components/schemas/CommentStatus" } }, "description": "A chunk of items." }, "CommentVoList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/CommentVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "CommentWithReplyVo": { "required": [ "metadata", "owner", "spec", "stats" ], "type": "object", "properties": { "metadata": { "$ref": "#/components/schemas/Metadata" }, "owner": { "$ref": "#/components/schemas/OwnerInfo" }, "replies": { "$ref": "#/components/schemas/ListResultReplyVo" }, "spec": { "$ref": "#/components/schemas/CommentSpec" }, "stats": { "$ref": "#/components/schemas/CommentStatsVo" }, "status": { "$ref": "#/components/schemas/CommentStatus" } }, "description": "A chunk of items." }, "CommentWithReplyVoList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/CommentWithReplyVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "Condition": { "required": [ "lastTransitionTime", "status", "type" ], "type": "object", "properties": { "lastTransitionTime": { "type": "string", "description": "Last time the condition transitioned from one status to another.", "format": "date-time" }, "message": { "maxLength": 32768, "type": "string", "description": "Human-readable message indicating details about last transition.\n This may be an empty string." }, "reason": { "maxLength": 1024, "pattern": "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$", "type": "string", "description": "Unique, one-word, CamelCase reason for the condition\u0027s last transition." }, "status": { "type": "string", "description": "Status is the status of the condition. Can be True, False, Unknown.", "enum": [ "TRUE", "FALSE", "UNKNOWN" ] }, "type": { "maxLength": 316, "pattern": "^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$", "type": "string", "description": "type of condition in CamelCase or in foo.example.com/CamelCase.\n example: Ready, Initialized.\n maxLength: 316." } }, "description": "EqualsAndHashCode 排除了lastTransitionTime否则失败时,lastTransitionTime 会被更新\n 导致 equals 为 false,一直被加入队列." }, "ContentVo": { "type": "object", "properties": { "content": { "type": "string" }, "raw": { "type": "string" } }, "description": "A value object for Content from {@link Snapshot Snapshot}." }, "ContributorVo": { "required": [ "metadata" ], "type": "object", "properties": { "avatar": { "type": "string" }, "bio": { "type": "string" }, "displayName": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "name": { "type": "string" }, "permalink": { "type": "string" } }, "description": "A value object for {@link run.halo.app.core.extension.User run.halo.app.core.extension.User}." }, "CopyOperation": { "required": [ "op", "from", "path" ], "type": "object", "properties": { "from": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "op": { "type": "string", "enum": [ "copy" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "CounterRequest": { "type": "object", "properties": { "group": { "type": "string" }, "hostname": { "type": "string" }, "language": { "type": "string" }, "name": { "type": "string" }, "plural": { "type": "string" }, "referrer": { "type": "string" }, "screen": { "type": "string" } } }, "Excerpt": { "required": [ "autoGenerate" ], "type": "object", "properties": { "autoGenerate": { "type": "boolean", "default": true }, "raw": { "type": "string" } } }, "HaloDocument": { "required": [ "content", "id", "metadataName", "ownerName", "permalink", "title", "type" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Custom metadata. Make sure the map is serializable." }, "categories": { "type": "array", "description": "Document categories. The item in the list is the category metadata name.", "items": { "type": "string" } }, "content": { "minLength": 1, "type": "string", "description": "Document content. Safety content, without HTML tag." }, "creationTimestamp": { "type": "string", "description": "Document creation timestamp.", "format": "date-time" }, "description": { "type": "string", "description": "Document description." }, "exposed": { "type": "boolean", "description": "Whether the document is exposed to the public." }, "id": { "minLength": 1, "type": "string", "description": "Document ID. It should be unique globally." }, "metadataName": { "minLength": 1, "type": "string", "description": "Metadata name of the corresponding extension." }, "ownerName": { "minLength": 1, "type": "string", "description": "Document owner metadata name." }, "permalink": { "minLength": 1, "type": "string", "description": "Document permalink." }, "published": { "type": "boolean", "description": "Whether the document is published." }, "recycled": { "type": "boolean", "description": "Whether the document is recycled." }, "tags": { "type": "array", "description": "Document tags. The item in the list is the tag metadata name.", "items": { "type": "string" } }, "title": { "minLength": 1, "type": "string", "description": "Document title." }, "type": { "minLength": 1, "type": "string", "description": "Document type. e.g.: post.content.halo.run, singlepage.content.halo.run, moment.moment\n .halo.run, doc.doc.halo.run." }, "updateTimestamp": { "type": "string", "description": "Document update timestamp.", "format": "date-time" } }, "description": "Document for search." }, "JsonPatch": { "minItems": 1, "uniqueItems": true, "type": "array", "description": "JSON schema for JSONPatch operations", "items": { "oneOf": [ { "$ref": "#/components/schemas/AddOperation" }, { "$ref": "#/components/schemas/ReplaceOperation" }, { "$ref": "#/components/schemas/TestOperation" }, { "$ref": "#/components/schemas/RemoveOperation" }, { "$ref": "#/components/schemas/MoveOperation" }, { "$ref": "#/components/schemas/CopyOperation" } ] } }, "ListResultReplyVo": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ReplyVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ListedPostVo": { "required": [ "metadata" ], "type": "object", "properties": { "categories": { "type": "array", "items": { "$ref": "#/components/schemas/CategoryVo" } }, "contributors": { "type": "array", "items": { "$ref": "#/components/schemas/ContributorVo" } }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "owner": { "$ref": "#/components/schemas/ContributorVo" }, "spec": { "$ref": "#/components/schemas/PostSpec" }, "stats": { "$ref": "#/components/schemas/StatsVo" }, "status": { "$ref": "#/components/schemas/PostStatus" }, "tags": { "type": "array", "items": { "$ref": "#/components/schemas/TagVo" } } }, "description": "A value object for {@link Post Post}." }, "ListedPostVoList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedPostVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "ListedSinglePageVo": { "required": [ "metadata" ], "type": "object", "properties": { "contributors": { "type": "array", "items": { "$ref": "#/components/schemas/ContributorVo" } }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "owner": { "$ref": "#/components/schemas/ContributorVo" }, "spec": { "$ref": "#/components/schemas/SinglePageSpec" }, "stats": { "$ref": "#/components/schemas/StatsVo" }, "status": { "$ref": "#/components/schemas/SinglePageStatus" } }, "description": "A chunk of items." }, "ListedSinglePageVoList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedSinglePageVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "MenuItemSpec": { "type": "object", "properties": { "children": { "uniqueItems": true, "type": "array", "description": "Children of this menu item", "items": { "type": "string", "description": "The name of menu item child" } }, "displayName": { "type": "string", "description": "The display name of menu item." }, "href": { "type": "string", "description": "The href of this menu item." }, "priority": { "type": "integer", "description": "The priority is for ordering.", "format": "int32" }, "target": { "type": "string", "description": "The \u003ca\u003e target attribute of this menu item.", "enum": [ "_blank", "_self", "_parent", "_top" ] }, "targetRef": { "$ref": "#/components/schemas/Ref" } } }, "MenuItemStatus": { "type": "object", "properties": { "displayName": { "type": "string", "description": "Calculated Display name of menu item." }, "href": { "type": "string", "description": "Calculated href of manu item." } } }, "MenuItemVo": { "required": [ "metadata" ], "type": "object", "properties": { "children": { "type": "array", "items": { "$ref": "#/components/schemas/MenuItemVo" } }, "displayName": { "type": "string", "description": "Gets menu item\u0027s display name." }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "parentName": { "type": "string" }, "spec": { "$ref": "#/components/schemas/MenuItemSpec" }, "status": { "$ref": "#/components/schemas/MenuItemStatus" } }, "description": "A value object for {@link MenuItem MenuItem}." }, "MenuSpec": { "required": [ "displayName" ], "type": "object", "properties": { "displayName": { "type": "string", "description": "The display name of the menu." }, "menuItems": { "uniqueItems": true, "type": "array", "description": "Menu items of this menu.", "items": { "type": "string", "description": "Name of menu item." } } } }, "MenuVo": { "required": [ "metadata" ], "type": "object", "properties": { "menuItems": { "type": "array", "items": { "$ref": "#/components/schemas/MenuItemVo" } }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/MenuSpec" } }, "description": "A value object for {@link Menu Menu}." }, "Metadata": { "required": [ "name" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Annotations are like key-value format." }, "creationTimestamp": { "type": "string", "description": "Creation timestamp of the Extension.", "format": "date-time", "nullable": true }, "deletionTimestamp": { "type": "string", "description": "Deletion timestamp of the Extension.", "format": "date-time", "nullable": true }, "finalizers": { "uniqueItems": true, "type": "array", "nullable": true, "items": { "type": "string", "nullable": true } }, "generateName": { "type": "string", "description": "The name field will be generated automatically according to the given generateName field" }, "labels": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Labels are like key-value format." }, "name": { "type": "string", "description": "Metadata name" }, "version": { "type": "integer", "description": "Current version of the Extension. It will be bumped up every update.", "format": "int64", "nullable": true } }, "description": "Metadata of Extension." }, "MoveOperation": { "required": [ "op", "from", "path" ], "type": "object", "properties": { "from": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "op": { "type": "string", "enum": [ "move" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "NavigationPostVo": { "type": "object", "properties": { "next": { "$ref": "#/components/schemas/ListedPostVo" }, "previous": { "$ref": "#/components/schemas/ListedPostVo" } }, "description": "Post navigation vo to hold previous and next item." }, "OwnerInfo": { "type": "object", "properties": { "avatar": { "type": "string" }, "displayName": { "type": "string" }, "email": { "type": "string" }, "kind": { "type": "string" }, "name": { "type": "string" } }, "description": "Comment owner info." }, "PostSpec": { "required": [ "allowComment", "deleted", "excerpt", "pinned", "priority", "publish", "slug", "title", "visible" ], "type": "object", "properties": { "allowComment": { "type": "boolean", "default": true }, "baseSnapshot": { "type": "string" }, "categories": { "type": "array", "items": { "type": "string" } }, "cover": { "type": "string" }, "deleted": { "type": "boolean", "default": false }, "excerpt": { "$ref": "#/components/schemas/Excerpt" }, "headSnapshot": { "type": "string" }, "htmlMetas": { "type": "array", "items": { "type": "object", "additionalProperties": { "type": "string" } } }, "owner": { "type": "string" }, "pinned": { "type": "boolean", "default": false }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "publish": { "type": "boolean", "default": false }, "publishTime": { "type": "string", "format": "date-time" }, "releaseSnapshot": { "type": "string", "description": "文章引用到的已发布的内容,用于主题端显示." }, "slug": { "minLength": 1, "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } }, "template": { "type": "string" }, "title": { "minLength": 1, "type": "string" }, "visible": { "type": "string", "enum": [ "PUBLIC", "INTERNAL", "PRIVATE" ], "default": "PUBLIC" } } }, "PostStatus": { "type": "object", "properties": { "commentsCount": { "type": "integer", "format": "int32" }, "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "contributors": { "type": "array", "items": { "type": "string" } }, "excerpt": { "type": "string" }, "hideFromList": { "type": "boolean", "description": "see {@link Category.CategorySpec#isHideFromList Category.CategorySpec#isHideFromList()}." }, "inProgress": { "type": "boolean" }, "lastModifyTime": { "type": "string", "format": "date-time" }, "observedVersion": { "type": "integer", "format": "int64" }, "permalink": { "type": "string" }, "phase": { "type": "string" } } }, "PostVo": { "required": [ "metadata" ], "type": "object", "properties": { "categories": { "type": "array", "items": { "$ref": "#/components/schemas/CategoryVo" } }, "content": { "$ref": "#/components/schemas/ContentVo" }, "contributors": { "type": "array", "items": { "$ref": "#/components/schemas/ContributorVo" } }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "owner": { "$ref": "#/components/schemas/ContributorVo" }, "spec": { "$ref": "#/components/schemas/PostSpec" }, "stats": { "$ref": "#/components/schemas/StatsVo" }, "status": { "$ref": "#/components/schemas/PostStatus" }, "tags": { "type": "array", "items": { "$ref": "#/components/schemas/TagVo" } } }, "description": "A value object for {@link Post Post}." }, "Ref": { "required": [ "group", "kind", "name" ], "type": "object", "properties": { "group": { "type": "string", "description": "Extension group" }, "kind": { "type": "string", "description": "Extension kind" }, "name": { "type": "string", "description": "Extension name. This field is mandatory" }, "version": { "type": "string", "description": "Extension version" } }, "description": "Extension reference object. The name is mandatory" }, "RemoveOperation": { "required": [ "op", "path" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "remove" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "ReplaceOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "replace" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "Reply": { "required": [ "apiVersion", "kind", "metadata", "spec", "status" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/ReplySpec" }, "status": { "$ref": "#/components/schemas/ReplyStatus" } } }, "ReplyRequest": { "required": [ "content", "raw" ], "type": "object", "properties": { "allowNotification": { "type": "boolean", "default": false }, "content": { "minLength": 1, "type": "string" }, "hidden": { "type": "boolean", "default": false }, "owner": { "$ref": "#/components/schemas/CommentEmailOwner" }, "quoteReply": { "type": "string" }, "raw": { "minLength": 1, "type": "string" } }, "description": "A request parameter object for {@link Reply Reply}." }, "ReplySpec": { "required": [ "allowNotification", "approved", "commentName", "content", "hidden", "owner", "priority", "raw", "top" ], "type": "object", "properties": { "allowNotification": { "type": "boolean", "default": true }, "approved": { "type": "boolean", "default": false }, "approvedTime": { "type": "string", "format": "date-time" }, "commentName": { "minLength": 1, "type": "string" }, "content": { "minLength": 1, "type": "string" }, "creationTime": { "type": "string", "description": "The user-defined creation time default is \u003ccode\u003emetadata.creationTimestamp\u003c/code\u003e.", "format": "date-time" }, "hidden": { "type": "boolean", "default": false }, "ipAddress": { "type": "string" }, "owner": { "$ref": "#/components/schemas/CommentOwner" }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "quoteReply": { "type": "string" }, "raw": { "minLength": 1, "type": "string" }, "top": { "type": "boolean", "default": false }, "userAgent": { "type": "string" } } }, "ReplyStatus": { "type": "object", "properties": { "observedVersion": { "type": "integer", "format": "int64" } } }, "ReplyVo": { "required": [ "metadata", "owner", "spec", "stats" ], "type": "object", "properties": { "metadata": { "$ref": "#/components/schemas/Metadata" }, "owner": { "$ref": "#/components/schemas/OwnerInfo" }, "spec": { "$ref": "#/components/schemas/ReplySpec" }, "stats": { "$ref": "#/components/schemas/CommentStatsVo" } }, "description": "A chunk of items." }, "ReplyVoList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ReplyVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "SearchOption": { "required": [ "keyword" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Additional annotations for extending search option by other search engines." }, "filterExposed": { "type": "boolean", "description": "Whether to filter exposed content. If null, it will not filter." }, "filterPublished": { "type": "boolean", "description": "Whether to filter published content. If null, it will not filter." }, "filterRecycled": { "type": "boolean", "description": "Whether to filter recycled content. If null, it will not filter." }, "highlightPostTag": { "type": "string", "description": "Post HTML tag of highlighted fragment." }, "highlightPreTag": { "type": "string", "description": "Pre HTML tag of highlighted fragment." }, "includeCategoryNames": { "type": "array", "description": "Category names to include(and). If null, it will include all categories.", "items": { "type": "string" } }, "includeOwnerNames": { "type": "array", "description": "Owner names to include(or). If null, it will include all owners.", "items": { "type": "string" } }, "includeTagNames": { "type": "array", "description": "Tag names to include(and). If null, it will include all tags.", "items": { "type": "string" } }, "includeTypes": { "type": "array", "description": "Types to include(or). If null, it will include all types.", "items": { "type": "string" } }, "keyword": { "minLength": 1, "type": "string", "description": "Search keyword." }, "limit": { "maximum": 1000, "minimum": 1, "type": "integer", "description": "Limit of result.", "format": "int32" } }, "description": "Search option. It is used to control search behavior." }, "SearchResult": { "type": "object", "properties": { "hits": { "type": "array", "items": { "$ref": "#/components/schemas/HaloDocument" } }, "keyword": { "type": "string" }, "limit": { "type": "integer", "format": "int32" }, "processingTimeMillis": { "type": "integer", "format": "int64" }, "total": { "type": "integer", "format": "int64" } } }, "SinglePageSpec": { "required": [ "allowComment", "deleted", "excerpt", "pinned", "priority", "publish", "slug", "title", "visible" ], "type": "object", "properties": { "allowComment": { "type": "boolean", "default": true }, "baseSnapshot": { "type": "string" }, "cover": { "type": "string" }, "deleted": { "type": "boolean", "default": false }, "excerpt": { "$ref": "#/components/schemas/Excerpt" }, "headSnapshot": { "type": "string" }, "htmlMetas": { "type": "array", "items": { "type": "object", "additionalProperties": { "type": "string" } } }, "owner": { "type": "string" }, "pinned": { "type": "boolean", "default": false }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "publish": { "type": "boolean", "default": false }, "publishTime": { "type": "string", "format": "date-time" }, "releaseSnapshot": { "type": "string", "description": "引用到的已发布的内容,用于主题端显示." }, "slug": { "minLength": 1, "type": "string" }, "template": { "type": "string" }, "title": { "minLength": 1, "type": "string" }, "visible": { "type": "string", "enum": [ "PUBLIC", "INTERNAL", "PRIVATE" ], "default": "PUBLIC" } } }, "SinglePageStatus": { "type": "object", "properties": { "commentsCount": { "type": "integer", "format": "int32" }, "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "contributors": { "type": "array", "items": { "type": "string" } }, "excerpt": { "type": "string" }, "hideFromList": { "type": "boolean", "description": "see {@link Category.CategorySpec#isHideFromList Category.CategorySpec#isHideFromList()}." }, "inProgress": { "type": "boolean" }, "lastModifyTime": { "type": "string", "format": "date-time" }, "observedVersion": { "type": "integer", "format": "int64" }, "permalink": { "type": "string" }, "phase": { "type": "string" } } }, "SinglePageVo": { "required": [ "metadata" ], "type": "object", "properties": { "content": { "$ref": "#/components/schemas/ContentVo" }, "contributors": { "type": "array", "items": { "$ref": "#/components/schemas/ContributorVo" } }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "owner": { "$ref": "#/components/schemas/ContributorVo" }, "spec": { "$ref": "#/components/schemas/SinglePageSpec" }, "stats": { "$ref": "#/components/schemas/StatsVo" }, "status": { "$ref": "#/components/schemas/SinglePageStatus" } }, "description": "A value object for {@link SinglePage SinglePage}." }, "SiteStatsVo": { "type": "object", "properties": { "category": { "type": "integer", "format": "int32" }, "comment": { "type": "integer", "format": "int32" }, "post": { "type": "integer", "format": "int32" }, "upvote": { "type": "integer", "format": "int32" }, "visit": { "type": "integer", "format": "int32" } }, "description": "A value object for site stats." }, "StatsVo": { "type": "object", "properties": { "comment": { "type": "integer", "format": "int32" }, "upvote": { "type": "integer", "format": "int32" }, "visit": { "type": "integer", "format": "int32" } }, "description": "Stats value object." }, "TagSpec": { "required": [ "displayName", "slug" ], "type": "object", "properties": { "color": { "pattern": "^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$", "type": "string", "description": "Color regex explanation.\n \u003cpre\u003e\n ^ # start of the line\n # # start with a number sign `#`\n ( # start of (group 1)\n [a-fA-F0-9]{6} # support z-f, A-F and 0-9, with a length of 6\n | # or\n [a-fA-F0-9]{3} # support z-f, A-F and 0-9, with a length of 3\n ) # end of (group 1)\n $ # end of the line\n \u003c/pre\u003e" }, "cover": { "type": "string" }, "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "slug": { "minLength": 1, "type": "string" } } }, "TagStatus": { "type": "object", "properties": { "observedVersion": { "type": "integer", "format": "int64" }, "permalink": { "type": "string" }, "postCount": { "type": "integer", "format": "int32" }, "visiblePostCount": { "type": "integer", "format": "int32" } } }, "TagVo": { "required": [ "metadata" ], "type": "object", "properties": { "metadata": { "$ref": "#/components/schemas/Metadata" }, "postCount": { "type": "integer", "format": "int32" }, "spec": { "$ref": "#/components/schemas/TagSpec" }, "status": { "$ref": "#/components/schemas/TagStatus" } }, "description": "A value object for {@link Tag Tag}." }, "TagVoList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/TagVo" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "TestOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "test" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "VoteRequest": { "type": "object", "properties": { "group": { "type": "string" }, "name": { "type": "string" }, "plural": { "type": "string" } } } }, "securitySchemes": { "basicAuth": { "scheme": "basic", "type": "http" }, "bearerAuth": { "bearerFormat": "JWT", "scheme": "bearer", "type": "http" } } } } ================================================ FILE: api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json ================================================ { "openapi": "3.0.1", "info": { "title": "Halo", "version": "2.23.0-SNAPSHOT" }, "servers": [ { "url": "http://localhost:8091", "description": "Generated server url" } ], "security": [ { "basicAuth": [], "bearerAuth": [] } ], "paths": { "/apis/api.notification.halo.run/v1alpha1/notifiers/{name}/receiver-config": { "get": { "description": "Fetch receiver config of notifier", "operationId": "FetchReceiverConfig", "parameters": [ { "description": "Notifier name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "object" } } }, "description": "default response" } }, "tags": [ "NotifierV1alpha1Uc" ] }, "post": { "description": "Save receiver config of notifier", "operationId": "SaveReceiverConfig", "parameters": [ { "description": "Notifier name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object" } } }, "required": true }, "responses": { "default": { "content": {}, "description": "default response" } }, "tags": [ "NotifierV1alpha1Uc" ] } }, "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notification-preferences": { "get": { "description": "List notification preferences for the authenticated user.", "operationId": "ListUserNotificationPreferences", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonTypeNotifierMatrix" } } }, "description": "default response" } }, "tags": [ "NotificationV1alpha1Uc" ] }, "post": { "description": "Save notification preferences for the authenticated user.", "operationId": "SaveUserNotificationPreferences", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonTypeNotifierCollectionRequest" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ReasonTypeNotifierMatrix" } } }, "description": "default response" } }, "tags": [ "NotificationV1alpha1Uc" ] } }, "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications": { "get": { "description": "List notifications for the authenticated user.", "operationId": "ListUserNotifications", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } }, { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/NotificationList" } } }, "description": "default response" } }, "tags": [ "NotificationV1alpha1Uc" ] } }, "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/-/mark-specified-as-read": { "put": { "description": "Mark the specified notifications as read.", "operationId": "MarkNotificationsAsRead", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MarkSpecifiedRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "type": "string" } } } }, "description": "default response" } }, "tags": [ "NotificationV1alpha1Uc" ] } }, "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/{name}": { "delete": { "description": "Delete the specified notification.", "operationId": "DeleteSpecifiedNotification", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } }, { "description": "Notification name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "default response" } }, "tags": [ "NotificationV1alpha1Uc" ] } }, "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/{name}/mark-as-read": { "put": { "description": "Mark the specified notification as read.", "operationId": "MarkNotificationAsRead", "parameters": [ { "description": "Username", "in": "path", "name": "username", "required": true, "schema": { "type": "string" } }, { "description": "Notification name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Notification" } } }, "description": "default response" } }, "tags": [ "NotificationV1alpha1Uc" ] } }, "/apis/uc.api.auth.halo.run/v1alpha1/user-connections/{registerId}/disconnect": { "put": { "description": "Disconnect my connection from a third-party platform.", "operationId": "DisconnectMyConnection", "parameters": [ { "description": "The registration ID of the third-party platform.", "in": "path", "name": "registerId", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/UserConnection" } } } }, "description": "default response" } }, "tags": [ "UserConnectionV1alpha1Uc" ] } }, "/apis/uc.api.content.halo.run/v1alpha1/posts": { "get": { "description": "List posts owned by the current user.", "operationId": "ListMyPosts", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Posts filtered by publish phase.", "in": "query", "name": "publishPhase", "schema": { "type": "string", "enum": [ "DRAFT", "PENDING_APPROVAL", "PUBLISHED", "FAILED" ] } }, { "description": "Posts filtered by keyword.", "in": "query", "name": "keyword", "schema": { "type": "string" } }, { "description": "Posts filtered by category including sub-categories.", "in": "query", "name": "categoryWithChildren", "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ListedPostList" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] }, "post": { "description": "Create my post. If you want to create a post with content, please set\n annotation: \"content.halo.run/content-json\" into annotations and refer\n to Content for corresponding data type.\n", "operationId": "CreateMyPost", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] } }, "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}": { "get": { "description": "Get post that belongs to the current user.", "operationId": "GetMyPost", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] }, "put": { "description": "Update my post.", "operationId": "UpdateMyPost", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] } }, "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}/draft": { "get": { "description": "Get my post draft.", "operationId": "GetMyPostDraft", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Should include patched content and raw or not.", "in": "query", "name": "patched", "schema": { "type": "boolean" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] }, "put": { "description": "Update draft of my post. Please make sure set annotation:\n\"content.halo.run/content-json\" into annotations and refer to\nContent for corresponding data type.\n", "operationId": "UpdateMyPostDraft", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] } }, "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}/publish": { "put": { "description": "Publish my post.", "operationId": "PublishMyPost", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] } }, "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}/recycle": { "delete": { "description": "Move my post to recycle bin.", "operationId": "RecycleMyPost", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] } }, "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}/unpublish": { "put": { "description": "Unpublish my post.", "operationId": "UnpublishMyPost", "parameters": [ { "description": "Post name", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Post" } } }, "description": "default response" } }, "tags": [ "PostV1alpha1Uc" ] } }, "/apis/uc.api.content.halo.run/v1alpha1/snapshots/{name}": { "get": { "description": "Get snapshot for one post.", "operationId": "GetSnapshotForPost", "parameters": [ { "description": "Snapshot name.", "in": "path", "name": "name", "required": true, "schema": { "type": "string" } }, { "description": "Post name.", "in": "query", "name": "postName", "required": true, "schema": { "type": "string" } }, { "description": "Should include patched content and raw or not.", "in": "query", "name": "patched", "schema": { "type": "boolean" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Snapshot" } } }, "description": "default response" } }, "tags": [ "SnapshotV1alpha1Uc" ] } }, "/apis/uc.api.halo.run/v1alpha1/annotationsettings": { "get": { "description": "List available AnnotationSettings for the given targetRef. The available AnnotationSettings are determined by the currently activated theme and started plugins.", "operationId": "listAvailableAnnotationSettings", "parameters": [ { "description": "The targetRef of the AnnotationSetting. e.g.: \u0027content.halo.run/Post", "in": "query", "name": "targetRef", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/AnnotationSetting" } } } }, "description": "default response" } }, "tags": [ "AnnotationSettingV1AlphaUc" ] } }, "/apis/uc.api.halo.run/v1alpha1/user-preferences/{group}": { "get": { "description": "Get my preference by group.", "operationId": "getMyPreference", "parameters": [ { "description": "Group of user preference, e.g. `notification`.", "in": "path", "name": "group", "required": true, "schema": { "type": "string" } } ], "responses": { "default": { "content": { "*/*": { "schema": { "type": "object" } } }, "description": "default response" } }, "tags": [ "UserPreferenceV1alpha1Uc" ] }, "put": { "description": "Create or update my preference by group.", "operationId": "updateMyPreference", "parameters": [ { "description": "Group of user preference, e.g. `notification`.", "in": "path", "name": "group", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "*/*": { "schema": { "type": "object" } } }, "required": true }, "responses": { "204": { "description": "No content, preference updated successfully." } }, "tags": [ "UserPreferenceV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/authentications/two-factor/settings": { "get": { "description": "Get Two-factor authentication settings.", "operationId": "GetTwoFactorAuthenticationSettings", "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TwoFactorAuthSettings" } } }, "description": "default response" } }, "tags": [ "TwoFactorAuthV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/authentications/two-factor/settings/disabled": { "put": { "description": "Disable Two-factor authentication", "operationId": "DisableTwoFactor", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PasswordRequest" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TwoFactorAuthSettings" } } }, "description": "default response" } }, "tags": [ "TwoFactorAuthV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/authentications/two-factor/settings/enabled": { "put": { "description": "Enable Two-factor authentication", "operationId": "EnableTwoFactor", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PasswordRequest" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TwoFactorAuthSettings" } } }, "description": "default response" } }, "tags": [ "TwoFactorAuthV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/authentications/two-factor/totp": { "post": { "description": "Configure a TOTP", "operationId": "ConfigurerTotp", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TotpRequest" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TwoFactorAuthSettings" } } }, "description": "default response" } }, "tags": [ "TwoFactorAuthV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/authentications/two-factor/totp/-": { "delete": { "operationId": "DeleteTotp", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PasswordRequest" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TwoFactorAuthSettings" } } }, "description": "default response" } }, "tags": [ "TwoFactorAuthV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/authentications/two-factor/totp/auth-link": { "get": { "description": "Get TOTP auth link, including secret", "operationId": "GetTotpAuthLink", "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/TotpAuthLinkResponse" } } }, "description": "default response" } }, "tags": [ "TwoFactorAuthV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/devices": { "get": { "description": "List all user devices", "operationId": "ListDevices", "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/UserDevice" } } } }, "description": "default response" } }, "tags": [ "DeviceV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/devices/{deviceId}": { "delete": { "description": "Revoke a own device", "operationId": "RevokeDevice", "parameters": [ { "description": "Device ID", "in": "path", "name": "deviceId", "required": true, "schema": { "type": "string" } } ], "responses": { "204 NO_CONTENT": { "description": "default response" } }, "tags": [ "DeviceV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens": { "get": { "description": "Obtain PAT list.", "operationId": "ObtainPats", "responses": { "default": { "content": { "*/*": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/PersonalAccessToken" } } } }, "description": "default response" } }, "tags": [ "PersonalAccessTokenV1alpha1Uc" ] }, "post": { "description": "Generate a PAT.", "operationId": "GeneratePat", "requestBody": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/PersonalAccessToken" } } }, "description": "default response" } }, "tags": [ "PersonalAccessTokenV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens/{name}": { "delete": { "description": "Delete a PAT", "operationId": "DeletePat", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": {}, "tags": [ "PersonalAccessTokenV1alpha1Uc" ] }, "get": { "description": "Obtain a PAT.", "operationId": "ObtainPat", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": {}, "tags": [ "PersonalAccessTokenV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens/{name}/actions/restoration": { "put": { "description": "Restore a PAT.", "operationId": "RestorePat", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": {}, "tags": [ "PersonalAccessTokenV1alpha1Uc" ] } }, "/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens/{name}/actions/revocation": { "put": { "description": "Revoke a PAT", "operationId": "RevokePat", "parameters": [ { "in": "path", "name": "name", "required": true, "schema": { "type": "string" } } ], "responses": {}, "tags": [ "PersonalAccessTokenV1alpha1Uc" ] } }, "/apis/uc.api.storage.halo.run/v1alpha1/attachments": { "get": { "description": "List attachments of the current user uploaded.", "operationId": "ListMyAttachments", "parameters": [ { "description": "Page number. Default is 0.", "in": "query", "name": "page", "schema": { "type": "integer", "format": "int32" } }, { "description": "Size number. Default is 0.", "in": "query", "name": "size", "schema": { "type": "integer", "format": "int32" } }, { "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", "name": "labelSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", "name": "fieldSelector", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", "name": "sort", "schema": { "type": "array", "items": { "type": "string" } } }, { "description": "Filter attachments without group. This parameter will ignore group parameter.", "in": "query", "name": "ungrouped", "schema": { "type": "boolean" } }, { "description": "Keyword for searching.", "in": "query", "name": "keyword", "schema": { "type": "string" } }, { "description": "Acceptable media types.", "in": "query", "name": "accepts", "schema": { "type": "array", "items": { "type": "string" } } } ], "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/AttachmentList" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Uc" ] }, "post": { "deprecated": true, "description": "Create attachment for the given post. Deprecated in favor of /attachments/-/upload.", "operationId": "CreateAttachmentForPost", "parameters": [ { "description": "Wait for permalink.", "in": "query", "name": "waitForPermalink", "schema": { "type": "boolean" } } ], "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PostAttachmentRequest" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Uc" ] } }, "/apis/uc.api.storage.halo.run/v1alpha1/attachments/-/upload": { "post": { "description": "Upload attachment to user center storage.", "operationId": "UploadAttachmentForUc", "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/UploadForm" } } } }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Uc" ] } }, "/apis/uc.api.storage.halo.run/v1alpha1/attachments/-/upload-from-url": { "post": { "deprecated": true, "description": "Upload attachment from the given URL.\nDeprecated in favor of /attachments/-/upload.", "operationId": "ExternalTransferAttachment_1", "parameters": [ { "description": "Wait for permalink.", "in": "query", "name": "waitForPermalink", "schema": { "type": "boolean" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UcUploadFromUrlRequest" } } }, "required": true }, "responses": { "default": { "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Attachment" } } }, "description": "default response" } }, "tags": [ "AttachmentV1alpha1Uc" ] } } }, "components": { "schemas": { "AddOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "add" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "AnnotationSetting": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/AnnotationSettingSpec" } } }, "AnnotationSettingSpec": { "required": [ "formSchema", "targetRef" ], "type": "object", "properties": { "formSchema": { "minLength": 1, "type": "array", "items": { "minLength": 1, "type": "object" } }, "targetRef": { "$ref": "#/components/schemas/GroupKind" } } }, "Attachment": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/AttachmentSpec" }, "status": { "$ref": "#/components/schemas/AttachmentStatus" } } }, "AttachmentList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Attachment" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "AttachmentSpec": { "type": "object", "properties": { "displayName": { "type": "string", "description": "Display name of attachment" }, "groupName": { "type": "string", "description": "Group name" }, "mediaType": { "type": "string", "description": "Media type of attachment" }, "ownerName": { "type": "string", "description": "Name of User who uploads the attachment" }, "policyName": { "type": "string", "description": "Policy name" }, "size": { "minimum": 0, "type": "integer", "description": "Size of attachment. Unit is Byte", "format": "int64" }, "tags": { "uniqueItems": true, "type": "array", "description": "Tags of attachment", "items": { "type": "string", "description": "Tag name" } } } }, "AttachmentStatus": { "type": "object", "properties": { "permalink": { "type": "string", "description": "Permalink of attachment.\nIf it is in local storage, the public URL will be set.\nIf it is in s3 storage, the Object URL will be set.\n" }, "thumbnails": { "type": "object", "additionalProperties": { "type": "string" } } } }, "Category": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/CategorySpec" }, "status": { "$ref": "#/components/schemas/CategoryStatus" } } }, "CategorySpec": { "required": [ "displayName", "priority", "slug" ], "type": "object", "properties": { "children": { "type": "array", "items": { "type": "string" } }, "cover": { "type": "string" }, "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "hideFromList": { "type": "boolean", "description": "\u003cp\u003eWhether to hide the category from the category list.\u003c/p\u003e\n \u003cp\u003eWhen set to true, the category including its subcategories and related posts will\n not be displayed in the category list, but it can still be accessed by permalink.\u003c/p\u003e\n \u003cp\u003eLimitation: It only takes effect on the theme-side categorized list and it only\n allows to be set to true on the first level(root node) of categories.\u003c/p\u003e" }, "postTemplate": { "maxLength": 255, "type": "string", "description": "\u003cp\u003eUsed to specify the template for the posts associated with the category.\u003c/p\u003e\n \u003cp\u003eThe priority is not as high as that of the post.\u003c/p\u003e\n \u003cp\u003eIf the post also specifies a template, the post\u0027s template will prevail.\u003c/p\u003e" }, "preventParentPostCascadeQuery": { "type": "boolean", "description": "\u003cp\u003eif a category is queried for related posts, the default behavior is to\n query all posts under the category including its subcategories, but if this field is\n set to true, cascade query behavior will be terminated here.\u003c/p\u003e\n \u003cp\u003eFor example, if a category has subcategories A and B, and A has subcategories C and\n D and C marked this field as true, when querying posts under A category,all posts under A\n and B will be queried, but C and D will not be queried.\u003c/p\u003e" }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "slug": { "minLength": 1, "type": "string" }, "template": { "maxLength": 255, "type": "string" } } }, "CategoryStatus": { "type": "object", "properties": { "permalink": { "type": "string" }, "postCount": { "type": "integer", "description": "包括当前和其下所有层级的文章数量 (depth\u003dmax).", "format": "int32" }, "visiblePostCount": { "type": "integer", "description": "包括当前和其下所有层级的已发布且公开的文章数量 (depth\u003dmax).", "format": "int32" } } }, "Condition": { "required": [ "lastTransitionTime", "status", "type" ], "type": "object", "properties": { "lastTransitionTime": { "type": "string", "description": "Last time the condition transitioned from one status to another.", "format": "date-time" }, "message": { "maxLength": 32768, "type": "string", "description": "Human-readable message indicating details about last transition.\n This may be an empty string." }, "reason": { "maxLength": 1024, "pattern": "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$", "type": "string", "description": "Unique, one-word, CamelCase reason for the condition\u0027s last transition." }, "status": { "type": "string", "description": "Status is the status of the condition. Can be True, False, Unknown.", "enum": [ "TRUE", "FALSE", "UNKNOWN" ] }, "type": { "maxLength": 316, "pattern": "^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$", "type": "string", "description": "type of condition in CamelCase or in foo.example.com/CamelCase.\n example: Ready, Initialized.\n maxLength: 316." } }, "description": "EqualsAndHashCode 排除了lastTransitionTime否则失败时,lastTransitionTime 会被更新\n 导致 equals 为 false,一直被加入队列." }, "Contributor": { "type": "object", "properties": { "avatar": { "type": "string" }, "displayName": { "type": "string" }, "name": { "type": "string" } }, "description": "Contributor from user." }, "CopyOperation": { "required": [ "op", "from", "path" ], "type": "object", "properties": { "from": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "op": { "type": "string", "enum": [ "copy" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "Device": { "required": [ "apiVersion", "kind", "metadata", "spec", "status" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/DeviceSpec" }, "status": { "$ref": "#/components/schemas/DeviceStatus" } } }, "DeviceSpec": { "required": [ "ipAddress", "principalName", "sessionId" ], "type": "object", "properties": { "ipAddress": { "maxLength": 129, "type": "string" }, "lastAccessedTime": { "type": "string", "format": "date-time" }, "lastAuthenticatedTime": { "type": "string", "format": "date-time" }, "principalName": { "minLength": 1, "type": "string" }, "rememberMeSeriesId": { "type": "string" }, "sessionId": { "minLength": 1, "type": "string" }, "userAgent": { "maxLength": 500, "type": "string" } } }, "DeviceStatus": { "type": "object", "properties": { "browser": { "type": "string" }, "os": { "type": "string" } } }, "Excerpt": { "required": [ "autoGenerate" ], "type": "object", "properties": { "autoGenerate": { "type": "boolean", "default": true }, "raw": { "type": "string" } } }, "GroupKind": { "type": "object", "properties": { "group": { "type": "string", "description": "is group name of Extension." }, "kind": { "type": "string", "description": "is kind name of Extension." } }, "description": "GroupKind contains group and kind data only." }, "JsonPatch": { "minItems": 1, "uniqueItems": true, "type": "array", "description": "JSON schema for JSONPatch operations", "items": { "oneOf": [ { "$ref": "#/components/schemas/AddOperation" }, { "$ref": "#/components/schemas/ReplaceOperation" }, { "$ref": "#/components/schemas/TestOperation" }, { "$ref": "#/components/schemas/RemoveOperation" }, { "$ref": "#/components/schemas/MoveOperation" }, { "$ref": "#/components/schemas/CopyOperation" } ] } }, "ListedPost": { "required": [ "categories", "contributors", "owner", "post", "stats", "tags" ], "type": "object", "properties": { "categories": { "type": "array", "items": { "$ref": "#/components/schemas/Category" } }, "contributors": { "type": "array", "items": { "$ref": "#/components/schemas/Contributor" } }, "owner": { "$ref": "#/components/schemas/Contributor" }, "post": { "$ref": "#/components/schemas/Post" }, "stats": { "$ref": "#/components/schemas/Stats" }, "tags": { "type": "array", "items": { "$ref": "#/components/schemas/Tag" } } }, "description": "A chunk of items." }, "ListedPostList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/ListedPost" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "MarkSpecifiedRequest": { "type": "object", "properties": { "names": { "type": "array", "items": { "type": "string" } } } }, "Metadata": { "required": [ "name" ], "type": "object", "properties": { "annotations": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Annotations are like key-value format." }, "creationTimestamp": { "type": "string", "description": "Creation timestamp of the Extension.", "format": "date-time", "nullable": true }, "deletionTimestamp": { "type": "string", "description": "Deletion timestamp of the Extension.", "format": "date-time", "nullable": true }, "finalizers": { "uniqueItems": true, "type": "array", "nullable": true, "items": { "type": "string", "nullable": true } }, "generateName": { "type": "string", "description": "The name field will be generated automatically according to the given generateName field" }, "labels": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Labels are like key-value format." }, "name": { "type": "string", "description": "Metadata name" }, "version": { "type": "integer", "description": "Current version of the Extension. It will be bumped up every update.", "format": "int64", "nullable": true } }, "description": "Metadata of Extension." }, "MoveOperation": { "required": [ "op", "from", "path" ], "type": "object", "properties": { "from": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "op": { "type": "string", "enum": [ "move" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "Notification": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/NotificationSpec" } }, "description": "\u003cp\u003e{@link Notification Notification} is a custom extension that used to store notification information for\n inner use, it\u0027s on-site notification.\u003c/p\u003e\n\n \u003cp\u003eSupports the following operations:\u003c/p\u003e\n \u003cul\u003e\n \u003cli\u003eMarked as read: {@link NotificationSpec#setUnread(boolean) NotificationSpec#setUnread(boolean)}\u003c/li\u003e\n \u003cli\u003eGet the last read time: {@link NotificationSpec#getLastReadAt NotificationSpec#getLastReadAt()}\u003c/li\u003e\n \u003cli\u003eFilter by recipient: {@link NotificationSpec#getRecipient NotificationSpec#getRecipient()}\u003c/li\u003e\n \u003c/ul\u003e" }, "NotificationList": { "required": [ "first", "hasNext", "hasPrevious", "items", "last", "page", "size", "total", "totalPages" ], "type": "object", "properties": { "first": { "type": "boolean", "description": "Indicates whether current page is the first page." }, "hasNext": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "hasPrevious": { "type": "boolean", "description": "Indicates whether current page has previous page." }, "items": { "type": "array", "description": "A chunk of items.", "items": { "$ref": "#/components/schemas/Notification" } }, "last": { "type": "boolean", "description": "Indicates whether current page is the last page." }, "page": { "type": "integer", "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, "size": { "type": "integer", "description": "Size of each page. If not set or equal to 0, it means no pagination.", "format": "int32" }, "total": { "type": "integer", "description": "Total elements.", "format": "int64" }, "totalPages": { "type": "integer", "description": "Indicates total pages.", "format": "int64" } } }, "NotificationSpec": { "required": [ "htmlContent", "rawContent", "reason", "recipient", "title" ], "type": "object", "properties": { "htmlContent": { "type": "string" }, "lastReadAt": { "type": "string", "format": "date-time" }, "rawContent": { "type": "string" }, "reason": { "minLength": 1, "type": "string", "description": "The name of reason" }, "recipient": { "minLength": 1, "type": "string", "description": "The name of user" }, "title": { "minLength": 1, "type": "string" }, "unread": { "type": "boolean" } } }, "NotifierInfo": { "type": "object", "properties": { "description": { "type": "string" }, "displayName": { "type": "string" }, "name": { "type": "string" } } }, "PasswordRequest": { "required": [ "password" ], "type": "object", "properties": { "password": { "minLength": 1, "type": "string" } } }, "PatSpec": { "required": [ "name", "tokenId", "username" ], "type": "object", "properties": { "description": { "type": "string" }, "expiresAt": { "type": "string", "format": "date-time" }, "lastUsed": { "type": "string", "format": "date-time" }, "name": { "type": "string" }, "revoked": { "type": "boolean" }, "revokesAt": { "type": "string", "format": "date-time" }, "roles": { "type": "array", "items": { "type": "string" } }, "scopes": { "type": "array", "items": { "type": "string" } }, "tokenId": { "type": "string" }, "username": { "type": "string" } } }, "PersonalAccessToken": { "required": [ "apiVersion", "kind", "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/PatSpec" } } }, "Post": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/PostSpec" }, "status": { "$ref": "#/components/schemas/PostStatus" } }, "description": "\u003cp\u003ePost extension.\u003c/p\u003e" }, "PostAttachmentRequest": { "required": [ "file" ], "type": "object", "properties": { "file": { "type": "string", "format": "binary" }, "postName": { "type": "string", "description": "Post name." }, "singlePageName": { "type": "string", "description": "Single page name." } } }, "PostSpec": { "required": [ "allowComment", "deleted", "excerpt", "pinned", "priority", "publish", "slug", "title", "visible" ], "type": "object", "properties": { "allowComment": { "type": "boolean", "default": true }, "baseSnapshot": { "type": "string" }, "categories": { "type": "array", "items": { "type": "string" } }, "cover": { "type": "string" }, "deleted": { "type": "boolean", "default": false }, "excerpt": { "$ref": "#/components/schemas/Excerpt" }, "headSnapshot": { "type": "string" }, "htmlMetas": { "type": "array", "items": { "type": "object", "additionalProperties": { "type": "string" } } }, "owner": { "type": "string" }, "pinned": { "type": "boolean", "default": false }, "priority": { "type": "integer", "format": "int32", "default": 0 }, "publish": { "type": "boolean", "default": false }, "publishTime": { "type": "string", "format": "date-time" }, "releaseSnapshot": { "type": "string", "description": "文章引用到的已发布的内容,用于主题端显示." }, "slug": { "minLength": 1, "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } }, "template": { "type": "string" }, "title": { "minLength": 1, "type": "string" }, "visible": { "type": "string", "enum": [ "PUBLIC", "INTERNAL", "PRIVATE" ], "default": "PUBLIC" } } }, "PostStatus": { "type": "object", "properties": { "commentsCount": { "type": "integer", "format": "int32" }, "conditions": { "type": "array", "properties": { "empty": { "type": "boolean" } }, "items": { "$ref": "#/components/schemas/Condition" } }, "contributors": { "type": "array", "items": { "type": "string" } }, "excerpt": { "type": "string" }, "hideFromList": { "type": "boolean", "description": "see {@link Category.CategorySpec#isHideFromList Category.CategorySpec#isHideFromList()}." }, "inProgress": { "type": "boolean" }, "lastModifyTime": { "type": "string", "format": "date-time" }, "observedVersion": { "type": "integer", "format": "int64" }, "permalink": { "type": "string" }, "phase": { "type": "string" } } }, "ReasonTypeInfo": { "type": "object", "properties": { "description": { "type": "string" }, "displayName": { "type": "string" }, "name": { "type": "string" }, "uiPermissions": { "uniqueItems": true, "type": "array", "items": { "type": "string" } } } }, "ReasonTypeNotifierCollectionRequest": { "required": [ "reasonTypeNotifiers" ], "type": "object", "properties": { "reasonTypeNotifiers": { "type": "array", "items": { "$ref": "#/components/schemas/ReasonTypeNotifierRequest" } } } }, "ReasonTypeNotifierMatrix": { "type": "object", "properties": { "notifiers": { "type": "array", "items": { "$ref": "#/components/schemas/NotifierInfo" } }, "reasonTypes": { "type": "array", "items": { "$ref": "#/components/schemas/ReasonTypeInfo" } }, "stateMatrix": { "type": "array", "items": { "type": "array", "items": { "type": "boolean" } } } } }, "ReasonTypeNotifierRequest": { "type": "object", "properties": { "notifiers": { "type": "array", "items": { "type": "string" } }, "reasonType": { "type": "string" } } }, "Ref": { "required": [ "group", "kind", "name" ], "type": "object", "properties": { "group": { "type": "string", "description": "Extension group" }, "kind": { "type": "string", "description": "Extension kind" }, "name": { "type": "string", "description": "Extension name. This field is mandatory" }, "version": { "type": "string", "description": "Extension version" } }, "description": "Extension reference object. The name is mandatory" }, "RemoveOperation": { "required": [ "op", "path" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "remove" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" } } }, "ReplaceOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "replace" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "SnapShotSpec": { "required": [ "owner", "rawType", "subjectRef" ], "type": "object", "properties": { "contentPatch": { "type": "string" }, "contributors": { "uniqueItems": true, "type": "array", "items": { "type": "string" } }, "lastModifyTime": { "type": "string", "format": "date-time" }, "owner": { "minLength": 1, "type": "string" }, "parentSnapshotName": { "type": "string" }, "rawPatch": { "type": "string" }, "rawType": { "maxLength": 50, "minLength": 1, "type": "string", "description": "such as: markdown | html | json | asciidoc | latex." }, "subjectRef": { "$ref": "#/components/schemas/Ref" } } }, "Snapshot": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/SnapShotSpec" } } }, "Stats": { "type": "object", "properties": { "approvedComment": { "type": "integer", "format": "int32" }, "totalComment": { "type": "integer", "format": "int32" }, "upvote": { "type": "integer", "format": "int32" }, "visit": { "type": "integer", "format": "int32" } }, "description": "Stats value object." }, "Tag": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/TagSpec" }, "status": { "$ref": "#/components/schemas/TagStatus" } } }, "TagSpec": { "required": [ "displayName", "slug" ], "type": "object", "properties": { "color": { "pattern": "^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$", "type": "string", "description": "Color regex explanation.\n \u003cpre\u003e\n ^ # start of the line\n # # start with a number sign `#`\n ( # start of (group 1)\n [a-fA-F0-9]{6} # support z-f, A-F and 0-9, with a length of 6\n | # or\n [a-fA-F0-9]{3} # support z-f, A-F and 0-9, with a length of 3\n ) # end of (group 1)\n $ # end of the line\n \u003c/pre\u003e" }, "cover": { "type": "string" }, "description": { "type": "string" }, "displayName": { "minLength": 1, "type": "string" }, "slug": { "minLength": 1, "type": "string" } } }, "TagStatus": { "type": "object", "properties": { "observedVersion": { "type": "integer", "format": "int64" }, "permalink": { "type": "string" }, "postCount": { "type": "integer", "format": "int32" }, "visiblePostCount": { "type": "integer", "format": "int32" } } }, "TestOperation": { "required": [ "op", "path", "value" ], "type": "object", "properties": { "op": { "type": "string", "enum": [ "test" ] }, "path": { "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", "description": "A JSON Pointer path pointing to the location to move/copy from.", "example": "/a/b/c" }, "value": { "description": "Value can be any JSON value" } } }, "TotpAuthLinkResponse": { "type": "object", "properties": { "authLink": { "type": "string", "description": "QR Code with base64 encoded.", "format": "uri" }, "rawSecret": { "type": "string" } } }, "TotpRequest": { "required": [ "code", "password", "secret" ], "type": "object", "properties": { "code": { "type": "string" }, "password": { "minLength": 1, "type": "string" }, "secret": { "minLength": 1, "type": "string" } } }, "TwoFactorAuthSettings": { "type": "object", "properties": { "available": { "type": "boolean", "description": "Check if 2FA is available." }, "emailVerified": { "type": "boolean" }, "enabled": { "type": "boolean" }, "totpConfigured": { "type": "boolean" } } }, "UcUploadFromUrlRequest": { "required": [ "url" ], "type": "object", "properties": { "filename": { "type": "string", "description": "Custom file name" }, "url": { "type": "string", "format": "url" } } }, "UploadForm": { "type": "object", "properties": { "file": { "type": "string", "description": "The file to upload. If not provided, the url will be used.", "format": "binary" }, "filename": { "type": "string", "description": "The filename to use when uploading from url. If not provided, the filename will be\n extracted from the url." }, "url": { "type": "string", "description": "The url to upload from. If not provided, the file will be used." } }, "description": "Upload form from console. The file and url are mutually exclusive. If both are provided,\n the file will be used." }, "UserConnection": { "required": [ "apiVersion", "kind", "metadata", "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, "spec": { "$ref": "#/components/schemas/UserConnectionSpec" } }, "description": "User connection extension." }, "UserConnectionSpec": { "required": [ "providerUserId", "registrationId", "username" ], "type": "object", "properties": { "providerUserId": { "type": "string", "description": "The unique identifier for the user\u0027s connection to the OAuth provider.\n for example, the user\u0027s GitHub id." }, "registrationId": { "type": "string", "description": "The name of the OAuth provider (e.g. Google, Facebook, Twitter)." }, "updatedAt": { "type": "string", "description": "The time when the user connection was last updated.", "format": "date-time" }, "username": { "type": "string", "description": "The {@link Metadata#getName Metadata#getName()} of the user associated with the OAuth connection." } } }, "UserDevice": { "required": [ "active", "currentDevice", "device" ], "type": "object", "properties": { "active": { "type": "boolean" }, "currentDevice": { "type": "boolean" }, "device": { "$ref": "#/components/schemas/Device" } } } }, "securitySchemes": { "basicAuth": { "scheme": "basic", "type": "http" }, "bearerAuth": { "bearerFormat": "JWT", "scheme": "bearer", "type": "http" } } } } ================================================ FILE: application/build.gradle ================================================ import de.undercouch.gradle.tasks.download.Download import org.gradle.crypto.checksum.Checksum import org.springframework.boot.gradle.tasks.bundling.BootBuildImage import org.springframework.boot.gradle.tasks.bundling.BootJar import org.springframework.util.StringUtils plugins { id 'checkstyle' id 'java' id 'idea' id 'jacoco' alias(libs.plugins.spring.boot) alias(libs.plugins.spring.dependency.management) alias(libs.plugins.git.properties) alias(libs.plugins.undercouch.download) alias(libs.plugins.lombok) alias(libs.plugins.checksum) alias(libs.plugins.springdoc.openapi) alias(libs.plugins.versions) } group = 'run.halo.app' tasks.withType(JavaCompile).configureEach { options.release = 21 options.encoding = 'UTF-8' } java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } idea { module { resourceDirs += file("../ui/build/dist/") } } checkstyle { toolVersion = libs.versions.checkstyle.get() showViolations = false ignoreFailures = false } repositories { mavenCentral() flatDir { dir layout.projectDirectory.dir('libs') } } configurations { compileOnly { extendsFrom annotationProcessor } } springBoot { buildInfo { properties { artifact = 'halo' name = 'halo' } } } bootJar { archiveBaseName = 'halo' manifest { attributes 'Implementation-Title': 'Halo Application', 'Implementation-Vendor': 'Halo OSS Team' } duplicatesStrategy = DuplicatesStrategy.EXCLUDE } gitProperties { dotGitDirectory = layout.settingsDirectory.dir('.git') } tasks.named('jar') { enabled = false } dependencies { implementation project(':api') implementation libs.r2dbc.migrate.starter // Fix https://github.com/halo-dev/halo/issues/7289 // Build from https://github.com/halo-dev/thymeleaf/commit/d23498ea297059deff04ba8c3578de59c73ccf03 runtimeOnly ':thymeleaf:3.1.3.RELEASE' runtimeOnly ':thymeleaf-spring6:3.1.3.RELEASE' annotationProcessor platform(project(':platform:application')) annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" annotationProcessor "org.springframework:spring-context-indexer" testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-webtestclient' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'io.projectreactor:reactor-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' annotationProcessor 'com.github.therapi:therapi-runtime-javadoc-scribe' // webjars runtimeOnly 'org.webjars.npm:jsencrypt:3.5.4' runtimeOnly 'org.webjars.npm:normalize.css:8.0.1' } tasks.register('createChecksums', Checksum) { dependsOn tasks.named('bootJar') inputFiles.setFrom(layout.buildDirectory.files('libs')) outputDirectory = layout.buildDirectory.dir("libs") checksumAlgorithm = Checksum.Algorithm.SHA256 } tasks.register('copyUiDist', Copy) { from project(':ui').layout.buildDirectory.dir('dist') into layout.buildDirectory.dir('resources/main') mustRunAfter ':ui:doBuild' } tasks.named('classes') { dependsOn tasks.named('copyUiDist') mustRunAfter tasks.named('downloadPluginPresets') } tasks.named('build') { dependsOn tasks.named('createChecksums') } tasks.named('test', Test) { useJUnitPlatform() maxHeapSize = '1G' finalizedBy jacocoTestReport } tasks.named('jacocoTestReport', JacocoReport) { reports { xml.required = true html.required = false } } tasks.register('downloadPluginPresets', Download) { def presetPluginUrls = [ 'https://github.com/halo-dev/plugin-comment-widget/releases/download/v3.0.0/plugin-comment-widget-3.0.0.jar' : 'plugin-comment-widget.jar', 'https://github.com/halo-dev/plugin-search-widget/releases/download/v1.7.0/plugin-search-widget-1.7.0.jar' : 'plugin-search-widget.jar', 'https://github.com/halo-dev/plugin-sitemap/releases/download/v1.1.2/plugin-sitemap-1.1.2.jar' : 'plugin-sitemap.jar', 'https://github.com/halo-dev/plugin-feed/releases/download/v1.5.0/plugin-feed-1.5.0.jar' : 'plugin-feed.jar', 'https://github.com/halo-sigs/plugin-shiki/releases/download/v1.0.11/plugin-shiki-1.0.11.jar' : 'plugin-shiki.jar', 'https://github.com/halo-sigs/plugin-editor-hyperlink-card/releases/download/v1.6.0/plugin-editor-hyperlink-card-1.6.0.jar': 'plugin-editor-hyperlink-card.jar', // Currently, plugin-app-store is not open source, so we need to download it from the official website. // Please see https://github.com/halo-dev/plugin-app-store/issues/55 // https://www.halo.run/store/apps/app-VYJbF/releases/app-release-qafgml3o 'https://www.halo.run/store/apps/app-VYJbF/releases/download/app-release-qafgml3o/assets/app-release-qafgml3o-wt4v7err' : 'appstore.jar', ] src presetPluginUrls.keySet() dest layout.buildDirectory.dir('resources/main/presets/plugins') eachFile { f -> f.name = presetPluginUrls[f.sourceURL.toString()] } } openApi { outputDir = file("$rootDir/api-docs/openapi/v3_0") groupedApiMappings = [ 'http://localhost:8091/v3/api-docs/apis_aggregated.api_v1alpha1': 'aggregated.json', 'http://localhost:8091/v3/api-docs/apis_public.api_v1alpha1' : 'apis_public.api_v1alpha1.json', 'http://localhost:8091/v3/api-docs/apis_console.api_v1alpha1' : 'apis_console.api_v1alpha1.json', 'http://localhost:8091/v3/api-docs/apis_uc.api_v1alpha1' : 'apis_uc.api_v1alpha1.json', 'http://localhost:8091/v3/api-docs/apis_extension.api_v1alpha1' : 'apis_extension.api_v1alpha1.json', ] customBootRun { args = ['--server.port=8091', '--spring.profiles.active=doc', "--halo.work-dir=${layout.buildDirectory.get()}/tmp/workdir-for-generating-apidocs"] } } tasks.named('forkedSpringBootRun') { dependsOn ':api:jar' } tasks.named('generateOpenApiDocs') { outputs.upToDateWhen { false } } def branchProvider = providers.exec { commandLine 'git', 'rev-parse', '--abbrev-ref', 'HEAD' } .standardOutput .asText .map { it.trim().replaceAll("/", "-") } def abbreviatedIdProvider = providers.exec { commandLine 'git', 'rev-parse', '--short', 'HEAD' } .standardOutput .asText .map { it.trim() } def isRelease = project.hasProperty('release') && Boolean.parseBoolean(project.property('release').toString()) def repoProvider = providers.provider { isRelease ? 'halo' : 'halo-dev' } def tagProvider = providers.provider { isRelease ? "${project.version}-cnb" : abbreviatedIdProvider.map { "sha-${it}-cnb" }.get() } def branchTagProvider = branchProvider.map { "${it}-cnb" } def archiveFileProvider = tasks.named('bootJar', BootJar). flatMap { it.archiveFile } tasks.register('publishToGhcr', BootBuildImage) { def ghcrUserProvider = providers.environmentVariable('GHCR_USERNAME') def ghcrTokenProvider = providers.environmentVariable('GHCR_TOKEN') group = 'publishing' description = 'Build and publish the Docker image to GitHub Container Registry.' imageName = "ghcr.io/halo-dev/${repoProvider.get()}:${tagProvider.get()}" publish = ghcrTokenProvider.map { StringUtils.hasText(it) }.orElse(false) if (!isRelease) { tags.add("ghcr.io/halo-dev/${repoProvider.get()}:${branchTagProvider.get()}") } docker { publishRegistry { username = ghcrUserProvider password = ghcrTokenProvider } } archiveFile.set(archiveFileProvider) } tasks.register('publishToDockerHub', BootBuildImage) { def dockerUserProvider = providers.environmentVariable('DOCKER_USERNAME') def dockerTokenProvider = providers.environmentVariable('DOCKER_TOKEN') group = 'publishing' description = 'Build and publish the Docker image to Docker Hub.' imageName = "halohub/${repoProvider.get()}:${tagProvider.get()}" publish = dockerTokenProvider.map { StringUtils.hasText(it) }.orElse(false) if (!isRelease) { tags.add("halohub/${repoProvider.get()}:${branchTagProvider.get()}") } docker { publishRegistry { username = dockerUserProvider password = dockerTokenProvider } } archiveFile.set(archiveFileProvider) } tasks.register('publishToAllRegistries') { group = 'publishing' description = 'Build and publish the Docker image to all configured registries.' dependsOn tasks.named('publishToGhcr'), tasks.named('publishToDockerHub') } ================================================ FILE: application/src/main/java/run/halo/app/Application.java ================================================ package run.halo.app; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.boot.integration.autoconfigure.IntegrationAutoConfiguration; import org.springframework.scheduling.annotation.EnableScheduling; /** * Halo main class. * * @author ryanwang * @author JohnNiang * @author guqing * @date 2017-11-14 */ @EnableScheduling @SpringBootApplication(scanBasePackages = "run.halo.app", exclude = IntegrationAutoConfiguration.class) @ConfigurationPropertiesScan(basePackages = "run.halo.app.infra.properties") public class Application { public static void main(String[] args) { new SpringApplicationBuilder(Application.class) .applicationStartup(new BufferingApplicationStartup(1024)) .run(args); } } ================================================ FILE: application/src/main/java/run/halo/app/content/AbstractContentService.java ================================================ package run.halo.app.content; import java.security.Principal; import java.time.Duration; import java.time.Instant; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Sort; import org.springframework.lang.Nullable; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.util.Assert; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.extension.index.query.Queries; import run.halo.app.extension.router.selector.FieldSelector; /** * Abstract Service for {@link Snapshot}. * * @author guqing * @since 2.0.0 */ @Slf4j @AllArgsConstructor public abstract class AbstractContentService { private final ReactiveExtensionClient client; public Mono getContent(String snapshotName, String baseSnapshotName) { if (StringUtils.isBlank(snapshotName) || StringUtils.isBlank(baseSnapshotName)) { return Mono.empty(); } // TODO: refactor this method to use client.get instead of fetch but please be careful return client.fetch(Snapshot.class, baseSnapshotName) .doOnNext(this::checkBaseSnapshot) .flatMap(baseSnapshot -> { if (StringUtils.equals(snapshotName, baseSnapshotName)) { var contentWrapper = ContentWrapper.patchSnapshot(baseSnapshot, baseSnapshot); return Mono.just(contentWrapper); } return client.fetch(Snapshot.class, snapshotName) .map(snapshot -> ContentWrapper.patchSnapshot(snapshot, baseSnapshot)); }) .switchIfEmpty(Mono.defer(() -> { log.error("The content snapshot [{}] or base snapshot [{}] not found.", snapshotName, baseSnapshotName); return Mono.empty(); })); } protected void checkBaseSnapshot(Snapshot snapshot) { Assert.notNull(snapshot, "The snapshot must not be null."); if (!Snapshot.isBaseSnapshot(snapshot)) { throw new IllegalArgumentException( String.format("The snapshot [%s] is not a base snapshot.", snapshot.getMetadata().getName())); } } protected Mono draftContent(@Nullable String baseSnapshotName, ContentRequest contentRequest, @Nullable String parentSnapshotName) { return create(baseSnapshotName, contentRequest, parentSnapshotName) .flatMap(head -> { String baseSnapshotNameToUse = StringUtils.defaultIfBlank(baseSnapshotName, head.getMetadata().getName()); return restoredContent(baseSnapshotNameToUse, head); }); } protected Mono draftContent(String baseSnapshotName, ContentRequest content) { return this.draftContent(baseSnapshotName, content, content.headSnapshotName()); } private Mono create(@Nullable String baseSnapshotName, ContentRequest contentRequest, @Nullable String parentSnapshotName) { Snapshot snapshot = contentRequest.toSnapshot(); snapshot.getMetadata().setName(UUID.randomUUID().toString()); snapshot.getSpec().setParentSnapshotName(parentSnapshotName); return client.fetch(Snapshot.class, baseSnapshotName) .doOnNext(this::checkBaseSnapshot) .defaultIfEmpty(snapshot) .map(baseSnapshot -> determineRawAndContentPatch(snapshot, baseSnapshot, contentRequest) ) .flatMap(source -> getContextUsername() .doOnNext(username -> { Snapshot.addContributor(source, username); source.getSpec().setOwner(username); }) .thenReturn(source) ) .flatMap(client::create); } protected Mono updateContent(String baseSnapshotName, ContentRequest contentRequest) { Assert.notNull(contentRequest, "The contentRequest must not be null"); Assert.notNull(baseSnapshotName, "The baseSnapshotName must not be null"); Assert.notNull(contentRequest.headSnapshotName(), "The headSnapshotName must not be null"); return Mono.defer(() -> client.fetch(Snapshot.class, contentRequest.headSnapshotName()) .flatMap(headSnapshot -> { var oldVersion = contentRequest.version(); var version = headSnapshot.getMetadata().getVersion(); if (hasConflict(oldVersion, version)) { // draft a new snapshot as the head snapshot return create(baseSnapshotName, contentRequest, contentRequest.headSnapshotName()); } return Mono.just(headSnapshot); }) .flatMap(headSnapshot -> client.fetch(Snapshot.class, baseSnapshotName) .map(baseSnapshot -> determineRawAndContentPatch(headSnapshot, baseSnapshot, contentRequest)) ) .flatMap(headSnapshot -> getContextUsername() .doOnNext(username -> Snapshot.addContributor(headSnapshot, username)) .thenReturn(headSnapshot) ) .flatMap(client::update) ) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(throwable -> throwable instanceof OptimisticLockingFailureException)) .flatMap(head -> restoredContent(baseSnapshotName, head)); } protected Flux listSnapshotsBy(Ref ref) { var snapshotListOptions = new ListOptions(); var query = Queries.isNull("metadata.deletionTimestamp") .and(Queries.equal("spec.subjectRef", Snapshot.toSubjectRefKey(ref))); snapshotListOptions.setFieldSelector(FieldSelector.of(query)); var sort = Sort.by("metadata.creationTimestamp", "metadata.name").descending(); return client.listAll(Snapshot.class, snapshotListOptions, sort); } boolean hasConflict(Long oldVersion, Long newVersion) { return oldVersion != null && !newVersion.equals(oldVersion); } protected Mono restoredContent(String baseSnapshotName, Snapshot headSnapshot) { return client.fetch(Snapshot.class, baseSnapshotName) .doOnNext(this::checkBaseSnapshot) .map(baseSnapshot -> ContentWrapper.patchSnapshot(headSnapshot, baseSnapshot)); } protected Snapshot determineRawAndContentPatch(Snapshot snapshotToUse, Snapshot baseSnapshot, ContentRequest contentRequest) { Assert.notNull(baseSnapshot, "The baseSnapshot must not be null."); Assert.notNull(contentRequest, "The contentRequest must not be null."); Assert.notNull(snapshotToUse, "The snapshotToUse not be null."); String originalRaw = baseSnapshot.getSpec().getRawPatch(); String originalContent = baseSnapshot.getSpec().getContentPatch(); String baseSnapshotName = baseSnapshot.getMetadata().getName(); snapshotToUse.getSpec().setLastModifyTime(Instant.now()); // it is the v1 snapshot, set the content directly if (StringUtils.equals(baseSnapshotName, snapshotToUse.getMetadata().getName())) { snapshotToUse.getSpec().setRawPatch(contentRequest.raw()); snapshotToUse.getSpec().setContentPatch(contentRequest.content()); MetadataUtil.nullSafeAnnotations(snapshotToUse) .put(Snapshot.KEEP_RAW_ANNO, Boolean.TRUE.toString()); } else { // otherwise diff a patch based on the v1 snapshot String revisedRaw = contentRequest.rawPatchFrom(originalRaw); String revisedContent = contentRequest.contentPatchFrom(originalContent); snapshotToUse.getSpec().setRawPatch(revisedRaw); snapshotToUse.getSpec().setContentPatch(revisedContent); } return snapshotToUse; } protected Mono getContextUsername() { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(Principal::getName); } } ================================================ FILE: application/src/main/java/run/halo/app/content/AbstractEventReconciler.java ================================================ package run.halo.app.content; import java.time.Duration; import java.time.Instant; import org.springframework.context.SmartLifecycle; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.DefaultController; import run.halo.app.extension.controller.DefaultQueue; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.RequestQueue; import run.halo.app.infra.InitializationPhase; /** * An abstract class for reconciling events. * * @author guqing * @since 2.15.0 */ public abstract class AbstractEventReconciler implements Reconciler, SmartLifecycle { protected final RequestQueue queue; protected final Controller controller; protected volatile boolean running = false; private final String controllerName; protected AbstractEventReconciler(String controllerName) { this.controllerName = controllerName; this.queue = new DefaultQueue<>(Instant::now); this.controller = this.setupWith(null); } @Override public Controller setupWith(ControllerBuilder builder) { return new DefaultController<>( controllerName, this, queue, null, Duration.ofMillis(100), Duration.ofMinutes(10) ); } @Override public void start() { controller.start(); running = true; } @Override public void stop() { running = false; controller.dispose(); } @Override public boolean isRunning() { return running; } @Override public int getPhase() { return InitializationPhase.CONTROLLERS.getPhase(); } } ================================================ FILE: application/src/main/java/run/halo/app/content/CategoryPostCountUpdater.java ================================================ package run.halo.app.content; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static run.halo.app.extension.index.query.Queries.equal; import com.google.common.collect.Sets; import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.Set; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Post; import run.halo.app.event.post.PostDeletedEvent; import run.halo.app.event.post.PostUpdatedEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.index.query.Condition; import run.halo.app.extension.index.query.Queries; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.extension.router.selector.LabelSelector; import run.halo.app.infra.utils.JsonUtils; /** * A class used to update the post count of the category when the post changes. * * @author guqing * @since 2.15.0 */ @Component public class CategoryPostCountUpdater extends AbstractEventReconciler { protected final ExtensionClient client; private final CategoryPostCountService categoryPostCountService; public CategoryPostCountUpdater(ExtensionClient client) { super(CategoryPostCountUpdater.class.getName()); this.client = client; this.categoryPostCountService = new CategoryPostCountService(client); } @Override public Result reconcile(PostRelatedCategories request) { var categoryChanges = request.categoryChanges(); categoryPostCountService.recalculatePostCount(categoryChanges); client.fetch(Post.class, request.postName()).ifPresent(post -> { var categories = defaultIfNull(post.getSpec().getCategories(), List.of()); var annotations = MetadataUtil.nullSafeAnnotations(post); var categoryAnno = JsonUtils.objectToJson(categories); var oldCategoryAnno = annotations.get(Post.LAST_ASSOCIATED_CATEGORIES_ANNO); if (!categoryAnno.equals(oldCategoryAnno)) { annotations.put(Post.LAST_ASSOCIATED_CATEGORIES_ANNO, categoryAnno); client.update(post); } }); return Result.doNotRetry(); } static class CategoryPostCountService { private final ExtensionClient client; public CategoryPostCountService(ExtensionClient client) { this.client = client; } public void recalculatePostCount(Collection categoryNames) { for (String categoryName : categoryNames) { recalculatePostCount(categoryName); } } public void recalculatePostCount(String categoryName) { var totalPostCount = countTotalPosts(categoryName); var visiblePostCount = countVisiblePosts(categoryName); client.fetch(Category.class, categoryName).ifPresent(category -> { category.getStatusOrDefault().setPostCount(totalPostCount); category.getStatusOrDefault().setVisiblePostCount(visiblePostCount); client.update(category); }); } private int countTotalPosts(String categoryName) { var postListOptions = new ListOptions(); postListOptions.setFieldSelector(FieldSelector.of( basePostQuery(categoryName) )); return (int) client.listBy(Post.class, postListOptions, PageRequestImpl.ofSize(1)) .getTotal(); } private int countVisiblePosts(String categoryName) { var postListOptions = new ListOptions(); var fieldQuery = basePostQuery(categoryName) .and(equal("spec.visible", Post.VisibleEnum.PUBLIC.name())); var labelSelector = LabelSelector.builder() .eq(Post.PUBLISHED_LABEL, BooleanUtils.TRUE) .build(); postListOptions.setFieldSelector(FieldSelector.of(fieldQuery)); postListOptions.setLabelSelector(labelSelector); return (int) client.listBy(Post.class, postListOptions, PageRequestImpl.ofSize(1)) .getTotal(); } private static Condition basePostQuery(String categoryName) { return Queries.isNull("metadata.deletionTimestamp") .and(equal("spec.deleted", BooleanUtils.FALSE)) .and(equal("spec.categories", categoryName)); } } public record PostRelatedCategories(String postName, Collection categoryChanges) { } @EventListener(PostUpdatedEvent.class) public void onPostUpdated(PostUpdatedEvent event) { var postName = event.getName(); var changes = calcCategoriesToUpdate(event.getName()); queue.addImmediately(new PostRelatedCategories(postName, changes)); } @EventListener(PostDeletedEvent.class) public void onPostDeleted(PostDeletedEvent event) { var postName = event.getName(); var categories = defaultIfNull(event.getPost().getSpec().getCategories(), List.of()); queue.addImmediately(new PostRelatedCategories(postName, categories)); } private Set calcCategoriesToUpdate(String postName) { return client.fetch(Post.class, postName) .map(post -> { var annotations = MetadataUtil.nullSafeAnnotations(post); var oldCategories = Optional.ofNullable(annotations.get(Post.LAST_ASSOCIATED_CATEGORIES_ANNO)) .filter(StringUtils::isNotBlank) .map(categoriesJson -> JsonUtils.jsonToObject(categoriesJson, String[].class)) .orElse(new String[0]); Set categoriesToUpdate = Sets.newHashSet(oldCategories); var newCategories = post.getSpec().getCategories(); if (newCategories != null) { categoriesToUpdate.addAll(newCategories); } return categoriesToUpdate; }) .orElse(Set.of()); } } ================================================ FILE: application/src/main/java/run/halo/app/content/CategoryService.java ================================================ package run.halo.app.content; import org.springframework.lang.NonNull; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Category; public interface CategoryService { Flux listChildren(@NonNull String categoryName); Mono getParentByName(@NonNull String categoryName); Mono isCategoryHidden(@NonNull String categoryName); } ================================================ FILE: application/src/main/java/run/halo/app/content/Content.java ================================================ package run.halo.app.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; public record Content(@Schema(requiredMode = REQUIRED) String raw, @Schema(requiredMode = REQUIRED) String content, @Schema(requiredMode = REQUIRED) String rawType) { } ================================================ FILE: application/src/main/java/run/halo/app/content/ContentRequest.java ================================================ package run.halo.app.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.util.HashMap; import lombok.Builder; import org.apache.commons.lang3.StringUtils; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.extension.Metadata; import run.halo.app.extension.Ref; /** * @author guqing * @since 2.0.0 */ @Builder public record ContentRequest(@Schema(requiredMode = REQUIRED) Ref subjectRef, String headSnapshotName, @Schema(requiredMode = NOT_REQUIRED) Long version, @Schema(requiredMode = REQUIRED) String raw, @Schema(requiredMode = REQUIRED) String content, @Schema(requiredMode = REQUIRED) String rawType) { public Snapshot toSnapshot() { final Snapshot snapshot = new Snapshot(); Metadata metadata = new Metadata(); metadata.setAnnotations(new HashMap<>()); snapshot.setMetadata(metadata); Snapshot.SnapShotSpec snapShotSpec = new Snapshot.SnapShotSpec(); snapShotSpec.setSubjectRef(subjectRef); snapShotSpec.setRawType(rawType); snapShotSpec.setRawPatch(StringUtils.defaultString(raw())); snapShotSpec.setContentPatch(StringUtils.defaultString(content())); snapshot.setSpec(snapShotSpec); return snapshot; } public String rawPatchFrom(String originalRaw) { // originalRaw content from v1 return PatchUtils.diffToJsonPatch(originalRaw, this.raw); } public String contentPatchFrom(String originalContent) { // originalContent from v1 return PatchUtils.diffToJsonPatch(originalContent, this.content); } } ================================================ FILE: application/src/main/java/run/halo/app/content/ContentUpdateParam.java ================================================ package run.halo.app.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; public record ContentUpdateParam(Long version, @Schema(requiredMode = REQUIRED) String raw, @Schema(requiredMode = REQUIRED) String content, @Schema(requiredMode = REQUIRED) String rawType) { public static ContentUpdateParam from(Content content) { return new ContentUpdateParam(null, content.raw(), content.content(), content.rawType()); } } ================================================ FILE: application/src/main/java/run/halo/app/content/Contributor.java ================================================ package run.halo.app.content; import lombok.Data; /** * Contributor from user. * * @author guqing * @since 2.0.0 */ @Data public class Contributor { private String displayName; private String avatar; private String name; } ================================================ FILE: application/src/main/java/run/halo/app/content/ListedPost.java ================================================ package run.halo.app.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.Data; import lombok.experimental.Accessors; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Tag; /** * An aggregate object of {@link Post} and {@link Category} * and {@link Tag} and more for post list. * * @author guqing * @since 2.0.0 */ @Data @Accessors(chain = true) public class ListedPost { @Schema(requiredMode = REQUIRED) private Post post; @Schema(requiredMode = REQUIRED) private List categories; @Schema(requiredMode = REQUIRED) private List tags; @Schema(requiredMode = REQUIRED) private List contributors; @Schema(requiredMode = REQUIRED) private Contributor owner; @Schema(requiredMode = REQUIRED) private Stats stats; } ================================================ FILE: application/src/main/java/run/halo/app/content/ListedSinglePage.java ================================================ package run.halo.app.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.Data; import lombok.experimental.Accessors; import run.halo.app.core.extension.content.SinglePage; /** * An aggregate object of {@link SinglePage} and {@link Contributor} single page list. * * @author guqing * @since 2.0.0 */ @Data @Accessors(chain = true) public class ListedSinglePage { @Schema(requiredMode = REQUIRED) private SinglePage page; @Schema(requiredMode = REQUIRED) private List contributors; @Schema(requiredMode = REQUIRED) private Contributor owner; @Schema(requiredMode = REQUIRED) private Stats stats; } ================================================ FILE: application/src/main/java/run/halo/app/content/ListedSnapshotDto.java ================================================ package run.halo.app.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import lombok.Data; import lombok.experimental.Accessors; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.extension.MetadataOperator; @Data @Accessors(chain = true) public class ListedSnapshotDto { @Schema(requiredMode = REQUIRED) private MetadataOperator metadata; @Schema(requiredMode = REQUIRED) private Spec spec; @Data @Accessors(chain = true) @Schema(name = "ListedSnapshotSpec") public static class Spec { @Schema(requiredMode = REQUIRED) private String owner; private Instant modifyTime; } /** * Creates from snapshot. */ public static ListedSnapshotDto from(Snapshot snapshot) { return new ListedSnapshotDto() .setMetadata(snapshot.getMetadata()) .setSpec(new Spec() .setOwner(snapshot.getSpec().getOwner()) .setModifyTime(snapshot.getSpec().getLastModifyTime()) ); } } ================================================ FILE: application/src/main/java/run/halo/app/content/NotificationReasonConst.java ================================================ package run.halo.app.content; /** * Notification reason constants for content module. * * @author guqing * @since 2.9.0 */ public enum NotificationReasonConst { ; public static final String NEW_COMMENT_ON_POST = "new-comment-on-post"; public static final String NEW_COMMENT_ON_PAGE = "new-comment-on-single-page"; public static final String SOMEONE_REPLIED_TO_YOU = "someone-replied-to-you"; } ================================================ FILE: application/src/main/java/run/halo/app/content/PostContentServiceImpl.java ================================================ package run.halo.app.content; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; /** * Provides ability to get post content for the specified post. * * @author guqing * @since 2.16.0 */ @Component public class PostContentServiceImpl extends AbstractContentService implements PostContentService { private final ReactiveExtensionClient client; public PostContentServiceImpl(ReactiveExtensionClient client) { super(client); this.client = client; } @Override public Mono getHeadContent(String postName) { return client.get(Post.class, postName) .flatMap(post -> { var headSnapshot = post.getSpec().getHeadSnapshot(); return super.getContent(headSnapshot, post.getSpec().getBaseSnapshot()); }); } @Override public Mono getReleaseContent(String postName) { return client.get(Post.class, postName) .flatMap(post -> { var releaseSnapshot = post.getSpec().getReleaseSnapshot(); return super.getContent(releaseSnapshot, post.getSpec().getBaseSnapshot()); }); } @Override public Mono getSpecifiedContent(String postName, String snapshotName) { return client.get(Post.class, postName) .flatMap(post -> { var baseSnapshot = post.getSpec().getBaseSnapshot(); return super.getContent(snapshotName, baseSnapshot); }); } @Override public Flux listSnapshots(String postName) { return client.get(Post.class, postName) .flatMapMany(page -> listSnapshotsBy(Ref.of(page))) .map(snapshot -> snapshot.getMetadata().getName()); } } ================================================ FILE: application/src/main/java/run/halo/app/content/PostHideFromListStateUpdater.java ================================================ package run.halo.app.content; import static run.halo.app.extension.index.query.Queries.equal; import java.time.Duration; import org.springframework.context.event.EventListener; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import run.halo.app.core.extension.content.Post; import run.halo.app.event.post.CategoryHiddenStateChangeEvent; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.ReactiveExtensionPaginatedOperator; import run.halo.app.infra.utils.ReactiveUtils; /** * Synchronize the {@link Post.PostStatus#getHideFromList()} state of the post with the category. * * @author guqing * @since 2.17.0 */ @Component public class PostHideFromListStateUpdater extends AbstractEventReconciler { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private final ReactiveExtensionPaginatedOperator reactiveExtensionPaginatedOperator; private final ReactiveExtensionClient client; protected PostHideFromListStateUpdater(ReactiveExtensionClient client, ReactiveExtensionPaginatedOperator reactiveExtensionPaginatedOperator) { super(PostHideFromListStateUpdater.class.getName()); this.reactiveExtensionPaginatedOperator = reactiveExtensionPaginatedOperator; this.client = client; } @Override public Result reconcile(CategoryHiddenStateChangeEvent request) { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of( equal("spec.categories", request.getCategoryName()) )); reactiveExtensionPaginatedOperator.list(Post.class, listOptions) .flatMap(post -> { post.getStatusOrDefault().setHideFromList(request.isHidden()); return client.update(post); }) .then() .block(BLOCKING_TIMEOUT); return Result.doNotRetry(); } @EventListener(CategoryHiddenStateChangeEvent.class) public void onApplicationEvent(@NonNull CategoryHiddenStateChangeEvent event) { this.queue.addImmediately(event); } } ================================================ FILE: application/src/main/java/run/halo/app/content/PostQuery.java ================================================ package run.halo.app.content; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static run.halo.app.core.extension.content.Post.PUBLISHED_LABEL; import static run.halo.app.core.extension.content.Post.PostPhase.PENDING_APPROVAL; import static run.halo.app.extension.index.query.Queries.contains; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.or; import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.util.Optional; import org.apache.commons.lang3.StringUtils; import org.springdoc.core.fn.builders.operation.Builder; import org.springframework.lang.Nullable; import org.springframework.web.reactive.function.server.ServerRequest; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListOptions; import run.halo.app.extension.router.IListRequest; import run.halo.app.extension.router.SortableRequest; /** * A query object for {@link Post} list. * * @author guqing * @since 2.0.0 */ public class PostQuery extends SortableRequest { private final String username; public PostQuery(ServerRequest request) { this(request, null); } public PostQuery(ServerRequest request, @Nullable String username) { super(request.exchange()); this.username = username; } @Nullable public String getPublishPhase() { return queryParams.getFirst("publishPhase"); } @Nullable public String getCategoryWithChildren() { var value = queryParams.getFirst("categoryWithChildren"); return StringUtils.defaultIfBlank(value, null); } @Nullable public String getKeyword() { return StringUtils.defaultIfBlank(queryParams.getFirst("keyword"), null); } /** * Build a list options from the query object. * * @return a list options */ @Override public ListOptions toListOptions() { var builder = ListOptions.builder(super.toListOptions()); Optional.ofNullable(getKeyword()) .filter(StringUtils::isNotBlank) .ifPresent(keyword -> builder.andQuery(or( contains("status.excerpt", keyword), contains("spec.slug", keyword), contains("spec.title", keyword) ))); Optional.ofNullable(getPublishPhase()) .filter(StringUtils::isNotBlank) .map(Post.PostPhase::from) .ifPresent(phase -> { if (PENDING_APPROVAL.equals(phase)) { builder.andQuery(equal("status.phase", phase.name())); } var labelSelector = builder.labelSelector(); Optional.of(phase) .filter(Post.PostPhase.PUBLISHED::equals) .ifPresentOrElse( published -> labelSelector.eq(PUBLISHED_LABEL, Boolean.TRUE.toString()), () -> labelSelector.notEq(PUBLISHED_LABEL, Boolean.TRUE.toString()) ); }); Optional.ofNullable(username) .filter(StringUtils::isNotBlank) .ifPresent(username -> builder.andQuery(equal("spec.owner", username))); return builder.build(); } public static void buildParameters(Builder builder) { IListRequest.buildParameters(builder); builder.parameter(sortParameter()) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("publishPhase") .description("Posts filtered by publish phase.") .implementation(Post.PostPhase.class) .required(false)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("keyword") .description("Posts filtered by keyword.") .implementation(String.class) .required(false)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("categoryWithChildren") .description("Posts filtered by category including sub-categories.") .implementation(String.class) .required(false)); } } ================================================ FILE: application/src/main/java/run/halo/app/content/PostRequest.java ================================================ package run.halo.app.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.lang.NonNull; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.Ref; /** * Post and content data for creating and updating post. * * @author guqing * @since 2.0.0 */ public record PostRequest(@Schema(requiredMode = REQUIRED) @NonNull Post post, @Schema(requiredMode = REQUIRED) @NonNull ContentUpdateParam content) { public ContentRequest contentRequest() { Ref subjectRef = Ref.of(post); return new ContentRequest(subjectRef, post.getSpec().getHeadSnapshot(), content.version(), content.raw(), content.content(), content.rawType()); } } ================================================ FILE: application/src/main/java/run/halo/app/content/PostService.java ================================================ package run.halo.app.content; import java.util.List; import org.springframework.lang.NonNull; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListResult; /** * Service for {@link Post}. * * @author guqing * @since 2.0.0 */ public interface PostService { Mono> listPost(PostQuery query); Mono draftPost(PostRequest postRequest); Mono updatePost(PostRequest postRequest); Mono updateBy(@NonNull Post post); Mono getHeadContent(String postName); Mono getHeadContent(Post post); Mono getReleaseContent(String postName); Mono getReleaseContent(Post post); Mono getContent(String snapshotName, String baseSnapshotName); Flux listSnapshots(String name); Mono publish(Post post); Mono unpublish(Post post); /** * Get post by username. * * @param postName is post name. * @param username is username. * @return full post data or empty. */ Mono getByUsername(String postName, String username); Mono revertToSpecifiedSnapshot(String postName, String snapshotName); Mono deleteContent(String postName, String snapshotName); Mono recycleBy(String postName, String username); Flux listCategories(List categories); } ================================================ FILE: application/src/main/java/run/halo/app/content/PostSorter.java ================================================ package run.halo.app.content; import java.time.Instant; import java.util.Comparator; import java.util.Objects; import java.util.function.Function; import org.springframework.util.comparator.Comparators; import run.halo.app.core.extension.content.Post; /** * A sorter for {@link Post}. * * @author guqing * @since 2.0.0 */ public enum PostSorter { PUBLISH_TIME, CREATE_TIME; static final Function name = post -> post.getMetadata().getName(); /** * Converts {@link Comparator} from {@link PostSorter} and ascending. * * @param sorter a {@link PostSorter} * @param ascending ascending if true, otherwise descending * @return a {@link Comparator} of {@link Post} */ public static Comparator from(PostSorter sorter, Boolean ascending) { if (Objects.equals(true, ascending)) { return from(sorter); } return from(sorter).reversed(); } /** * Converts {@link Comparator} from {@link PostSorter}. * * @param sorter a {@link PostSorter} * @return a {@link Comparator} of {@link Post} */ public static Comparator from(PostSorter sorter) { if (sorter == null) { return defaultComparator(); } if (CREATE_TIME.equals(sorter)) { Function comparatorFunc = post -> post.getMetadata().getCreationTimestamp(); return Comparator.comparing(comparatorFunc) .thenComparing(name); } if (PUBLISH_TIME.equals(sorter)) { Function comparatorFunc = post -> post.getSpec().getPublishTime(); return Comparator.comparing(comparatorFunc, Comparators.nullsLow()) .thenComparing(name); } throw new IllegalArgumentException("Unsupported sort value: " + sorter); } static PostSorter convertFrom(String sort) { for (PostSorter sorter : values()) { if (sorter.name().equalsIgnoreCase(sort)) { return sorter; } } return null; } static Comparator defaultComparator() { Function createTime = post -> post.getMetadata().getCreationTimestamp(); return Comparator.comparing(createTime) .thenComparing(name); } } ================================================ FILE: application/src/main/java/run/halo/app/content/SinglePageQuery.java ================================================ package run.halo.app.content; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static run.halo.app.core.extension.content.Post.PostPhase.PENDING_APPROVAL; import static run.halo.app.core.extension.content.SinglePage.PUBLISHED_LABEL; import static run.halo.app.extension.index.query.Queries.contains; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.in; import static run.halo.app.extension.index.query.Queries.or; import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.util.Optional; import org.apache.commons.lang3.StringUtils; import org.springdoc.core.fn.builders.operation.Builder; import org.springframework.data.domain.Sort; import org.springframework.web.reactive.function.server.ServerRequest; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.ListOptions; import run.halo.app.extension.router.IListRequest; import run.halo.app.extension.router.SortableRequest; /** * Query parameter for {@link SinglePage} list. * * @author guqing * @since 2.0.0 */ public class SinglePageQuery extends SortableRequest { public SinglePageQuery(ServerRequest request) { super(request.exchange()); } @Override public ListOptions toListOptions() { var builder = ListOptions.builder(super.toListOptions()); Optional.ofNullable(queryParams.getFirst("keyword")) .filter(StringUtils::isNotBlank) .ifPresent(keyword -> builder.andQuery(or( contains("spec.title", keyword), contains("spec.slug", keyword), contains("status.excerpt", keyword) ))); Optional.ofNullable(queryParams.getFirst("publishPhase")) .filter(StringUtils::isNotBlank) .map(Post.PostPhase::from) .ifPresent(phase -> { if (PENDING_APPROVAL.equals(phase)) { builder.andQuery(equal("status.phase", phase.name())); } var labelSelector = builder.labelSelector(); Optional.of(phase) .filter(Post.PostPhase.PUBLISHED::equals) .ifPresentOrElse( published -> labelSelector.eq(PUBLISHED_LABEL, Boolean.TRUE.toString()), () -> labelSelector.notEq(PUBLISHED_LABEL, Boolean.TRUE.toString()) ); }); Optional.ofNullable(queryParams.getFirst("visible")) .filter(StringUtils::isNotBlank) .map(Post.VisibleEnum::from) .ifPresent(visible -> builder.andQuery(equal("spec.visible", visible.name()))); Optional.ofNullable(queryParams.get("contributor")) .filter(contributors -> !contributors.isEmpty()) .ifPresent(contributors -> builder.andQuery(in("status.contributors", contributors))); return builder.build(); } @Override public Sort getSort() { var sort = super.getSort(); var orders = sort.stream() .map(order -> { if ("creationTimestamp".equals(order.getProperty())) { return order.withProperty("metadata.creationTimestamp"); } if ("publishTime".equals(order.getProperty())) { return order.withProperty("spec.publishTime"); } return order; }) .toList(); return Sort.by(orders); } public static void buildParameters(Builder builder) { IListRequest.buildParameters(builder); builder.parameter(sortParameter()) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("contributor") .description("SinglePages filtered by contributor.") .implementationArray(String.class) .required(false)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("publishPhase") .description("SinglePages filtered by publish phase.") .implementation(Post.PostPhase.class) .required(false)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("visible") .description("SinglePages filtered by visibility.") .implementation(Post.VisibleEnum.class) .required(false)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("keyword") .description("SinglePages filtered by keyword.") .implementation(String.class) .required(false)); } } ================================================ FILE: application/src/main/java/run/halo/app/content/SinglePageRequest.java ================================================ package run.halo.app.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.Ref; /** * A request parameter for {@link SinglePage}. * * @author guqing * @since 2.0.0 */ public record SinglePageRequest(@Schema(requiredMode = REQUIRED) SinglePage page, @Schema(requiredMode = REQUIRED) ContentUpdateParam content) { public ContentRequest contentRequest() { Ref subjectRef = Ref.of(page); return new ContentRequest(subjectRef, page.getSpec().getHeadSnapshot(), content.version(), content.raw(), content.content(), content.rawType()); } } ================================================ FILE: application/src/main/java/run/halo/app/content/SinglePageService.java ================================================ package run.halo.app.content; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.ListResult; /** * Single page service. * * @author guqing * @since 2.0.0 */ public interface SinglePageService { Mono getHeadContent(String singlePageName); Mono getReleaseContent(String singlePageName); Mono getContent(String snapshotName, String baseSnapshotName); Flux listSnapshots(String pageName); Mono> list(SinglePageQuery listRequest); Mono draft(SinglePageRequest pageRequest); Mono update(SinglePageRequest pageRequest); Mono revertToSpecifiedSnapshot(String pageName, String snapshotName); Mono deleteContent(String postName, String snapshotName); } ================================================ FILE: application/src/main/java/run/halo/app/content/SnapshotService.java ================================================ package run.halo.app.content; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Snapshot; public interface SnapshotService { Mono getBy(String snapshotName); Mono getPatchedBy(String snapshotName, String baseSnapshotName); Mono patchAndCreate(@NonNull Snapshot snapshot, @Nullable Snapshot baseSnapshot, @NonNull Content content); Mono patchAndUpdate(@NonNull Snapshot snapshot, @NonNull Snapshot baseSnapshot, @NonNull Content content); } ================================================ FILE: application/src/main/java/run/halo/app/content/Stats.java ================================================ package run.halo.app.content; import lombok.Builder; import lombok.Data; /** * Stats value object. * * @author guqing * @since 2.0.0 */ @Data public class Stats { private Integer visit; private Integer upvote; private Integer totalComment; private Integer approvedComment; public Stats() { } @Builder public Stats(Integer visit, Integer upvote, Integer totalComment, Integer approvedComment) { this.visit = visit; this.upvote = upvote; this.totalComment = totalComment; this.approvedComment = approvedComment; } public static Stats empty() { return Stats.builder() .visit(0) .upvote(0) .totalComment(0) .approvedComment(0) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/AbstractCommentService.java ================================================ package run.halo.app.content.comment; import java.util.Set; import lombok.RequiredArgsConstructor; import org.jsoup.Jsoup; import org.jsoup.safety.Safelist; import org.springframework.lang.NonNull; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.util.Assert; import reactor.core.publisher.Mono; import run.halo.app.core.counter.CounterService; import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.security.authorization.AuthorityUtils; @RequiredArgsConstructor public abstract class AbstractCommentService { protected final RoleService roleService; protected final ReactiveExtensionClient client; protected final UserService userService; protected final CounterService counterService; private final Safelist safelist = Safelist.relaxed() // Allow tag, which is used for strikethrough .addTags("s") // Allow tag's class attribute, for syntax highlighting .addAttributes("code", "class") // Allow tag's target attribute .addAttributes("a", "target") .preserveRelativeLinks(true); protected Mono fetchCurrentUser() { return ReactiveSecurityContextHolder.getContext() .map(securityContext -> securityContext.getAuthentication().getName()) .flatMap(username -> client.fetch(User.class, username)); } Mono hasCommentManagePermission() { return ReactiveSecurityContextHolder.getContext() .flatMap(securityContext -> { var authentication = securityContext.getAuthentication(); var roles = AuthorityUtils.authoritiesToRoles(authentication.getAuthorities()); return roleService.contains(roles, Set.of(AuthorityUtils.COMMENT_MANAGEMENT_ROLE_NAME)); }); } protected Comment.CommentOwner toCommentOwner(User user) { Comment.CommentOwner owner = new Comment.CommentOwner(); owner.setKind(User.KIND); owner.setName(user.getMetadata().getName()); owner.setDisplayName(user.getSpec().getDisplayName()); return owner; } protected Mono getOwnerInfo(Comment.CommentOwner owner) { if (User.KIND.equals(owner.getKind())) { return userService.getUserOrGhost(owner.getName()) .map(OwnerInfo::from); } if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) { return Mono.just(OwnerInfo.from(owner)); } return Mono.error(new IllegalStateException("Unsupported owner kind: " + owner.getKind())); } protected Mono fetchCommentStats(String commentName) { return this.fetchStats(MeterUtils.nameOf(Comment.class, commentName)); } protected Mono fetchReplyStats(String replyName) { return this.fetchStats(MeterUtils.nameOf(Reply.class, replyName)); } private Mono fetchStats(String meterName) { Assert.notNull(meterName, "The reply must not be null."); return counterService.getByName(meterName) .map(counter -> CommentStats.builder() .upvote(counter.getUpvote()) .build() ) .switchIfEmpty(Mono.fromSupplier(CommentStats::empty)); } /** * Check if the given html is a safe HTML. * * @param html html content * @return true if the html is safe, false otherwise */ protected boolean isSafeHtml(@NonNull String html) { return Jsoup.isValid(html, safelist); } } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/CommentEmailOwner.java ================================================ package run.halo.app.content.comment; import java.util.LinkedHashMap; import java.util.Map; import org.apache.commons.lang3.StringUtils; import org.springframework.util.Assert; import run.halo.app.core.extension.content.Comment; /** *

The creator info of the comment.

* This {@link CommentEmailOwner} is only applicable to the user who is allowed to comment * without logging in. * * @param email email for comment owner * @param avatar avatar for comment owner * @param displayName display name for comment owner * @param website website for comment owner */ public record CommentEmailOwner(String email, String avatar, String displayName, String website) { public CommentEmailOwner { Assert.hasText(displayName, "The 'displayName' must not be empty."); } /** * Converts {@link CommentEmailOwner} to {@link Comment.CommentOwner}. * * @return a comment owner */ public Comment.CommentOwner toCommentOwner() { Comment.CommentOwner commentOwner = new Comment.CommentOwner(); commentOwner.setKind(Comment.CommentOwner.KIND_EMAIL); // email nullable commentOwner.setName(StringUtils.defaultString(email)); commentOwner.setDisplayName(displayName); Map annotations = new LinkedHashMap<>(); commentOwner.setAnnotations(annotations); annotations.put(Comment.CommentOwner.AVATAR_ANNO, avatar); annotations.put(Comment.CommentOwner.WEBSITE_ANNO, website); return commentOwner; } } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/CommentNotificationReasonPublisher.java ================================================ package run.halo.app.content.comment; import static org.apache.commons.lang3.StringUtils.defaultIfBlank; import static run.halo.app.content.comment.ReplyNotificationSubscriptionHelper.identityFrom; import com.fasterxml.jackson.core.type.TypeReference; import java.time.Duration; import java.util.Map; import java.util.Optional; import lombok.Builder; import lombok.RequiredArgsConstructor; import lombok.experimental.UtilityClass; import org.apache.commons.lang3.StringUtils; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import run.halo.app.content.NotificationReasonConst; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Reply; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.event.post.CommentCreatedEvent; import run.halo.app.event.post.ReplyCreatedEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.Ref; import run.halo.app.infra.ExternalLinkProcessor; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.notification.NotificationReasonEmitter; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** * Notification reason publisher for {@link Comment} and {@link Reply}. * * @author guqing * @since 2.9.0 */ @Component @RequiredArgsConstructor public class CommentNotificationReasonPublisher { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private static final GroupVersionKind POST_GVK = GroupVersionKind.fromExtension(Post.class); private static final GroupVersionKind PAGE_GVK = GroupVersionKind.fromExtension(SinglePage.class); private final ExtensionClient client; private final NewCommentOnPostReasonPublisher newCommentOnPostReasonPublisher; private final NewCommentOnPageReasonPublisher newCommentOnPageReasonPublisher; private final NewReplyReasonPublisher newReplyReasonPublisher; /** * On new comment. */ @Async @EventListener(CommentCreatedEvent.class) public void onNewComment(CommentCreatedEvent event) { Comment comment = event.getComment(); if (isPostComment(comment)) { newCommentOnPostReasonPublisher.publishReasonBy(comment); } else if (isPageComment(comment)) { newCommentOnPageReasonPublisher.publishReasonBy(comment); } } /** * On new reply. */ @Async @EventListener(ReplyCreatedEvent.class) public void onNewReply(ReplyCreatedEvent event) { Reply reply = event.getReply(); var commentName = reply.getSpec().getCommentName(); client.fetch(Comment.class, commentName) .ifPresent(comment -> newReplyReasonPublisher.publishReasonBy(reply, comment)); } boolean isPostComment(Comment comment) { return Ref.groupKindEquals(comment.getSpec().getSubjectRef(), POST_GVK); } boolean isPageComment(Comment comment) { return Ref.groupKindEquals(comment.getSpec().getSubjectRef(), PAGE_GVK); } /** * Comment content converter, convert relative links to absolute links. */ @Component @RequiredArgsConstructor static class CommentContentConverter { private final ExternalLinkProcessor externalLinkProcessor; /** * Convert relative links to absolute links. * * @param content the content to convert * @return the converted content */ public String convertRelativeLinks(String content) { Document parse = Jsoup.parse(content); parse.select("img").forEach(element -> { var src = element.attr("src"); element.attr("src", externalLinkProcessor.processLink(src)); }); return parse.body().html(); } } @Component @RequiredArgsConstructor static class NewCommentOnPostReasonPublisher { private final ExtensionClient client; private final NotificationReasonEmitter notificationReasonEmitter; private final ExternalLinkProcessor externalLinkProcessor; private final CommentContentConverter commentContentConverter; public void publishReasonBy(Comment comment) { Ref subjectRef = comment.getSpec().getSubjectRef(); Post post = client.fetch(Post.class, subjectRef.getName()).orElseThrow(); if (doNotEmitReason(comment, post)) { return; } String postUrl = externalLinkProcessor.processLink(post.getStatusOrDefault().getPermalink()); var reasonSubject = Reason.Subject.builder() .apiVersion(post.getApiVersion()) .kind(post.getKind()) .name(subjectRef.getName()) .title(post.getSpec().getTitle()) .url(postUrl) .build(); Comment.CommentOwner owner = comment.getSpec().getOwner(); notificationReasonEmitter.emit(NotificationReasonConst.NEW_COMMENT_ON_POST, builder -> { var attributes = CommentOnPostReasonData.builder() .postName(subjectRef.getName()) .postOwner(post.getSpec().getOwner()) .postTitle(post.getSpec().getTitle()) .postUrl(postUrl) .commenter(owner.getDisplayName()) .content(commentContentConverter.convertRelativeLinks( comment.getSpec().getContent())) .commentName(comment.getMetadata().getName()) .build(); builder.attributes(ReasonDataConverter.toAttributeMap(attributes)) .author(identityFrom(owner)) .subject(reasonSubject); }).block(BLOCKING_TIMEOUT); } boolean doNotEmitReason(Comment comment, Post post) { Comment.CommentOwner commentOwner = comment.getSpec().getOwner(); return isPostOwner(post, commentOwner); } boolean isPostOwner(Post post, Comment.CommentOwner commentOwner) { String kind = commentOwner.getKind(); String name = commentOwner.getName(); var postOwner = post.getSpec().getOwner(); if (Comment.CommentOwner.KIND_EMAIL.equals(kind)) { return client.fetch(User.class, postOwner) .filter(user -> name.equals(user.getSpec().getEmail())) .isPresent(); } return name.equals(postOwner); } @Builder record CommentOnPostReasonData(String postName, String postOwner, String postTitle, String postUrl, String commenter, String content, String commentName) { } } @Component @RequiredArgsConstructor static class NewCommentOnPageReasonPublisher { private final ExtensionClient client; private final NotificationReasonEmitter notificationReasonEmitter; private final ExternalLinkProcessor externalLinkProcessor; private final CommentContentConverter commentContentConverter; public void publishReasonBy(Comment comment) { Ref subjectRef = comment.getSpec().getSubjectRef(); var singlePage = client.fetch(SinglePage.class, subjectRef.getName()).orElseThrow(); if (doNotEmitReason(comment, singlePage)) { return; } var pageUrl = externalLinkProcessor .processLink(singlePage.getStatusOrDefault().getPermalink()); var reasonSubject = Reason.Subject.builder() .apiVersion(singlePage.getApiVersion()) .kind(singlePage.getKind()) .name(subjectRef.getName()) .title(singlePage.getSpec().getTitle()) .url(pageUrl) .build(); Comment.CommentOwner owner = comment.getSpec().getOwner(); notificationReasonEmitter.emit(NotificationReasonConst.NEW_COMMENT_ON_PAGE, builder -> { var attributes = CommentOnPageReasonData.builder() .pageName(subjectRef.getName()) .pageOwner(singlePage.getSpec().getOwner()) .pageTitle(singlePage.getSpec().getTitle()) .pageUrl(pageUrl) .commenter(defaultIfBlank(owner.getDisplayName(), owner.getName())) .content(commentContentConverter.convertRelativeLinks( comment.getSpec().getContent())) .commentName(comment.getMetadata().getName()) .build(); builder.attributes(ReasonDataConverter.toAttributeMap(attributes)) .author(identityFrom(owner)) .subject(reasonSubject); }).block(BLOCKING_TIMEOUT); } public boolean doNotEmitReason(Comment comment, SinglePage page) { Comment.CommentOwner commentOwner = comment.getSpec().getOwner(); return isPageOwner(page, commentOwner); } boolean isPageOwner(SinglePage page, Comment.CommentOwner commentOwner) { String kind = commentOwner.getKind(); String name = commentOwner.getName(); var pageOwner = page.getSpec().getOwner(); if (Comment.CommentOwner.KIND_EMAIL.equals(kind)) { return client.fetch(User.class, pageOwner) .filter(user -> name.equals(user.getSpec().getEmail())) .isPresent(); } return name.equals(pageOwner); } @Builder record CommentOnPageReasonData(String pageName, String pageOwner, String pageTitle, String pageUrl, String commenter, String content, String commentName) { } } @UtilityClass static class ReasonDataConverter { public static Map toAttributeMap(T data) { Assert.notNull(data, "Reason attributes must not be null"); return JsonUtils.mapper().convertValue(data, new TypeReference<>() { }); } } @Component @RequiredArgsConstructor static class NewReplyReasonPublisher { private final ExtensionClient client; private final NotificationReasonEmitter notificationReasonEmitter; private final ExtensionGetter extensionGetter; private final CommentContentConverter commentContentConverter; public void publishReasonBy(Reply reply, Comment comment) { boolean isQuoteReply = StringUtils.isNotBlank(reply.getSpec().getQuoteReply()); Optional quoteReplyOptional = Optional.of(isQuoteReply) .filter(Boolean::booleanValue) .flatMap(isQuote -> client.fetch(Reply.class, reply.getSpec().getQuoteReply())); if (doNotEmitReason(reply, quoteReplyOptional.orElse(null), comment)) { return; } var reasonSubject = quoteReplyOptional .map(quoteReply -> Subscription.ReasonSubject.builder() .apiVersion(quoteReply.getApiVersion()) .kind(quoteReply.getKind()) .name(quoteReply.getMetadata().getName()) .build() ) .orElseGet(() -> Subscription.ReasonSubject.builder() .apiVersion(comment.getApiVersion()) .kind(comment.getKind()) .name(comment.getMetadata().getName()) .build() ); var reasonSubjectTitle = quoteReplyOptional .map(quoteReply -> quoteReply.getSpec().getContent()) .orElse(comment.getSpec().getContent()); var quoteReplyContent = quoteReplyOptional .map(quoteReply -> commentContentConverter .convertRelativeLinks(quoteReply.getSpec().getContent())) .orElse(null); var replyOwner = reply.getSpec().getOwner(); var repliedOwner = quoteReplyOptional .map(quoteReply -> quoteReply.getSpec().getOwner()) .orElseGet(() -> comment.getSpec().getOwner()); var reasonAttributesBuilder = NewReplyReasonData.builder() .commentContent( commentContentConverter.convertRelativeLinks(comment.getSpec().getContent()) ) .isQuoteReply(isQuoteReply) .quoteContent(quoteReplyContent) .commentName(comment.getMetadata().getName()) .replier(defaultIfBlank(replyOwner.getDisplayName(), replyOwner.getName())) .content(commentContentConverter.convertRelativeLinks(reply.getSpec().getContent())) .replyName(reply.getMetadata().getName()) .replyOwner(identityFrom(replyOwner).name()) .repliedOwner(identityFrom(repliedOwner).name()); getCommentSubjectDisplay(comment.getSpec().getSubjectRef()) .ifPresent(subject -> { reasonAttributesBuilder.commentSubjectTitle(subject.title()); reasonAttributesBuilder.commentSubjectUrl(subject.url()); }); notificationReasonEmitter.emit(NotificationReasonConst.SOMEONE_REPLIED_TO_YOU, builder -> { var data = ReasonDataConverter.toAttributeMap(reasonAttributesBuilder.build()); builder.attributes(data) .author(identityFrom(replyOwner)) .subject(Reason.Subject.builder() .apiVersion(reasonSubject.getApiVersion()) .kind(reasonSubject.getKind()) .name(reasonSubject.getName()) .title(reasonSubjectTitle) .build()); }).block(BLOCKING_TIMEOUT); } /** * To be compatible with older versions, it may be empty, so use optional. */ @SuppressWarnings("unchecked") Optional getCommentSubjectDisplay(Ref ref) { return extensionGetter.getExtensions(CommentSubject.class) .filter(commentSubject -> commentSubject.supports(ref)) .next() .flatMap(subject -> subject.getSubjectDisplay(ref.getName())) .blockOptional(BLOCKING_TIMEOUT); } boolean doNotEmitReason(Reply currentReply, Reply quoteReply, Comment comment) { boolean isQuoteReply = StringUtils.isNotBlank(currentReply.getSpec().getQuoteReply()); if (isQuoteReply && quoteReply == null) { throw new IllegalArgumentException( "quoteReply can not be null when currentReply is reply to quote"); } Comment.CommentOwner commentOwner = isQuoteReply ? quoteReply.getSpec().getOwner() : comment.getSpec().getOwner(); var currentReplyOwner = currentReply.getSpec().getOwner(); // reply to oneself do not emit reason return currentReplyOwner.getKind().equals(commentOwner.getKind()) && currentReplyOwner.getName().equals(commentOwner.getName()); } @Builder record NewReplyReasonData(String commentContent, String commentSubjectTitle, String commentSubjectUrl, boolean isQuoteReply, String quoteContent, String commentName, String replier, String content, String replyName, String replyOwner, String repliedOwner) { } } } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/CommentQuery.java ================================================ package run.halo.app.content.comment; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springframework.data.domain.Sort.Order.desc; import static run.halo.app.extension.index.query.Queries.contains; import static run.halo.app.extension.index.query.Queries.equal; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.util.Optional; import org.apache.commons.lang3.StringUtils; import org.springdoc.core.fn.builders.operation.Builder; import org.springframework.data.domain.Sort; import org.springframework.lang.Nullable; import org.springframework.web.reactive.function.server.ServerRequest; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.extension.ListOptions; import run.halo.app.extension.router.IListRequest; import run.halo.app.extension.router.QueryParamBuildUtil; import run.halo.app.extension.router.SortableRequest; /** * Query criteria for comment list. * * @author guqing * @since 2.0.0 */ public class CommentQuery extends SortableRequest { public CommentQuery(ServerRequest request) { super(request.exchange()); } @Nullable public String getKeyword() { return queryParams.getFirst("keyword"); } @Nullable public String getOwnerKind() { return queryParams.getFirst("ownerKind"); } @Nullable public String getOwnerName() { return queryParams.getFirst("ownerName"); } @Override public Sort getSort() { // set default sort by last reply time return super.getSort().and(Sort.by(desc("status.lastReplyTime"))); } /** * Convert to list options. */ @Override public ListOptions toListOptions() { var builder = ListOptions.builder(super.toListOptions()); Optional.ofNullable(getKeyword()) .filter(StringUtils::isNotBlank) .ifPresent(keyword -> builder.andQuery(contains("spec.raw", keyword))); Optional.ofNullable(getOwnerName()) .filter(StringUtils::isNotBlank) .ifPresent(ownerName -> { var ownerKind = Optional.ofNullable(getOwnerKind()) .filter(StringUtils::isNotBlank) .orElse(User.KIND); builder.andQuery( equal("spec.owner", Comment.CommentOwner.ownerIdentity(ownerKind, ownerName)) ); }); return builder.build(); } public static void buildParameters(Builder builder) { IListRequest.buildParameters(builder); builder.parameter(QueryParamBuildUtil.sortParameter()) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("keyword") .description("Comments filtered by keyword.") .implementation(String.class)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("ownerKind") .description("Commenter kind.") .implementation(String.class)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("ownerName") .description("Commenter name.") .implementation(String.class)); } } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/CommentRequest.java ================================================ package run.halo.app.content.comment; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.util.UUID; import lombok.Data; import run.halo.app.core.extension.content.Comment; import run.halo.app.extension.Metadata; import run.halo.app.extension.Ref; /** * Request parameter object for {@link Comment}. * * @author guqing * @since 2.0.0 */ @Data public class CommentRequest { @Schema(requiredMode = REQUIRED) private Ref subjectRef; private CommentEmailOwner owner; @Schema(requiredMode = REQUIRED, minLength = 1) private String raw; @Schema(requiredMode = REQUIRED, minLength = 1) private String content; @Schema(defaultValue = "false") private Boolean allowNotification; @Schema(defaultValue = "false") private Boolean hidden; /** * Converts {@link CommentRequest} to {@link Comment}. * * @return a comment */ public Comment toComment() { Comment comment = new Comment(); comment.setMetadata(new Metadata()); comment.getMetadata().setName(UUID.randomUUID().toString()); Comment.CommentSpec spec = new Comment.CommentSpec(); comment.setSpec(spec); spec.setSubjectRef(subjectRef); spec.setRaw(raw); spec.setContent(content); spec.setAllowNotification(allowNotification); spec.setHidden(hidden); if (owner != null) { spec.setOwner(owner.toCommentOwner()); } return comment; } } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/CommentService.java ================================================ package run.halo.app.content.comment; import org.springframework.lang.NonNull; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Comment; import run.halo.app.extension.ListResult; import run.halo.app.extension.Ref; /** * An application service for {@link Comment}. * * @author guqing * @since 2.0.0 */ public interface CommentService { Mono> listComment(CommentQuery query); Mono create(Comment comment); Mono removeBySubject(@NonNull Ref subjectRef); } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java ================================================ package run.halo.app.content.comment; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.isNull; import java.time.Duration; import java.time.Instant; import java.util.function.Function; import org.apache.commons.lang3.BooleanUtils; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Sort; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.counter.CounterService; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.Extension; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.exception.AccessDeniedException; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** * Comment service implementation. * * @author guqing * @since 2.0.0 */ @Component public class CommentServiceImpl extends AbstractCommentService implements CommentService { private final ExtensionGetter extensionGetter; private final SystemConfigFetcher environmentFetcher; public CommentServiceImpl(RoleService roleService, ReactiveExtensionClient client, UserService userService, CounterService counterService, ExtensionGetter extensionGetter, SystemConfigFetcher environmentFetcher) { super(roleService, client, userService, counterService); this.extensionGetter = extensionGetter; this.environmentFetcher = environmentFetcher; } @Override public Mono> listComment(CommentQuery commentQuery) { return this.client.listBy(Comment.class, commentQuery.toListOptions(), commentQuery.toPageRequest()) .flatMap(comments -> Flux.fromStream(comments.get() .map(this::toListedComment)) .flatMapSequential(Function.identity()) .collectList() .map(list -> new ListResult<>(comments.getPage(), comments.getSize(), comments.getTotal(), list) ) ); } @Override public Mono create(Comment comment) { if (comment.getSpec() == null || comment.getSpec().getContent() == null || !isSafeHtml(comment.getSpec().getContent())) { return Mono.error(new ServerWebInputException(""" The content of comment must not be empty or contains unsafe HTML.\ """)); } return environmentFetcher.fetchComment() .flatMap(commentSetting -> { if (Boolean.FALSE.equals(commentSetting.getEnable())) { return Mono.error( new AccessDeniedException("The comment function has been turned off.", "problemDetail.comment.turnedOff", null)); } if (checkCommentOwner(comment, commentSetting.getSystemUserOnly())) { return Mono.error( new AccessDeniedException("Allow only system users to comment.", "problemDetail.comment.systemUsersOnly", null)); } if (comment.getSpec().getTop() == null) { comment.getSpec().setTop(false); } if (comment.getSpec().getPriority() == null) { comment.getSpec().setPriority(0); } comment.getSpec() .setApproved(Boolean.FALSE.equals(commentSetting.getRequireReviewForNew())); if (BooleanUtils.isTrue(comment.getSpec().getApproved()) && comment.getSpec().getApprovedTime() == null) { comment.getSpec().setApprovedTime(Instant.now()); } if (comment.getSpec().getCreationTime() == null) { comment.getSpec().setCreationTime(Instant.now()); } if (comment.getSpec().getHidden() == null) { comment.getSpec().setHidden(false); } return Mono.just(comment); }) .flatMap(populatedComment -> Mono.when(populateOwner(populatedComment), populateApproveState(populatedComment)) .thenReturn(populatedComment) ) .flatMap(client::create); } private Mono populateApproveState(Comment comment) { return hasCommentManagePermission() .filter(Boolean::booleanValue) .doOnNext(hasPermission -> { comment.getSpec().setApproved(true); comment.getSpec().setApprovedTime(Instant.now()); }) .then(); } Mono populateOwner(Comment comment) { if (comment.getSpec().getOwner() != null) { return Mono.empty(); } return fetchCurrentUser() .switchIfEmpty(Mono.error(new IllegalStateException("The owner must not be null."))) .map(this::toCommentOwner) .doOnNext(owner -> comment.getSpec().setOwner(owner)) .then(); } @Override public Mono removeBySubject(@NonNull Ref subjectRef) { Assert.notNull(subjectRef, "The subjectRef must not be null."); return cleanupComments(subjectRef, 200); } private Mono cleanupComments(Ref subjectRef, int batchSize) { // ascending order by creation time and name final var pageRequest = PageRequestImpl.of(1, batchSize, Sort.by("metadata.creationTimestamp", "metadata.name")); // forever loop first page until no more to delete return listCommentsByRef(subjectRef, pageRequest) .flatMap(page -> Flux.fromIterable(page.getItems()) .flatMap(this::deleteWithRetry) .then(page.hasNext() ? cleanupComments(subjectRef, batchSize) : Mono.empty()) ); } private Mono deleteWithRetry(Comment item) { return client.delete(item) .onErrorResume(OptimisticLockingFailureException.class, e -> attemptToDelete(item.getMetadata().getName())); } private Mono attemptToDelete(String name) { return Mono.defer(() -> client.fetch(Comment.class, name) .flatMap(client::delete) ) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)); } Mono> listCommentsByRef(Ref subjectRef, PageRequest pageRequest) { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of( and( equal("spec.subjectRef", Comment.toSubjectRefKey(subjectRef)), isNull("metadata.deletionTimestamp") ) )); return client.listBy(Comment.class, listOptions, pageRequest); } private boolean checkCommentOwner(Comment comment, Boolean onlySystemUser) { Comment.CommentOwner owner = comment.getSpec().getOwner(); if (Boolean.TRUE.equals(onlySystemUser)) { return owner != null && Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind()); } return false; } private Mono toListedComment(Comment comment) { var builder = ListedComment.builder().comment(comment); // not empty var ownerInfoMono = getOwnerInfo(comment.getSpec().getOwner()) .doOnNext(builder::owner); var subjectMono = getCommentSubject(comment.getSpec().getSubjectRef()) .doOnNext(builder::subject); var statsMono = fetchCommentStats(comment.getMetadata().getName()) .doOnNext(builder::stats); return Mono.when(ownerInfoMono, subjectMono, statsMono) .then(Mono.fromSupplier(builder::build)); } @SuppressWarnings("unchecked") Mono getCommentSubject(Ref ref) { return extensionGetter.getExtensions(CommentSubject.class) .filter(subject -> subject.supports(ref)) .next() .flatMap(subject -> subject.get(ref.getName())); } } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/CommentStats.java ================================================ package run.halo.app.content.comment; import lombok.Builder; import lombok.Value; /** * comment stats value object. * * @author LIlGG * @since 2.0.0 */ @Value @Builder public class CommentStats { Integer upvote; public static CommentStats empty() { return CommentStats.builder() .upvote(0) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/ListedComment.java ================================================ package run.halo.app.content.comment; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; import run.halo.app.core.extension.content.Comment; import run.halo.app.extension.Extension; /** * Listed comment. * * @author guqing * @since 2.0.0 */ @Data @Builder public class ListedComment { @Schema(requiredMode = REQUIRED) private Comment comment; @Schema(requiredMode = REQUIRED) private OwnerInfo owner; private Extension subject; @Schema(requiredMode = REQUIRED) private CommentStats stats; } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/ListedReply.java ================================================ package run.halo.app.content.comment; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; import run.halo.app.core.extension.content.Reply; /** * Listed reply for {@link Reply}. * * @author guqing * @since 2.0.0 */ @Data @Builder public class ListedReply { @Schema(requiredMode = REQUIRED) private Reply reply; @Schema(requiredMode = REQUIRED) private OwnerInfo owner; @Schema(requiredMode = REQUIRED) private CommentStats stats; } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/OwnerInfo.java ================================================ package run.halo.app.content.comment; import lombok.Builder; import lombok.Value; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; /** * Comment owner info. * * @author guqing * @since 2.0.0 */ @Value @Builder public class OwnerInfo { String kind; String name; String displayName; String avatar; String email; /** * Convert user to owner info by owner that has an email kind . * * @param owner comment owner reference. * @return owner info. */ public static OwnerInfo from(Comment.CommentOwner owner) { if (!Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) { throw new IllegalArgumentException("Only support 'email' owner kind."); } return OwnerInfo.builder() .kind(owner.getKind()) .name(owner.getName()) .email(owner.getName()) .displayName(owner.getDisplayName()) .avatar(owner.getAnnotation(Comment.CommentOwner.AVATAR_ANNO)) .build(); } /** * Convert user to owner info by {@link User}. * * @param user user extension. * @return owner info. */ public static OwnerInfo from(User user) { return OwnerInfo.builder() .kind(user.getKind()) .name(user.getMetadata().getName()) .email(user.getSpec().getEmail()) .avatar(user.getSpec().getAvatar()) .displayName(user.getSpec().getDisplayName()) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/PostCommentSubject.java ================================================ package run.halo.app.content.comment; import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.infra.ExternalLinkProcessor; /** * Comment subject for post. * * @author guqing * @since 2.0.0 */ @Component @RequiredArgsConstructor public class PostCommentSubject implements CommentSubject { private final ReactiveExtensionClient client; private final ExternalLinkProcessor externalLinkProcessor; @Override public Mono get(String name) { return client.fetch(Post.class, name); } @Override public Mono getSubjectDisplay(String name) { return get(name) .map(post -> { var url = externalLinkProcessor .processLink(post.getStatusOrDefault().getPermalink()); return new SubjectDisplay(post.getSpec().getTitle(), url, "文章"); }); } @Override public boolean supports(Ref ref) { Assert.notNull(ref, "Subject ref must not be null."); var gvk = Post.GVK; return Objects.equals(gvk.group(), ref.getGroup()) && Objects.equals(gvk.kind(), ref.getKind()); } } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelper.java ================================================ package run.halo.app.content.comment; import io.micrometer.common.util.StringUtils; import java.time.Duration; import lombok.RequiredArgsConstructor; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import run.halo.app.content.NotificationReasonConst; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.notification.NotificationCenter; import run.halo.app.notification.UserIdentity; /** * Reply notification subscription helper. * * @author guqing * @since 2.9.0 */ @Component @RequiredArgsConstructor public class ReplyNotificationSubscriptionHelper { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private final NotificationCenter notificationCenter; /** * Subscribe new reply reason for comment. * * @param comment comment */ public void subscribeNewReplyReasonForComment(Comment comment) { subscribeReply(identityFrom(comment.getSpec().getOwner())); } /** * Subscribe new reply reason for reply. * * @param reply reply */ public void subscribeNewReplyReasonForReply(Reply reply) { var subjectOwner = reply.getSpec().getOwner(); subscribeReply(identityFrom(subjectOwner)); } void subscribeReply(UserIdentity identity) { var subscriber = createSubscriber(identity); if (subscriber == null) { return; } var interestReason = new Subscription.InterestReason(); interestReason.setReasonType(NotificationReasonConst.SOMEONE_REPLIED_TO_YOU); interestReason.setExpression("props.repliedOwner == '%s'".formatted(identity.name())); notificationCenter.subscribe(subscriber, interestReason).block(BLOCKING_TIMEOUT); } @Nullable private Subscription.Subscriber createSubscriber(UserIdentity author) { if (StringUtils.isBlank(author.name())) { return null; } Subscription.Subscriber subscriber = new Subscription.Subscriber(); subscriber.setName(author.name()); return subscriber; } public static UserIdentity identityFrom(Comment.CommentOwner owner) { if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) { return UserIdentity.anonymousWithEmail(owner.getName()); } return UserIdentity.of(owner.getName()); } } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/ReplyQuery.java ================================================ package run.halo.app.content.comment; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToListOptions; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import org.apache.commons.lang3.StringUtils; import org.springdoc.core.fn.builders.operation.Builder; import org.springframework.data.domain.Sort; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebInputException; import run.halo.app.core.extension.content.Reply; import run.halo.app.extension.ListOptions; import run.halo.app.extension.PageRequest; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.router.SortableRequest; /** * Query criteria for {@link Reply} list. * * @author guqing * @since 2.0.0 */ public class ReplyQuery extends SortableRequest { public ReplyQuery(ServerWebExchange exchange) { super(exchange); } @Schema(description = "Replies filtered by commentName.") public String getCommentName() { String commentName = queryParams.getFirst("commentName"); if (StringUtils.isBlank(commentName)) { throw new ServerWebInputException("The required parameter 'commentName' is missing."); } return commentName; } /** * Build list options from query criteria. */ public ListOptions toListOptions() { var listOptions = labelAndFieldSelectorToListOptions(getLabelSelector(), getFieldSelector()); var newFieldSelector = listOptions.getFieldSelector() .andQuery(equal("spec.commentName", getCommentName())); listOptions.setFieldSelector(newFieldSelector); return listOptions; } public PageRequest toPageRequest() { var sort = getSort().and(Sort.by("spec.creationTime").ascending()); return PageRequestImpl.of(getPage(), getSize(), sort); } public static void buildParameters(Builder builder) { SortableRequest.buildParameters(builder); builder.parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("commentName") .description("Replies filtered by commentName.") .implementation(String.class) .required(true)); } } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/ReplyRequest.java ================================================ package run.halo.app.content.comment; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.util.UUID; import lombok.Data; import run.halo.app.core.extension.content.Reply; import run.halo.app.extension.Metadata; /** * A request parameter object for {@link Reply}. * * @author guqing * @since 2.0.0 */ @Data public class ReplyRequest { @Schema(requiredMode = REQUIRED, minLength = 1) private String raw; @Schema(requiredMode = REQUIRED, minLength = 1) private String content; @Schema(defaultValue = "false") private Boolean allowNotification; @Schema(defaultValue = "false") private Boolean hidden; private CommentEmailOwner owner; private String quoteReply; /** * Converts {@link ReplyRequest} to {@link Reply}. * * @return a reply */ public Reply toReply() { Reply reply = new Reply(); reply.setMetadata(new Metadata()); reply.getMetadata().setName(UUID.randomUUID().toString()); Reply.ReplySpec spec = new Reply.ReplySpec(); reply.setSpec(spec); spec.setRaw(raw); spec.setContent(content); spec.setAllowNotification(allowNotification); spec.setHidden(hidden); spec.setQuoteReply(quoteReply); if (owner != null) { spec.setOwner(owner.toCommentOwner()); } return reply; } } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/ReplyService.java ================================================ package run.halo.app.content.comment; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Reply; import run.halo.app.extension.ListResult; /** * An application service for {@link Reply}. * * @author guqing * @since 2.0.0 */ public interface ReplyService { Mono create(String commentName, Reply reply); Mono> list(ReplyQuery query); Mono removeAllByComment(String commentName); } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java ================================================ package run.halo.app.content.comment; import static org.apache.commons.lang3.BooleanUtils.isTrue; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.isNull; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.function.Function; import java.util.function.Supplier; import java.util.function.UnaryOperator; import org.apache.commons.lang3.StringUtils; import org.reactivestreams.Publisher; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.util.Assert; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.counter.CounterService; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.exception.RequestRestrictedException; /** * A default implementation of {@link ReplyService}. * * @author guqing * @since 2.0.0 */ @Service public class ReplyServiceImpl extends AbstractCommentService implements ReplyService { private final Supplier requestRestrictedExceptionSupplier = () -> new RequestRestrictedException("problemDetail.comment.waitingForApproval"); public ReplyServiceImpl(RoleService roleService, ReactiveExtensionClient client, UserService userService, CounterService counterService) { super(roleService, client, userService, counterService); } @Override public Mono create(String commentName, Reply reply) { if (reply.getSpec() == null || reply.getSpec().getContent() == null || !isSafeHtml(reply.getSpec().getContent())) { return Mono.error(new ServerWebInputException(""" The content of reply must not be empty or contains unsafe HTML.\ """)); } return client.get(Comment.class, commentName) .flatMap(this::approveComment) .filter(comment -> isTrue(comment.getSpec().getApproved())) .switchIfEmpty(Mono.error(requestRestrictedExceptionSupplier)) .flatMap(comment -> prepareReply(comment, reply)) .flatMap(this::doCreateReply); } private Mono doCreateReply(Reply prepared) { var quotedReply = prepared.getSpec().getQuoteReply(); if (StringUtils.isBlank(quotedReply)) { return client.create(prepared); } return approveReply(quotedReply) .filter(reply -> isTrue(reply.getSpec().getApproved())) .switchIfEmpty(Mono.error(requestRestrictedExceptionSupplier)) .doOnNext(approvedQuoteReply -> prepared.getSpec() .setHidden(approvedQuoteReply.getSpec().getHidden()) ) .flatMap(approvedQuoteReply -> client.create(prepared)); } private Mono approveComment(Comment comment) { return hasCommentManagePermission() .flatMap(hasPermission -> { if (hasPermission) { return doApproveComment(comment); } return Mono.just(comment); }); } private Mono doApproveComment(Comment comment) { UnaryOperator updateFunc = commentToUpdate -> { commentToUpdate.getSpec().setApproved(true); commentToUpdate.getSpec().setApprovedTime(Instant.now()); return commentToUpdate; }; return client.update(updateFunc.apply(comment)) .onErrorResume(OptimisticLockingFailureException.class, e -> updateCommentWithRetry(comment.getMetadata().getName(), updateFunc)); } private Mono approveReply(String replyName) { return hasCommentManagePermission() .flatMap(hasPermission -> { if (hasPermission) { return doApproveReply(replyName); } return client.get(Reply.class, replyName); }); } private Mono doApproveReply(String replyName) { return Mono.defer(() -> client.get(Reply.class, replyName) .flatMap(reply -> { reply.getSpec().setApproved(true); reply.getSpec().setApprovedTime(Instant.now()); return client.update(reply); }) ) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)); } private Mono updateCommentWithRetry(String name, UnaryOperator updateFunc) { return Mono.defer(() -> client.get(Comment.class, name) .map(updateFunc) .flatMap(client::update) ) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)); } private Mono prepareReply(Comment comment, Reply reply) { reply.getSpec().setCommentName(comment.getMetadata().getName()); reply.getSpec().setHidden(comment.getSpec().getHidden()); if (reply.getSpec().getTop() == null) { reply.getSpec().setTop(false); } if (reply.getSpec().getPriority() == null) { reply.getSpec().setPriority(0); } if (reply.getSpec().getCreationTime() == null) { reply.getSpec().setCreationTime(Instant.now()); } if (reply.getSpec().getApproved() == null) { reply.getSpec().setApproved(false); } if (isTrue(reply.getSpec().getApproved()) && reply.getSpec().getApprovedTime() == null) { reply.getSpec().setApprovedTime(Instant.now()); } var steps = new ArrayList>(); var approveItMono = hasCommentManagePermission() .filter(Boolean::booleanValue) .doOnNext(hasPermission -> { reply.getSpec().setApproved(true); reply.getSpec().setApprovedTime(Instant.now()); }); steps.add(approveItMono); var populateOwnerMono = fetchCurrentUser() .switchIfEmpty( Mono.error(new IllegalArgumentException("Reply owner must not be null."))) .doOnNext(user -> reply.getSpec().setOwner(toCommentOwner(user))); if (reply.getSpec().getOwner() == null) { steps.add(populateOwnerMono); } return Mono.when(steps).thenReturn(reply); } @Override public Mono> list(ReplyQuery query) { return client.listBy(Reply.class, query.toListOptions(), query.toPageRequest()) .flatMap(list -> Flux.fromStream(list.get() .map(this::toListedReply)) .flatMapSequential(Function.identity()) .collectList() .map(listedReplies -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), listedReplies)) ); } @Override public Mono removeAllByComment(String commentName) { Assert.notNull(commentName, "The commentName must not be null."); return cleanupComments(commentName, 200); } private Mono cleanupComments(String commentName, int batchSize) { // ascending order by creation time and name final var pageRequest = PageRequestImpl.of(1, batchSize, Sort.by("metadata.creationTimestamp", "metadata.name")); // forever loop first page until no more to delete return listRepliesByComment(commentName, pageRequest) .flatMap(page -> Flux.fromIterable(page.getItems()) .flatMap(this::deleteWithRetry) .then(page.hasNext() ? cleanupComments(commentName, batchSize) : Mono.empty()) ); } private Mono deleteWithRetry(Reply item) { return client.delete(item) .onErrorResume(OptimisticLockingFailureException.class, e -> attemptToDelete(item.getMetadata().getName())); } private Mono attemptToDelete(String name) { return Mono.defer(() -> client.fetch(Reply.class, name) .flatMap(client::delete) ) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)); } Mono> listRepliesByComment(String commentName, PageRequest pageRequest) { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of( and( equal("spec.commentName", commentName), isNull("metadata.deletionTimestamp") ) )); return client.listBy(Reply.class, listOptions, pageRequest); } private Mono toListedReply(Reply reply) { ListedReply.ListedReplyBuilder builder = ListedReply.builder() .reply(reply); return getOwnerInfo(reply.getSpec().getOwner()) .map(ownerInfo -> { builder.owner(ownerInfo); return builder; }) .map(ListedReply.ListedReplyBuilder::build) .flatMap(listedReply -> fetchReplyStats(reply.getMetadata().getName()) .doOnNext(listedReply::setStats) .thenReturn(listedReply)); } } ================================================ FILE: application/src/main/java/run/halo/app/content/comment/SinglePageCommentSubject.java ================================================ package run.halo.app.content.comment; import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.infra.ExternalLinkProcessor; /** * Comment subject for {@link SinglePage}. * * @author guqing * @since 2.0.0 */ @Component @RequiredArgsConstructor public class SinglePageCommentSubject implements CommentSubject { private final ReactiveExtensionClient client; private final ExternalLinkProcessor externalLinkProcessor; @Override public Mono get(String name) { return client.fetch(SinglePage.class, name); } @Override public Mono getSubjectDisplay(String name) { return get(name) .map(page -> { var url = externalLinkProcessor .processLink(page.getStatusOrDefault().getPermalink()); return new SubjectDisplay(page.getSpec().getTitle(), url, "页面"); }); } @Override public boolean supports(Ref ref) { Assert.notNull(ref, "Subject ref must not be null."); var gvk = SinglePage.GVK; return Objects.equals(gvk.group(), ref.getGroup()) && Objects.equals(gvk.kind(), ref.getKind()); } } ================================================ FILE: application/src/main/java/run/halo/app/content/impl/CategoryServiceImpl.java ================================================ package run.halo.app.content.impl; import static run.halo.app.extension.index.query.Queries.equal; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Sort; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.content.CategoryService; import run.halo.app.core.extension.content.Category; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.selector.FieldSelector; @Component @RequiredArgsConstructor public class CategoryServiceImpl implements CategoryService { private final ReactiveExtensionClient client; @Override public Flux listChildren(@NonNull String categoryName) { return client.fetch(Category.class, categoryName) .expand(category -> { var children = category.getSpec().getChildren(); if (children == null || children.isEmpty()) { return Mono.empty(); } return Flux.fromIterable(children) .flatMap(name -> client.fetch(Category.class, name)) .filter(this::isNotIndependent); }); } @Override public Mono getParentByName(@NonNull String name) { if (StringUtils.isBlank(name)) { return Mono.empty(); } var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of( equal("spec.children", name) )); return client.listBy(Category.class, listOptions, PageRequestImpl.of(1, 1, defaultSort()) ) .flatMap(result -> Mono.justOrEmpty(ListResult.first(result))); } @Override public Mono isCategoryHidden(@NonNull String categoryName) { return client.fetch(Category.class, categoryName) .expand(category -> getParentByName(category.getMetadata().getName())) .filter(category -> category.getSpec().isHideFromList()) .hasElements(); } static Sort defaultSort() { return Sort.by(Sort.Order.desc("spec.priority"), Sort.Order.desc("metadata.creationTimestamp"), Sort.Order.desc("metadata.name")); } private boolean isNotIndependent(Category category) { return !category.getSpec().isPreventParentPostCascadeQuery(); } } ================================================ FILE: application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java ================================================ package run.halo.app.content.impl; import static run.halo.app.extension.index.query.Queries.in; import java.time.Duration; import java.time.Instant; import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.function.Function; import java.util.function.ToIntFunction; import java.util.function.UnaryOperator; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Sort; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.content.AbstractContentService; import run.halo.app.content.CategoryService; import run.halo.app.content.ContentRequest; import run.halo.app.content.ContentWrapper; import run.halo.app.content.Contributor; import run.halo.app.content.ListedPost; import run.halo.app.content.ListedSnapshotDto; import run.halo.app.content.PostQuery; import run.halo.app.content.PostRequest; import run.halo.app.content.PostService; import run.halo.app.content.Stats; import run.halo.app.core.counter.CounterService; import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.core.extension.content.Tag; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.MetadataOperator; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionStatus; /** * A default implementation of {@link PostService}. * * @author guqing * @since 2.0.0 */ @Slf4j @Component public class PostServiceImpl extends AbstractContentService implements PostService { private final ReactiveExtensionClient client; private final CounterService counterService; private final UserService userService; private final CategoryService categoryService; public PostServiceImpl(ReactiveExtensionClient client, CounterService counterService, UserService userService, CategoryService categoryService) { super(client); this.client = client; this.counterService = counterService; this.userService = userService; this.categoryService = categoryService; } @Override public Mono> listPost(PostQuery query) { return buildListOptions(query) .flatMap(listOptions -> client.listBy(Post.class, listOptions, query.toPageRequest()) ) .flatMap(listResult -> Flux.fromStream(listResult.get()) .map(this::getListedPost) .flatMapSequential(Function.identity()) .collectList() .map(listedPosts -> new ListResult<>(listResult.getPage(), listResult.getSize(), listResult.getTotal(), listedPosts) ) .defaultIfEmpty(ListResult.emptyResult()) ); } Mono buildListOptions(PostQuery query) { var categoryName = query.getCategoryWithChildren(); if (categoryName == null) { return Mono.just(query.toListOptions()); } return categoryService.listChildren(categoryName) .collectList() .map(categories -> { var categoryNames = categories.stream() .map(Category::getMetadata) .map(MetadataOperator::getName) .toList(); var listOptions = query.toListOptions(); var newFiledSelector = listOptions.getFieldSelector() .andQuery(in("spec.categories", categoryNames)); listOptions.setFieldSelector(newFiledSelector); return listOptions; }); } Mono fetchStats(Post post) { Assert.notNull(post, "The post must not be null."); String name = post.getMetadata().getName(); return counterService.getByName(MeterUtils.nameOf(Post.class, name)) .map(counter -> Stats.builder() .visit(counter.getVisit()) .upvote(counter.getUpvote()) .totalComment(counter.getTotalComment()) .approvedComment(counter.getApprovedComment()) .build() ) .defaultIfEmpty(Stats.empty()); } private Mono getListedPost(Post post) { Assert.notNull(post, "The post must not be null."); var listedPost = new ListedPost().setPost(post); var statsMono = fetchStats(post) .doOnNext(listedPost::setStats); var tagsMono = listTags(post.getSpec().getTags()) .collectList() .doOnNext(listedPost::setTags); var categoriesMono = listCategories(post.getSpec().getCategories()) .collectList() .doOnNext(listedPost::setCategories); var contributorsMono = listContributors(post.getStatusOrDefault().getContributors()) .collectList() .doOnNext(listedPost::setContributors); var ownerMono = userService.getUserOrGhost(post.getSpec().getOwner()) .map(user -> { Contributor contributor = new Contributor(); contributor.setName(user.getMetadata().getName()); contributor.setDisplayName(user.getSpec().getDisplayName()); contributor.setAvatar(user.getSpec().getAvatar()); return contributor; }) .doOnNext(listedPost::setOwner); return Mono.when(statsMono, tagsMono, categoriesMono, contributorsMono, ownerMono) .thenReturn(listedPost); } private Flux listTags(List tagNames) { if (CollectionUtils.isEmpty(tagNames)) { return Flux.empty(); } var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of(in("metadata.name", tagNames))); return client.listAll(Tag.class, listOptions, Sort.by("metadata.creationTimestamp")); } @Override public Flux listCategories(List categoryNames) { if (CollectionUtils.isEmpty(categoryNames)) { return Flux.empty(); } ToIntFunction comparator = category -> categoryNames.indexOf(category.getMetadata().getName()); var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of(in("metadata.name", categoryNames))); return client.listAll(Category.class, listOptions, Sort.unsorted()) .sort(Comparator.comparingInt(comparator)); } private Flux listContributors(List usernames) { if (CollectionUtils.isEmpty(usernames)) { return Flux.empty(); } return Flux.fromIterable(usernames) .flatMapSequential(userService::getUserOrGhost) .map(user -> { Contributor contributor = new Contributor(); contributor.setName(user.getMetadata().getName()); contributor.setDisplayName(user.getSpec().getDisplayName()); contributor.setAvatar(user.getSpec().getAvatar()); return contributor; }); } @Override public Mono draftPost(PostRequest postRequest) { return Mono.defer( () -> { var post = postRequest.post(); return getContextUsername() .doOnNext(username -> post.getSpec().setOwner(username)) .thenReturn(post); }) .flatMap(client::create) .flatMap(post -> { if (postRequest.content() == null) { return Mono.just(post); } var contentRequest = new ContentRequest(Ref.of(post), post.getSpec().getHeadSnapshot(), null, postRequest.content().raw(), postRequest.content().content(), postRequest.content().rawType()); return draftContent(post.getSpec().getBaseSnapshot(), contentRequest) .flatMap(contentWrapper -> waitForPostToDraftConcludingWork( post.getMetadata().getName(), contentWrapper) ); }) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)); } private Mono waitForPostToDraftConcludingWork(String postName, ContentWrapper contentWrapper) { return Mono.defer(() -> client.fetch(Post.class, postName) .flatMap(post -> { post.getSpec().setBaseSnapshot(contentWrapper.getSnapshotName()); post.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName()); if (Objects.equals(true, post.getSpec().getPublish())) { post.getSpec().setReleaseSnapshot(post.getSpec().getHeadSnapshot()); } Condition condition = Condition.builder() .type(Post.PostPhase.DRAFT.name()) .reason("DraftedSuccessfully") .message("Drafted post successfully.") .status(ConditionStatus.TRUE) .lastTransitionTime(Instant.now()) .build(); Post.PostStatus status = post.getStatusOrDefault(); status.setPhase(Post.PostPhase.DRAFT.name()); status.getConditionsOrDefault().addAndEvictFIFO(condition); return client.update(post); })) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)); } @Override public Mono updatePost(PostRequest postRequest) { Post post = postRequest.post(); String headSnapshot = post.getSpec().getHeadSnapshot(); String releaseSnapshot = post.getSpec().getReleaseSnapshot(); String baseSnapshot = post.getSpec().getBaseSnapshot(); if (StringUtils.equals(releaseSnapshot, headSnapshot)) { // create new snapshot to update first return draftContent(baseSnapshot, postRequest.contentRequest(), headSnapshot) .flatMap(contentWrapper -> { post.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName()); return client.update(post); }); } return updateContent(baseSnapshot, postRequest.contentRequest()) .flatMap(contentWrapper -> { post.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName()); return client.update(post); }); } @Override public Mono updateBy(@NonNull Post post) { return client.update(post); } @Override public Mono getHeadContent(String postName) { return client.get(Post.class, postName) .flatMap(this::getHeadContent); } @Override public Mono getHeadContent(Post post) { var headSnapshot = post.getSpec().getHeadSnapshot(); return getContent(headSnapshot, post.getSpec().getBaseSnapshot()); } @Override public Mono getReleaseContent(String postName) { return client.get(Post.class, postName) .flatMap(this::getReleaseContent); } @Override public Mono getReleaseContent(Post post) { var releaseSnapshot = post.getSpec().getReleaseSnapshot(); return getContent(releaseSnapshot, post.getSpec().getBaseSnapshot()); } @Override public Flux listSnapshots(String name) { return client.fetch(Post.class, name) .flatMapMany(page -> listSnapshotsBy(Ref.of(page))) .map(ListedSnapshotDto::from); } @Override public Mono publish(Post post) { var spec = post.getSpec(); spec.setPublish(true); if (spec.getHeadSnapshot() == null) { spec.setHeadSnapshot(spec.getBaseSnapshot()); } spec.setReleaseSnapshot(spec.getHeadSnapshot()); return client.update(post); } @Override public Mono unpublish(Post post) { post.getSpec().setPublish(false); return client.update(post); } @Override public Mono getByUsername(String postName, String username) { return client.get(Post.class, postName) .filter(post -> post.getSpec() != null) .filter(post -> Objects.equals(username, post.getSpec().getOwner())); } @Override public Mono revertToSpecifiedSnapshot(String postName, String snapshotName) { return client.get(Post.class, postName) .filter(post -> { var head = post.getSpec().getHeadSnapshot(); return !StringUtils.equals(head, snapshotName); }) .flatMap(post -> { var baseSnapshot = post.getSpec().getBaseSnapshot(); return getContent(snapshotName, baseSnapshot) .map(content -> ContentRequest.builder() .subjectRef(Ref.of(post)) .headSnapshotName(post.getSpec().getHeadSnapshot()) .content(content.getContent()) .raw(content.getRaw()) .rawType(content.getRawType()) .build() ) .flatMap(contentRequest -> draftContent(baseSnapshot, contentRequest)) .flatMap(content -> { post.getSpec().setHeadSnapshot(content.getSnapshotName()); return publishPostWithRetry(post); }); }); } @Override public Mono deleteContent(String postName, String snapshotName) { return client.get(Post.class, postName) .flatMap(post -> { var headSnapshotName = post.getSpec().getHeadSnapshot(); if (StringUtils.equals(headSnapshotName, snapshotName)) { return updatePostWithRetry(post, record -> { // update head to release record.getSpec().setHeadSnapshot(record.getSpec().getReleaseSnapshot()); return record; }); } return Mono.just(post); }) .flatMap(post -> { var baseSnapshotName = post.getSpec().getBaseSnapshot(); var releaseSnapshotName = post.getSpec().getReleaseSnapshot(); if (StringUtils.equals(releaseSnapshotName, snapshotName)) { return Mono.error(new ServerWebInputException( "The snapshot to delete is the release snapshot, please" + " revert to another snapshot first.")); } if (StringUtils.equals(baseSnapshotName, snapshotName)) { return Mono.error( new ServerWebInputException("The first snapshot cannot be deleted.")); } return client.fetch(Snapshot.class, snapshotName) .flatMap(client::delete) .flatMap(deleted -> restoredContent(baseSnapshotName, deleted)); }); } @Override public Mono recycleBy(String postName, String username) { return getByUsername(postName, username) .flatMap(post -> updatePostWithRetry(post, record -> { record.getSpec().setDeleted(true); return record; })); } private Mono updatePostWithRetry(Post post, UnaryOperator func) { return client.update(func.apply(post)) .onErrorResume(OptimisticLockingFailureException.class, e -> Mono.defer(() -> client.get(Post.class, post.getMetadata().getName()) .map(func) .flatMap(client::update) ) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)) ); } Mono publishPostWithRetry(Post post) { return publish(post) .onErrorResume(OptimisticLockingFailureException.class, e -> Mono.defer(() -> client.get(Post.class, post.getMetadata().getName()) .flatMap(this::publish)) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)) ); } } ================================================ FILE: application/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java ================================================ package run.halo.app.content.impl; import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Objects; import java.util.function.Function; import java.util.function.UnaryOperator; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.util.Assert; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.content.AbstractContentService; import run.halo.app.content.ContentRequest; import run.halo.app.content.ContentWrapper; import run.halo.app.content.Contributor; import run.halo.app.content.ListedSinglePage; import run.halo.app.content.ListedSnapshotDto; import run.halo.app.content.SinglePageQuery; import run.halo.app.content.SinglePageRequest; import run.halo.app.content.SinglePageService; import run.halo.app.content.Stats; import run.halo.app.core.counter.CounterService; import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionStatus; /** * Single page service implementation. * * @author guqing * @since 2.0.0 */ @Slf4j @Service public class SinglePageServiceImpl extends AbstractContentService implements SinglePageService { private final ReactiveExtensionClient client; private final CounterService counterService; private final UserService userService; public SinglePageServiceImpl(ReactiveExtensionClient client, CounterService counterService, UserService userService) { super(client); this.client = client; this.counterService = counterService; this.userService = userService; } @Override public Mono getHeadContent(String singlePageName) { return client.get(SinglePage.class, singlePageName) .flatMap(singlePage -> { String headSnapshot = singlePage.getSpec().getHeadSnapshot(); return getContent(headSnapshot, singlePage.getSpec().getBaseSnapshot()); }); } @Override public Mono getReleaseContent(String singlePageName) { return client.get(SinglePage.class, singlePageName) .flatMap(singlePage -> { String releaseSnapshot = singlePage.getSpec().getReleaseSnapshot(); return getContent(releaseSnapshot, singlePage.getSpec().getBaseSnapshot()); }); } @Override public Flux listSnapshots(String pageName) { return client.fetch(SinglePage.class, pageName) .flatMapMany(page -> listSnapshotsBy(Ref.of(page))) .map(ListedSnapshotDto::from); } @Override public Mono> list(SinglePageQuery query) { return client.listBy(SinglePage.class, query.toListOptions(), query.toPageRequest()) .flatMap(listResult -> Flux.fromStream(listResult.get().map(this::getListedSinglePage)) .flatMapSequential(Function.identity()) .collectList() .map(listedSinglePages -> new ListResult<>( listResult.getPage(), listResult.getSize(), listResult.getTotal(), listedSinglePages) ) ); } @Override public Mono draft(SinglePageRequest pageRequest) { return Mono.defer( () -> { SinglePage page = pageRequest.page(); return getContextUsername() .doOnNext(username -> page.getSpec().setOwner(username)) .thenReturn(page); } ) .flatMap(client::create) .flatMap(page -> { var contentRequest = new ContentRequest(Ref.of(page), page.getSpec().getHeadSnapshot(), null, pageRequest.content().raw(), pageRequest.content().content(), pageRequest.content().rawType()); return draftContent(page.getSpec().getBaseSnapshot(), contentRequest) .flatMap( contentWrapper -> waitForPageToDraftConcludingWork( page.getMetadata().getName(), contentWrapper ) ); }); } private Mono waitForPageToDraftConcludingWork(String pageName, ContentWrapper contentWrapper) { return Mono.defer(() -> client.fetch(SinglePage.class, pageName) .flatMap(page -> { page.getSpec().setBaseSnapshot(contentWrapper.getSnapshotName()); page.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName()); if (Objects.equals(true, page.getSpec().getPublish())) { page.getSpec().setReleaseSnapshot(page.getSpec().getHeadSnapshot()); } Condition condition = Condition.builder() .type(Post.PostPhase.DRAFT.name()) .reason("DraftedSuccessfully") .message("Drafted page successfully") .status(ConditionStatus.TRUE) .lastTransitionTime(Instant.now()) .build(); SinglePage.SinglePageStatus status = page.getStatusOrDefault(); status.getConditionsOrDefault().addAndEvictFIFO(condition); status.setPhase(Post.PostPhase.DRAFT.name()); return client.update(page); })) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance) ); } @Override public Mono update(SinglePageRequest pageRequest) { SinglePage page = pageRequest.page(); String headSnapshot = page.getSpec().getHeadSnapshot(); String releaseSnapshot = page.getSpec().getReleaseSnapshot(); String baseSnapshot = page.getSpec().getBaseSnapshot(); // create new snapshot to update first if (StringUtils.equals(headSnapshot, releaseSnapshot)) { return draftContent(baseSnapshot, pageRequest.contentRequest(), headSnapshot) .flatMap(contentWrapper -> { page.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName()); return client.update(page); }); } return updateContent(baseSnapshot, pageRequest.contentRequest()) .flatMap(contentWrapper -> { page.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName()); return client.update(page); }); } @Override public Mono revertToSpecifiedSnapshot(String pageName, String snapshotName) { return client.get(SinglePage.class, pageName) .filter(page -> { var head = page.getSpec().getHeadSnapshot(); return !StringUtils.equals(head, snapshotName); }) .flatMap(page -> { var baseSnapshot = page.getSpec().getBaseSnapshot(); return getContent(snapshotName, baseSnapshot) .map(content -> ContentRequest.builder() .subjectRef(Ref.of(page)) .headSnapshotName(page.getSpec().getHeadSnapshot()) .content(content.getContent()) .raw(content.getRaw()) .rawType(content.getRawType()) .build() ) .flatMap(contentRequest -> draftContent(baseSnapshot, contentRequest)) .flatMap(content -> { page.getSpec().setHeadSnapshot(content.getSnapshotName()); return publishPageWithRetry(page); }); }); } @Override public Mono deleteContent(String pageName, String snapshotName) { return client.get(SinglePage.class, pageName) .flatMap(page -> { var headSnapshotName = page.getSpec().getHeadSnapshot(); if (StringUtils.equals(headSnapshotName, snapshotName)) { return updatePageWithRetry(page, record -> { // update head to release page.getSpec().setHeadSnapshot(page.getSpec().getReleaseSnapshot()); return record; }); } return Mono.just(page); }) .flatMap(page -> { var baseSnapshotName = page.getSpec().getBaseSnapshot(); var releaseSnapshotName = page.getSpec().getReleaseSnapshot(); if (StringUtils.equals(releaseSnapshotName, snapshotName)) { return Mono.error(new ServerWebInputException( "The snapshot to delete is the release snapshot, please" + " revert to another snapshot first.")); } if (StringUtils.equals(baseSnapshotName, snapshotName)) { return Mono.error( new ServerWebInputException("The first snapshot cannot be deleted.")); } return client.fetch(Snapshot.class, snapshotName) .flatMap(client::delete) .flatMap(deleted -> restoredContent(baseSnapshotName, deleted)); }); } private Mono updatePageWithRetry(SinglePage page, UnaryOperator func) { return client.update(func.apply(page)) .onErrorResume(OptimisticLockingFailureException.class, e -> Mono.defer(() -> client.get(SinglePage.class, page.getMetadata().getName()) .map(func) .flatMap(client::update) ) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)) ); } private Mono publish(SinglePage singlePage) { var spec = singlePage.getSpec(); spec.setPublish(true); if (spec.getHeadSnapshot() == null) { spec.setHeadSnapshot(spec.getBaseSnapshot()); } spec.setReleaseSnapshot(spec.getHeadSnapshot()); return client.update(singlePage); } Mono publishPageWithRetry(SinglePage page) { return publish(page) .onErrorResume(OptimisticLockingFailureException.class, e -> Mono.defer(() -> client.get(SinglePage.class, page.getMetadata().getName()) .flatMap(this::publish)) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)) ); } private Mono getListedSinglePage(SinglePage singlePage) { Assert.notNull(singlePage, "The singlePage must not be null."); var listedSinglePage = new ListedSinglePage() .setPage(singlePage); var statsMono = fetchStats(singlePage) .doOnNext(listedSinglePage::setStats); var contributorsMono = listContributors(singlePage.getStatusOrDefault().getContributors()) .collectList() .doOnNext(listedSinglePage::setContributors); var ownerMono = userService.getUserOrGhost(singlePage.getSpec().getOwner()) .map(user -> { Contributor contributor = new Contributor(); contributor.setName(user.getMetadata().getName()); contributor.setDisplayName(user.getSpec().getDisplayName()); contributor.setAvatar(user.getSpec().getAvatar()); return contributor; }) .doOnNext(listedSinglePage::setOwner); return Mono.when(statsMono, contributorsMono, ownerMono) .thenReturn(listedSinglePage); } Mono fetchStats(SinglePage singlePage) { Assert.notNull(singlePage, "The singlePage must not be null."); String name = singlePage.getMetadata().getName(); return counterService.getByName(MeterUtils.nameOf(SinglePage.class, name)) .map(counter -> Stats.builder() .visit(counter.getVisit()) .upvote(counter.getUpvote()) .totalComment(counter.getTotalComment()) .approvedComment(counter.getApprovedComment()) .build() ) .defaultIfEmpty(Stats.empty()); } private Flux listContributors(List usernames) { if (usernames == null) { return Flux.empty(); } return Flux.fromIterable(usernames) .flatMap(userService::getUserOrGhost) .map(user -> { Contributor contributor = new Contributor(); contributor.setName(user.getMetadata().getName()); contributor.setDisplayName(user.getSpec().getDisplayName()); contributor.setAvatar(user.getSpec().getAvatar()); return contributor; }); } } ================================================ FILE: application/src/main/java/run/halo/app/content/impl/SnapshotServiceImpl.java ================================================ package run.halo.app.content.impl; import java.time.Clock; import java.util.HashMap; import java.util.Objects; import org.apache.commons.lang3.StringUtils; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; import run.halo.app.content.Content; import run.halo.app.content.PatchUtils; import run.halo.app.content.SnapshotService; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.extension.ReactiveExtensionClient; @Service public class SnapshotServiceImpl implements SnapshotService { private final ReactiveExtensionClient client; private final Clock clock; public SnapshotServiceImpl(ReactiveExtensionClient client) { this.client = client; this.clock = Clock.systemDefaultZone(); } @Override public Mono getBy(String snapshotName) { return client.get(Snapshot.class, snapshotName); } @Override public Mono getPatchedBy(String snapshotName, String baseSnapshotName) { if (StringUtils.isBlank(snapshotName) || StringUtils.isBlank(baseSnapshotName)) { return Mono.empty(); } return client.fetch(Snapshot.class, baseSnapshotName) .filter(Snapshot::isBaseSnapshot) .switchIfEmpty(Mono.error(() -> new IllegalArgumentException( "The snapshot " + baseSnapshotName + " is not a base snapshot."))) .flatMap(baseSnapshot -> Mono.defer(() -> { if (Objects.equals(snapshotName, baseSnapshotName)) { return Mono.just(baseSnapshot); } return client.fetch(Snapshot.class, snapshotName); }).doOnNext(snapshot -> { var baseRaw = baseSnapshot.getSpec().getRawPatch(); var baseContent = baseSnapshot.getSpec().getContentPatch(); var rawPatch = snapshot.getSpec().getRawPatch(); var contentPatch = snapshot.getSpec().getContentPatch(); var annotations = snapshot.getMetadata().getAnnotations(); if (annotations == null) { annotations = new HashMap<>(); snapshot.getMetadata().setAnnotations(annotations); } String patchedContent = baseContent; String patchedRaw = baseRaw; if (!Objects.equals(snapshot, baseSnapshot)) { patchedContent = PatchUtils.applyPatch(baseContent, contentPatch); patchedRaw = PatchUtils.applyPatch(baseRaw, rawPatch); } annotations.put(Snapshot.PATCHED_CONTENT_ANNO, patchedContent); annotations.put(Snapshot.PATCHED_RAW_ANNO, patchedRaw); }) ); } @Override public Mono patchAndCreate(@NonNull Snapshot snapshot, @Nullable Snapshot baseSnapshot, @NonNull Content content) { return Mono.just(snapshot) .doOnNext(s -> this.patch(s, baseSnapshot, content)) .flatMap(client::create); } @Override public Mono patchAndUpdate(@NonNull Snapshot snapshot, @NonNull Snapshot baseSnapshot, @NonNull Content content) { return Mono.just(snapshot) .doOnNext(s -> this.patch(s, baseSnapshot, content)) .flatMap(client::update); } private void patch(@NonNull Snapshot snapshot, @Nullable Snapshot baseSnapshot, @NonNull Content content) { var annotations = snapshot.getMetadata().getAnnotations(); if (annotations != null) { annotations.remove(Snapshot.PATCHED_CONTENT_ANNO); annotations.remove(Snapshot.PATCHED_RAW_ANNO); } var spec = snapshot.getSpec(); if (spec == null) { spec = new Snapshot.SnapShotSpec(); } spec.setRawType(content.rawType()); if (baseSnapshot == null || Objects.equals(snapshot, baseSnapshot)) { // indicate the snapshot is a base snapshot // update raw and content directly spec.setRawPatch(content.raw()); spec.setContentPatch(content.content()); } else { // apply the patch and set the raw and content var baseSpec = baseSnapshot.getSpec(); var baseContent = baseSpec.getContentPatch(); var baseRaw = baseSpec.getRawPatch(); var rawPatch = PatchUtils.diffToJsonPatch(baseRaw, content.raw()); var contentPatch = PatchUtils.diffToJsonPatch(baseContent, content.content()); spec.setRawPatch(rawPatch); spec.setContentPatch(contentPatch); } spec.setLastModifyTime(clock.instant()); } } ================================================ FILE: application/src/main/java/run/halo/app/content/permalinks/CategoryPermalinkPolicy.java ================================================ package run.halo.app.content.permalinks; import static org.springframework.web.util.UriUtils.encode; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Constant; import run.halo.app.extension.MetadataUtil; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.PathUtils; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.theme.utils.PatternUtils; /** * @author guqing * @since 2.0.0 */ @Component @RequiredArgsConstructor public class CategoryPermalinkPolicy implements PermalinkPolicy { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private static final String DEFAULT_PERMALINK_PREFIX = SystemSetting.ThemeRouteRules.empty().getCategories(); private final ExternalUrlSupplier externalUrlSupplier; private final SystemConfigFetcher environmentFetcher; @Override public String permalink(Category category) { Map annotations = MetadataUtil.nullSafeAnnotations(category); String permalinkPrefix = annotations.getOrDefault(Constant.PERMALINK_PATTERN_ANNO, DEFAULT_PERMALINK_PREFIX); String slug = encode(category.getSpec().getSlug(), StandardCharsets.UTF_8); String path = PathUtils.combinePath(permalinkPrefix, slug); return externalUrlSupplier.get() .resolve(path) .normalize().toString(); } public String pattern() { return environmentFetcher.fetchRouteRules() .map(SystemSetting.ThemeRouteRules::getCategories) .defaultIfEmpty(DEFAULT_PERMALINK_PREFIX) .map(PatternUtils::normalizePattern) .block(BLOCKING_TIMEOUT); } } ================================================ FILE: application/src/main/java/run/halo/app/content/permalinks/ExtensionLocator.java ================================================ package run.halo.app.content.permalinks; import java.util.Objects; import run.halo.app.extension.GroupVersionKind; /** * Slug can be modified, so it is not included in {@link #equals(Object)} and {@link #hashCode()}. * * @param gvk group version kind * @param name extension name * @param slug extension slug */ public record ExtensionLocator(GroupVersionKind gvk, String name, String slug) { /** * Create a new {@link ExtensionLocator} instance. * * @param gvk group version kind * @param name extension name * @param slug extension slug */ public ExtensionLocator { Objects.requireNonNull(gvk, "Group version kind must not be null"); Objects.requireNonNull(name, "Extension name must not be null"); Objects.requireNonNull(slug, "Extension slug must not be null"); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } ExtensionLocator locator = (ExtensionLocator) o; return gvk.equals(locator.gvk) && name.equals(locator.name); } @Override public int hashCode() { return Objects.hash(gvk, name); } } ================================================ FILE: application/src/main/java/run/halo/app/content/permalinks/PermalinkPolicy.java ================================================ package run.halo.app.content.permalinks; import org.springframework.util.PropertyPlaceholderHelper; import run.halo.app.extension.AbstractExtension; /** * @author guqing * @since 2.0.0 */ public interface PermalinkPolicy { PropertyPlaceholderHelper PROPERTY_PLACEHOLDER_HELPER = new PropertyPlaceholderHelper("{", "}"); String permalink(T extension); } ================================================ FILE: application/src/main/java/run/halo/app/content/permalinks/PostPermalinkPolicy.java ================================================ package run.halo.app.content.permalinks; import static org.springframework.web.util.UriUtils.encode; import java.nio.charset.StandardCharsets; import java.text.DecimalFormat; import java.text.NumberFormat; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.Map; import java.util.Properties; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import run.halo.app.content.PostService; import run.halo.app.core.extension.content.Constant; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.MetadataUtil; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.PathUtils; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.theme.utils.PatternUtils; /** * @author guqing * @since 2.0.0 */ @Component @RequiredArgsConstructor public class PostPermalinkPolicy implements PermalinkPolicy { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; public static final String DEFAULT_CATEGORY = "default"; private static final String DEFAULT_PERMALINK_PATTERN = SystemSetting.ThemeRouteRules.empty().getPost(); private static final NumberFormat NUMBER_FORMAT = new DecimalFormat("00"); private final SystemConfigFetcher environmentFetcher; private final ExternalUrlSupplier externalUrlSupplier; private final PostService postService; @Override public String permalink(Post post) { Map annotations = MetadataUtil.nullSafeAnnotations(post); String permalinkPattern = annotations.getOrDefault(Constant.PERMALINK_PATTERN_ANNO, DEFAULT_PERMALINK_PATTERN); return createPermalink(post, permalinkPattern); } public String pattern() { return environmentFetcher.fetchRouteRules() .map(PatternUtils::normalizePostPattern) .defaultIfEmpty(DEFAULT_PERMALINK_PATTERN) .block(BLOCKING_TIMEOUT); } private String createPermalink(Post post, String pattern) { Instant archiveTime = post.getSpec().getPublishTime(); if (archiveTime == null) { archiveTime = post.getMetadata().getCreationTimestamp(); } ZonedDateTime zonedDateTime = archiveTime.atZone(ZoneId.systemDefault()); Properties properties = new Properties(); properties.put("name", post.getMetadata().getName()); properties.put("slug", encode(post.getSpec().getSlug(), StandardCharsets.UTF_8)); properties.put("year", String.valueOf(zonedDateTime.getYear())); properties.put("month", NUMBER_FORMAT.format(zonedDateTime.getMonthValue())); properties.put("day", NUMBER_FORMAT.format(zonedDateTime.getDayOfMonth())); var categorySlug = postService.listCategories(post.getSpec().getCategories()) .next() .blockOptional(BLOCKING_TIMEOUT) .map(category -> category.getSpec().getSlug()) .orElse(DEFAULT_CATEGORY); properties.put("categorySlug", categorySlug); String simplifiedPattern = PathUtils.simplifyPathPattern(pattern); String permalink = PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders(simplifiedPattern, properties); return externalUrlSupplier.get() .resolve(permalink) .normalize() .toString(); } } ================================================ FILE: application/src/main/java/run/halo/app/content/permalinks/TagPermalinkPolicy.java ================================================ package run.halo.app.content.permalinks; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.web.util.UriUtils; import run.halo.app.core.extension.content.Constant; import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.MetadataUtil; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.PathUtils; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.theme.utils.PatternUtils; /** * @author guqing * @since 2.0.0 */ @Component @RequiredArgsConstructor public class TagPermalinkPolicy implements PermalinkPolicy { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private static final String DEFAULT_PERMALINK_PREFIX = SystemSetting.ThemeRouteRules.empty().getTags(); private final ExternalUrlSupplier externalUrlSupplier; private final SystemConfigFetcher environmentFetcher; @Override public String permalink(Tag tag) { Map annotations = MetadataUtil.nullSafeAnnotations(tag); String permalinkPrefix = annotations.getOrDefault(Constant.PERMALINK_PATTERN_ANNO, DEFAULT_PERMALINK_PREFIX); String slug = UriUtils.encode(tag.getSpec().getSlug(), StandardCharsets.UTF_8); String path = PathUtils.combinePath(permalinkPrefix, slug); return externalUrlSupplier.get() .resolve(path) .normalize().toString(); } public String pattern() { return environmentFetcher.fetchRouteRules() .map(SystemSetting.ThemeRouteRules::getTags) .defaultIfEmpty(DEFAULT_PERMALINK_PREFIX) .map(PatternUtils::normalizePattern) .block(BLOCKING_TIMEOUT); } } ================================================ FILE: application/src/main/java/run/halo/app/content/stats/PostStatsUpdater.java ================================================ package run.halo.app.content.stats; import java.time.Duration; import java.time.Instant; import org.springframework.context.SmartLifecycle; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import run.halo.app.content.Stats; import run.halo.app.core.extension.content.Post; import run.halo.app.event.post.PostStatsChangedEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.DefaultController; import run.halo.app.extension.controller.DefaultQueue; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.RequestQueue; import run.halo.app.infra.InitializationPhase; import run.halo.app.infra.utils.JsonUtils; @Component public class PostStatsUpdater implements Reconciler, SmartLifecycle { private volatile boolean running = false; private final ExtensionClient client; private final RequestQueue queue; private final Controller controller; public PostStatsUpdater(ExtensionClient client) { this.client = client; queue = new DefaultQueue<>(Instant::now); controller = this.setupWith(null); } @Override public Result reconcile(StatsRequest request) { client.fetch(Post.class, request.postName()).ifPresent(post -> { var annotations = MetadataUtil.nullSafeAnnotations(post); annotations.put(Post.STATS_ANNO, JsonUtils.objectToJson(request.stats())); client.update(post); }); return Result.doNotRetry(); } @Override public Controller setupWith(ControllerBuilder builder) { return new DefaultController<>( this.getClass().getName(), this, queue, null, Duration.ofMillis(100), Duration.ofMinutes(10)); } @Override public void start() { this.controller.start(); this.running = true; } @Override public void stop() { this.running = false; this.controller.dispose(); } @Override public boolean isRunning() { return this.running; } @Override public int getPhase() { return InitializationPhase.CONTROLLERS.getPhase(); } @EventListener(PostStatsChangedEvent.class) public void onReplyEvent(PostStatsChangedEvent event) { var counter = event.getCounter(); var stats = Stats.builder() .visit(counter.getVisit()) .upvote(counter.getUpvote()) .totalComment(counter.getTotalComment()) .approvedComment(counter.getApprovedComment()) .build(); var request = new StatsRequest(event.getPostName(), stats); queue.addImmediately(request); } public record StatsRequest(String postName, Stats stats) { } } ================================================ FILE: application/src/main/java/run/halo/app/content/stats/ReplyEventReconciler.java ================================================ package run.halo.app.content.stats; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.greaterThan; import static run.halo.app.extension.index.query.Queries.isNull; import java.time.Duration; import java.time.Instant; import java.util.Optional; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.BooleanUtils; import org.springframework.context.SmartLifecycle; import org.springframework.context.event.EventListener; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; import run.halo.app.event.post.CommentUnreadReplyCountChangedEvent; import run.halo.app.event.post.ReplyEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.DefaultController; import run.halo.app.extension.controller.DefaultQueue; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.RequestQueue; import run.halo.app.extension.index.query.Condition; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.InitializationPhase; /** * Update the comment status after receiving the reply event. * * @author guqing * @since 2.0.0 */ @Slf4j @Component public class ReplyEventReconciler implements Reconciler, SmartLifecycle { private volatile boolean running = false; private final ExtensionClient client; private final RequestQueue replyEventQueue; private final Controller replyEventController; public ReplyEventReconciler(ExtensionClient client) { this.client = client; replyEventQueue = new DefaultQueue<>(Instant::now); replyEventController = this.setupWith(null); } @Override public Result reconcile(CommentName request) { String commentName = request.name(); client.fetch(Comment.class, commentName) // if the comment has been deleted, then do nothing. .filter(comment -> comment.getMetadata().getDeletionTimestamp() == null) .ifPresent(comment -> { // order by reply creation time desc to get first as last reply time var baseQuery = and( equal("spec.commentName", commentName), isNull("metadata.deletionTimestamp") ); var pageRequest = PageRequestImpl.ofSize(1).withSort( Sort.by("spec.creationTime", "metadata.name").descending() ); final Comment.CommentStatus status = comment.getStatusOrDefault(); var replyPageResult = client.listBy(Reply.class, listOptionsWithFieldQuery(baseQuery), pageRequest); // total reply count status.setReplyCount((int) replyPageResult.getTotal()); // calculate last reply time from total replies(top 1) Instant lastReplyTime = replyPageResult.get() .map(reply -> reply.getSpec().getCreationTime()) .findFirst() .orElse(null); status.setLastReplyTime(lastReplyTime); // calculate visible reply count(only approved and not hidden) var visibleReplyPageResult = client.listBy(Reply.class, listOptionsWithFieldQuery(and( baseQuery, equal("spec.approved", BooleanUtils.TRUE), equal("spec.hidden", BooleanUtils.FALSE) )), pageRequest); status.setVisibleReplyCount((int) visibleReplyPageResult.getTotal()); // calculate unread reply count(after last read time) var unReadQuery = Optional.ofNullable(comment.getSpec().getLastReadTime()) .map(lastReadTime -> and( baseQuery, greaterThan("spec.creationTime", lastReadTime.toString()) )) .orElse(baseQuery); var unReadPageResult = client.listBy(Reply.class, listOptionsWithFieldQuery(unReadQuery), pageRequest); status.setUnreadReplyCount((int) unReadPageResult.getTotal()); status.setHasNewReply(defaultIfNull(status.getUnreadReplyCount(), 0) > 0); client.update(comment); }); return new Result(false, null); } public record CommentName(String name) { public static CommentName of(String name) { return new CommentName(name); } } static ListOptions listOptionsWithFieldQuery(Condition query) { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of(query)); return listOptions; } @Override public Controller setupWith(ControllerBuilder builder) { return new DefaultController<>( this.getClass().getName(), this, replyEventQueue, null, Duration.ofMillis(300), Duration.ofMinutes(5)); } @Override public void start() { this.replyEventController.start(); this.running = true; } @Override public void stop() { this.running = false; this.replyEventController.dispose(); } @Override public boolean isRunning() { return this.running; } @Override public int getPhase() { return InitializationPhase.CONTROLLERS.getPhase(); } @EventListener(ReplyEvent.class) public void onReplyEvent(ReplyEvent replyEvent) { var commentName = replyEvent.getReply().getSpec().getCommentName(); replyEventQueue.addImmediately(CommentName.of(commentName)); } @EventListener(CommentUnreadReplyCountChangedEvent.class) public void onUnreadReplyCountChangedEvent(CommentUnreadReplyCountChangedEvent event) { replyEventQueue.addImmediately(CommentName.of(event.getCommentName())); } } ================================================ FILE: application/src/main/java/run/halo/app/content/stats/TagPostCountUpdater.java ================================================ package run.halo.app.content.stats; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.isNull; import com.google.common.collect.Sets; import java.util.List; import java.util.Optional; import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import run.halo.app.content.AbstractEventReconciler; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Tag; import run.halo.app.core.extension.content.Tag.TagStatus; import run.halo.app.event.post.PostDeletedEvent; import run.halo.app.event.post.PostEvent; import run.halo.app.event.post.PostUpdatedEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.extension.router.selector.LabelSelector; import run.halo.app.infra.utils.JsonUtils; /** * Update {@link TagStatus#postCount} when post related to tag is updated. * * @author guqing * @since 2.13.0 */ @Component public class TagPostCountUpdater extends AbstractEventReconciler { private final ExtensionClient client; public TagPostCountUpdater(ExtensionClient client) { super(TagPostCountUpdater.class.getName()); this.client = client; } @Override public Result reconcile(PostRelatedTags postRelatedTags) { for (var tag : postRelatedTags.tags()) { updateTagRelatedPostCount(tag); } // Update last associated tags when handled client.fetch(Post.class, postRelatedTags.postName()).ifPresent(post -> { var tags = defaultIfNull(post.getSpec().getTags(), List.of()); var annotations = MetadataUtil.nullSafeAnnotations(post); var tagAnno = JsonUtils.objectToJson(tags); var oldTagAnno = annotations.get(Post.LAST_ASSOCIATED_TAGS_ANNO); if (!tagAnno.equals(oldTagAnno)) { annotations.put(Post.LAST_ASSOCIATED_TAGS_ANNO, tagAnno); client.update(post); } }); return Result.doNotRetry(); } /** * Listen to post event to calculate post related to tag for updating. */ @EventListener(PostEvent.class) public void onPostUpdated(PostEvent postEvent) { var postName = postEvent.getName(); if (postEvent instanceof PostUpdatedEvent) { var tagsToUpdate = calcTagsToUpdate(postEvent.getName()); queue.addImmediately(new PostRelatedTags(postName, tagsToUpdate)); return; } if (postEvent instanceof PostDeletedEvent deletedEvent) { var tags = defaultIfNull(deletedEvent.getPost().getSpec().getTags(), List.of()); queue.addImmediately(new PostRelatedTags(postName, Sets.newHashSet(tags))); } } private Set calcTagsToUpdate(String postName) { var post = client.fetch(Post.class, postName).orElseThrow(); var annotations = MetadataUtil.nullSafeAnnotations(post); var oldTags = Optional.ofNullable(annotations.get(Post.LAST_ASSOCIATED_TAGS_ANNO)) .filter(StringUtils::isNotBlank) .map(tagsJson -> JsonUtils.jsonToObject(tagsJson, String[].class)) .orElse(new String[0]); var tagsToUpdate = Sets.newHashSet(oldTags); var newTags = post.getSpec().getTags(); if (newTags != null) { tagsToUpdate.addAll(newTags); } return tagsToUpdate; } public record PostRelatedTags(String postName, Set tags) { } private void updateTagRelatedPostCount(String tagName) { client.fetch(Tag.class, tagName).ifPresent(tag -> { var commonFieldQuery = and( equal("spec.tags", tag.getMetadata().getName()), isNull("metadata.deletionTimestamp") ); // Update post count var allPostOptions = new ListOptions(); allPostOptions.setFieldSelector(FieldSelector.of(commonFieldQuery)); var result = client.listBy(Post.class, allPostOptions, PageRequestImpl.ofSize(1)); tag.getStatusOrDefault().setPostCount((int) result.getTotal()); // Update visible post count var publicPostOptions = new ListOptions(); publicPostOptions.setLabelSelector(LabelSelector.builder() .eq(Post.PUBLISHED_LABEL, "true") .build()); publicPostOptions.setFieldSelector(FieldSelector.of( and( commonFieldQuery, equal("spec.deleted", "false"), equal("spec.visible", Post.VisibleEnum.PUBLIC.name()) ) )); var publicPosts = client.listBy(Post.class, publicPostOptions, PageRequestImpl.ofSize(1)); tag.getStatusOrDefault().setVisiblePostCount((int) publicPosts.getTotal()); client.update(tag); }); } } ================================================ FILE: application/src/main/java/run/halo/app/content/stats/VisitedEventReconciler.java ================================================ package run.halo.app.content.stats; import java.time.Duration; import java.time.Instant; import java.util.Iterator; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.ObjectUtils; import org.springframework.context.SmartLifecycle; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.Counter; import run.halo.app.event.post.VisitedEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.Scheme; import run.halo.app.extension.SchemeManager; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.DefaultController; import run.halo.app.extension.controller.DefaultQueue; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.RequestQueue; import run.halo.app.infra.InitializationPhase; /** * Update counters after receiving visit event. * It will cache the count in memory for one minute and then batch update to the database. * * @author guqing * @since 2.0.0 */ @Slf4j @Component public class VisitedEventReconciler implements Reconciler, SmartLifecycle { private volatile boolean running = false; private final ExtensionClient client; private final RequestQueue visitedEventQueue; private final Map pooledVisitsMap = new ConcurrentHashMap<>(); private final Controller visitedEventController; public VisitedEventReconciler(ExtensionClient client) { this.client = client; visitedEventQueue = new DefaultQueue<>(Instant::now); visitedEventController = this.setupWith(null); } @Override public Result reconcile(VisitCountBucket visitCountBucket) { createOrUpdateVisits(visitCountBucket.name(), visitCountBucket.visits()); return new Result(false, null); } private void createOrUpdateVisits(String name, Integer visits) { client.fetch(Counter.class, name) .ifPresentOrElse(counter -> { Integer existingVisit = ObjectUtils.defaultIfNull(counter.getVisit(), 0); counter.setVisit(existingVisit + visits); client.update(counter); }, () -> { Counter counter = Counter.emptyCounter(name); counter.setVisit(visits); client.create(counter); }); } /** * Put the merged data into the queue every minute for updating to the database. */ @Scheduled(cron = "0 0/1 * * * ?") public void queuedVisitBucketTask() { Iterator> iterator = pooledVisitsMap.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry item = iterator.next(); visitedEventQueue.addImmediately(new VisitCountBucket(item.getKey(), item.getValue())); iterator.remove(); } } @Override public Controller setupWith(ControllerBuilder builder) { return new DefaultController<>( this.getClass().getName(), this, visitedEventQueue, null, Duration.ofMillis(300), Duration.ofMinutes(5)); } @Override public void start() { this.visitedEventController.start(); this.running = true; } @Override public void stop() { log.debug("Persist visits to database before destroy..."); try { Iterator> iterator = pooledVisitsMap.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry item = iterator.next(); createOrUpdateVisits(item.getKey(), item.getValue()); iterator.remove(); } } catch (Exception e) { log.error("Failed to persist visits to database.", e); } this.running = false; this.visitedEventController.dispose(); } @Override public boolean isRunning() { return this.running; } @Override public int getPhase() { return InitializationPhase.CONTROLLERS.getPhase(); } public record VisitCountBucket(String name, int visits) { } @Component @RequiredArgsConstructor public class VisitedEventListener { private final SchemeManager schemeManager; @Async @EventListener(VisitedEvent.class) public void onVisited(VisitedEvent visitedEvent) { mergeVisits(visitedEvent); } private void mergeVisits(VisitedEvent event) { var gpn = new GroupPluralName(event.getGroup(), event.getPlural(), event.getName()); if (!checkVisitSubject(gpn)) { log.debug("Skip visit event for: {}", gpn); return; } String counterName = MeterUtils.nameOf(event.getGroup(), event.getPlural(), event.getName()); pooledVisitsMap.compute(counterName, (name, visits) -> { if (visits == null) { return 1; } else { return visits + 1; } }); } private boolean checkVisitSubject(GroupPluralName groupPluralName) { Optional schemeOptional = schemeManager.schemes().stream() .filter(scheme -> { GroupVersionKind gvk = scheme.groupVersionKind(); return scheme.plural().equals(groupPluralName.plural()) && gvk.group().equals(groupPluralName.group()); }) .findFirst(); return schemeOptional.map( scheme -> client.fetch(scheme.groupVersionKind(), groupPluralName.name()) .isPresent() ) .orElse(false); } record GroupPluralName(String group, String plural, String name) { } } } ================================================ FILE: application/src/main/java/run/halo/app/content/stats/VotedEventReconciler.java ================================================ package run.halo.app.content.stats; import java.time.Duration; import java.time.Instant; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.ObjectUtils; import org.springframework.context.SmartLifecycle; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.Counter; import run.halo.app.event.post.DownvotedEvent; import run.halo.app.event.post.UpvotedEvent; import run.halo.app.event.post.VotedEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.Scheme; import run.halo.app.extension.SchemeManager; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.DefaultController; import run.halo.app.extension.controller.DefaultQueue; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.RequestQueue; import run.halo.app.infra.InitializationPhase; /** * Update counters after receiving upvote or downvote event. * * @author guqing * @since 2.0.0 */ @Slf4j @Component public class VotedEventReconciler implements Reconciler, SmartLifecycle { private volatile boolean running = false; private final ExtensionClient client; private final RequestQueue votedEventQueue; private final Controller votedEventController; public VotedEventReconciler(ExtensionClient client) { this.client = client; votedEventQueue = new DefaultQueue<>(Instant::now); votedEventController = this.setupWith(null); } @Override public Result reconcile(VotedEvent votedEvent) { String counterName = MeterUtils.nameOf(votedEvent.getGroup(), votedEvent.getPlural(), votedEvent.getName()); client.fetch(Counter.class, counterName) .ifPresentOrElse(counter -> { if (votedEvent instanceof UpvotedEvent) { Integer existingVote = ObjectUtils.defaultIfNull(counter.getUpvote(), 0); counter.setUpvote(existingVote + 1); } else if (votedEvent instanceof DownvotedEvent) { Integer existingVote = ObjectUtils.defaultIfNull(counter.getDownvote(), 0); counter.setDownvote(existingVote + 1); } client.update(counter); }, () -> { Counter counter = Counter.emptyCounter(counterName); if (votedEvent instanceof UpvotedEvent) { counter.setUpvote(1); } else if (votedEvent instanceof DownvotedEvent) { counter.setDownvote(1); } client.create(counter); }); return new Result(false, null); } @Override public Controller setupWith(ControllerBuilder builder) { return new DefaultController<>( this.getClass().getName(), this, votedEventQueue, null, Duration.ofMillis(300), Duration.ofMinutes(5)); } @Override public void start() { this.votedEventController.start(); this.running = true; } @Override public void stop() { this.running = false; this.votedEventController.dispose(); } @Override public boolean isRunning() { return this.running; } @Override public int getPhase() { return InitializationPhase.CONTROLLERS.getPhase(); } @Component @RequiredArgsConstructor public class VotedEventListener { private final SchemeManager schemeManager; /** * Add up/down vote event to queue. */ @Async @EventListener(VotedEvent.class) public void onVoted(VotedEvent event) { var gpn = new GroupPluralName(event.getGroup(), event.getPlural(), event.getName()); if (!checkSubject(gpn)) { log.debug("Skip voted event for: {}", gpn); return; } votedEventQueue.addImmediately(event); } private boolean checkSubject( GroupPluralName groupPluralName) { Optional schemeOptional = schemeManager.schemes().stream() .filter(scheme -> { GroupVersionKind gvk = scheme.groupVersionKind(); return scheme.plural().equals(groupPluralName.plural()) && gvk.group().equals(groupPluralName.group()); }) .findFirst(); return schemeOptional.map( scheme -> client.fetch(scheme.groupVersionKind(), groupPluralName.name()) .isPresent() ) .orElse(false); } record GroupPluralName(String group, String plural, String name) { } } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/AttachmentChangedEvent.java ================================================ package run.halo.app.core.attachment; import lombok.Getter; import org.springframework.context.ApplicationEvent; import run.halo.app.core.extension.attachment.Attachment; /** * Event triggered when an attachment is created, updated, or deleted. * * @author johnniang */ public class AttachmentChangedEvent extends ApplicationEvent { @Getter private final Attachment attachment; public AttachmentChangedEvent(Object source, Attachment attachment) { super(source); this.attachment = attachment; } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/AttachmentLister.java ================================================ package run.halo.app.core.attachment; import reactor.core.publisher.Mono; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.extension.ListResult; public interface AttachmentLister { Mono> listBy(SearchRequest searchRequest); } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/AttachmentRootGetter.java ================================================ package run.halo.app.core.attachment; import java.nio.file.Path; import java.util.function.Supplier; /** * Gets the root path(work dir) of the local attachment. */ public interface AttachmentRootGetter extends Supplier { } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/PolicyConfigChangeDetector.java ================================================ package run.halo.app.core.attachment; import static run.halo.app.extension.index.query.Queries.equal; import java.time.Duration; import java.time.Instant; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.SmartLifecycle; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionMatcher; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.DefaultController; import run.halo.app.extension.controller.DefaultQueue; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.RequestQueue; /** *

Detects changes to {@link ConfigMap} that are referenced by {@link Policy} and updates the * {@link Attachment} with the {@link Policy} reference to reflect the change.

*

Without this, the link to the attachment corresponding to the storage policy configuration * change may not be correctly updated and only the service can be restarted.

* * @author guqing * @since 2.20.0 */ @Component @RequiredArgsConstructor public class PolicyConfigChangeDetector implements Reconciler { static final String POLICY_UPDATED_AT = "storage.halo.run/policy-updated-at"; private final GroupVersionKind attachmentGvk = GroupVersionKind.fromExtension(Attachment.class); private final ExtensionClient client; private final AttachmentUpdateTrigger attachmentUpdateTrigger; @Override public Result reconcile(Request request) { client.fetch(ConfigMap.class, request.name()) .ifPresent(configMap -> { var labels = configMap.getMetadata().getLabels(); if (labels == null) { return; } var policyName = labels.get(Policy.POLICY_OWNER_LABEL); if (StringUtils.hasText(policyName)) { var options = ListOptions.builder() .andQuery(equal("spec.policyName", policyName)) .build(); var attachmentNames = client.listAllNames(Attachment.class, options, Sort.unsorted()); attachmentUpdateTrigger.addAll(attachmentNames); } }); return Result.doNotRetry(); } @Override public Controller setupWith(ControllerBuilder builder) { ExtensionMatcher matcher = extension -> { var configMap = (ConfigMap) extension; var labels = configMap.getMetadata().getLabels(); return labels != null && labels.containsKey(Policy.POLICY_OWNER_LABEL); }; return builder .extension(new ConfigMap()) .syncAllOnStart(false) .onAddMatcher(matcher) .onUpdateMatcher(matcher) .onDeleteMatcher(matcher) .build(); } @Component static class AttachmentUpdateTrigger implements Reconciler, SmartLifecycle { private final RequestQueue queue; private final Controller controller; private volatile boolean running = false; private final ExtensionClient client; public AttachmentUpdateTrigger(ExtensionClient client) { this.client = client; this.queue = new DefaultQueue<>(Instant::now); this.controller = this.setupWith(null); } @Override public Result reconcile(String name) { client.fetch(Attachment.class, name).ifPresent(attachment -> { var annotations = MetadataUtil.nullSafeAnnotations(attachment); annotations.put(POLICY_UPDATED_AT, Instant.now().toString()); client.update(attachment); }); return Result.doNotRetry(); } void addAll(List names) { for (String name : names) { queue.addImmediately(name); } } @Override public Controller setupWith(ControllerBuilder builder) { return new DefaultController<>( "PolicyChangeAttachmentUpdater", this, queue, null, Duration.ofMillis(100), Duration.ofMinutes(10) ); } @Override public void start() { controller.start(); running = true; } @Override public void stop() { running = false; controller.dispose(); } @Override public boolean isRunning() { return running; } } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/SearchRequest.java ================================================ package run.halo.app.core.attachment; import static org.springdoc.core.fn.builders.arrayschema.Builder.arraySchemaBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import static org.springframework.boot.convert.ApplicationConversionService.getSharedInstance; import static run.halo.app.extension.index.query.Queries.contains; import static run.halo.app.extension.index.query.Queries.in; import static run.halo.app.extension.index.query.Queries.isNull; import static run.halo.app.extension.index.query.Queries.not; import static run.halo.app.extension.index.query.Queries.or; import static run.halo.app.extension.index.query.Queries.startsWith; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.util.List; import java.util.Optional; import org.springdoc.core.fn.builders.operation.Builder; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.server.ServerRequest; import run.halo.app.extension.ListOptions; import run.halo.app.extension.index.query.Queries; import run.halo.app.extension.router.IListRequest; import run.halo.app.extension.router.QueryParamBuildUtil; import run.halo.app.extension.router.SortableRequest; public class SearchRequest extends SortableRequest { public SearchRequest(ServerRequest request) { super(request.exchange()); } public Optional getKeyword() { return Optional.ofNullable(queryParams.getFirst("keyword")) .filter(StringUtils::hasText); } public Optional getUngrouped() { return Optional.ofNullable(queryParams.getFirst("ungrouped")) .map(ungroupedStr -> getSharedInstance().convert(ungroupedStr, Boolean.class)); } public Optional> getAccepts() { return Optional.ofNullable(queryParams.get("accepts")) .filter(accepts -> !accepts.isEmpty() && !accepts.contains("*") && !accepts.contains("*/*") ); } public ListOptions toListOptions(List hiddenGroups) { var builder = ListOptions.builder(super.toListOptions()); getKeyword().ifPresent(keyword -> { builder.andQuery(contains("spec.displayName", keyword)); }); getUngrouped() .filter(ungrouped -> ungrouped) .ifPresent(ungrouped -> builder.andQuery(isNull("spec.groupName"))); if (!CollectionUtils.isEmpty(hiddenGroups)) { builder.andQuery(or(isNull("spec.groupName"), not(in("spec.groupName", hiddenGroups)))); } getAccepts().flatMap(accepts -> accepts.stream() .filter(StringUtils::hasText) .map(accept -> accept.replace("/*", "/").toLowerCase()) .distinct() .map(accept -> startsWith("spec.mediaType", accept)) .reduce(Queries::or) ) .ifPresent(builder::andQuery); return builder.build(); } public static void buildParameters(Builder builder) { IListRequest.buildParameters(builder); builder.parameter(QueryParamBuildUtil.sortParameter()) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("ungrouped") .required(false) .description(""" Filter attachments without group. This parameter will ignore group \ parameter.\ """) .implementation(Boolean.class)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("keyword") .required(false) .description("Keyword for searching.") .implementation(String.class)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("accepts") .required(false) .description("Acceptable media types.") .array( arraySchemaBuilder() .uniqueItems(true) .schema(schemaBuilder() .implementation(String.class) .example("image/*")) ) .implementationArray(String.class) ); } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/endpoint/AttachmentEndpoint.java ================================================ package run.halo.app.core.attachment.endpoint; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; import static run.halo.app.extension.ListResult.generateGenericClass; import io.swagger.v3.oas.annotations.media.Schema; import java.net.URL; import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.FormFieldPart; import org.springframework.http.codec.multipart.Part; import org.springframework.stereotype.Component; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.AttachmentLister; import run.halo.app.core.attachment.SearchRequest; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.service.AttachmentService; @Slf4j @Component @RequiredArgsConstructor public class AttachmentEndpoint implements CustomEndpoint { private final AttachmentService attachmentService; private final AttachmentLister attachmentLister; @Override public RouterFunction endpoint() { var tag = "AttachmentV1alpha1Console"; return SpringdocRouteBuilder.route() .POST("/attachments/upload", contentType(MediaType.MULTIPART_FORM_DATA), request -> request.body(BodyExtractors.toMultipartData()) .map(UploadRequest::new) .flatMap(uploadReq -> { var policyName = uploadReq.getPolicyName(); var groupName = uploadReq.getGroupName(); var filePart = uploadReq.getFile(); return attachmentService.upload(policyName, groupName, filePart.filename(), filePart.content(), filePart.headers().getContentType()); }) .flatMap(attachment -> ServerResponse.ok().bodyValue(attachment)), builder -> builder .operationId("UploadAttachment") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) .schema(schemaBuilder().implementation(IUploadRequest.class)) )) .response(responseBuilder().implementation(Attachment.class)) .build()) .POST("/attachments/-/upload-from-url", contentType(MediaType.APPLICATION_JSON), request -> request.bodyToMono(UploadFromUrlRequest.class) .flatMap(uploadFromUrlRequest -> { var url = uploadFromUrlRequest.url(); var policyName = uploadFromUrlRequest.policyName(); var groupName = uploadFromUrlRequest.groupName(); var fileName = uploadFromUrlRequest.filename(); return attachmentService.uploadFromUrl(url, policyName, groupName, fileName); }) .flatMap(attachment -> ServerResponse.ok().bodyValue(attachment)), builder -> builder .operationId("ExternalTransferAttachment") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(schemaBuilder().implementation(UploadFromUrlRequest.class)) )) .response(responseBuilder().implementation(Attachment.class)) .build() ) .GET("/attachments", this::search, builder -> { builder .operationId("SearchAttachments") .tag(tag) .response( responseBuilder().implementation(generateGenericClass(Attachment.class)) ); SearchRequest.buildParameters(builder); } ) .build(); } Mono search(ServerRequest request) { var searchRequest = new SearchRequest(request); return attachmentLister.listBy(searchRequest) .flatMap(listResult -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(listResult) ); } public record UploadFromUrlRequest(@Schema(requiredMode = REQUIRED) URL url, @Schema(requiredMode = REQUIRED, description = "Storage " + "policy name") String policyName, @Schema(description = "The name of the group to which the " + "attachment belongs") String groupName, @Schema(description = "Custom file name") String filename) { public UploadFromUrlRequest { if (Objects.isNull(url)) { throw new ServerWebInputException("Required url is missing."); } if (!StringUtils.hasText(policyName)) { throw new ServerWebInputException("Policy name must not be blank"); } } } @Schema(types = "object") public interface IUploadRequest { @Schema(requiredMode = REQUIRED, description = "Attachment file") FilePart getFile(); @Schema(requiredMode = REQUIRED, description = "Storage policy name") String getPolicyName(); @Schema(description = "The name of the group to which the attachment belongs") String getGroupName(); } public record UploadRequest(MultiValueMap formData) implements IUploadRequest { public FilePart getFile() { if (formData.getFirst("file") instanceof FilePart file) { return file; } throw new ServerWebInputException("Invalid part of file"); } public String getPolicyName() { if (formData.getFirst("policyName") instanceof FormFieldPart form) { return form.value(); } throw new ServerWebInputException("Invalid part of policyName"); } @Override public String getGroupName() { if (formData.getFirst("groupName") instanceof FormFieldPart form) { return form.value(); } return null; } } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java ================================================ package run.halo.app.core.attachment.endpoint; import static java.nio.file.StandardOpenOption.CREATE_NEW; import static run.halo.app.infra.utils.FileNameUtils.randomFileName; import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal; import static run.halo.app.infra.utils.FileUtils.deleteFileSilently; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; import java.time.Clock; import java.time.Duration; import java.util.ArrayList; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.FilenameUtils; import org.apache.commons.lang3.RandomStringUtils; import org.reactivestreams.Publisher; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.util.unit.DataSize; import org.springframework.web.util.UriComponentsBuilder; import reactor.core.Exceptions; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.SynchronousSink; import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.core.attachment.thumbnail.LocalThumbnailService; import run.halo.app.core.attachment.thumbnail.ThumbnailUtils; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Attachment.AttachmentSpec; import run.halo.app.core.extension.attachment.Constant; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.infra.FileCategoryMatcher; import run.halo.app.infra.exception.AttachmentAlreadyExistsException; import run.halo.app.infra.exception.FileSizeExceededException; import run.halo.app.infra.exception.FileTypeNotAllowedException; import run.halo.app.infra.utils.FileNameUtils; import run.halo.app.infra.utils.FileTypeDetectUtils; import run.halo.app.infra.utils.HaloUtils; import run.halo.app.infra.utils.JsonUtils; @Slf4j @Component @RequiredArgsConstructor class LocalAttachmentUploadHandler implements AttachmentHandler { private static final String UPLOAD_PATH = "upload"; private final AttachmentRootGetter attachmentDirGetter; private final LocalThumbnailService localThumbnailService; private Clock clock = Clock.systemUTC(); /** * Set clock for test. * * @param clock new clock */ void setClock(Clock clock) { this.clock = clock; } @Override public Mono upload(UploadContext uploadOption) { return Mono.just(uploadOption) .filter(option -> this.shouldHandle(option.policy())) .flatMap(option -> { var configMap = option.configMap(); var setting = Optional.ofNullable(configMap) .map(ConfigMap::getData) .map(data -> data.get("default")) .map(json -> JsonUtils.jsonToObject(json, PolicySetting.class)) .orElseGet(PolicySetting::new); final var attachmentsRoot = attachmentDirGetter.get(); final var uploadRoot = attachmentsRoot.resolve(UPLOAD_PATH); final var file = option.file(); final Path attachmentPath; final String filename = getFilename(file.filename(), setting); if (StringUtils.hasText(setting.getLocation())) { attachmentPath = uploadRoot.resolve(setting.getLocation()).resolve(filename); } else { attachmentPath = uploadRoot.resolve(filename); } checkDirectoryTraversal(uploadRoot, attachmentPath); return validateFile(file, setting).then(Mono.fromRunnable( () -> { try { // init parent folders Files.createDirectories(attachmentPath.getParent()); } catch (IOException e) { throw Exceptions.propagate(e); } }) .subscribeOn(Schedulers.boundedElastic()) .then(writeContent(file.content(), attachmentPath, true)) .map(path -> { log.info("Wrote attachment {} into {}", filename, path); // TODO check the file extension var metadata = new Metadata(); metadata.setName(UUID.randomUUID().toString()); var relativePath = attachmentsRoot.relativize(path).toString(); var pathSegments = new ArrayList(); pathSegments.add(UPLOAD_PATH); for (Path p : uploadRoot.relativize(path)) { pathSegments.add(p.toString()); } var uri = UriComponentsBuilder.newInstance() .pathSegment(pathSegments.toArray(String[]::new)) .encode(StandardCharsets.UTF_8) .build() .toString(); metadata.setAnnotations(Map.of( Constant.LOCAL_REL_PATH_ANNO_KEY, relativePath, Constant.URI_ANNO_KEY, uri)); var spec = new AttachmentSpec(); spec.setSize(path.toFile().length()); spec.setMediaType(Optional.ofNullable(file.headers().getContentType()) .map(MediaType::toString) .orElse(null)); spec.setDisplayName(path.getFileName().toString()); var attachment = new Attachment(); attachment.setMetadata(metadata); attachment.setSpec(spec); attachment.setStatus(new Attachment.AttachmentStatus()); doGetPermalink(attachment).ifPresent(permalink -> attachment.getStatus().setPermalink(permalink.toASCIIString()) ); var thumbnailLinks = doGetThumbnailLinks(attachment); var thumbnails = thumbnailLinks.keySet().stream() .collect(Collectors.toMap( ThumbnailSize::name, size -> thumbnailLinks.get(size).toASCIIString() )); if (!thumbnails.isEmpty()) { attachment.getStatus().setThumbnails(thumbnails); } return attachment; }) .onErrorMap(FileAlreadyExistsException.class, e -> new AttachmentAlreadyExistsException(e.getFile())) ); }); } private Mono validateFile(FilePart file, PolicySetting setting) { var validations = new ArrayList>(2); var maxSize = setting.getMaxFileSize(); if (maxSize != null && maxSize.toBytes() > 0) { validations.add( file.content() .map(DataBuffer::readableByteCount) .reduce(0L, Long::sum) .filter(size -> size <= setting.getMaxFileSize().toBytes()) .switchIfEmpty(Mono.error(new FileSizeExceededException( "File size exceeds the maximum limit", "problemDetail.attachment.upload.fileSizeExceeded", new Object[] {setting.getMaxFileSize().toKilobytes() + "KB"}) )) ); } if (!CollectionUtils.isEmpty(setting.getAllowedFileTypes())) { var typeValidator = file.content() .next() .handle((dataBuffer, sink) -> { var mimeType = detectMimeType(dataBuffer.asInputStream(), file.filename()); if (!FileTypeDetectUtils.isValidExtensionForMime(mimeType, file.filename())) { handleFileTypeError(sink, "fileTypeNotMatch", mimeType); return; } var isAllow = setting.getAllowedFileTypes() .stream() .map(FileCategoryMatcher::of) .anyMatch(matcher -> matcher.match(mimeType)); if (isAllow) { sink.next(dataBuffer); return; } handleFileTypeError(sink, "fileTypeNotSupported", mimeType); }); validations.add(typeValidator); } return Mono.when(validations); } private static void handleFileTypeError(SynchronousSink sink, String detailCode, String mimeType) { sink.error(new FileTypeNotAllowedException("File type is not allowed", "problemDetail.attachment.upload." + detailCode, new Object[] {mimeType}) ); } @NonNull private String detectMimeType(InputStream inputStream, String name) { try { return FileTypeDetectUtils.detectMimeType(inputStream, name); } catch (IOException e) { log.warn("Failed to detect file type", e); return "Unknown"; } } @Override public Mono delete(DeleteContext deleteContext) { return Mono.just(deleteContext) .filter(context -> this.shouldHandle(context.policy())) .publishOn(Schedulers.boundedElastic()) .doOnNext(context -> { var attachment = context.attachment(); log.info("Trying to delete {} from local", attachment.getMetadata().getName()); var annotations = attachment.getMetadata().getAnnotations(); if (annotations != null) { var localRelativePath = annotations.get(Constant.LOCAL_REL_PATH_ANNO_KEY); if (StringUtils.hasText(localRelativePath)) { var attachmentsRoot = attachmentDirGetter.get(); var attachmentPath = attachmentsRoot.resolve(localRelativePath); deleteAttachmentFile(attachmentPath); deleteThumbnails(attachmentPath); } } }) .map(DeleteContext::attachment); } @Override public Mono getPermalink(Attachment attachment, Policy policy, ConfigMap configMap) { if (!this.shouldHandle(policy)) { return Mono.empty(); } return Mono.justOrEmpty(doGetPermalink(attachment)); } @Override public Mono getSharedURL(Attachment attachment, Policy policy, ConfigMap configMap, Duration ttl) { return getPermalink(attachment, policy, configMap); } @Override public Mono> getThumbnailLinks(Attachment attachment, Policy policy, ConfigMap configMap) { if (!this.shouldHandle(policy)) { return Mono.empty(); } return Mono.just(doGetThumbnailLinks(attachment)); } protected Optional doGetPermalink(Attachment attachment) { var annotations = attachment.getMetadata().getAnnotations(); if (annotations == null || !annotations.containsKey(Constant.URI_ANNO_KEY)) { return Optional.empty(); } var uriStr = annotations.get(Constant.URI_ANNO_KEY); return Optional.of(HaloUtils.safeToUri(uriStr)); } private Map doGetThumbnailLinks(Attachment attachment) { if (attachment.getStatus() == null || !StringUtils.hasText(attachment.getStatus().getPermalink())) { return Map.of(); } // Check media type first, then check file extension from permalink var supported = Optional.ofNullable(attachment.getSpec().getMediaType()) .map(MediaType::parseMediaType) .map(ThumbnailUtils::isSupportedImage) .filter(Boolean::booleanValue) .or(() -> Optional.ofNullable(attachment.getStatus().getPermalink()) .map(permalink -> { var path = URI.create(permalink).getPath(); return FilenameUtils.getExtension(path); }) .map(ThumbnailUtils::isSupportedImage) .filter(Boolean::booleanValue) ) .isPresent(); if (!supported) { return Map.of(); } var permalinkUri = URI.create(attachment.getStatus().getPermalink()); return ThumbnailUtils.buildSrcsetMap(permalinkUri); } private void deleteAttachmentFile(Path attachmentPath) { var attachmentsRoot = attachmentDirGetter.get(); checkDirectoryTraversal(attachmentsRoot, attachmentPath); try { Files.deleteIfExists(attachmentPath); log.info("Deleted attachment file {}", attachmentPath); } catch (IOException e) { throw Exceptions.propagate(e); } } private void deleteThumbnails(Path attachmentPath) { this.localThumbnailService.delete(attachmentPath); } private boolean shouldHandle(Policy policy) { if (policy == null || policy.getSpec() == null || !StringUtils.hasText(policy.getSpec().getTemplateName())) { return false; } return "local".equals(policy.getSpec().getTemplateName()); } /** * Write content into file. We will detect duplicate filename and auto-rename it with 3 times * retry. * * @param content is file content * @param targetPath is target path * @return file path */ private Mono writeContent(Flux content, Path targetPath, boolean renameIfExists) { return Mono.defer(() -> { final var pathRef = new AtomicReference<>(targetPath); return Mono.defer( // we have to use defer method to obtain a fresh path () -> DataBufferUtils.write(content, pathRef.get(), CREATE_NEW)) .retryWhen(Retry.max(3) .filter(t -> { if (renameIfExists) { return t instanceof FileAlreadyExistsException; } return false; }) .doAfterRetry(signal -> { // rename the path var oldPath = pathRef.get(); var fileName = randomFileName(oldPath.toString(), 4); pathRef.set(oldPath.resolveSibling(fileName)); })) // Delete file already wrote partially into attachment folder // in case of content is terminated with an error .onErrorResume(t -> deleteFileSilently(pathRef.get()).then(Mono.error(t))) .then(Mono.fromSupplier(pathRef::get)); }); } private String getFilename(String filename, PolicySetting setting) { if (!setting.isAlwaysRenameFilename()) { return filename; } var renameStrategy = setting.getRenameStrategy(); if (renameStrategy == null) { return filename; } var renameMethod = renameStrategy.getMethod(); if (renameMethod == null) { renameMethod = RenameMethod.RANDOM; } var excludeOriginalFilename = renameStrategy.isExcludeOriginalFilename(); switch (renameMethod) { case TIMESTAMP -> { return FileNameUtils.renameFilename( filename, () -> { var now = clock.instant(); return now.toEpochMilli() + ""; }, excludeOriginalFilename); } case UUID -> { return FileNameUtils.renameFilename( filename, () -> UUID.randomUUID().toString(), excludeOriginalFilename ); } default -> { return FileNameUtils.renameFilename( filename, () -> { var length = renameStrategy.getRandomLength(); if (length < 8) { length = 8; } else if (length > 64) { // The max filename length is 256, so we limit the random length to 64 // for most cases. length = 64; } return RandomStringUtils.secure().nextAlphabetic(length); }, excludeOriginalFilename); } } } @Data public static class PolicySetting { private String location; private DataSize maxFileSize; private Set allowedFileTypes; private boolean alwaysRenameFilename; private RenameStrategy renameStrategy; public void setMaxFileSize(String maxFileSize) { if (!StringUtils.hasText(maxFileSize)) { return; } this.maxFileSize = DataSize.parse(maxFileSize); } } public enum RenameMethod { RANDOM, UUID, TIMESTAMP } @Data public static class RenameStrategy { private RenameMethod method; private int randomLength = 32; private boolean excludeOriginalFilename; } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/endpoint/PolicyEndpoint.java ================================================ package run.halo.app.core.attachment.endpoint; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springframework.http.HttpStatus.NO_CONTENT; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.util.HashMap; import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.transaction.ReactiveTransactionManager; import org.springframework.transaction.reactive.TransactionalOperator; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.server.RequestPredicates; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; @Component @RequiredArgsConstructor class PolicyEndpoint implements CustomEndpoint { private final ReactiveExtensionClient client; private final JsonMapper mapper; private final ReactiveTransactionManager txManager; @Override public RouterFunction endpoint() { var tag = "PolicyAlpha1Console"; return SpringdocRouteBuilder.route() .GET( "/policies/{name}/configs/{group}", this::getPolicyConfigByGroup, builder -> builder.operationId("getPolicyConfigByGroup") .description("Get policy config by group") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Name of the policy") .required(true) .implementation(String.class) ) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("group") .description("Name of the group") .required(true) .implementation(String.class) ) .response(responseBuilder().implementation(Object.class)) ) .PUT( "/policies/{name}/configs/{group}", RequestPredicates.contentType(MediaType.APPLICATION_JSON), this::updatePolicyConfigByGroup, builder -> builder.operationId("updatePolicyConfigByGroup") .description("Update policy config by group") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Name of the policy") .required(true) .implementation(String.class) ) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("group") .description("Name of the group") .required(true) .implementation(String.class) ) .requestBody( requestBodyBuilder().required(true).implementation(Object.class)) .response( responseBuilder().responseCode(String.valueOf(NO_CONTENT.value())) ) ) .build(); } private Mono updatePolicyConfigByGroup(ServerRequest serverRequest) { var policyName = serverRequest.pathVariable("name"); var configGroup = serverRequest.pathVariable("group"); return serverRequest.bodyToMono(JsonNode.class) .switchIfEmpty(Mono.error(() -> new ServerWebInputException( "Request body is required.") )) .flatMap(jsonNode -> { var tx = TransactionalOperator.create(txManager); return client.get(Policy.class, policyName) .flatMap(policy -> Mono.justOrEmpty(policy.getSpec()) .mapNotNull(Policy.PolicySpec::getConfigMapName) .filter(StringUtils::hasText) .flatMap(cmName -> client.fetch(ConfigMap.class, cmName)) .switchIfEmpty(Mono.fromSupplier(() -> { // create a new configmap var cm = new ConfigMap(); cm.setMetadata(new Metadata()); cm.getMetadata().setGenerateName(policyName + "-config-"); return cm; })) .flatMap(cm -> Mono.fromCallable(() -> { if (cm.getData() == null) { cm.setData(new HashMap<>()); } var oldJson = cm.getData().get(configGroup); if (StringUtils.hasText(oldJson) && Objects.equals(jsonNode, mapper.readTree(oldJson))) { // skip if no change return null; } var newJson = mapper.writeValueAsString(jsonNode); cm.getData().put(configGroup, newJson); return cm; })) .flatMap(cm -> { if (cm.getMetadata().getVersion() != null) { return client.update(cm); } return client.create(cm); }) .flatMap(cm -> { var cmName = cm.getMetadata().getName(); if (policy.getSpec() != null && Objects.equals(policy.getSpec().getConfigMapName(), cmName)) { return Mono.just(cm); } if (policy.getSpec() == null) { policy.setSpec(new Policy.PolicySpec()); } policy.getSpec().setConfigMapName(cmName); return client.update(policy); }) ) .as(tx::transactional); }) .then(ServerResponse.noContent().build()); } private Mono getPolicyConfigByGroup(ServerRequest serverRequest) { var policyName = serverRequest.pathVariable("name"); var configGroup = serverRequest.pathVariable("group"); return client.get(Policy.class, policyName) .filter(p -> p.getSpec() != null) .map(p -> p.getSpec().getConfigMapName()) .filter(StringUtils::hasText) .flatMap(cmName -> client.fetch(ConfigMap.class, cmName)) .filter(cm -> cm.getData() != null && cm.getData().containsKey(configGroup)) .map(cm -> cm.getData().get(configGroup)) .flatMap(json -> Mono.fromCallable(() -> mapper.readTree(json))) .defaultIfEmpty(mapper.nullNode()) .flatMap(config -> ServerResponse.ok().bodyValue(config)); } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("console.api.storage.halo.run/v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/extension/LocalThumbnail.java ================================================ package run.halo.app.core.attachment.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.experimental.Accessors; import org.springframework.lang.NonNull; import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "storage.halo.run", version = "v1alpha1", kind = "LocalThumbnail", plural = "localthumbnails", singular = "localthumbnail") @Deprecated(forRemoval = true, since = "2.22.0") public class LocalThumbnail extends AbstractExtension { public static final String UNIQUE_IMAGE_AND_SIZE_INDEX = "uniqueImageAndSize"; public static final String REQUEST_TO_GENERATE_ANNO = "storage.halo.run/request-to-generate"; @Schema(requiredMode = REQUIRED) private Spec spec; @Getter(onMethod_ = @NonNull) @Schema(requiredMode = NOT_REQUIRED) private Status status = new Status(); public void setStatus(Status status) { this.status = (status == null ? new Status() : status); } @Data @Accessors(chain = true) @Schema(name = "LocalThumbnailSpec") public static class Spec { /** * A hash signature for the image uri. * * @see #getImageUri() */ @Schema(requiredMode = REQUIRED, minLength = 1) private String imageSignature; @Schema(requiredMode = REQUIRED, minLength = 1) private String imageUri; @Schema(requiredMode = REQUIRED, minLength = 1) private String thumbnailUri; /** * A hash signature for the thumbnail uri. * * @see #getThumbnailUri() */ @Schema(requiredMode = REQUIRED, minLength = 1) private String thumbSignature; @Schema(requiredMode = REQUIRED) private ThumbnailSize size; /** * Consider the compatibility of the system and migration, use unix-style relative paths * here. * * @see AttachmentRootGetter */ @Schema(requiredMode = REQUIRED) private String filePath; } @Data @Schema(name = "LocalThumbnailStatus") public static class Status { private Phase phase; } public enum Phase { PENDING, SUCCEEDED, FAILED } public static String uniqueImageAndSize(LocalThumbnail localThumbnail) { return uniqueImageAndSize(localThumbnail.getSpec().getImageSignature(), localThumbnail.getSpec().getSize()); } public static String uniqueImageAndSize(String imageSignature, ThumbnailSize size) { return imageSignature + "-" + size.name(); } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/extension/Thumbnail.java ================================================ package run.halo.app.core.attachment.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @Data @EqualsAndHashCode(callSuper = true) @GVK(group = "storage.halo.run", version = "v1alpha1", kind = "Thumbnail", plural = "thumbnails", singular = "thumbnail") @Deprecated(forRemoval = true, since = "2.22.0") public class Thumbnail extends AbstractExtension { public static final String ID_INDEX = "thumbnail-id"; @Schema(requiredMode = REQUIRED) private Spec spec; @Data @Accessors(chain = true) @Schema(name = "ThumbnailSpec") public static class Spec { @Schema(requiredMode = REQUIRED, minLength = 1) private String imageSignature; @Schema(requiredMode = REQUIRED, minLength = 1) private String imageUri; @Schema(requiredMode = REQUIRED) private ThumbnailSize size; @Schema(requiredMode = REQUIRED, minLength = 1) private String thumbnailUri; } public static String idIndexFunc(Thumbnail thumbnail) { return idIndexFunc(thumbnail.getSpec().getImageSignature(), thumbnail.getSpec().getSize().name()); } public static String idIndexFunc(String imageHash, String size) { return imageHash + "-" + size; } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/impl/AttachmentListerImpl.java ================================================ package run.halo.app.core.attachment.impl; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.AttachmentLister; import run.halo.app.core.attachment.SearchRequest; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Group; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; @Component @RequiredArgsConstructor public class AttachmentListerImpl implements AttachmentLister { private final ReactiveExtensionClient client; @Override public Mono> listBy(SearchRequest searchRequest) { var groupListOptions = ListOptions.builder() .labelSelector() .exists(Group.HIDDEN_LABEL) .end() .build(); return client.listAll(Group.class, groupListOptions, Sort.unsorted()) .map(group -> group.getMetadata().getName()) .collectList() .defaultIfEmpty(List.of()) .flatMap(hiddenGroups -> client.listBy(Attachment.class, searchRequest.toListOptions(hiddenGroups), searchRequest.toPageRequest() )); } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/impl/AttachmentRootGetterImpl.java ================================================ package run.halo.app.core.attachment.impl; import java.nio.file.Path; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.infra.properties.HaloProperties; @Component @RequiredArgsConstructor public class AttachmentRootGetterImpl implements AttachmentRootGetter { private final HaloProperties haloProp; @Override public Path get() { return haloProp.getWorkDir().resolve("attachments"); } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java ================================================ package run.halo.app.core.attachment.reconciler; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import java.net.URI; import java.time.Duration; import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import run.halo.app.core.attachment.AttachmentChangedEvent; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Attachment.AttachmentStatus; import run.halo.app.core.extension.attachment.Constant; import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler.Request; import run.halo.app.extension.controller.RequeueException; @Slf4j @Component @RequiredArgsConstructor class AttachmentReconciler implements Reconciler { private final ExtensionClient client; private final AttachmentService attachmentService; private final ApplicationEventPublisher eventPublisher; @Override public Result reconcile(Request request) { return client.fetch(Attachment.class, request.name()).map(attachment -> { if (ExtensionUtil.isDeleted(attachment)) { if (removeFinalizers(attachment.getMetadata(), Set.of(Constant.FINALIZER_NAME))) { cleanUpResources(attachment); client.update(attachment); this.eventPublisher.publishEvent(new AttachmentChangedEvent(this, attachment)); } return null; } // add finalizer addFinalizers(attachment.getMetadata(), Set.of(Constant.FINALIZER_NAME)); if (attachment.getStatus() == null) { attachment.setStatus(new AttachmentStatus()); } var permalink = attachmentService.getPermalink(attachment) .map(URI::toASCIIString) .blockOptional(Duration.ofSeconds(10)) .orElseThrow(() -> new RequeueException(new Result(true, null), "Attachment handler is unavailable, requeue the request" )); log.debug("Set attachment permalink: {} for {}", permalink, request.name()); attachment.getStatus().setPermalink(permalink); var thumbnails = attachmentService.getThumbnailLinks(attachment) .map(map -> map.keySet() .stream() .collect(Collectors.toMap(Enum::name, k -> map.get(k).toString())) ) .blockOptional(Duration.ofSeconds(10)) .orElse(null); Result result = null; if (thumbnails == null) { log.warn(""" Failed to get thumbnails for attachment: {}, \ consider upgrading storage plugins""", request.name() ); result = new Result(true, Duration.ofSeconds(10)); } attachment.getStatus().setThumbnails(thumbnails); log.debug("Set attachment thumbnails: {} for {}", thumbnails, request.name()); client.update(attachment); this.eventPublisher.publishEvent(new AttachmentChangedEvent(this, attachment)); return result; }).orElse(null); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new Attachment()) .build(); } void cleanUpResources(Attachment attachment) { attachmentService.delete(attachment).block(Duration.ofSeconds(20)); } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/reconciler/LocalThumbnailsReconciler.java ================================================ package run.halo.app.core.attachment.reconciler; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import java.io.IOException; import java.nio.file.Files; import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.core.attachment.extension.LocalThumbnail; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; @Slf4j @Component @RequiredArgsConstructor @Deprecated(forRemoval = true, since = "2.22.0") class LocalThumbnailsReconciler implements Reconciler { private static final String CLEAN_UP_FINALIZER = "thumbnail-cleaner"; private final ExtensionClient client; private final AttachmentRootGetter attachmentRoot; @Override public Result reconcile(Request request) { client.fetch(LocalThumbnail.class, request.name()) .ifPresent(thumbnail -> { if (ExtensionUtil.isDeleted(thumbnail)) { if (removeFinalizers(thumbnail.getMetadata(), Set.of(CLEAN_UP_FINALIZER))) { // clean up thumbnail file cleanUpThumbnailFile(thumbnail); client.update(thumbnail); } return; } // Cleanup all existing local thumbnails addFinalizers(thumbnail.getMetadata(), Set.of(CLEAN_UP_FINALIZER)); log.info("Cleaning up local thumbnail: {}", thumbnail.getMetadata().getName()); client.delete(thumbnail); }); return Result.doNotRetry(); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new LocalThumbnail()) .build(); } private void cleanUpThumbnailFile(LocalThumbnail thumbnail) { var filePath = thumbnail.getSpec().getFilePath(); if (StringUtils.hasText(filePath)) { var thumbnailFile = attachmentRoot.get().resolve(filePath); try { if (Files.deleteIfExists(thumbnailFile)) { log.info("Deleted thumbnail file: {} for {}", thumbnailFile, thumbnail.getMetadata().getName()); } } catch (IOException e) { throw new RuntimeException(e); } } } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/reconciler/PolicyReconciler.java ================================================ package run.halo.app.core.attachment.reconciler; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; @Component @RequiredArgsConstructor public class PolicyReconciler implements Reconciler { private final ExtensionClient client; @Override public Result reconcile(Request request) { client.fetch(Policy.class, request.name()) .ifPresent(this::checkOwnerLabel); return Result.doNotRetry(); } private void checkOwnerLabel(Policy policy) { var policyName = policy.getMetadata().getName(); var configMapName = policy.getSpec().getConfigMapName(); client.fetch(ConfigMap.class, configMapName) .ifPresent(configMap -> { var labels = MetadataUtil.nullSafeLabels(configMap); labels.put(Policy.POLICY_OWNER_LABEL, policyName); client.update(configMap); }); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new Policy()) // sync on start for compatible with previous data .syncAllOnStart(true) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/reconciler/ThumbnailReconciler.java ================================================ package run.halo.app.core.attachment.reconciler; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import run.halo.app.core.attachment.extension.Thumbnail; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; @Slf4j @Component @Deprecated(forRemoval = true, since = "2.22.0") class ThumbnailReconciler implements Reconciler { private final ExtensionClient client; ThumbnailReconciler(ExtensionClient client) { this.client = client; } @Override public Result reconcile(Request request) { client.fetch(Thumbnail.class, request.name()) .ifPresent(thumbnail -> { if (ExtensionUtil.isDeleted(thumbnail)) { return; } log.info("Clean up thumbnail: {}", thumbnail.getMetadata().getName()); client.delete(thumbnail); }); return null; } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new Thumbnail()) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/thumbnail/DefaultLocalThumbnailService.java ================================================ package run.halo.app.core.attachment.thumbnail; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; import static java.nio.file.StandardOpenOption.READ; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; import net.coobird.thumbnailator.ThumbnailParameter; import net.coobird.thumbnailator.Thumbnails; import net.coobird.thumbnailator.resizers.configurations.Rendering; import org.springframework.beans.factory.DisposableBean; import org.springframework.core.io.PathResource; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.infra.properties.AttachmentProperties; import run.halo.app.infra.properties.HaloProperties; /** * Default implementation of {@link LocalThumbnailService} that generates thumbnails using * Thumbnailator library and deletes them when no longer needed. * * @author johnniang * @since 2.22.0 */ @Slf4j @Service class DefaultLocalThumbnailService implements LocalThumbnailService, DisposableBean { private static final String THUMBNAIL_ROOT = "thumbnails"; private static final int DEFAULT_GENERATION_TIMEOUT_SECONDS = 60; private static final int DEFAULT_GENERATION_CONCURRENT_THREADS = 5; private ExecutorService executorService; private final AttachmentRootGetter attachmentRootGetter; /** * Map to track in-progress thumbnail generation tasks. The key is the filename, and the * value * is a CompletableFuture representing the generation task. */ private final ConcurrentMap> inProgress; private final AttachmentProperties.ThumbnailProperties thumbnailProperties; public DefaultLocalThumbnailService(AttachmentRootGetter attachmentRootGetter, HaloProperties haloProperties) { this.attachmentRootGetter = attachmentRootGetter; this.thumbnailProperties = haloProperties.getAttachment().getThumbnail(); var concurrentThreads = this.thumbnailProperties.getConcurrentThreads(); if (concurrentThreads == null || concurrentThreads < 1) { concurrentThreads = DEFAULT_GENERATION_CONCURRENT_THREADS; } this.executorService = Executors.newFixedThreadPool(concurrentThreads, Thread.ofPlatform() .daemon() .name("thumbnail-generator-", 0) .factory()); this.inProgress = new ConcurrentHashMap<>(); } void setExecutorService(ExecutorService executorService) { this.executorService = executorService; } @Override public void destroy() throws Exception { this.executorService.close(); } @Override public Mono generate(Path source, ThumbnailSize size) { if (thumbnailProperties.isDisabled()) { return Mono.empty(); } var optionalThumbnailPath = resolveThumbnailPath(source, size); if (optionalThumbnailPath.isEmpty()) { log.warn("Failed to resolve thumbnail path for source: {}, size: {}", source, size); return Mono.empty(); } var thumbnailPath = optionalThumbnailPath.get(); var thumbnailResource = new PathResource(thumbnailPath); if (thumbnailResource.isReadable()) { log.trace("Thumbnail already exists: {}", thumbnailPath); return Mono.just(thumbnailResource); } return Mono.fromFuture(() -> inProgress.computeIfAbsent(thumbnailPath, f -> CompletableFuture.supplyAsync(() -> generateThumbnail( source, thumbnailPath, size ), this.executorService ) .orTimeout( DEFAULT_GENERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS ) ) .whenComplete((p, t) -> inProgress.remove(thumbnailPath)), // We don't want to cancel the thumbnail generation task // when some requests are cancelled true ) .map(PathResource::new); } @Override public void delete(Path source) { Arrays.stream(ThumbnailSize.values()).forEach(size -> { var thumbnailPath = resolveThumbnailPath(source, size); if (thumbnailPath.isEmpty()) { log.warn("Failed to resolve thumbnail path for source: {}, size: {}", source, size); return; } try { var deleted = Files.deleteIfExists(thumbnailPath.get()); if (deleted) { log.info("Deleted thumbnail: {} for {}", thumbnailPath.get(), source); } } catch (IOException e) { // Ignore the error log.error("Failed to delete thumbnail: {}", thumbnailPath.get(), e); } }); } Optional resolveThumbnailPath(Path source, ThumbnailSize size) { var attachmentRoot = this.attachmentRootGetter.get(); Path relativize; try { relativize = attachmentRoot.relativize(source); } catch (IllegalArgumentException e) { // The source path is not under the attachment root if (log.isDebugEnabled()) { log.warn("Failed to resolve thumbnail path for source: {}, size: {}", source, size, e); } return Optional.empty(); } var thumbnailPath = attachmentRoot.resolve(THUMBNAIL_ROOT) .resolve("w" + size.getWidth()) .resolve(relativize); return Optional.of(thumbnailPath); } private Path generateThumbnail(Path sourcePath, Path thumbnailPath, ThumbnailSize size) { if (!Files.exists(sourcePath)) { log.trace("Attachment path does not exist: {}", sourcePath); return null; } // Double check if the thumbnail already exists if (Files.exists(thumbnailPath)) { return thumbnailPath; } if (log.isDebugEnabled()) { log.debug( "Generating thumbnail for path: {}, target: {}, size: {}", sourcePath, thumbnailPath, size); } boolean shouldCleanup = true; try (var inputStream = Files.newInputStream(sourcePath, READ)) { Files.createDirectories(thumbnailPath.getParent()); // Pass InputStream or File here. // See https://github.com/coobird/thumbnailator/issues/159#issuecomment-694978197 // for more. var builder = Thumbnails.of(inputStream) .width(size.getWidth()) .imageType(ThumbnailParameter.DEFAULT_IMAGE_TYPE) .rendering(Rendering.SPEED) .useExifOrientation(true); if (thumbnailProperties.getQuality() != null) { builder.outputQuality(thumbnailProperties.getQuality()); } builder.toFile(thumbnailPath.toFile()); log.info("Generated thumbnail for path: {}, target: {}, size: {}", sourcePath, thumbnailPath, size); // check size of thumbnails var attachmentFileSize = Files.size(sourcePath); var thumbnailFileSize = Files.size(thumbnailPath); if (attachmentFileSize < thumbnailFileSize) { Files.copy(sourcePath, thumbnailPath, REPLACE_EXISTING); log.info(""" Replaced thumbnail with original file since it's smaller, \ path: {}, size: {} < {}\ """, thumbnailPath, attachmentFileSize, thumbnailFileSize); } shouldCleanup = false; return thumbnailPath; } catch (IOException e) { log.warn("Failed to generate thumbnail for path: {}", sourcePath, e); // return the original attachment path return null; } finally { if (shouldCleanup) { // delete the possibly created file try { Files.deleteIfExists(thumbnailPath); } catch (IOException ex) { // ignore this error log.warn("Failed to delete possibly created thumbnail file: {}", thumbnailPath, ex); } } } } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/thumbnail/DefaultThumbnailService.java ================================================ package run.halo.app.core.attachment.thumbnail; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import java.net.URI; import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Objects; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.web.filter.reactive.ServerWebExchangeContextFilter; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.AttachmentChangedEvent; import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.Queries; import run.halo.app.infra.ExternalUrlSupplier; /** * Implementation of {@link ThumbnailService}. * *

* Caches thumbnail links in memory for better performance. * * @author johnniang * @since 2.22.0 */ @Slf4j @Component class DefaultThumbnailService implements ThumbnailService { private static final Map EMPTY_THUMBNAILS = Map.of(); private final Cache> thumbnailCache; private final ReactiveExtensionClient client; private final ExternalUrlSupplier externalUrlSupplier; public DefaultThumbnailService(ReactiveExtensionClient client, ExternalUrlSupplier externalUrlSupplier) { this.client = client; this.externalUrlSupplier = externalUrlSupplier; this.thumbnailCache = Caffeine.newBuilder() // TODO make it configurable .maximumSize(10_000) .build(); } @EventListener void handleAttachmentChangedEvent(AttachmentChangedEvent event) { updateCache(event.getAttachment()); } void updateCache(Attachment attachment) { if (attachment.getStatus() == null) { return; } var permalink = attachment.getStatus().getPermalink(); if (!StringUtils.hasText(permalink)) { return; } if (ExtensionUtil.isDeleted(attachment)) { thumbnailCache.invalidate(permalink); return; } var thumbnails = attachment.getStatus().getThumbnails(); if (CollectionUtils.isEmpty(thumbnails)) { thumbnailCache.put(permalink, EMPTY_THUMBNAILS); return; } Map validThumbnails = new HashMap<>(); thumbnails.forEach((key, value) -> { var size = ThumbnailSize.optionalValueOf(key); if (size.isPresent() && StringUtils.hasText(value)) { validThumbnails.put(size.get(), URI.create(value)); } }); if (validThumbnails.isEmpty()) { thumbnailCache.put(permalink, EMPTY_THUMBNAILS); } else { thumbnailCache.put(permalink, Collections.unmodifiableMap(validThumbnails)); } } @Override public Mono get(URI permalink, ThumbnailSize size) { return get(permalink).mapNotNull(thumbnails -> thumbnails.get(size)); } @Override public Mono> get(URI permalink) { var encodedPermalink = URI.create(permalink.toASCIIString()); if (!encodedPermalink.isAbsolute()) { // build permalinks return Mono.just(ThumbnailUtils.buildSrcsetMap(encodedPermalink)); } // TODO Optimize concurrent requests for the same permalink return Mono.deferContextual(contextView -> { var externalUrl = ServerWebExchangeContextFilter.getExchange(contextView) .map(exchange -> externalUrlSupplier.getURL(exchange.getRequest())) .orElseGet(externalUrlSupplier::getRaw); // check if the permalink is from local site if (externalUrl != null && Objects.equals(externalUrl.getAuthority(), encodedPermalink.getAuthority())) { return Mono.just(ThumbnailUtils.buildSrcsetMap(encodedPermalink)); } var permalinkString = encodedPermalink.toASCIIString(); var thumbnails = thumbnailCache.getIfPresent(permalinkString); if (thumbnails != null) { return Mono.just(thumbnails); } // query from attachments var listOptions = ListOptions.builder() .andQuery(Queries.equal("status.permalink", permalinkString)) .build(); return client.listAll(Attachment.class, listOptions, ExtensionUtil.defaultSort()) .next() // Here we allow concurrent updates .doOnNext(this::updateCache) .mapNotNull(attachment -> this.thumbnailCache.getIfPresent(permalinkString)) .switchIfEmpty(Mono.fromSupplier(() -> { // No attachment or no thumbnails, cache empty map to avoid cache miss again and // again. this.thumbnailCache.put(permalinkString, EMPTY_THUMBNAILS); return EMPTY_THUMBNAILS; })); }); } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/thumbnail/LocalThumbnailService.java ================================================ package run.halo.app.core.attachment.thumbnail; import java.nio.file.Path; import org.springframework.core.io.Resource; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.ThumbnailSize; /** * Service for generating and deleting local image thumbnails. * * @author johnniang * @since 2.22.0 */ public interface LocalThumbnailService { /** * Generates thumbnail for the source image. If the thumbnail already exists, it will return the * existing one. * * @param source the source image path * @param size the thumbnail size * @return the generated thumbnail resource */ Mono generate(Path source, ThumbnailSize size); /** * Deletes thumbnails associated with the source image. * * @param source the source image path */ void delete(Path source); } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/thumbnail/ThumbnailImgTagPostProcessor.java ================================================ package run.halo.app.core.attachment.thumbnail; import static org.thymeleaf.templatemode.TemplateMode.HTML; import java.util.Map; import java.util.Optional; import java.util.function.Predicate; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.engine.ElementNames; import org.thymeleaf.model.IAttribute; import org.thymeleaf.model.IProcessableElementTag; import org.thymeleaf.processor.element.MatchingElementName; import reactor.core.publisher.Mono; import run.halo.app.infra.utils.HaloUtils; import run.halo.app.theme.dialect.ElementTagPostProcessor; @Slf4j @Component class ThumbnailImgTagPostProcessor implements ElementTagPostProcessor { private static final String DEFAULT_SIZES = """ (max-width: 640px) 94vw, \ (max-width: 768px) 92vw, \ (max-width: 1024px) 88vw, \ min(800px, 85vw)\ """; private final MatchingElementName matchingElementName; private final ThumbnailService thumbnailService; public ThumbnailImgTagPostProcessor(ThumbnailService thumbnailService) { this.thumbnailService = thumbnailService; this.matchingElementName = MatchingElementName.forElementName(HTML, ElementNames.forHTMLName("img")); } @Override public Mono process(ITemplateContext context, IProcessableElementTag tag) { if (!matchingElementName.matches(tag.getElementDefinition().getElementName())) { return Mono.empty(); } if (tag.hasAttribute("srcset")) { return Mono.empty(); } var srcValue = Optional.ofNullable(tag.getAttribute("src")) .map(IAttribute::getValue) .filter(StringUtils::hasText) .map(String::trim) .map(HaloUtils::safeToUri); if (srcValue.isEmpty()) { log.debug("Skip processing img tag without src attribute"); return Mono.empty(); } // get img tag var imageUri = srcValue.get(); return thumbnailService.get(imageUri) .filter(Predicate.not(Map::isEmpty)) .map(thumbnails -> { var modelFactory = context.getModelFactory(); var newTag = tag; if (!newTag.hasAttribute("sizes")) { newTag = modelFactory.setAttribute(newTag, "sizes", DEFAULT_SIZES); } var srcset = thumbnails.keySet().stream() .map(size -> { var uri = thumbnails.get(size); return uri + " " + size.getWidth() + "w"; }) .collect(Collectors.joining(", ")); newTag = modelFactory.setAttribute(newTag, "srcset", srcset); return newTag; }); } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/thumbnail/ThumbnailResourceTransformer.java ================================================ package run.halo.app.core.attachment.thumbnail; import java.io.IOException; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.PathResource; import org.springframework.core.io.Resource; import org.springframework.util.StringUtils; import org.springframework.web.reactive.resource.ResourceTransformer; import org.springframework.web.reactive.resource.ResourceTransformerChain; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.ThumbnailSize; /** * A {@link ResourceTransformer} to generate and serve image thumbnails on the fly. * * @author johnniang * @since 2.22.0 */ @Slf4j public class ThumbnailResourceTransformer implements ResourceTransformer { private final LocalThumbnailService localThumbnailService; public ThumbnailResourceTransformer(LocalThumbnailService localThumbnailService) { this.localThumbnailService = localThumbnailService; } @Override public Mono transform(ServerWebExchange exchange, Resource resource, ResourceTransformerChain transformerChain) { var width = exchange.getRequest().getQueryParams().getFirst("width"); if (!StringUtils.hasText(width) || !resource.isFile()) { return transformerChain.transform(exchange, resource); } var extension = StringUtils.getFilenameExtension(resource.getFilename()); if (!ThumbnailUtils.isSupportedImage(extension)) { log.trace("Not a supported image type: {}", extension); return transformerChain.transform(exchange, resource); } var size = ThumbnailSize.fromWidth(width); try { var source = resource.getFile().toPath(); return localThumbnailService.generate(source, size) .switchIfEmpty(Mono.fromSupplier(() -> new PathResource(source))) .flatMap(transformed -> transformerChain.transform(exchange, transformed)); } catch (IOException e) { return Mono.error(e); } } } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/thumbnail/ThumbnailService.java ================================================ package run.halo.app.core.attachment.thumbnail; import java.net.URI; import java.util.Map; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.ThumbnailSize; /** * Service for managing thumbnails. * * @author johnniang * @since 2.22.0 */ public interface ThumbnailService { /** * Get the thumbnail link for the given image URI and size. * * @param permalink the permalink of the image * @param size the size of the thumbnail * @return the thumbnail link */ Mono get(URI permalink, ThumbnailSize size); /** * Get all thumbnail links for the given image URI. * * @param permalink the permalink of the image * @return the map of thumbnail size to thumbnail link */ Mono> get(URI permalink); } ================================================ FILE: application/src/main/java/run/halo/app/core/attachment/thumbnail/ThumbnailUtils.java ================================================ package run.halo.app.core.attachment.thumbnail; import java.net.URI; import java.util.Arrays; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import org.apache.commons.io.FilenameUtils; import org.jspecify.annotations.Nullable; import org.springframework.http.MediaType; import org.springframework.util.MimeType; import org.springframework.util.StringUtils; import org.springframework.web.util.UriComponentsBuilder; import run.halo.app.core.attachment.ThumbnailSize; public enum ThumbnailUtils { ; private static final Set SUPPORTED_IMAGE_SUFFIXES = Set.of( "jpg", "jpeg", "png", "bmp", "wbmp" ); private static final Set SUPPORTED_IMAGE_MIME_TYPES = Set.of( "image/jpg", "image/jpeg", "image/png", "image/bmp", "image/vnd.wap.wbmp" ) .stream() .map(MediaType::parseMediaType) .collect(Collectors.toSet()); /** * Check if the given file suffix is a supported image format for thumbnail generation. * * @param fileSuffix the file suffix to check (without the dot) * @return true if the file suffix is supported, false otherwise */ public static boolean isSupportedImage(@Nullable String fileSuffix) { if (!StringUtils.hasText(fileSuffix)) { return false; } return SUPPORTED_IMAGE_SUFFIXES.contains(fileSuffix.toLowerCase()); } public static boolean isSupportedImage(@Nullable MimeType mimeType) { return SUPPORTED_IMAGE_MIME_TYPES.stream() .anyMatch(supported -> supported.isCompatibleWith(mimeType)); } /** * Build a map of thumbnail size to its corresponding URI based on the given permalink. * * @param permalink permalink of the attachment in local storage. Make sure it's encoded. * @return a map where the key is the thumbnail size and the value is the URI of the thumbnail */ public static Map buildSrcsetMap(URI permalink) { var fileSuffix = FilenameUtils.getExtension(permalink.getPath()); if (!isSupportedImage(fileSuffix)) { return Map.of(); } return Arrays.stream(ThumbnailSize.values()) .collect(Collectors.toMap(t -> t, t -> UriComponentsBuilder.fromUri(permalink) .queryParam("width", t.getWidth()) .build(true) .toUri() )); } } ================================================ FILE: application/src/main/java/run/halo/app/core/counter/CounterService.java ================================================ package run.halo.app.core.counter; import java.util.Collection; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Counter; /** * @author guqing * @since 2.0.0 */ public interface CounterService { Mono getByName(String counterName); Flux getByNames(Collection names); Mono deleteByName(String counterName); } ================================================ FILE: application/src/main/java/run/halo/app/core/counter/CounterServiceImpl.java ================================================ package run.halo.app.core.counter; import java.util.Collection; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Counter; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.Queries; /** * Counter service implementation. * * @author guqing * @since 2.0.0 */ @Service public class CounterServiceImpl implements CounterService { private final ReactiveExtensionClient client; public CounterServiceImpl(ReactiveExtensionClient client) { this.client = client; } @Override public Mono getByName(String counterName) { return client.fetch(Counter.class, counterName); } @Override public Flux getByNames(Collection names) { if (CollectionUtils.isEmpty(names)) { return Flux.empty(); } var options = ListOptions.builder() .andQuery(Queries.in("metadata.name", names)) .build(); return client.listAll(Counter.class, options, ExtensionUtil.defaultSort()); } @Override public Mono deleteByName(String counterName) { return client.fetch(Counter.class, counterName) .flatMap(client::delete); } } ================================================ FILE: application/src/main/java/run/halo/app/core/counter/MeterUtils.java ================================================ package run.halo.app.core.counter; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.Tags; import org.apache.commons.lang3.StringUtils; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; /** * Meter utils. * * @author guqing * @since 2.0.0 */ public class MeterUtils { public static final Tag METRICS_COMMON_TAG = Tag.of("metrics.halo.run", "true"); public static final String SCENE = "scene"; public static final String VISIT_SCENE = "visit"; public static final String UPVOTE_SCENE = "upvote"; public static final String DOWNVOTE_SCENE = "downvote"; public static final String TOTAL_COMMENT_SCENE = "total_comment"; public static final String APPROVED_COMMENT_SCENE = "approved_comment"; /** * Build a counter name. * * @param group extension group * @param plural extension plural * @param name extension name * @return counter name */ public static String nameOf(String group, String plural, String name) { if (StringUtils.isBlank(group)) { return String.join("/", plural, name); } return String.join(".", plural, group) + "/" + name; } public static String nameOf(Class clazz, String name) { GVK annotation = clazz.getAnnotation(GVK.class); return nameOf(annotation.group(), annotation.plural(), name); } public static Counter visitCounter(MeterRegistry registry, String name) { return counter(registry, name, Tag.of(SCENE, VISIT_SCENE)); } public static Counter upvoteCounter(MeterRegistry registry, String name) { return counter(registry, name, Tag.of(SCENE, UPVOTE_SCENE)); } public static Counter downvoteCounter(MeterRegistry registry, String name) { return counter(registry, name, Tag.of(SCENE, DOWNVOTE_SCENE)); } public static Counter totalCommentCounter(MeterRegistry registry, String name) { return counter(registry, name, Tag.of(SCENE, TOTAL_COMMENT_SCENE)); } public static Counter approvedCommentCounter(MeterRegistry registry, String name) { return counter(registry, name, Tag.of(SCENE, APPROVED_COMMENT_SCENE)); } public static boolean isVisitCounter(Counter counter) { String sceneValue = counter.getId().getTag(SCENE); if (StringUtils.isBlank(sceneValue)) { return false; } return VISIT_SCENE.equals(sceneValue); } public static boolean isUpvoteCounter(Counter counter) { String sceneValue = counter.getId().getTag(SCENE); if (StringUtils.isBlank(sceneValue)) { return false; } return UPVOTE_SCENE.equals(sceneValue); } public static boolean isDownvoteCounter(Counter counter) { String sceneValue = counter.getId().getTag(SCENE); if (StringUtils.isBlank(sceneValue)) { return false; } return DOWNVOTE_SCENE.equals(sceneValue); } public static boolean isTotalCommentCounter(Counter counter) { String sceneValue = counter.getId().getTag(SCENE); if (StringUtils.isBlank(sceneValue)) { return false; } return TOTAL_COMMENT_SCENE.equals(sceneValue); } public static boolean isApprovedCommentCounter(Counter counter) { String sceneValue = counter.getId().getTag(SCENE); if (StringUtils.isBlank(sceneValue)) { return false; } return APPROVED_COMMENT_SCENE.equals(sceneValue); } /** * Build a {@link Counter} for halo extension. * * @param registry meter registry * @param name counter name,build by {@link #nameOf(String, String, String)} * @return counter find by name from registry if exists, otherwise create a new one. */ private static Counter counter(MeterRegistry registry, String name, Tag... tags) { Tags withTags = Tags.of(METRICS_COMMON_TAG).and(tags); Counter counter = registry.find(name) .tags(withTags) .counter(); if (counter == null) { return registry.counter(name, withTags); } return counter; } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/AttachmentHandler.java ================================================ package run.halo.app.core.endpoint; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import java.net.MalformedURLException; import java.net.URI; import java.util.Optional; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.Nullable; import org.springdoc.core.fn.builders.operation.Builder; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.infra.SystemSetting.Attachment.UploadOptions; @Slf4j @RequiredArgsConstructor @Component public class AttachmentHandler { private final AttachmentService attachmentService; /** * Build OpenAPI doc of request and response for upload attachment endpoint. * * @param builder the operation builder */ public void buildDoc(Builder builder) { builder.requestBody(requestBodyBuilder() .content(contentBuilder() .mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) .schema(schemaBuilder().implementation(UploadForm.class)) ) ) .response(responseBuilder().implementation(Attachment.class)); } /** * Handle upload attachment request. * * @param serverRequest the server request * @param getConfig the upload options fetcher * @return the server response */ public Mono handleUpload(ServerRequest serverRequest, Mono getConfig) { var getForm = serverRequest.bind(UploadForm.class); var uploadAttachment = Mono.zip(getForm, getConfig) .flatMap(tuple2 -> { var form = tuple2.getT1(); var config = tuple2.getT2(); var file = form.getFile(); var upload = Mono.defer(() -> { if (file != null) { var mediaType = Optional.ofNullable(file.headers().getContentType()) .orElse(MediaType.APPLICATION_OCTET_STREAM); if (log.isDebugEnabled()) { log.debug("Preparing to upload attachment [filename={} mediaType={}]", file.name(), mediaType); } return attachmentService.upload( config.policyName(), config.groupName(), file.filename(), file.content(), mediaType ); } if (log.isDebugEnabled()) { log.debug("Preparing to upload attachment from url [{}], filename: {}", form.getUrl(), form.getFilename()); } var url = Optional.ofNullable(form.getUrl()) .filter(StringUtils::hasText) .map(URI::create) .map(uri -> { try { return uri.toURL(); } catch (MalformedURLException e) { throw new RuntimeException(e); } }) .orElse(null); if (url == null) { return Mono.error(new ServerWebInputException( "Invalid url provided: " + form.getUrl() )); } return attachmentService.uploadFromUrl( url, config.policyName(), config.groupName(), form.getFilename() ); }); return upload.flatMap(a -> attachmentService.getPermalink(a) .doOnNext(permalink -> a.getStatus().setPermalink(permalink.toString())) .thenReturn(a) ); }); return ServerResponse.ok().body(uploadAttachment, Attachment.class); } /** * Upload form from console. The file and url are mutually exclusive. If both are provided, * the file will be used. * */ @Data @NoArgsConstructor @AllArgsConstructor public static class UploadForm { /** * The file to upload. If not provided, the url will be used. */ @Nullable private FilePart file; /** * The filename to use when uploading from url. If not provided, the filename will be * extracted from the url. */ @Nullable private String filename; /** * The url to upload from. If not provided, the file will be used. */ @Nullable private String url; } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/WebSocketEndpointManager.java ================================================ package run.halo.app.core.endpoint; import java.util.Collection; /** * Interface for managing WebSocket endpoints, including registering and unregistering. * * @author johnniang */ public interface WebSocketEndpointManager { void register(Collection endpoints); void unregister(Collection endpoints); } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/WebSocketHandlerMapping.java ================================================ package run.halo.app.core.endpoint; import com.google.common.collect.BiMap; import com.google.common.collect.HashBiMap; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.InitializingBean; import org.springframework.http.HttpMethod; import org.springframework.http.server.reactive.observation.ServerRequestObservationContext; import org.springframework.lang.NonNull; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.web.reactive.handler.AbstractHandlerMapping; import org.springframework.web.reactive.socket.WebSocketHandler; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.util.pattern.PathPattern; import reactor.core.publisher.Mono; import run.halo.app.infra.ui.WebSocketUtils; public class WebSocketHandlerMapping extends AbstractHandlerMapping implements WebSocketEndpointManager, InitializingBean { private final BiMap endpointMap; private final ReadWriteLock rwLock; public WebSocketHandlerMapping() { this.endpointMap = HashBiMap.create(); this.rwLock = new ReentrantReadWriteLock(); } @Override @NonNull public Mono getHandlerInternal(ServerWebExchange exchange) { var request = exchange.getRequest(); if (!HttpMethod.GET.equals(request.getMethod()) || !WebSocketUtils.isWebSocketUpgrade(request.getHeaders())) { // skip getting handler if the request is not a WebSocket. return Mono.empty(); } var lock = rwLock.readLock(); lock.lock(); try { // Refer to org.springframework.web.reactive.handler.AbstractUrlHandlerMapping // .lookupHandler var pathContainer = request.getPath().pathWithinApplication(); List matches = null; for (var pattern : this.endpointMap.keySet()) { if (pattern.matches(pathContainer)) { if (matches == null) { matches = new ArrayList<>(); } matches.add(pattern); } } if (matches == null) { return Mono.empty(); } if (matches.size() > 1) { matches.sort(PathPattern.SPECIFICITY_COMPARATOR); } var pattern = matches.get(0); exchange.getAttributes().put(BEST_MATCHING_PATTERN_ATTRIBUTE, pattern); var handler = endpointMap.get(pattern).handler(); exchange.getAttributes().put(BEST_MATCHING_HANDLER_ATTRIBUTE, handler); ServerRequestObservationContext.findCurrent(exchange.getAttributes()) .ifPresent(context -> context.setPathPattern(pattern.toString())); var pathWithinMapping = pattern.extractPathWithinPattern(pathContainer); exchange.getAttributes().put(PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, pathWithinMapping); var matchInfo = pattern.matchAndExtract(pathContainer); Assert.notNull(matchInfo, "Expect a match"); exchange.getAttributes() .put(URI_TEMPLATE_VARIABLES_ATTRIBUTE, matchInfo.getUriVariables()); return Mono.just(handler); } catch (Exception e) { return Mono.error(e); } finally { lock.unlock(); } } @Override public void register(Collection endpoints) { if (CollectionUtils.isEmpty(endpoints)) { return; } var lock = rwLock.writeLock(); lock.lock(); try { endpoints.forEach(endpoint -> { var urlPath = endpoint.urlPath(); urlPath = StringUtils.prependIfMissing(urlPath, "/"); var groupVersion = endpoint.groupVersion(); var parser = getPathPatternParser(); var pattern = parser.parse("/apis/" + groupVersion + urlPath); endpointMap.put(pattern, endpoint); }); } finally { lock.unlock(); } } @Override public void unregister(Collection endpoints) { if (CollectionUtils.isEmpty(endpoints)) { return; } var lock = rwLock.writeLock(); lock.lock(); try { BiMap inverseMap = endpointMap.inverse(); endpoints.forEach(inverseMap::remove); } finally { lock.unlock(); } } @Override public void afterPropertiesSet() { var endpoints = obtainApplicationContext().getBeanProvider(WebSocketEndpoint.class) .orderedStream() .toList(); register(endpoints); } BiMap getEndpointMap() { return endpointMap; } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/console/AttachmentConsoleEndpoint.java ================================================ package run.halo.app.core.endpoint.console; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; import static org.springframework.web.reactive.function.server.RequestPredicates.path; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.endpoint.AttachmentHandler; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; @Slf4j @Component @RequiredArgsConstructor class AttachmentConsoleEndpoint implements CustomEndpoint { private final SystemConfigFetcher systemConfigFetcher; private final AttachmentHandler attachmentHandler; @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("console.api.storage.halo.run/v1alpha1"); } @Override public RouterFunction endpoint() { var tag = "AttachmentV1alpha1Console"; return SpringdocRouteBuilder.route() .POST( path("/attachments/-/upload") .and(contentType(MediaType.MULTIPART_FORM_DATA)), this::handleUpload, builder -> { builder.operationId("uploadAttachmentForConsole") .tag(tag) .description("Upload attachment endpoint for console."); this.attachmentHandler.buildDoc(builder); } ) .build(); } private Mono handleUpload(ServerRequest serverRequest) { var getConfig = systemConfigFetcher.fetch( SystemSetting.Attachment.GROUP, SystemSetting.Attachment.class ) .mapNotNull(SystemSetting.Attachment::console) .filter(ac -> StringUtils.hasText(ac.policyName())) .switchIfEmpty(Mono.error(() -> new ServerWebInputException( "Attachment system setting is not configured for console" ))); return attachmentHandler.handleUpload(serverRequest, getConfig); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/console/AuthProviderEndpoint.java ================================================ package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import lombok.RequiredArgsConstructor; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.AuthProvider; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.security.AuthProviderService; import run.halo.app.security.ListedAuthProvider; /** * Auth provider endpoint. * * @author guqing * @since 2.0.0 */ @Component @RequiredArgsConstructor public class AuthProviderEndpoint implements CustomEndpoint { private final AuthProviderService authProviderService; @Override public RouterFunction endpoint() { final var tag = "AuthProviderV1alpha1Console"; return SpringdocRouteBuilder.route() .GET("auth-providers", this::listAuthProviders, builder -> builder.operationId("listAuthProviders") .description("Lists all auth providers") .tag(tag) .response(responseBuilder() .implementationArray(ListedAuthProvider.class)) ) .PUT("auth-providers/{name}/enable", this::enableAuthProvider, builder -> builder.operationId("enableAuthProvider") .description("Enables an auth provider") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(AuthProvider.class)) ) .PUT("auth-providers/{name}/disable", this::disableAuthProvider, builder -> builder.operationId("disableAuthProvider") .description("Disables an auth provider") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(AuthProvider.class)) ) .build(); } private Mono enableAuthProvider(ServerRequest request) { String name = request.pathVariable("name"); return authProviderService.enable(name) .flatMap(authProvider -> ServerResponse.ok().bodyValue(authProvider)); } private Mono disableAuthProvider(ServerRequest request) { String name = request.pathVariable("name"); return authProviderService.disable(name) .flatMap(authProvider -> ServerResponse.ok().bodyValue(authProvider)); } Mono listAuthProviders(ServerRequest request) { return authProviderService.listAll() .flatMap(providers -> ServerResponse.ok().bodyValue(providers)); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/console/CommentEndpoint.java ================================================ package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.time.Instant; import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.content.comment.CommentQuery; import run.halo.app.content.comment.CommentRequest; import run.halo.app.content.comment.CommentService; import run.halo.app.content.comment.ListedComment; import run.halo.app.content.comment.ReplyRequest; import run.halo.app.content.comment.ReplyService; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ListResult; import run.halo.app.infra.utils.HaloUtils; import run.halo.app.infra.utils.IpAddressUtils; /** * Endpoint for managing comment. * * @author guqing * @since 2.0.0 */ @Component public class CommentEndpoint implements CustomEndpoint { private final CommentService commentService; private final ReplyService replyService; public CommentEndpoint(CommentService commentService, ReplyService replyService) { this.commentService = commentService; this.replyService = replyService; } @Override public RouterFunction endpoint() { final var tag = "CommentV1alpha1Console"; return SpringdocRouteBuilder.route() .GET("comments", this::listComments, builder -> { builder.operationId("ListComments") .description("List comments.") .tag(tag) .response(responseBuilder() .implementation(ListResult.generateGenericClass(ListedComment.class)) ); CommentQuery.buildParameters(builder); } ) .POST("comments", this::createComment, builder -> builder.operationId("CreateComment") .description("Create a comment.") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() .implementation(CommentRequest.class)) )) .response(responseBuilder() .implementation(Comment.class)) ) .POST("comments/{name}/reply", this::createReply, builder -> builder.operationId("CreateReply") .description("Create a reply.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class)) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() .implementation(ReplyRequest.class)) )) .response(responseBuilder() .implementation(Reply.class)) ) .build(); } Mono listComments(ServerRequest request) { CommentQuery commentQuery = new CommentQuery(request); return commentService.listComment(commentQuery) .flatMap(listedComments -> ServerResponse.ok().bodyValue(listedComments)); } Mono createComment(ServerRequest request) { return request.bodyToMono(CommentRequest.class) .flatMap(commentRequest -> { Comment comment = commentRequest.toComment(); comment.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request)); comment.getSpec().setUserAgent(HaloUtils.userAgentFrom(request)); return commentService.create(comment); }) .flatMap(comment -> ServerResponse.ok().bodyValue(comment)); } Mono createReply(ServerRequest request) { String commentName = request.pathVariable("name"); return request.bodyToMono(ReplyRequest.class) .flatMap(replyRequest -> { Reply reply = replyRequest.toReply(); // Create via console without audit reply.getSpec().setApproved(true); reply.getSpec().setApprovedTime(Instant.now()); reply.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request)); reply.getSpec().setUserAgent(HaloUtils.userAgentFrom(request)); // fix gh-2951 if (reply.getSpec().getHidden() == null) { reply.getSpec().setHidden(false); } return replyService.create(commentName, reply); }) .flatMap(comment -> ServerResponse.ok().bodyValue(comment)); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/console/ConsoleUserEndpoint.java ================================================ package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.util.Objects; import org.springdoc.core.fn.builders.parameter.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RequestPredicates; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.GroupVersion; /** * User endpoint for console. * * @author johnniang */ @Component class ConsoleUserEndpoint implements CustomEndpoint { private final UserService userService; ConsoleUserEndpoint(UserService userService) { this.userService = userService; } @Override public RouterFunction endpoint() { var tag = "UserV1alpha1Console"; return RouterFunctions.nest(RequestPredicates.path("/users"), SpringdocRouteBuilder.route() .POST("/{username}/disable", this::handleDisableUser, ops -> { ops.operationId("DisableUser") .tag(tag) .description("Disable user by username") .parameter(Builder.parameterBuilder() .name("username") .in(ParameterIn.PATH) .description("Username") .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(User.class) .description("The user has been disabled.") ); }) .POST("/{username}/enable", this::handleEnableUser, ops -> { ops.operationId("EnableUser") .tag(tag) .description("Enable user by username") .parameter(Builder.parameterBuilder() .name("username") .in(ParameterIn.PATH) .description("Username") .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(User.class) .description("The user has been enabled.") ); }) .build()); } private Mono handleEnableUser(ServerRequest request) { return userService.enable(request.pathVariable("username")) .switchIfEmpty(Mono.error( () -> new ServerWebInputException("The user was not found or has been enabled.")) ) .flatMap(user -> ServerResponse.ok().bodyValue(user)); } private Mono handleDisableUser(ServerRequest request) { var username = request.pathVariable("username"); return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(Authentication::getName) .switchIfEmpty(Mono.error( () -> new ServerWebInputException("The current user is not authenticated.")) ) .filter(currentUsername -> !Objects.equals(currentUsername, username)) .switchIfEmpty(Mono.error(() -> new ServerWebInputException( "The user is the current user, can't disable it." ))) .then(Mono.defer(() -> userService.disable(username))) .switchIfEmpty(Mono.error( () -> new ServerWebInputException("The user was not found or has been disabled.")) ) .flatMap(user -> ServerResponse.ok().bodyValue(user)); } @Override public GroupVersion groupVersion() { return new GroupVersion("console.api.security.halo.run", "v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/console/CustomEndpointsBuilder.java ================================================ package run.halo.app.core.endpoint.console; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.web.reactive.function.server.RequestPredicates; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; public class CustomEndpointsBuilder { private final Map>> routerFunctionsMap; public CustomEndpointsBuilder() { routerFunctionsMap = new HashMap<>(); } public CustomEndpointsBuilder add(CustomEndpoint customEndpoint) { routerFunctionsMap .computeIfAbsent(customEndpoint.groupVersion(), gv -> new LinkedList<>()) .add(customEndpoint.endpoint()); return this; } public RouterFunction build() { SpringdocRouteBuilder routeBuilder = SpringdocRouteBuilder.route(); routerFunctionsMap.forEach((gv, routerFunctions) -> routeBuilder.nest(RequestPredicates.path("/apis/" + gv), () -> routerFunctions.stream().reduce(RouterFunction::and).orElse(null) ) ); if (routerFunctionsMap.isEmpty()) { // return empty route. return request -> Mono.empty(); } routerFunctionsMap.clear(); return routeBuilder.build(); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/console/PluginEndpoint.java ================================================ package run.halo.app.core.endpoint.console; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import static org.springframework.boot.convert.ApplicationConversionService.getSharedInstance; import static org.springframework.core.io.buffer.DataBufferUtils.write; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; import static run.halo.app.extension.ListResult.generateGenericClass; import static run.halo.app.extension.index.query.Queries.contains; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.or; import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter; import static run.halo.app.infra.utils.FileUtils.deleteFileSilently; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import java.io.FileNotFoundException; import java.io.IOException; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; import java.time.Instant; import java.util.Map; import java.util.Optional; import java.util.function.Function; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.reactivestreams.Publisher; import org.springdoc.core.fn.builders.operation.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.autoconfigure.web.WebProperties; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Sort; import org.springframework.http.CacheControl; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.FormFieldPart; import org.springframework.http.codec.multipart.Part; import org.springframework.stereotype.Component; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.resource.NoResourceFoundException; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.user.service.SettingConfigService; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.IListRequest; import run.halo.app.extension.router.SortableRequest; import run.halo.app.infra.ReactiveUrlDataBufferFetcher; import run.halo.app.infra.utils.SettingUtils; import run.halo.app.plugin.PluginService; import tools.jackson.databind.node.ObjectNode; @Slf4j @Component public class PluginEndpoint implements CustomEndpoint, InitializingBean { private final ReactiveExtensionClient client; private final PluginService pluginService; private final ReactiveUrlDataBufferFetcher reactiveUrlDataBufferFetcher; private final SettingConfigService settingConfigService; private final WebProperties webProperties; private final Scheduler scheduler = Schedulers.boundedElastic(); private boolean useLastModified; private CacheControl bundleCacheControl = CacheControl.empty(); public PluginEndpoint(ReactiveExtensionClient client, PluginService pluginService, ReactiveUrlDataBufferFetcher reactiveUrlDataBufferFetcher, SettingConfigService settingConfigService, WebProperties webProperties) { this.client = client; this.pluginService = pluginService; this.reactiveUrlDataBufferFetcher = reactiveUrlDataBufferFetcher; this.settingConfigService = settingConfigService; this.webProperties = webProperties; } @Override public RouterFunction endpoint() { var tag = "PluginV1alpha1Console"; return SpringdocRouteBuilder.route() .POST("plugins/install", contentType(MediaType.MULTIPART_FORM_DATA), this::install, builder -> builder.operationId("InstallPlugin") .description("Install a plugin by uploading a Jar file.") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) .schema(schemaBuilder().implementation(InstallRequest.class)) )) .response(responseBuilder().implementation(Plugin.class)) ) .POST("plugins/-/install-from-uri", this::installFromUri, builder -> builder.operationId("InstallPluginFromUri") .description("Install a plugin from uri.") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(schemaBuilder() .implementation(InstallFromUriRequest.class)) )) .response(responseBuilder() .implementation(Plugin.class)) ) .POST("plugins/{name}/upgrade-from-uri", this::upgradeFromUri, builder -> builder.operationId("UpgradePluginFromUri") .description("Upgrade a plugin from uri.") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .required(true) ) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(schemaBuilder() .implementation(UpgradeFromUriRequest.class)) )) .response(responseBuilder() .implementation(Plugin.class)) ) .POST("plugins/{name}/upgrade", contentType(MediaType.MULTIPART_FORM_DATA), this::upgrade, builder -> builder.operationId("UpgradePlugin") .description("Upgrade a plugin by uploading a Jar file") .tag(tag) .parameter(parameterBuilder().name("name").in(ParameterIn.PATH).required(true)) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder().mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) .schema(schemaBuilder().implementation(InstallRequest.class)))) ) .PUT("plugins/{name}/json-config", this::updatePluginJsonConfig, builder -> builder.operationId("updatePluginJsonConfig") .description("Update the config of plugin setting.") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder().mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(schemaBuilder().implementation(Object.class)))) .response(responseBuilder() .responseCode(String.valueOf(HttpStatus.NO_CONTENT.value())) .implementation(Void.class)) ) .PUT("plugins/{name}/reset-config", this::resetSettingConfig, builder -> builder.operationId("ResetPluginConfig") .description("Reset the configMap of plugin setting.") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(ConfigMap.class)) ) .PUT("plugins/{name}/reload", this::reload, builder -> builder.operationId("reloadPlugin") .description("Reload a plugin by name.") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(Plugin.class)) ) .PUT("plugins/{name}/plugin-state", this::changePluginRunningState, builder -> builder.operationId("ChangePluginRunningState") .description("Change the running state of a plugin by name.") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(schemaBuilder() .implementation(RunningStateRequest.class)) ) ) .response(responseBuilder() .implementation(Plugin.class)) ) .GET("plugins", this::list, builder -> { builder.operationId("ListPlugins") .tag(tag) .description("List plugins using query criteria and sort params") .response(responseBuilder().implementation(generateGenericClass(Plugin.class))); ListRequest.buildParameters(builder); }) .GET("plugins/{name}/setting", this::fetchPluginSetting, builder -> builder.operationId("fetchPluginSetting") .description("Fetch setting of plugin.") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(Setting.class)) ) .GET("plugins/{name}/json-config", this::fetchPluginJsonConfig, builder -> builder.operationId("fetchPluginJsonConfig") .description( "Fetch converted json config of plugin by configured configMapName.") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(Object.class)) ) .GET("plugins/-/bundle.js", this::fetchJsBundle, builder -> builder.operationId("fetchJsBundle") .description("Merge all JS bundles of enabled plugins into one.") .tag(tag) .response(responseBuilder().implementation(String.class)) ) .GET("plugins/-/bundle.css", this::fetchCssBundle, builder -> builder.operationId("fetchCssBundle") .description("Merge all CSS bundles of enabled plugins into one.") .tag(tag) .response(responseBuilder().implementation(String.class)) ) .build(); } private Mono fetchPluginJsonConfig(ServerRequest request) { final var name = request.pathVariable("name"); return client.fetch(Plugin.class, name) .mapNotNull(plugin -> plugin.getSpec().getConfigMapName()) .flatMap(settingConfigService::fetchConfig) .flatMap(json -> ServerResponse.ok().bodyValue(json)); } private Mono updatePluginJsonConfig(ServerRequest request) { final var pluginName = request.pathVariable("name"); return client.fetch(Plugin.class, pluginName) .doOnNext(plugin -> { String configMapName = plugin.getSpec().getConfigMapName(); if (!StringUtils.hasText(configMapName)) { throw new ServerWebInputException( "Unable to complete the request because the plugin configMapName is blank"); } }) .flatMap(plugin -> { final String configMapName = plugin.getSpec().getConfigMapName(); return request.bodyToMono(ObjectNode.class) .switchIfEmpty( Mono.error(new ServerWebInputException("Required request body is missing"))) .flatMap(configMapJsonData -> settingConfigService.upsertConfig(configMapName, configMapJsonData)); }) .then(ServerResponse.noContent().build()); } Mono changePluginRunningState(ServerRequest request) { final var name = request.pathVariable("name"); return request.bodyToMono(RunningStateRequest.class) .flatMap(runningState -> { var enable = runningState.isEnable(); var async = runningState.isAsync(); return pluginService.changeState(name, enable, !async); }) .flatMap(plugin -> ServerResponse.ok().bodyValue(plugin)); } @Override public void afterPropertiesSet() { var cache = this.webProperties.getResources().getCache(); this.useLastModified = cache.isUseLastModified(); var cacheControl = cache.getCachecontrol().toHttpCacheControl(); if (cacheControl != null) { this.bundleCacheControl = cacheControl; } } @Data @Schema(name = "PluginRunningStateRequest") static class RunningStateRequest { private boolean enable; private boolean async; } private Mono fetchJsBundle(ServerRequest request) { var versionOption = request.queryParam("v"); return versionOption.map(s -> pluginService.getJsBundle(s).flatMap( jsRes -> { var bodyBuilder = ServerResponse.ok() .cacheControl(bundleCacheControl) .contentType(MediaType.valueOf("text/javascript")); if (useLastModified) { try { var lastModified = Instant.ofEpochMilli(jsRes.lastModified()); bodyBuilder = bodyBuilder.lastModified(lastModified); } catch (IOException e) { if (e instanceof FileNotFoundException) { return Mono.error( new NoResourceFoundException(request.uri(), "bundle.js") ); } return Mono.error(e); } } return bodyBuilder.body(BodyInserters.fromResource(jsRes)); })) .orElseGet(() -> pluginService.generateBundleVersion() .flatMap(version -> ServerResponse .temporaryRedirect(buildJsBundleUri("js", version)) .cacheControl(CacheControl.noStore()) .build())); } private Mono fetchCssBundle(ServerRequest request) { return request.queryParam("v") .map(s -> pluginService.getCssBundle(s).flatMap(cssRes -> { var bodyBuilder = ServerResponse.ok() .cacheControl(bundleCacheControl) .contentType(MediaType.valueOf("text/css")); if (useLastModified) { try { var lastModified = Instant.ofEpochMilli(cssRes.lastModified()); bodyBuilder = bodyBuilder.lastModified(lastModified); } catch (IOException e) { if (e instanceof FileNotFoundException) { return Mono.error( new NoResourceFoundException(request.uri(), "bundle.css") ); } return Mono.error(e); } } return bodyBuilder.body(BodyInserters.fromResource(cssRes)); })) .orElseGet(() -> pluginService.generateBundleVersion() .flatMap(version -> ServerResponse .temporaryRedirect(buildJsBundleUri("css", version)) .cacheControl(CacheControl.noStore()) .build())); } URI buildJsBundleUri(String type, String version) { return URI.create( "/apis/api.console.halo.run/v1alpha1/plugins/-/bundle." + type + "?v=" + version); } private Mono upgradeFromUri(ServerRequest request) { var name = request.pathVariable("name"); var content = request.bodyToMono(UpgradeFromUriRequest.class) .map(UpgradeFromUriRequest::uri) .flatMapMany(reactiveUrlDataBufferFetcher::fetch); return Mono.usingWhen( writeToTempFile(content), path -> pluginService.upgrade(name, path), this::deleteFileIfExists) .flatMap(upgradedPlugin -> ServerResponse.ok().bodyValue(upgradedPlugin)); } private Mono installFromUri(ServerRequest request) { var content = request.bodyToMono(InstallFromUriRequest.class) .map(InstallFromUriRequest::uri) .flatMapMany(reactiveUrlDataBufferFetcher::fetch); return Mono.usingWhen( writeToTempFile(content), pluginService::install, this::deleteFileIfExists ) .flatMap(newPlugin -> ServerResponse.ok().bodyValue(newPlugin)); } public record InstallFromUriRequest(@Schema(requiredMode = REQUIRED) URI uri) { } public record UpgradeFromUriRequest(@Schema(requiredMode = REQUIRED) URI uri) { } private Mono reload(ServerRequest serverRequest) { var name = serverRequest.pathVariable("name"); return ServerResponse.ok().body(pluginService.reload(name), Plugin.class); } private Mono fetchPluginConfig(ServerRequest request) { final var name = request.pathVariable("name"); return client.fetch(Plugin.class, name) .mapNotNull(plugin -> plugin.getSpec().getConfigMapName()) .flatMap(configMapName -> client.fetch(ConfigMap.class, configMapName)) .flatMap(configMap -> ServerResponse.ok().bodyValue(configMap)); } private Mono fetchPluginSetting(ServerRequest request) { final var name = request.pathVariable("name"); return client.fetch(Plugin.class, name) .mapNotNull(plugin -> plugin.getSpec().getSettingName()) .flatMap(settingName -> client.fetch(Setting.class, settingName)) .flatMap(setting -> ServerResponse.ok().bodyValue(setting)); } private Mono updatePluginConfig(ServerRequest request) { final var pluginName = request.pathVariable("name"); return client.fetch(Plugin.class, pluginName) .doOnNext(plugin -> { String configMapName = plugin.getSpec().getConfigMapName(); if (!StringUtils.hasText(configMapName)) { throw new ServerWebInputException( "Unable to complete the request because the plugin configMapName is blank"); } }) .flatMap(plugin -> { final String configMapName = plugin.getSpec().getConfigMapName(); return request.bodyToMono(ConfigMap.class) .doOnNext(configMapToUpdate -> { var configMapNameToUpdate = configMapToUpdate.getMetadata().getName(); if (!configMapName.equals(configMapNameToUpdate)) { throw new ServerWebInputException( "The name from the request body does not match the plugin " + "configMapName name."); } }) .flatMap(configMapToUpdate -> client.fetch(ConfigMap.class, configMapName) .map(persisted -> { configMapToUpdate.getMetadata() .setVersion(persisted.getMetadata().getVersion()); return configMapToUpdate; }) .switchIfEmpty(client.create(configMapToUpdate)) ) .flatMap(client::update) .retryWhen(Retry.backoff(5, Duration.ofMillis(300)) .filter(OptimisticLockingFailureException.class::isInstance) ); }) .flatMap(configMap -> ServerResponse.ok().bodyValue(configMap)); } private Mono resetSettingConfig(ServerRequest request) { String name = request.pathVariable("name"); return client.fetch(Plugin.class, name) .filter(plugin -> StringUtils.hasText(plugin.getSpec().getSettingName())) .flatMap(plugin -> { String configMapName = plugin.getSpec().getConfigMapName(); String settingName = plugin.getSpec().getSettingName(); return client.fetch(Setting.class, settingName) .map(SettingUtils::settingDefinedDefaultValueMap) .flatMap(data -> updateConfigMapData(configMapName, data)); }) .flatMap(configMap -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(configMap)); } private Mono updateConfigMapData(String configMapName, Map data) { return client.fetch(ConfigMap.class, configMapName) .flatMap(configMap -> { configMap.setData(data); return client.update(configMap); }) .retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100)) .filter(t -> t instanceof OptimisticLockingFailureException)); } private Mono install(ServerRequest request) { return request.multipartData() .map(InstallRequest::new) .flatMap(installRequest -> installRequest.getSource() .flatMap(source -> { if (InstallSource.FILE.equals(source)) { return installFromFile(installRequest.getFile(), pluginService::install); } return Mono.error( new UnsupportedOperationException("Unsupported install source " + source)); })) .flatMap(plugin -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(plugin)); } private Mono upgrade(ServerRequest request) { var pluginName = request.pathVariable("name"); return request.multipartData() .map(InstallRequest::new) .flatMap(installRequest -> installRequest.getSource() .flatMap(source -> { if (InstallSource.FILE.equals(source)) { return installFromFile(installRequest.getFile(), path -> pluginService.upgrade(pluginName, path)); } return Mono.error( new UnsupportedOperationException("Unsupported install source " + source)); })) .flatMap(upgradedPlugin -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(upgradedPlugin)); } private Mono installFromFile(FilePart filePart, Function> resourceClosure) { return Mono.usingWhen( writeToTempFile(filePart.content()), resourceClosure, this::deleteFileIfExists); } public static class ListRequest extends SortableRequest { public ListRequest(ServerRequest request) { super(request.exchange()); } @Schema(name = "keyword", description = "Keyword of plugin name or description") public String getKeyword() { return queryParams.getFirst("keyword"); } @Schema(name = "enabled", description = "Whether the plugin is enabled") public Boolean getEnabled() { var enabled = queryParams.getFirst("enabled"); return enabled == null ? null : getSharedInstance().convert(enabled, Boolean.class); } @Override public Sort getSort() { var orders = super.getSort().stream() .map(order -> { if ("creationTimestamp".equals(order.getProperty())) { return order.withProperty("metadata.creationTimestamp"); } return order; }) .toList(); return Sort.by(orders); } @Override public ListOptions toListOptions() { var builder = ListOptions.builder(super.toListOptions()); Optional.ofNullable(queryParams.getFirst("keyword")) .filter(StringUtils::hasText) .ifPresent(keyword -> builder.andQuery(or( contains("spec.displayName", keyword), contains("spec.description", keyword) ))); Optional.ofNullable(queryParams.getFirst("enabled")) .map(Boolean::parseBoolean) .ifPresent(enabled -> builder.andQuery(equal("spec.enabled", enabled.toString()))); return builder.build(); } public static void buildParameters(Builder builder) { IListRequest.buildParameters(builder); builder.parameter(sortParameter()); builder.parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("keyword") .description("Keyword of plugin name or description") .implementation(String.class) .required(false)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("enabled") .description("Whether the plugin is enabled") .implementation(Boolean.class) .required(false)); } } Mono list(ServerRequest request) { return Mono.just(request) .map(ListRequest::new) .flatMap(listRequest -> client.listBy( Plugin.class, listRequest.toListOptions(), listRequest.toPageRequest() )) .flatMap(listResult -> ServerResponse.ok().bodyValue(listResult)); } @Schema(name = "PluginInstallRequest", types = "object") public static class InstallRequest { private final MultiValueMap multipartData; public InstallRequest(MultiValueMap multipartData) { this.multipartData = multipartData; } @Schema(requiredMode = NOT_REQUIRED, description = "Plugin Jar file.") public FilePart getFile() { var part = multipartData.getFirst("file"); if (part == null) { throw new ServerWebInputException("Form field file is required"); } if (!(part instanceof FilePart file)) { throw new ServerWebInputException("Invalid parameter of file"); } if (!Paths.get(file.filename()).toString().endsWith(".jar")) { throw new ServerWebInputException("Invalid file type, only jar is supported"); } return file; } @Schema(requiredMode = NOT_REQUIRED, description = "Plugin preset name. We will find the plugin from plugin presets") public Mono getPresetName() { var part = multipartData.getFirst("presetName"); if (part == null) { return Mono.error(new ServerWebInputException( "Form field presetName is required.")); } if (!(part instanceof FormFieldPart presetName)) { return Mono.error(new ServerWebInputException( "Invalid format of presetName field, string required")); } if (!StringUtils.hasText(presetName.value())) { return Mono.error(new ServerWebInputException("presetName must not be blank")); } return Mono.just(presetName.value()); } @Schema(requiredMode = NOT_REQUIRED, description = "Install source. Default is file.") public Mono getSource() { var part = multipartData.getFirst("source"); if (part == null) { return Mono.just(InstallSource.FILE); } if (!(part instanceof FormFieldPart sourcePart)) { return Mono.error(new ServerWebInputException( "Invalid format of source field, string required.")); } var installSource = InstallSource.valueOf(sourcePart.value().toUpperCase()); return Mono.just(installSource); } } public enum InstallSource { FILE, PRESET, URL } Mono deleteFileIfExists(Path path) { return deleteFileSilently(path, this.scheduler).then(); } private Mono writeToTempFile(Publisher content) { return Mono.fromCallable(() -> Files.createTempFile("halo-plugin-", ".jar")) .flatMap(path -> write(content, path).thenReturn(path)) .subscribeOn(this.scheduler); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/console/PostEndpoint.java ================================================ package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static run.halo.app.extension.MetadataUtil.nullSafeLabels; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Duration; import java.util.Objects; import java.util.function.Predicate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.BooleanUtils; import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerErrorException; import org.springframework.web.server.ServerWebInputException; import reactor.core.Exceptions; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.content.Content; import run.halo.app.content.ContentUpdateParam; import run.halo.app.content.ContentWrapper; import run.halo.app.content.ListedPost; import run.halo.app.content.ListedSnapshotDto; import run.halo.app.content.PostQuery; import run.halo.app.content.PostRequest; import run.halo.app.content.PostService; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ListResult; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; /** * Endpoint for managing posts. * * @author guqing * @since 2.0.0 */ @Slf4j @Component @RequiredArgsConstructor public class PostEndpoint implements CustomEndpoint { private int maxAttemptsWaitForPublish = 10; private final PostService postService; private final ReactiveExtensionClient client; @Override public RouterFunction endpoint() { final var tag = "PostV1alpha1Console"; return SpringdocRouteBuilder.route() .GET("posts", this::listPost, builder -> { builder.operationId("ListPosts") .description("List posts.") .tag(tag) .response(responseBuilder() .implementation(ListResult.generateGenericClass(ListedPost.class)) ); PostQuery.buildParameters(builder); } ) .GET("posts/{name}/head-content", this::fetchHeadContent, builder -> builder.operationId("fetchPostHeadContent") .description("Fetch head content of post.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(ContentWrapper.class)) ) .GET("posts/{name}/content", this::fetchContent, builder -> builder.operationId("fetchPostContent") .description("Fetch content of post.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .parameter(parameterBuilder() .name("snapshotName") .in(ParameterIn.QUERY) .required(true) .implementation(String.class)) .response(responseBuilder() .implementation(ContentWrapper.class)) ) .GET("posts/{name}/release-content", this::fetchReleaseContent, builder -> builder.operationId("fetchPostReleaseContent") .description("Fetch release content of post.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(ContentWrapper.class)) ) .GET("posts/{name}/snapshot", this::listSnapshots, builder -> builder.operationId("listPostSnapshots") .description("List all snapshots for post content.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class)) .response(responseBuilder() .implementationArray(ListedSnapshotDto.class)) ) .POST("posts", this::draftPost, builder -> builder.operationId("DraftPost") .description("Draft a post.") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() .implementation(PostRequest.class)) )) .response(responseBuilder() .implementation(Post.class)) ) .PUT("posts/{name}", this::updatePost, builder -> builder.operationId("UpdateDraftPost") .description("Update a post.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class)) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() .implementation(PostRequest.class)) )) .response(responseBuilder() .implementation(Post.class)) ) .PUT("posts/{name}/content", this::updateContent, builder -> builder.operationId("UpdatePostContent") .description("Update a post's content.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class)) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() .implementation(Content.class)) )) .response(responseBuilder() .implementation(Post.class)) ) .PUT("posts/{name}/revert-content", this::revertToSpecifiedSnapshot, builder -> builder.operationId("revertToSpecifiedSnapshotForPost") .description("Revert to specified snapshot for post content.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class)) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() .implementation(RevertSnapshotParam.class)) )) .response(responseBuilder() .implementation(Post.class)) ) .PUT("posts/{name}/publish", this::publishPost, builder -> builder.operationId("PublishPost") .description("Publish a post.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class)) .parameter(parameterBuilder().name("headSnapshot") .description("Head snapshot name of content.") .in(ParameterIn.QUERY) .required(false)) .parameter(parameterBuilder() .name("async") .in(ParameterIn.QUERY) .implementation(Boolean.class) .required(false)) .response(responseBuilder() .implementation(Post.class)) ) .PUT("posts/{name}/unpublish", this::unpublishPost, builder -> builder.operationId("UnpublishPost") .description("UnPublish a post.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true)) .response(responseBuilder() .implementation(Post.class))) .PUT("posts/{name}/recycle", this::recyclePost, builder -> builder.operationId("RecyclePost") .description("Recycle a post.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true))) .DELETE("posts/{name}/content", this::deleteContent, builder -> builder.operationId("deletePostContent") .description("Delete a content for post.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .parameter(parameterBuilder() .name("snapshotName") .in(ParameterIn.QUERY) .required(true) .implementation(String.class)) .response(responseBuilder() .implementation(ContentWrapper.class)) ) .build(); } private Mono deleteContent(ServerRequest request) { final var postName = request.pathVariable("name"); final var snapshotName = request.queryParam("snapshotName").orElseThrow(); return postService.deleteContent(postName, snapshotName) .flatMap(content -> ServerResponse.ok().bodyValue(content)); } private Mono revertToSpecifiedSnapshot(ServerRequest request) { final var postName = request.pathVariable("name"); return request.bodyToMono(RevertSnapshotParam.class) .switchIfEmpty( Mono.error(new ServerWebInputException("Required request body is missing."))) .flatMap(param -> postService.revertToSpecifiedSnapshot(postName, param.snapshotName)) .flatMap(post -> ServerResponse.ok().bodyValue(post)); } @Schema(name = "RevertSnapshotForPostParam") record RevertSnapshotParam( @Schema(requiredMode = Schema.RequiredMode.REQUIRED, minLength = 1) String snapshotName) { } private Mono fetchContent(ServerRequest request) { final var name = request.pathVariable("name"); final var snapshotName = request.queryParam("snapshotName").orElseThrow(); return client.fetch(Post.class, name) .flatMap(post -> { var baseSnapshot = post.getSpec().getBaseSnapshot(); return postService.getContent(snapshotName, baseSnapshot); }) .flatMap(content -> ServerResponse.ok().bodyValue(content)); } private Mono listSnapshots(ServerRequest request) { String name = request.pathVariable("name"); var resultFlux = postService.listSnapshots(name); return ServerResponse.ok().body(resultFlux, ListedSnapshotDto.class); } private Mono fetchReleaseContent(ServerRequest request) { final var name = request.pathVariable("name"); return postService.getReleaseContent(name) .flatMap(content -> ServerResponse.ok().bodyValue(content)); } private Mono fetchHeadContent(ServerRequest request) { String name = request.pathVariable("name"); return postService.getHeadContent(name) .flatMap(content -> ServerResponse.ok().bodyValue(content)); } Mono draftPost(ServerRequest request) { return request.bodyToMono(PostRequest.class) .flatMap(postService::draftPost) .flatMap(post -> ServerResponse.ok().bodyValue(post)); } Mono updateContent(ServerRequest request) { String postName = request.pathVariable("name"); return request.bodyToMono(ContentUpdateParam.class) .flatMap(content -> Mono.defer(() -> client.fetch(Post.class, postName) .flatMap(post -> { PostRequest postRequest = new PostRequest(post, content); return postService.updatePost(postRequest); })) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(throwable -> throwable instanceof OptimisticLockingFailureException)) ) .flatMap(post -> ServerResponse.ok().bodyValue(post)); } Mono updatePost(ServerRequest request) { return request.bodyToMono(PostRequest.class) .flatMap(postService::updatePost) .flatMap(post -> ServerResponse.ok().bodyValue(post)); } Mono publishPost(ServerRequest request) { var name = request.pathVariable("name"); boolean asyncPublish = request.queryParam("async") .map(Boolean::parseBoolean) .orElse(false); return Mono.defer(() -> client.get(Post.class, name) .doOnNext(post -> { var spec = post.getSpec(); request.queryParam("headSnapshot").ifPresent(spec::setHeadSnapshot); spec.setPublish(true); if (spec.getHeadSnapshot() == null) { spec.setHeadSnapshot(spec.getBaseSnapshot()); } spec.setReleaseSnapshot(spec.getHeadSnapshot()); }) .flatMap(client::update) ) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(t -> t instanceof OptimisticLockingFailureException)) .filter(post -> asyncPublish) .switchIfEmpty(Mono.defer(() -> awaitPostPublished(name))) .onErrorMap(Exceptions::isRetryExhausted, err -> new ServerErrorException( "Post publishing failed, please try again later.", err)) .flatMap(publishResult -> ServerResponse.ok().bodyValue(publishResult)); } private Mono awaitPostPublished(String postName) { Predicate schedulePublish = post -> { var labels = nullSafeLabels(post); return BooleanUtils.TRUE.equals(labels.get(Post.SCHEDULING_PUBLISH_LABEL)); }; return Mono.defer(() -> client.get(Post.class, postName) .filter(post -> { var releasedSnapshot = MetadataUtil.nullSafeAnnotations(post) .get(Post.LAST_RELEASED_SNAPSHOT_ANNO); var expectReleaseSnapshot = post.getSpec().getReleaseSnapshot(); return Objects.equals(releasedSnapshot, expectReleaseSnapshot) || schedulePublish.test(post); }) .switchIfEmpty(Mono.error( () -> new IllegalStateException("Retry to check post publish status")) )) .retryWhen(Retry.backoff(maxAttemptsWaitForPublish, Duration.ofMillis(100)) .filter(IllegalStateException.class::isInstance) ); } private Mono unpublishPost(ServerRequest request) { var name = request.pathVariable("name"); return Mono.defer(() -> client.get(Post.class, name) .doOnNext(post -> { var spec = post.getSpec(); spec.setPublish(false); }) .flatMap(client::update)) .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) .filter(t -> t instanceof OptimisticLockingFailureException)) .flatMap(post -> ServerResponse.ok().bodyValue(post)); } private Mono recyclePost(ServerRequest request) { var name = request.pathVariable("name"); return Mono.defer(() -> client.get(Post.class, name) .doOnNext(post -> { var spec = post.getSpec(); spec.setDeleted(true); }) .flatMap(client::update)) .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) .filter(t -> t instanceof OptimisticLockingFailureException)) .flatMap(post -> ServerResponse.ok().bodyValue(post)); } Mono listPost(ServerRequest request) { PostQuery postQuery = new PostQuery(request); return postService.listPost(postQuery) .flatMap(listedPosts -> ServerResponse.ok().bodyValue(listedPosts)); } /** * Convenient for testing, to avoid waiting too long for post published when testing. */ void setMaxAttemptsWaitForPublish(int maxAttempts) { this.maxAttemptsWaitForPublish = maxAttempts; } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/console/ReplyEndpoint.java ================================================ package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.content.comment.ListedReply; import run.halo.app.content.comment.ReplyQuery; import run.halo.app.content.comment.ReplyService; import run.halo.app.core.extension.content.Reply; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ListResult; /** * Endpoint for managing {@link Reply}. * * @author guqing * @since 2.0.0 */ @Component public class ReplyEndpoint implements CustomEndpoint { private final ReplyService replyService; public ReplyEndpoint(ReplyService replyService) { this.replyService = replyService; } @Override public RouterFunction endpoint() { var tag = "ReplyV1alpha1Console"; return SpringdocRouteBuilder.route() .GET("replies", this::listReplies, builder -> { builder.operationId("ListReplies") .description("List replies.") .tag(tag) .response(responseBuilder() .implementation(ListResult.generateGenericClass(ListedReply.class)) ); ReplyQuery.buildParameters(builder); } ) .build(); } Mono listReplies(ServerRequest request) { ReplyQuery replyQuery = new ReplyQuery(request.exchange()); return replyService.list(replyQuery) .flatMap(listedReplies -> ServerResponse.ok().bodyValue(listedReplies)); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/console/SinglePageEndpoint.java ================================================ package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Duration; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import org.thymeleaf.util.StringUtils; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.content.Content; import run.halo.app.content.ContentUpdateParam; import run.halo.app.content.ContentWrapper; import run.halo.app.content.ListedSinglePage; import run.halo.app.content.ListedSnapshotDto; import run.halo.app.content.SinglePageQuery; import run.halo.app.content.SinglePageRequest; import run.halo.app.content.SinglePageService; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ListResult; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; /** * Endpoint for managing {@link SinglePage}. * * @author guqing * @since 2.0.0 */ @Slf4j @Component @AllArgsConstructor public class SinglePageEndpoint implements CustomEndpoint { private final SinglePageService singlePageService; private final ReactiveExtensionClient client; @Override public RouterFunction endpoint() { var tag = "SinglePageV1alpha1Console"; return SpringdocRouteBuilder.route() .GET("singlepages", this::listSinglePage, builder -> { builder.operationId("ListSinglePages") .description("List single pages.") .tag(tag) .response(responseBuilder() .implementation(ListResult.generateGenericClass(ListedSinglePage.class)) ); SinglePageQuery.buildParameters(builder); } ) .GET("singlepages/{name}/head-content", this::fetchHeadContent, builder -> builder.operationId("fetchSinglePageHeadContent") .description("Fetch head content of single page.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(ContentWrapper.class)) ) .GET("singlepages/{name}/release-content", this::fetchReleaseContent, builder -> builder.operationId("fetchSinglePageReleaseContent") .description("Fetch release content of single page.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(ContentWrapper.class)) ) .GET("singlepages/{name}/content", this::fetchContent, builder -> builder.operationId("fetchSinglePageContent") .description("Fetch content of single page.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .parameter(parameterBuilder().name("snapshotName") .in(ParameterIn.QUERY) .required(true) .implementation(String.class)) .response(responseBuilder() .implementation(ContentWrapper.class)) ) .GET("singlepages/{name}/snapshot", this::listSnapshots, builder -> builder.operationId("listSinglePageSnapshots") .description("List all snapshots for single page content.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class)) .response(responseBuilder() .implementationArray(ListedSnapshotDto.class)) ) .POST("singlepages", this::draftSinglePage, builder -> builder.operationId("DraftSinglePage") .description("Draft a single page.") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() .implementation(SinglePageRequest.class)) )) .response(responseBuilder() .implementation(SinglePage.class)) ) .PUT("singlepages/{name}", this::updateSinglePage, builder -> builder.operationId("UpdateDraftSinglePage") .description("Update a single page.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class)) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() .implementation(SinglePageRequest.class)) )) .response(responseBuilder() .implementation(SinglePage.class)) ) .PUT("singlepages/{name}/content", this::updateContent, builder -> builder.operationId("UpdateSinglePageContent") .description("Update a single page's content.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class)) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() .implementation(Content.class)) )) .response(responseBuilder() .implementation(Post.class)) ) .PUT("singlepages/{name}/revert-content", this::revertToSpecifiedSnapshot, builder -> builder.operationId("revertToSpecifiedSnapshotForSinglePage") .description("Revert to specified snapshot for single page content.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class)) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() .implementation(RevertSnapshotParam.class)) )) .response(responseBuilder() .implementation(Post.class)) ) .PUT("singlepages/{name}/publish", this::publishSinglePage, builder -> builder.operationId("PublishSinglePage") .description("Publish a single page.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class)) .response(responseBuilder() .implementation(SinglePage.class)) ) .DELETE("singlepages/{name}/content", this::deleteContent, builder -> builder.operationId("deleteSinglePageContent") .description("Delete a content for post.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .parameter(parameterBuilder() .name("snapshotName") .in(ParameterIn.QUERY) .required(true) .implementation(String.class)) .response(responseBuilder() .implementation(ContentWrapper.class)) ) .build(); } private Mono deleteContent(ServerRequest request) { final var postName = request.pathVariable("name"); final var snapshotName = request.queryParam("snapshotName").orElseThrow(); return singlePageService.deleteContent(postName, snapshotName) .flatMap(content -> ServerResponse.ok().bodyValue(content)); } private Mono revertToSpecifiedSnapshot(ServerRequest request) { final var postName = request.pathVariable("name"); return request.bodyToMono(RevertSnapshotParam.class) .switchIfEmpty( Mono.error(new ServerWebInputException("Required request body is missing."))) .flatMap( param -> singlePageService.revertToSpecifiedSnapshot(postName, param.snapshotName)) .flatMap(page -> ServerResponse.ok().bodyValue(page)); } @Schema(name = "RevertSnapshotForSingleParam") record RevertSnapshotParam( @Schema(requiredMode = Schema.RequiredMode.REQUIRED, minLength = 1) String snapshotName) { } private Mono fetchContent(ServerRequest request) { final var snapshotName = request.queryParam("snapshotName").orElseThrow(); return client.fetch(SinglePage.class, request.pathVariable("name")) .flatMap(page -> { var baseSnapshot = page.getSpec().getBaseSnapshot(); return singlePageService.getContent(snapshotName, baseSnapshot); }) .flatMap(content -> ServerResponse.ok().bodyValue(content)); } private Mono listSnapshots(ServerRequest request) { final var name = request.pathVariable("name"); var resultFlux = singlePageService.listSnapshots(name); return ServerResponse.ok().body(resultFlux, ListedSnapshotDto.class); } private Mono fetchReleaseContent(ServerRequest request) { final var name = request.pathVariable("name"); return singlePageService.getReleaseContent(name) .flatMap(content -> ServerResponse.ok().bodyValue(content)); } private Mono fetchHeadContent(ServerRequest request) { String name = request.pathVariable("name"); return singlePageService.getHeadContent(name) .flatMap(content -> ServerResponse.ok().bodyValue(content)); } Mono draftSinglePage(ServerRequest request) { return request.bodyToMono(SinglePageRequest.class) .flatMap(singlePageService::draft) .flatMap(singlePage -> ServerResponse.ok().bodyValue(singlePage)); } Mono updateContent(ServerRequest request) { String pageName = request.pathVariable("name"); return request.bodyToMono(ContentUpdateParam.class) .flatMap(content -> Mono.defer(() -> client.fetch(SinglePage.class, pageName) .flatMap(page -> { SinglePageRequest pageRequest = new SinglePageRequest(page, content); return singlePageService.update(pageRequest); })) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(throwable -> throwable instanceof OptimisticLockingFailureException)) ) .flatMap(post -> ServerResponse.ok().bodyValue(post)); } Mono updateSinglePage(ServerRequest request) { return request.bodyToMono(SinglePageRequest.class) .flatMap(singlePageService::update) .flatMap(page -> ServerResponse.ok().bodyValue(page)); } Mono publishSinglePage(ServerRequest request) { String name = request.pathVariable("name"); boolean asyncPublish = request.queryParam("async") .map(Boolean::parseBoolean) .orElse(false); return Mono.defer(() -> client.get(SinglePage.class, name) .flatMap(singlePage -> { SinglePage.SinglePageSpec spec = singlePage.getSpec(); spec.setPublish(true); if (spec.getHeadSnapshot() == null) { spec.setHeadSnapshot(spec.getBaseSnapshot()); } spec.setReleaseSnapshot(spec.getHeadSnapshot()); return client.update(singlePage); }) ) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(t -> t instanceof OptimisticLockingFailureException)) .flatMap(post -> { if (asyncPublish) { return Mono.just(post); } return client.fetch(SinglePage.class, name) .flatMap(latest -> { var latestReleasedSnapshotName = MetadataUtil.nullSafeAnnotations(latest) .get(Post.LAST_RELEASED_SNAPSHOT_ANNO); if (!StringUtils.equals(latestReleasedSnapshotName, latest.getSpec().getReleaseSnapshot())) { return Mono.error(new IllegalStateException( "SinglePage publishing status is not as expected" )); } return Mono.just(latest); }) .retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100)) .filter(IllegalStateException.class::isInstance) ) .doOnError(IllegalStateException.class, err -> { log.error("Failed to publish single page [{}]", name, err); throw new IllegalStateException("Publishing wait timeout."); }); }) .flatMap(page -> ServerResponse.ok().bodyValue(page)); } Mono listSinglePage(ServerRequest request) { var listRequest = new SinglePageQuery(request); return singlePageService.list(listRequest) .flatMap(listedPages -> ServerResponse.ok().bodyValue(listedPages)); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/console/StatsEndpoint.java ================================================ package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.isNull; import lombok.Data; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ReactiveExtensionClient; /** * Stats endpoint. * * @author guqing * @since 2.0.0 */ @Component public class StatsEndpoint implements CustomEndpoint { private final ReactiveExtensionClient client; public StatsEndpoint(ReactiveExtensionClient client) { this.client = client; } @Override public RouterFunction endpoint() { var tag = "SystemV1alpha1Console"; return SpringdocRouteBuilder.route() .GET("stats", this::getStats, builder -> builder.operationId("getStats") .description("Get stats.") .tag(tag) .response(responseBuilder() .implementation(DashboardStats.class) ) ) .build(); } Mono getStats(ServerRequest request) { var stats = DashboardStats.emptyStats(); Mono setFromCounters = client.listAll( Counter.class, ListOptions.builder().build(), Sort.unsorted() ) .doOnNext(counter -> { var visit = counter.getVisit(); if (visit != null) { stats.setVisits(stats.getVisits() + visit); } var totalComment = counter.getTotalComment(); if (totalComment != null) { stats.setComments(stats.getComments() + totalComment); } var approvedComment = counter.getApprovedComment(); if (approvedComment != null) { stats.setApprovedComments( stats.getApprovedComments() + approvedComment ); } var upvote = counter.getUpvote(); if (upvote != null) { stats.setUpvotes(stats.getUpvotes() + upvote); } }) .then(); Mono setUsers = client.countBy(User.class, ListOptions.builder() .labelSelector() .notEq(User.HIDDEN_USER_LABEL, "true") .end() .andQuery(isNull("metadata.deletionTimestamp")) .build() ) .doOnNext(stats::setUsers) .then(); Mono setPosts = client.countBy(Post.class, ListOptions.builder() .andQuery(and( isNull("metadata.deletionTimestamp"), equal("spec.deleted", "false") )) .build() ) .doOnNext(stats::setPosts) .then(); return Mono.when(setFromCounters, setUsers, setPosts) .thenReturn(stats) .flatMap(body -> ServerResponse.ok().bodyValue(body)); } @Data public static class DashboardStats { private long visits; private long comments; private long approvedComments; private long upvotes; private long users; private long posts; /** * Creates an empty stats that populated initialize value. * * @return stats with initialize value. */ public static DashboardStats emptyStats() { DashboardStats stats = new DashboardStats(); stats.setVisits(0L); stats.setComments(0L); stats.setApprovedComments(0L); stats.setUpvotes(0L); stats.setUsers(0L); stats.setPosts(0L); return stats; } } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/console/SystemConfigEndpoint.java ================================================ package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.time.Duration; import java.util.HashMap; import lombok.RequiredArgsConstructor; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemConfigFetcher; import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.node.ObjectNode; @Component @RequiredArgsConstructor public class SystemConfigEndpoint implements CustomEndpoint { private final SystemConfigFetcher configurableEnvironmentFetcher; private final ReactiveExtensionClient client; @Override public RouterFunction endpoint() { final var tag = "SystemConfigV1alpha1Console"; return SpringdocRouteBuilder.route() .GET("/systemconfigs/{group}", this::getConfigByGroup, builder -> builder.operationId("getSystemConfigByGroup") .description("Get system config by group") .tag(tag) .response(responseBuilder() .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) ) .implementation(Object.class)) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("group") .required(true) .description("Group of the system config") ) ) .PUT("/systemconfigs/{group}", this::updateConfigByGroup, builder -> builder.operationId("updateSystemConfigByGroup") .description("Update system config by group") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("group") .required(true) .description("Group of the system config") ) .requestBody(requestBodyBuilder().implementation(Object.class)) .response(responseBuilder() .responseCode(String.valueOf(HttpStatus.NO_CONTENT)) .implementation(Void.class) ) ) .build(); } private Mono updateConfigByGroup(ServerRequest request) { final var group = request.pathVariable("group"); return request.bodyToMono(ObjectNode.class) .flatMap(objectNode -> configurableEnvironmentFetcher.getConfigMap() .flatMap(cm -> { if (cm.getData() == null) { cm.setData(new HashMap<>()); } cm.getData().put(group, objectNode.toString()); return client.update(cm); }) ) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)) .then(ServerResponse.noContent().build()); } private Mono getConfigByGroup(ServerRequest request) { final var group = request.pathVariable("group"); return configurableEnvironmentFetcher.fetch(group, ObjectNode.class) .switchIfEmpty(Mono.fromSupplier(JsonMapper.shared()::createObjectNode)) .flatMap(json -> ServerResponse.ok().bodyValue(json)); } @Override public GroupVersion groupVersion() { return new GroupVersion("console.api.halo.run", "v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/console/TagEndpoint.java ================================================ package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static run.halo.app.extension.index.query.Queries.contains; import static run.halo.app.extension.index.query.Queries.or; import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springdoc.core.fn.builders.operation.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Tag; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.IListRequest; import run.halo.app.extension.router.SortableRequest; /** * post tag endpoint. * * @author LIlGG */ @Slf4j @Component @RequiredArgsConstructor public class TagEndpoint implements CustomEndpoint { private final ReactiveExtensionClient client; @Override public RouterFunction endpoint() { var tag = "TagV1alpha1Console"; return SpringdocRouteBuilder.route() .GET("tags", this::listTag, builder -> { builder.operationId("ListPostTags") .description("List Post Tags.") .tag(tag) .response( responseBuilder() .implementation(ListResult.generateGenericClass(Tag.class)) ); TagQuery.buildParameters(builder); } ) .build(); } Mono listTag(ServerRequest request) { var tagQuery = new TagQuery(request); return client.listBy(Tag.class, tagQuery.toListOptions(), PageRequestImpl.of(tagQuery.getPage(), tagQuery.getSize(), tagQuery.getSort()) ) .flatMap(tags -> ServerResponse.ok().bodyValue(tags)); } public static class TagQuery extends SortableRequest { public TagQuery(ServerRequest request) { super(request.exchange()); } public Optional getKeyword() { return Optional.ofNullable(queryParams.getFirst("keyword")) .filter(StringUtils::hasText); } @Override public ListOptions toListOptions() { var builder = ListOptions.builder(super.toListOptions()); getKeyword().ifPresent(keyword -> builder.andQuery( or( contains("spec.displayName", keyword), contains("spec.slug", keyword) ) )); return builder.build(); } public static void buildParameters(Builder builder) { IListRequest.buildParameters(builder); builder.parameter(sortParameter()) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("keyword") .description("Post tags filtered by keyword.") .implementation(String.class) .required(false)); } } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/console/TrackerEndpoint.java ================================================ package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.event.post.DownvotedEvent; import run.halo.app.event.post.UpvotedEvent; import run.halo.app.event.post.VisitedEvent; import run.halo.app.extension.GroupVersion; /** * Metrics counter endpoint. * * @author guqing * @since 2.0.0 */ @AllArgsConstructor @Component public class TrackerEndpoint implements CustomEndpoint { private final ApplicationEventPublisher eventPublisher; @Override public RouterFunction endpoint() { var tag = "MetricsV1alpha1Public"; return SpringdocRouteBuilder.route() .POST("trackers/counter", this::increaseVisit, builder -> builder.operationId("count") .description("Count an extension resource visits.") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() .implementation(CounterRequest.class)) )) .response(responseBuilder() .implementation(Void.class)) ) .POST("trackers/upvote", this::upvote, builder -> builder.operationId("upvote") .description("Upvote an extension resource.") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() .implementation(VoteRequest.class)) )) .response(responseBuilder() .implementation(Void.class)) ) .POST("trackers/downvote", this::downvote, builder -> builder.operationId("downvote") .description("Downvote an extension resource.") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() .implementation(VoteRequest.class)) )) .response(responseBuilder() .implementation(Void.class)) ) .build(); } private Mono increaseVisit(ServerRequest request) { return request.bodyToMono(CounterRequest.class) .switchIfEmpty( Mono.error(new IllegalArgumentException("Counter request body must not be empty"))) .doOnNext(counterRequest -> { eventPublisher.publishEvent(new VisitedEvent(this, counterRequest.group(), counterRequest.name(), counterRequest.plural())); }) .then(ServerResponse.ok().build()); } private Mono upvote(ServerRequest request) { return request.bodyToMono(VoteRequest.class) .switchIfEmpty( Mono.error(new IllegalArgumentException("Upvote request body must not be empty"))) .doOnNext(voteRequest -> { eventPublisher.publishEvent(new UpvotedEvent(this, voteRequest.group(), voteRequest.name(), voteRequest.plural())); }) .then(ServerResponse.ok().build()); } private Mono downvote(ServerRequest request) { return request.bodyToMono(VoteRequest.class) .switchIfEmpty( Mono.error(new IllegalArgumentException("Downvote request body must not be empty"))) .doOnNext(voteRequest -> { eventPublisher.publishEvent(new DownvotedEvent(this, voteRequest.group(), voteRequest.name(), voteRequest.plural())); }) .then(ServerResponse.ok().build()); } public record VoteRequest(String group, String plural, String name) { } public record CounterRequest(String group, String plural, String name, String hostname, String screen, String language, String referrer) { /** * Construct counter request. * group and session uid can be empty. */ public CounterRequest { Assert.notNull(plural, "The plural must not be null."); Assert.notNull(name, "The name must not be null."); group = StringUtils.defaultString(group); } } @Override public GroupVersion groupVersion() { return new GroupVersion("api.halo.run", "v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/console/UserEndpoint.java ================================================ package run.halo.app.core.endpoint.console; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static org.apache.commons.lang3.StringUtils.defaultIfBlank; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; import static run.halo.app.extension.ListResult.generateGenericClass; import static run.halo.app.extension.index.query.Queries.contains; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.in; import static run.halo.app.extension.index.query.Queries.or; import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToListOptions; import static run.halo.app.security.authorization.AuthorityUtils.authoritiesToRoles; import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.io.Files; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import io.github.resilience4j.ratelimiter.RequestNotPermitted; import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import java.security.Principal; import java.time.Duration; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import lombok.Data; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springdoc.core.fn.builders.operation.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.Part; import org.springframework.lang.NonNull; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; import org.springframework.util.unit.DataSize; import org.springframework.validation.Validator; import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.User; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.core.user.service.EmailVerificationService; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.SortableRequest; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.SystemSetting.Attachment.UploadOptions; import run.halo.app.infra.ValidationUtils; import run.halo.app.infra.exception.RateLimitExceededException; import run.halo.app.infra.exception.RestrictedNameException; import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.utils.JsonUtils; @Component @RequiredArgsConstructor public class UserEndpoint implements CustomEndpoint { private static final String SELF_USER = "-"; private static final String USER_AVATAR_GROUP_NAME = "user-avatar-group"; private static final String DEFAULT_USER_AVATAR_ATTACHMENT_POLICY_NAME = "default-policy"; private static final DataSize MAX_AVATAR_FILE_SIZE = DataSize.ofMegabytes(2L); private final ReactiveExtensionClient client; private final UserService userService; private final RoleService roleService; private final AttachmentService attachmentService; private final EmailVerificationService emailVerificationService; private final RateLimiterRegistry rateLimiterRegistry; private final SystemConfigFetcher environmentFetcher; private final Validator validator; @Override public RouterFunction endpoint() { var tag = "UserV1alpha1Console"; return SpringdocRouteBuilder.route() .GET("/users/-", this::me, builder -> builder.operationId("GetCurrentUserDetail") .description("Get current user detail") .tag(tag) .response(responseBuilder().implementation(DetailedUser.class))) .GET("/users/{name}", this::getUserByName, builder -> builder.operationId("GetUserDetail") .description("Get user detail by name") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("User name") .required(true) ) .response(responseBuilder().implementation(DetailedUser.class))) .PUT("/users/-", this::updateProfile, builder -> builder.operationId("UpdateCurrentUser") .description("Update current user profile, but password.") .tag(tag) .requestBody(requestBodyBuilder().required(true).implementation(User.class)) .response(responseBuilder().implementation(User.class))) .POST("/users/{name}/permissions", this::grantPermission, builder -> builder.operationId("GrantPermission") .description("Grant permissions to user") .tag(tag) .parameter(parameterBuilder().in(ParameterIn.PATH).name("name") .description("User name") .required(true)) .requestBody(requestBodyBuilder() .required(true) .implementation(GrantRequest.class)) .response(responseBuilder().implementation(User.class))) .POST("/users", this::createUser, builder -> builder.operationId("CreateUser") .description("Creates a new user.") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .implementation(CreateUserRequest.class)) .response(responseBuilder().implementation(User.class))) .GET("/users/{name}/permissions", this::getUserPermission, builder -> builder.operationId("GetPermissions") .description("Get permissions of user") .tag(tag) .parameter(parameterBuilder().in(ParameterIn.PATH).name("name") .description("User name") .required(true)) .response(responseBuilder().implementation(UserPermission.class))) .PUT("/users/-/password", this::changeOwnPassword, builder -> builder.operationId("ChangeOwnPassword") .description("Change own password of user.") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .implementation(ChangeOwnPasswordRequest.class)) .response(responseBuilder() .implementation(User.class)) ) .PUT("/users/{name}/password", this::changeAnyonePasswordForAdmin, builder -> builder.operationId("ChangeAnyonePassword") .description("Change anyone password of user for admin.") .tag(tag) .parameter(parameterBuilder().in(ParameterIn.PATH).name("name") .description( "Name of user. If the name is equal to '-', it will change the " + "password of current user.") .required(true)) .requestBody(requestBodyBuilder() .required(true) .implementation(ChangePasswordRequest.class)) .response(responseBuilder() .implementation(User.class)) ) .GET("users", this::list, builder -> { builder.operationId("ListUsers") .tag(tag) .description("List users") .response(responseBuilder() .implementation(generateGenericClass(ListedUser.class))); ListRequest.buildParameters(builder); }) .POST("users/{name}/avatar", contentType(MediaType.MULTIPART_FORM_DATA), this::uploadUserAvatar, builder -> builder .operationId("UploadUserAvatar") .description("upload user avatar") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("User name") .required(true) ) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) .schema(schemaBuilder().implementation(IAvatarUploadRequest.class)) )) .response(responseBuilder().implementation(User.class)) ) .DELETE("users/{name}/avatar", this::deleteUserAvatar, builder -> builder .tag(tag) .operationId("DeleteUserAvatar") .description("delete user avatar") .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("User name") .required(true) ) .response(responseBuilder().implementation(User.class)) .build()) .POST("users/-/send-email-verification-code", this::sendEmailVerificationCode, builder -> builder .tag(tag) .operationId("SendEmailVerificationCode") .requestBody(requestBodyBuilder() .implementation(EmailVerifyRequest.class) .required(true) ) .description("Send email verification code for user") .response(responseBuilder().implementation(Void.class)) .build() ) .POST("users/-/verify-email", this::verifyEmail, builder -> builder .tag(tag) .operationId("VerifyEmail") .description("Verify email for user by code.") .requestBody(requestBodyBuilder() .required(true) .implementation(VerifyCodeRequest.class)) .response(responseBuilder().implementation(Void.class)) .build() ) .build(); } private Mono verifyEmail(ServerRequest request) { return request.bodyToMono(VerifyCodeRequest.class) .switchIfEmpty(Mono.error( () -> new ServerWebInputException("Request body is required.")) ) .flatMap(this::doVerifyCode) .then(ServerResponse.ok().build()); } private Mono doVerifyCode(VerifyCodeRequest verifyCodeRequest) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(Principal::getName) .flatMap(username -> verifyPasswordAndCode(username, verifyCodeRequest)); } private Mono verifyPasswordAndCode(String username, VerifyCodeRequest verifyCodeRequest) { return userService.confirmPassword(username, verifyCodeRequest.password()) .filter(Boolean::booleanValue) .switchIfEmpty(Mono.error(new UnsatisfiedAttributeValueException( "Password is incorrect.", "problemDetail.user.password.notMatch", null))) .flatMap(verified -> verifyEmailCode(username, verifyCodeRequest.code())); } private Mono verifyEmailCode(String username, String code) { return Mono.just(username) .transformDeferred(verificationEmailRateLimiter(username)) .flatMap(name -> emailVerificationService.verify(username, code)) .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); } public record EmailVerifyRequest( @Schema(requiredMode = REQUIRED) @Email @NotBlank String email) { } public record VerifyCodeRequest( @Schema(requiredMode = REQUIRED) String password, @Schema(requiredMode = REQUIRED, minLength = 1) String code) { } private Mono sendEmailVerificationCode(ServerRequest request) { var emailMono = request.bodyToMono(EmailVerifyRequest.class) .switchIfEmpty(Mono.error( () -> new ServerWebInputException("Request body is required.")) ) .doOnNext(emailReq -> { var bindingResult = ValidationUtils.validate(emailReq, validator, request.exchange()); if (bindingResult.hasErrors()) { // only email field is validated throw new ServerWebInputException("validation.error.email.pattern"); } }) .map(EmailVerifyRequest::email) .map(String::toLowerCase); return Mono.zip(emailMono, getAuthenticatedUserName()) .flatMap(tuple -> { var email = tuple.getT1(); var username = tuple.getT2(); return Mono.just(username) .transformDeferred(sendEmailVerificationCodeRateLimiter(username, email)) .flatMap(u -> emailVerificationService.sendVerificationCode(username, email)) .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); }) .then(ServerResponse.ok().build()); } RateLimiterOperator verificationEmailRateLimiter(String username) { String rateLimiterKey = "verify-email-" + username; var rateLimiter = rateLimiterRegistry.rateLimiter(rateLimiterKey, "verify-email"); return RateLimiterOperator.of(rateLimiter); } RateLimiterOperator sendEmailVerificationCodeRateLimiter(String username, String email) { String rateLimiterKey = "send-email-verification-code-" + username + ":" + email; var rateLimiter = rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-email-verification-code"); return RateLimiterOperator.of(rateLimiter); } private Mono deleteUserAvatar(ServerRequest request) { final var nameInPath = request.pathVariable("name"); return getUserOrSelf(nameInPath) .flatMap(user -> { MetadataUtil.nullSafeAnnotations(user) .remove(User.AVATAR_ATTACHMENT_NAME_ANNO); user.getSpec().setAvatar(null); return client.update(user); }) .flatMap(user -> ServerResponse.ok().bodyValue(user)); } private Mono getUserOrSelf(String name) { if (!SELF_USER.equals(name)) { return client.get(User.class, name); } return getAuthenticatedUserName() .flatMap(currentUserName -> client.get(User.class, currentUserName)); } private Mono uploadUserAvatar(ServerRequest request) { final var username = request.pathVariable("name"); return request.body(BodyExtractors.toMultipartData()) .map(AvatarUploadRequest::new) .flatMap(this::uploadAvatar) .flatMap(attachment -> getUserOrSelf(username) .flatMap(user -> { MetadataUtil.nullSafeAnnotations(user) .put(User.AVATAR_ATTACHMENT_NAME_ANNO, attachment.getMetadata().getName()); return client.update(user); }) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)) ) .flatMap(user -> ServerResponse.ok().bodyValue(user)); } @Schema(types = "object") public interface IAvatarUploadRequest { @Schema(requiredMode = REQUIRED, description = "Avatar file") FilePart getFile(); } public record AvatarUploadRequest(MultiValueMap formData) { public FilePart getFile() { Part file = formData.getFirst("file"); if (file == null) { throw new ServerWebInputException("No file part found in the request"); } if (!(file instanceof FilePart filePart)) { throw new ServerWebInputException("Invalid part of file"); } if (!filePart.filename().endsWith(".png")) { throw new ServerWebInputException("Only support avatar in PNG format"); } return filePart; } } private Mono uploadAvatar(AvatarUploadRequest uploadRequest) { var fallbackSetting = environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class) .mapNotNull(SystemSetting.User::getAvatarPolicy) .filter(StringUtils::isNotBlank); var getAvatarPolicy = environmentFetcher.fetch( SystemSetting.Attachment.GROUP, SystemSetting.Attachment.class ) .mapNotNull(SystemSetting.Attachment::avatar) .mapNotNull(UploadOptions::policyName) .filter(StringUtils::isNotBlank) .switchIfEmpty(fallbackSetting) .defaultIfEmpty(DEFAULT_USER_AVATAR_ATTACHMENT_POLICY_NAME); return getAvatarPolicy.flatMap(avatarPolicy -> { FilePart filePart = uploadRequest.getFile(); var ext = Files.getFileExtension(filePart.filename()); return attachmentService.upload(avatarPolicy, USER_AVATAR_GROUP_NAME, UUID.randomUUID() + "." + ext, maxSizeCheck(filePart.content()), filePart.headers().getContentType() ); }); } private Flux maxSizeCheck(Flux content) { var lenRef = new AtomicInteger(0); return content.doOnNext(dataBuffer -> { int len = lenRef.accumulateAndGet(dataBuffer.readableByteCount(), Integer::sum); if (len > MAX_AVATAR_FILE_SIZE.toBytes()) { throw new ServerWebInputException("The avatar file needs to be smaller than " + MAX_AVATAR_FILE_SIZE.toMegabytes() + " MB."); } }); } private Mono createUser(ServerRequest request) { return request.bodyToMono(CreateUserRequest.class) .doOnNext(createUserRequest -> { if (StringUtils.isBlank(createUserRequest.name())) { throw new ServerWebInputException("Name is required"); } if (StringUtils.isBlank(createUserRequest.email())) { throw new ServerWebInputException("Email is required"); } }) .flatMap(userRequest -> { User newUser = CreateUserRequest.from(userRequest); var encryptedPwd = userService.encryptPassword(userRequest.password()); newUser.getSpec().setPassword(encryptedPwd); return userService.createUser(newUser, userRequest.roles()); }) .flatMap(user -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(user) ); } private Mono getUserByName(ServerRequest request) { final var name = request.pathVariable("name"); return userService.getUser(name) .flatMap(user -> roleService.getRolesByUsername(name) .collectList() .flatMap(roleNames -> roleService.list(new HashSet<>(roleNames), true) .collectList() .map(roles -> new DetailedUser(user, roles)) ) ) .flatMap(detailedUser -> ServerResponse.ok().bodyValue(detailedUser)); } record CreateUserRequest(@Schema(requiredMode = REQUIRED) String name, @Schema(requiredMode = REQUIRED) String email, String displayName, String avatar, String phone, String password, String bio, Map annotations, Set roles) { /** *

Creates a new user from {@link CreateUserRequest}.

* Note: this method will not set password. * * @param userRequest user request * @return user from request */ public static User from(CreateUserRequest userRequest) { var user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName(userRequest.name()); user.getMetadata().setAnnotations(new HashMap<>()); Map annotations = defaultIfNull(userRequest.annotations(), Map.of()); user.getMetadata().getAnnotations().putAll(annotations); var spec = new User.UserSpec(); user.setSpec(spec); spec.setEmail(userRequest.email()); spec.setDisplayName(defaultIfBlank(userRequest.displayName(), userRequest.name())); spec.setAvatar(userRequest.avatar()); spec.setPhone(userRequest.phone()); spec.setBio(userRequest.bio()); return user; } } private Mono updateProfile(ServerRequest request) { return getAuthenticatedUserName() .flatMap(currentUserName -> client.get(User.class, currentUserName)) .flatMap(currentUser -> request.bodyToMono(User.class) .filter(user -> user.getMetadata() != null && Objects.equals(user.getMetadata().getName(), currentUser.getMetadata().getName()) ) .switchIfEmpty( Mono.error(() -> new ServerWebInputException("Username didn't match."))) .flatMap(user -> { var newDisplayName = user.getSpec().getDisplayName(); var oldDisplayName = currentUser.getSpec().getDisplayName(); return Mono.just(user) .filterWhen(u -> { if (Objects.equals(oldDisplayName, newDisplayName)) { return Mono.just(true); } return environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class) .map(setting -> isDisplayNameAllowed(setting, newDisplayName)) .defaultIfEmpty(false); }) .switchIfEmpty(Mono.defer(() -> Mono.error(new RestrictedNameException( "The display name is restricted.", "problemDetail.user.displayName.restricted", new Object[] {newDisplayName} )))); }) .map(user -> { Map oldAnnotations = MetadataUtil.nullSafeAnnotations(currentUser); Map newAnnotations = user.getMetadata().getAnnotations(); if (!CollectionUtils.isEmpty(newAnnotations)) { newAnnotations.put(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO, oldAnnotations.get(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO)); newAnnotations.put(User.AVATAR_ATTACHMENT_NAME_ANNO, oldAnnotations.get(User.AVATAR_ATTACHMENT_NAME_ANNO)); newAnnotations.put(User.EMAIL_TO_VERIFY, oldAnnotations.get(User.EMAIL_TO_VERIFY)); currentUser.getMetadata().setAnnotations(newAnnotations); } var spec = currentUser.getSpec(); var newSpec = user.getSpec(); spec.setBio(newSpec.getBio()); spec.setDisplayName(newSpec.getDisplayName()); spec.setTwoFactorAuthEnabled(newSpec.getTwoFactorAuthEnabled()); spec.setPhone(newSpec.getPhone()); return currentUser; }) ) .flatMap(client::update) .flatMap(updatedUser -> ServerResponse.ok().bodyValue(updatedUser)); } private static Mono getAuthenticatedUserName() { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(Authentication::getName); } Mono changeAnyonePasswordForAdmin(ServerRequest request) { final var nameInPath = request.pathVariable("name"); return ReactiveSecurityContextHolder.getContext() .map(ctx -> SELF_USER.equals(nameInPath) ? ctx.getAuthentication().getName() : nameInPath) .flatMap(username -> request.bodyToMono(ChangePasswordRequest.class) .switchIfEmpty(Mono.defer(() -> Mono.error(new ServerWebInputException("Request body is empty")))) .flatMap(changePasswordRequest -> { var password = changePasswordRequest.password(); // encode password return userService.updateWithRawPassword(username, password); })) .flatMap(updatedUser -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(updatedUser)); } Mono changeOwnPassword(ServerRequest request) { return ReactiveSecurityContextHolder.getContext() .map(ctx -> ctx.getAuthentication().getName()) .flatMap(username -> request.bodyToMono(ChangeOwnPasswordRequest.class) .switchIfEmpty(Mono.defer(() -> Mono.error(new ServerWebInputException("Request body is empty")))) .flatMap(changePasswordRequest -> { var rawOldPassword = changePasswordRequest.oldPassword(); return userService.confirmPassword(username, rawOldPassword) .filter(Boolean::booleanValue) .switchIfEmpty(Mono.error(new UnsatisfiedAttributeValueException( "Old password is incorrect.", "problemDetail.user.oldPassword.notMatch", null)) ) .thenReturn(changePasswordRequest); }) .flatMap(changePasswordRequest -> { var password = changePasswordRequest.password(); // encode password return userService.updateWithRawPassword(username, password); })) .flatMap(updatedUser -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(updatedUser)); } record ChangeOwnPasswordRequest( @Schema(description = "Old password.", requiredMode = REQUIRED) String oldPassword, @Schema(description = "New password.", requiredMode = REQUIRED, minLength = 5) String password) { public ChangeOwnPasswordRequest { if (password == null || password.length() < 5 || password.length() > 257) { throw new UnsatisfiedAttributeValueException( "password is required.", "validation.error.password.size", new Object[] {5, 257}); } } } record ChangePasswordRequest( @Schema(description = "New password.", requiredMode = REQUIRED, minLength = 5) String password) { } @NonNull Mono me(ServerRequest request) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .filter(Authentication::isAuthenticated) .flatMap(auth -> userService.getUser(auth.getName()) .flatMap(user -> { var roleNames = authoritiesToRoles(auth.getAuthorities()); return roleService.list(roleNames, true) .collectList() .map(roles -> new DetailedUser(user, roles)); }) ) .flatMap(detailedUser -> ServerResponse.ok().bodyValue(detailedUser)); } record DetailedUser(@Schema(requiredMode = REQUIRED) User user, @Schema(requiredMode = REQUIRED) List roles) { } @NonNull Mono grantPermission(ServerRequest request) { var username = request.pathVariable("name"); return request.bodyToMono(GrantRequest.class) .switchIfEmpty( Mono.error(() -> new ServerWebInputException("Request body is empty"))) .flatMap(grantRequest -> userService.grantRoles(username, grantRequest.roles()) .then(ServerResponse.ok().build())); } record GrantRequest(Set roles) { } @NonNull private Mono getUserPermission(ServerRequest request) { var username = request.pathVariable("name"); return Mono.defer(() -> { if (SELF_USER.equals(username)) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(auth -> authoritiesToRoles(auth.getAuthorities())); } return roleService.getRolesByUsername(username) .collect(Collectors.toCollection(LinkedHashSet::new)); }).flatMap(roleNames -> { var up = new UserPermission(); var setRoles = roleService.list(roleNames, true) .distinct() .collectSortedList() .doOnNext(up::setRoles); var setPerms = roleService.listPermissions(roleNames) .distinct() .collectSortedList() .doOnNext(permissions -> { up.setPermissions(permissions); up.setUiPermissions(uiPermissions(permissions)); }); return Mono.when(setRoles, setPerms).thenReturn(up); }).flatMap(userPermission -> ServerResponse.ok().bodyValue(userPermission)); } private List uiPermissions(Collection roles) { if (CollectionUtils.isEmpty(roles)) { return List.of(); } var uiPerms = new LinkedList(); roles.forEach(role -> Optional.ofNullable(role.getMetadata().getAnnotations()) .map(annotations -> annotations.get(Role.UI_PERMISSIONS_ANNO)) .filter(StringUtils::isNotBlank) .map(json -> JsonUtils.jsonToObject(json, new TypeReference>() { })) .ifPresent(uiPerms::addAll) ); return uiPerms.stream().distinct().sorted().toList(); } @Data public static class UserPermission { @Schema(requiredMode = REQUIRED) private List roles; @Schema(requiredMode = REQUIRED) private List permissions; @Schema(requiredMode = REQUIRED) private List uiPermissions; } public static class ListRequest extends SortableRequest { public ListRequest(ServerRequest request) { super(request.exchange()); } @Schema(name = "keyword") public String getKeyword() { return queryParams.getFirst("keyword"); } @Schema(name = "role") public String getRole() { return queryParams.getFirst("role"); } /** * Converts query parameters to list options. */ public ListOptions toListOptions() { var defaultListOptions = labelAndFieldSelectorToListOptions(getLabelSelector(), getFieldSelector()); var builder = ListOptions.builder(defaultListOptions); Optional.ofNullable(getKeyword()) .filter(StringUtils::isNotBlank) .ifPresent(keyword -> builder.andQuery(or( equal("spec.email", keyword), contains("spec.displayName", keyword), equal("metadata.name", keyword) ))); Optional.ofNullable(getRole()) .filter(StringUtils::isNotBlank) .ifPresent(role -> builder.andQuery(in(User.USER_RELATED_ROLES_INDEX, role))); return builder.build(); } public static void buildParameters(Builder builder) { SortableRequest.buildParameters(builder); builder.parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("keyword") .description("Keyword to search") .implementation(String.class) .required(false)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("role") .description("Role name") .implementation(String.class) .required(false)); } } record ListedUser(@Schema(requiredMode = REQUIRED) User user, @Schema(requiredMode = REQUIRED) List roles) { } Mono list(ServerRequest request) { return Mono.just(request) .map(UserEndpoint.ListRequest::new) .flatMap(listRequest -> client.listBy(User.class, listRequest.toListOptions(), PageRequestImpl.of( listRequest.getPage(), listRequest.getSize(), listRequest.getSort() ) )) .flatMap(this::toListedUser) .flatMap(listResult -> ServerResponse.ok().bodyValue(listResult)); } private Mono> toListedUser(ListResult listResult) { var usernames = listResult.getItems().stream() .map(user -> user.getMetadata().getName()) .collect(Collectors.toList()); return roleService.getRolesByUsernames(usernames) .flatMap(usernameRolesMap -> { var allRoleNames = new HashSet(); usernameRolesMap.values().forEach(allRoleNames::addAll); return roleService.list(allRoleNames) .collectMap(role -> role.getMetadata().getName()) .map(roleMap -> { var listedUsers = listResult.getItems().stream() .map(user -> { var username = user.getMetadata().getName(); var roles = Optional.ofNullable(usernameRolesMap.get(username)) .map(roleNames -> roleNames.stream() .map(roleMap::get) .filter(Objects::nonNull) .toList() ) .orElseGet(List::of); return new ListedUser(user, roles); }) .toList(); return convertFrom(listResult, listedUsers); }); }); } ListResult convertFrom(ListResult listResult, List items) { Assert.notNull(listResult, "listResult must not be null"); Assert.notNull(items, "items must not be null"); return new ListResult<>(listResult.getPage(), listResult.getSize(), listResult.getTotal(), items); } private boolean isDisplayNameAllowed(SystemSetting.User setting, String displayName) { String protectedUsernamesStr = setting.getProtectedUsernames(); if (protectedUsernamesStr == null || protectedUsernamesStr.trim().isEmpty()) { return true; } Set protectedLowerSet = Arrays.stream(protectedUsernamesStr.split(",")) .map(String::trim) .filter(n -> !n.isEmpty()) .map(String::toLowerCase) .collect(Collectors.toUnmodifiableSet()); return !protectedLowerSet.contains(displayName.trim().toLowerCase()); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/theme/CategoryQueryEndpoint.java ================================================ package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static run.halo.app.core.endpoint.theme.PublicApiUtils.toAnotherListResult; import io.swagger.v3.oas.annotations.enums.ParameterIn; import lombok.RequiredArgsConstructor; import org.springdoc.core.fn.builders.operation.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.Queries; import run.halo.app.extension.router.SortableRequest; import run.halo.app.theme.finders.PostPublicQueryService; import run.halo.app.theme.finders.vo.CategoryVo; import run.halo.app.theme.finders.vo.ListedPostVo; /** * Endpoint for category query APIs. * * @author guqing * @since 2.5.0 */ @Component @RequiredArgsConstructor public class CategoryQueryEndpoint implements CustomEndpoint { private final ReactiveExtensionClient client; private final PostPublicQueryService postPublicQueryService; @Override public RouterFunction endpoint() { final var tag = "CategoryV1alpha1Public"; return SpringdocRouteBuilder.route() .GET("categories", this::listCategories, builder -> { builder.operationId("queryCategories") .description("Lists categories.") .tag(tag) .response(responseBuilder() .implementation(ListResult.generateGenericClass(CategoryVo.class)) ); CategoryPublicQuery.buildParameters(builder); } ) .GET("categories/{name}", this::getByName, builder -> builder.operationId("queryCategoryByName") .description("Gets category by name.") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Category name") .required(true) ) .response(responseBuilder() .implementation(CategoryVo.class) ) ) .GET("categories/{name}/posts", this::listPostsByCategoryName, builder -> { builder.operationId("queryPostsByCategoryName") .description("Lists posts by category name.") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Category name") .required(true) ) .response(responseBuilder() .implementation(ListResult.generateGenericClass(ListedPostVo.class)) ); PostPublicQuery.buildParameters(builder); } ) .build(); } private Mono listPostsByCategoryName(ServerRequest request) { final var name = request.pathVariable("name"); final var query = new PostPublicQuery(request.exchange()); var listOptions = query.toListOptions(); var newFieldSelector = listOptions.getFieldSelector() .andQuery(Queries.equal("spec.categories", name)); listOptions.setFieldSelector(newFieldSelector); return postPublicQueryService.list(listOptions, query.toPageRequest()) .flatMap(result -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(result) ); } private Mono getByName(ServerRequest request) { String name = request.pathVariable("name"); return client.get(Category.class, name) .map(CategoryVo::from) .flatMap(categoryVo -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(categoryVo) ); } private Mono listCategories(ServerRequest request) { CategoryPublicQuery query = new CategoryPublicQuery(request.exchange()); return client.listBy(Category.class, query.toListOptions(), query.toPageRequest()) .map(listResult -> toAnotherListResult(listResult, CategoryVo::from)) .flatMap(result -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(result) ); } public static class CategoryPublicQuery extends SortableRequest { public CategoryPublicQuery(ServerWebExchange exchange) { super(exchange); } public static void buildParameters(Builder builder) { SortableRequest.buildParameters(builder); } } @Override public GroupVersion groupVersion() { return PublicApiUtils.groupVersion(new Category()); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/theme/CommentFinderEndpoint.java ================================================ package run.halo.app.core.endpoint.theme; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.apache.commons.lang3.BooleanUtils.isFalse; import static org.apache.commons.lang3.BooleanUtils.isTrue; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter; import com.fasterxml.jackson.annotation.JsonIgnore; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import io.github.resilience4j.ratelimiter.RequestNotPermitted; import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springdoc.core.fn.builders.operation.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.data.domain.Sort; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import run.halo.app.content.comment.CommentRequest; import run.halo.app.content.comment.CommentService; import run.halo.app.content.comment.ReplyRequest; import run.halo.app.content.comment.ReplyService; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.endpoint.SortResolver; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.Ref; import run.halo.app.extension.router.IListRequest; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.exception.AccessDeniedException; import run.halo.app.infra.exception.RateLimitExceededException; import run.halo.app.infra.utils.HaloUtils; import run.halo.app.infra.utils.IpAddressUtils; import run.halo.app.theme.finders.CommentFinder; import run.halo.app.theme.finders.CommentPublicQueryService; import run.halo.app.theme.finders.vo.CommentVo; import run.halo.app.theme.finders.vo.CommentWithReplyVo; import run.halo.app.theme.finders.vo.ReplyVo; /** * Endpoint for {@link CommentFinder}. */ @Component @RequiredArgsConstructor public class CommentFinderEndpoint implements CustomEndpoint { private final CommentPublicQueryService commentPublicQueryService; private final CommentService commentService; private final ReplyService replyService; private final SystemConfigFetcher environmentFetcher; private final RateLimiterRegistry rateLimiterRegistry; @Override public RouterFunction endpoint() { final var tag = "CommentV1alpha1Public"; return SpringdocRouteBuilder.route() .POST("comments", this::createComment, builder -> builder.operationId("CreateComment_1") .description("Create a comment.") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(schemaBuilder() .implementation(CommentRequest.class)) )) .response(responseBuilder() .implementation(Comment.class)) ) .POST("comments/{name}/reply", this::createReply, builder -> builder.operationId("CreateReply_1") .description("Create a reply.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class)) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(schemaBuilder() .implementation(ReplyRequest.class)) )) .response(responseBuilder() .implementation(Reply.class)) ) .GET("comments", this::listComments, builder -> { builder.operationId("ListComments_1") .description("List comments.") .tag(tag) .response(responseBuilder() .implementation(ListResult.generateGenericClass(CommentWithReplyVo.class)) ); CommentQuery.buildParameters(builder); }) .GET("comments/{name}", this::getComment, builder -> { builder.operationId("GetComment") .description("Get a comment.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class)) .response(responseBuilder() .implementation(ListResult.generateGenericClass(CommentVo.class)) ); }) .GET("comments/{name}/reply", this::listCommentReplies, builder -> { builder.operationId("ListCommentReplies") .description("List comment replies.") .tag(tag) .parameter(parameterBuilder().name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class)) .response(responseBuilder() .implementation(ListResult.generateGenericClass(ReplyVo.class)) ); PageableRequest.buildParameters(builder); }) .build(); } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("api.halo.run/v1alpha1"); } Mono createComment(ServerRequest request) { return request.bodyToMono(CommentRequest.class) .flatMap(commentRequest -> { Comment comment = commentRequest.toComment(); comment.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request)); comment.getSpec().setUserAgent(HaloUtils.userAgentFrom(request)); return commentService.create(comment); }) .flatMap(comment -> ServerResponse.ok().bodyValue(comment)) .transformDeferred(createIpBasedRateLimiter(request)) .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); } private RateLimiterOperator createIpBasedRateLimiter(ServerRequest request) { var clientIp = IpAddressUtils.getIpAddress(request); var rateLimiter = rateLimiterRegistry.rateLimiter("comment-creation-from-ip-" + clientIp, "comment-creation"); return RateLimiterOperator.of(rateLimiter); } Mono createReply(ServerRequest request) { String commentName = request.pathVariable("name"); return request.bodyToMono(ReplyRequest.class) .flatMap(replyRequest -> { Reply reply = replyRequest.toReply(); reply.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request)); reply.getSpec().setUserAgent(HaloUtils.userAgentFrom(request)); return environmentFetcher.fetchComment() .map(commentSetting -> { if (isFalse(commentSetting.getEnable())) { throw new AccessDeniedException( "The comment function has been turned off.", "problemDetail.comment.turnedOff", null); } if (checkReplyOwner(reply, commentSetting.getSystemUserOnly())) { throw new AccessDeniedException("Allow only system users to comment.", "problemDetail.comment.systemUsersOnly", null); } reply.getSpec() .setApproved(isFalse(commentSetting.getRequireReviewForNew())); if (reply.getSpec().getHidden() == null) { reply.getSpec().setHidden(false); } return reply; }) .defaultIfEmpty(reply); }) .flatMap(reply -> replyService.create(commentName, reply)) .flatMap(comment -> ServerResponse.ok().bodyValue(comment)) .transformDeferred(createIpBasedRateLimiter(request)) .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); } private boolean checkReplyOwner(Reply reply, Boolean onlySystemUser) { Comment.CommentOwner owner = reply.getSpec().getOwner(); if (isTrue(onlySystemUser)) { return owner != null && Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind()); } return false; } Mono listComments(ServerRequest request) { CommentQuery commentQuery = new CommentQuery(request); return commentPublicQueryService.list(commentQuery.toRef(), commentQuery.toPageRequest()) .flatMap(result -> { if (commentQuery.getWithReplies()) { return commentPublicQueryService.convertToWithReplyVo(result, commentQuery.getReplySize()); } return Mono.just(result); }) .flatMap(list -> ServerResponse.ok().bodyValue(list)); } Mono getComment(ServerRequest request) { String name = request.pathVariable("name"); return Mono.defer(() -> Mono.justOrEmpty(commentPublicQueryService.getByName(name))) .subscribeOn(Schedulers.boundedElastic()) .flatMap(comment -> ServerResponse.ok().bodyValue(comment)); } Mono listCommentReplies(ServerRequest request) { String commentName = request.pathVariable("name"); IListRequest.QueryListRequest queryParams = new IListRequest.QueryListRequest(request.queryParams()); return commentPublicQueryService.listReply(commentName, queryParams.getPage(), queryParams.getSize()) .flatMap(list -> ServerResponse.ok().bodyValue(list)); } public static class CommentQuery extends PageableRequest { private final ServerWebExchange exchange; public CommentQuery(ServerRequest request) { super(request.queryParams()); this.exchange = request.exchange(); } @Schema(description = "The comment subject group.") public String getGroup() { return queryParams.getFirst("group"); } @Schema(requiredMode = REQUIRED, description = "The comment subject version.") public String getVersion() { return emptyToNull(queryParams.getFirst("version")); } /** * Gets the {@link Ref}s kind. * * @return comment subject ref kind */ @Schema(requiredMode = REQUIRED, description = "The comment subject kind.") public String getKind() { String kind = emptyToNull(queryParams.getFirst("kind")); if (kind == null) { throw new ServerWebInputException("The kind must not be null."); } return kind; } /** * Gets the {@link Ref}s name. * * @return comment subject ref name */ @Schema(requiredMode = REQUIRED, description = "The comment subject name.") public String getName() { String name = emptyToNull(queryParams.getFirst("name")); if (name == null) { throw new ServerWebInputException("The name must not be null."); } return name; } @Schema(description = "Whether to include replies. Default is false.", defaultValue = "false") public Boolean getWithReplies() { var withReplies = queryParams.getFirst("withReplies"); return StringUtils.isNotBlank(withReplies) && Boolean.parseBoolean(withReplies); } @Schema(description = "Reply size of the comment, default is 10, only works when " + "withReplies is true.", defaultValue = "10") public int getReplySize() { var replySize = queryParams.getFirst("replySize"); return StringUtils.isNotBlank(replySize) ? Integer.parseInt(replySize) : 10; } @ArraySchema(uniqueItems = true, arraySchema = @Schema(name = "sort", description = "Sort property and direction of the list result. Supported fields: " + "creationTimestamp"), schema = @Schema(description = "like field,asc or field,desc", implementation = String.class, example = "creationTimestamp,desc")) public Sort getSort() { return SortResolver.defaultInstance.resolve(exchange); } Ref toRef() { Ref ref = new Ref(); ref.setGroup(getGroup()); ref.setKind(getKind()); ref.setVersion(getVersion()); ref.setName(getName()); return ref; } public PageRequest toPageRequest() { return PageRequestImpl.of(getPage(), getSize(), getSort()); } String emptyToNull(String str) { return StringUtils.isBlank(str) ? null : str; } public static void buildParameters(Builder builder) { PageableRequest.buildParameters(builder); builder.parameter(sortParameter()) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("group") .description("The comment subject group.") .required(false) .implementation(String.class)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("version") .description("The comment subject version.") .required(true) .implementation(String.class)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("kind") .description("The comment subject kind.") .required(true)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("name") .description("The comment subject name.") .required(true) .implementation(String.class)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("withReplies") .description("Whether to include replies. Default is false.") .required(false) .implementation(Boolean.class)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("replySize") .description("Reply size of the comment, default is 10, only works when " + "withReplies is true.") .required(false) .schema(schemaBuilder() .implementation(Integer.class) .defaultValue("10"))); } } public static class PageableRequest extends IListRequest.QueryListRequest { public PageableRequest(MultiValueMap queryParams) { super(queryParams); } @Override @JsonIgnore public List getLabelSelector() { throw new UnsupportedOperationException("Unsupported this parameter"); } @Override @JsonIgnore public List getFieldSelector() { throw new UnsupportedOperationException("Unsupported this parameter"); } public static void buildParameters(Builder builder) { builder.parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("page") .implementation(Integer.class) .required(false) .description("Page number. Default is 0.")) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("size") .implementation(Integer.class) .required(false) .description("Size number. Default is 0.")); } } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/theme/MenuQueryEndpoint.java ================================================ package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import lombok.RequiredArgsConstructor; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Menu; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.theme.finders.MenuFinder; import run.halo.app.theme.finders.vo.MenuVo; /** * Endpoint for menu query APIs. * * @author guqing * @since 2.5.0 */ @Component @RequiredArgsConstructor public class MenuQueryEndpoint implements CustomEndpoint { private final MenuFinder menuFinder; private final SystemConfigFetcher environmentFetcher; @Override public RouterFunction endpoint() { final var tag = "MenuV1alpha1Public"; return SpringdocRouteBuilder.route() .GET("menus/-", this::getByName, builder -> builder.operationId("queryPrimaryMenu") .description("Gets primary menu.") .tag(tag) .response(responseBuilder() .implementation(MenuVo.class) ) ) .GET("menus/{name}", this::getByName, builder -> builder.operationId("queryMenuByName") .description("Gets menu by name.") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Menu name") .required(true) ) .response(responseBuilder() .implementation(MenuVo.class) ) ) .build(); } private Mono getByName(ServerRequest request) { return determineMenuName(request) .flatMap(menuFinder::getByName) .flatMap(menuVo -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(menuVo) ); } private Mono determineMenuName(ServerRequest request) { String name = request.pathVariables().getOrDefault("name", "-"); if (!"-".equals(name)) { return Mono.just(name); } // If name is "-", then get primary menu. return environmentFetcher.fetch(SystemSetting.Menu.GROUP, SystemSetting.Menu.class) .mapNotNull(SystemSetting.Menu::getPrimary) .switchIfEmpty( Mono.error(() -> new ServerWebInputException("Primary menu is not configured.")) ); } @Override public GroupVersion groupVersion() { return PublicApiUtils.groupVersion(new Menu()); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/theme/PluginQueryEndpoint.java ================================================ package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import lombok.RequiredArgsConstructor; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.theme.finders.PluginFinder; /** * Endpoint for plugin query APIs. * * @author guqing * @since 2.5.0 */ @Component @RequiredArgsConstructor public class PluginQueryEndpoint implements CustomEndpoint { private final PluginFinder pluginFinder; @Override public RouterFunction endpoint() { final var tag = "PluginV1alpha1Public"; return SpringdocRouteBuilder.route() .GET("plugins/{name}/available", this::availableByName, builder -> builder.operationId("queryPluginAvailableByName") .description("Gets plugin available by name.") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Plugin name") .required(true) ) .response(responseBuilder() .implementation(Boolean.class) ) ) .build(); } private Mono availableByName(ServerRequest request) { String name = request.pathVariable("name"); boolean available = pluginFinder.available(name); return ServerResponse.ok().bodyValue(available); } @Override public GroupVersion groupVersion() { return PublicApiUtils.groupVersion(new Plugin()); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/theme/PostPublicQuery.java ================================================ package run.halo.app.core.endpoint.theme; import org.springdoc.core.fn.builders.operation.Builder; import org.springframework.web.server.ServerWebExchange; import run.halo.app.extension.router.SortableRequest; /** * Query parameters for post public APIs. * * @author guqing * @since 2.5.0 */ public class PostPublicQuery extends SortableRequest { public PostPublicQuery(ServerWebExchange exchange) { super(exchange); } public static void buildParameters(Builder builder) { SortableRequest.buildParameters(builder); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/theme/PostQueryEndpoint.java ================================================ package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import lombok.RequiredArgsConstructor; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListResult; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.PostPublicQueryService; import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.finders.vo.NavigationPostVo; import run.halo.app.theme.finders.vo.PostVo; /** * Endpoint for post query. * * @author guqing * @since 2.5.0 */ @Component @RequiredArgsConstructor public class PostQueryEndpoint implements CustomEndpoint { private final PostFinder postFinder; private final PostPublicQueryService postPublicQueryService; @Override public RouterFunction endpoint() { var tag = "PostV1alpha1Public"; return SpringdocRouteBuilder.route() .GET("posts", this::listPosts, builder -> { builder.operationId("queryPosts") .description("Lists posts.") .tag(tag) .response(responseBuilder() .implementation(ListResult.generateGenericClass(ListedPostVo.class)) ); PostPublicQuery.buildParameters(builder); } ) .GET("posts/{name}", this::getPostByName, builder -> builder.operationId("queryPostByName") .description("Gets a post by name.") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Post name") .required(true) ) .response(responseBuilder() .implementation(PostVo.class) ) ) .GET("posts/{name}/navigation", this::getPostNavigationByName, builder -> builder.operationId("queryPostNavigationByName") .description("Gets a post navigation by name.") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Post name") .required(true) ) .response(responseBuilder() .implementation(NavigationPostVo.class) ) ) .build(); } private Mono getPostNavigationByName(ServerRequest request) { final var name = request.pathVariable("name"); return postFinder.cursor(name) .flatMap(result -> ServerResponse.ok().bodyValue(result)); } private Mono getPostByName(ServerRequest request) { final var name = request.pathVariable("name"); return postFinder.getByName(name) .switchIfEmpty(Mono.error(() -> new NotFoundException("Post not found"))) .flatMap(post -> ServerResponse.ok().contentType(MediaType.APPLICATION_JSON) .bodyValue(post) ); } private Mono listPosts(ServerRequest request) { PostPublicQuery query = new PostPublicQuery(request.exchange()); return postPublicQueryService.list(query.toListOptions(), query.toPageRequest()) .flatMap(result -> ServerResponse.ok().contentType(MediaType.APPLICATION_JSON) .bodyValue(result) ); } @Override public GroupVersion groupVersion() { return PublicApiUtils.groupVersion(new Post()); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/theme/PublicApiUtils.java ================================================ package run.halo.app.core.endpoint.theme; import java.util.Collection; import java.util.List; import java.util.function.Function; import lombok.experimental.UtilityClass; import org.apache.commons.lang3.StringUtils; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import run.halo.app.extension.Extension; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.ListResult; /** * Utility class for public api. * * @author guqing * @since 2.5.0 */ @UtilityClass public class PublicApiUtils { /** * Get group version from extension for public api. * * @param extension extension * @return api.{group}/{version} if group is not empty, * otherwise api.halo.run/{version}. */ public static GroupVersion groupVersion(Extension extension) { GroupVersionKind groupVersionKind = extension.groupVersionKind(); String group = StringUtils.defaultIfBlank(groupVersionKind.group(), "halo.run"); return new GroupVersion("api." + group, groupVersionKind.version()); } /** * Converts list result to another list result. * * @param listResult list result to be converted * @param mapper mapper function to convert item * @param item type * @param converted item type * @return converted list result */ public static ListResult toAnotherListResult(ListResult listResult, Function mapper) { Assert.notNull(listResult, "List result must not be null"); Assert.notNull(mapper, "The mapper must not be null"); List mappedItems = listResult.get() .map(mapper) .toList(); return new ListResult<>(listResult.getPage(), listResult.getSize(), listResult.getTotal(), mappedItems); } /** * Checks whether collection contains element. * * @param element type * @return true if collection contains element, otherwise false. */ public static boolean containsElement(@Nullable Collection collection, @Nullable T element) { if (collection != null && element != null) { return collection.contains(element); } return false; } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/theme/SinglePageQueryEndpoint.java ================================================ package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import lombok.RequiredArgsConstructor; import org.springdoc.core.fn.builders.operation.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListResult; import run.halo.app.extension.router.SortableRequest; import run.halo.app.theme.finders.SinglePageConversionService; import run.halo.app.theme.finders.SinglePageFinder; import run.halo.app.theme.finders.vo.ListedSinglePageVo; import run.halo.app.theme.finders.vo.SinglePageVo; /** * Endpoint for single page query. * * @author guqing * @since 2.5.0 */ @Component @RequiredArgsConstructor public class SinglePageQueryEndpoint implements CustomEndpoint { private final SinglePageFinder singlePageFinder; private final SinglePageConversionService singlePageConversionService; @Override public RouterFunction endpoint() { var tag = "SinglePageV1alpha1Public"; return SpringdocRouteBuilder.route() .GET("singlepages", this::listSinglePages, builder -> { builder.operationId("querySinglePages") .description("Lists single pages") .tag(tag) .response(responseBuilder() .implementation( ListResult.generateGenericClass(ListedSinglePageVo.class)) ); SinglePagePublicQuery.buildParameters(builder); } ) .GET("singlepages/{name}", this::getByName, builder -> builder.operationId("querySinglePageByName") .description("Gets single page by name") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("SinglePage name") .required(true) ) .response(responseBuilder() .implementation(SinglePageVo.class) ) ) .build(); } private Mono getByName(ServerRequest request) { var name = request.pathVariable("name"); return singlePageFinder.getByName(name) .flatMap(result -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(result) ); } private Mono listSinglePages(ServerRequest request) { var query = new SinglePagePublicQuery(request.exchange()); return singlePageConversionService.listBy(query.toListOptions(), query.toPageRequest()) .flatMap(result -> ServerResponse.ok().bodyValue(result)); } static class SinglePagePublicQuery extends SortableRequest { public SinglePagePublicQuery(ServerWebExchange exchange) { super(exchange); } public static void buildParameters(Builder builder) { SortableRequest.buildParameters(builder); } } @Override public GroupVersion groupVersion() { return PublicApiUtils.groupVersion(new SinglePage()); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/theme/SiteStatsQueryEndpoint.java ================================================ package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import lombok.RequiredArgsConstructor; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.theme.finders.SiteStatsFinder; import run.halo.app.theme.finders.vo.SiteStatsVo; /** * Endpoint for site stats query APIs. * * @author guqing * @since 2.5.0 */ @Component @RequiredArgsConstructor public class SiteStatsQueryEndpoint implements CustomEndpoint { private final SiteStatsFinder siteStatsFinder; @Override public RouterFunction endpoint() { var tag = "SystemV1alpha1Public"; return SpringdocRouteBuilder.route() .GET("stats/-", this::getStats, builder -> builder.operationId("queryStats") .description("Gets site stats") .tag(tag) .response(responseBuilder() .implementation(SiteStatsVo.class) ) ) .build(); } private Mono getStats(ServerRequest request) { return siteStatsFinder.getStats() .flatMap(result -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(result) ); } @Override public GroupVersion groupVersion() { return new GroupVersion("api.halo.run", "v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/theme/TagQueryEndpoint.java ================================================ package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import lombok.RequiredArgsConstructor; import org.springdoc.core.fn.builders.operation.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Tag; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.Queries; import run.halo.app.extension.router.SortableRequest; import run.halo.app.theme.finders.PostPublicQueryService; import run.halo.app.theme.finders.TagFinder; import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.finders.vo.TagVo; /** * Endpoint for tag query APIs. * * @author guqing * @since 2.5.0 */ @Component @RequiredArgsConstructor public class TagQueryEndpoint implements CustomEndpoint { private final ReactiveExtensionClient client; private final TagFinder tagFinder; private final PostPublicQueryService postPublicQueryService; @Override public RouterFunction endpoint() { var tag = "TagV1alpha1Public"; return SpringdocRouteBuilder.route() .GET("tags", this::listTags, builder -> { builder.operationId("queryTags") .description("Lists tags") .tag(tag) .response(responseBuilder() .implementation( ListResult.generateGenericClass(TagVo.class)) ); TagPublicQuery.buildParameters(builder); } ) .GET("tags/{name}", this::getTagByName, builder -> builder.operationId("queryTagByName") .description("Gets tag by name") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Tag name") .required(true) ) .response(responseBuilder() .implementation(TagVo.class) ) ) .GET("tags/{name}/posts", this::listPostsByTagName, builder -> { builder.operationId("queryPostsByTagName") .description("Lists posts by tag name") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Tag name") .required(true) ) .response(responseBuilder() .implementation(ListedPostVo.class) ); PostPublicQuery.buildParameters(builder); } ) .build(); } private Mono getTagByName(ServerRequest request) { String name = request.pathVariable("name"); return tagFinder.getByName(name) .flatMap(tag -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(tag) ); } private Mono listPostsByTagName(ServerRequest request) { final var name = request.pathVariable("name"); final var query = new PostPublicQuery(request.exchange()); var listOptions = query.toListOptions(); var newFieldSelector = listOptions.getFieldSelector() .andQuery(Queries.equal("spec.tags", name)); listOptions.setFieldSelector(newFieldSelector); return postPublicQueryService.list(listOptions, query.toPageRequest()) .flatMap(result -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(result) ); } private Mono listTags(ServerRequest request) { var query = new TagPublicQuery(request.exchange()); return client.listBy(Tag.class, query.toListOptions(), query.toPageRequest()) .map(result -> { var tagVos = tagFinder.convertToVo(result.getItems()); return new ListResult<>(result.getPage(), result.getSize(), result.getTotal(), tagVos); }) .flatMap(result -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(result) ); } static class TagPublicQuery extends SortableRequest { public TagPublicQuery(ServerWebExchange exchange) { super(exchange); } public static void buildParameters(Builder builder) { SortableRequest.buildParameters(builder); } } @Override public GroupVersion groupVersion() { return PublicApiUtils.groupVersion(new Tag()); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/theme/ThumbnailEndpoint.java ================================================ package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.util.Arrays; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.core.attachment.thumbnail.ThumbnailService; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.infra.utils.HaloUtils; /** * Thumbnail endpoint for thumbnail resource access. * * @author guqing * @author johnniang * @since 2.19.0 */ @Component @RequiredArgsConstructor public class ThumbnailEndpoint implements CustomEndpoint { private final ThumbnailService thumbnailService; @Override public RouterFunction endpoint() { var tag = "ThumbnailV1alpha1Public"; return SpringdocRouteBuilder.route() .GET("/thumbnails/-/via-uri", this::getThumbnailByUri, builder -> { builder.operationId("GetThumbnailByUri") .description("Get thumbnail by URI") .tag(tag) .response(responseBuilder().implementation(Resource.class)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("uri") .description("The URI of the image") .required(true) ) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("size") .implementation(ThumbnailSize.class) .description("The size of the thumbnail") .required(true) ) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("width") .schema(Builder.schemaBuilder() .type("integer") .allowableValues(Arrays.stream(ThumbnailSize.allowedWidths()) .map(String::valueOf) .toArray(String[]::new) ) ) .description(""" The width of the thumbnail, if 'size' is not provided, this \ parameter will be used to determine the size\ """) .required(false) ); }) .build(); } private Mono getThumbnailByUri(ServerRequest request) { var uri = request.queryParam("uri") .filter(StringUtils::isNotBlank) .map(HaloUtils::safeToUri); if (uri.isEmpty()) { return Mono.error( new ServerWebInputException("Required parameter 'uri' is missing or invalid") ); } var size = request.queryParam("size") .filter(StringUtils::isNotBlank) .flatMap(ThumbnailSize::optionalValueOf) .or(() -> request.queryParam("width") .filter(StringUtils::isNotBlank) .map(ThumbnailSize::fromWidth) ); if (size.isEmpty()) { return Mono.error(new ServerWebInputException( "Required parameter 'size' or 'width' is missing or invalid" )); } return thumbnailService.get(uri.get(), size.get()) .defaultIfEmpty(uri.get()) .flatMap(thumbnailLink -> ServerResponse.status(HttpStatus.FOUND) .location(thumbnailLink) .build() ); } @Override public GroupVersion groupVersion() { return new GroupVersion("api.storage.halo.run", "v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/uc/AnnotationSettingEndpoint.java ================================================ package run.halo.app.core.endpoint.uc; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static run.halo.app.extension.ExtensionUtil.defaultSort; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springdoc.core.fn.builders.apiresponse.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.AnnotationSetting; import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.Condition; import run.halo.app.extension.index.query.Queries; import run.halo.app.plugin.PluginConst; import run.halo.app.plugin.PluginService; import run.halo.app.theme.service.ThemeService; /** * The endpoint for managing AnnotationSettings. * * @author johnniang * @since 2.22.3 */ @Component @RequiredArgsConstructor class AnnotationSettingEndpoint implements CustomEndpoint { private final ReactiveExtensionClient client; private final ThemeService themeService; private final PluginService pluginService; @Override public RouterFunction endpoint() { var tag = "AnnotationSettingV1AlphaUc"; return SpringdocRouteBuilder.route() .GET( "/annotationsettings", this::listAvailableAnnotationSettings, builder -> builder .operationId("listAvailableAnnotationSettings") .description(""" List available AnnotationSettings for the given targetRef. \ The available AnnotationSettings are determined by \ the currently activated theme and started plugins.""") .tag(tag) .parameter(parameterBuilder() .name("targetRef") .in(ParameterIn.QUERY) .description( "The targetRef of the AnnotationSetting. e.g.: 'content.halo.run/Post" ) .required(true) .implementation(String.class) ) .response(Builder.responseBuilder() .implementationArray(AnnotationSetting.class) ) ) .build(); } private Mono listAvailableAnnotationSettings(ServerRequest serverRequest) { var targetRef = serverRequest.queryParam("targetRef") .filter(StringUtils::hasText) .orElse(null); if (targetRef == null) { return Mono.error(new ServerWebInputException("Query param 'targetRef' is required")); } var getActivatedTheme = themeService.fetchActivatedThemeName() .map(Optional::of) .defaultIfEmpty(Optional.empty()); var getStartedPlugins = pluginService.getStartedPluginNames().collectList(); var annotationSettings = Mono.zip(getActivatedTheme, getStartedPlugins, (themeName, pluginNames) -> { Condition labelConditions = null; if (themeName.isPresent()) { labelConditions = Queries.labelEqual(Theme.THEME_NAME_LABEL, themeName.get()); } if (!CollectionUtils.isEmpty(pluginNames)) { var pluginLabelCondition = Queries.labelIn(PluginConst.PLUGIN_NAME_LABEL_NAME, pluginNames); if (labelConditions == null) { labelConditions = pluginLabelCondition; } else { labelConditions = labelConditions.or(pluginLabelCondition); } } if (labelConditions == null) { labelConditions = Queries.empty(); } var builder = ListOptions.builder() .andQuery(labelConditions) .andQuery(Queries.equal("spec.targetRef", targetRef)); return builder.build(); }) .flatMapMany( listOptions -> client.listAll(AnnotationSetting.class, listOptions, defaultSort()) ); return ServerResponse.ok().body(annotationSettings, AnnotationSetting.class); } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("uc.api.halo.run/v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/uc/AttachmentUcEndpoint.java ================================================ package run.halo.app.core.endpoint.uc; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; import static run.halo.app.extension.index.query.Queries.equal; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import java.net.URL; import java.util.HashMap; import java.util.List; import java.util.Objects; import java.util.function.Consumer; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.FormFieldPart; import org.springframework.http.codec.multipart.Part; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.content.PostService; import run.halo.app.core.attachment.AttachmentLister; import run.halo.app.core.attachment.SearchRequest; import run.halo.app.core.endpoint.AttachmentHandler; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.exception.NotFoundException; @Component @RequiredArgsConstructor public class AttachmentUcEndpoint implements CustomEndpoint { public static final String POST_NAME_LABEL = "content.halo.run/post-name"; public static final String SINGLE_PAGE_NAME_LABEL = "content.halo.run/single-page-name"; private final AttachmentService attachmentService; private final AttachmentLister attachmentLister; private final PostService postService; private final SystemConfigFetcher systemSettingFetcher; private final AttachmentHandler attachmentHandler; @Override public RouterFunction endpoint() { var tag = "AttachmentV1alpha1Uc"; return route() .POST("/attachments", this::createAttachmentForPost, builder -> builder.operationId("CreateAttachmentForPost").tag(tag) .description(""" Create attachment for the given post. \ Deprecated in favor of /attachments/-/upload.""" ) .deprecated(true) .parameter(parameterBuilder() .name("waitForPermalink") .description("Wait for permalink.") .in(ParameterIn.QUERY) .required(false) .implementation(boolean.class)) .requestBody(requestBodyBuilder() .content(contentBuilder() .mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) .schema(schemaBuilder().implementation(PostAttachmentRequest.class))) ) .response(responseBuilder().implementation(Attachment.class)) ) .POST( "/attachments/-/upload", contentType(MediaType.MULTIPART_FORM_DATA), this::uploadAttachment, builder -> { builder.operationId("UploadAttachmentForUc") .description("Upload attachment to user center storage.") .tag(tag); this.attachmentHandler.buildDoc(builder); } ) .POST("/attachments/-/upload-from-url", contentType(MediaType.APPLICATION_JSON), this::uploadFromUrlForPost, builder -> builder .operationId("ExternalTransferAttachment_1") .description(""" Upload attachment from the given URL. Deprecated in favor of /attachments/-/upload.""") .tag(tag) .deprecated(true) .parameter(parameterBuilder() .name("waitForPermalink") .description("Wait for permalink.") .in(ParameterIn.QUERY) .required(false) .implementation(boolean.class)) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(schemaBuilder().implementation(UploadFromUrlRequest.class)) )) .response(responseBuilder().implementation(Attachment.class)) .build() ) .GET("/attachments", this::listMyAttachments, builder -> { builder.operationId("ListMyAttachments") .description("List attachments of the current user uploaded.") .tag(tag) .response(responseBuilder() .implementation(ListResult.generateGenericClass(Attachment.class)) ); SearchRequest.buildParameters(builder); }) .build(); } private Mono uploadAttachment(ServerRequest request) { var getConfigFromUser = systemSettingFetcher.fetch( SystemSetting.User.GROUP, SystemSetting.User.class ) .mapNotNull(SystemSetting.User::getUcAttachmentPolicy) .filter(StringUtils::isNotBlank) .map(policyName -> SystemSetting.Attachment.UploadOptions.builder() .policyName(policyName) .build() ); var getConfig = systemSettingFetcher.fetch( SystemSetting.Attachment.GROUP, SystemSetting.Attachment.class ) .mapNotNull(SystemSetting.Attachment::uc) .filter(uo -> StringUtils.isNotBlank(uo.policyName())) .switchIfEmpty(Mono.defer(() -> getConfigFromUser)) .switchIfEmpty(Mono.error(() -> new ServerWebInputException( "Attachment system setting is not configured for console" ))); return attachmentHandler.handleUpload(request, getConfig); } private Mono listMyAttachments(ServerRequest request) { return getCurrentUser() .flatMap(username -> { var searchRequest = new UcSearchRequest(request, username); return attachmentLister.listBy(searchRequest) .flatMap(listResult -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(listResult) ); }); } @Getter public static class UcSearchRequest extends SearchRequest { private final String owner; public UcSearchRequest(ServerRequest request, String owner) { super(request); Assert.state(StringUtils.isNotBlank(owner), "Owner must not be blank."); this.owner = owner; } @Override public ListOptions toListOptions(List hiddenGroups) { var listOptions = super.toListOptions(hiddenGroups); return ListOptions.builder(listOptions) .andQuery((equal("spec.ownerName", owner))) .build(); } } private Mono uploadFromUrlForPost(ServerRequest request) { var uploadFromUrlRequestMono = request.bodyToMono(UploadFromUrlRequest.class); var uploadAttachment = getPostSettingMono() .flatMap(postSetting -> uploadFromUrlRequestMono.flatMap( uploadFromUrlRequest -> { var url = uploadFromUrlRequest.url(); var fileName = uploadFromUrlRequest.filename(); return attachmentService.uploadFromUrl(url, postSetting.getAttachmentPolicyName(), postSetting.getAttachmentGroupName(), fileName ); }) ); var waitForPermalink = request.queryParam("waitForPermalink") .map(Boolean::valueOf) .orElse(false); if (waitForPermalink) { uploadAttachment = waitForPermalink(uploadAttachment); } return ServerResponse.ok().body(uploadAttachment, Attachment.class); } private Mono createAttachmentForPost(ServerRequest request) { var postAttachmentRequestMono = request.body(BodyExtractors.toMultipartData()) .map(PostAttachmentRequest::from) .cache(); // get settings var createdAttachment = getPostSettingMono().flatMap(postSetting -> postAttachmentRequestMono .flatMap(postAttachmentRequest -> getCurrentUser().flatMap( username -> attachmentService.upload(username, postSetting.getAttachmentPolicyName(), postSetting.getAttachmentGroupName(), postAttachmentRequest.file(), linkWith(postAttachmentRequest))))); var waitForPermalink = request.queryParam("waitForPermalink") .map(Boolean::valueOf) .orElse(false); if (waitForPermalink) { createdAttachment = waitForPermalink(createdAttachment); } return ServerResponse.ok().body(createdAttachment, Attachment.class); } private Mono waitForPermalink(Mono createdAttachment) { createdAttachment = createdAttachment.flatMap(attachment -> attachmentService.getPermalink(attachment) .doOnNext(permalink -> { var status = attachment.getStatus(); if (status == null) { status = new Attachment.AttachmentStatus(); attachment.setStatus(status); } status.setPermalink(permalink.toString()); }) .thenReturn(attachment)); return createdAttachment; } private Mono getPostSettingMono() { return systemSettingFetcher.fetchPost().handle((postSetting, sink) -> { var attachmentPolicyName = postSetting.getAttachmentPolicyName(); if (StringUtils.isBlank(attachmentPolicyName)) { sink.error(new ServerWebInputException( "Please configure storage policy for post attachment first.")); return; } sink.next(postSetting); }); } private Consumer linkWith(PostAttachmentRequest request) { return attachment -> { var labels = attachment.getMetadata().getLabels(); if (labels == null) { labels = new HashMap<>(); attachment.getMetadata().setLabels(labels); } if (StringUtils.isNotBlank(request.postName())) { labels.put(POST_NAME_LABEL, request.postName()); } if (StringUtils.isNotBlank(request.singlePageName())) { labels.put(SINGLE_PAGE_NAME_LABEL, request.singlePageName()); } }; } private Mono checkPostOwnership(Mono postAttachmentRequest) { // check the post var postNotFoundError = Mono.error( () -> new NotFoundException("The post was not found or deleted.") ); return postAttachmentRequest.map(PostAttachmentRequest::postName) .flatMap(postName -> getCurrentUser() .flatMap(username -> postService.getByUsername(postName, username) .switchIfEmpty(postNotFoundError))) .then(); } private Mono getCurrentUser() { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(Authentication::getName); } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("uc.api.storage.halo.run/v1alpha1"); } @Schema(name = "UcUploadFromUrlRequest") public record UploadFromUrlRequest(@Schema(requiredMode = REQUIRED) URL url, @Schema(description = "Custom file name") String filename) { public UploadFromUrlRequest { if (Objects.isNull(url)) { throw new ServerWebInputException("Required url is missing."); } } } @Schema(types = "object") public record PostAttachmentRequest( @Schema(requiredMode = REQUIRED, description = "Attachment data.") FilePart file, @Schema(requiredMode = NOT_REQUIRED, description = "Post name.") String postName, @Schema(requiredMode = NOT_REQUIRED, description = "Single page name.") String singlePageName ) { /** * Convert multipart data into PostAttachmentRequest. * * @param multipartData is multipart data from request. * @return post attachment request data. */ public static PostAttachmentRequest from(MultiValueMap multipartData) { var part = multipartData.getFirst("postName"); String postName = null; if (part instanceof FormFieldPart formFieldPart) { postName = formFieldPart.value(); } part = multipartData.getFirst("singlePageName"); String singlePageName = null; if (part instanceof FormFieldPart formFieldPart) { singlePageName = formFieldPart.value(); } part = multipartData.getFirst("file"); if (!(part instanceof FilePart file)) { throw new ServerWebInputException("Invalid type of parameter 'file'."); } return new PostAttachmentRequest(file, postName, singlePageName); } } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/uc/UcPostEndpoint.java ================================================ package run.halo.app.core.endpoint.uc; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route; import static org.springframework.web.reactive.function.server.RequestPredicates.path; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.util.Objects; import org.apache.commons.lang3.StringUtils; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.content.Content; import run.halo.app.content.ContentUpdateParam; import run.halo.app.content.ListedPost; import run.halo.app.content.PostQuery; import run.halo.app.content.PostRequest; import run.halo.app.content.PostService; import run.halo.app.content.SnapshotService; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.Ref; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.infra.utils.JsonUtils; @Component public class UcPostEndpoint implements CustomEndpoint { private static final String CONTENT_JSON_ANNO = "content.halo.run/content-json"; private final PostService postService; private final SnapshotService snapshotService; public UcPostEndpoint(PostService postService, SnapshotService snapshotService) { this.postService = postService; this.snapshotService = snapshotService; } @Override public RouterFunction endpoint() { var tag = "PostV1alpha1Uc"; var namePathParam = parameterBuilder().name("name") .description("Post name") .in(ParameterIn.PATH) .required(true); return route().nest( path("/posts"), () -> route() .GET(this::listMyPost, builder -> { builder.operationId("ListMyPosts") .description("List posts owned by the current user.") .tag(tag) .response(responseBuilder().implementation( ListResult.generateGenericClass(ListedPost.class))); PostQuery.buildParameters(builder); } ) .POST(this::createMyPost, builder -> builder.operationId("CreateMyPost") .tag(tag) .description(""" Create my post. If you want to create a post with content, please set annotation: "content.halo.run/content-json" into annotations and refer to Content for corresponding data type. """) .requestBody(requestBodyBuilder().implementation(Post.class)) .response(responseBuilder().implementation(Post.class)) ) .GET("/{name}", this::getMyPost, builder -> builder.operationId("GetMyPost") .tag(tag) .parameter(namePathParam) .description("Get post that belongs to the current user.") .response(responseBuilder().implementation(Post.class)) ) .PUT("/{name}", this::updateMyPost, builder -> builder.operationId("UpdateMyPost") .tag(tag) .parameter(namePathParam) .description("Update my post.") .requestBody(requestBodyBuilder().implementation(Post.class)) .response(responseBuilder().implementation(Post.class)) ) .GET("/{name}/draft", this::getMyPostDraft, builder -> builder.tag(tag) .operationId("GetMyPostDraft") .description("Get my post draft.") .parameter(namePathParam) .parameter(parameterBuilder() .name("patched") .in(ParameterIn.QUERY) .required(false) .implementation(Boolean.class) .description("Should include patched content and raw or not.") ) .response(responseBuilder().implementation(Snapshot.class)) ) .PUT("/{name}/draft", this::updateMyPostDraft, builder -> builder.tag(tag) .operationId("UpdateMyPostDraft") .description(""" Update draft of my post. Please make sure set annotation: "content.halo.run/content-json" into annotations and refer to Content for corresponding data type. """) .parameter(namePathParam) .requestBody(requestBodyBuilder().implementation(Snapshot.class)) .response(responseBuilder().implementation(Snapshot.class))) .PUT("/{name}/publish", this::publishMyPost, builder -> builder.tag(tag) .operationId("PublishMyPost") .description("Publish my post.") .parameter(namePathParam) .response(responseBuilder().implementation(Post.class))) .PUT("/{name}/unpublish", this::unpublishMyPost, builder -> builder.tag(tag) .operationId("UnpublishMyPost") .description("Unpublish my post.") .parameter(namePathParam) .response(responseBuilder().implementation(Post.class)) ) .DELETE("/{name}/recycle", this::recycleMyPost, builder -> builder.tag(tag) .operationId("RecycleMyPost") .description("Move my post to recycle bin.") .parameter(namePathParam) .response(responseBuilder().implementation(Post.class)) ) .build() ) .build(); } private Mono recycleMyPost(ServerRequest request) { final var name = request.pathVariable("name"); return getCurrentUser() .flatMap(username -> postService.recycleBy(name, username)) .flatMap(post -> ServerResponse.ok().bodyValue(post)); } private Mono getMyPostDraft(ServerRequest request) { var name = request.pathVariable("name"); var patched = request.queryParam("patched").map(Boolean::valueOf).orElse(false); var draft = getMyPost(name) .flatMap(post -> { var headSnapshotName = post.getSpec().getHeadSnapshot(); var baseSnapshotName = post.getSpec().getBaseSnapshot(); if (StringUtils.isBlank(headSnapshotName)) { headSnapshotName = baseSnapshotName; } if (patched) { return snapshotService.getPatchedBy(headSnapshotName, baseSnapshotName); } return snapshotService.getBy(headSnapshotName); }); return ServerResponse.ok().body(draft, Snapshot.class); } private Mono unpublishMyPost(ServerRequest request) { var name = request.pathVariable("name"); var postMono = getCurrentUser() .flatMap(username -> postService.getByUsername(name, username)); var unpublishedPost = postMono.flatMap(postService::unpublish); return ServerResponse.ok().body(unpublishedPost, Post.class); } private Mono publishMyPost(ServerRequest request) { var name = request.pathVariable("name"); var postMono = getCurrentUser() .flatMap(username -> postService.getByUsername(name, username)); var publishedPost = postMono.flatMap(postService::publish); return ServerResponse.ok().body(publishedPost, Post.class); } private Mono updateMyPostDraft(ServerRequest request) { var name = request.pathVariable("name"); var postMono = getMyPost(name).cache(); var snapshotMono = request.bodyToMono(Snapshot.class).cache(); var contentMono = snapshotMono .map(Snapshot::getMetadata) .filter(metadata -> { var annotations = metadata.getAnnotations(); return annotations != null && annotations.containsKey(CONTENT_JSON_ANNO); }) .map(metadata -> { var contentJson = metadata.getAnnotations().remove(CONTENT_JSON_ANNO); return JsonUtils.jsonToObject(contentJson, Content.class); }) .cache(); // check the snapshot belongs to the post. var checkSnapshot = postMono.flatMap(post -> snapshotMono.filter( snapshot -> Ref.equals(snapshot.getSpec().getSubjectRef(), post) ).switchIfEmpty(Mono.error(() -> new ServerWebInputException("The snapshot does not belong to the given post.")) ).filter(snapshot -> { var snapshotName = snapshot.getMetadata().getName(); var headSnapshotName = post.getSpec().getHeadSnapshot(); return Objects.equals(snapshotName, headSnapshotName); }).switchIfEmpty(Mono.error(() -> new ServerWebInputException("The snapshot was not the head snapshot of the post."))) ).then(); var setContributor = getCurrentUser().flatMap(username -> snapshotMono.doOnNext(snapshot -> Snapshot.addContributor(snapshot, username))); var getBaseSnapshot = postMono.map(post -> post.getSpec().getBaseSnapshot()) .flatMap(snapshotService::getBy); var updatedSnapshot = getBaseSnapshot.flatMap( baseSnapshot -> contentMono.flatMap(content -> postMono.flatMap(post -> { var postName = post.getMetadata().getName(); var headSnapshotName = post.getSpec().getHeadSnapshot(); var releaseSnapshotName = post.getSpec().getReleaseSnapshot(); if (!Objects.equals(headSnapshotName, releaseSnapshotName)) { // patch and update return snapshotMono.flatMap( s -> snapshotService.patchAndUpdate(s, baseSnapshot, content)); } // patch and create return getCurrentUser().map( username -> { var metadata = new Metadata(); metadata.setGenerateName(postName + "-snapshot-"); var spec = new Snapshot.SnapShotSpec(); spec.setParentSnapshotName(headSnapshotName); spec.setOwner(username); spec.setSubjectRef(Ref.of(post)); var snapshot = new Snapshot(); snapshot.setMetadata(metadata); snapshot.setSpec(spec); Snapshot.addContributor(snapshot, username); return snapshot; }) .flatMap(s -> snapshotService.patchAndCreate(s, baseSnapshot, content)) .flatMap(createdSnapshot -> { post.getSpec().setHeadSnapshot(createdSnapshot.getMetadata().getName()); return postService.updateBy(post).thenReturn(createdSnapshot); }); }))); return ServerResponse.ok() .body(checkSnapshot.and(setContributor).then(updatedSnapshot), Snapshot.class); } private Mono updateMyPost(ServerRequest request) { var name = request.pathVariable("name"); var postBody = request.bodyToMono(Post.class) .doOnNext(post -> { var annotations = post.getMetadata().getAnnotations(); if (annotations != null) { // we don't support updating content while updating post. annotations.remove(CONTENT_JSON_ANNO); } }) .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required."))); var updatedPost = getMyPost(name).flatMap(oldPost -> postBody.doOnNext(post -> { var oldSpec = oldPost.getSpec(); // restrict fields of post.spec. var spec = post.getSpec(); spec.setOwner(oldSpec.getOwner()); spec.setPublish(oldSpec.getPublish()); spec.setHeadSnapshot(oldSpec.getHeadSnapshot()); spec.setBaseSnapshot(oldSpec.getBaseSnapshot()); spec.setReleaseSnapshot(oldSpec.getReleaseSnapshot()); spec.setDeleted(oldSpec.getDeleted()); post.getMetadata().setName(oldPost.getMetadata().getName()); })) .flatMap(postService::updateBy); return ServerResponse.ok().body(updatedPost, Post.class); } private Mono createMyPost(ServerRequest request) { var postFromRequest = request.bodyToMono(Post.class) .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required."))); var createdPost = getCurrentUser() .flatMap(username -> postFromRequest .doOnNext(post -> { if (post.getSpec() == null) { post.setSpec(new Post.PostSpec()); } post.getSpec().setOwner(username); })) .map(post -> new PostRequest(post, ContentUpdateParam.from(getContent(post)))) .flatMap(postService::draftPost); return ServerResponse.ok().body(createdPost, Post.class); } private Content getContent(Post post) { Content content = null; var annotations = post.getMetadata().getAnnotations(); if (annotations != null && annotations.containsKey(CONTENT_JSON_ANNO)) { var contentJson = annotations.remove(CONTENT_JSON_ANNO); content = JsonUtils.jsonToObject(contentJson, Content.class); } return content; } private Mono listMyPost(ServerRequest request) { var posts = getCurrentUser() .map(username -> new PostQuery(request, username)) .flatMap(postService::listPost); return ServerResponse.ok().body(posts, ListedPost.class); } private Mono getMyPost(ServerRequest request) { var postName = request.pathVariable("name"); var post = getMyPost(postName); return ServerResponse.ok().body(post, Post.class); } private Mono getMyPost(String postName) { return getCurrentUser() .flatMap(username -> postService.getByUsername(postName, username) .switchIfEmpty( Mono.error(() -> new NotFoundException("The post was not found or deleted")) ) ); } private Mono getCurrentUser() { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(Authentication::getName); } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("uc.api.content.halo.run/v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/uc/UcSnapshotEndpoint.java ================================================ package run.halo.app.core.endpoint.uc; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route; import static org.springframework.web.reactive.function.server.RequestPredicates.path; import io.swagger.v3.oas.annotations.enums.ParameterIn; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.content.PostService; import run.halo.app.content.SnapshotService; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.Ref; import run.halo.app.infra.exception.NotFoundException; @Component public class UcSnapshotEndpoint implements CustomEndpoint { private final PostService postService; private final SnapshotService snapshotService; public UcSnapshotEndpoint(PostService postService, SnapshotService snapshotService) { this.postService = postService; this.snapshotService = snapshotService; } @Override public RouterFunction endpoint() { var tag = "SnapshotV1alpha1Uc"; return route().nest(path("/snapshots"), () -> route() .GET("/{name}", this::getSnapshot, builder -> builder.operationId("GetSnapshotForPost") .description("Get snapshot for one post.") .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .description("Snapshot name.") ) .parameter(parameterBuilder() .name("postName") .in(ParameterIn.QUERY) .required(true) .description("Post name.") ) .parameter(parameterBuilder() .name("patched") .in(ParameterIn.QUERY) .required(false) .implementation(Boolean.class) .description("Should include patched content and raw or not.") ) .response(responseBuilder().implementation(Snapshot.class)) .tag(tag)) .build() ) .build(); } private Mono getSnapshot(ServerRequest request) { var snapshotName = request.pathVariable("name"); var postName = request.queryParam("postName") .orElseThrow(() -> new ServerWebInputException("Query parameter postName is required")); var patched = request.queryParam("patched").map(Boolean::valueOf).orElse(false); var postNotFoundError = Mono.error( () -> new NotFoundException("The post was not found or deleted.") ); var snapshotNotFoundError = Mono.error( () -> new NotFoundException("The snapshot was not found or deleted.") ); var postMono = getCurrentUser().flatMap(username -> postService.getByUsername(postName, username).switchIfEmpty(postNotFoundError) ); // check the post belongs to the current user. var snapshotMono = postMono.flatMap(post -> Mono.defer( () -> { if (patched) { var baseSnapshotName = post.getSpec().getBaseSnapshot(); return snapshotService.getPatchedBy(snapshotName, baseSnapshotName); } return snapshotService.getBy(snapshotName); }) .filter(snapshot -> { var subjectRef = snapshot.getSpec().getSubjectRef(); return Ref.equals(subjectRef, post); }) .switchIfEmpty(snapshotNotFoundError) ); return ServerResponse.ok().body(snapshotMono, Snapshot.class); } private Mono getCurrentUser() { return ReactiveSecurityContextHolder .getContext() .map(SecurityContext::getAuthentication) .map(Authentication::getName); } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("uc.api.content.halo.run/v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/uc/UcUserPreferenceEndpoint.java ================================================ package run.halo.app.core.endpoint.uc; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.util.HashMap; import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springdoc.core.fn.builders.requestbody.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationTrustResolver; import org.springframework.security.authentication.AuthenticationTrustResolverImpl; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; /** * User preference endpoint for UC (User Center). * This endpoint allows users to get and update their preferences by group. * * @author JohnNiang * @since 2.21.0 */ @Component @RequiredArgsConstructor class UcUserPreferenceEndpoint implements CustomEndpoint { private static final String PREFERENCE_PREFIX = "user-preferences-"; private final AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl(); private final ReactiveExtensionClient client; private final JsonMapper mapper; @Override public RouterFunction endpoint() { var tag = "UserPreferenceV1alpha1Uc"; return SpringdocRouteBuilder.route() .GET( "/user-preferences/{group}", this::getMyPreference, builder -> builder.operationId("getMyPreference") .tag(tag) .description("Get my preference by group.") .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("group") .description("Group of user preference, e.g. `notification`.") .implementation(String.class) .required(true) ) .response(responseBuilder().implementation(Object.class)) ) .PUT( "/user-preferences/{group}", this::updateMyPreference, builder -> builder.operationId("updateMyPreference") .tag(tag) .description("Create or update my preference by group.") .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("group") .description("Group of user preference, e.g. `notification`.") .implementation(String.class) .required(true) ) .requestBody(Builder.requestBodyBuilder() .required(true) .implementation(Object.class)) .response(responseBuilder() .description("No content, preference updated successfully.") .responseCode(String.valueOf(HttpStatus.NO_CONTENT.value())) ) ) .build(); } private Mono updateMyPreference(ServerRequest serverRequest) { var group = serverRequest.pathVariable("group"); return authenticated() .map(Authentication::getName) .flatMap(username -> client.fetch(ConfigMap.class, PREFERENCE_PREFIX + username) .switchIfEmpty(Mono.fromSupplier(() -> { var cm = new ConfigMap(); cm.setMetadata(new Metadata()); cm.getMetadata().setName(PREFERENCE_PREFIX + username); return cm; })) ) .flatMap(cm -> serverRequest.bodyToMono(JsonNode.class) .switchIfEmpty( Mono.error(() -> new ServerWebInputException("Request body is required.")) ) .flatMap(jsonNode -> Mono.fromCallable(() -> { if (cm.getData() == null) { cm.setData(new HashMap<>()); } var json = mapper.writeValueAsString(jsonNode); if (Objects.equals(json, cm.getData().get(group))) { return null; } cm.getData().put(group, json); return cm; })) .flatMap(extension -> { if (extension.getMetadata().getVersion() == null) { return client.create(extension); } return client.update(extension); }) .defaultIfEmpty(cm) ) .flatMap(cm -> ServerResponse.noContent().build()); } private Mono getMyPreference(ServerRequest serverRequest) { var group = serverRequest.pathVariable("group"); return authenticated() .map(Authentication::getName) .flatMap(username -> client.fetch(ConfigMap.class, PREFERENCE_PREFIX + username)) .mapNotNull(ConfigMap::getData) .mapNotNull(data -> data.get(group)) .flatMap(json -> Mono.fromCallable(() -> mapper.readTree(json))) .switchIfEmpty(Mono.fromSupplier(mapper::nullNode)) .flatMap(jsonNode -> ServerResponse.ok().bodyValue(jsonNode)); } private Mono authenticated() { return ReactiveSecurityContextHolder.getContext() .mapNotNull(SecurityContext::getAuthentication) .filter(trustResolver::isAuthenticated) .switchIfEmpty(Mono.error(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, "Anonymous user is not allowed to access user preference." ))); } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("uc.api.halo.run/v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/core/endpoint/uc/UserConnectionEndpoint.java ================================================ package run.halo.app.core.endpoint.uc; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import org.springdoc.core.fn.builders.parameter.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.security.authentication.AuthenticationTrustResolver; import org.springframework.security.authentication.AuthenticationTrustResolverImpl; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.core.extension.UserConnection; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.user.service.UserConnectionService; import run.halo.app.extension.GroupVersion; /** * User connection endpoint. * * @author johnniang * @since 2.20.0 */ @Component public class UserConnectionEndpoint implements CustomEndpoint { private final UserConnectionService connectionService; private final AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl(); public UserConnectionEndpoint(UserConnectionService connectionService) { this.connectionService = connectionService; } @Override public RouterFunction endpoint() { var tag = "UserConnectionV1alpha1Uc"; return SpringdocRouteBuilder.route() .PUT( "/user-connections/{registerId}/disconnect", request -> { var removedUserConnections = ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .filter(authenticationTrustResolver::isAuthenticated) .map(Authentication::getName) .flatMapMany(username -> connectionService.removeUserConnection( request.pathVariable("registerId"), username) ); return ServerResponse.ok().body(removedUserConnections, UserConnection.class); }, builder -> builder.operationId("DisconnectMyConnection") .description("Disconnect my connection from a third-party platform.") .tag(tag) .parameter(Builder.parameterBuilder() .in(ParameterIn.PATH) .name("registerId") .description("The registration ID of the third-party platform.") .required(true) .implementation(String.class) ) .response(responseBuilder().implementationArray(UserConnection.class)) ) .build(); } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("uc.api.auth.halo.run/v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/AnnotationSettingReconciler.java ================================================ package run.halo.app.core.reconciler; import java.util.Map; import lombok.AllArgsConstructor; import org.springframework.stereotype.Component; import org.thymeleaf.util.StringUtils; import run.halo.app.core.extension.AnnotationSetting; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.GroupKind; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; /** * Reconciler for {@link AnnotationSetting}. * * @author guqing * @since 2.0.0 */ @Component @AllArgsConstructor public class AnnotationSettingReconciler implements Reconciler { private final ExtensionClient client; @Override public Result reconcile(Request request) { populateDefaultLabels(request.name()); return new Result(false, null); } private void populateDefaultLabels(String name) { client.fetch(AnnotationSetting.class, name).ifPresent(annotationSetting -> { Map labels = MetadataUtil.nullSafeLabels(annotationSetting); String oldTargetRef = labels.get(AnnotationSetting.TARGET_REF_LABEL); GroupKind targetRef = annotationSetting.getSpec().getTargetRef(); String targetRefLabel = targetRef.group() + "/" + targetRef.kind(); labels.put(AnnotationSetting.TARGET_REF_LABEL, targetRefLabel); if (!StringUtils.equals(oldTargetRef, targetRefLabel)) { client.update(annotationSetting); } }); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new AnnotationSetting()) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/AuthProviderReconciler.java ================================================ package run.halo.app.core.reconciler; import java.time.Duration; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.BooleanUtils; import org.springframework.stereotype.Component; import run.halo.app.core.extension.AuthProvider; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.security.AuthProviderService; /** * Reconciler for {@link AuthProvider}. * * @author guqing * @since 2.4.0 */ @Component @RequiredArgsConstructor public class AuthProviderReconciler implements Reconciler { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private final ExtensionClient client; private final AuthProviderService authProviderService; @Override public Result reconcile(Request request) { client.fetch(AuthProvider.class, request.name()) .ifPresent(this::handlePrivileged); return Result.doNotRetry(); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new AuthProvider()) .build(); } private void handlePrivileged(AuthProvider authProvider) { if (privileged(authProvider)) { authProviderService.enable(authProvider.getMetadata().getName()) .block(BLOCKING_TIMEOUT); } } private boolean privileged(AuthProvider authProvider) { return BooleanUtils.TRUE.equals(MetadataUtil.nullSafeLabels(authProvider) .get(AuthProvider.PRIVILEGED_LABEL)); } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/CategoryReconciler.java ================================================ package run.halo.app.core.reconciler; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations; import java.time.Duration; import java.util.Set; import lombok.AllArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import run.halo.app.content.CategoryService; import run.halo.app.content.permalinks.CategoryPermalinkPolicy; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Constant; import run.halo.app.core.extension.content.Post; import run.halo.app.event.post.CategoryHiddenStateChangeEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListOptions; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.index.query.Queries; import run.halo.app.infra.utils.ReactiveUtils; /** * Reconciler for {@link Category}. * * @author guqing * @since 2.0.0 */ @Component @AllArgsConstructor public class CategoryReconciler implements Reconciler { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; static final String FINALIZER_NAME = "category-protection"; private final ExtensionClient client; private final CategoryPermalinkPolicy categoryPermalinkPolicy; private final CategoryService categoryService; private final ApplicationEventPublisher eventPublisher; @Override public Result reconcile(Request request) { client.fetch(Category.class, request.name()) .ifPresent(category -> { if (ExtensionUtil.isDeleted(category)) { if (removeFinalizers(category.getMetadata(), Set.of(FINALIZER_NAME))) { refreshHiddenState(category, false); updateCategoryForPost(category.getMetadata().getName()); client.update(category); } return; } addFinalizers(category.getMetadata(), Set.of(FINALIZER_NAME)); populatePermalinkPattern(category); populatePermalink(category); checkHiddenState(category); client.update(category); }); return Result.doNotRetry(); } private void checkHiddenState(Category category) { final boolean hidden = categoryService.isCategoryHidden(category.getMetadata().getName()) .blockOptional(BLOCKING_TIMEOUT) .orElse(false); refreshHiddenState(category, hidden); } /** * TODO move this logic to before-create/update hook in the future see {@code gh-4343}. */ private void refreshHiddenState(Category category, boolean hidden) { category.getSpec().setHideFromList(hidden); if (isHiddenStateChanged(category)) { publishHiddenStateChangeEvent(category); } var children = category.getSpec().getChildren(); if (CollectionUtils.isEmpty(children)) { return; } for (String childName : children) { client.fetch(Category.class, childName) .ifPresent(child -> { child.getSpec().setHideFromList(hidden); if (isHiddenStateChanged(child)) { publishHiddenStateChangeEvent(child); } client.update(child); }); } } private void publishHiddenStateChangeEvent(Category category) { var hidden = category.getSpec().isHideFromList(); nullSafeAnnotations(category).put(Category.LAST_HIDDEN_STATE_ANNO, String.valueOf(hidden)); eventPublisher.publishEvent(new CategoryHiddenStateChangeEvent(this, category.getMetadata().getName(), hidden)); } boolean isHiddenStateChanged(Category category) { var lastHiddenState = nullSafeAnnotations(category).get(Category.LAST_HIDDEN_STATE_ANNO); return !String.valueOf(category.getSpec().isHideFromList()).equals(lastHiddenState); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new Category()) .build(); } void populatePermalinkPattern(Category category) { var annotations = nullSafeAnnotations(category); if (!annotations.containsKey(Constant.PERMALINK_PATTERN_ANNO)) { var newPattern = categoryPermalinkPolicy.pattern(); annotations.put(Constant.PERMALINK_PATTERN_ANNO, newPattern); } } void populatePermalink(Category category) { category.getStatusOrDefault() .setPermalink(categoryPermalinkPolicy.permalink(category)); } private void updateCategoryForPost(String categoryName) { var posts = client.listAll(Post.class, ListOptions.builder() .fieldQuery(Queries.equal("spec.categories", categoryName)) .build(), Sort.by("metadata.creationTimestamp", "metadata.name") ); for (Post post : posts) { var categoryNames = post.getSpec().getCategories(); if (!CollectionUtils.isEmpty(categoryNames)) { categoryNames.remove(categoryName); } client.update(post); } } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/CommentReconciler.java ================================================ package run.halo.app.core.reconciler; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.isDeleted; import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.isNull; import java.time.Duration; import java.time.Instant; import java.util.Map; import java.util.Objects; import java.util.Set; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.BooleanUtils; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import run.halo.app.content.comment.ReplyNotificationSubscriptionHelper; import run.halo.app.content.comment.ReplyService; import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Constant; import run.halo.app.event.post.CommentCreatedEvent; import run.halo.app.event.post.CommentUnreadReplyCountChangedEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.Ref; import run.halo.app.extension.SchemeManager; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.index.query.Condition; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.utils.ReactiveUtils; /** * Reconciler for {@link Comment}. * * @author guqing * @since 2.0.0 */ @Component @RequiredArgsConstructor public class CommentReconciler implements Reconciler { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; public static final String FINALIZER_NAME = "comment-protection"; private final ExtensionClient client; private final SchemeManager schemeManager; private final ReplyService replyService; private final ApplicationEventPublisher eventPublisher; private final ReplyNotificationSubscriptionHelper replyNotificationSubscriptionHelper; @Override public Result reconcile(Request request) { client.fetch(Comment.class, request.name()) .ifPresent(comment -> { if (isDeleted(comment)) { if (removeFinalizers(comment.getMetadata(), Set.of(FINALIZER_NAME))) { cleanUpResources(comment); client.update(comment); } return; } if (addFinalizers(comment.getMetadata(), Set.of(FINALIZER_NAME))) { replyNotificationSubscriptionHelper.subscribeNewReplyReasonForComment(comment); client.update(comment); eventPublisher.publishEvent(new CommentCreatedEvent(this, comment)); } compatibleCreationTime(comment); Comment.CommentStatus status = comment.getStatusOrDefault(); status.setHasNewReply(defaultIfNull(status.getUnreadReplyCount(), 0) > 0); updateUnReplyCountIfNecessary(comment); updateSameSubjectRefCommentCounter(comment); // version + 1 is required to truly equal version // as a version will be incremented after the update comment.getStatusOrDefault() .setObservedVersion(comment.getMetadata().getVersion() + 1); client.update(comment); }); return new Result(false, null); } @Override public Controller setupWith(ControllerBuilder builder) { var extension = new Comment(); return builder .extension(extension) .syncAllListOptions(ListOptions.builder() .andQuery(equal(Comment.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, true)) .build()) .build(); } /** * If the comment creation time is null, set it to the approved time or the current time. * TODO remove this method in the future and fill in attributes in hook mode instead. */ void compatibleCreationTime(Comment comment) { var creationTime = comment.getSpec().getCreationTime(); if (creationTime == null) { creationTime = defaultIfNull(comment.getSpec().getApprovedTime(), comment.getMetadata().getCreationTimestamp()); } comment.getSpec().setCreationTime(creationTime); } private void updateUnReplyCountIfNecessary(Comment comment) { Instant lastReadTime = comment.getSpec().getLastReadTime(); Map annotations = MetadataUtil.nullSafeAnnotations(comment); String lastReadTimeAnno = annotations.get(Constant.LAST_READ_TIME_ANNO); if (lastReadTime != null && lastReadTime.toString().equals(lastReadTimeAnno)) { return; } // delegate to other handler though event String commentName = comment.getMetadata().getName(); eventPublisher.publishEvent(new CommentUnreadReplyCountChangedEvent(this, commentName)); // handled flag if (lastReadTime != null) { annotations.put(Constant.LAST_READ_TIME_ANNO, lastReadTime.toString()); } else { annotations.remove(Constant.LAST_READ_TIME_ANNO); } } private void updateSameSubjectRefCommentCounter(Comment comment) { var commentSubjectRef = comment.getSpec().getSubjectRef(); var totalCount = countTotalComments(commentSubjectRef); var approvedTotalCount = countApprovedComments(commentSubjectRef); var findScheme = schemeManager.schemes().stream() .filter(scheme -> { var gvk = scheme.groupVersionKind(); return Objects.equals(gvk.group(), commentSubjectRef.getGroup()) && Objects.equals(gvk.kind(), commentSubjectRef.getKind()); }) .findFirst(); findScheme.ifPresent(scheme -> { String counterName = MeterUtils.nameOf(commentSubjectRef.getGroup(), scheme.plural(), commentSubjectRef.getName()); client.fetch(Counter.class, counterName).ifPresentOrElse(counter -> { counter.setTotalComment(totalCount); counter.setApprovedComment(approvedTotalCount); client.update(counter); }, () -> { Counter counter = Counter.emptyCounter(counterName); counter.setTotalComment(totalCount); counter.setApprovedComment(approvedTotalCount); client.create(counter); }); }); } int countTotalComments(Ref commentSubjectRef) { var totalListOptions = new ListOptions(); totalListOptions.setFieldSelector(FieldSelector.of(getBaseQuery(commentSubjectRef))); return (int) client.listBy(Comment.class, totalListOptions, PageRequestImpl.ofSize(1)) .getTotal(); } int countApprovedComments(Ref commentSubjectRef) { var approvedListOptions = new ListOptions(); approvedListOptions.setFieldSelector(FieldSelector.of(and( getBaseQuery(commentSubjectRef), equal("spec.approved", BooleanUtils.TRUE) ))); return (int) client.listBy(Comment.class, approvedListOptions, PageRequestImpl.ofSize(1)) .getTotal(); } private static Condition getBaseQuery(Ref commentSubjectRef) { return and(equal("spec.subjectRef", Comment.toSubjectRefKey(commentSubjectRef)), isNull("metadata.deletionTimestamp")); } private void cleanUpResources(Comment comment) { // delete all replies under current comment replyService.removeAllByComment(comment.getMetadata().getName()).block(BLOCKING_TIMEOUT); // decrement total comment count updateSameSubjectRefCommentCounter(comment); } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/MenuItemReconciler.java ================================================ package run.halo.app.core.reconciler; import java.time.Duration; import java.util.Objects; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import run.halo.app.core.extension.MenuItem; import run.halo.app.core.extension.MenuItem.MenuItemSpec; import run.halo.app.core.extension.MenuItem.MenuItemStatus; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler.Request; @Slf4j @Component public class MenuItemReconciler implements Reconciler { private final ExtensionClient client; public MenuItemReconciler(ExtensionClient client) { this.client = client; } @Override public Result reconcile(Request request) { return client.fetch(MenuItem.class, request.name()) .map(menuItem -> { final var spec = menuItem.getSpec(); if (menuItem.getStatus() == null) { menuItem.setStatus(new MenuItemStatus()); } var status = menuItem.getStatus(); var targetRef = spec.getTargetRef(); if (targetRef != null) { if (Ref.groupKindEquals(targetRef, Category.GVK)) { return handleCategoryRef(request.name(), status, targetRef); } if (Ref.groupKindEquals(targetRef, Tag.GVK)) { return handleTagRef(request.name(), status, targetRef); } if (Ref.groupKindEquals(targetRef, SinglePage.GVK)) { return handleSinglePageSpec(request.name(), status, targetRef); } if (Ref.groupKindEquals(targetRef, Post.GVK)) { return handlePostRef(request.name(), status, targetRef); } // unsupported ref log.error("Unsupported MenuItem targetRef " + targetRef); return Result.doNotRetry(); } else { return handleMenuSpec(request.name(), status, spec); } }).orElseGet(() -> new Result(false, null)); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new MenuItem()) .build(); } private Result handleCategoryRef(String menuItemName, MenuItemStatus status, Ref categoryRef) { client.fetch(Category.class, categoryRef.getName()) .filter(category -> category.getStatus() != null) .filter(category -> StringUtils.hasText(category.getStatus().getPermalink())) .ifPresent(category -> { status.setHref(category.getStatus().getPermalink()); status.setDisplayName(category.getSpec().getDisplayName()); updateStatus(menuItemName, status); }); return new Result(true, Duration.ofMinutes(1)); } private Result handleTagRef(String menuItemName, MenuItemStatus status, Ref tagRef) { client.fetch(Tag.class, tagRef.getName()).filter(tag -> tag.getStatus() != null) .filter(tag -> StringUtils.hasText(tag.getStatus().getPermalink())).ifPresent(tag -> { status.setHref(tag.getStatus().getPermalink()); status.setDisplayName(tag.getSpec().getDisplayName()); updateStatus(menuItemName, status); }); return new Result(true, Duration.ofMinutes(1)); } private Result handlePostRef(String menuItemName, MenuItemStatus status, Ref postRef) { client.fetch(Post.class, postRef.getName()).filter(post -> post.getStatus() != null) .filter(post -> StringUtils.hasText(post.getStatus().getPermalink())) .ifPresent(post -> { status.setHref(post.getStatus().getPermalink()); status.setDisplayName(post.getSpec().getTitle()); updateStatus(menuItemName, status); }); return new Result(true, Duration.ofMinutes(1)); } private Result handleSinglePageSpec(String menuItemName, MenuItemStatus status, Ref pageRef) { client.fetch(SinglePage.class, pageRef.getName()) .filter(page -> page.getStatus() != null) .filter(page -> StringUtils.hasText(page.getStatus().getPermalink())) .ifPresent(page -> { status.setHref(page.getStatus().getPermalink()); status.setDisplayName(page.getSpec().getTitle()); updateStatus(menuItemName, status); }); return new Result(true, Duration.ofMinutes(1)); } private Result handleMenuSpec(String menuItemName, MenuItemStatus status, MenuItemSpec spec) { if (spec.getHref() != null && StringUtils.hasText(spec.getDisplayName())) { status.setHref(spec.getHref()); status.setDisplayName(spec.getDisplayName()); updateStatus(menuItemName, status); } return new Result(false, null); } private void updateStatus(String menuItemName, MenuItemStatus status) { client.fetch(MenuItem.class, menuItemName) .filter(menuItem -> !Objects.deepEquals(menuItem.getStatus(), status)) .ifPresent(menuItem -> { menuItem.setStatus(status); client.update(menuItem); }); } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/PluginReconciler.java ================================================ package run.halo.app.core.reconciler; import static run.halo.app.core.extension.Plugin.PluginStatus.nullSafeConditions; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations; import static run.halo.app.plugin.PluginConst.PLUGIN_PATH; import static run.halo.app.plugin.PluginConst.RELOAD_ANNO; import static run.halo.app.plugin.PluginConst.REQUEST_TO_UNLOAD_LABEL; import static run.halo.app.plugin.PluginExtensionLoaderUtils.isSetting; import static run.halo.app.plugin.PluginExtensionLoaderUtils.lookupExtensions; import static run.halo.app.plugin.PluginUtils.generateFileName; import static run.halo.app.plugin.PluginUtils.isDevelopmentMode; import java.io.FileNotFoundException; import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; import java.nio.file.FileSystemNotFoundException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Clock; import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.function.Predicate; import java.util.function.Supplier; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.pf4j.PluginState; import org.pf4j.PluginWrapper; import org.pf4j.RuntimeMode; import org.springframework.beans.factory.DisposableBean; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.ResourceUtils; import org.springframework.web.util.UriComponentsBuilder; import reactor.core.Disposable; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.ReverseProxy; import run.halo.app.core.extension.Setting; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.Metadata; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.Unstructured; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler.Request; import run.halo.app.extension.controller.RequeueException; import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionList; import run.halo.app.infra.ConditionStatus; import run.halo.app.infra.utils.PathUtils; import run.halo.app.infra.utils.SettingUtils; import run.halo.app.infra.utils.YamlUnstructuredLoader; import run.halo.app.plugin.OptionalDependentResolver; import run.halo.app.plugin.PluginConst; import run.halo.app.plugin.PluginProperties; import run.halo.app.plugin.PluginService; import run.halo.app.plugin.SpringPluginManager; /** * Plugin reconciler. * * @author guqing * @author johnniang * @since 2.0.0 */ @Slf4j @Component @RequiredArgsConstructor class PluginReconciler implements Reconciler, DisposableBean { private static final String FINALIZER_NAME = "plugin-protection"; private static final Set UNUSED_ANNOTATIONS = Set.of("plugin.halo.run/dependents-snapshot"); private final ExtensionClient client; private final SpringPluginManager pluginManager; private final PluginProperties pluginProperties; private final PluginService pluginService; private final ConcurrentMap pluginStartTasks = new ConcurrentHashMap<>(); private Scheduler scheduler = Schedulers.newBoundedElastic( 1, Schedulers.DEFAULT_BOUNDED_ELASTIC_QUEUESIZE, "plugin-starter" ); private Clock clock = Clock.systemUTC(); @Override public void destroy() throws Exception { pluginStartTasks.clear(); this.scheduler.dispose(); } /** * Only for testing. * * @param clock new clock. */ void setClock(Clock clock) { Assert.notNull(clock, "clock must not be null"); this.clock = clock; } /** * Only for testing. * * @param scheduler new scheduler. */ void setScheduler(Scheduler scheduler) { Assert.notNull(scheduler, "scheduler must not be null"); this.scheduler = scheduler; } @Override public Result reconcile(Request request) { return client.fetch(Plugin.class, request.name()) .map(plugin -> { if (ExtensionUtil.isDeleted(plugin)) { if (!checkDependents(plugin)) { client.update(plugin); // Check dependents every 10 seconds return Result.requeue(Duration.ofSeconds(10)); } // CleanUp resources and remove finalizer. if (removeFinalizers(plugin.getMetadata(), Set.of(FINALIZER_NAME))) { cleanupResources(plugin); syncPluginState(plugin); client.update(plugin); } return Result.doNotRetry(); } addFinalizers(plugin.getMetadata(), Set.of(FINALIZER_NAME)); removeUnusedAnnotations(plugin); var status = plugin.getStatus(); if (status == null) { status = new Plugin.PluginStatus(); plugin.setStatus(status); } if (status.getPhase() == null) { // reset phase to pending status.setPhase(Plugin.Phase.PENDING); } // init condition list if not exists if (status.getConditions() == null) { status.setConditions(new ConditionList()); } var steps = new LinkedList>(); steps.add(() -> resolveLoadLocation(plugin)); steps.add(() -> loadOrReload(plugin)); steps.add(() -> createOrUpdateSetting(plugin)); steps.add(() -> createOrUpdateReverseProxy(plugin)); steps.add(() -> resolveStaticResources(plugin)); if (requestToEnable(plugin)) { steps.add(() -> enablePlugin(plugin)); } else { steps.add(() -> disablePlugin(plugin)); } Result result = null; try { for (var step : steps) { result = step.get(); if (result != null) { break; } } return result; } catch (Throwable e) { status.getConditions().addAndEvictFIFO(Condition.builder() .type(ConditionType.READY) .status(ConditionStatus.FALSE) .reason(ConditionReason.SYSTEM_ERROR) .message(e.getMessage()) .lastTransitionTime(clock.instant()) .build()); status.setPhase(Plugin.Phase.UNKNOWN); throw e; } finally { var pw = pluginManager.getPlugin(plugin.getMetadata().getName()); if (pw != null) { status.setLastProbeState(pw.getPluginState()); } client.update(plugin); } }) .orElseGet(Result::doNotRetry); } private void removeUnusedAnnotations(Plugin plugin) { var annotations = plugin.getMetadata().getAnnotations(); if (annotations != null) { UNUSED_ANNOTATIONS.forEach(annotations::remove); } } private boolean checkDependents(Plugin plugin) { var pluginId = plugin.getMetadata().getName(); var dependents = pluginManager.getDependents(pluginId); if (CollectionUtils.isEmpty(dependents)) { return true; } var status = plugin.statusNonNull(); var condition = Condition.builder() .type(ConditionType.PROGRESSING) .status(ConditionStatus.UNKNOWN) .reason(ConditionReason.WAIT_FOR_DEPENDENTS_DELETED) .message( "The plugin has dependents %s, please delete them first." .formatted(dependents.stream().map(PluginWrapper::getPluginId).toList()) ) .lastTransitionTime(clock.instant()) .build(); var conditions = nullSafeConditions(status); removeConditionBy(conditions, ConditionType.INITIALIZED); removeConditionBy(conditions, ConditionType.READY); conditions.addAndEvictFIFO(condition); status.setPhase(Plugin.Phase.UNKNOWN); return false; } private void syncPluginState(Plugin plugin) { var pluginName = plugin.getMetadata().getName(); var p = pluginManager.getPlugin(pluginName); if (p != null) { plugin.statusNonNull().setLastProbeState(p.getPluginState()); } else { plugin.statusNonNull().setLastProbeState(null); } } private static String requestToUnload(Plugin plugin) { var labels = plugin.getMetadata().getLabels(); if (labels == null) { return null; } return labels.get(REQUEST_TO_UNLOAD_LABEL); } private static boolean requestToReload(Plugin plugin) { var annotations = plugin.getMetadata().getAnnotations(); return annotations != null && annotations.get(RELOAD_ANNO) != null; } private static void removeRequestToReload(Plugin plugin) { var annotations = plugin.getMetadata().getAnnotations(); if (annotations != null) { annotations.remove(RELOAD_ANNO); } } private void cleanupResources(Plugin plugin) { var pluginName = plugin.getMetadata().getName(); var reverseProxyName = buildReverseProxyName(pluginName); log.info("Deleting reverse proxy {} for plugin {}", reverseProxyName, pluginName); client.fetch(ReverseProxy.class, reverseProxyName) .ifPresent(reverseProxy -> { client.delete(reverseProxy); throw new RequeueException(Result.requeue(null), String.format(""" Waiting for reverse proxy %s to be deleted.""", reverseProxyName) ); }); var settingName = plugin.getSpec().getSettingName(); if (StringUtils.isNotBlank(settingName)) { log.info("Deleting settings {} for plugin {}", settingName, pluginName); client.fetch(Setting.class, settingName) .ifPresent(setting -> { client.delete(setting); throw new RequeueException(Result.requeue(null), String.format(""" Waiting for setting %s to be deleted.""", settingName)); }); } if (pluginManager.getPlugin(pluginName) != null) { log.info("Deleting plugin {} in plugin manager.", pluginName); var deleted = pluginManager.deletePlugin(pluginName); if (!deleted) { log.warn("Failed to delete plugin {}", pluginName); } } } private Result enablePlugin(Plugin plugin) { // start the plugin var pluginName = plugin.getMetadata().getName(); log.info("Starting plugin {}", pluginName); var status = plugin.getStatus(); // check if the parent plugin is started var unstartedDependencies = pluginService.getRequiredDependencies(plugin, pw -> pw == null || !PluginState.STARTED.equals(pw.getPluginState()) ); var conditions = status.getConditions(); if (!CollectionUtils.isEmpty(unstartedDependencies)) { removeConditionBy(conditions, ConditionType.READY); conditions.addAndEvictFIFO(Condition.builder() .type(ConditionType.PROGRESSING) .status(ConditionStatus.UNKNOWN) .reason(ConditionReason.WAIT_FOR_DEPENDENCIES_STARTED) .message("Wait for parent plugins " + unstartedDependencies + " to be started") .lastTransitionTime(clock.instant()) .build()); status.setPhase(Plugin.Phase.UNKNOWN); return Result.requeue(Duration.ofSeconds(5)); } var current = pluginManager.getPlugin(pluginName); if (current == null) { conditions.addAndEvictFIFO(Condition.builder() .type(ConditionType.READY) .status(ConditionStatus.FALSE) .reason(ConditionReason.START_ERROR) .message("Plugin " + pluginName + " is not loaded.") .lastTransitionTime(clock.instant()) .build()); status.setPhase(Plugin.Phase.FAILED); removeStartTaskIfPresent(pluginName); return Result.doNotRetry(); } var pluginState = current.getPluginState(); if (pluginState.isStarted()) { removeConditionBy(conditions, ConditionType.PROGRESSING); status.setLastStartTime(clock.instant()); conditions.addAndEvictFIFO(Condition.builder() .type(ConditionType.READY) .status(ConditionStatus.TRUE) .reason(ConditionReason.STARTED) .message("Started successfully") .lastTransitionTime(clock.instant()) .build()); status.setPhase(Plugin.Phase.STARTED); removeStartTaskIfPresent(pluginName); requestToReloadPluginsOptionallyDependentOn(pluginName); return Result.doNotRetry(); } if (pluginState.isFailed()) { var t = current.getFailedException(); log.debug("Error occurred when starting plugin {}", pluginName, t); var writer = new StringWriter(); t.printStackTrace(new PrintWriter(writer)); conditions.addAndEvictFIFO(Condition.builder() .type(ConditionType.READY) .status(ConditionStatus.FALSE) .reason(ConditionReason.START_ERROR) .message(writer.toString()) .lastTransitionTime(clock.instant()) .build()); status.setPhase(Plugin.Phase.FAILED); removeStartTaskIfPresent(pluginName); return Result.doNotRetry(); } if (!Plugin.Phase.STARTING.equals(status.getPhase())) { pluginStartTasks.compute(pluginName, (name, old) -> { if (old != null && !old.isDisposed()) { log.info("Cancelling old starting task for plugin {}.", name); old.dispose(); log.info("Cancelled old starting task for plugin {}.", name); } return scheduler.schedule(() -> { log.info("Starting plugin {} in background thread.", name); try { var state = pluginManager.startPlugin(name); log.info("Plugin {} started with state {}.", name, state); } catch (Throwable t) { var pluginWrapper = pluginManager.getPlugin(name); if (pluginWrapper != null) { pluginWrapper.setPluginState(PluginState.FAILED); pluginWrapper.setFailedException(t); } } }); }); status.setPhase(Plugin.Phase.STARTING); conditions.addAndEvictFIFO(Condition.builder() .type(ConditionType.PROGRESSING) .status(ConditionStatus.TRUE) .reason(ConditionReason.STARTING) .message("Starting plugin " + pluginName) .lastTransitionTime(clock.instant()) .build()); } else { log.debug("Plugin {} is starting...", pluginName); } return Result.requeue(Duration.ofSeconds(2)); } void requestToReloadPluginsOptionallyDependentOn(String pluginName) { var startedPlugins = pluginManager.startedPlugins() .stream() .map(PluginWrapper::getDescriptor) .toList(); var resolver = new OptionalDependentResolver(startedPlugins); var dependents = resolver.getOptionalDependents(pluginName); for (String dependentName : dependents) { client.fetch(Plugin.class, dependentName) .ifPresent(childPlugin -> { var annotations = MetadataUtil.nullSafeAnnotations(childPlugin); // loadLocation never be null for started plugins annotations.put(RELOAD_ANNO, childPlugin.getStatus().getLoadLocation().toString()); client.update(childPlugin); }); } } private Result disablePlugin(Plugin plugin) { var pluginName = plugin.getMetadata().getName(); var status = plugin.getStatus(); if (pluginManager.getPlugin(pluginName) != null) { // check if the plugin has children var dependents = pluginManager.getDependents(pluginName) .stream() .filter(pw -> PluginState.STARTED.equals(pw.getPluginState())) .map(PluginWrapper::getPluginId) .toList(); var conditions = status.getConditions(); if (!CollectionUtils.isEmpty(dependents)) { removeConditionBy(conditions, ConditionType.READY); conditions.addAndEvictFIFO(Condition.builder() .type(ConditionType.PROGRESSING) .status(ConditionStatus.UNKNOWN) .reason(ConditionReason.WAIT_FOR_DEPENDENTS_DISABLED) .message("Wait for children plugins " + dependents + " to be disabled") .lastTransitionTime(clock.instant()) .build()); status.setPhase(Plugin.Phase.DISABLING); return Result.requeue(Duration.ofSeconds(1)); } try { // First, stop starting task if exists removeStartTaskIfPresent(pluginName); pluginManager.disablePlugin(pluginName); } catch (Throwable e) { log.error("Error occurred when disabling plugin {}", pluginName, e); conditions.addAndEvictFIFO(Condition.builder() .type(ConditionType.READY) .status(ConditionStatus.FALSE) .reason(ConditionReason.DISABLE_ERROR) .message(e.getMessage()) .lastTransitionTime(clock.instant()) .build()); status.setPhase(Plugin.Phase.FAILED); return Result.doNotRetry(); } } var conditions = plugin.getStatus().getConditions(); removeConditionBy(conditions, ConditionType.PROGRESSING); conditions.addAndEvictFIFO(Condition.builder() .type(ConditionType.READY) .status(ConditionStatus.TRUE) .reason(ConditionReason.DISABLED) .lastTransitionTime(clock.instant()) .build()); plugin.statusNonNull().setPhase(Plugin.Phase.DISABLED); return null; } private static boolean requestToEnable(Plugin plugin) { var enabled = plugin.getSpec().getEnabled(); return enabled != null && enabled; } private Result resolveStaticResources(Plugin plugin) { var pluginName = plugin.getMetadata().getName(); var pluginVersion = plugin.getSpec().getVersion(); if (isDevelopmentMode(plugin)) { // when we are in dev mode, the plugin version is not always changed. pluginVersion = String.valueOf(clock.instant().toEpochMilli()); } var status = plugin.statusNonNull(); var specLogo = plugin.getSpec().getLogo(); if (StringUtils.isNotBlank(specLogo)) { log.info("Resolving logo resource for plugin {}", pluginName); // the logo might be: // 1. URL // 2. relative path to "resources" folder // 3. base64 format data image var logo = specLogo; if (!specLogo.startsWith("data:image")) { try { logo = new URL(specLogo).toString(); } catch (MalformedURLException ignored) { // indicate the logo is a path logo = UriComponentsBuilder.newInstance() .pathSegment("plugins", pluginName, "assets") .path(specLogo) .queryParam("version", pluginVersion) .build(true) .toString(); } } status.setLogo(logo); } log.info("Resolving main.js and style.css for plugin {}", pluginName); var p = pluginManager.getPlugin(pluginName); var classLoader = p.getPluginClassLoader(); var resLoader = new DefaultResourceLoader(classLoader); var entryRes = resLoader.getResource("classpath:console/main.js"); var cssRes = resLoader.getResource("classpath:console/style.css"); if (entryRes.exists()) { var entry = UriComponentsBuilder.newInstance() .pathSegment("plugins", pluginName, "assets", "console", "main.js") .queryParam("version", pluginVersion) .build(true) .toString(); status.setEntry(entry); } if (cssRes.exists()) { var stylesheet = UriComponentsBuilder.newInstance() .pathSegment("plugins", pluginName, "assets", "console", "style.css") .queryParam("version", pluginVersion) .build(true) .toString(); status.setStylesheet(stylesheet); } return null; } private Result loadOrReload(Plugin plugin) { var pluginName = plugin.getMetadata().getName(); var p = pluginManager.getPlugin(pluginName); var conditions = plugin.getStatus().getConditions(); var requestToUnloadBy = requestToUnload(plugin); var requestToUnload = requestToUnloadBy != null; var notFullyLoaded = p != null && pluginManager.getUnresolvedPlugins().contains(p); var alreadyLoaded = p != null && pluginManager.getResolvedPlugins().contains(p); var requestToReload = requestToReload(plugin); // TODO Check load location var shouldUnload = requestToUnload || requestToReload || notFullyLoaded; if (shouldUnload) { // check if the plugin is already loaded or not fully loaded. if (alreadyLoaded || notFullyLoaded) { // get all dependencies var dependents = requestToUnloadChildren(pluginName); if (!CollectionUtils.isEmpty(dependents)) { removeConditionBy(conditions, ConditionType.READY); conditions.addAndEvictFIFO(Condition.builder() .type(ConditionType.PROGRESSING) .status(ConditionStatus.UNKNOWN) .reason(ConditionReason.WAIT_FOR_DEPENDENTS_UNLOADED) .message("Wait for children plugins " + dependents + "to be unloaded") .lastTransitionTime(clock.instant()) .build()); plugin.getStatus().setPhase(Plugin.Phase.UNKNOWN); // wait for children plugins unloaded // retry after 1 second return Result.requeue(Duration.ofSeconds(1)); } // unload the plugin exactly pluginManager.unloadPlugin(pluginName); removeConditionBy(conditions, ConditionType.INITIALIZED); removeConditionBy(conditions, ConditionType.PROGRESSING); removeConditionBy(conditions, ConditionType.READY); cancelUnloadRequest(pluginName); p = null; } // ensure removing the reload annotation after the plugin is unloaded if (requestToUnload) { // skip loading and wait for removing the annotation by other plugins. var status = plugin.getStatus(); status.getConditions().addAndEvictFIFO(Condition.builder() .type(ConditionType.INITIALIZED) .status(ConditionStatus.FALSE) .reason(ConditionReason.REQUEST_TO_UNLOAD) .message("Request to unload by " + requestToUnloadBy) .lastTransitionTime(clock.instant()) .build()); return Result.doNotRetry(); } if (requestToReload) { removeRequestToReload(plugin); } } // check dependencies before loading var unresolvedParentPlugins = pluginService.getRequiredDependencies(plugin, pw -> pw == null || pluginManager.getUnresolvedPlugins().contains(pw) ); if (!unresolvedParentPlugins.isEmpty()) { // requeue if the parent plugin is not loaded yet. removeConditionBy(conditions, ConditionType.INITIALIZED); removeConditionBy(conditions, ConditionType.READY); conditions.addAndEvictFIFO(Condition.builder() .type(ConditionType.PROGRESSING) .status(ConditionStatus.UNKNOWN) .reason(ConditionReason.WAIT_FOR_DEPENDENCIES_LOADED) .message("Wait for parent plugins " + unresolvedParentPlugins + " to be loaded") .lastTransitionTime(clock.instant()) .build()); plugin.getStatus().setPhase(Plugin.Phase.UNKNOWN); return Result.requeue(Duration.ofSeconds(1)); } if (p == null) { var loadLocation = plugin.getStatus().getLoadLocation(); log.info("Loading plugin {} from {}", pluginName, loadLocation); pluginManager.loadPlugin(Paths.get(loadLocation)); plugin.getStatus().setPhase(Plugin.Phase.RESOLVED); log.info("Loaded plugin {} from {}", pluginName, loadLocation); conditions.addAndEvictFIFO(Condition.builder() .type(ConditionType.INITIALIZED) .status(ConditionStatus.TRUE) .reason(ConditionReason.LOADED) .lastTransitionTime(clock.instant()) .build()); } return null; } private Result createOrUpdateSetting(Plugin plugin) { log.info("Initializing setting and config map for plugin {}", plugin.getMetadata().getName()); var settingName = plugin.getSpec().getSettingName(); if (StringUtils.isBlank(settingName)) { // do nothing if no setting name provided. return null; } var pluginName = plugin.getMetadata().getName(); var p = pluginManager.getPlugin(pluginName); var resources = lookupExtensions(p.getPluginClassLoader()); var loader = new YamlUnstructuredLoader(resources); var setting = loader.load().stream() .filter(isSetting(settingName)) .findFirst() .map(u -> Unstructured.OBJECT_MAPPER.convertValue(u, Setting.class)) .orElseThrow(() -> new IllegalStateException(String.format(""" Setting name %s was provided but setting extension \ was not found in plugin %s.""", settingName, pluginName))); client.fetch(Setting.class, settingName) .ifPresentOrElse(oldSetting -> { // overwrite the setting var version = oldSetting.getMetadata().getVersion(); setting.getMetadata().setVersion(version); // TODO Remove this line in the future removeFinalizers(setting.getMetadata(), Set.of("plugin-protector")); client.update(setting); }, () -> client.create(setting)); log.info("Initialized setting {} for plugin {}", settingName, pluginName); // create default config map var configMapName = plugin.getSpec().getConfigMapName(); if (StringUtils.isBlank(configMapName)) { return null; } var defaultConfigMap = SettingUtils.populateDefaultConfig(setting, configMapName); client.fetch(ConfigMap.class, configMapName) .ifPresentOrElse(configMap -> { // merge data var oldData = configMap.getData(); var defaultData = defaultConfigMap.getData(); var mergedData = SettingUtils.mergePatch(oldData, defaultData); configMap.setData(mergedData); client.update(configMap); }, () -> client.create(defaultConfigMap)); log.info("Initialized config map {} for plugin {}", configMapName, pluginName); return null; } private Result resolveLoadLocation(Plugin plugin) { log.debug("Resolving load location for plugin {}", plugin.getMetadata().getName()); // populate load location from annotations var pluginName = plugin.getMetadata().getName(); var annotations = nullSafeAnnotations(plugin); var pluginPathAnno = annotations.get(PLUGIN_PATH); var status = plugin.statusNonNull(); if (isDevelopmentMode(plugin)) { if (!isInDevEnvironment()) { status.getConditions().addAndEvictFIFO(Condition.builder() .type(ConditionType.INITIALIZED) .status(ConditionStatus.FALSE) .reason(ConditionReason.INVALID_RUNTIME_MODE) .message(""" Cannot run the plugin with development mode in non-development environment.\ """) .lastTransitionTime(clock.instant()) .build()); status.setPhase(Plugin.Phase.UNKNOWN); return Result.doNotRetry(); } log.debug("Plugin {} is in development mode", pluginName); if (StringUtils.isBlank(pluginPathAnno)) { status.getConditions().addAndEvictFIFO(Condition.builder() .type(ConditionType.INITIALIZED) .status(ConditionStatus.FALSE) .reason(ConditionReason.PLUGIN_PATH_NOT_SET) .message(""" Plugin path annotation is not set. \ Please set plugin path annotation "%s" in development mode.\ """.formatted(PLUGIN_PATH)) .build()); return Result.doNotRetry(); } try { var loadLocation = ResourceUtils.getURL(pluginPathAnno).toURI(); if (!Objects.equals(status.getLoadLocation(), loadLocation)) { log.debug("Populated load location {} for plugin {} from annotation {}", loadLocation, pluginName, pluginPathAnno); status.setLoadLocation(loadLocation); status.setPhase(Plugin.Phase.RESOLVED); status.getConditions().addAndEvictFIFO(Condition.builder() .type(ConditionType.INITIALIZED) .status(ConditionStatus.TRUE) .reason(ConditionReason.LOAD_LOCATION_RESOLVED) .lastTransitionTime(clock.instant()) .build()); log.debug("Populated load location {} for plugin {}", status.getLoadLocation(), pluginName ); } } catch (URISyntaxException | FileNotFoundException e) { // TODO Refactor this using event in the future. var condition = Condition.builder() .type(ConditionType.INITIALIZED) .status(ConditionStatus.FALSE) .reason(ConditionReason.INVALID_PLUGIN_PATH) .message("Invalid plugin path " + pluginPathAnno + " configured.") .lastTransitionTime(clock.instant()) .build(); status.getConditions().addAndEvictFIFO(condition); status.setPhase(Plugin.Phase.UNKNOWN); return Result.doNotRetry(); } } else { // reset annotation PLUGIN_PATH in non-dev mode var pluginFilename = generateFileName(plugin); var pluginRoot = pluginManager.getPluginsRoots().stream() .filter(root -> Files.exists(root.resolve(pluginFilename))) .findFirst() .orElse(null); if (pluginRoot == null) { var condition = Condition.builder() .type(ConditionType.INITIALIZED) .status(ConditionStatus.FALSE) .reason(ConditionReason.INVALID_PLUGIN_PATH) .message("Cannot find plugin file " + pluginFilename + " in plugins roots.") .lastTransitionTime(clock.instant()) .build(); status.getConditions().addAndEvictFIFO(condition); status.setPhase(Plugin.Phase.UNKNOWN); return Result.doNotRetry(); } var pluginPath = pluginRoot.resolve(pluginFilename); annotations.put(PLUGIN_PATH, pluginRoot.relativize(pluginPath).toString()); // delete old load location if changed. var oldLoadLocation = status.getLoadLocation(); var newLoadLocation = pluginPath.toUri(); if (oldLoadLocation != null && !Objects.equals(oldLoadLocation, newLoadLocation)) { // delete the old load location log.info("Deleting old plugin file {} for plugin {}, and new load location is {}.", oldLoadLocation, pluginName, newLoadLocation); try { var deleted = Files.deleteIfExists(Path.of(oldLoadLocation)); if (deleted) { log.info("Deleted old plugin file {} for plugin {}.", oldLoadLocation, pluginName); } } catch (IOException e) { log.warn("Failed to delete old plugin file {} for plugin {}", oldLoadLocation, pluginName, e); } catch (FileSystemNotFoundException e) { log.warn( "Failed to delete old plugin file {} for plugin {}: File system not found.", oldLoadLocation, pluginName, e); } status.setPhase(Plugin.Phase.RESOLVED); status.getConditions().addAndEvictFIFO(Condition.builder() .type(ConditionType.INITIALIZED) .status(ConditionStatus.TRUE) .reason(ConditionReason.LOAD_LOCATION_RESOLVED) .lastTransitionTime(clock.instant()) .build()); log.debug("Populated load location {} for plugin {}", status.getLoadLocation(), pluginName ); } status.setLoadLocation(newLoadLocation); } return null; } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new Plugin()) .syncAllOnStart(true) .build(); } private void removeStartTaskIfPresent(String pluginName) { pluginStartTasks.computeIfPresent(pluginName, (name, disposable) -> { if (!disposable.isDisposed()) { log.info("Cancelling starting task for plugin {}.", name); disposable.dispose(); log.info("Cancelled starting task for plugin {}.", name); } return null; }); } private Result createOrUpdateReverseProxy(Plugin plugin) { String pluginName = plugin.getMetadata().getName(); String reverseProxyName = buildReverseProxyName(pluginName); ReverseProxy reverseProxy = new ReverseProxy(); reverseProxy.setMetadata(new Metadata()); reverseProxy.getMetadata().setName(reverseProxyName); // put label to identify this reverse reverseProxy.getMetadata().setLabels(new HashMap<>()); reverseProxy.getMetadata().getLabels().put(PluginConst.PLUGIN_NAME_LABEL_NAME, pluginName); reverseProxy.setRules(new ArrayList<>()); String logo = plugin.getSpec().getLogo(); if (StringUtils.isNotBlank(logo) && !PathUtils.isAbsoluteUri(logo)) { ReverseProxy.ReverseProxyRule logoRule = new ReverseProxy.ReverseProxyRule(logo, new ReverseProxy.FileReverseProxyProvider(null, logo)); reverseProxy.getRules().add(logoRule); } client.fetch(ReverseProxy.class, reverseProxyName) .ifPresentOrElse(persisted -> { reverseProxy.getMetadata() .setVersion(persisted.getMetadata().getVersion()); client.update(reverseProxy); }, () -> client.create(reverseProxy)); return null; } private boolean isInDevEnvironment() { return RuntimeMode.DEVELOPMENT.equals(pluginProperties.getRuntimeMode()); } static String buildReverseProxyName(String pluginName) { return pluginName + "-system-generated-reverse-proxy"; } private List requestToUnloadChildren(String pluginName) { // get all dependencies var dependents = pluginManager.getDependents(pluginName) .stream() .map(PluginWrapper::getPluginId) .toList(); // request all dependents to reload. dependents.forEach(dependent -> client.fetch(Plugin.class, dependent) .ifPresent(childPlugin -> { var labels = childPlugin.getMetadata().getLabels(); if (labels == null) { labels = new HashMap<>(); childPlugin.getMetadata().setLabels(labels); } var label = labels.get(REQUEST_TO_UNLOAD_LABEL); if (!pluginName.equals(label)) { labels.put(REQUEST_TO_UNLOAD_LABEL, pluginName); client.update(childPlugin); } })); return dependents; } private void cancelUnloadRequest(String pluginName) { // remove label REQUEST_TO_UNLOAD_LABEL // TODO Use index mechanism Predicate filter = aplugin -> { var labels = aplugin.getMetadata().getLabels(); return labels != null && pluginName.equals(labels.get(REQUEST_TO_UNLOAD_LABEL)); }; client.list(Plugin.class, filter, null) .forEach(aplugin -> { var labels = aplugin.getMetadata().getLabels(); if (labels != null && labels.remove(REQUEST_TO_UNLOAD_LABEL) != null) { client.update(aplugin); } }); } private static void removeConditionBy(ConditionList conditions, String type) { conditions.removeIf(condition -> Objects.equals(type, condition.getType())); } public static class ConditionType { /** * Indicates whether the plugin is initialized. */ public static final String INITIALIZED = "Initialized"; /** * Indicates whether the plugin is starting, disabling or deleting. */ public static final String PROGRESSING = "Progressing"; /** * Indicates whether the plugin is ready. */ public static final String READY = "Ready"; } public static class ConditionReason { public static final String LOAD_LOCATION_RESOLVED = "LoadLocationResolved"; public static final String INVALID_PLUGIN_PATH = "InvalidPluginPath"; public static final String WAIT_FOR_DEPENDENCIES_STARTED = "WaitForDependenciesStarted"; public static final String WAIT_FOR_DEPENDENCIES_LOADED = "WaitForDependenciesLoaded"; public static final String WAIT_FOR_DEPENDENTS_DELETED = "WaitForDependentsDeleted"; public static final String WAIT_FOR_DEPENDENTS_DISABLED = "WaitForDependentsDisabled"; public static final String WAIT_FOR_DEPENDENTS_UNLOADED = "WaitForDependentsUnloaded"; public static final String STARTING = "Starting"; public static final String STARTED = "Started"; public static final String DISABLED = "Disabled"; public static final String SYSTEM_ERROR = "SystemError"; public static final String REQUEST_TO_UNLOAD = "RequestToUnload"; public static final String LOADED = "Loaded"; public static final String START_ERROR = "StartError"; public static final String DISABLE_ERROR = "DisableError"; public static final String INVALID_RUNTIME_MODE = "InvalidRuntimeMode"; public static final String PLUGIN_PATH_NOT_SET = "PluginPathNotSet"; } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/PostCounterReconciler.java ================================================ package run.halo.app.core.reconciler; import static run.halo.app.extension.index.query.Queries.startsWith; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.content.Post; import run.halo.app.event.post.PostStatsChangedEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; @Component @RequiredArgsConstructor public class PostCounterReconciler implements Reconciler { private final ApplicationEventPublisher eventPublisher; private final ExtensionClient client; @Override public Result reconcile(Request request) { if (!isSameAsPost(request.name())) { return Result.doNotRetry(); } client.fetch(Counter.class, request.name()).ifPresent(counter -> { eventPublisher.publishEvent(new PostStatsChangedEvent(this, counter)); }); return Result.doNotRetry(); } @Override public Controller setupWith(ControllerBuilder builder) { var extension = new Counter(); return builder .extension(extension) .syncAllListOptions(ListOptions.builder() .andQuery(startsWith("metadata.name", MeterUtils.nameOf(Post.class, ""))) .build()) .build(); } static boolean isSameAsPost(String name) { return name.startsWith(MeterUtils.nameOf(Post.class, "")); } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/PostReconciler.java ================================================ package run.halo.app.core.reconciler; import static java.nio.charset.StandardCharsets.UTF_8; import static org.apache.commons.lang3.BooleanUtils.TRUE; import static org.apache.commons.lang3.BooleanUtils.isFalse; import static org.apache.commons.lang3.BooleanUtils.isTrue; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations; import static run.halo.app.extension.MetadataUtil.nullSafeLabels; import static run.halo.app.extension.index.query.Queries.in; import com.google.common.hash.Hashing; import java.time.Duration; import java.time.Instant; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.jsoup.Jsoup; import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.content.CategoryService; import run.halo.app.content.ContentWrapper; import run.halo.app.content.ExcerptGenerator; import run.halo.app.content.NotificationReasonConst; import run.halo.app.content.PostService; import run.halo.app.content.comment.CommentService; import run.halo.app.content.permalinks.PostPermalinkPolicy; import run.halo.app.core.counter.CounterService; import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.content.Constant; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Post.PostPhase; import run.halo.app.core.extension.content.Post.VisibleEnum; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.core.extension.content.Tag; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.event.post.PostDeletedEvent; import run.halo.app.event.post.PostPublishedEvent; import run.halo.app.event.post.PostUnpublishedEvent; import run.halo.app.event.post.PostUpdatedEvent; import run.halo.app.event.post.PostVisibleChangedEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionOperator; import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.Ref; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.RequeueException; import run.halo.app.extension.index.query.Queries; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionStatus; import run.halo.app.infra.utils.HaloUtils; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.notification.NotificationCenter; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** *

Reconciler for {@link Post}.

* *

things to do:

*
    * 1. generate permalink * 2. generate excerpt if auto generate is enabled *
* * @author guqing * @since 2.0.0 */ @Slf4j @AllArgsConstructor @Component public class PostReconciler implements Reconciler { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private static final String FINALIZER_NAME = "post-protection"; private final ExtensionClient client; private final PostService postService; private final PostPermalinkPolicy postPermalinkPolicy; private final CounterService counterService; private final CommentService commentService; private final CategoryService categoryService; private final ExtensionGetter extensionGetter; private final ApplicationEventPublisher eventPublisher; private final NotificationCenter notificationCenter; @Override public Result reconcile(Request request) { var events = new LinkedHashSet(); client.fetch(Post.class, request.name()) .ifPresent(post -> { if (ExtensionOperator.isDeleted(post)) { removeFinalizers(post.getMetadata(), Set.of(FINALIZER_NAME)); unPublishPost(post, events); events.add(new PostDeletedEvent(this, post)); cleanUpResources(post); // update post to be able to be collected by gc collector. client.update(post); // fire event after updating post events.forEach(eventPublisher::publishEvent); return; } addFinalizers(post.getMetadata(), Set.of(FINALIZER_NAME)); populateLabels(post, events); schedulePublishIfNecessary(post); subscribeNewCommentNotification(post); var status = post.getStatus(); if (status == null) { status = new Post.PostStatus(); post.setStatus(status); } if (post.isPublished() && post.getSpec().getPublishTime() == null) { post.getSpec().setPublishTime(Instant.now()); } // calculate the sha256sum var configSha256sum = Hashing.sha256().hashString(post.getSpec().toString(), UTF_8) .toString(); var annotations = nullSafeAnnotations(post); var oldConfigChecksum = annotations.get(Constant.CHECKSUM_CONFIG_ANNO); if (!Objects.equals(oldConfigChecksum, configSha256sum)) { // if the checksum doesn't match events.add(new PostUpdatedEvent(this, post.getMetadata().getName())); annotations.put(Constant.CHECKSUM_CONFIG_ANNO, configSha256sum); } if (shouldUnPublish(post)) { unPublishPost(post, events); } else { publishPost(post, events); } if (!annotations.containsKey(Constant.PERMALINK_PATTERN_ANNO)) { // only set the permalink pattern if not present var permalinkPattern = postPermalinkPolicy.pattern(); annotations.put(Constant.PERMALINK_PATTERN_ANNO, permalinkPattern); } status.setPermalink(postPermalinkPolicy.permalink(post)); if (status.getPhase() == null) { status.setPhase(PostPhase.DRAFT.toString()); } var excerpt = post.getSpec().getExcerpt(); if (excerpt == null) { excerpt = new Post.Excerpt(); } var isAutoGenerate = defaultIfNull(excerpt.getAutoGenerate(), true); if (isAutoGenerate) { status.setExcerpt(getExcerpt(post)); } else { status.setExcerpt(excerpt.getRaw()); } var ref = Ref.of(post); // handle contributors var headSnapshot = post.getSpec().getHeadSnapshot(); var contributors = listSnapshots(ref) .stream() .map(snapshot -> { Set usernames = snapshot.getSpec().getContributors(); return Objects.requireNonNullElseGet(usernames, () -> new HashSet()); }) .flatMap(Set::stream) .distinct() .sorted() .toList(); status.setContributors(contributors); // update in progress status status.setInProgress( !StringUtils.equals(headSnapshot, post.getSpec().getReleaseSnapshot())); computeHiddenState(post); // version + 1 is required to truly equal version // as a version will be incremented after the update status.setObservedVersion(post.getMetadata().getVersion() + 1); client.update(post); // fire event after updating post events.forEach(eventPublisher::publishEvent); }); return Result.doNotRetry(); } private void computeHiddenState(Post post) { var categories = post.getSpec().getCategories(); if (categories == null) { post.getStatusOrDefault().setHideFromList(false); return; } var hidden = categories.stream() .anyMatch(categoryName -> categoryService.isCategoryHidden(categoryName) .blockOptional(BLOCKING_TIMEOUT).orElse(false) ); post.getStatusOrDefault().setHideFromList(hidden); } private void populateLabels(Post post, Set events) { var labels = nullSafeLabels(post); labels.put(Post.DELETED_LABEL, String.valueOf(isTrue(post.getSpec().getDeleted()))); var expectVisible = defaultIfNull(post.getSpec().getVisible(), VisibleEnum.PUBLIC); var oldVisible = VisibleEnum.from(labels.get(Post.VISIBLE_LABEL)); if (!Objects.equals(oldVisible, expectVisible)) { var postName = post.getMetadata().getName(); events.add(new PostVisibleChangedEvent(this, postName, oldVisible, expectVisible)); } labels.put(Post.VISIBLE_LABEL, expectVisible.toString()); var ownerName = post.getSpec().getOwner(); if (StringUtils.isNotBlank(ownerName)) { labels.put(Post.OWNER_LABEL, ownerName); } var publishTime = post.getSpec().getPublishTime(); if (publishTime != null) { labels.put(Post.ARCHIVE_YEAR_LABEL, HaloUtils.getYearText(publishTime)); labels.put(Post.ARCHIVE_MONTH_LABEL, HaloUtils.getMonthText(publishTime)); labels.put(Post.ARCHIVE_DAY_LABEL, HaloUtils.getDayText(publishTime)); } if (!labels.containsKey(Post.PUBLISHED_LABEL)) { labels.put(Post.PUBLISHED_LABEL, BooleanUtils.FALSE); } } private static boolean shouldUnPublish(Post post) { return isTrue(post.getSpec().getDeleted()) || isFalse(post.getSpec().getPublish()); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new Post()) .syncAllListOptions(ListOptions.builder() .andQuery(Queries.equal(Post.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, true)) .build()) .build(); } void schedulePublishIfNecessary(Post post) { var labels = nullSafeLabels(post); // ensure the label is removed labels.remove(Post.SCHEDULING_PUBLISH_LABEL); final var now = Instant.now(); var publishTime = post.getSpec().getPublishTime(); if (post.isPublished() || publishTime == null) { return; } // expect to publish in the future if (isTrue(post.getSpec().getPublish()) && publishTime.isAfter(now)) { labels.put(Post.SCHEDULING_PUBLISH_LABEL, TRUE); // update post changes before requeue client.update(post); throw new RequeueException(Result.requeue(Duration.between(now, publishTime)), "Requeue for scheduled publish."); } } void subscribeNewCommentNotification(Post post) { var subscriber = new Subscription.Subscriber(); subscriber.setName(post.getSpec().getOwner()); var interestReason = new Subscription.InterestReason(); interestReason.setReasonType(NotificationReasonConst.NEW_COMMENT_ON_POST); interestReason.setExpression( "props.postOwner == '%s'".formatted(post.getSpec().getOwner())); notificationCenter.subscribe(subscriber, interestReason).block(BLOCKING_TIMEOUT); } private void publishPost(Post post, Set events) { var expectReleaseSnapshot = post.getSpec().getReleaseSnapshot(); if (StringUtils.isBlank(expectReleaseSnapshot)) { // Do nothing if release snapshot is not set return; } var annotations = post.getMetadata().getAnnotations(); var lastReleaseSnapshot = annotations.get(Post.LAST_RELEASED_SNAPSHOT_ANNO); if (post.isPublished() && Objects.equals(expectReleaseSnapshot, lastReleaseSnapshot)) { // If the release snapshot is not change return; } var status = post.getStatus(); // validate the release snapshot var snapshot = client.fetch(Snapshot.class, expectReleaseSnapshot); if (snapshot.isEmpty()) { Condition condition = Condition.builder() .type(PostPhase.FAILED.name()) .reason("SnapshotNotFound") .message( String.format("Snapshot [%s] not found for publish", expectReleaseSnapshot)) .status(ConditionStatus.FALSE) .lastTransitionTime(Instant.now()) .build(); status.getConditionsOrDefault().addAndEvictFIFO(condition); status.setPhase(PostPhase.FAILED.name()); return; } annotations.put(Post.LAST_RELEASED_SNAPSHOT_ANNO, expectReleaseSnapshot); status.setPhase(PostPhase.PUBLISHED.toString()); var condition = Condition.builder() .type(PostPhase.PUBLISHED.name()) .reason("Published") .message("Post published successfully.") .lastTransitionTime(Instant.now()) .status(ConditionStatus.TRUE) .build(); status.getConditionsOrDefault().addAndEvictFIFO(condition); var labels = post.getMetadata().getLabels(); labels.put(Post.PUBLISHED_LABEL, Boolean.TRUE.toString()); if (post.getSpec().getPublishTime() == null) { // TODO Set the field in creation hook in the future. post.getSpec().setPublishTime(Instant.now()); } status.setLastModifyTime(snapshot.get().getSpec().getLastModifyTime()); events.add(new PostPublishedEvent(this, post.getMetadata().getName())); } void unPublishPost(Post post, Set events) { if (!post.isPublished()) { return; } var labels = post.getMetadata().getLabels(); labels.put(Post.PUBLISHED_LABEL, Boolean.FALSE.toString()); final var status = post.getStatus(); var condition = new Condition(); condition.setType("CancelledPublish"); condition.setStatus(ConditionStatus.TRUE); condition.setReason(condition.getType()); condition.setMessage("CancelledPublish"); condition.setLastTransitionTime(Instant.now()); status.getConditionsOrDefault().addAndEvictFIFO(condition); status.setPhase(PostPhase.DRAFT.toString()); events.add(new PostUnpublishedEvent(this, post.getMetadata().getName())); } private void cleanUpResources(Post post) { // clean up snapshots final Ref ref = Ref.of(post); listSnapshots(ref).forEach(client::delete); // clean up comments commentService.removeBySubject(ref).block(BLOCKING_TIMEOUT); // delete counter counterService.deleteByName(MeterUtils.nameOf(Post.class, post.getMetadata().getName())) .block(BLOCKING_TIMEOUT); } private String getExcerpt(Post post) { Optional contentWrapper = postService.getContent(post.getSpec().getReleaseSnapshot(), post.getSpec().getBaseSnapshot()) .blockOptional(BLOCKING_TIMEOUT); if (contentWrapper.isEmpty()) { return StringUtils.EMPTY; } var content = contentWrapper.get(); if (StringUtils.isAnyBlank(content.getContent(), content.getRaw())) { return StringUtils.EMPTY; } var contentChecksum = Hashing.sha256().hashString(content.getContent(), UTF_8).toString(); var annotations = MetadataUtil.nullSafeAnnotations(post); var oldChecksum = annotations.get(Constant.CONTENT_CHECKSUM_ANNO); if (Objects.equals(oldChecksum, contentChecksum)) { return post.getStatusOrDefault().getExcerpt(); } // update the checksum and generate new excerpt annotations.put(Constant.CONTENT_CHECKSUM_ANNO, contentChecksum); var tags = listTagDisplayNames(post); var keywords = new HashSet<>(tags); keywords.add(post.getSpec().getTitle()); var context = new ExcerptGenerator.Context() .setRaw(content.getRaw()) .setContent(content.getContent()) .setRawType(content.getRawType()) .setKeywords(keywords) .setMaxLength(160); return extensionGetter.getEnabledExtension(ExcerptGenerator.class) .defaultIfEmpty(new DefaultExcerptGenerator()) .flatMap(generator -> generator.generate(context)) .onErrorResume(Throwable.class, e -> { log.error("Failed to generate excerpt for post [{}]", post.getMetadata().getName(), e); return Mono.empty(); }) .blockOptional(BLOCKING_TIMEOUT) .orElse(StringUtils.EMPTY); } private Set listTagDisplayNames(Post post) { return Optional.ofNullable(post.getSpec().getTags()) .map(tags -> client.listAll(Tag.class, ListOptions.builder() .fieldQuery(in("metadata.name", tags)) .build(), Sort.unsorted()) ) .stream() .flatMap(List::stream) .map(tag -> tag.getSpec().getDisplayName()) .collect(Collectors.toSet()); } static class DefaultExcerptGenerator implements ExcerptGenerator { @Override public Mono generate(Context context) { String shortHtmlContent = StringUtils.substring(context.getContent(), 0, 500); String text = Jsoup.parse(shortHtmlContent).text(); return Mono.just(StringUtils.substring(text, 0, 150)); } } List listSnapshots(Ref ref) { var snapshotListOptions = new ListOptions(); snapshotListOptions.setFieldSelector(FieldSelector.of( Queries.equal("spec.subjectRef", Snapshot.toSubjectRefKey(ref)))); return client.listAll(Snapshot.class, snapshotListOptions, Sort.unsorted()); } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/ReplyReconciler.java ================================================ package run.halo.app.core.reconciler; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.index.query.Queries.equal; import java.util.Set; import lombok.AllArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import run.halo.app.content.comment.ReplyNotificationSubscriptionHelper; import run.halo.app.core.extension.content.Reply; import run.halo.app.event.post.ReplyChangedEvent; import run.halo.app.event.post.ReplyCreatedEvent; import run.halo.app.event.post.ReplyDeletedEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; /** * Reconciler for {@link Reply}. * * @author guqing * @since 2.0.0 */ @Component @AllArgsConstructor public class ReplyReconciler implements Reconciler { protected static final String FINALIZER_NAME = "reply-protection"; private final ExtensionClient client; private final ApplicationEventPublisher eventPublisher; private final ReplyNotificationSubscriptionHelper replyNotificationSubscriptionHelper; @Override public Result reconcile(Request request) { client.fetch(Reply.class, request.name()) .ifPresent(reply -> { if (reply.getMetadata().getDeletionTimestamp() != null) { cleanUpResourcesAndRemoveFinalizer(request.name()); return; } if (addFinalizers(reply.getMetadata(), Set.of(FINALIZER_NAME))) { replyNotificationSubscriptionHelper.subscribeNewReplyReasonForReply(reply); client.update(reply); eventPublisher.publishEvent(new ReplyCreatedEvent(this, reply)); } if (reply.getSpec().getCreationTime() == null) { reply.getSpec().setCreationTime( defaultIfNull(reply.getSpec().getApprovedTime(), reply.getMetadata().getCreationTimestamp() ) ); } // version + 1 is required to truly equal version // as a version will be incremented after the update reply.getStatus().setObservedVersion(reply.getMetadata().getVersion() + 1); client.update(reply); eventPublisher.publishEvent(new ReplyChangedEvent(this, reply)); }); return new Result(false, null); } private void cleanUpResourcesAndRemoveFinalizer(String replyName) { client.fetch(Reply.class, replyName).ifPresent(reply -> { if (reply.getMetadata().getFinalizers() != null) { reply.getMetadata().getFinalizers().remove(FINALIZER_NAME); } client.update(reply); // on reply removing eventPublisher.publishEvent(new ReplyDeletedEvent(this, reply)); }); } @Override public Controller setupWith(ControllerBuilder builder) { var extension = new Reply(); return builder .extension(extension) .syncAllListOptions(ListOptions.builder() .andQuery(equal(Reply.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, true)) .build()) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/ReverseProxyReconciler.java ================================================ package run.halo.app.core.reconciler; import java.util.HashSet; import java.util.Map; import java.util.Objects; import java.util.Set; import org.springframework.stereotype.Component; import run.halo.app.core.extension.ReverseProxy; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.plugin.PluginConst; import run.halo.app.plugin.resources.ReverseProxyRouterFunctionRegistry; /** * Reconciler for {@link ReverseProxy}. * * @author guqing * @since 2.0.0 */ @Component public class ReverseProxyReconciler implements Reconciler { private static final String FINALIZER_NAME = "reverse-proxy-protection"; private final ExtensionClient client; private final ReverseProxyRouterFunctionRegistry routerFunctionRegistry; public ReverseProxyReconciler(ExtensionClient client, ReverseProxyRouterFunctionRegistry routerFunctionRegistry) { this.client = client; this.routerFunctionRegistry = routerFunctionRegistry; } @Override public Result reconcile(Request request) { return client.fetch(ReverseProxy.class, request.name()) .map(reverseProxy -> { if (isDeleted(reverseProxy)) { cleanUpResourcesAndRemoveFinalizer(request.name()); return new Result(false, null); } addFinalizerIfNecessary(reverseProxy); registerReverseProxy(reverseProxy); return new Result(false, null); }) .orElse(new Result(false, null)); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new ReverseProxy()) .build(); } private void registerReverseProxy(ReverseProxy reverseProxy) { String pluginId = getPluginId(reverseProxy); routerFunctionRegistry.register(pluginId, reverseProxy); } private void cleanUpResources(ReverseProxy reverseProxy) { String pluginId = getPluginId(reverseProxy); routerFunctionRegistry.remove(pluginId, reverseProxy.getMetadata().getName()); } private void addFinalizerIfNecessary(ReverseProxy oldReverseProxy) { Set finalizers = oldReverseProxy.getMetadata().getFinalizers(); if (finalizers != null && finalizers.contains(FINALIZER_NAME)) { return; } client.fetch(ReverseProxy.class, oldReverseProxy.getMetadata().getName()) .ifPresent(reverseProxy -> { Set newFinalizers = reverseProxy.getMetadata().getFinalizers(); if (newFinalizers == null) { newFinalizers = new HashSet<>(); reverseProxy.getMetadata().setFinalizers(newFinalizers); } newFinalizers.add(FINALIZER_NAME); client.update(reverseProxy); }); } private void cleanUpResourcesAndRemoveFinalizer(String name) { client.fetch(ReverseProxy.class, name).ifPresent(reverseProxy -> { cleanUpResources(reverseProxy); if (reverseProxy.getMetadata().getFinalizers() != null) { reverseProxy.getMetadata().getFinalizers().remove(FINALIZER_NAME); } client.update(reverseProxy); }); } private boolean isDeleted(ReverseProxy reverseProxy) { return reverseProxy.getMetadata().getDeletionTimestamp() != null; } private String getPluginId(ReverseProxy reverseProxy) { Map labels = reverseProxy.getMetadata().getLabels(); if (labels == null) { return PluginConst.SYSTEM_PLUGIN_NAME; } return Objects.toString(labels.get(PluginConst.PLUGIN_NAME_LABEL_NAME), PluginConst.SYSTEM_PLUGIN_NAME); } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/RoleReconciler.java ================================================ package run.halo.app.core.reconciler; import static java.util.Objects.deepEquals; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import run.halo.app.core.extension.Role; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler.Request; /** * Role reconcile. * * @author guqing * @since 2.0.0 */ @Slf4j @Component public class RoleReconciler implements Reconciler { private final ExtensionClient client; public RoleReconciler(ExtensionClient client) { this.client = client; } @Override public Result reconcile(Request request) { client.fetch(Role.class, request.name()) .ifPresent(role -> { Map annotations = MetadataUtil.nullSafeAnnotations(role); // override dependency rules to annotations annotations.put(Role.ROLE_DEPENDENCY_RULES, "[]"); annotations.put(Role.UI_PERMISSIONS_AGGREGATED_ANNO, "[]"); updateLabelsAndAnnotations(role); }); return new Result(false, null); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new Role()) .build(); } private void updateLabelsAndAnnotations(Role role) { var annotations = role.getMetadata().getAnnotations(); var labels = role.getMetadata().getLabels(); client.fetch(Role.class, role.getMetadata().getName()) .filter(freshRole -> !deepEquals(annotations, freshRole.getMetadata().getAnnotations()) || deepEquals(labels, freshRole.getMetadata().getLabels())) .ifPresent(freshRole -> { freshRole.getMetadata().setAnnotations(annotations); freshRole.getMetadata().setLabels(labels); client.update(freshRole); }); } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/SinglePageReconciler.java ================================================ package run.halo.app.core.reconciler; import static java.nio.charset.StandardCharsets.UTF_8; import static org.springframework.web.util.UriUtils.encodePath; import com.google.common.hash.Hashing; import java.time.Duration; import java.time.Instant; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.jsoup.Jsoup; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import reactor.core.publisher.Mono; import run.halo.app.content.ContentWrapper; import run.halo.app.content.ExcerptGenerator; import run.halo.app.content.NotificationReasonConst; import run.halo.app.content.SinglePageService; import run.halo.app.content.comment.CommentService; import run.halo.app.core.counter.CounterService; import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.content.Constant; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionOperator; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.Ref; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.index.query.Queries; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionList; import run.halo.app.infra.ConditionStatus; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.notification.NotificationCenter; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** *

Reconciler for {@link SinglePage}.

* *

things to do:

*
    * 1. generate permalink * 2. generate excerpt if auto generate is enabled *
* * @author guqing * @since 2.0.0 */ @Slf4j @AllArgsConstructor @Component public class SinglePageReconciler implements Reconciler { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private static final String FINALIZER_NAME = "single-page-protection"; private final ExtensionClient client; private final SinglePageService singlePageService; private final CounterService counterService; private final CommentService commentService; private final ExtensionGetter extensionGetter; private final ExternalUrlSupplier externalUrlSupplier; private final NotificationCenter notificationCenter; @Override public Result reconcile(Request request) { client.fetch(SinglePage.class, request.name()) .ifPresent(singlePage -> { if (ExtensionOperator.isDeleted(singlePage)) { cleanUpResourcesAndRemoveFinalizer(request.name()); return; } if (ExtensionUtil.addFinalizers(singlePage.getMetadata(), Set.of(FINALIZER_NAME))) { client.update(singlePage); } subscribeNewCommentNotification(singlePage); // reconcile spec first reconcileSpec(request.name()); // then reconcileMetadata(request.name()); reconcileStatus(request.name()); }); return new Result(false, null); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new SinglePage()) .build(); } void subscribeNewCommentNotification(SinglePage page) { var subscriber = new Subscription.Subscriber(); subscriber.setName(page.getSpec().getOwner()); var interestReason = new Subscription.InterestReason(); interestReason.setReasonType(NotificationReasonConst.NEW_COMMENT_ON_PAGE); interestReason.setExpression( "props.pageOwner == '%s'".formatted(page.getSpec().getOwner())); notificationCenter.subscribe(subscriber, interestReason).block(BLOCKING_TIMEOUT); } private void reconcileSpec(String name) { client.fetch(SinglePage.class, name).ifPresent(page -> { if (page.isPublished() && page.getSpec().getPublishTime() == null) { page.getSpec().setPublishTime(Instant.now()); } // un-publish if necessary if (page.isPublished() && Objects.equals(false, page.getSpec().getPublish())) { unPublish(name); return; } try { publishPage(name); } catch (Throwable e) { publishFailed(name, e); throw e; } }); } private void publishPage(String name) { client.fetch(SinglePage.class, name) .filter(page -> Objects.equals(true, page.getSpec().getPublish())) .ifPresent(page -> { Map annotations = MetadataUtil.nullSafeAnnotations(page); String lastReleasedSnapshot = annotations.get(Post.LAST_RELEASED_SNAPSHOT_ANNO); String releaseSnapshot = page.getSpec().getReleaseSnapshot(); if (StringUtils.isBlank(releaseSnapshot)) { return; } // do nothing if release snapshot is not changed and page is published if (page.isPublished() && StringUtils.equals(lastReleasedSnapshot, releaseSnapshot)) { return; } SinglePage.SinglePageStatus status = page.getStatusOrDefault(); // validate release snapshot Optional releasedSnapshotOpt = client.fetch(Snapshot.class, releaseSnapshot); if (releasedSnapshotOpt.isEmpty()) { Condition condition = Condition.builder() .type(Post.PostPhase.FAILED.name()) .reason("SnapshotNotFound") .message( String.format("Snapshot [%s] not found for publish", releaseSnapshot)) .status(ConditionStatus.FALSE) .lastTransitionTime(Instant.now()) .build(); status.getConditionsOrDefault().addAndEvictFIFO(condition); status.setPhase(Post.PostPhase.FAILED.name()); client.update(page); return; } // do publish annotations.put(SinglePage.LAST_RELEASED_SNAPSHOT_ANNO, releaseSnapshot); status.setPhase(Post.PostPhase.PUBLISHED.name()); Condition condition = Condition.builder() .type(Post.PostPhase.PUBLISHED.name()) .reason("Published") .message("SinglePage published successfully.") .lastTransitionTime(Instant.now()) .status(ConditionStatus.TRUE) .build(); status.getConditionsOrDefault().addAndEvictFIFO(condition); SinglePage.changePublishedState(page, true); if (page.getSpec().getPublishTime() == null) { page.getSpec().setPublishTime(Instant.now()); } // populate lastModifyTime status.setLastModifyTime(releasedSnapshotOpt.get().getSpec().getLastModifyTime()); client.update(page); }); } private void unPublish(String name) { client.fetch(SinglePage.class, name).ifPresent(page -> { final SinglePage oldPage = JsonUtils.deepCopy(page); SinglePage.changePublishedState(page, false); final SinglePage.SinglePageStatus status = page.getStatusOrDefault(); Condition condition = new Condition(); condition.setType("CancelledPublish"); condition.setStatus(ConditionStatus.TRUE); condition.setReason(condition.getType()); condition.setMessage("CancelledPublish"); condition.setLastTransitionTime(Instant.now()); status.getConditionsOrDefault().addAndEvictFIFO(condition); status.setPhase(Post.PostPhase.DRAFT.name()); if (!oldPage.equals(page)) { client.update(page); } }); } private void publishFailed(String name, Throwable error) { Assert.notNull(name, "Name must not be null"); Assert.notNull(error, "Error must not be null"); client.fetch(SinglePage.class, name).ifPresent(page -> { final SinglePage oldPage = JsonUtils.deepCopy(page); SinglePage.SinglePageStatus status = page.getStatusOrDefault(); Post.PostPhase phase = Post.PostPhase.FAILED; status.setPhase(phase.name()); final ConditionList conditions = status.getConditionsOrDefault(); Condition condition = Condition.builder() .type(phase.name()) .reason("PublishFailed") .message(error.getMessage()) .lastTransitionTime(Instant.now()) .status(ConditionStatus.FALSE) .build(); conditions.addAndEvictFIFO(condition); page.setStatus(status); if (!oldPage.equals(page)) { client.update(page); } }); } private void cleanUpResources(SinglePage singlePage) { // clean up snapshot Ref ref = Ref.of(singlePage); listSnapshots(ref).forEach(client::delete); // clean up comments commentService.removeBySubject(ref).block(BLOCKING_TIMEOUT); // delete counter for single page counterService.deleteByName( MeterUtils.nameOf(SinglePage.class, singlePage.getMetadata().getName())) .block(BLOCKING_TIMEOUT); } private void cleanUpResourcesAndRemoveFinalizer(String pageName) { client.fetch(SinglePage.class, pageName).ifPresent(singlePage -> { cleanUpResources(singlePage); if (singlePage.getMetadata().getFinalizers() != null) { singlePage.getMetadata().getFinalizers().remove(FINALIZER_NAME); } client.update(singlePage); }); } private void reconcileMetadata(String name) { client.fetch(SinglePage.class, name).ifPresent(singlePage -> { final SinglePage oldPage = JsonUtils.deepCopy(singlePage); SinglePage.SinglePageSpec spec = singlePage.getSpec(); // handle logic delete Map labels = MetadataUtil.nullSafeLabels(singlePage); if (isDeleted(singlePage)) { labels.put(SinglePage.DELETED_LABEL, Boolean.TRUE.toString()); } else { labels.put(SinglePage.DELETED_LABEL, Boolean.FALSE.toString()); } labels.put(SinglePage.VISIBLE_LABEL, Objects.requireNonNullElse(spec.getVisible(), Post.VisibleEnum.PUBLIC).name()); labels.put(SinglePage.OWNER_LABEL, spec.getOwner()); if (!labels.containsKey(SinglePage.PUBLISHED_LABEL)) { labels.put(Post.PUBLISHED_LABEL, Boolean.FALSE.toString()); } if (!oldPage.equals(singlePage)) { client.update(singlePage); } }); } String createPermalink(SinglePage page) { var permalink = encodePath(page.getSpec().getSlug(), UTF_8); permalink = StringUtils.prependIfMissing(permalink, "/"); return externalUrlSupplier.get().resolve(permalink).normalize().toString(); } private void reconcileStatus(String name) { client.fetch(SinglePage.class, name).ifPresent(singlePage -> { final SinglePage oldPage = JsonUtils.deepCopy(singlePage); singlePage.getStatusOrDefault() .setPermalink(createPermalink(singlePage)); SinglePage.SinglePageSpec spec = singlePage.getSpec(); SinglePage.SinglePageStatus status = singlePage.getStatusOrDefault(); if (status.getPhase() == null) { status.setPhase(Post.PostPhase.DRAFT.name()); } // handle excerpt Post.Excerpt excerpt = spec.getExcerpt(); if (excerpt == null) { excerpt = new Post.Excerpt(); excerpt.setAutoGenerate(true); spec.setExcerpt(excerpt); } if (excerpt.getAutoGenerate()) { status.setExcerpt(getExcerpt(singlePage)); } else { status.setExcerpt(excerpt.getRaw()); } // handle contributors String headSnapshot = singlePage.getSpec().getHeadSnapshot(); List contributors = listSnapshots(Ref.of(singlePage)) .stream() .peek(snapshot -> { snapshot.getSpec().setContentPatch(StringUtils.EMPTY); snapshot.getSpec().setRawPatch(StringUtils.EMPTY); }) .map(snapshot -> { Set usernames = snapshot.getSpec().getContributors(); return Objects.requireNonNullElseGet(usernames, () -> new HashSet()); }) .flatMap(Set::stream) .distinct() .sorted() .toList(); status.setContributors(contributors); // update in progress status String releaseSnapshot = singlePage.getSpec().getReleaseSnapshot(); status.setInProgress(!StringUtils.equals(releaseSnapshot, headSnapshot)); if (singlePage.isPublished() && status.getLastModifyTime() == null) { client.fetch(Snapshot.class, singlePage.getSpec().getReleaseSnapshot()) .ifPresent(releasedSnapshot -> status.setLastModifyTime(releasedSnapshot.getSpec().getLastModifyTime())); } if (!oldPage.equals(singlePage)) { client.update(singlePage); } }); } private String getExcerpt(SinglePage singlePage) { Optional contentWrapper = singlePageService.getContent(singlePage.getSpec().getReleaseSnapshot(), singlePage.getSpec().getBaseSnapshot()) .blockOptional(BLOCKING_TIMEOUT); if (contentWrapper.isEmpty()) { return StringUtils.EMPTY; } var content = contentWrapper.get(); var contentChecksum = Hashing.sha256().hashString(content.getContent(), UTF_8).toString(); var annotations = MetadataUtil.nullSafeAnnotations(singlePage); var oldChecksum = annotations.get(Constant.CONTENT_CHECKSUM_ANNO); if (Objects.equals(oldChecksum, contentChecksum)) { return singlePage.getStatusOrDefault().getExcerpt(); } // update the checksum and generate new excerpt annotations.put(Constant.CONTENT_CHECKSUM_ANNO, contentChecksum); var context = new ExcerptGenerator.Context() .setRaw(content.getRaw()) .setContent(content.getContent()) .setRaw(content.getRawType()) .setKeywords(Set.of()) .setMaxLength(160); return extensionGetter.getEnabledExtension(ExcerptGenerator.class) .defaultIfEmpty(new DefaultExcerptGenerator()) .flatMap(generator -> generator.generate(context)) .onErrorResume(Throwable.class, e -> { log.error("Failed to generate excerpt for single page [{}]", singlePage.getMetadata().getName(), e); return Mono.empty(); }) .blockOptional(BLOCKING_TIMEOUT) .orElse(StringUtils.EMPTY); } static class DefaultExcerptGenerator implements ExcerptGenerator { @Override public Mono generate(Context context) { String shortHtmlContent = StringUtils.substring(context.getContent(), 0, 500); String text = Jsoup.parse(shortHtmlContent).text(); return Mono.just(StringUtils.substring(text, 0, 150)); } } private boolean isDeleted(SinglePage singlePage) { return Objects.equals(true, singlePage.getSpec().getDeleted()) || singlePage.getMetadata().getDeletionTimestamp() != null; } List listSnapshots(Ref ref) { var snapshotListOptions = new ListOptions(); snapshotListOptions.setFieldSelector(FieldSelector.of( Queries.equal("spec.subjectRef", Snapshot.toSubjectRefKey(ref)))); return client.listAll(Snapshot.class, snapshotListOptions, Sort.unsorted()); } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/SystemConfigReconciler.java ================================================ package run.halo.app.core.reconciler; import static java.util.Objects.requireNonNullElse; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.infra.utils.SystemConfigUtils.mergeMap; import com.fasterxml.jackson.core.JsonProcessingException; import java.util.Map; import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.Nullable; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionMatcher; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListOptions; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.SystemConfigChangedEvent; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.SystemConfigUtils; @Slf4j @Component @RequiredArgsConstructor class SystemConfigReconciler implements Reconciler { private final ExtensionClient client; private final ApplicationEventPublisher eventPublisher; @Override public Result reconcile(Request request) { Assert.state( Objects.equals(SystemSetting.SYSTEM_CONFIG, request.name()), "Only system config reconciler is supported to reconcile system config." ); client.fetch(ConfigMap.class, request.name()) .ifPresent(configMap -> { if (ExtensionUtil.isDeleted(configMap)) { log.warn("System config was attempted to be deleted"); return; } // calculate if the configMap has changed // and publish event if changed var dataSnapshot = SystemConfigUtils.getDataSnapshot(configMap); if (SystemConfigUtils.populateChecksum(configMap)) { SystemConfigUtils.updateDataSnapshot(configMap); client.update(configMap); log.info("System config has been detected as changed"); eventPublisher.publishEvent( computeChangedEvent(configMap, dataSnapshot) ); } // do nothing if not changed }); return null; } @Override public Controller setupWith(ControllerBuilder builder) { ExtensionMatcher matcher = extension -> Objects.equals(extension.getMetadata().getName(), SystemSetting.SYSTEM_CONFIG); return builder.extension(new ConfigMap()) .syncAllOnStart(true) .syncAllListOptions(ListOptions.builder() .fieldQuery(equal("metadata.name", SystemSetting.SYSTEM_CONFIG)) .build() ) .onAddMatcher(matcher) .onUpdateMatcher(matcher) .onDeleteMatcher(matcher) .build(); } private SystemConfigChangedEvent computeChangedEvent(ConfigMap configMap, @Nullable Map oldData) { return client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG_DEFAULT) .map(defaultConfigMap -> { var defaultData = requireNonNullElse(defaultConfigMap.getData(), Map.of()); try { var mergedOldData = mergeMap( defaultData, requireNonNullElse(oldData, Map.of()) ); var mergedNewData = mergeMap( defaultData, requireNonNullElse(configMap.getData(), Map.of()) ); return new SystemConfigChangedEvent(this, mergedOldData, mergedNewData); } catch (JsonProcessingException e) { throw new RuntimeException(e); } }) .orElseGet(() -> new SystemConfigChangedEvent( this, oldData, requireNonNullElse(configMap.getData(), Map.of()) )); } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/TagReconciler.java ================================================ package run.halo.app.core.reconciler; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import static run.halo.app.extension.index.query.Queries.equal; import java.util.Map; import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import run.halo.app.content.permalinks.TagPermalinkPolicy; import run.halo.app.core.extension.content.Constant; import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; /** * Reconciler for {@link Tag}. * * @author guqing * @since 2.0.0 */ @Component @RequiredArgsConstructor public class TagReconciler implements Reconciler { static final String FINALIZER_NAME = "tag-protection"; private final ExtensionClient client; private final TagPermalinkPolicy tagPermalinkPolicy; @Override public Result reconcile(Request request) { client.fetch(Tag.class, request.name()) .ifPresent(tag -> { if (ExtensionUtil.isDeleted(tag)) { if (removeFinalizers(tag.getMetadata(), Set.of(FINALIZER_NAME))) { client.update(tag); } return; } addFinalizers(tag.getMetadata(), Set.of(FINALIZER_NAME)); Map annotations = MetadataUtil.nullSafeAnnotations(tag); if (!annotations.containsKey(Constant.PERMALINK_PATTERN_ANNO)) { var newPattern = tagPermalinkPolicy.pattern(); annotations.put(Constant.PERMALINK_PATTERN_ANNO, newPattern); } var status = tag.getStatusOrDefault(); String permalink = tagPermalinkPolicy.permalink(tag); status.setPermalink(permalink); if (status.getPostCount() == null) { status.setPostCount(0); } if (status.getVisiblePostCount() == null) { status.setVisiblePostCount(0); } // Update the observed version. status.setObservedVersion(tag.getMetadata().getVersion() + 1); client.update(tag); }); return Result.doNotRetry(); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new Tag()) .syncAllListOptions(ListOptions.builder() .andQuery(equal(Tag.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, true)) .build()) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/ThemeReconciler.java ================================================ package run.halo.app.core.reconciler; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.isDeleted; import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import java.io.IOException; import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.core.retry.RetryException; import org.springframework.core.retry.RetryPolicy; import org.springframework.core.retry.RetryTemplate; import org.springframework.stereotype.Component; import org.springframework.util.FileSystemUtils; import org.springframework.util.backoff.FixedBackOff; import reactor.core.Exceptions; import run.halo.app.core.extension.AnnotationSetting; import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Theme; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler.Request; import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionStatus; import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.infra.ThemeRootGetter; import run.halo.app.infra.exception.ThemeUninstallException; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.infra.utils.SettingUtils; import run.halo.app.infra.utils.VersionUtils; import run.halo.app.theme.TemplateEngineManager; /** * Reconciler for theme. * * @author guqing * @since 2.0.0 */ @Component @RequiredArgsConstructor public class ThemeReconciler implements Reconciler { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private static final String FINALIZER_NAME = "theme-protection"; private final ExtensionClient client; private final ThemeRootGetter themeRoot; private final SystemVersionSupplier systemVersionSupplier; private final TemplateEngineManager templateEngineManager; private RetryTemplate retryTemplate = new RetryTemplate(RetryPolicy.builder() .backOff(new FixedBackOff(300, 20)) .predicate(IllegalStateException.class::isInstance) .build()); /** * Set retry template. Only for testing purpose. * * @param retryTemplate the retry template */ void setRetryTemplate(RetryTemplate retryTemplate) { this.retryTemplate = retryTemplate; } @Override public Result reconcile(Request request) { client.fetch(Theme.class, request.name()) .ifPresent(theme -> { if (isDeleted(theme)) { if (removeFinalizers(theme.getMetadata(), Set.of(FINALIZER_NAME))) { cleanUpResources(theme); client.update(theme); } return; } addFinalizers(theme.getMetadata(), Set.of(FINALIZER_NAME)); themeSettingDefaultConfig(theme); reconcileStatus(theme); client.update(theme); }); return new Result(false, null); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new Theme()) .build(); } void reconcileStatus(Theme theme) { var status = theme.getStatus(); if (status == null) { status = new Theme.ThemeStatus(); theme.setStatus(status); } var name = theme.getMetadata().getName(); var themePath = themeRoot.get().resolve(name); status.setLocation(themePath.toAbsolutePath().toString()); status.setPhase(Theme.ThemePhase.READY); var conditionBuilder = Condition.builder() .type(Theme.ThemePhase.READY.name()) .status(ConditionStatus.TRUE) .reason(Theme.ThemePhase.READY.name()) .message(StringUtils.EMPTY) .lastTransitionTime(Instant.now()); // Check if this theme version is match requires param. var normalVersion = systemVersionSupplier.get().toStableVersion().toString(); var requires = theme.getSpec().getRequires(); if (!VersionUtils.satisfiesRequires(normalVersion, requires)) { status.setPhase(Theme.ThemePhase.FAILED); conditionBuilder .type(Theme.ThemePhase.FAILED.name()) .status(ConditionStatus.FALSE) .reason("UnsatisfiedRequiresVersion") .message(String.format( "Theme requires a minimum system version of [%s], and you have [%s].", requires, normalVersion)); } Theme.nullSafeConditionList(theme).addAndEvictFIFO(conditionBuilder.build()); } private void themeSettingDefaultConfig(Theme theme) { var spec = theme.getSpec(); var settingName = spec.getSettingName(); if (StringUtils.isBlank(settingName)) { return; } var configMapName = spec.getConfigMapName(); if (StringUtils.isBlank(configMapName)) { configMapName = UUID.randomUUID().toString(); } spec.setConfigMapName(configMapName); SettingUtils.createOrUpdateConfigMap(client, settingName, configMapName); } private void cleanUpResources(Theme theme) { reconcileThemeDeletion(theme); } private void reconcileThemeDeletion(Theme theme) { templateEngineManager.clearCache(theme.getMetadata().getName()).block(BLOCKING_TIMEOUT); // delete theme setting form var settingName = theme.getSpec().getSettingName(); if (StringUtils.isNotBlank(settingName)) { client.fetch(Setting.class, settingName).ifPresent(client::delete); try { retryTemplate.execute(() -> { client.fetch(Setting.class, settingName).ifPresent(setting -> { throw new IllegalStateException("Waiting for setting to be deleted."); }); return null; }); } catch (RetryException e) { throw Exceptions.propagate(e); } } // delete annotation setting deleteAnnotationSettings(theme.getMetadata().getName()); deleteThemeFiles(theme); } private void deleteAnnotationSettings(String themeName) { var result = listAnnotationSettingsByThemeName(themeName); for (AnnotationSetting annotationSetting : result) { client.delete(annotationSetting); } try { retryTemplate.execute(() -> { var annotationSettings = listAnnotationSettingsByThemeName(themeName); if (annotationSettings.isEmpty()) { return null; } throw new IllegalStateException("Waiting for annotation settings to be deleted."); }); } catch (RetryException e) { throw Exceptions.propagate(e); } } private List listAnnotationSettingsByThemeName(String themeName) { return client.list(AnnotationSetting.class, annotationSetting -> { Map labels = MetadataUtil.nullSafeLabels(annotationSetting); return themeName.equals(labels.get(Theme.THEME_NAME_LABEL)); }, null); } private void deleteThemeFiles(Theme theme) { var themeDir = themeRoot.get().resolve(theme.getMetadata().getName()); try { FileSystemUtils.deleteRecursively(themeDir); } catch (IOException e) { throw new ThemeUninstallException("Failed to delete theme files.", e); } } } ================================================ FILE: application/src/main/java/run/halo/app/core/reconciler/UserReconciler.java ================================================ package run.halo.app.core.reconciler; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.defaultSort; import static run.halo.app.extension.ExtensionUtil.isDeleted; import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import static run.halo.app.extension.index.query.Queries.equal; import java.net.URI; import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import org.springframework.web.util.UriComponentsBuilder; import run.halo.app.core.extension.User; import run.halo.app.core.extension.UserConnection; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler.Request; import run.halo.app.extension.controller.RequeueException; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.ReactiveUtils; @Slf4j @Component @RequiredArgsConstructor public class UserReconciler implements Reconciler { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private static final String FINALIZER_NAME = "user-protection"; private final ExtensionClient client; private final ExternalUrlSupplier externalUrlSupplier; private final RoleService roleService; private final AttachmentService attachmentService; private final UserService userService; @Override public Result reconcile(Request request) { client.fetch(User.class, request.name()).ifPresent(user -> { if (isDeleted(user)) { deleteUserConnections(request.name()); removeFinalizers(user.getMetadata(), Set.of(FINALIZER_NAME)); client.update(user); return; } addFinalizers(user.getMetadata(), Set.of(FINALIZER_NAME)); ensureRoleNamesAnno(user); updatePermalink(user); handleAvatar(user); checkVerifiedEmail(user); client.update(user); }); return new Result(false, null); } private void checkVerifiedEmail(User user) { var username = user.getMetadata().getName(); if (!user.getSpec().isEmailVerified()) { return; } var email = user.getSpec().getEmail(); if (StringUtils.isBlank(email)) { return; } if (checkEmailInUse(username, email)) { user.getSpec().setEmailVerified(false); } } private Boolean checkEmailInUse(String username, String email) { return userService.listByEmail(email) .filter(existUser -> existUser.getSpec().isEmailVerified()) .filter(existUser -> !existUser.getMetadata().getName().equals(username)) .hasElements() .blockOptional(BLOCKING_TIMEOUT) .orElse(false); } private void handleAvatar(User user) { var annotations = Optional.ofNullable(user.getMetadata().getAnnotations()) .orElseGet(HashMap::new); user.getMetadata().setAnnotations(annotations); var avatarAttachmentName = annotations.get(User.AVATAR_ATTACHMENT_NAME_ANNO); var oldAvatarAttachmentName = annotations.get(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO); // remove old avatar if needed if (StringUtils.isNotBlank(oldAvatarAttachmentName) && !StringUtils.equals(avatarAttachmentName, oldAvatarAttachmentName)) { client.fetch(Attachment.class, oldAvatarAttachmentName) .ifPresent(client::delete); annotations.remove(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO); } var spec = user.getSpec(); if (StringUtils.isBlank(avatarAttachmentName)) { if (StringUtils.isNotBlank(spec.getAvatar())) { log.info("Remove avatar for user({})", user.getMetadata().getName()); } spec.setAvatar(null); return; } client.fetch(Attachment.class, avatarAttachmentName) .flatMap(attachment -> attachmentService.getPermalink(attachment) .blockOptional(BLOCKING_TIMEOUT) ) .map(URI::toString) .ifPresentOrElse(avatar -> { if (!Objects.equals(avatar, spec.getAvatar())) { log.info( "Update avatar for user({}) to {}", user.getMetadata().getName(), avatar ); } spec.setAvatar(avatar); // reset last avatar annotations.put( User.LAST_AVATAR_ATTACHMENT_NAME_ANNO, avatarAttachmentName ); }, () -> { throw new RequeueException( new Result(true, null), "Avatar permalink(%s) is not available yet." .formatted(avatarAttachmentName) ); }); } private void ensureRoleNamesAnno(User user) { roleService.getRolesByUsername(user.getMetadata().getName()) .collectList() .map(JsonUtils::objectToJson) .doOnNext(roleNamesJson -> { var annotations = Optional.ofNullable(user.getMetadata().getAnnotations()) .orElseGet(HashMap::new); user.getMetadata().setAnnotations(annotations); annotations.put(User.ROLE_NAMES_ANNO, roleNamesJson); }) .block(BLOCKING_TIMEOUT); } private void updatePermalink(User user) { var name = user.getMetadata().getName(); if (AnonymousUserConst.isAnonymousUser(name)) { // anonymous user is not allowed to have permalink return; } var status = Optional.ofNullable(user.getStatus()) .orElseGet(User.UserStatus::new); user.setStatus(status); status.setPermalink(getUserPermalink(user)); } private String getUserPermalink(User user) { return UriComponentsBuilder.fromUri(externalUrlSupplier.get()) .pathSegment("authors", user.getMetadata().getName()) .toUriString(); } void deleteUserConnections(String username) { var userConnections = listConnectionsByUsername(username); if (CollectionUtils.isEmpty(userConnections)) { return; } userConnections.forEach(client::delete); throw new RequeueException(new Result(true, null), "User connections are not deleted yet"); } List listConnectionsByUsername(String username) { var listOptions = ListOptions.builder() .andQuery(equal("spec.username", username)) .build(); return client.listAll(UserConnection.class, listOptions, defaultSort()); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new User()) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/DefaultRoleService.java ================================================ package run.halo.app.core.user.service; import static run.halo.app.extension.ExtensionUtil.defaultSort; import static run.halo.app.extension.ExtensionUtil.notDeleting; import static run.halo.app.security.authorization.AuthorityUtils.containsSuperRole; import com.fasterxml.jackson.core.type.TypeReference; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.RoleBinding.RoleRef; import run.halo.app.core.extension.RoleBinding.Subject; import run.halo.app.core.extension.User; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.Queries; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.security.SuperAdminInitializer; /** * @author guqing * @since 2.0.0 */ @Slf4j @Service public class DefaultRoleService implements RoleService { private final ReactiveExtensionClient client; public DefaultRoleService(ReactiveExtensionClient client) { this.client = client; } private Flux listRoleRefs(Subject subject) { return listRoleBindings(subject).map(RoleBinding::getRoleRef); } @Override public Flux listRoleBindings(Subject subject) { var listOptions = ListOptions.builder() .andQuery(notDeleting()) .andQuery(Queries.in("subjects", subject.toString())) .build(); return client.listAll(RoleBinding.class, listOptions, defaultSort()); } @Override public Flux getRolesByUsername(String username) { return listRoleRefs(toUserSubject(username)) .filter(DefaultRoleService::isRoleKind) .map(RoleRef::getName); } @Override public Mono>> getRolesByUsernames(Collection usernames) { if (CollectionUtils.isEmpty(usernames)) { return Mono.just(Map.of()); } var subjects = usernames.stream().map(DefaultRoleService::toUserSubject) .map(Object::toString) .collect(Collectors.toSet()); var listOptions = ListOptions.builder() .andQuery(notDeleting()) .andQuery(Queries.in("subjects", subjects)) .build(); return client.listAll(RoleBinding.class, listOptions, defaultSort()) .collect(HashMap::new, (map, roleBinding) -> { for (Subject subject : roleBinding.getSubjects()) { if (subjects.contains(subject.toString())) { var username = subject.getName(); var roleRef = roleBinding.getRoleRef(); if (isRoleKind(roleRef)) { var roleName = roleRef.getName(); map.computeIfAbsent(username, k -> new HashSet<>()).add(roleName); } } } }); } @Override public Mono contains(Collection source, Collection candidates) { if (source.contains(SuperAdminInitializer.SUPER_ROLE_NAME)) { return Mono.just(true); } return listWithDependencies(new HashSet<>(source), shouldExcludeHidden(false)) .map(role -> role.getMetadata().getName()) .collect(Collectors.toSet()) .map(roleNames -> roleNames.containsAll(candidates)); } @Override public Flux listPermissions(Set names) { if (containsSuperRole(names)) { // search all permissions return client.listAll(Role.class, shouldExcludeHidden(true), ExtensionUtil.defaultSort()); } return listWithDependencies(names, shouldExcludeHidden(true)); } @Override public Flux listDependenciesFlux(Set names) { return listWithDependencies(names, shouldExcludeHidden(false)); } private static boolean isRoleKind(RoleRef roleRef) { return Role.GROUP.equals(roleRef.getApiGroup()) && Role.KIND.equals(roleRef.getKind()); } private static Subject toUserSubject(String username) { var subject = new Subject(); subject.setApiGroup(User.GROUP); subject.setKind(User.KIND); subject.setName(username); return subject; } private Flux listRoles(Set names, ListOptions additionalListOptions) { if (CollectionUtils.isEmpty(names)) { return Flux.empty(); } var listOptions = Optional.ofNullable(additionalListOptions) .map(ListOptions::builder) .orElseGet(ListOptions::builder) .andQuery(notDeleting()) .andQuery(Queries.in("metadata.name", names)) .build(); return client.listAll(Role.class, listOptions, ExtensionUtil.defaultSort()); } private static ListOptions shouldExcludeHidden(boolean excludeHidden) { if (!excludeHidden) { return null; } return ListOptions.builder().labelSelector() .notEq(Role.HIDDEN_LABEL_NAME, Boolean.TRUE.toString()) .end() .build(); } private Flux listWithDependencies(Set names, ListOptions additionalListOptions) { var visited = new HashSet(); return listRoles(names, additionalListOptions) .expand(role -> { var name = role.getMetadata().getName(); if (visited.contains(name)) { return Flux.empty(); } if (log.isTraceEnabled()) { log.trace("Expand role: {}", role.getMetadata().getName()); } visited.add(name); var annotations = MetadataUtil.nullSafeAnnotations(role); var dependenciesJson = annotations.get(Role.ROLE_DEPENDENCIES_ANNO); var dependencies = stringToList(dependenciesJson); return Flux.fromIterable(dependencies) .filter(dep -> !visited.contains(dep)) .collect(Collectors.toSet()) .flatMapMany(deps -> listRoles(deps, additionalListOptions)); }) .concatWith(Flux.defer(() -> listAggregatedRoles(visited, additionalListOptions))); } private Flux listAggregatedRoles(Set roleNames, ListOptions additionalListOptions) { if (CollectionUtils.isEmpty(roleNames)) { return Flux.empty(); } var listOptions = Optional.ofNullable(additionalListOptions) .map(ListOptions::builder) .orElseGet(ListOptions::builder) .andQuery(Queries.in("labels.aggregateToRoles", roleNames)) .build(); return client.listAll(Role.class, listOptions, ExtensionUtil.defaultSort()); } Predicate getRoleBindingPredicate(Subject targetSubject) { return roleBinding -> { List subjects = roleBinding.getSubjects(); for (Subject subject : subjects) { return matchSubject(targetSubject, subject); } return false; }; } private static boolean matchSubject(Subject targetSubject, Subject subject) { if (targetSubject == null || subject == null) { return false; } return StringUtils.equals(targetSubject.getKind(), subject.getKind()) && StringUtils.equals(targetSubject.getName(), subject.getName()) && StringUtils.defaultString(targetSubject.getApiGroup()) .equals(StringUtils.defaultString(subject.getApiGroup())); } @Override public Flux list(Set roleNames) { return list(roleNames, false); } @Override public Flux list(Set roleNames, boolean excludeHidden) { if (CollectionUtils.isEmpty(roleNames)) { return Flux.empty(); } var builder = ListOptions.builder() .andQuery(notDeleting()) .andQuery(Queries.in("metadata.name", roleNames)); if (excludeHidden) { builder.labelSelector().notEq(Role.HIDDEN_LABEL_NAME, Boolean.TRUE.toString()); } return client.listAll(Role.class, builder.build(), defaultSort()); } @NonNull private List stringToList(String str) { if (StringUtils.isBlank(str)) { return Collections.emptyList(); } return JsonUtils.jsonToObject(str, new TypeReference<>() { }); } } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/EmailPasswordRecoveryService.java ================================================ package run.halo.app.core.user.service; import reactor.core.publisher.Mono; import run.halo.app.infra.exception.AccessDeniedException; /** * An interface for email password recovery. * * @author guqing * @since 2.11.0 */ public interface EmailPasswordRecoveryService { /** *

Send password reset email.

* if the user does not exist, it will return {@link Mono#empty()} * if the user exists, but the email is not the same, it will return {@link Mono#empty()} * * @param username username to request password reset * @param email email to match the user with the username * @return {@link Mono#empty()} if the user does not exist, or the email is not the same. */ Mono sendPasswordResetEmail(String username, String email); Mono sendPasswordResetEmail(String email); /** *

Reset password by token.

* if the token is invalid, it will return {@link Mono#error(Throwable)}} * if the token is valid, but the username is not the same, it will return * {@link Mono#error(Throwable)} * * @param newPassword new password * @param token token to validate the user * @return {@link Mono#empty()} if the token is invalid or the username is not the same. * @throws AccessDeniedException if the token is invalid */ Mono changePassword(String newPassword, String token); Mono getValidResetToken(String token); } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/EmailVerificationService.java ================================================ package run.halo.app.core.user.service; import reactor.core.publisher.Mono; import run.halo.app.infra.exception.EmailVerificationFailed; /** * Email verification service to handle email verification. * * @author guqing * @since 2.11.0 */ public interface EmailVerificationService { /** * Send verification code by given username. * * @param username username to verify email must not be blank * @param email email to send must not be blank */ Mono sendVerificationCode(String username, String email); /** * Verify email by given username and code. * * @param username username to verify email must not be blank * @param code code to verify email must not be blank * @throws EmailVerificationFailed if send failed */ Mono verify(String username, String code); /** * Send verification code. * The only difference is use email as username. * * @param email email to send must not be blank */ Mono sendRegisterVerificationCode(String email); /** * Verify email by given code. * * @param email email as username to verify email must not be blank * @param code code to verify email must not be blank * @throws EmailVerificationFailed if send failed */ Mono verifyRegisterVerificationCode(String email, String code); } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/InMemoryResetTokenRepository.java ================================================ package run.halo.app.core.user.service; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import java.time.Duration; import java.util.Objects; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; /** * In-memory reset token repository. * * @author johnniang * @since 2.20.0 */ @Component public class InMemoryResetTokenRepository implements ResetTokenRepository { /** * Key: Token Hash. */ private final Cache tokens; public InMemoryResetTokenRepository() { this.tokens = Caffeine.newBuilder() .expireAfterWrite(Duration.ofDays(1)) .maximumSize(10000) .build(); } @Override public Mono save(ResetToken resetToken) { return Mono.defer(() -> { var savedResetToken = tokens.get(resetToken.tokenHash(), k -> resetToken); if (Objects.equals(savedResetToken, resetToken)) { return Mono.empty(); } // should never happen return Mono.error(new DuplicateKeyException("Reset token already exists")); }); } @Override public Mono findByTokenHash(String tokenHash) { return Mono.fromSupplier(() -> tokens.getIfPresent(tokenHash)); } @Override public Mono removeByTokenHash(String tokenHash) { return Mono.fromRunnable(() -> tokens.invalidate(tokenHash)); } } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/InvalidResetTokenException.java ================================================ package run.halo.app.core.user.service; import org.springframework.web.server.ServerWebInputException; /** * Invalid reset token exception. * * @author johnniang * @since 2.20.0 */ public class InvalidResetTokenException extends ServerWebInputException { public InvalidResetTokenException() { super("Invalid reset token"); } } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/PatService.java ================================================ package run.halo.app.core.user.service; import reactor.core.publisher.Mono; import run.halo.app.security.PersonalAccessToken; /** * Service for personal access token. * * @author johnniang */ public interface PatService { /** * Create a new personal access token. We will automatically use the current user as the * owner of the token from the security context. * * @param patRequest the personal access token request * @return the created personal access token */ Mono create(PersonalAccessToken patRequest); /** * Create a new personal access token for the specified user. * * @param patRequest the personal access token request * @param username the username of the user * @return the created personal access token */ Mono create(PersonalAccessToken patRequest, String username); /** * Revoke a personal access token. * * @param patName the name of the personal access token * @param username the username of the user * @return the revoked personal access token */ Mono revoke(String patName, String username); /** * Restore a personal access token. * * @param patName the name of the personal access token * @param username the username of the user * @return the restored personal access token */ Mono restore(String patName, String username); /** * Delete a personal access token. * * @param patName the name of the personal access token * @param username the username of the user * @return the deleted personal access token */ Mono delete(String patName, String username); /** * Get a personal access token by name. * * @param patName the name of the personal access token * @param username the username of the user * @return the personal access token */ Mono get(String patName, String username); /** * Generate a personal access token. * * @param pat the personal access token * @return the generated token */ Mono generateToken(PersonalAccessToken pat); } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/ResetToken.java ================================================ package run.halo.app.core.user.service; import java.time.Instant; /** * Reset token data. * * @param tokenHash The token hash * @param username The username * @param expiresAt The expires at * @author johnniang * @since 2.20.0 */ public record ResetToken(String tokenHash, String username, Instant expiresAt) { } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/ResetTokenRepository.java ================================================ package run.halo.app.core.user.service; import reactor.core.publisher.Mono; /** * Reset token repository. * * @author johnniang * @since 2.20.0 */ public interface ResetTokenRepository { /** * Save reset token. * * @param resetToken reset token * @return empty mono if saved successfully. * @throws org.springframework.dao.DuplicateKeyException if token already exists. */ Mono save(ResetToken resetToken); /** * Find reset token by token hash. * * @param tokenHash token hash * @return reset token if found, or empty mono. */ Mono findByTokenHash(String tokenHash); /** * Remove reset token by token hash. * * @param tokenHash token hash * @return empty mono if removed successfully. */ Mono removeByTokenHash(String tokenHash); } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/SettingConfigService.java ================================================ package run.halo.app.core.user.service; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Setting; import run.halo.app.extension.ConfigMap; import tools.jackson.databind.node.ObjectNode; /** * {@link Setting} related {@link ConfigMap} service. * * @author guqing * @since 2.20.0 */ public interface SettingConfigService { Mono upsertConfig(String configMapName, ObjectNode configJsonData); Mono fetchConfig(String configMapName); } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/UserConnectionService.java ================================================ package run.halo.app.core.user.service; import org.springframework.security.oauth2.core.user.OAuth2User; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.UserConnection; public interface UserConnectionService { /** * Create user connection. * * @param username Username * @param registrationId Registration id * @param oauth2User OAuth2 user * @return Created user connection */ Mono createUserConnection( String username, String registrationId, OAuth2User oauth2User ); /** * Update the user connection if present. * If found, update updatedAt timestamp of the user connection. * * @param registrationId Registration id * @param oauth2User OAuth2 user * @return Updated user connection or empty */ Mono updateUserConnectionIfPresent( String registrationId, OAuth2User oauth2User ); /** * Remove user connection. * * @param registrationId Registration ID * @param username Username * @return A list of user connections */ Flux removeUserConnection(String registrationId, String username); } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/UserLoginOrLogoutProcessing.java ================================================ package run.halo.app.core.user.service; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.event.user.UserLoginEvent; import run.halo.app.event.user.UserLogoutEvent; /** * User login or logout processing service. * * @author lywq **/ @Component @RequiredArgsConstructor public class UserLoginOrLogoutProcessing { private final UserService userService; private final ApplicationEventPublisher eventPublisher; public Mono loginProcessing(String username) { return userService.getUser(username) .doOnNext(user -> { eventPublisher.publishEvent(new UserLoginEvent(this, user)); }) .then(); } public Mono logoutProcessing(String username) { return userService.getUser(username) .doOnNext(user -> { eventPublisher.publishEvent(new UserLogoutEvent(this, user)); }) .then(); } } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/impl/DefaultAttachmentService.java ================================================ package run.halo.app.core.user.service.impl; import java.net.URI; import java.net.URL; import java.nio.file.Paths; import java.time.Duration; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ServerErrorException; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Group; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler; import run.halo.app.core.extension.attachment.endpoint.DeleteOption; import run.halo.app.core.extension.attachment.endpoint.SimpleFilePart; import run.halo.app.core.extension.attachment.endpoint.UploadOption; import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.ReactiveUrlDataBufferFetcher; import run.halo.app.plugin.extensionpoint.ExtensionGetter; @Component public class DefaultAttachmentService implements AttachmentService { private final ReactiveExtensionClient client; private final ExtensionGetter extensionGetter; private final ReactiveUrlDataBufferFetcher dataBufferFetcher; public DefaultAttachmentService(ReactiveExtensionClient client, ExtensionGetter extensionGetter, ReactiveUrlDataBufferFetcher dataBufferFetcher) { this.client = client; this.extensionGetter = extensionGetter; this.dataBufferFetcher = dataBufferFetcher; } @Override public Mono upload( @NonNull String username, @NonNull String policyName, @Nullable String groupName, @NonNull FilePart filePart, @Nullable Consumer beforeCreating) { var builder = UploadOption.builder(); builder.file(filePart); var getPolicyAndConfigMap = client.get(Policy.class, policyName) .doOnNext(builder::policy) .mapNotNull(p -> p.getSpec().getConfigMapName()) .filter(StringUtils::hasText) .switchIfEmpty(Mono.error(() -> new ServerWebInputException( "ConfigMap name not found in Policy " + policyName ))) .flatMap(configMapName -> client.get(ConfigMap.class, configMapName)) .doOnNext(builder::configMap) .then(); var getGroup = Mono.justOrEmpty(groupName) .filter(StringUtils::hasText) .flatMap(name -> client.get(Group.class, name)) .doOnNext(builder::group) .then(); return Mono.when(getPolicyAndConfigMap, getGroup).then(Mono.fromSupplier(builder::build)) .flatMap(uploadContext -> extensionGetter.getExtensions(AttachmentHandler.class) .concatMap(handler -> handler.upload(uploadContext)) .next()) .switchIfEmpty(Mono.error(() -> new ServerErrorException( "No suitable handler found for uploading the attachment.", null))) .doOnNext(attachment -> { var spec = attachment.getSpec(); if (spec == null) { spec = new Attachment.AttachmentSpec(); attachment.setSpec(spec); } spec.setOwnerName(username); if (StringUtils.hasText(groupName)) { spec.setGroupName(groupName); } spec.setPolicyName(policyName); }) .doOnNext(attachment -> { if (beforeCreating != null) { beforeCreating.accept(attachment); } }) .flatMap(client::create); } @Override public Mono upload(@NonNull String policyName, @Nullable String groupName, @NonNull String filename, @NonNull Flux content, @Nullable MediaType mediaType) { var file = new SimpleFilePart(filename, content, mediaType); return authenticationConsumer( authentication -> upload(authentication.getName(), policyName, groupName, file, null)); } @Override public Mono delete(Attachment attachment) { var spec = attachment.getSpec(); return client.get(Policy.class, spec.getPolicyName()) .flatMap(policy -> client.get(ConfigMap.class, policy.getSpec().getConfigMapName()) .map(configMap -> new DeleteOption(attachment, policy, configMap))) .flatMap(deleteOption -> extensionGetter.getExtensions(AttachmentHandler.class) .concatMap(handler -> handler.delete(deleteOption)) .next()); } @Override public Mono getPermalink(Attachment attachment) { return client.get(Policy.class, attachment.getSpec().getPolicyName()) .flatMap(policy -> client.get(ConfigMap.class, policy.getSpec().getConfigMapName()) .flatMap(configMap -> extensionGetter.getExtensions(AttachmentHandler.class) .concatMap(handler -> handler.getPermalink(attachment, policy, configMap)) .next() ) ); } @Override public Mono getSharedURL(Attachment attachment, Duration ttl) { return client.get(Policy.class, attachment.getSpec().getPolicyName()) .flatMap(policy -> client.get(ConfigMap.class, policy.getSpec().getConfigMapName()) .flatMap(configMap -> extensionGetter.getExtensions(AttachmentHandler.class) .concatMap(handler -> handler.getSharedURL(attachment, policy, configMap, ttl)) .next() ) ); } @Override public Mono> getThumbnailLinks(Attachment attachment) { return client.get(Policy.class, attachment.getSpec().getPolicyName()) .zipWhen(policy -> client.get(ConfigMap.class, policy.getSpec().getConfigMapName())) .flatMap(tuple2 -> { var policy = tuple2.getT1(); var configMap = tuple2.getT2(); return extensionGetter.getExtensions(AttachmentHandler.class) .concatMap(handler -> handler.getThumbnailLinks(attachment, policy, configMap)) .next(); }); } @Override public Mono uploadFromUrl(@NonNull URL url, @NonNull String policyName, String groupName, String filename) { var uri = URI.create(url.toString()); AtomicReference mediaTypeRef = new AtomicReference<>(); AtomicReference fileNameRef = new AtomicReference<>(filename); Mono> contentMono = dataBufferFetcher.head(uri) .map(httpHeaders -> { if (!StringUtils.hasText(fileNameRef.get())) { fileNameRef.set(getExternalUrlFilename(uri, httpHeaders)); } MediaType contentType = httpHeaders.getContentType(); mediaTypeRef.set(contentType); return httpHeaders; }) .map(response -> dataBufferFetcher.fetch(uri)); return contentMono.flatMap( (content) -> upload(policyName, groupName, fileNameRef.get(), content, mediaTypeRef.get()) ) .onErrorResume(throwable -> Mono.error( new ServerWebInputException( "Failed to transfer the attachment from the external URL.")) ); } private static String getExternalUrlFilename(URI externalUrl, HttpHeaders httpHeaders) { String fileName = httpHeaders.getContentDisposition().getFilename(); if (!StringUtils.hasText(fileName)) { var path = externalUrl.getPath(); fileName = Paths.get(path).getFileName().toString(); } // TODO get file extension from media type return fileName; } private Mono authenticationConsumer(Function> func) { return ReactiveSecurityContextHolder.getContext() .switchIfEmpty(Mono.error(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required."))) .map(SecurityContext::getAuthentication) .flatMap(func); } } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java ================================================ package run.halo.app.core.user.service.impl; import java.time.Clock; import java.time.Duration; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.security.core.token.Sha512DigestUtils; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.web.util.UriComponentsBuilder; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.extension.User; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.core.user.service.EmailPasswordRecoveryService; import run.halo.app.core.user.service.InvalidResetTokenException; import run.halo.app.core.user.service.ResetToken; import run.halo.app.core.user.service.ResetTokenRepository; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.ExternalLinkProcessor; import run.halo.app.notification.NotificationCenter; import run.halo.app.notification.NotificationReasonEmitter; import run.halo.app.notification.UserIdentity; /** * A default implementation for {@link EmailPasswordRecoveryService}. * * @author guqing * @since 2.11.0 */ @Component @RequiredArgsConstructor public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoveryService { public static final int MAX_ATTEMPTS = 5; public static final long LINK_EXPIRATION_MINUTES = 30; private static final Duration RESET_TOKEN_LIFE_TIME = Duration.ofMinutes(LINK_EXPIRATION_MINUTES); static final String RESET_PASSWORD_BY_EMAIL_REASON_TYPE = "reset-password-by-email"; private final ExternalLinkProcessor externalLinkProcessor; private final ReactiveExtensionClient client; private final NotificationReasonEmitter reasonEmitter; private final NotificationCenter notificationCenter; private final UserService userService; private final ResetTokenRepository resetTokenRepository; private Clock clock = Clock.systemDefaultZone(); @Override public Mono sendPasswordResetEmail(String username, String email) { return client.fetch(User.class, username) .flatMap(user -> { var userEmail = user.getSpec().getEmail(); if (!StringUtils.equals(userEmail, email)) { return Mono.empty(); } if (!user.getSpec().isEmailVerified()) { return Mono.empty(); } return sendResetPasswordNotification(username, email); }); } @Override public Mono sendPasswordResetEmail(String email) { if (StringUtils.isBlank(email)) { return Mono.empty(); } return userService.listByEmail(email) .filter(user -> user.getSpec().isEmailVerified()) .next() .flatMap(user -> sendResetPasswordNotification(user.getMetadata().getName(), email)); } @Override public Mono changePassword(String newPassword, String token) { Assert.state(StringUtils.isNotBlank(newPassword), "NewPassword must not be blank"); Assert.state(StringUtils.isNotBlank(token), "Token for reset password must not be blank"); var tokenHash = hashToken(token); return getValidResetToken(token).flatMap(resetToken -> userService.updateWithRawPassword(resetToken.username(), newPassword) .flatMap(user -> unSubscribeResetPasswordEmailNotification( user.getSpec().getEmail()) ) .then(resetTokenRepository.removeByTokenHash(tokenHash)) ); } @Override public Mono getValidResetToken(String token) { return resetTokenRepository.findByTokenHash(hashToken(token)) .filter(resetToken -> clock.instant().isBefore(resetToken.expiresAt())) .switchIfEmpty(Mono.error(InvalidResetTokenException::new)); } Mono unSubscribeResetPasswordEmailNotification(String email) { if (StringUtils.isBlank(email)) { return Mono.empty(); } var subscriber = new Subscription.Subscriber(); subscriber.setName(UserIdentity.anonymousWithEmail(email).name()); return notificationCenter.unsubscribe(subscriber, createInterestReason(email)) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)); } private Mono sendResetPasswordNotification(String username, String email) { var token = generateToken(); var tokenHash = hashToken(token); var expiresAt = clock.instant().plus(RESET_TOKEN_LIFE_TIME); var uri = UriComponentsBuilder.fromUriString("/") .pathSegment("password-reset", "email", token) .build(true) .toUri(); var resetToken = new ResetToken(tokenHash, username, expiresAt); return resetTokenRepository.save(resetToken) .then(externalLinkProcessor.processLink(uri).flatMap(link -> { var interestReasonSubject = createInterestReason(email).getSubject(); var emitReasonMono = reasonEmitter.emit(RESET_PASSWORD_BY_EMAIL_REASON_TYPE, builder -> builder.attribute("expirationAtMinutes", LINK_EXPIRATION_MINUTES) .attribute("username", username) .attribute("link", link) .author(UserIdentity.of(username)) .subject(Reason.Subject.builder() .apiVersion(interestReasonSubject.getApiVersion()) .kind(interestReasonSubject.getKind()) .name(interestReasonSubject.getName()) .title("使用邮箱地址重置密码:" + email) .build() ) ); return autoSubscribeResetPasswordEmailNotification(email).then(emitReasonMono); })); } Mono autoSubscribeResetPasswordEmailNotification(String email) { var subscriber = new Subscription.Subscriber(); subscriber.setName(UserIdentity.anonymousWithEmail(email).name()); var interestReason = createInterestReason(email); return notificationCenter.subscribe(subscriber, interestReason) .then(); } Subscription.InterestReason createInterestReason(String email) { var interestReason = new Subscription.InterestReason(); interestReason.setReasonType(RESET_PASSWORD_BY_EMAIL_REASON_TYPE); interestReason.setSubject(Subscription.ReasonSubject.builder() .apiVersion(new GroupVersion(User.GROUP, User.KIND).toString()) .kind(User.KIND) .name(UserIdentity.anonymousWithEmail(email).name()) .build()); return interestReason; } private static String hashToken(String token) { return Sha512DigestUtils.shaHex(token); } private static String generateToken() { return RandomStringUtils.secure().nextAlphanumeric(64); } } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImpl.java ================================================ package run.halo.app.core.user.service.impl; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import java.time.Duration; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.experimental.Accessors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; import run.halo.app.core.extension.User; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.core.user.service.EmailVerificationService; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.Queries; import run.halo.app.infra.exception.EmailVerificationFailed; import run.halo.app.notification.NotificationCenter; import run.halo.app.notification.NotificationReasonEmitter; import run.halo.app.notification.UserIdentity; /** * A default implementation of {@link EmailVerificationService}. * * @author guqing * @since 2.11.0 */ @Slf4j @Component @RequiredArgsConstructor public class EmailVerificationServiceImpl implements EmailVerificationService { public static final int MAX_ATTEMPTS = 5; public static final long CODE_EXPIRATION_MINUTES = 10; static final String EMAIL_VERIFICATION_REASON_TYPE = "email-verification"; private final EmailVerificationManager emailVerificationManager = new EmailVerificationManager(); private final ReactiveExtensionClient client; private final NotificationReasonEmitter reasonEmitter; private final NotificationCenter notificationCenter; @Override public Mono sendVerificationCode(String username, String email) { Assert.state(StringUtils.isNotBlank(username), "Username must not be blank"); Assert.state(StringUtils.isNotBlank(email), "Email must not be blank"); return Mono.defer(() -> client.get(User.class, username) .flatMap(user -> { var userEmail = user.getSpec().getEmail(); var isVerified = user.getSpec().isEmailVerified(); if (StringUtils.equalsIgnoreCase(userEmail, email) && isVerified) { return Mono.error( () -> new ServerWebInputException("Email already verified.")); } var annotations = MetadataUtil.nullSafeAnnotations(user); var oldEmailToVerify = annotations.get(User.EMAIL_TO_VERIFY); var unsubMono = unSubscribeVerificationEmailNotification(oldEmailToVerify); var updateUserAnnoMono = Mono.defer(() -> { annotations.put(User.EMAIL_TO_VERIFY, email); return client.update(user); }); emailVerificationManager.removeCode(username, oldEmailToVerify); return Mono.when(unsubMono, updateUserAnnoMono).thenReturn(user); }) ) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)) .flatMap(user -> sendVerificationNotification(username, email)); } @Override public Mono verify(String username, String code) { Assert.state(StringUtils.isNotBlank(username), "Username must not be blank"); Assert.state(StringUtils.isNotBlank(code), "Code must not be blank"); return Mono.defer(() -> client.get(User.class, username) .flatMap(user -> verifyUserEmail(user, code)) ) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)); } private Mono verifyUserEmail(User user, String code) { var username = user.getMetadata().getName(); var annotations = MetadataUtil.nullSafeAnnotations(user); var emailToVerify = annotations.get(User.EMAIL_TO_VERIFY); if (StringUtils.isBlank(emailToVerify)) { return Mono.error(EmailVerificationFailed::new); } var verified = emailVerificationManager.verifyCode(username, emailToVerify, code); if (!verified) { return Mono.error(EmailVerificationFailed::new); } return isEmailInUse(username, emailToVerify) .flatMap(inUse -> { if (inUse) { return Mono.error(new EmailVerificationFailed("Email already in use.", null, "problemDetail.user.email.verify.emailInUse", null) ); } // remove code when verified emailVerificationManager.removeCode(username, emailToVerify); user.getSpec().setEmailVerified(true); user.getSpec().setEmail(emailToVerify); return client.update(user); }) .then(); } Mono isEmailInUse(String username, String emailToVerify) { var listOptions = ListOptions.builder() .andQuery(Queries.equal("spec.email", emailToVerify.toLowerCase())) .build(); return client.listAll(User.class, listOptions, ExtensionUtil.defaultSort()) .filter(user -> user.getSpec().isEmailVerified()) .filter(user -> !user.getMetadata().getName().equals(username)) .hasElements(); } @Override public Mono sendRegisterVerificationCode(String email) { Assert.state(StringUtils.isNotBlank(email), "Email must not be blank"); return sendVerificationNotification(email.toLowerCase(), email); } @Override public Mono verifyRegisterVerificationCode(String email, String code) { Assert.state(StringUtils.isNotBlank(email), "Username must not be blank"); Assert.state(StringUtils.isNotBlank(code), "Code must not be blank"); return Mono.fromSupplier(() -> emailVerificationManager.verifyCode(email, email, code)) // Why use boundedElastic? Because the verification uses synchronized block. .subscribeOn(Schedulers.boundedElastic()); } Mono sendVerificationNotification(String username, String email) { var code = emailVerificationManager.generateCode(username, email); if (log.isDebugEnabled()) { log.debug("Generated verification code for user '{}' and email '{}': {}", username, email, code); } var subscribeNotification = autoSubscribeVerificationEmailNotification(email); var interestReasonSubject = createInterestReason(email).getSubject(); var emitReasonMono = reasonEmitter.emit(EMAIL_VERIFICATION_REASON_TYPE, builder -> builder.attribute("code", code) .attribute("expirationAtMinutes", CODE_EXPIRATION_MINUTES) .attribute("username", username) .author(UserIdentity.of(username)) .subject(Reason.Subject.builder() .apiVersion(interestReasonSubject.getApiVersion()) .kind(interestReasonSubject.getKind()) .name(interestReasonSubject.getName()) .title("验证邮箱:" + email) .build() ) ); return Mono.when(subscribeNotification).then(emitReasonMono); } Mono autoSubscribeVerificationEmailNotification(String email) { var subscriber = new Subscription.Subscriber(); subscriber.setName(UserIdentity.anonymousWithEmail(email).name()); var interestReason = createInterestReason(email); return notificationCenter.subscribe(subscriber, interestReason) .then(); } Mono unSubscribeVerificationEmailNotification(String oldEmail) { if (StringUtils.isBlank(oldEmail)) { return Mono.empty(); } var subscriber = new Subscription.Subscriber(); subscriber.setName(UserIdentity.anonymousWithEmail(oldEmail).name()); return notificationCenter.unsubscribe(subscriber, createInterestReason(oldEmail)); } Subscription.InterestReason createInterestReason(String email) { var interestReason = new Subscription.InterestReason(); interestReason.setReasonType(EMAIL_VERIFICATION_REASON_TYPE); interestReason.setSubject(Subscription.ReasonSubject.builder() .apiVersion(new GroupVersion(User.GROUP, User.KIND).toString()) .kind(User.KIND) .name(UserIdentity.anonymousWithEmail(email).name()) .build()); return interestReason; } /** * A simple email verification manager that stores the verification code in memory. * It is a thread-safe class. * * @author guqing * @since 2.11.0 */ static class EmailVerificationManager { private final Cache emailVerificationCodeCache = CacheBuilder.newBuilder() .expireAfterWrite(CODE_EXPIRATION_MINUTES, TimeUnit.MINUTES) .maximumSize(10000) .build(); private final Cache blackListCache = CacheBuilder.newBuilder() .expireAfterWrite(Duration.ofHours(1)) .maximumSize(1000) .build(); public boolean verifyCode(String username, String email, String code) { var key = new UsernameEmail(username, email); var verification = emailVerificationCodeCache.getIfPresent(key); if (verification == null) { // expired or not generated return false; } if (blackListCache.getIfPresent(key) != null) { // in blacklist throw new EmailVerificationFailed("Too many attempts. Please try again later.", null, "problemDetail.user.email.verify.maxAttempts", null); } synchronized (verification) { if (verification.getAttempts().get() >= MAX_ATTEMPTS) { // add to blacklist to prevent brute force attack blackListCache.put(key, true); return false; } if (!verification.getCode().equals(code)) { verification.getAttempts().incrementAndGet(); return false; } } return true; } public void removeCode(String username, String email) { var key = new UsernameEmail(username, email); emailVerificationCodeCache.invalidate(key); } public String generateCode(String username, String email) { Assert.state(StringUtils.isNotBlank(username), "Username must not be blank"); Assert.state(StringUtils.isNotBlank(email), "Email must not be blank"); var key = new UsernameEmail(username, email); var verification = new Verification(); verification.setCode(RandomStringUtils.randomNumeric(6)); verification.setAttempts(new AtomicInteger(0)); emailVerificationCodeCache.put(key, verification); return verification.getCode(); } /** * Only for test. */ boolean contains(String username, String email) { return emailVerificationCodeCache .getIfPresent(new UsernameEmail(username, email)) != null; } record UsernameEmail(String username, String email) { public UsernameEmail { // convert to lower case to make it case-insensitive email = StringUtils.lowerCase(email); } } @Data @Accessors(chain = true) static class Verification { private String code; private AtomicInteger attempts; } } } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/impl/PatServiceImpl.java ================================================ package run.halo.app.core.user.service.impl; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import java.time.Clock; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Objects; import org.springframework.security.authentication.AuthenticationTrustResolver; import org.springframework.security.authentication.AuthenticationTrustResolverImpl; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.JwsHeader; import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.stereotype.Service; import org.springframework.util.AlternativeJdkIdGenerator; import org.springframework.util.CollectionUtils; import org.springframework.util.IdGenerator; import org.springframework.web.filter.reactive.ServerWebExchangeContextFilter; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.user.service.PatService; import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.security.PersonalAccessToken; import run.halo.app.security.authentication.CryptoService; import run.halo.app.security.authorization.AuthorityUtils; /** * Service for managing personal access tokens (PATs). * * @author johnniang */ @Service class PatServiceImpl implements PatService { private final RoleService roleService; private final IdGenerator idGenerator; private final ReactiveExtensionClient client; private final AuthenticationTrustResolver authTrustResolver = new AuthenticationTrustResolverImpl(); private final JwtEncoder jwtEncoder; private final ExternalUrlSupplier externalUrl; private final ReactiveUserDetailsService userDetailsService; private final String keyId; private Clock clock; public PatServiceImpl(RoleService roleService, ReactiveExtensionClient client, ExternalUrlSupplier externalUrl, CryptoService cryptoService, ReactiveUserDetailsService userDetailsService) { this.roleService = roleService; this.client = client; this.externalUrl = externalUrl; this.userDetailsService = userDetailsService; this.clock = Clock.systemUTC(); idGenerator = new AlternativeJdkIdGenerator(); var jwk = cryptoService.getJwk(); this.jwtEncoder = new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(jwk))); this.keyId = jwk.getKeyID(); } /** * Set clock for testing. * * @param clock the clock to set */ void setClock(Clock clock) { this.clock = clock; } @Override public Mono create(PersonalAccessToken patRequest) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) // TODO We only allow authenticated users to create PATs. .filter(authTrustResolver::isAuthenticated) .switchIfEmpty( Mono.error(() -> new ServerWebInputException("Authentication required.")) ) .flatMap(auth -> create(patRequest, auth.getName(), auth.getAuthorities()) ); } @Override public Mono create(PersonalAccessToken patRequest, String username) { return userDetailsService.findByUsername(username) .flatMap(userDetails -> create(patRequest, username, userDetails.getAuthorities()) ); } private Mono create(PersonalAccessToken patRequest, String username, Collection authorities) { var patSpec = patRequest.getSpec(); // preflight check var expiresAt = patSpec.getExpiresAt(); if (expiresAt != null && expiresAt.isBefore(clock.instant())) { return Mono.error(new ServerWebInputException("Invalid expiresAt.")); } var roles = patSpec.getRoles(); return hasSufficientRoles(authorities, roles) .filter(has -> has) .switchIfEmpty( Mono.error(() -> new ServerWebInputException("Insufficient roles.")) ) .map(has -> { var pat = new PersonalAccessToken(); pat.setMetadata(new Metadata()); if (patRequest.getMetadata() != null) { var metadata = patRequest.getMetadata(); if (metadata.getName() != null) { pat.getMetadata().setName(metadata.getName()); } if (metadata.getGenerateName() != null) { pat.getMetadata().setGenerateName(metadata.getGenerateName()); } if (metadata.getLabels() != null) { pat.getMetadata().setLabels(new HashMap<>()); pat.getMetadata().getLabels().putAll(metadata.getLabels()); } if (metadata.getAnnotations() != null) { pat.getMetadata().setAnnotations(new HashMap<>()); pat.getMetadata().getAnnotations() .putAll(metadata.getAnnotations()); } if (metadata.getFinalizers() != null) { pat.getMetadata().setFinalizers(new HashSet<>()); pat.getMetadata().getFinalizers().addAll(metadata.getFinalizers()); } } if (pat.getMetadata().getGenerateName() == null) { pat.getMetadata().setGenerateName("pat-" + username + "-"); } pat.getSpec().setUsername(username); pat.getSpec().setName(patSpec.getName()); pat.getSpec().setDescription(patSpec.getDescription()); if (patSpec.getRoles() != null) { pat.getSpec().setRoles(new ArrayList<>()); pat.getSpec().getRoles().addAll(patSpec.getRoles()); } if (patSpec.getScopes() != null) { pat.getSpec().setScopes(new ArrayList<>()); pat.getSpec().getScopes().addAll(patSpec.getScopes()); } pat.getSpec().setExpiresAt(patSpec.getExpiresAt()); pat.getSpec().setTokenId(idGenerator.generateId().toString()); return pat; }) .flatMap(client::create); } @Override public Mono revoke(String patName, String username) { return get(patName, username) .filter(pat -> !pat.getSpec().isRevoked()) .switchIfEmpty(Mono.error( () -> new ServerWebInputException("The token has been revoked before.")) ) .doOnNext(pat -> { pat.getSpec().setRevoked(true); pat.getSpec().setRevokesAt(clock.instant()); }) .flatMap(client::update); } @Override public Mono restore(String patName, String username) { return get(patName, username) .filter(pat -> pat.getSpec().isRevoked()) .switchIfEmpty(Mono.error( () -> new ServerWebInputException("The token has not been revoked before.")) ) .doOnNext(pat -> { pat.getSpec().setRevoked(false); pat.getSpec().setRevokesAt(null); }) .flatMap(client::update); } @Override public Mono delete(String patName, String username) { return get(patName, username) .flatMap(client::delete); } @Override public Mono get(String patName, String username) { return client.fetch(PersonalAccessToken.class, patName) .filter(pat -> Objects.equals(pat.getSpec().getUsername(), username)) .switchIfEmpty(Mono.error(() -> new NotFoundException( "The personal access token was not found or deleted." ))); } @Override public Mono generateToken(PersonalAccessToken pat) { return Mono.deferContextual( contextView -> { var externalUrl = ServerWebExchangeContextFilter.getExchange(contextView) .map(exchange -> this.externalUrl.getURL(exchange.getRequest())) .orElse(null); if (externalUrl == null) { return Mono.error(new ServerWebInputException("Server web exchange is " + "required")); } var claimsBuilder = JwtClaimsSet.builder() .issuer(externalUrl.toString()) .id(pat.getSpec().getTokenId()) .subject(pat.getSpec().getUsername()) .issuedAt(clock.instant()) .claim("pat_name", pat.getMetadata().getName()); var expiresAt = pat.getSpec().getExpiresAt(); if (expiresAt != null) { claimsBuilder.expiresAt(expiresAt); } var headerBuilder = JwsHeader.with(SignatureAlgorithm.RS256) .keyId(this.keyId); var jwt = jwtEncoder.encode(JwtEncoderParameters.from( headerBuilder.build(), claimsBuilder.build())); return Mono.just(jwt); } ) .map(jwt -> PersonalAccessToken.PAT_TOKEN_PREFIX + jwt.getTokenValue()); } private Mono hasSufficientRoles( Collection grantedAuthorities, List requestRoles) { if (CollectionUtils.isEmpty(requestRoles)) { return Mono.just(true); } var grantedRoles = AuthorityUtils.authoritiesToRoles(grantedAuthorities); return roleService.contains(grantedRoles, requestRoles); } } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/impl/SettingConfigServiceImpl.java ================================================ package run.halo.app.core.user.service.impl; import java.time.Duration; import lombok.RequiredArgsConstructor; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.extension.Setting; import run.halo.app.core.user.service.SettingConfigService; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.SettingUtils; import tools.jackson.databind.node.ObjectNode; /** * {@link Setting} related {@link ConfigMap} service implementation. * * @author guqing * @since 2.20.0 */ @Component @RequiredArgsConstructor class SettingConfigServiceImpl implements SettingConfigService { private final ReactiveExtensionClient client; @Override public Mono upsertConfig(String configMapName, ObjectNode configJsonData) { Assert.notNull(configMapName, "Config map name must not be null"); Assert.notNull(configJsonData, "Config json data must not be null"); var data = SettingUtils.settingConfigJsonToMap(configJsonData); return Mono.defer(() -> client.fetch(ConfigMap.class, configMapName) .flatMap(persisted -> { persisted.setData(data); return client.update(persisted); })) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance) ) .switchIfEmpty(Mono.defer(() -> { var configMap = new ConfigMap(); configMap.setMetadata(new Metadata()); configMap.getMetadata().setName(configMapName); configMap.setData(data); return client.create(configMap); })) .then(); } @Override public Mono fetchConfig(String configMapName) { return client.fetch(ConfigMap.class, configMapName) .map(SettingUtils::settingConfigToJson); } } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/impl/UserConnectionServiceImpl.java ================================================ package run.halo.app.core.user.service.impl; import static run.halo.app.extension.ExtensionUtil.defaultSort; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.equal; import java.time.Clock; import java.util.HashMap; import java.util.Optional; import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.UserConnection; import run.halo.app.core.extension.UserConnection.UserConnectionSpec; import run.halo.app.core.user.service.UserConnectionService; import run.halo.app.event.user.UserConnectionDisconnectedEvent; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.MetadataOperator; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.exception.OAuth2UserAlreadyBoundException; import run.halo.app.infra.utils.JsonUtils; @Service public class UserConnectionServiceImpl implements UserConnectionService { private final ReactiveExtensionClient client; private final ApplicationEventPublisher eventPublisher; private Clock clock = Clock.systemDefaultZone(); public UserConnectionServiceImpl(ReactiveExtensionClient client, ApplicationEventPublisher eventPublisher) { this.client = client; this.eventPublisher = eventPublisher; } void setClock(Clock clock) { this.clock = clock; } @Override public Mono createUserConnection( String username, String registrationId, OAuth2User oauth2User ) { return getUserConnection(registrationId, username) .flatMap(connection -> Mono.error( () -> new OAuth2UserAlreadyBoundException(connection)) ) .switchIfEmpty(Mono.defer(() -> { var connection = new UserConnection(); connection.setMetadata(new Metadata()); var metadata = connection.getMetadata(); updateUserInfo(metadata, oauth2User); metadata.setGenerateName(username + "-"); connection.setSpec(new UserConnectionSpec()); var spec = connection.getSpec(); spec.setUsername(username); spec.setProviderUserId(oauth2User.getName()); spec.setRegistrationId(registrationId); spec.setUpdatedAt(clock.instant()); return client.create(connection); })); } private Mono updateUserConnection(UserConnection connection, OAuth2User oauth2User) { connection.getSpec().setUpdatedAt(clock.instant()); updateUserInfo(connection.getMetadata(), oauth2User); return client.update(connection); } private Mono getUserConnection(String registrationId, String username) { var listOptions = ListOptions.builder() .fieldQuery(and( equal("spec.registrationId", registrationId), equal("spec.username", username) )) .build(); return client.listAll(UserConnection.class, listOptions, defaultSort()).next(); } @Override public Mono updateUserConnectionIfPresent(String registrationId, OAuth2User oauth2User) { var listOptions = ListOptions.builder() .fieldQuery(and( equal("spec.registrationId", registrationId), equal("spec.providerUserId", oauth2User.getName()) )) .build(); return client.listAll(UserConnection.class, listOptions, defaultSort()).next() .flatMap(connection -> updateUserConnection(connection, oauth2User)); } @Override public Flux removeUserConnection(String registrationId, String username) { var listOptions = ListOptions.builder() .fieldQuery(and( equal("spec.registrationId", registrationId), equal("spec.username", username) )) .build(); return client.listAll(UserConnection.class, listOptions, defaultSort()) .flatMap(client::delete) .doOnNext(deleted -> eventPublisher.publishEvent(new UserConnectionDisconnectedEvent(this, deleted)) ); } private void updateUserInfo(MetadataOperator metadata, OAuth2User oauth2User) { var annotations = Optional.ofNullable(metadata.getAnnotations()) .orElseGet(HashMap::new); metadata.setAnnotations(annotations); annotations.put( "auth.halo.run/oauth2-user-info", JsonUtils.objectToJson(oauth2User.getAttributes()) ); } } ================================================ FILE: application/src/main/java/run/halo/app/core/user/service/impl/UserServiceImpl.java ================================================ package run.halo.app.core.user.service.impl; import static run.halo.app.extension.ExtensionUtil.defaultSort; import static run.halo.app.extension.index.query.Queries.equal; import java.time.Clock; import java.time.Duration; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.session.ReactiveSessionRegistry; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.ReactiveTransactionManager; import org.springframework.transaction.reactive.TransactionalOperator; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.User; import run.halo.app.core.user.service.EmailVerificationService; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.SignUpData; import run.halo.app.core.user.service.UserPostCreatingHandler; import run.halo.app.core.user.service.UserPreCreatingHandler; import run.halo.app.core.user.service.UserService; import run.halo.app.event.user.PasswordChangedEvent; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.exception.ExtensionNotFoundException; import run.halo.app.extension.index.query.Queries; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.ValidationUtils; import run.halo.app.infra.exception.DuplicateNameException; import run.halo.app.infra.exception.EmailAlreadyTakenException; import run.halo.app.infra.exception.EmailVerificationFailed; import run.halo.app.infra.exception.RestrictedNameException; import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.exception.UserNotFoundException; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.security.authorization.AuthorityUtils; import run.halo.app.security.device.DeviceService; @Slf4j @Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { private final ReactiveExtensionClient client; private final PasswordEncoder passwordEncoder; private final SystemConfigFetcher environmentFetcher; private final ApplicationEventPublisher eventPublisher; private final RoleService roleService; private final EmailVerificationService emailVerificationService; private final ExtensionGetter extensionGetter; private final DeviceService deviceService; private final ReactiveTransactionManager transactionManager; private final ReactiveSessionRegistry sessionRegistry; private Clock clock = Clock.systemUTC(); void setClock(Clock clock) { this.clock = clock; } @Override public Mono getUser(String username) { return client.get(User.class, username) .onErrorMap(ExtensionNotFoundException.class, e -> new UserNotFoundException(username)); } @Override public Mono findUserByVerifiedEmail(String email) { var listOptions = ListOptions.builder() .andQuery(equal("spec.emailVerified", true)) .andQuery(equal("spec.email", email.toLowerCase())) .build(); return client.listAll(User.class, listOptions, defaultSort()).next(); } @Override public Mono getUserOrGhost(String username) { return client.fetch(User.class, username) .switchIfEmpty(Mono.defer(() -> client.get(User.class, GHOST_USER_NAME))); } @Override public Flux getUsersOrGhosts(Collection names) { if (CollectionUtils.isEmpty(names)) { return Flux.empty(); } var nameSet = new HashSet<>(names); nameSet.add(GHOST_USER_NAME); var options = ListOptions.builder() .andQuery(Queries.in("metadata.name", nameSet)) .build(); return client.listAll(User.class, options, defaultSort()) .collectMap(u -> u.getMetadata().getName()) .map(map -> { var ghost = map.get(GHOST_USER_NAME); return names.stream() .map(name -> map.getOrDefault(name, ghost)) .toList(); }) .flatMapMany(Flux::fromIterable); } @Override public Mono updatePassword(String username, String newPassword) { return getUser(username) .filter(user -> !Objects.equals(user.getSpec().getPassword(), newPassword)) .flatMap(user -> { user.getSpec().setPassword(newPassword); return client.update(user); }) .doOnNext(user -> publishPasswordChangedEvent(username)); } @Override public Mono updateWithRawPassword(String username, String rawPassword) { if (!ValidationUtils.PASSWORD_PATTERN.matcher(rawPassword).matches()) { return Mono.error( new UnsatisfiedAttributeValueException("validation.error.password.pattern")); } return getUser(username) .filter(user -> { if (!StringUtils.hasText(user.getSpec().getPassword())) { // Check if the old password is set before, or the passwordEncoder#matches // will complain an error due to null password. return true; } return !passwordEncoder.matches(rawPassword, user.getSpec().getPassword()); }) .flatMap(user -> { user.getSpec().setPassword(passwordEncoder.encode(rawPassword)); return client.update(user); }) .doOnNext(user -> publishPasswordChangedEvent(username)); } @Override public Mono grantRoles(String username, Set roles) { var bindingsToUpdate = new HashSet(); var bindingsToDelete = new HashSet(); var existingRoles = new HashSet(); var subject = new RoleBinding.Subject(); subject.setKind(User.KIND); subject.setApiGroup(User.GROUP); subject.setName(username); var tx = TransactionalOperator.create(transactionManager); return roleService.listRoleBindings(subject) .doOnNext(binding -> { var roleName = binding.getRoleRef().getName(); existingRoles.add(roleName); if (roles.contains(roleName)) { return; } binding.getSubjects().removeIf(RoleBinding.Subject.isUser(username)); if (CollectionUtils.isEmpty(binding.getSubjects())) { // remove it if subjects is empty bindingsToDelete.add(binding); } else { bindingsToUpdate.add(binding); } }) .then(Mono.defer(() -> { if (log.isDebugEnabled()) { log.debug(""" Updating roles for user {}: existingRoles={}, roles={}, \ bindingsToUpdate={}, bindingsToDelete={}""", username, existingRoles, roles, bindingsToUpdate, bindingsToDelete ); } var updateBindings = Flux.fromIterable(bindingsToUpdate) .flatMap(client::update) .then(); var deleteBindings = Flux.fromIterable(bindingsToDelete) .flatMap(client::delete) .then(); var createBindings = Flux.fromIterable(roles) .filter(role -> !existingRoles.contains(role)) .filter(StringUtils::hasText) .map(role -> RoleBinding.create(username, role)) .flatMap(client::create); return Mono.when(updateBindings, deleteBindings, createBindings); })) .as(tx::transactional) .then(Mono.defer(() -> { if (Objects.equals(roles, existingRoles)) { // No need to update the user if roles are not changed log.debug("No role changes for user {}, skip updating user annotations.", username); return Mono.empty(); } log.info("Updated roles for user {}: existingRoles={}, roles={}", username, existingRoles, roles ); var invalidateSessions = sessionRegistry.getAllSessions(username) .flatMap(reactiveSessionInformation -> { log.info("Invalidating session {} for user {}", reactiveSessionInformation.getSessionId(), username ); return reactiveSessionInformation.invalidate(); }) .then(); var updateUser = client.get(User.class, username) .doOnNext(u -> { var annotations = u.getMetadata().getAnnotations(); if (annotations == null) { annotations = new HashMap<>(); u.getMetadata().setAnnotations(annotations); } annotations.put(User.REQUEST_TO_UPDATE, clock.instant().toString()); }) .flatMap(client::update) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance) ); return invalidateSessions.then(updateUser); })); } @Override public Mono hasSufficientRoles(Collection roles) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(a -> AuthorityUtils.authoritiesToRoles(a.getAuthorities())) .flatMap(userRoles -> roleService.contains(userRoles, roles)) .defaultIfEmpty(false); } @Override public Mono signUp(SignUpData signUpData) { return environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class) .filter(SystemSetting.User::isAllowRegistration) .switchIfEmpty(Mono.error(() -> new ServerWebInputException( "The registration is not allowed by the administrator." ))) .filter(setting -> isUsernameAllowed(setting, signUpData.getUsername())) .switchIfEmpty(Mono.error(() -> new RestrictedNameException( "The username is restricted.", "problemDetail.user.username.restricted", new Object[] {signUpData.getUsername()} ))) .filter(setting -> isDisplayNameAllowed(setting, signUpData.getDisplayName())) .switchIfEmpty(Mono.error(() -> new RestrictedNameException( "The display name is restricted.", "problemDetail.user.displayName.restricted", new Object[] {signUpData.getDisplayName()} ))) .filter(setting -> StringUtils.hasText(setting.getDefaultRole())) .switchIfEmpty(Mono.error(() -> new ServerWebInputException( "The default role is not configured by the administrator." ))) .flatMap(setting -> { var email = Optional.ofNullable(signUpData.getEmail()) .map(String::toLowerCase) .orElse(null); var user = new User(); user.setMetadata(new Metadata()); var metadata = user.getMetadata(); metadata.setName(signUpData.getUsername()); user.setSpec(new User.UserSpec()); var spec = user.getSpec(); spec.setPassword(passwordEncoder.encode(signUpData.getPassword())); spec.setEmailVerified(false); spec.setRegisteredAt(clock.instant()); spec.setEmail(email); spec.setDisplayName(signUpData.getDisplayName()); Mono verifyEmail = Mono.empty(); if (setting.isMustVerifyEmailOnRegistration()) { if (!StringUtils.hasText(email)) { return Mono.error( new EmailVerificationFailed("Email captcha is required", null) ); } verifyEmail = emailVerificationService.verifyRegisterVerificationCode( email, signUpData.getEmailCode() ) .filter(Boolean::booleanValue) .switchIfEmpty(Mono.error(() -> new EmailVerificationFailed("Invalid email captcha.", null) )) .then(this.checkEmailAlreadyVerified(email)) .filter(has -> !has) .switchIfEmpty(Mono.error( () -> new EmailAlreadyTakenException("Email is already taken") )) .doOnNext(v -> spec.setEmailVerified(true)) .then(); } return verifyEmail.then(Mono.defer(() -> { var defaultRole = setting.getDefaultRole(); return createUser(user, Set.of(defaultRole)); })); }); } @Override public Mono createUser(User user, Set roleNames) { Assert.notNull(user, "User must not be null"); Assert.notNull(roleNames, "Roles must not be null"); return client.fetch(User.class, user.getMetadata().getName()) .hasElement() .flatMap(hasUser -> { if (hasUser) { return Mono.error( new DuplicateNameException("User name is already in use", null, "problemDetail.user.duplicateName", new Object[] {user.getMetadata().getName()})); } // Check if all roles exist return Flux.fromIterable(roleNames) .flatMap(roleName -> client.fetch(Role.class, roleName) .switchIfEmpty(Mono.error(() -> new ServerWebInputException( "Role [" + roleName + "] is not found.")) ) ) .then(); }) .then(extensionGetter.getExtensions(UserPreCreatingHandler.class) .concatMap(handler -> handler.preCreating(user)) .then(Mono.defer(() -> client.create(user) .flatMap(newUser -> grantRoles(user.getMetadata().getName(), roleNames)) )) .flatMap(createdUser -> extensionGetter.getExtensions(UserPostCreatingHandler.class) .concatMap(handler -> handler.postCreating(createdUser)) .then() .thenReturn(createdUser) ) ); } @Override public Mono confirmPassword(String username, String rawPassword) { return getUser(username) .filter(user -> { if (!StringUtils.hasText(user.getSpec().getPassword())) { // If the password is not set, return true directly. return true; } if (!StringUtils.hasText(rawPassword)) { return false; } return passwordEncoder.matches(rawPassword, user.getSpec().getPassword()); }) .hasElement(); } @Override public Flux listByEmail(String email) { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of(equal("spec.email", email.toLowerCase()))); return client.listAll(User.class, listOptions, defaultSort()); } @Override public Mono checkEmailAlreadyVerified(String email) { return listByEmail(email) // TODO Use index query in the future .filter(u -> u.getSpec().isEmailVerified()) .hasElements(); } @Override public String encryptPassword(String rawPassword) { return passwordEncoder.encode(rawPassword); } @Override public Mono disable(String username) { var tx = TransactionalOperator.create(transactionManager); return client.fetch(User.class, username) .filter(user -> !Boolean.TRUE.equals(user.getSpec().getDisabled())) .flatMap(user -> deviceService.revoke(username).thenReturn(user)) .doOnNext(user -> user.getSpec().setDisabled(true)) .flatMap(client::update) .as(tx::transactional); } @Override public Mono enable(String username) { return client.fetch(User.class, username) .filter(user -> Boolean.TRUE.equals(user.getSpec().getDisabled())) .doOnNext(user -> user.getSpec().setDisabled(false)) .flatMap(client::update); } void publishPasswordChangedEvent(String username) { eventPublisher.publishEvent(new PasswordChangedEvent(this, username)); } private Set getProtectedUsernamesSet(SystemSetting.User setting) { String protectedUsernamesStr = setting.getProtectedUsernames(); if (protectedUsernamesStr == null || protectedUsernamesStr.trim().isEmpty()) { return Set.of(); } return Arrays.stream(protectedUsernamesStr.split(",")) .map(String::trim) .filter(n -> !n.isEmpty()) .map(String::toLowerCase) .collect(Collectors.toUnmodifiableSet()); } private boolean isUsernameAllowed(SystemSetting.User setting, String username) { Set protectedLowerSet = getProtectedUsernamesSet(setting); return !protectedLowerSet.contains(username.trim().toLowerCase()); } private boolean isDisplayNameAllowed(SystemSetting.User setting, String displayName) { Set protectedLowerSet = getProtectedUsernamesSet(setting); return !protectedLowerSet.contains(displayName.trim().toLowerCase()); } } ================================================ FILE: application/src/main/java/run/halo/app/event/post/CategoryHiddenStateChangeEvent.java ================================================ package run.halo.app.event.post; import lombok.Getter; import org.springframework.context.ApplicationEvent; import run.halo.app.core.extension.content.Category; /** * When the category {@link Category.CategorySpec#isHideFromList()} state changes, this event is * triggered. * * @author guqing * @since 2.17.0 */ @Getter public class CategoryHiddenStateChangeEvent extends ApplicationEvent { private final String categoryName; private final boolean hidden; public CategoryHiddenStateChangeEvent(Object source, String categoryName, boolean hidden) { super(source); this.categoryName = categoryName; this.hidden = hidden; } } ================================================ FILE: application/src/main/java/run/halo/app/event/post/CommentCreatedEvent.java ================================================ package run.halo.app.event.post; import lombok.Getter; import org.springframework.context.ApplicationEvent; import run.halo.app.core.extension.content.Comment; /** * Comment created event. * * @author guqing * @since 2.9.0 */ @Getter public class CommentCreatedEvent extends ApplicationEvent { private final Comment comment; public CommentCreatedEvent(Object source, Comment comment) { super(source); this.comment = comment; } } ================================================ FILE: application/src/main/java/run/halo/app/event/post/CommentUnreadReplyCountChangedEvent.java ================================================ package run.halo.app.event.post; import lombok.Getter; import org.springframework.context.ApplicationEvent; /** *

This event will be triggered when the unread reply count of the comment is changed.

*

It is used to update the unread reply count of the comment,such as when the user reads the * reply(lastReadTime changed in comment), the unread reply count will be updated.

* * @author guqing * @since 2.14.0 */ @Getter public class CommentUnreadReplyCountChangedEvent extends ApplicationEvent { private final String commentName; public CommentUnreadReplyCountChangedEvent(Object source, String commentName) { super(source); this.commentName = commentName; } } ================================================ FILE: application/src/main/java/run/halo/app/event/post/DownvotedEvent.java ================================================ package run.halo.app.event.post; /** * Downvote event. * * @author guqing * @since 2.0.0 */ public class DownvotedEvent extends VotedEvent { public DownvotedEvent(Object source, String group, String name, String plural) { super(source, group, name, plural); } } ================================================ FILE: application/src/main/java/run/halo/app/event/post/PostStatsChangedEvent.java ================================================ package run.halo.app.event.post; import lombok.Getter; import org.apache.commons.lang3.StringUtils; import org.springframework.context.ApplicationEvent; import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.content.Post; @Getter public class PostStatsChangedEvent extends ApplicationEvent { private final Counter counter; public PostStatsChangedEvent(Object source, Counter counter) { super(source); this.counter = counter; } public String getPostName() { var counterName = counter.getMetadata().getName(); return StringUtils.removeStart(counterName, MeterUtils.nameOf(Post.class, "")); } } ================================================ FILE: application/src/main/java/run/halo/app/event/post/ReplyChangedEvent.java ================================================ package run.halo.app.event.post; import run.halo.app.core.extension.content.Reply; /** * @author guqing * @since 2.0.0 */ public class ReplyChangedEvent extends ReplyEvent { public ReplyChangedEvent(Object source, Reply reply) { super(source, reply); } } ================================================ FILE: application/src/main/java/run/halo/app/event/post/ReplyCreatedEvent.java ================================================ package run.halo.app.event.post; import run.halo.app.core.extension.content.Reply; /** * Reply created event. * * @author guqing * @since 2.9.0 */ public class ReplyCreatedEvent extends ReplyEvent { public ReplyCreatedEvent(Object source, Reply reply) { super(source, reply); } } ================================================ FILE: application/src/main/java/run/halo/app/event/post/ReplyDeletedEvent.java ================================================ package run.halo.app.event.post; import run.halo.app.core.extension.content.Reply; public class ReplyDeletedEvent extends ReplyEvent { public ReplyDeletedEvent(Object source, Reply reply) { super(source, reply); } } ================================================ FILE: application/src/main/java/run/halo/app/event/post/ReplyEvent.java ================================================ package run.halo.app.event.post; import org.springframework.context.ApplicationEvent; import run.halo.app.core.extension.content.Reply; /** * @author guqing * @since 2.0.0 */ public abstract class ReplyEvent extends ApplicationEvent { private final Reply reply; public ReplyEvent(Object source, Reply reply) { super(source); this.reply = reply; } public Reply getReply() { return reply; } } ================================================ FILE: application/src/main/java/run/halo/app/event/post/UpvotedEvent.java ================================================ package run.halo.app.event.post; /** * Upvote event. * * @author guqing * @since 2.0.0 */ public class UpvotedEvent extends VotedEvent { public UpvotedEvent(Object source, String group, String name, String plural) { super(source, group, name, plural); } } ================================================ FILE: application/src/main/java/run/halo/app/event/post/VisitedEvent.java ================================================ package run.halo.app.event.post; import lombok.Getter; import org.springframework.context.ApplicationEvent; /** * @author guqing * @since 2.0.0 */ @Getter public class VisitedEvent extends ApplicationEvent { private final String group; private final String name; private final String plural; public VisitedEvent(Object source, String group, String name, String plural) { super(source); this.group = group; this.name = name; this.plural = plural; } } ================================================ FILE: application/src/main/java/run/halo/app/event/post/VotedEvent.java ================================================ package run.halo.app.event.post; import lombok.Getter; import org.springframework.context.ApplicationEvent; /** * @author guqing * @since 2.0.0 */ @Getter public abstract class VotedEvent extends ApplicationEvent { private final String group; private final String name; private final String plural; public VotedEvent(Object source, String group, String name, String plural) { super(source); this.group = group; this.name = name; this.plural = plural; } } ================================================ FILE: application/src/main/java/run/halo/app/event/user/PasswordChangedEvent.java ================================================ package run.halo.app.event.user; import lombok.Getter; import org.springframework.context.ApplicationEvent; @Getter public class PasswordChangedEvent extends ApplicationEvent { private final String username; public PasswordChangedEvent(Object source, String username) { super(source); this.username = username; } } ================================================ FILE: application/src/main/java/run/halo/app/extension/DefaultSchemeManager.java ================================================ package run.halo.app.extension; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; import org.springframework.context.ApplicationEventPublisher; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import run.halo.app.extension.event.SchemeAddedEvent; import run.halo.app.extension.event.SchemeRemovedEvent; import run.halo.app.extension.index.IndexEngine; import run.halo.app.extension.index.IndexSpecs; import run.halo.app.extension.index.ValueIndexSpec; @Component public class DefaultSchemeManager implements SchemeManager { private final List schemes; private final IndexEngine indexEngine; private final ApplicationEventPublisher eventPublisher; public DefaultSchemeManager(IndexEngine indexEngine, ApplicationEventPublisher eventPublisher) { this.indexEngine = indexEngine; this.eventPublisher = eventPublisher; // we have to use CopyOnWriteArrayList at here to prevent concurrent modification between // registering and listing. schemes = new CopyOnWriteArrayList<>(); } @Override public void register(Class type, Consumer> specsConsumer) { var scheme = Scheme.buildFromType(type); if (schemes.contains(scheme)) { return; } var indexSpecs = new DefaultIndexSpecs(); if (specsConsumer != null) { specsConsumer.accept(indexSpecs); } indexEngine.getIndicesManager().add(type, indexSpecs.getIndexSpecs()); schemes.add(scheme); eventPublisher.publishEvent(new SchemeAddedEvent(this, scheme)); } @Override public void unregister(@NonNull Scheme scheme) { if (schemes.contains(scheme)) { indexEngine.getIndicesManager().remove(scheme.type()); schemes.remove(scheme); eventPublisher.publishEvent(new SchemeRemovedEvent(this, scheme)); } } @Override @NonNull public List schemes() { return Collections.unmodifiableList(schemes); } private static class DefaultIndexSpecs implements IndexSpecs { private final Map> specMap; private DefaultIndexSpecs() { this.specMap = new HashMap<>(); } @Override public > void add(ValueIndexSpec indexSpec) { Assert.isTrue(!specMap.containsKey(indexSpec.getName()), "Index spec with name " + indexSpec.getName() + " already exists."); this.specMap.put(indexSpec.getName(), indexSpec); } @Override public List> getIndexSpecs() { return specMap.values().stream().toList(); } } } ================================================ FILE: application/src/main/java/run/halo/app/extension/DelegateExtensionClient.java ================================================ package run.halo.app.extension; import java.time.Duration; import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.function.Predicate; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import run.halo.app.extension.index.IndexedQueryEngine; /** * DelegateExtensionClient fully delegates ReactiveExtensionClient. * * @author johnniang */ @Component public class DelegateExtensionClient implements ExtensionClient { private static final Duration TIMEOUT = Duration.ofSeconds(30); private final ReactiveExtensionClient client; public DelegateExtensionClient(ReactiveExtensionClient client) { this.client = client; } @Override public List list(Class type, Predicate predicate, Comparator comparator) { return client.list(type, predicate, comparator).collectList().block(TIMEOUT); } @Override public ListResult list(Class type, Predicate predicate, Comparator comparator, int page, int size) { return client.list(type, predicate, comparator, page, size).block(TIMEOUT); } @Override public List listAll(Class type, ListOptions options, Sort sort) { return client.listAll(type, options, sort).collectList().block(TIMEOUT); } @Override public List listAllNames(Class type, ListOptions options, Sort sort) { return client.listAllNames(type, options, sort).collectList().block(TIMEOUT); } @Override public List listTopNames(Class type, ListOptions options, Sort sort, int topN) { return client.listTopNames(type, options, sort, topN).collectList().block(TIMEOUT); } @Override public ListResult listBy(Class type, ListOptions options, PageRequest page) { return client.listBy(type, options, page).block(TIMEOUT); } @Override public ListResult listNamesBy(Class type, ListOptions options, PageRequest page) { return client.listNamesBy(type, options, page).block(TIMEOUT); } @Override public long countBy(Class type, ListOptions options) { return client.countBy(type, options).blockOptional(TIMEOUT).orElse(0L); } @Override public Optional fetch(Class type, String name) { return client.fetch(type, name).blockOptional(TIMEOUT); } @Override public Optional fetch(GroupVersionKind gvk, String name) { return client.fetch(gvk, name).blockOptional(TIMEOUT); } @Override public void create(E extension) { client.create(extension).block(TIMEOUT); } @Override public void update(E extension) { client.update(extension).block(TIMEOUT); } @Override public void delete(E extension) { client.delete(extension).block(TIMEOUT); } @Override public IndexedQueryEngine indexedQueryEngine() { return client.indexedQueryEngine(); } @Override public void watch(Watcher watcher) { client.watch(watcher); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/ExtensionConverter.java ================================================ package run.halo.app.extension; import run.halo.app.extension.store.ExtensionStore; /** * ExtensionConverter contains bidirectional conversions between Extension and ExtensionStore. * * @author johnniang */ public interface ExtensionConverter { /** * Converts Extension to ExtensionStore. * * @param extension is an Extension to be converted. * @param is Extension type. * @return an ExtensionStore. */ ExtensionStore convertTo(E extension); /** * Converts Extension from ExtensionStore. * * @param type is Extension type. * @param extensionStore is an ExtensionStore * @param is Extension type. * @return an Extension */ E convertFrom(Class type, ExtensionStore extensionStore); } ================================================ FILE: application/src/main/java/run/halo/app/extension/ExtensionStoreUtil.java ================================================ package run.halo.app.extension; import org.springframework.util.StringUtils; /** * Extension utilities. * * @author johnniang */ public final class ExtensionStoreUtil { private ExtensionStoreUtil() { } /** * Builds the name prefix of ExtensionStore. * * @param scheme is scheme of an Extension. * @return name prefix of ExtensionStore. */ public static String buildStoreNamePrefix(Scheme scheme) { // rule of key: /registry/[group]/plural-name/extension-name StringBuilder builder = new StringBuilder("/registry/"); if (StringUtils.hasText(scheme.groupVersionKind().group())) { builder.append(scheme.groupVersionKind().group()).append('/'); } builder.append(scheme.plural()); return builder.toString(); } /** * Builds full name of ExtensionStore. * * @param scheme is scheme of an Extension. * @param name the exact name of Extension. * @return full name of ExtensionStore. */ public static String buildStoreName(Scheme scheme, String name) { return buildStoreNamePrefix(scheme) + "/" + name; } } ================================================ FILE: application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java ================================================ package run.halo.app.extension; import static run.halo.app.extension.ExtensionStoreUtil.buildStoreName; import static run.halo.app.extension.Unstructured.OBJECT_MAPPER; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.openapi4j.core.exception.ResolutionException; import org.openapi4j.core.model.v3.OAI3; import org.openapi4j.core.model.v3.OAI3Context; import org.openapi4j.schema.validator.ValidationContext; import org.openapi4j.schema.validator.ValidationData; import org.openapi4j.schema.validator.v3.SchemaValidator; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import reactor.core.Exceptions; import run.halo.app.extension.event.SchemeRemovedEvent; import run.halo.app.extension.exception.ExtensionConvertException; import run.halo.app.extension.exception.SchemaViolationException; import run.halo.app.extension.store.ExtensionStore; /** * JSON implementation of ExtensionConverter. * * @author johnniang */ @Slf4j @Component class JSONExtensionConverter implements ExtensionConverter { @Getter public ObjectMapper objectMapper; private final SchemeManager schemeManager; private final ConcurrentMap validatorMap = new ConcurrentHashMap<>(); public JSONExtensionConverter(SchemeManager schemeManager) { this.schemeManager = schemeManager; setObjectMapper(OBJECT_MAPPER); } /** * Sets ObjectMapper. * * @param objectMapper the object mapper, must not be null */ void setObjectMapper(ObjectMapper objectMapper) { Assert.notNull(objectMapper, "ObjectMapper must not be null"); this.objectMapper = objectMapper; } @Override public ExtensionStore convertTo(E extension) { var gvk = extension.groupVersionKind(); var scheme = schemeManager.get(gvk); try { var convertedExtension = Optional.of(extension) .map(item -> scheme.type().isAssignableFrom(item.getClass()) ? item : objectMapper.convertValue(item, scheme.type()) ) .orElseThrow(); var validation = new ValidationData<>(extension); var extensionJsonNode = objectMapper.valueToTree(convertedExtension); var validator = getValidator(scheme); validator.validate(extensionJsonNode, validation); if (!validation.isValid()) { log.debug("Failed to validate Extension: {}, and errors were: {}", extension.getClass(), validation.results()); throw new SchemaViolationException(extension.groupVersionKind(), validation.results()); } var version = extension.getMetadata().getVersion(); var storeName = buildStoreName(scheme, extension.getMetadata().getName()); var data = objectMapper.writeValueAsBytes(extensionJsonNode); return new ExtensionStore(storeName, data, version); } catch (IOException e) { throw new ExtensionConvertException("Failed write Extension as bytes", e); } catch (ResolutionException e) { throw new RuntimeException("Failed to create schema validator", e); } } @Override public E convertFrom(Class type, ExtensionStore extensionStore) { try { var extension = objectMapper.readValue(extensionStore.getData(), type); extension.getMetadata().setVersion(extensionStore.getVersion()); return extension; } catch (IOException e) { throw new ExtensionConvertException("Failed to read Extension " + type + " from bytes", e); } } @EventListener void onSchemeRemovedEvent(SchemeRemovedEvent event) { var removed = validatorMap.remove(event.getScheme()); if (log.isDebugEnabled()) { if (removed == null) { log.debug("No available validator found while removing validator for scheme: {}", event.getScheme().groupVersionKind() ); } else { log.debug("Removed schema validator {} for scheme: {}", removed, event.getScheme().groupVersionKind() ); } } } private SchemaValidator getValidator(Scheme scheme) throws MalformedURLException, ResolutionException { return validatorMap.computeIfAbsent(scheme, s -> { try { var context = new ValidationContext( new OAI3Context(new URL("file:/"), scheme.openApiSchema()) ); context.setFastFail(false); var validator = new SchemaValidator(context, null, scheme.openApiSchema()); if (log.isDebugEnabled()) { log.debug("Created schema validator {} for scheme: {}", validator, scheme.groupVersionKind() ); } return validator; } catch (ResolutionException | MalformedURLException e) { throw Exceptions.propagate(e); } }); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java ================================================ package run.halo.app.extension; import static org.apache.commons.lang3.RandomStringUtils.secure; import static org.springframework.util.StringUtils.hasText; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.time.Duration; import java.time.Instant; import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.function.Predicate; import java.util.stream.StreamSupport; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Sort; import org.springframework.data.util.Predicates; import org.springframework.stereotype.Component; import org.springframework.transaction.ReactiveTransactionManager; import org.springframework.transaction.reactive.TransactionalOperator; import org.springframework.util.Assert; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; import run.halo.app.extension.exception.ExtensionNotFoundException; import run.halo.app.extension.index.IndexEngine; import run.halo.app.extension.index.IndexedQueryEngine; import run.halo.app.extension.store.ReactiveExtensionStoreClient; @Slf4j @Component public class ReactiveExtensionClientImpl implements ReactiveExtensionClient { public static final int GENERATE_NAME_RANDOM_LENGTH = 8; private final ReactiveExtensionStoreClient client; private final ExtensionConverter converter; private final SchemeManager schemeManager; private final Watcher.WatcherComposite watchers = new Watcher.WatcherComposite(); private final ObjectMapper objectMapper; private final IndexEngine indexEngine; private Scheduler scheduler; private TransactionalOperator transactionalOperator; public ReactiveExtensionClientImpl(ReactiveExtensionStoreClient client, ExtensionConverter converter, SchemeManager schemeManager, ObjectMapper objectMapper, IndexEngine indexEngine, ReactiveTransactionManager reactiveTransactionManager) { this.client = client; this.converter = converter; this.schemeManager = schemeManager; this.objectMapper = objectMapper; this.indexEngine = indexEngine; this.transactionalOperator = TransactionalOperator.create(reactiveTransactionManager); this.scheduler = Schedulers.boundedElastic(); } /** * Only for test. * * @param scheduler the scheduler to set */ void setScheduler(Scheduler scheduler) { this.scheduler = scheduler; } /** * Only for test. */ void setTransactionalOperator(TransactionalOperator transactionalOperator) { this.transactionalOperator = transactionalOperator; } @Override public Flux list(Class type, Predicate predicate, Comparator comparator) { var scheme = schemeManager.get(type); var prefix = ExtensionStoreUtil.buildStoreNamePrefix(scheme); return client.listByNamePrefix(prefix) .map(extensionStore -> converter.convertFrom(type, extensionStore)) .filter(predicate == null ? Predicates.isTrue() : predicate) .sort(comparator == null ? Comparator.naturalOrder() : comparator); } @Override public Mono> list(Class type, Predicate predicate, Comparator comparator, int page, int size) { var extensions = list(type, predicate, comparator); var totalMono = extensions.count(); if (page > 0) { extensions = extensions.skip(((long) (page - 1)) * (long) size); } if (size > 0) { extensions = extensions.take(size); } return extensions.collectList().zipWith(totalMono) .map(tuple -> { List content = tuple.getT1(); Long total = tuple.getT2(); return new ListResult<>(page, size, total, content); }); } @Override public Flux listAll(Class type, ListOptions options, Sort sort) { var nullSafeSort = Optional.ofNullable(sort) .orElseGet(() -> { log.warn("The sort parameter is null, it is recommended to use Sort.unsorted() " + "instead and the compatibility support for null will be removed in the " + "subsequent version."); return Sort.unsorted(); }); var scheme = schemeManager.get(type); return Mono.fromCallable( () -> indexEngine.retrieveAll(scheme.type(), options, nullSafeSort) ) .flatMapMany(objectKeys -> { var storeNames = StreamSupport.stream(objectKeys.spliterator(), false) .map(objectKey -> ExtensionStoreUtil.buildStoreName(scheme, objectKey)) .toList(); if (log.isDebugEnabled()) { if (storeNames.size() > 500) { log.warn(""" The number of objects retrieved by listAll is too large ({}) \ and it is recommended to use paging query.\ """, storeNames.size() ); } } long startTimeMs = System.currentTimeMillis(); return client.listByNames(storeNames) .map(extensionStore -> converter.convertFrom(type, extensionStore)) .doOnComplete(() -> log.debug( "Successfully retrieved all by names from db for {} in {}ms", scheme.groupVersionKind(), System.currentTimeMillis() - startTimeMs) ); }); } @Override public Flux listAllNames( Class type, ListOptions options, Sort sort ) { var scheme = schemeManager.get(type); return Mono.fromCallable(() -> indexEngine.retrieveAll(scheme.type(), options, sort)) .flatMapMany(Flux::fromIterable); } @Override public Flux listTopNames( Class type, ListOptions options, Sort sort, int topN ) { var scheme = schemeManager.get(type); return Mono.fromCallable(() -> indexEngine.retrieveTopN(scheme.type(), options, sort, topN)) .flatMapMany(Flux::fromIterable); } @Override public Mono> listBy(Class type, ListOptions options, PageRequest page) { var scheme = schemeManager.get(type); return Mono.fromCallable(() -> indexEngine.retrieve(scheme.type(), options, page)) .flatMap(listResult -> { var storeNames = listResult.get() .map(objectKey -> ExtensionStoreUtil.buildStoreName(scheme, objectKey)) .toList(); final long startTimeMs = System.currentTimeMillis(); return client.listByNames(storeNames) .map(extensionStore -> converter.convertFrom(type, extensionStore)) .doOnComplete(() -> log.debug( "Successfully retrieved by names from db for {} in {}ms", scheme.groupVersionKind(), System.currentTimeMillis() - startTimeMs) ) .collectList() .map(items -> new ListResult<>(page.getPageNumber(), page.getPageSize(), listResult.getTotal(), items) ); }) .defaultIfEmpty(ListResult.emptyResult()); } @Override public Mono> listNamesBy(Class type, ListOptions options, PageRequest pageable) { var scheme = schemeManager.get(type); return Mono.fromCallable(() -> indexEngine.retrieve(scheme.type(), options, pageable)); } @Override public Mono countBy(Class type, ListOptions options) { var scheme = schemeManager.get(type); return Mono.fromCallable(() -> indexEngine.count(scheme.type(), options)); } @Override public Mono fetch(Class type, String name) { var storeName = ExtensionStoreUtil.buildStoreName(schemeManager.get(type), name); return client.fetchByName(storeName) .map(extensionStore -> converter.convertFrom(type, extensionStore)); } @Override public Mono fetch(GroupVersionKind gvk, String name) { var storeName = ExtensionStoreUtil.buildStoreName(schemeManager.get(gvk), name); return client.fetchByName(storeName) .map(extensionStore -> converter.convertFrom(Unstructured.class, extensionStore)); } private Mono fetchJsonExtension(GroupVersionKind gvk, String name) { var storeName = ExtensionStoreUtil.buildStoreName(schemeManager.get(gvk), name); return client.fetchByName(storeName) .map(extensionStore -> converter.convertFrom(JsonExtension.class, extensionStore)); } @Override public Mono get(Class type, String name) { return fetch(type, name) .switchIfEmpty(Mono.error(() -> { var gvk = GroupVersionKind.fromExtension(type); return new ExtensionNotFoundException(gvk, name); })); } private Mono get(GroupVersionKind gvk, String name) { return fetch(gvk, name) .switchIfEmpty(Mono.error(() -> new ExtensionNotFoundException(gvk, name))); } @Override public Mono getJsonExtension(GroupVersionKind gvk, String name) { return fetchJsonExtension(gvk, name) .switchIfEmpty(Mono.error(() -> new ExtensionNotFoundException(gvk, name))); } @Override public Mono create(E extension) { return Mono.fromCallable( () -> { var metadata = extension.getMetadata(); // those fields should be managed by halo. metadata.setCreationTimestamp(Instant.now()); metadata.setDeletionTimestamp(null); metadata.setVersion(null); if (!hasText(metadata.getName())) { if (!hasText(metadata.getGenerateName())) { throw new IllegalArgumentException( "The metadata.generateName must not be blank when metadata.name is " + "blank"); } // generate name with random text metadata.setName(metadata.getGenerateName() + secure() .nextAlphanumeric(GENERATE_NAME_RANDOM_LENGTH) // Prevent data conflicts caused by database case sensitivity .toLowerCase() ); } extension.setMetadata(metadata); return converter.convertTo(extension); }) .subscribeOn(this.scheduler) // the method secureStrong() may invoke blocking SecureRandom, so we need to subscribe // on boundedElastic thread pool. .flatMap(extStore -> doCreate(extension, extStore.getName(), extStore.getData())) .flatMap(created -> Mono.fromCallable( () -> { watchers.onAdd(convertToRealExtension(created)); return created; }) .subscribeOn(this.scheduler) ) .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) // retry when generateName is set .filter(t -> t instanceof DataIntegrityViolationException && hasText(extension.getMetadata().getGenerateName())) ); } @Override public Mono update(E extension) { // Refactor the atomic reference if we have a better solution. return getLatest(extension).flatMap(old -> { var oldJsonExt = new JsonExtension(objectMapper, old); var newJsonExt = new JsonExtension(objectMapper, extension); // reset some mandatory fields var oldMetadata = oldJsonExt.getMetadata(); var newMetadata = newJsonExt.getMetadata(); newMetadata.setCreationTimestamp(oldMetadata.getCreationTimestamp()); newMetadata.setGenerateName(oldMetadata.getGenerateName()); // If the extension is an unstructured, the version type may be integer instead of long. // reset metadata.version for long type. oldMetadata.setVersion(oldMetadata.getVersion()); newMetadata.setVersion(newMetadata.getVersion()); if (Objects.equals(oldJsonExt, newJsonExt)) { // skip updating if not data changed. return Mono.just(extension); } var onlyStatusChanged = isOnlyStatusChanged(oldJsonExt.getInternal(), newJsonExt.getInternal()); var store = this.converter.convertTo(newJsonExt); var doUpdate = doUpdate(extension, store.getName(), store.getVersion(), store.getData()); if (!onlyStatusChanged) { doUpdate = doUpdate.flatMap(updated -> Mono.fromCallable( () -> { watchers.onUpdate(convertToRealExtension(old), convertToRealExtension(updated)); return updated; }) .subscribeOn(this.scheduler) ); } return doUpdate; }); } private Mono getLatest(Extension extension) { if (extension instanceof Unstructured) { return get(extension.groupVersionKind(), extension.getMetadata().getName()); } if (extension instanceof JsonExtension) { return getJsonExtension( extension.groupVersionKind(), extension.getMetadata().getName() ); } return get(extension.getClass(), extension.getMetadata().getName()); } @Override public Mono delete(E extension) { // make sure the version is not null, or it will cause extension insertion. Assert.notNull(extension.getMetadata().getVersion(), "Extension version must not be null"); // set deletionTimestamp extension.getMetadata().setDeletionTimestamp(Instant.now()); var es = converter.convertTo(extension); return doUpdate(extension, es.getName(), es.getVersion(), es.getData()) .flatMap(deleted -> Mono.fromCallable( () -> { watchers.onDelete(convertToRealExtension(extension)); return deleted; }) .subscribeOn(this.scheduler) ); } @Override public IndexedQueryEngine indexedQueryEngine() { return new IndexedQueryEngine() { @Override public ListResult retrieve(GroupVersionKind gvk, ListOptions options, PageRequest page) { var scheme = schemeManager.get(gvk); return indexEngine.retrieve(scheme.type(), options, page); } @Override public List retrieveAll(GroupVersionKind gvk, ListOptions options, Sort sort) { var scheme = schemeManager.get(gvk); return StreamSupport.stream( indexEngine.retrieveAll(scheme.type(), options, sort).spliterator(), false ) .toList(); } }; } /** * Create extension in store and update index. Please make sure subscribe on proper scheduler. * * @param oldExtension the extension to create * @param name the name of the extension * @param data the data of the extension * @param the type of the extension * @return the created extension */ @SuppressWarnings("unchecked") Mono doCreate(E oldExtension, String name, byte[] data) { return Mono.defer(() -> { var type = (Class) oldExtension.getClass(); return client.create(name, data) .map(created -> converter.convertFrom(type, created)) .flatMap(extension -> Mono.fromCallable( () -> { this.indexEngine.insert(List.of(convertToRealExtension(extension))); return extension; }) .subscribeOn(this.scheduler) ) .as(transactionalOperator::transactional); }); } /** * Update extension in store and update index. Please make sure subscribe on proper scheduler. * * @param oldExtension the extension to update * @param name the name of the extension * @param version the version of the extension * @param data the data of the extension * @param the type of the extension * @return the updated extension */ @SuppressWarnings("unchecked") Mono doUpdate(E oldExtension, String name, Long version, byte[] data) { return Mono.defer(() -> { var type = (Class) oldExtension.getClass(); return client.update(name, version, data) .map(updated -> converter.convertFrom(type, updated)) .flatMap(extension -> Mono.fromCallable( () -> { this.indexEngine.update(List.of(convertToRealExtension(extension))); return extension; }) .subscribeOn(this.scheduler) ) .as(transactionalOperator::transactional); }); } private Extension convertToRealExtension(Extension extension) { var gvk = extension.groupVersionKind(); var realType = schemeManager.get(gvk).type(); Extension realExtension = extension; if (extension instanceof Unstructured) { realExtension = Unstructured.OBJECT_MAPPER.convertValue(extension, realType); } else if (extension instanceof JsonExtension jsonExtension) { realExtension = jsonExtension.getObjectMapper().convertValue(jsonExtension, realType); } return realExtension; } @Override public void watch(Watcher watcher) { this.watchers.addWatcher(watcher); } private static boolean isOnlyStatusChanged(ObjectNode oldNode, ObjectNode newNode) { if (Objects.equals(oldNode, newNode)) { return false; } // WARNING!!! // Do not edit the ObjectNode var oldFields = new HashSet(); var newFields = new HashSet(); oldNode.fieldNames().forEachRemaining(oldFields::add); newNode.fieldNames().forEachRemaining(newFields::add); oldFields.remove("status"); newFields.remove("status"); if (!Objects.equals(oldFields, newFields)) { return false; } for (var field : oldFields) { if (!Objects.equals(oldNode.get(field), newNode.get(field))) { return false; } } return true; } } ================================================ FILE: application/src/main/java/run/halo/app/extension/availability/IndexBuildState.java ================================================ package run.halo.app.extension.availability; import org.springframework.boot.availability.AvailabilityState; public enum IndexBuildState implements AvailabilityState { BUILDING, BUILT; } ================================================ FILE: application/src/main/java/run/halo/app/extension/controller/DefaultControllerManager.java ================================================ package run.halo.app.extension.controller; import static org.springframework.core.ResolvableType.forClassWithGenerics; import java.util.concurrent.ConcurrentHashMap; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.context.SmartLifecycle; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.controller.Reconciler.Request; import run.halo.app.infra.InitializationPhase; @Slf4j public class DefaultControllerManager implements ApplicationContextAware, SmartLifecycle { private final ExtensionClient client; private ApplicationContext applicationContext; /** * Map with key: reconciler class name, value: controller self. */ private final ConcurrentHashMap controllers; private volatile boolean running; public DefaultControllerManager(ExtensionClient client) { this.client = client; controllers = new ConcurrentHashMap<>(); } @Override public void start() { if (this.running) { return; } this.running = true; // register reconcilers in system after scheme initialized applicationContext.>getBeanProvider( forClassWithGenerics(Reconciler.class, Request.class)) .orderedStream() .forEach(this::start); } void start(Reconciler reconciler) { var builder = new ControllerBuilder(reconciler, client); var controller = reconciler.setupWith(builder); controllers.put(reconciler.getClass().getName(), controller); controller.start(); } @Override public void stop() { if (!running) { return; } this.running = false; log.info("Shutting down {} controllers...", controllers.size()); controllers.forEach((name, controller) -> disposeSilently(controller)); log.info("Shutdown {} controllers.", controllers.size()); } private static void disposeSilently(Controller controller) { if (controller == null) { return; } try { log.info("Shutting down controller {}...", controller.getName()); controller.dispose(); log.info("Shutdown controller {} successfully", controller.getName()); } catch (Throwable t) { log.error("Failed to dispose controller {}", controller.getName(), t); } } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } @Override public boolean isRunning() { return running; } @Override public int getPhase() { return InitializationPhase.CONTROLLERS.getPhase(); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/event/IndexerBuiltEvent.java ================================================ package run.halo.app.extension.event; import lombok.Getter; import org.springframework.context.ApplicationEvent; import run.halo.app.extension.Scheme; /** * IndexBuildEvent is fired when index build of a scheme is triggered and completed. */ public class IndexerBuiltEvent extends ApplicationEvent { @Getter private final Scheme scheme; public IndexerBuiltEvent(Object source, Scheme scheme) { super(source); this.scheme = scheme; } } ================================================ FILE: application/src/main/java/run/halo/app/extension/event/SchemeAddedEvent.java ================================================ package run.halo.app.extension.event; import lombok.Getter; import org.springframework.context.ApplicationEvent; import run.halo.app.extension.Scheme; /** * Event published when a scheme is added. * * @author johnniang */ public class SchemeAddedEvent extends ApplicationEvent { @Getter private final Scheme scheme; public SchemeAddedEvent(Object source, Scheme scheme) { super(source); this.scheme = scheme; } } ================================================ FILE: application/src/main/java/run/halo/app/extension/event/SchemeRemovedEvent.java ================================================ package run.halo.app.extension.event; import lombok.Getter; import org.springframework.context.ApplicationEvent; import run.halo.app.extension.Scheme; /** * Event published when a scheme is removed. * * @author johnniang */ public class SchemeRemovedEvent extends ApplicationEvent { @Getter private final Scheme scheme; public SchemeRemovedEvent(Object source, Scheme scheme) { super(source); this.scheme = scheme; } } ================================================ FILE: application/src/main/java/run/halo/app/extension/exception/ExtensionConvertException.java ================================================ package run.halo.app.extension.exception; /** * ExtensionConvertException is thrown when an Extension conversion error occurs. * * @author johnniang */ public class ExtensionConvertException extends ExtensionException { public ExtensionConvertException(String reason) { super(reason); } public ExtensionConvertException(String reason, Throwable cause) { super(reason, cause); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/exception/ExtensionNotFoundException.java ================================================ package run.halo.app.extension.exception; import java.net.URI; import org.springframework.http.HttpStatus; import run.halo.app.extension.GroupVersionKind; public class ExtensionNotFoundException extends ExtensionException { public static final URI TYPE = URI.create("https://www.halo.run/api/errors/extension-not-found"); public ExtensionNotFoundException(GroupVersionKind gvk, String name) { super(HttpStatus.NOT_FOUND, "Extension " + gvk + "/" + name + " was not found.", null, null, new Object[] {gvk, name}); setType(TYPE); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/exception/SchemaViolationException.java ================================================ package run.halo.app.extension.exception; import org.openapi4j.core.validation.ValidationResults; import org.springframework.http.HttpStatus; import run.halo.app.extension.GroupVersionKind; /** * This exception is thrown when Schema is violation. * * @author johnniang */ public class SchemaViolationException extends ExtensionException { /** * Validation errors. */ private final ValidationResults errors; public SchemaViolationException(GroupVersionKind gvk, ValidationResults errors) { super(HttpStatus.BAD_REQUEST, "Failed to validate " + gvk, null, null, new Object[] {gvk, errors}); this.errors = errors; } public ValidationResults getErrors() { return errors; } } ================================================ FILE: application/src/main/java/run/halo/app/extension/gc/GcControllerInitializer.java ================================================ package run.halo.app.extension.gc; import org.springframework.context.SmartLifecycle; import org.springframework.stereotype.Component; import run.halo.app.extension.controller.Controller; import run.halo.app.infra.InitializationPhase; @Component class GcControllerInitializer implements SmartLifecycle { private volatile boolean running; private final Controller gcController; public GcControllerInitializer(GcReconciler gcReconciler) { this.gcController = gcReconciler.setupWith(null); } @Override public void start() { if (running) { return; } running = true; gcController.start(); } @Override public void stop() { if (!running) { return; } running = false; gcController.dispose(); } @Override public boolean isRunning() { return running; } @Override public int getPhase() { return InitializationPhase.GC_CONTROLLER.getPhase(); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/gc/GcReconciler.java ================================================ package run.halo.app.extension.gc; import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.function.Predicate; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.transaction.ReactiveTransactionManager; import org.springframework.transaction.reactive.TransactionalOperator; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; import run.halo.app.extension.Extension; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionConverter; import run.halo.app.extension.SchemeManager; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.DefaultController; import run.halo.app.extension.controller.DefaultQueue; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.RequestQueue; import run.halo.app.extension.event.SchemeAddedEvent; import run.halo.app.extension.index.IndexEngine; import run.halo.app.extension.store.ReactiveExtensionStoreClient; @Slf4j @Component class GcReconciler implements Reconciler { private final ExtensionClient client; private final ReactiveExtensionStoreClient storeClient; private final ExtensionConverter converter; private final IndexEngine indexEngine; private final SchemeManager schemeManager; private final RequestQueue queue; private final GcSynchronizer synchronizer; private final ReactiveTransactionManager transactionManager; private Scheduler scheduler; GcReconciler(ExtensionClient client, ReactiveExtensionStoreClient storeClient, ExtensionConverter converter, SchemeManager schemeManager, IndexEngine indexEngine, ReactiveTransactionManager transactionManager) { this.client = client; this.storeClient = storeClient; this.converter = converter; this.indexEngine = indexEngine; this.transactionManager = transactionManager; this.queue = new DefaultQueue<>(Instant::now, Duration.ofMillis(500)); this.synchronizer = new GcSynchronizer(client, queue, schemeManager); this.schemeManager = schemeManager; this.scheduler = Schedulers.boundedElastic(); } @Override public Result reconcile(GcRequest request) { log.debug("Extension {} is being deleted", request); var scheme = schemeManager.get(request.gvk()); client.fetch(scheme.type(), request.name()) .filter(deletable()) .ifPresent(extension -> doDelete(extension).blockOptional(Duration.ofSeconds(30))); return null; } private Mono doDelete(E extension) { var extensionStore = converter.convertTo(extension); var tx = TransactionalOperator.create(transactionManager); return storeClient.delete(extensionStore.getName(), extensionStore.getVersion()) .flatMap(deleted -> Mono.fromRunnable(() -> indexEngine.delete(List.of(extension))) .subscribeOn(this.scheduler) ) .as(tx::transactional) .then() .doOnSuccess(ignored -> log.info("Extension {}/{} was deleted", extension.groupVersionKind(), extension) ); } @Override public Controller setupWith(ControllerBuilder builder) { return new DefaultController<>( "garbage-collector-controller", this, queue, synchronizer, Duration.ofMillis(500), Duration.ofSeconds(1000), // TODO Make it configurable 10); } @EventListener void onSchemeAddedEvent(SchemeAddedEvent event) { synchronizer.onApplicationEvent(event); } private Predicate deletable() { return extension -> CollectionUtils.isEmpty(extension.getMetadata().getFinalizers()) && extension.getMetadata().getDeletionTimestamp() != null; } } ================================================ FILE: application/src/main/java/run/halo/app/extension/gc/GcRequest.java ================================================ package run.halo.app.extension.gc; import org.springframework.util.Assert; import run.halo.app.extension.GroupVersionKind; record GcRequest(GroupVersionKind gvk, String name) { public GcRequest { Assert.notNull(gvk, "Group, version and kind must not be null"); Assert.hasText(name, "Extension name must not be blank"); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/gc/GcSynchronizer.java ================================================ package run.halo.app.extension.gc; import static run.halo.app.extension.index.query.Queries.isNull; import static run.halo.app.extension.index.query.Queries.not; import java.util.List; import org.springframework.context.ApplicationListener; import org.springframework.data.domain.Sort; import run.halo.app.extension.Extension; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Scheme; import run.halo.app.extension.SchemeManager; import run.halo.app.extension.Watcher; import run.halo.app.extension.controller.RequestQueue; import run.halo.app.extension.controller.Synchronizer; import run.halo.app.extension.event.SchemeAddedEvent; import run.halo.app.extension.router.selector.FieldSelector; class GcSynchronizer implements Synchronizer, ApplicationListener { private final ExtensionClient client; private final SchemeManager schemeManager; private boolean disposed = false; private boolean started = false; private final Watcher watcher; GcSynchronizer(ExtensionClient client, RequestQueue queue, SchemeManager schemeManager) { this.client = client; this.schemeManager = schemeManager; this.watcher = new GcWatcher(queue); } @Override public void dispose() { if (isDisposed()) { return; } this.disposed = true; this.watcher.dispose(); } @Override public boolean isDisposed() { return disposed; } @Override public void onApplicationEvent(SchemeAddedEvent event) { if (started) { var scheme = event.getScheme(); listDeleted(scheme.type()).forEach(watcher::onDelete); } } @Override public void start() { if (isDisposed() || started) { return; } this.started = true; client.watch(watcher); schemeManager.schemes().stream() .map(Scheme::type) .forEach(type -> listDeleted(type).forEach(watcher::onDelete)); } List listDeleted(Class type) { var options = new ListOptions() .setFieldSelector( FieldSelector.of(not(isNull("metadata.deletionTimestamp"))) ); // TODO Refine with scrolling query return client.listAll(type, options, Sort.by(Sort.Order.asc("metadata.creationTimestamp"))); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/gc/GcWatcher.java ================================================ package run.halo.app.extension.gc; import run.halo.app.extension.Extension; import run.halo.app.extension.Watcher; import run.halo.app.extension.controller.RequestQueue; class GcWatcher implements Watcher { private final RequestQueue queue; private Runnable disposeHook; private boolean disposed = false; GcWatcher(RequestQueue queue) { this.queue = queue; } @Override public void onAdd(Extension extension) { // TODO Should we ignore finalizers here? if (!isDisposed() && extension.getMetadata().getDeletionTimestamp() != null) { queue.addImmediately( new GcRequest(extension.groupVersionKind(), extension.getMetadata().getName())); } } @Override public void onUpdate(Extension oldExt, Extension newExt) { if (!isDisposed() && newExt.getMetadata().getDeletionTimestamp() != null) { queue.addImmediately( new GcRequest(newExt.groupVersionKind(), newExt.getMetadata().getName())); } } @Override public void onDelete(Extension extension) { if (!isDisposed() && extension.getMetadata().getDeletionTimestamp() != null) { queue.addImmediately( new GcRequest(extension.groupVersionKind(), extension.getMetadata().getName())); } } @Override public void registerDisposeHook(Runnable dispose) { this.disposeHook = dispose; } @Override public void dispose() { if (isDisposed()) { return; } this.disposed = true; if (this.disposeHook != null) { this.disposeHook.run(); } } @Override public boolean isDisposed() { return disposed; } } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/DefaultIndexEngine.java ================================================ package run.halo.app.extension.index; import java.util.Comparator; import java.util.LinkedList; import java.util.PriorityQueue; import org.springframework.beans.factory.DisposableBean; import org.springframework.core.convert.ConversionService; import org.springframework.data.domain.Sort; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import run.halo.app.extension.Extension; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.index.query.QueryVisitor; /** * Default implementation of {@link IndexEngine}. * * @author johnniang * @since 2.22.0 */ @Component class DefaultIndexEngine implements IndexEngine, DisposableBean { private IndicesManager indicesManager; private final ConversionService conversionService; public DefaultIndexEngine(ConversionService conversionService) { this.conversionService = conversionService; this.indicesManager = new DefaultIndicesManager(); } /** * Set the indices manager. Only for testing purpose. * * @param indicesManager the indices manager */ void setIndicesManager(IndicesManager indicesManager) { Assert.notNull(indicesManager, "indicesManager must not be null"); this.indicesManager = indicesManager; } @Override public void destroy() throws Exception { this.indicesManager.close(); } @Override public void insert(@NonNull Iterable extensions) { extensions.forEach(extension -> { // get indices manager var indices = indicesManager.get((Class) extension.getClass()); indices.insert(extension); }); } @Override public void update(@NonNull Iterable extensions) { extensions.forEach(extension -> { var indices = indicesManager.get((Class) extension.getClass()); indices.update(extension); }); } @Override public void delete(@NonNull Iterable extensions) { extensions.forEach(extension -> { var indices = indicesManager.get((Class) extension.getClass()); indices.delete(extension); }); } @Override public ListResult retrieve( Class type, ListOptions options, PageRequest page) { if (options == null) { options = ListOptions.builder().build(); } var finalCondition = options.toCondition(); var indices = indicesManager.get(type); var queryVisitor = new QueryVisitor<>(indices, conversionService); queryVisitor.enter(finalCondition); var result = queryVisitor.getResult(); var total = result.size(); // create comparator var sort = page.getSort(); var comparator = buildComparator(sort, indices); int offset = (page.getPageNumber() - 1) * page.getPageSize(); int limit = page.getPageSize(); if (limit <= 0) { // return all results for backward compatibility var finalResult = result.stream().sorted(comparator).toList(); return new ListResult<>( page.getPageNumber(), page.getPageSize(), total, finalResult ); } if (offset >= total) { return new ListResult<>( page.getPageNumber(), page.getPageSize(), total, new LinkedList<>() ); } if (offset + limit > total) { limit = total - offset; } var n = offset + limit; if (n > 1000) { var finalResult = result.stream().sorted(comparator) .skip(offset) .limit(limit) .toList(); return new ListResult<>( page.getPageNumber(), page.getPageSize(), total, finalResult ); } var pq = new PriorityQueue<>(n, comparator.reversed()); result.forEach(primaryKey -> { pq.offer(primaryKey); if (pq.size() > n) { pq.poll(); } }); var finalResult = new LinkedList(); while (!pq.isEmpty()) { finalResult.addFirst(pq.poll()); if (finalResult.size() >= limit) { // no need to compare further break; } } pq.clear(); return new ListResult<>( page.getPageNumber(), page.getPageSize(), total, finalResult ); } @Override public Iterable retrieveAll( Class type, ListOptions options, Sort sort) { if (options == null) { options = ListOptions.builder().build(); } if (sort == null) { sort = Sort.unsorted(); } var finalCondition = options.toCondition(); var indices = indicesManager.get(type); var queryVisitor = new QueryVisitor<>(indices, conversionService); queryVisitor.enter(finalCondition); var result = queryVisitor.getResult(); if (sort.isUnsorted()) { // no need to sort the result return result.stream()::iterator; } // create comparator var comparator = buildComparator(sort, indices); return result.stream().sorted(comparator)::iterator; } @Override public Iterable retrieveTopN( Class type, ListOptions options, Sort sort, int topN) { Assert.isTrue(topN > 0, "topN must be greater than 0"); if (options == null) { options = ListOptions.builder().build(); } if (sort == null) { sort = Sort.unsorted(); } var finalCondition = options.toCondition(); var indices = indicesManager.get(type); var queryVisitor = new QueryVisitor<>(indices, conversionService); queryVisitor.enter(finalCondition); var result = queryVisitor.getResult(); // create comparator var comparator = buildComparator(sort, indices); // make sure using reversed comparator to get top N var pq = new PriorityQueue<>(topN + 1, comparator.reversed()); result.forEach(primaryKey -> { pq.offer(primaryKey); if (pq.size() > topN) { pq.poll(); } }); var finalResult = new LinkedList(); while (!pq.isEmpty()) { finalResult.addFirst(pq.poll()); } return finalResult; } @Override public long count(Class type, ListOptions options) { if (options == null) { options = ListOptions.builder().build(); } var finalCondition = options.toCondition(); var indices = indicesManager.get(type); var queryVisitor = new QueryVisitor<>(indices, conversionService); queryVisitor.enter(finalCondition); return queryVisitor.getResult().size(); } @Override @NonNull public IndicesManager getIndicesManager() { return this.indicesManager; } private Comparator buildComparator( Sort sort, Indices indices ) { return sort.stream() .map(order -> buildComparator(order, indices)) .reduce(Comparator::thenComparing) .orElseGet(Comparator::naturalOrder); } private , E extends Extension> Comparator buildComparator( Sort.Order order, Indices indices ) { var index = indices.getIndex(order.getProperty()); Comparator comparator; if (index instanceof MultiValueIndex multiValueIndex) { comparator = (left, right) -> { var leftKeys = multiValueIndex.getKeys(left); var rightKeys = multiValueIndex.getKeys(right); // null first by default if (CollectionUtils.isEmpty(leftKeys)) { return CollectionUtils.isEmpty(rightKeys) ? 0 : -1; } if (CollectionUtils.isEmpty(rightKeys)) { return 1; } // compare the first key K leftKey = leftKeys.iterator().next(); K rightKey = rightKeys.iterator().next(); return Comparator.naturalOrder().compare(leftKey, rightKey); }; } else if (index instanceof SingleValueIndex singleValueIndex) { comparator = (left, right) -> { K leftKey = singleValueIndex.getKey(left); K rightKey = singleValueIndex.getKey(right); // null first by default if (leftKey == null) { return rightKey == null ? 0 : -1; } if (rightKey == null) { return 1; } return leftKey.compareTo(rightKey); }; } else { throw new UnsupportedOperationException( "Unsupported index type for sorting: " + index.getClass() ); } if (order.isDescending()) { comparator = comparator.reversed(); } return comparator; } } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/DefaultIndices.java ================================================ package run.halo.app.extension.index; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import java.io.IOException; import java.time.Duration; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Function; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; import org.springframework.lang.NonNull; import run.halo.app.extension.Extension; /** * Default implementation of {@link Indices}. * * @param the type of extension * @author johnniang * @since 2.22.0 */ @Slf4j class DefaultIndices implements Indices { private final Map> indexMap; private final Cache lockCache; private volatile boolean closed; public DefaultIndices(List> indices) { this.indexMap = indices.stream() .collect(Collectors.toMap( Index::getName, Function.identity(), // keep existing in case of duplicate names (existing, replacing) -> existing, // keep insertion order LinkedHashMap::new) ); this.lockCache = Caffeine.newBuilder() .expireAfterAccess(Duration.ofHours(1)) .maximumSize(10_000) .build(); } @Override public void close() throws IOException { closed = true; IOUtils.close(indexMap.values().toArray(Index[]::new)); lockCache.invalidateAll(); } @Override public void insert(E extension) { ensureNotClosed(); // get primary key var primaryKey = extension.getMetadata().getName(); var lock = Objects.requireNonNull( lockCache.get(primaryKey, pk -> new ReentrantReadWriteLock()) ).writeLock(); var ops = new ArrayList(); lock.lock(); try { for (var index : indexMap.values()) { var op = index.prepareInsert(extension); op.prepare(); ops.add(op); } ops.forEach(TransactionalOperation::commit); } catch (Exception e) { log.warn("Failed to insert extension {} and trying to rollback", primaryKey, e); ops.forEach(TransactionalOperation::rollback); throw e; } finally { lock.unlock(); } } @Override public void update(E extension) { ensureNotClosed(); var primaryKey = extension.getMetadata().getName(); var lock = Objects.requireNonNull( lockCache.get(primaryKey, pk -> new ReentrantReadWriteLock()) ).writeLock(); var updaters = new ArrayList(); lock.lock(); try { for (var index : indexMap.values()) { var updater = index.prepareUpdate(extension); updater.prepare(); updaters.add(updater); } updaters.forEach(TransactionalOperation::commit); } catch (Exception e) { updaters.forEach(TransactionalOperation::rollback); throw e; } finally { lock.unlock(); } } @Override public void delete(E extension) { ensureNotClosed(); var primaryKey = extension.getMetadata().getName(); var lock = Objects.requireNonNull( lockCache.get(primaryKey, pk -> new ReentrantReadWriteLock()) ).writeLock(); var updaters = new ArrayList(); lock.lock(); try { for (var index : indexMap.values()) { var updater = index.prepareDelete(primaryKey); updater.prepare(); updaters.add(updater); } updaters.forEach(TransactionalOperation::commit); } catch (Exception e) { updaters.forEach(TransactionalOperation::rollback); throw e; } finally { lock.unlock(); } } @Override @NonNull public > Index getIndex(String indexName) { ensureNotClosed(); var index = (Index) indexMap.get(indexName); if (index == null) { throw new IllegalArgumentException("No index found with name: " + indexName); } return index; } private void ensureNotClosed() { if (closed) { throw new IllegalStateException("Indices has been closed"); } } } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/DefaultIndicesManager.java ================================================ package run.halo.app.extension.index; import java.io.IOException; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.stream.Stream; import org.apache.commons.io.IOUtils; import run.halo.app.extension.Extension; /** * Default implementation of {@link IndicesManager}. * * @author johnniang * @since 2.22.0 */ class DefaultIndicesManager implements IndicesManager { private final ConcurrentMap, Indices> indicesMap; DefaultIndicesManager() { indicesMap = new ConcurrentHashMap<>(); } @Override public void add(Class type, List> indexSpecs) { indicesMap.computeIfAbsent(type, t -> { var indices = new ArrayList>(); // the default index specs should be added first in case of index overwriting Stream.concat(this.createDefaultIndexSpecs().stream(), indexSpecs.stream()) .distinct() .forEach(indexSpec -> { if (indexSpec instanceof MultiValueIndexSpec spec) { indices.add(new MultiValueIndex<>(spec)); } else if (indexSpec instanceof SingleValueIndexSpec spec) { indices.add(new SingleValueIndex<>(spec)); } // ignore other implementations, should never happen }); indices.add(new LabelIndex<>()); return new DefaultIndices<>(indices); }); } @Override public void close() throws IOException { IOUtils.close(indicesMap.values().toArray(Indices[]::new)); indicesMap.clear(); } @Override public Indices get(Class type) { var indices = (Indices) indicesMap.get(type); if (indices == null) { throw new IllegalArgumentException("No indices found for type: " + type.getName()); } return indices; } @Override public void remove(Class type) { var indices = indicesMap.remove(type); IOUtils.closeQuietly(indices); } private List> createDefaultIndexSpecs() { var metadataNameSpec = IndexSpecs.single("metadata.name", String.class) .indexFunc(e -> e.getMetadata().getName()) .unique(true) .nullable(false) .build(); var creationTimestampSpec = IndexSpecs.single("metadata.creationTimestamp", Instant.class) .indexFunc(e -> e.getMetadata().getCreationTimestamp()) .unique(false) .nullable(false) .build(); var deletionTimestampSpec = IndexSpecs.single("metadata.deletionTimestamp", Instant.class) .indexFunc(e -> e.getMetadata().getDeletionTimestamp()) .unique(false) .nullable(true) .build(); return List.of(metadataNameSpec, creationTimestampSpec, deletionTimestampSpec); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/Index.java ================================================ package run.halo.app.extension.index; import java.io.Closeable; import run.halo.app.extension.Extension; /** * Index for Extensions. * * @param the type of the extension. * @param the type of the index key. * @author johnniang * @since 2.22.0 */ public interface Index> extends Closeable { /** * Get the name of the index. * * @return the name of the index. */ String getName(); /** * Get the type of the index key. * * @return the type of the index key. */ Class getKeyType(); /** * Whether the index is unique. * * @return true if the index is unique, false otherwise. */ default boolean isUnique() { return false; } /** * Prepare insert operation. * * @param extension the extension to insert. * @return the transactional operation. */ TransactionalOperation prepareInsert(E extension); /** * Prepare update operation. * * @param newExtension the new extension. * @return the transactional operation. */ TransactionalOperation prepareUpdate(E newExtension); /** * Prepare delete operation. * * @param primaryKey the primary key of the extension to delete. * @return the transactional operation. */ TransactionalOperation prepareDelete(String primaryKey); } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/IndexEngine.java ================================================ package run.halo.app.extension.index; import org.springframework.data.domain.Sort; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import run.halo.app.extension.Extension; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; /** * Index engine for managing extension indices. * * @author johnniang * @since 2.22.0 */ public interface IndexEngine { /** * Insert extensions into the index. * * @param extensions the extensions to insert * @param the type of the extension */ void insert(@NonNull Iterable extensions); /** * Update extension in the index. * * @param extension the extension to update * @param the type of the extension */ void update(@NonNull Iterable extension); /** * Delete extensions from the index. * * @param extensions the extensions to delete * @param the type of the extension */ void delete(@NonNull Iterable extensions); /** * Retrieve extension names from the index. * * @param type the type of the extension * @param options the list options * @param page the page request * @param the type of the extension * @return the list result of extension names */ ListResult retrieve( Class type, @Nullable ListOptions options, @NonNull PageRequest page ); /** * Retrieve all extension names from the index. * * @param type the type of the extension * @param options the list options * @param sort the sort options * @param the type of the extension * @return the iterable of extension names */ Iterable retrieveAll( Class type, @Nullable ListOptions options, @Nullable Sort sort ); /** * Retrieve top N extension names from the index. * * @param type the type of the extension * @param options the list options * @param sort the sort options * @param topN the number of top extensions to retrieve * @param the type of the extension * @return the iterable of extension names */ Iterable retrieveTopN( Class type, @Nullable ListOptions options, @Nullable Sort sort, int topN ); /** * Count the number of extensions in the index. * * @param type the type of the extension * @param options the list options * @param the type of the extension * @return the count of extensions */ long count(Class type, ListOptions options); /** * Get the indices' manager. * * @return the indices manager */ @NonNull IndicesManager getIndicesManager(); } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/Indices.java ================================================ package run.halo.app.extension.index; import java.io.Closeable; import org.springframework.lang.NonNull; import run.halo.app.extension.Extension; public interface Indices extends Closeable { void insert(E extension); void update(E extension); void delete(E extension); /** * Get index by name. * * @param indexName index name * @param the key type * @return the index * @throws IllegalArgumentException if the index with the given name does not exist */ @NonNull > Index getIndex(String indexName); } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/IndicesInitializer.java ================================================ package run.halo.app.extension.index; import run.halo.app.extension.Scheme; public interface IndicesInitializer { void initialize(Scheme scheme); } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/IndicesManager.java ================================================ package run.halo.app.extension.index; import java.io.Closeable; import java.util.List; import org.springframework.lang.NonNull; import run.halo.app.extension.Extension; public interface IndicesManager extends Closeable { /** * Add a new {@link Indices} for the given extension type and index specifications. * * @param type the type of the extension * @param specs the list of index specifications * @param the type of the extension */ void add(Class type, List> specs); /** * Get the {@link Indices} for the given extension type. * * @param type the type of the extension * @param the type of the extension * @return the indices for the given extension type * @throws IllegalArgumentException if the indices for the given extension type does not exist */ @NonNull Indices get(Class type); /** * Remove the {@link Indices} for the given extension type and release resources. * * @param type type of the extension * @param type of the extension */ void remove(Class type); } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/LabelIndex.java ================================================ package run.halo.app.extension.index; import static java.util.stream.Collectors.toUnmodifiableMap; import java.io.IOException; import java.util.Collection; import java.util.Comparator; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentNavigableMap; import java.util.concurrent.ConcurrentSkipListMap; import java.util.stream.Collectors; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.springframework.lang.NonNull; import org.springframework.util.CollectionUtils; import run.halo.app.extension.Extension; /** * Label index implementation. * * @param the type of extension * @author johnniang * @since 2.22.0 */ class LabelIndex implements LabelIndexQuery, Index { private final ConcurrentNavigableMap> index; private final ConcurrentMap> invertedIndex; /** * Set of primary keys of extensions with empty labels. */ private final Set emptyLabelsSet; public LabelIndex() { this.index = new ConcurrentSkipListMap<>(); this.invertedIndex = new ConcurrentHashMap<>(); this.emptyLabelsSet = ConcurrentHashMap.newKeySet(); } @Override public void close() throws IOException { this.index.clear(); this.invertedIndex.clear(); this.emptyLabelsSet.clear(); } @Override public String getName() { return "metadata.labels"; } @Override public Class getKeyType() { return String.class; } @Override public TransactionalOperation prepareInsert(E extension) { var primaryKey = extension.getMetadata().getName(); var labels = extension.getMetadata().getLabels(); return new UpsertTransactionalOperation(primaryKey, labels); } @Override public TransactionalOperation prepareUpdate(E extension) { var primaryKey = extension.getMetadata().getName(); var labels = extension.getMetadata().getLabels(); return new UpsertTransactionalOperation(primaryKey, labels); } @Override public TransactionalOperation prepareDelete(String primaryKey) { return new DeleteTransactionalOperation(primaryKey); } @Override public Set exists(String labelKey) { return index.subMap( new LabelEntry(labelKey, null), true, new LabelEntry(labelKey, Character.MAX_VALUE + ""), true ).values().stream() .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set equal(String labelKey, String labelValue) { return Optional.ofNullable(index.get(new LabelEntry(labelKey, labelValue))) .orElse(Set.of()); } @Override public Set notEqual(String labelKey, String labelValue) { // collect all primary keys var labelEntry = new LabelEntry(labelKey, labelValue); return index.subMap( new LabelEntry(labelKey, null), true, new LabelEntry(labelKey, Character.MAX_VALUE + ""), true ) .entrySet().stream() .filter(entry -> !Objects.equals(entry.getKey(), labelEntry)) .map(Map.Entry::getValue) .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set in(String labelKey, Collection labelValues) { if (CollectionUtils.isEmpty(labelValues)) { return Set.of(); } return labelValues.stream() .distinct() .map(labelValue -> new LabelEntry(labelKey, labelValue)) .map(index::get) .filter(Objects::nonNull) .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set notIn(String labelKey, Collection labelValues) { if (CollectionUtils.isEmpty(labelValues)) { return Set.of(); } var valueSet = labelValues instanceof Set set ? set : Set.copyOf(labelValues); return index.subMap( new LabelEntry(labelKey, null), true, new LabelEntry(labelKey, Character.MAX_VALUE + ""), true ) .entrySet() .stream() .filter(entry -> !valueSet.contains(entry.getKey().labelValue())) .map(Map.Entry::getValue) .flatMap(Set::stream) .collect(Collectors.toSet()); } record LabelEntry(@NonNull String labelKey, @Nullable String labelValue) implements Comparable { public LabelEntry { Objects.requireNonNull(labelKey, "labelKey must not be null"); } @Override public int compareTo(@NotNull LabelEntry o) { var compare = Comparator.naturalOrder().compare(this.labelKey, o.labelKey); if (compare != 0) { return compare; } return Comparator.nullsFirst(Comparator.naturalOrder()) .compare(this.labelValue, o.labelValue); } } class UpsertTransactionalOperation implements TransactionalOperation { private final String primaryKey; private final Map labels; private Map previousLabels; private boolean committed; UpsertTransactionalOperation(String primaryKey, Map labels) { this.primaryKey = primaryKey; this.labels = labels; } @Override public void prepare() { this.previousLabels = Optional.ofNullable(invertedIndex.get(primaryKey)) .map(labelEntries -> labelEntries.stream() .filter(entry -> entry.labelValue() != null) .collect(toUnmodifiableMap(LabelEntry::labelKey, LabelEntry::labelValue)) ) .orElse(null); } @Override public void commit() { if (committed) { return; } this.committed = true; // remove old labels removeLabels(primaryKey, previousLabels); addLabels(primaryKey, labels); } @Override public void rollback() { if (!committed) { return; } removeLabels(primaryKey, labels); addLabels(primaryKey, previousLabels); } } class DeleteTransactionalOperation implements TransactionalOperation { private final String primaryKey; private Map previousLabels; private boolean committed; DeleteTransactionalOperation(String primaryKey) { this.primaryKey = primaryKey; } @Override public void prepare() { this.previousLabels = Optional.ofNullable(invertedIndex.get(primaryKey)) .map(labelEntries -> labelEntries.stream() .filter(entry -> entry.labelValue() != null) .collect(toUnmodifiableMap(LabelEntry::labelKey, LabelEntry::labelValue)) ) .orElse(null); } @Override public void commit() { if (committed) { return; } this.committed = true; removeLabels(primaryKey, previousLabels); } @Override public void rollback() { if (!committed) { return; } addLabels(primaryKey, previousLabels); } } private void removeLabels(String primaryKey, Map labels) { if (CollectionUtils.isEmpty(labels)) { emptyLabelsSet.remove(primaryKey); return; } invertedIndex.remove(primaryKey); labels.forEach((labelKey, labelValue) -> { var labelEntry = new LabelEntry(labelKey, labelValue); index.computeIfPresent(labelEntry, (key, primaryKeys) -> { primaryKeys.remove(primaryKey); if (primaryKeys.isEmpty()) { return null; } return primaryKeys; }); }); } private void addLabels(String primaryKey, Map labels) { if (CollectionUtils.isEmpty(labels)) { emptyLabelsSet.add(primaryKey); return; } emptyLabelsSet.remove(primaryKey); labels.forEach((labelKey, labelValue) -> { var labelEntry = new LabelEntry(labelKey, labelValue); index.compute(labelEntry, (key, primaryKeys) -> { if (primaryKeys == null) { primaryKeys = ConcurrentHashMap.newKeySet(); } primaryKeys.add(primaryKey); return primaryKeys; }); invertedIndex.compute(primaryKey, (pk, labelEntries) -> { if (labelEntries == null) { labelEntries = ConcurrentHashMap.newKeySet(); } labelEntries.add(labelEntry); return labelEntries; }); }); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/LabelIndexQuery.java ================================================ package run.halo.app.extension.index; import java.util.Collection; import java.util.Set; /** * Label index query interface. * * @author johnniang * @since 2.22.0 */ public interface LabelIndexQuery { /** * Checks if the label with the given key exists. * * @param labelKey the label key * @return the set of entity IDs that have the label key */ Set exists(String labelKey); /** * Checks if the label with the given key does not exist. * * @param labelKey the label key * @param labelValue the label value * @return the set of entity IDs that do not have the label key */ Set equal(String labelKey, String labelValue); /** * Checks if the label with the given key does not equal the specified value. * * @param labelKey the label key * @param labelValue the label value * @return the set of entity IDs that do not have the label key equal to the specified value */ Set notEqual(String labelKey, String labelValue); /** * Checks if the label with the given key has a value in the specified collection. * * @param labelKey the label key * @param labelValues the collection of label values * @return the set of entity IDs that have the label key with values in the specified collection */ Set in(String labelKey, Collection labelValues); /** * Checks if the label with the given key has a value not in the specified collection. * * @param labelKey the label key * @param labelValues the collection of label values * @return the set of entity IDs that have the label key with values not in the specified * collection. */ Set notIn(String labelKey, Collection labelValues); } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/MultiValueIndex.java ================================================ package run.halo.app.extension.index; import java.io.IOException; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentNavigableMap; import java.util.concurrent.ConcurrentSkipListMap; import java.util.stream.Collectors; import java.util.stream.Stream; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import run.halo.app.extension.Extension; /** * Multi-value index implementation. * * @param the extension type * @param the key type * @author johnniang * @since 2.22.0 */ @Slf4j class MultiValueIndex> implements ValueIndexQuery, Index { private final ConcurrentNavigableMap> index; private final ConcurrentMap> invertedIndex; private final Set nullKeyValues; private final MultiValueIndexSpec spec; public MultiValueIndex(MultiValueIndexSpec spec) { this.spec = spec; this.index = new ConcurrentSkipListMap<>(Comparator.naturalOrder()); this.invertedIndex = new ConcurrentHashMap<>(); this.nullKeyValues = ConcurrentHashMap.newKeySet(); } @Override public void close() throws IOException { this.index.clear(); this.invertedIndex.clear(); this.nullKeyValues.clear(); } @Override public String getName() { return spec.getName(); } @Override public Class getKeyType() { return spec.getKeyType(); } @Override public boolean isUnique() { return spec.isUnique(); } @Override public TransactionalOperation prepareInsert(E extension) { var keys = spec.getValues(extension); return new UpsertTransactionalOperation(extension.getMetadata().getName(), keys); } @Override public TransactionalOperation prepareUpdate(E extension) { // find old state var newKeys = spec.getValues(extension); var primaryKey = extension.getMetadata().getName(); return new UpsertTransactionalOperation(primaryKey, newKeys); } @Override public TransactionalOperation prepareDelete(String primaryKey) { return new DeleteTransactionalOperation(primaryKey); } /** * Get the keys associated with the given primary key. * * @param primaryKey the primary key * @return the associated keys */ Set getKeys(String primaryKey) { return Collections.unmodifiableSet(invertedIndex.get(primaryKey)); } @Override public Set between(K fromKey, boolean fromInclusive, K toKey, boolean toInclusive) { throw new UnsupportedOperationException( "Multi-value index does not support between operation"); } @Override public Set notBetween(K fromKey, boolean fromInclusive, K toKey, boolean toInclusive) { throw new UnsupportedOperationException( "Multi-value index does not support notBetween operation"); } @Override public Set in(Collection keys) { if (CollectionUtils.isEmpty(keys)) { return Set.of(); } return keys.stream() .distinct() .map(index::get) .filter(Objects::nonNull) .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set notIn(Collection keys) { if (CollectionUtils.isEmpty(keys)) { return all(); } var inResult = in(keys); return index.values().stream() .flatMap(Set::stream) .filter(v -> !inResult.contains(v)) .collect(Collectors.toSet()); } @Override public Set lessThan(K key, boolean inclusive) { throw new UnsupportedOperationException( "Multi-value index does not support lessThan operation" ); } @Override public Set greaterThan(K key, boolean inclusive) { throw new UnsupportedOperationException( "Multi-value index does not support greaterThan operation" ); } @Override public Set isNull() { return Collections.unmodifiableSet(nullKeyValues); } @Override public Set isNotNull() { return index.values() .stream() .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set stringContains(String keyword) { throw new UnsupportedOperationException( "Multi-value index does not support stringContains operation" ); } @Override public Set stringNotContains(String keyword) { throw new UnsupportedOperationException( "Multi-value index does not support stringNotContains operation" ); } @Override public Set stringStartsWith(String prefix) { throw new UnsupportedOperationException( "Multi-value index does not support stringStartsWith operation" ); } @Override public Set stringNotStartsWith(String prefix) { throw new UnsupportedOperationException( "Multi-value index does not support stringNotStartsWith operation" ); } @Override public Set stringEndsWith(String suffix) { throw new UnsupportedOperationException( "Multi-value index does not support stringEndsWith operation" ); } @Override public Set stringNotEndsWith(String suffix) { throw new UnsupportedOperationException( "Multi-value index does not support stringNotEndsWith operation" ); } @Override public Set notEqual(K key) { return notIn(Collections.singleton(key)); } @Override public Set equal(K key) { return index.getOrDefault(key, Set.of()); } @Override public Set all() { return Stream.concat(index.values().stream(), Stream.of(nullKeyValues)) .flatMap(Set::stream) .collect(Collectors.toSet()); } class UpsertTransactionalOperation implements TransactionalOperation { @NonNull private final String primaryKey; @Nullable private final Set newKeys; private boolean committed; private Set previousKeys; private boolean previousNullKey; UpsertTransactionalOperation( @NonNull String primaryKey, @Nullable Set newKeys ) { this.primaryKey = primaryKey; this.newKeys = newKeys; } @Override public void prepare() { this.previousKeys = invertedIndex.get(primaryKey); this.previousNullKey = nullKeyValues.contains(primaryKey); } @Override public void commit() { if (committed) { return; } committed = true; if (Objects.equals(previousKeys, newKeys)) { return; } invertedIndex.put(primaryKey, newKeys); // remove previous keys if (!CollectionUtils.isEmpty(previousKeys)) { previousKeys.forEach(key -> index.computeIfPresent(key, (k, v) -> { v.remove(primaryKey); return v.isEmpty() ? null : v; })); } // add new keys if (!CollectionUtils.isEmpty(newKeys)) { for (K key : newKeys) { index.compute(key, (k, v) -> { if (v == null) { v = ConcurrentHashMap.newKeySet(); } if (spec.isUnique() && !v.isEmpty()) { throw new DuplicateKeyException( String.format("Duplicate key '%s' for extension '%s'", k, primaryKey) ); } v.add(primaryKey); return v; }); } nullKeyValues.remove(primaryKey); } else { nullKeyValues.add(primaryKey); } } @Override public void rollback() { if (Objects.equals(this.previousKeys, newKeys) || !committed) { return; } // remove possibly added new keys if (!CollectionUtils.isEmpty(newKeys)) { newKeys.forEach(key -> index.computeIfPresent(key, (k, v) -> { v.remove(primaryKey); return v.isEmpty() ? null : v; })); } // add previous keys if (this.previousKeys == null) { // remove from inverted index invertedIndex.remove(primaryKey); } else { this.previousKeys.forEach(key -> index.compute(key, (k, v) -> { if (v == null) { v = ConcurrentHashMap.newKeySet(); } // No need to check duplicate here, as it was already present before. v.add(primaryKey); return v; })); invertedIndex.put(primaryKey, this.previousKeys); } if (previousNullKey) { nullKeyValues.add(primaryKey); } else { nullKeyValues.remove(primaryKey); } } } class DeleteTransactionalOperation implements TransactionalOperation { @NonNull private final String primaryKey; private boolean committed; private Set previousKeys; private boolean previousNullKey; DeleteTransactionalOperation(@NonNull String primaryKey) { this.primaryKey = primaryKey; } @Override public void prepare() { this.previousKeys = invertedIndex.get(primaryKey); this.previousNullKey = nullKeyValues.contains(primaryKey); } @Override public void commit() { if (committed) { return; } committed = true; invertedIndex.remove(primaryKey); if (this.previousKeys != null) { this.previousKeys.forEach(key -> index.computeIfPresent(key, (k, v) -> { v.remove(primaryKey); return v.isEmpty() ? null : v; })); } nullKeyValues.remove(primaryKey); } @Override public void rollback() { if (this.previousKeys == null || !committed) { return; } if (previousNullKey) { nullKeyValues.add(primaryKey); } else { nullKeyValues.remove(primaryKey); } // add previous keys this.previousKeys.forEach(key -> index.compute(key, (k, v) -> { if (v == null) { v = ConcurrentHashMap.newKeySet(); } v.add(primaryKey); return v; })); invertedIndex.put(primaryKey, this.previousKeys); } } private void ensureStringKeyType() { Assert.isTrue( getKeyType() == String.class || getKeyType() == UnknownKey.class, "Key type must be String for this operation" ); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/SingleValueIndex.java ================================================ package run.halo.app.extension.index; import java.io.IOException; import java.util.Collection; import java.util.Comparator; import java.util.HashSet; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentNavigableMap; import java.util.concurrent.ConcurrentSkipListMap; import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.commons.lang3.StringUtils; import org.springframework.dao.DuplicateKeyException; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import run.halo.app.extension.Extension; /** * Single value index implementation. * * @param the type of extension * @param the type of index key * @author johnniang * @since 2.22.0 */ class SingleValueIndex> implements ValueIndexQuery, Index { private final ConcurrentNavigableMap> index; private final ConcurrentMap invertedIndex; private final Set nullKeyValues; private final SingleValueIndexSpec spec; public SingleValueIndex(SingleValueIndexSpec spec) { this.spec = spec; this.index = new ConcurrentSkipListMap<>(Comparator.naturalOrder()); this.invertedIndex = new ConcurrentHashMap<>(); this.nullKeyValues = ConcurrentHashMap.newKeySet(); } @Override public void close() throws IOException { this.index.clear(); this.invertedIndex.clear(); this.nullKeyValues.clear(); } @Override public Class getKeyType() { return spec.getKeyType(); } @Override public Set equal(K key) { var primaryKeys = index.get(key); return CollectionUtils.isEmpty(primaryKeys) ? Set.of() : new HashSet<>(primaryKeys); } @Override public Set notEqual(K key) { return index.entrySet().stream() .filter(entry -> !Objects.equals(entry.getKey(), key)) .map(Map.Entry::getValue) .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set all() { return invertedIndex.keySet(); } @Override public Set between(K fromKey, boolean fromInclusive, K toKey, boolean toInclusive) { Assert.notNull(fromKey, "From key must not be null"); Assert.notNull(toKey, "To key must not be null"); Assert.isTrue(fromKey.compareTo(toKey) <= 0, "From key must be less than or equal to to key" ); return index.subMap(fromKey, fromInclusive, toKey, toInclusive).values() .stream() .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set notBetween(K fromKey, boolean fromInclusive, K toKey, boolean toInclusive) { Assert.notNull(fromKey, "From key must not be null"); Assert.notNull(toKey, "To key must not be null"); Assert.isTrue(fromKey.compareTo(toKey) <= 0, "From key must be less than or equal to to key" ); return Stream.concat( index.headMap(fromKey, !fromInclusive).values().stream(), index.tailMap(toKey, !toInclusive).values().stream() ) .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set in(Collection keys) { if (CollectionUtils.isEmpty(keys)) { return Set.of(); } return keys.stream() .distinct() .map(index::get) .filter(Objects::nonNull) .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set notIn(Collection keys) { if (CollectionUtils.isEmpty(keys)) { return Set.of(); } var keySet = keys instanceof Set set ? set : new HashSet<>(keys); return index.entrySet().stream() .distinct() .filter(entry -> !keySet.contains(entry.getKey())) .map(Map.Entry::getValue) .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set lessThan(K key, boolean inclusive) { Assert.notNull(key, "Key must not be null"); return index.headMap(key, inclusive).values().stream() .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set greaterThan(K key, boolean inclusive) { Assert.notNull(key, "Key must not be null"); return index.tailMap(key, inclusive).values().stream() .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set isNull() { Assert.isTrue(spec.isNullable(), "Index " + getName() + " is not nullable"); return new HashSet<>(nullKeyValues); } @Override public Set isNotNull() { Assert.isTrue(spec.isNullable(), "Index is not nullable"); return index.values().stream() .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set stringContains(String keyword) { ensureStringKeyType(); return index.entrySet().stream() .filter(entry -> StringUtils.containsIgnoreCase(entry.getKey().toString(), keyword)) .map(Map.Entry::getValue) .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set stringNotContains(String keyword) { ensureStringKeyType(); return index.entrySet().stream() .filter(entry -> !StringUtils.containsIgnoreCase(entry.getKey().toString(), keyword)) .map(Map.Entry::getValue) .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set stringStartsWith(String prefix) { ensureStringKeyType(); var toKey = prefix + Character.MAX_VALUE; return index.subMap((K) prefix, true, (K) toKey, true).values().stream() .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set stringNotStartsWith(String prefix) { ensureStringKeyType(); var toKey = prefix + Character.MAX_VALUE; return Stream.concat( index.headMap((K) prefix, false).values().stream(), index.tailMap((K) toKey, true).values().stream() ) .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set stringEndsWith(String suffix) { ensureStringKeyType(); return index.entrySet().stream() .filter(entry -> StringUtils.endsWithIgnoreCase(entry.getKey().toString(), suffix)) .map(Map.Entry::getValue) .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public Set stringNotEndsWith(String suffix) { ensureStringKeyType(); return index.entrySet().stream() .filter(entry -> !StringUtils.endsWithIgnoreCase(entry.getKey().toString(), suffix)) .map(Map.Entry::getValue) .flatMap(Set::stream) .collect(Collectors.toSet()); } @Override public String getName() { return spec.getName(); } @Override public TransactionalOperation prepareInsert(E extension) { var primaryKey = extension.getMetadata().getName(); var key = spec.getValue(extension); return new UpsertTransactionalOperation(primaryKey, key); } @Override public TransactionalOperation prepareUpdate(E extension) { var primaryKey = extension.getMetadata().getName(); var key = spec.getValue(extension); return new UpsertTransactionalOperation(primaryKey, key); } @Override public TransactionalOperation prepareDelete(String primaryKey) { return new DeleteTransactionalOperation(primaryKey); } /** * Get key for the given primary key. * * @param primaryKey the primary key * @return the index key, or null if not found */ @Nullable K getKey(String primaryKey) { return invertedIndex.get(primaryKey); } @Override public boolean isUnique() { return spec.isUnique(); } class UpsertTransactionalOperation implements TransactionalOperation { private final String primaryKey; @Nullable private final K newKey; private K previousKey; private boolean previousNull; private boolean committed; UpsertTransactionalOperation(String primaryKey, @Nullable K newKey) { this.primaryKey = primaryKey; this.newKey = newKey; } @Override public void prepare() { // preflight checks if (!spec.isNullable() && newKey == null) { throw new IllegalArgumentException( "Index %s of %s is not nullable".formatted(getName(), primaryKey) ); } previousKey = invertedIndex.get(primaryKey); previousNull = nullKeyValues.contains(primaryKey); if (isUnique() && newKey != null && !Objects.equals(previousKey, newKey)) { var existingPrimaryKeys = index.get(newKey); if (!CollectionUtils.isEmpty(existingPrimaryKeys)) { throw new DuplicateKeyException( "Duplicate key '" + newKey + "' for index '" + getName() + "'" ); } } } @Override public void commit() { if (committed) { return; } committed = true; removeKey(primaryKey, previousKey); addKey(primaryKey, newKey); } @Override public void rollback() { if (!committed) { return; } removeKey(primaryKey, newKey); if (spec.isNullable() || previousKey != null) { addKey(primaryKey, previousKey); } if (previousNull) { nullKeyValues.add(primaryKey); } else { nullKeyValues.remove(primaryKey); } } } class DeleteTransactionalOperation implements TransactionalOperation { private final String primaryKey; private K previousKey; private boolean previousNull; private boolean committed; DeleteTransactionalOperation(String primaryKey) { this.primaryKey = primaryKey; } @Override public void prepare() { previousKey = invertedIndex.get(primaryKey); previousNull = nullKeyValues.contains(primaryKey); } @Override public void commit() { if (committed) { return; } committed = true; removeKey(primaryKey, previousKey); } @Override public void rollback() { if (!committed) { return; } if (spec.isNullable() || previousKey != null) { addKey(primaryKey, previousKey); } if (previousNull) { nullKeyValues.add(primaryKey); } else { nullKeyValues.remove(primaryKey); } } } private void removeKey(String primaryKey, K key) { nullKeyValues.remove(primaryKey); if (key == null) { var oldKey = invertedIndex.remove(primaryKey); if (oldKey != null) { index.computeIfPresent(oldKey, (k, v) -> { v.remove(primaryKey); return v.isEmpty() ? null : v; }); } return; } index.computeIfPresent(key, (k, v) -> { v.remove(primaryKey); return v.isEmpty() ? null : v; }); invertedIndex.remove(primaryKey, key); } private void addKey(String primaryKey, K key) { if (!spec.isNullable() && key == null) { throw new IllegalArgumentException( "Index %s of %s is not nullable".formatted(getName(), primaryKey) ); } if (key == null) { var oldKey = invertedIndex.remove(primaryKey); if (oldKey != null) { index.computeIfPresent(oldKey, (k, v) -> { v.remove(primaryKey); return v.isEmpty() ? null : v; }); } nullKeyValues.add(primaryKey); return; } nullKeyValues.remove(primaryKey); index.compute(key, (k, v) -> { if (v == null) { v = ConcurrentHashMap.newKeySet(); } if (!v.add(primaryKey) && spec.isUnique()) { throw new DuplicateKeyException( "Duplicate key '" + key + "' for index '" + getName() + "'" ); } return v; }); invertedIndex.put(primaryKey, key); } private void ensureStringKeyType() { Assert.isTrue( getKeyType() == String.class || getKeyType() == UnknownKey.class, "Key type must be String for this operation" ); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/StringUnknownKeyConverter.java ================================================ package run.halo.app.extension.index; import org.springframework.core.convert.converter.Converter; import org.springframework.stereotype.Component; /** * String to UnknownKey converter. * * @author johnniang * @since 2.22.0 * @deprecated for backward compatibility. May remove with {@link UnknownKey} in the future * */ @Component @Deprecated(forRemoval = true, since = "2.22.0") class StringUnknownKeyConverter implements Converter { @Override public UnknownKey convert(String source) { return new UnknownKey(source); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/TransactionalOperation.java ================================================ package run.halo.app.extension.index; /** * Represents a transactional operation with prepare, commit, and rollback methods. * * @author johnniang * @since 2.20.0 */ public interface TransactionalOperation { /** * Prepares the operation for execution. Implementation should perform necessary checks here and * save any state needed for rollback. */ void prepare(); /** * Commits the operation. */ void commit(); /** * Rolls back the operation. */ void rollback(); } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/ValueIndexQuery.java ================================================ package run.halo.app.extension.index; import java.util.Collection; import java.util.Set; /** * Value index query interface. * * @param the type of the key */ public interface ValueIndexQuery> { /** * Gets the type of the key. * * @return the class of the key type */ Class getKeyType(); /** * Checks for equality with the given key. * * @param key the key to compare * @return the set of entity IDs that match the equality condition */ Set equal(K key); /** * Checks for inequality with the given key. * * @param key the key to compare * @return the set of entity IDs that match the inequality condition */ Set notEqual(K key); /** * Gets all entity IDs in the index. * * @return the set of all entity IDs */ Set all(); /** * Gets entity IDs between the specified range. * * @param fromKey the starting key * @param fromInclusive whether the starting key is inclusive * @param toKey the ending key * @param toInclusive whether the ending key is inclusive * @return the set of entity IDs within the specified range */ Set between(K fromKey, boolean fromInclusive, K toKey, boolean toInclusive); /** * Gets entity IDs not between the specified range. * * @param fromKey the starting key * @param fromInclusive whether the starting key is inclusive * @param toKey the ending key * @param toInclusive whether the ending key is inclusive * @return the set of entity IDs outside the specified range */ Set notBetween(K fromKey, boolean fromInclusive, K toKey, boolean toInclusive); /** * Gets entity IDs with keys in the specified collection. * * @param keys the collection of keys * @return the set of entity IDs with keys in the collection */ Set in(Collection keys); /** * Gets entity IDs with keys not in the specified collection. * * @param keys the collection of keys * @return the set of entity IDs with keys not in the collection */ Set notIn(Collection keys); /** * Gets entity IDs with keys less than the specified key. * * @param key the key to compare * @param inclusive whether the comparison is inclusive * @return the set of entity IDs with keys less than the specified key */ Set lessThan(K key, boolean inclusive); /** * Gets entity IDs with keys greater than the specified key. * * @param key the key to compare * @param inclusive whether the comparison is inclusive * @return the set of entity IDs with keys greater than the specified key */ Set greaterThan(K key, boolean inclusive); /** * Gets entity IDs with null keys. * * @return the set of entity IDs with null keys * @throws IllegalArgumentException if the key type is not nullable */ Set isNull(); /** * Gets entity IDs with non-null keys. * * @return the set of entity IDs with non-null keys */ Set isNotNull(); /** * Gets entity IDs where the string representation of the key contains the specified keyword. * * @param keyword the keyword to search for * @return the set of entity IDs that contain the keyword * @throws IllegalArgumentException if the key type is not String */ Set stringContains(String keyword); /** * Gets entity IDs where the string representation of the key does not contain the specified * keyword. * * @param keyword the keyword to search for * @return the set of entity IDs that do not contain the keyword * @throws IllegalArgumentException if the key type is not String */ Set stringNotContains(String keyword); /** * Gets entity IDs where the string representation of the key starts with the specified prefix. * * @param prefix the prefix to search for * @return the set of entity IDs that start with the prefix * @throws IllegalArgumentException if the key type is not String */ Set stringStartsWith(String prefix); /** * Gets entity IDs where the string representation of the key does not start with the * specified prefix. * * @param prefix the prefix to search for * @return the set of entity IDs that do not start with the prefix * @throws IllegalArgumentException if the key type is not String */ Set stringNotStartsWith(String prefix); /** * Gets entity IDs where the string representation of the key ends with the specified suffix. * * @param suffix the suffix to search for * @return the set of entity IDs that end with the suffix * @throws IllegalArgumentException if the key type is not String */ Set stringEndsWith(String suffix); /** * Gets entity IDs where the string representation of the key does not end with the specified * suffix. * * @param suffix the suffix to search for * @return the set of entity IDs that do not end with the suffix * @throws IllegalArgumentException if the key type is not String */ Set stringNotEndsWith(String suffix); } ================================================ FILE: application/src/main/java/run/halo/app/extension/index/query/QueryVisitor.java ================================================ package run.halo.app.extension.index.query; import java.util.Collection; import java.util.HashSet; import java.util.Set; import lombok.Getter; import org.springframework.core.convert.ConversionService; import org.springframework.data.relational.core.sql.Visitable; import org.springframework.data.relational.core.sql.Visitor; import org.springframework.lang.NonNull; import run.halo.app.extension.Extension; import run.halo.app.extension.index.Indices; import run.halo.app.extension.index.LabelIndexQuery; import run.halo.app.extension.index.ValueIndexQuery; /** * A visitor that visits a query and returns the matching extension names. * * @param the type of extension * @author johnniang * @since 2.22.0 */ public class QueryVisitor implements Visitor { private final ConversionService conversionService; private final Indices indices; private final Set result; public QueryVisitor(Indices indices, ConversionService conversionService) { this.indices = indices; this.result = new HashSet<>(); this.conversionService = conversionService; } @Override public void enter(Visitable segment) { var visitor = new ConditionVisitor(); segment.visit(visitor); result.addAll(visitor.getResult()); } @NonNull public Set getResult() { return result; } class ConditionVisitor implements Visitor { @Getter private final Set result; ConditionVisitor() { result = new HashSet<>(); } @Override public void enter(@NonNull Visitable segment) { switch (segment) { case And(var left, var right) -> // delegate to AndCondition for backward compatibility new AndCondition(left, right).visit(this); case EmptyCondition ignored -> result.addAll(allQuery("metadata.name", false)); case AndCondition(var left, var right) -> { if (left instanceof EmptyCondition) { right.visit(this); return; } if (right instanceof EmptyCondition) { left.visit(this); return; } left.visit(this); // fail fast if left result is empty if (!result.isEmpty()) { var rightVisitor = new ConditionVisitor(); right.visit(rightVisitor); result.retainAll(rightVisitor.getResult()); } } case OrCondition(var left, var right) -> { if (left instanceof EmptyCondition) { left.visit(this); return; } if (right instanceof EmptyCondition) { right.visit(this); return; } left.visit(this); var rightVisitor = new ConditionVisitor(); right.visit(rightVisitor); result.addAll(rightVisitor.getResult()); } case NotCondition(var condition) -> { if (condition instanceof EmptyCondition) { return; } condition.not().visit(this); } case EqualCondition(var indexName, var key) -> result.addAll(equalQuery(indexName, key, false)); case NotEqualCondition(var indexName, var key) -> result.addAll(equalQuery(indexName, key, true)); case InCondition(var indexName, var keys) -> result.addAll(inQuery(indexName, keys, false)); case NotInCondition(var indexName, var keys) -> result.addAll(inQuery(indexName, keys, true)); case LessThanCondition(var indexName, var upperBound, var inclusive) -> result.addAll(lessThanQuery(indexName, upperBound, inclusive, false)); case GreaterThanCondition(var indexName, var lowerBound, var inclusive) -> result.addAll(lessThanQuery(indexName, lowerBound, inclusive, true)); case BetweenCondition bc -> result.addAll(betweenQuery( bc.indexName(), bc.fromKey(), bc.fromInclusive(), bc.toKey(), bc.toInclusive(), false ) ); case NotBetweenCondition nbc -> result.addAll(betweenQuery( nbc.indexName(), nbc.fromKey(), nbc.fromInclusive(), nbc.toKey(), nbc.toInclusive(), true )); case IsNullCondition(var indexName) -> result.addAll(isNullQuery(indexName, false)); case IsNotNullCondition(var indexName) -> result.addAll(isNullQuery(indexName, true)); case StringContainsCondition(var indexName, var keyword) -> result.addAll(stringContainsQuery(indexName, keyword, false)); case StringNotContainsCondition(var indexName, var keyword) -> result.addAll(stringContainsQuery(indexName, keyword, true)); case StringStartsWithCondition(var indexName, var prefix) -> result.addAll(stringStartsWithQuery(indexName, prefix, false)); case StringNotStartsWithCondition(var indexName, var prefix) -> result.addAll(stringStartsWithQuery(indexName, prefix, true)); case StringEndsWithCondition(var indexName, var suffix) -> result.addAll(stringEndsWithQuery(indexName, suffix, false)); case StringNotEndsWithCondition(var indexName, var suffix) -> result.addAll(stringEndsWithQuery(indexName, suffix, true)); case AllCondition(var indexName) -> result.addAll(allQuery(indexName, false)); case NoneCondition(var indexName) -> result.addAll(allQuery(indexName, true)); case LabelExistsCondition(var labelKey) -> result.addAll(labelExistsQuery(labelKey)); case LabelNotExistsCondition(var labelKey) -> { // To get all extensions that do not have the label, we get all extensions result.addAll(allQuery("metadata.name", false)); result.removeAll(labelExistsQuery(labelKey)); } case LabelEqualsCondition(var labelKey, var labelValue) -> result.addAll(labelEqualsQuery(labelKey, labelValue, false)); case LabelNotEqualsCondition(var labelKey, var labelValue) -> { // Only for backward compatibility result.addAll(allQuery("metadata.name", false)); result.removeAll(labelEqualsQuery(labelKey, labelValue, false)); } case LabelInCondition(var labelKey, var labelValues) -> result.addAll(labelInQuery(labelKey, labelValues, false)); case LabelNotInCondition(var labelKey, var labelValues) -> result.addAll(labelInQuery(labelKey, labelValues, true)); default -> { } } } private Set labelInQuery(String labelKey, Collection labelValues, boolean negated) { var index = this.getLabelIndexQuery(); if (negated) { return index.notIn(labelKey, labelValues); } return index.in(labelKey, labelValues); } private Set labelEqualsQuery(String labelKey, String labelValue, boolean negated) { var index = this.getLabelIndexQuery(); if (negated) { return index.notEqual(labelKey, labelValue); } return index.equal(labelKey, labelValue); } private Set labelExistsQuery(String labelKey) { var index = getLabelIndexQuery(); return index.exists(labelKey); } private Set stringEndsWithQuery(String indexName, String suffix, boolean negated) { var index = getValueIndexQuery(indexName); if (negated) { return index.stringNotEndsWith(suffix); } return index.stringEndsWith(suffix); } private Set allQuery(String indexName, boolean negated) { var index = getValueIndexQuery(indexName); if (negated) { return Set.of(); } return index.all(); } private Set stringStartsWithQuery(String indexName, String prefix, boolean negated) { var index = getValueIndexQuery(indexName); if (negated) { return index.stringNotStartsWith(prefix); } return index.stringStartsWith(prefix); } private Set stringContainsQuery(String indexName, String keyword, boolean negated) { var index = getValueIndexQuery(indexName); if (negated) { return index.stringNotContains(keyword); } return index.stringContains(keyword); } private > Set isNullQuery(String indexName, boolean negated) { var index = this.getValueIndexQuery(indexName); if (negated) { return index.isNotNull(); } return index.isNull(); } private > Set betweenQuery(String indexName, Object fromKey, boolean fromInclusive, Object toKey, boolean toInclusive, boolean negated) { var index = this.getValueIndexQuery(indexName); if (!conversionService.canConvert(fromKey.getClass(), index.getKeyType())) { throw new IllegalArgumentException( "Cannot convert key: " + fromKey + " to type: " + index.getKeyType() ); } if (!conversionService.canConvert(toKey.getClass(), index.getKeyType())) { throw new IllegalArgumentException( "Cannot convert key: " + toKey + " to type: " + index.getKeyType() ); } if (negated) { return index.notBetween( conversionService.convert(fromKey, index.getKeyType()), fromInclusive, conversionService.convert(toKey, index.getKeyType()), toInclusive ); } else { return index.between( conversionService.convert(fromKey, index.getKeyType()), fromInclusive, conversionService.convert(toKey, index.getKeyType()), toInclusive ); } } private > Set lessThanQuery(String indexName, Object bound, boolean inclusive, boolean negated) { var index = this.getValueIndexQuery(indexName); if (!conversionService.canConvert(bound.getClass(), index.getKeyType())) { throw new IllegalArgumentException( "Cannot convert key: " + bound + " to type: " + index.getKeyType() ); } if (negated) { return index.greaterThan( conversionService.convert(bound, index.getKeyType()), inclusive ); } else { return index.lessThan(conversionService.convert(bound, index.getKeyType()), inclusive); } } private > Set equalQuery(String indexName, Object key, boolean negated) { var index = this.getValueIndexQuery(indexName); if (!conversionService.canConvert(key.getClass(), index.getKeyType())) { throw new IllegalArgumentException( "Cannot convert key: " + key + " to type: " + index.getKeyType() ); } if (negated) { return index.notEqual(conversionService.convert(key, index.getKeyType())); } else { return index.equal(conversionService.convert(key, index.getKeyType())); } } private > Set inQuery( String indexName, Collection keys, boolean negated ) { var index = this.getValueIndexQuery(indexName); var convertedKeys = keys.stream() .map(key -> { if (!conversionService.canConvert(key.getClass(), index.getKeyType())) { throw new IllegalArgumentException( "Cannot convert key: " + key + " to type: " + index.getKeyType() ); } return conversionService.convert(key, index.getKeyType()); }) .toList(); if (negated) { return index.notIn(convertedKeys); } else { return index.in(convertedKeys); } } private > ValueIndexQuery getValueIndexQuery(String indexName) { var index = indices.getIndex(indexName); if (!(index instanceof ValueIndexQuery valueIndexQuery)) { throw new IllegalArgumentException("Index is not in-memory: " + indexName); } return (ValueIndexQuery) valueIndexQuery; } private LabelIndexQuery getLabelIndexQuery() { var indexName = "metadata.labels"; var index = indices.getIndex(indexName); if (!(index instanceof LabelIndexQuery labelIndexQuery)) { throw new IllegalArgumentException("Index is not a label index: " + indexName); } return labelIndexQuery; } } } ================================================ FILE: application/src/main/java/run/halo/app/extension/indexer/DefaultIndicesInitializer.java ================================================ package run.halo.app.extension.indexer; import java.util.List; import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.util.StopWatch; import run.halo.app.extension.Extension; import run.halo.app.extension.ExtensionConverter; import run.halo.app.extension.ExtensionStoreUtil; import run.halo.app.extension.Scheme; import run.halo.app.extension.event.SchemeAddedEvent; import run.halo.app.extension.index.IndexEngine; import run.halo.app.extension.index.IndicesInitializer; import run.halo.app.extension.store.ExtensionStore; import run.halo.app.extension.store.ExtensionStoreClient; @Component @Slf4j class DefaultIndicesInitializer implements IndicesInitializer { private final IndexEngine indexEngine; private final ExtensionStoreClient client; private final ExtensionConverter extensionConverter; DefaultIndicesInitializer(IndexEngine indexEngine, ExtensionStoreClient client, ExtensionConverter extensionConverter) { this.indexEngine = indexEngine; this.client = client; this.extensionConverter = extensionConverter; } @EventListener void onSchemeAddedEvent(SchemeAddedEvent event) { var scheme = event.getScheme(); this.initialize(scheme); } @Override public void initialize(Scheme scheme) { doInitialize(scheme); } public void doInitialize(Scheme scheme) { var type = (Class) scheme.type(); var prefix = ExtensionStoreUtil.buildStoreNamePrefix(scheme); List extensionStores; String nameCursor = null; log.info("Start to initialize indices for type: {}, prefix: {}", type.getName(), prefix); var watch = new StopWatch("Initialize indices for " + type.getName()); var indexedCount = 0L; do { watch.start("Indexing from " + (nameCursor == null ? "@start" : nameCursor)); extensionStores = client.listBy(prefix, nameCursor, 100); indexEngine.insert(extensionStores.stream() .map(es -> this.extensionConverter.convertFrom(type, es))::iterator ); if (!extensionStores.isEmpty()) { nameCursor = extensionStores.getLast().getName(); } indexedCount += extensionStores.size(); watch.stop(); } while (!extensionStores.isEmpty()); log.info("Total indexed count: {}, initialization summary: {}", indexedCount, watch.prettyPrint(TimeUnit.MILLISECONDS)); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/router/ExtensionCompositeRouterFunction.java ================================================ package run.halo.app.extension.router; import java.util.Collections; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import org.springframework.context.event.EventListener; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; import run.halo.app.extension.event.SchemeAddedEvent; import run.halo.app.extension.event.SchemeRemovedEvent; @Component public class ExtensionCompositeRouterFunction implements RouterFunction { private final ConcurrentMap> schemeRouterFuncMapper; private final ReactiveExtensionClient client; public ExtensionCompositeRouterFunction(ReactiveExtensionClient client) { this.client = client; schemeRouterFuncMapper = new ConcurrentHashMap<>(); } @Override @NonNull public Mono> route(@NonNull ServerRequest request) { return Flux.fromIterable(getRouterFunctions()) .concatMap(routerFunction -> routerFunction.route(request)) .next(); } @Override public void accept(@NonNull RouterFunctions.Visitor visitor) { getRouterFunctions().forEach(routerFunction -> routerFunction.accept(visitor)); } private Iterable> getRouterFunctions() { // TODO Copy router functions here return Collections.unmodifiableCollection(schemeRouterFuncMapper.values()); } @EventListener void onSchemeAddedEvent(SchemeAddedEvent event) { var scheme = event.getScheme(); var factory = new ExtensionRouterFunctionFactory(scheme, client); this.schemeRouterFuncMapper.put(scheme, factory.create()); } @EventListener void onSchemeRemovedEvent(SchemeRemovedEvent event) { this.schemeRouterFuncMapper.remove(event.getScheme()); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/router/ExtensionCreateHandler.java ================================================ package run.halo.app.extension.router; import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern; import java.net.URI; import org.springframework.http.MediaType; import org.springframework.lang.NonNull; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; import run.halo.app.extension.Unstructured; import run.halo.app.extension.exception.ExtensionConvertException; import run.halo.app.extension.router.ExtensionRouterFunctionFactory.CreateHandler; class ExtensionCreateHandler implements CreateHandler { private final Scheme scheme; private final ReactiveExtensionClient client; public ExtensionCreateHandler(Scheme scheme, ReactiveExtensionClient client) { this.scheme = scheme; this.client = client; } @Override @NonNull public Mono handle(@NonNull ServerRequest request) { return request.bodyToMono(Unstructured.class) .switchIfEmpty(Mono.error(() -> new ExtensionConvertException( "Cannot read body to " + scheme.groupVersionKind()))) .flatMap(client::create) .flatMap(createdExt -> ServerResponse .created(URI.create(pathPattern() + "/" + createdExt.getMetadata().getName())) .contentType(MediaType.APPLICATION_JSON) .bodyValue(createdExt)); } @Override public String pathPattern() { return buildExtensionPathPattern(scheme); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/router/ExtensionDeleteHandler.java ================================================ package run.halo.app.extension.router; import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern; import org.springframework.http.MediaType; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; import run.halo.app.extension.router.ExtensionRouterFunctionFactory.DeleteHandler; class ExtensionDeleteHandler implements DeleteHandler { private final Scheme scheme; private final ReactiveExtensionClient client; ExtensionDeleteHandler(Scheme scheme, ReactiveExtensionClient client) { this.scheme = scheme; this.client = client; } @Override public Mono handle(ServerRequest request) { var name = request.pathVariable("name"); return client.get(scheme.type(), name) .flatMap(client::delete) .flatMap(deleted -> ServerResponse .ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(deleted)); } @Override public String pathPattern() { return buildExtensionPathPattern(scheme) + "/{name}"; } } ================================================ FILE: application/src/main/java/run/halo/app/extension/router/ExtensionGetHandler.java ================================================ package run.halo.app.extension.router; import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern; import org.springframework.http.MediaType; import org.springframework.lang.NonNull; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; import run.halo.app.extension.router.ExtensionRouterFunctionFactory.GetHandler; class ExtensionGetHandler implements GetHandler { private final Scheme scheme; private final ReactiveExtensionClient client; public ExtensionGetHandler(Scheme scheme, ReactiveExtensionClient client) { this.scheme = scheme; this.client = client; } @Override public String pathPattern() { return buildExtensionPathPattern(scheme) + "/{name}"; } @Override @NonNull public Mono handle(@NonNull ServerRequest request) { var extensionName = request.pathVariable("name"); return client.get(scheme.type(), extensionName) .flatMap(extension -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(extension)); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/router/ExtensionListHandler.java ================================================ package run.halo.app.extension.router; import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern; import org.springframework.http.MediaType; import org.springframework.lang.NonNull; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; import run.halo.app.extension.router.ExtensionRouterFunctionFactory.ListHandler; class ExtensionListHandler implements ListHandler { private final Scheme scheme; private final ReactiveExtensionClient client; public ExtensionListHandler(Scheme scheme, ReactiveExtensionClient client) { this.scheme = scheme; this.client = client; } @Override @NonNull public Mono handle(@NonNull ServerRequest request) { var queryParams = new SortableRequest(request.exchange()); return client.listBy(scheme.type(), queryParams.toListOptions(), queryParams.toPageRequest() ) .flatMap(listResult -> ServerResponse .ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(listResult)); } @Override public String pathPattern() { return buildExtensionPathPattern(scheme); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/router/ExtensionPatchHandler.java ================================================ package run.halo.app.extension.router; import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern; import com.fasterxml.jackson.databind.node.ObjectNode; import com.github.fge.jsonpatch.JsonPatch; import com.github.fge.jsonpatch.JsonPatchException; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import org.springframework.web.server.UnsupportedMediaTypeStatusException; import reactor.core.publisher.Mono; import run.halo.app.extension.JsonExtension; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; import run.halo.app.extension.router.ExtensionRouterFunctionFactory.PatchHandler; /** * Handler for patching extension. * * @author johnniang */ @Slf4j public class ExtensionPatchHandler implements PatchHandler { private static final MediaType JSON_PATCH_MEDIA_TYPE = MediaType.valueOf("application/json-patch+json"); private final Scheme scheme; private final ReactiveExtensionClient client; public ExtensionPatchHandler(Scheme scheme, ReactiveExtensionClient client) { this.scheme = scheme; this.client = client; } @Override public Mono handle(ServerRequest request) { var name = request.pathVariable("name"); var contentTypeOpt = request.headers().contentType(); if (contentTypeOpt.isEmpty()) { return Mono.error( new UnsupportedMediaTypeStatusException((MediaType) null, List.of(JSON_PATCH_MEDIA_TYPE)) ); } var contentType = contentTypeOpt.get(); if (!contentType.isCompatibleWith(JSON_PATCH_MEDIA_TYPE)) { return Mono.error( new UnsupportedMediaTypeStatusException(contentType, List.of(JSON_PATCH_MEDIA_TYPE)) ); } return request.bodyToMono(JsonPatch.class) .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required."))) .flatMap(jsonPatch -> client.getJsonExtension(scheme.groupVersionKind(), name) .flatMap(jsonExtension -> { try { // apply the patch var appliedJsonNode = (ObjectNode) jsonPatch.apply(jsonExtension.getInternal()); var patchedExtension = new JsonExtension(jsonExtension.getObjectMapper(), appliedJsonNode); // update the patched extension return client.update(patchedExtension); } catch (JsonPatchException e) { return Mono.error(e); } })) .flatMap(updated -> ServerResponse.ok().bodyValue(updated)); } @Override public String pathPattern() { return buildExtensionPathPattern(scheme) + "/{name}"; } } ================================================ FILE: application/src/main/java/run/halo/app/extension/router/ExtensionRouterFunctionFactory.java ================================================ package run.halo.app.extension.router; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import io.swagger.v3.core.util.RefUtils; import io.swagger.v3.oas.annotations.enums.ParameterIn; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.lang.NonNull; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; public class ExtensionRouterFunctionFactory { private final Scheme scheme; private final ReactiveExtensionClient client; public ExtensionRouterFunctionFactory(Scheme scheme, ReactiveExtensionClient client) { this.scheme = scheme; this.client = client; } @NonNull public RouterFunction create() { var getHandler = new ExtensionGetHandler(scheme, client); var listHandler = new ExtensionListHandler(scheme, client); var createHandler = new ExtensionCreateHandler(scheme, client); var updateHandler = new ExtensionUpdateHandler(scheme, client); var deleteHandler = new ExtensionDeleteHandler(scheme, client); var patchHandler = new ExtensionPatchHandler(scheme, client); // TODO More handlers here var gvk = scheme.groupVersionKind(); var kind = gvk.kind(); var tagName = gvk.kind() + StringUtils.capitalize(gvk.version()); return SpringdocRouteBuilder.route() .GET(getHandler.pathPattern(), getHandler, builder -> builder.operationId("get" + kind) .description("Get " + kind) .tag(tagName) .parameter(parameterBuilder().in(ParameterIn.PATH) .name("name") .description("Name of " + scheme.singular())) .response(responseBuilder().responseCode("200") .description("Response single " + scheme.singular()) .implementation(scheme.type()))) .GET(listHandler.pathPattern(), listHandler, builder -> { builder.operationId("list" + kind) .description("List " + kind) .tag(tagName) .response(responseBuilder().responseCode("200") .description("Response " + scheme.plural()) .implementation(ListResult.generateGenericClass(scheme))); SortableRequest.buildParameters(builder); }) .POST(createHandler.pathPattern(), createHandler, builder -> builder.operationId("create" + kind) .description("Create " + kind) .tag(tagName) .requestBody(requestBodyBuilder() .description("Fresh " + scheme.singular()) .implementation(scheme.type())) .response(responseBuilder().responseCode("200") .description("Response " + scheme.plural() + " created just now") .implementation(scheme.type()))) .PUT(updateHandler.pathPattern(), updateHandler, builder -> builder.operationId("update" + kind) .description("Update " + kind) .tag(tagName) .parameter(parameterBuilder().in(ParameterIn.PATH) .name("name") .description("Name of " + scheme.singular())) .requestBody(requestBodyBuilder() .description("Updated " + scheme.singular()) .implementation(scheme.type())) .response(responseBuilder().responseCode("200") .description("Response " + scheme.plural() + " updated just now") .implementation(scheme.type()))) .PATCH(patchHandler.pathPattern(), patchHandler, builder -> builder.operationId("patch" + kind) .description("Patch " + kind) .tag(tagName) .parameter(parameterBuilder().in(ParameterIn.PATH) .name("name") .description("Name of " + scheme.singular())) .requestBody(requestBodyBuilder() .content(contentBuilder() .mediaType("application/json-patch+json") .schema( schemaBuilder().ref(RefUtils.constructRef(JsonPatch.SCHEMA_NAME)) ) ) ) .response(responseBuilder().responseCode("200") .description("Response " + scheme.singular() + " patched just now") .implementation(scheme.type()) ) ) .DELETE(deleteHandler.pathPattern(), deleteHandler, builder -> builder.operationId("delete" + kind) .description("Delete " + kind) .tag(tagName) .parameter(parameterBuilder().in(ParameterIn.PATH) .name("name") .description("Name of " + scheme.singular())) .response(responseBuilder().responseCode("200") .description("Response " + scheme.singular() + " deleted just now"))) .build(); } interface PathPatternGenerator { String pathPattern(); static String buildExtensionPathPattern(Scheme scheme) { var gvk = scheme.groupVersionKind(); StringBuilder pattern = new StringBuilder(); if (gvk.hasGroup()) { pattern.append("/apis/").append(gvk.group()); } else { pattern.append("/api"); } return pattern.append('/').append(gvk.version()).append('/').append(scheme.plural()) .toString(); } } interface GetHandler extends HandlerFunction, PathPatternGenerator { } interface ListHandler extends HandlerFunction, PathPatternGenerator { } interface CreateHandler extends HandlerFunction, PathPatternGenerator { } interface UpdateHandler extends HandlerFunction, PathPatternGenerator { } interface DeleteHandler extends HandlerFunction, PathPatternGenerator { } interface PatchHandler extends HandlerFunction, PathPatternGenerator { } } ================================================ FILE: application/src/main/java/run/halo/app/extension/router/ExtensionUpdateHandler.java ================================================ package run.halo.app.extension.router; import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern; import java.util.Objects; import org.springframework.http.MediaType; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; import run.halo.app.extension.Unstructured; import run.halo.app.extension.router.ExtensionRouterFunctionFactory.UpdateHandler; class ExtensionUpdateHandler implements UpdateHandler { private final Scheme scheme; private final ReactiveExtensionClient client; ExtensionUpdateHandler(Scheme scheme, ReactiveExtensionClient client) { this.scheme = scheme; this.client = client; } @Override public Mono handle(ServerRequest request) { String name = request.pathVariable("name"); return request.bodyToMono(Unstructured.class) .filter(unstructured -> unstructured.getMetadata() != null && StringUtils.hasText(unstructured.getMetadata().getName()) && Objects.equals(unstructured.getMetadata().getName(), name)) .switchIfEmpty(Mono.error(() -> new ServerWebInputException( "Cannot read body to " + scheme.groupVersionKind()))) .flatMap(client::update) .flatMap(updated -> ServerResponse .ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(updated)); } @Override public String pathPattern() { return buildExtensionPathPattern(scheme) + "/{name}"; } } ================================================ FILE: application/src/main/java/run/halo/app/extension/router/JsonPatch.java ================================================ package run.halo.app.extension.router; import static io.swagger.v3.oas.models.Components.COMPONENTS_SCHEMAS_REF; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.media.StringSchema; import java.util.List; import java.util.Map; import java.util.function.Function; /** * JSON schema for JSONPatch operations. * * @author johnniang */ public final class JsonPatch { private JsonPatch() {} public static final String SCHEMA_NAME = "JsonPatch"; public static void addSchema(Components components) { Function> opSchemaFunc = op -> new StringSchema()._enum(List.of(op)).type("string"); var pathSchema = new StringSchema() .description("A JSON Pointer path") .pattern("^(/[^/~]*(~[01][^/~]*)*)*$") .example("/a/b/c"); var valueSchema = new Schema<>().description("Value can be any JSON value"); var operationSchema = new io.swagger.v3.oas.models.media.Schema<>() .oneOf(List.of( new io.swagger.v3.oas.models.media.Schema<>() .$ref(COMPONENTS_SCHEMAS_REF + "AddOperation"), new io.swagger.v3.oas.models.media.Schema<>() .$ref(COMPONENTS_SCHEMAS_REF + "ReplaceOperation"), new io.swagger.v3.oas.models.media.Schema<>() .$ref(COMPONENTS_SCHEMAS_REF + "TestOperation"), new io.swagger.v3.oas.models.media.Schema<>() .$ref(COMPONENTS_SCHEMAS_REF + "RemoveOperation"), new io.swagger.v3.oas.models.media.Schema<>() .$ref(COMPONENTS_SCHEMAS_REF + "MoveOperation"), new io.swagger.v3.oas.models.media.Schema<>() .$ref(COMPONENTS_SCHEMAS_REF + "CopyOperation") )); components.addSchemas("AddOperation", new io.swagger.v3.oas.models.media.ObjectSchema() .required(List.of("op", "path", "value")) .properties(Map.of( "op", opSchemaFunc.apply("add"), "path", pathSchema, "value", valueSchema ))) ; components.addSchemas("ReplaceOperation", new io.swagger.v3.oas.models.media.ObjectSchema() .required(List.of("op", "path", "value")) .properties(Map.of( "op", opSchemaFunc.apply("replace"), "path", pathSchema, "value", valueSchema ))) ; components.addSchemas("TestOperation", new io.swagger.v3.oas.models.media.ObjectSchema() .required(List.of("op", "path", "value")) .properties(Map.of( "op", opSchemaFunc.apply("test"), "path", pathSchema, "value", valueSchema ))) ; components.addSchemas("RemoveOperation", new io.swagger.v3.oas.models.media.ObjectSchema() .required(List.of("op", "path")) .properties(Map.of( "op", opSchemaFunc.apply("remove"), "path", pathSchema ))) ; components.addSchemas("MoveOperation", new io.swagger.v3.oas.models.media.ObjectSchema() .required(List.of("op", "from", "path")) .properties(Map.of( "op", opSchemaFunc.apply("move"), "from", pathSchema .description("A JSON Pointer path pointing to the location to move/copy from."), "path", pathSchema ))) ; components.addSchemas("CopyOperation", new io.swagger.v3.oas.models.media.ObjectSchema() .required(List.of("op", "from", "path")) .properties(Map.of( "op", opSchemaFunc.apply("copy"), "from", pathSchema .description("A JSON Pointer path pointing to the location to move/copy from."), "path", pathSchema ))) ; components.addSchemas(SCHEMA_NAME, new io.swagger.v3.oas.models.media.ArraySchema() .description("JSON schema for JSONPatch operations") .uniqueItems(true) .minItems(1) .items(operationSchema) ); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/store/ExtensionStore.java ================================================ package run.halo.app.extension.store; import lombok.Data; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Version; import org.springframework.data.relational.core.mapping.Table; /** * ExtensionStore is an entity for storing Extension data into database. * * @author johnniang */ @Data @Table(name = "extensions") public class ExtensionStore { /** * Extension store name, which is globally unique. * We will use it to query Extensions by using left-like query clause. */ @Id private String name; /** * Exactly Extension body, which might be base64 format. */ private byte[] data; /** * This field only for serving optimistic lock value. */ @Version private Long version; public ExtensionStore() { } public ExtensionStore(String name, byte[] data) { this.name = name; this.data = data; } public ExtensionStore(String name, Long version) { this.name = name; this.version = version; } public ExtensionStore(String name, byte[] data, Long version) { this.name = name; this.data = data; this.version = version; } } ================================================ FILE: application/src/main/java/run/halo/app/extension/store/ExtensionStoreClient.java ================================================ package run.halo.app.extension.store; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; /** * An interface to query and operate ExtensionStore. * * @author johnniang */ public interface ExtensionStoreClient { /** * Lists all ExtensionStores by name prefix. * * @param prefix is the prefix of ExtensionStore name. * @return all ExtensionStores which names start with the prefix. */ List listByNamePrefix(String prefix); Page listByNamePrefix(String prefix, Pageable pageable); /** * Lists ExtensionStores by name prefix, after the given cursor name, and limit the result size. * * @param prefix the name prefix * @param nameCursor cursor name, exclusive and can be null * @param limit the max result size * @return a list of extension stores */ List listBy(String prefix, String nameCursor, int limit); List listByNames(List names); /** * Fetches an ExtensionStore by unique name. * * @param name is the full name of an ExtensionStore. * @return an optional ExtensionStore. */ Optional fetchByName(String name); /** * Creates an ExtensionStore. * * @param name is the full name of an ExtensionStore. * @param data is Extension body to be persisted. * @return a fresh ExtensionStore created just now. */ ExtensionStore create(String name, byte[] data); /** * Updates an ExtensionStore with version to prevent concurrent update. * * @param name is the full name of an ExtensionStore. * @param version is the expected version of ExtensionStore. * @param data is Extension body to be updated. * @return updated ExtensionStore with a fresh version. */ ExtensionStore update(String name, Long version, byte[] data); /** * Deletes an ExtensionStore by name and current version. * * @param name is the full name of an ExtensionStore. * @param version is the expected version of ExtensionStore. * @return previous ExtensionStore. */ ExtensionStore delete(String name, Long version); //TODO add watch method here. } ================================================ FILE: application/src/main/java/run/halo/app/extension/store/ExtensionStoreClientJPAImpl.java ================================================ package run.halo.app.extension.store; import java.time.Duration; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; /** * An implementation of ExtensionStoreClient using JPA. * * @author johnniang */ @Service public class ExtensionStoreClientJPAImpl implements ExtensionStoreClient { private static final Duration TIMEOUT = Duration.ofSeconds(30); private final ReactiveExtensionStoreClient storeClient; public ExtensionStoreClientJPAImpl(ReactiveExtensionStoreClient storeClient) { this.storeClient = storeClient; } @Override public List listByNamePrefix(String prefix) { return storeClient.listByNamePrefix(prefix).collectList().block(TIMEOUT); } @Override public Page listByNamePrefix(String prefix, Pageable pageable) { return storeClient.listByNamePrefix(prefix, pageable).block(TIMEOUT); } @Override public List listBy(String prefix, String nameCursor, int limit) { return storeClient.listBy(prefix, nameCursor, limit).collectList().block(TIMEOUT); } @Override public List listByNames(List names) { return storeClient.listByNames(names).collectList().block(TIMEOUT); } @Override public Optional fetchByName(String name) { return storeClient.fetchByName(name).blockOptional(TIMEOUT); } @Override public ExtensionStore create(String name, byte[] data) { return storeClient.create(name, data).block(TIMEOUT); } @Override public ExtensionStore update(String name, Long version, byte[] data) { return storeClient.update(name, version, data).block(TIMEOUT); } @Override public ExtensionStore delete(String name, Long version) { return storeClient.delete(name, version).block(TIMEOUT); } } ================================================ FILE: application/src/main/java/run/halo/app/extension/store/ExtensionStoreRepository.java ================================================ package run.halo.app.extension.store; import java.util.Collection; import org.springframework.data.domain.Pageable; import org.springframework.data.r2dbc.repository.R2dbcRepository; import org.springframework.stereotype.Repository; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** * This repository contains some basic operations on ExtensionStore entity. * * @author johnniang */ @Repository public interface ExtensionStoreRepository extends R2dbcRepository { /** * Finds all ExtensionStore by name prefix. * * @param prefix is the prefix of name. * @return all ExtensionStores which names starts with the given prefix. */ Flux findAllByNameStartingWith(String prefix); Flux findAllByNameStartingWith(String prefix, Pageable pageable); Mono countByNameStartingWith(String prefix); /** *

Finds all ExtensionStore by name in, the result no guarantee the same order as the given * names, so if you want this, please order the result by yourself.

* * @param names names to find * @return a flux of extension stores */ Flux findByNameIn(Collection names); Flux findAllByNameStartingWithAndNameGreaterThan( String prefix, String nameCursor, Pageable pageable); } ================================================ FILE: application/src/main/java/run/halo/app/extension/store/ReactiveExtensionStoreClient.java ================================================ package run.halo.app.extension.store; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.lang.Nullable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public interface ReactiveExtensionStoreClient { Flux listByNamePrefix(String prefix); Mono> listByNamePrefix(String prefix, Pageable pageable); /** * List stores by name prefix, after the given cursor name, and limit the result size. * * @param prefix the name prefix * @param nameCursor cursor name, exclusive and can be null * @param limit the max result size * @return a flux of extension stores */ Flux listBy(String prefix, @Nullable String nameCursor, int limit); Mono countByNamePrefix(String prefix); /** * List stores by names and return data according to given names order. * * @param names store names to list * @return a flux of extension stores */ Flux listByNames(List names); Mono fetchByName(String name); Mono create(String name, byte[] data); Mono update(String name, Long version, byte[] data); Mono delete(String name, Long version); } ================================================ FILE: application/src/main/java/run/halo/app/extension/store/ReactiveExtensionStoreClientImpl.java ================================================ package run.halo.app.extension.store; import java.util.Comparator; import java.util.List; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Strings; import org.jspecify.annotations.Nullable; import org.springframework.dao.DuplicateKeyException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.r2dbc.core.R2dbcEntityOperations; import org.springframework.data.relational.core.query.Criteria; import org.springframework.data.relational.core.query.Query; import org.springframework.data.support.ReactivePageableExecutionUtils; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.infra.exception.DuplicateNameException; @Component @RequiredArgsConstructor public class ReactiveExtensionStoreClientImpl implements ReactiveExtensionStoreClient { private static final int DEFAULT_FETCH_SIZE = 100; private final ExtensionStoreRepository repository; private final R2dbcEntityOperations entityOperations; private int fetchSize = DEFAULT_FETCH_SIZE; void setFetchSize(int fetchSize) { Assert.isTrue(fetchSize >= 0, "fetchSize must be greater than or equal to 0"); this.fetchSize = fetchSize; } @Override public Flux listByNamePrefix(String prefix) { Assert.hasText(prefix, "Prefix must not be blank"); prefix = Strings.CS.appendIfMissing(prefix, "/"); return entityOperations.select(ExtensionStore.class) .withFetchSize(fetchSize) .matching(Query.query( Criteria.where("name").like(prefix + "%") ) .sort(Sort.by(Sort.Direction.ASC, "name")) ) .all(); } @Override public Mono> listByNamePrefix(String prefix, Pageable pageable) { Assert.hasText(prefix, "Prefix must not be blank"); var q = Query.query( Criteria.where("name").like(prefix + "%") ).sort(Sort.by(Sort.Direction.ASC, "name")); var getItems = entityOperations.select(ExtensionStore.class) .matching(q.with(pageable)) .all() .collectList(); var getCount = entityOperations.select(ExtensionStore.class) .matching(q) .count(); return getItems.flatMap( items -> ReactivePageableExecutionUtils.getPage(items, pageable, getCount) ); } @Override public Flux listBy(String prefix, @Nullable String nameCursor, int limit) { Assert.hasText(prefix, "Prefix must not be blank"); Assert.isTrue(limit > 0, "Limit must be greater than 0"); prefix = Strings.CS.appendIfMissing(prefix, "/"); var criteria = Criteria.where("name").like(prefix + "%"); if (StringUtils.isNotBlank(nameCursor)) { nameCursor = Strings.CS.prependIfMissing(nameCursor, prefix); criteria = criteria.and(Criteria.where("name").greaterThan(nameCursor)); } var q = Query.query(criteria).sort(Sort.by(Sort.Direction.ASC, "name")); return entityOperations.select(ExtensionStore.class) .matching(q.limit(limit)) .all(); } @Override public Mono countByNamePrefix(String prefix) { Assert.hasText(prefix, "Prefix must not be blank"); var q = Query.query( Criteria.where("name").like(prefix + "%") ) .sort(Sort.by(Sort.Direction.ASC, "name")); return entityOperations.select(ExtensionStore.class) .matching(q) .count(); } @Override public Flux listByNames(List names) { if (CollectionUtils.isEmpty(names)) { return Flux.empty(); } // Keep the order of names efficiently var orderMap = IntStream.range(0, names.size()) .boxed() .collect(Collectors.toMap(names::get, Function.identity(), (a, b) -> a)); return repository.findByNameIn(names) .sort(Comparator.comparingInt(es -> orderMap.get(es.getName()))); } @Override public Mono fetchByName(String name) { return repository.findById(name); } @Override public Mono create(String name, byte[] data) { return repository.save(new ExtensionStore(name, data)) .onErrorMap(DuplicateKeyException.class, t -> new DuplicateNameException("Duplicate name detected.", t)); } @Override public Mono update(String name, Long version, byte[] data) { return repository.save(new ExtensionStore(name, data, version)); } @Override public Mono delete(String name, Long version) { return repository.findById(name) .flatMap(extensionStore -> { // reset the version extensionStore.setVersion(version); return repository.delete(extensionStore).thenReturn(extensionStore); }); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/DefaultBackupRootGetter.java ================================================ package run.halo.app.infra; import java.nio.file.Path; import org.springframework.stereotype.Component; import run.halo.app.infra.properties.HaloProperties; @Component public class DefaultBackupRootGetter implements BackupRootGetter { private final HaloProperties haloProperties; public DefaultBackupRootGetter(HaloProperties haloProperties) { this.haloProperties = haloProperties; } @Override public Path get() { return haloProperties.getWorkDir().resolve("backups"); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java ================================================ package run.halo.app.infra; import java.net.URI; import java.net.URISyntaxException; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import org.springframework.web.filter.reactive.ServerWebExchangeContextFilter; import org.springframework.web.util.UriComponentsBuilder; import reactor.core.publisher.Mono; import run.halo.app.infra.utils.PathUtils; /** * Default implementation of {@link ExternalLinkProcessor}. * * @author guqing * @since 2.9.0 */ @Component @RequiredArgsConstructor public class DefaultExternalLinkProcessor implements ExternalLinkProcessor { private final ExternalUrlSupplier externalUrlSupplier; @Override public String processLink(String link) { var externalLink = externalUrlSupplier.getRaw(); if (StringUtils.isBlank(link) || externalLink == null || PathUtils.isAbsoluteUri(link)) { return link; } return append(externalLink.toString(), link); } @Override public Mono processLink(URI uri) { if (uri.isAbsolute()) { return Mono.just(uri); } return Mono.deferContextual(contextView -> Mono.fromSupplier( () -> ServerWebExchangeContextFilter.getExchange(contextView) .map(exchange -> externalUrlSupplier.getURL(exchange.getRequest())) .or(() -> Optional.ofNullable(externalUrlSupplier.getRaw())) .map(externalUrl -> { try { var uriComponents = UriComponentsBuilder.fromUriString(uri.toASCIIString()) .build(true); return UriComponentsBuilder.fromUri(externalUrl.toURI()) .pathSegment(uriComponents.getPathSegments().toArray(new String[0])) .queryParams(uriComponents.getQueryParams()) .fragment(uriComponents.getFragment()) .build(true) .toUri(); } catch (URISyntaxException e) { // should never happen return uri; } }) .orElse(uri) )); } String append(String externalLink, String link) { return StringUtils.removeEnd(externalLink, "/") + StringUtils.prependIfMissing(link, "/"); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/DefaultInitializationStateGetter.java ================================================ package run.halo.app.infra; import static org.apache.commons.lang3.BooleanUtils.isTrue; import static run.halo.app.extension.index.query.Queries.isNull; import java.util.concurrent.atomic.AtomicBoolean; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ListOptions; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.extension.router.selector.LabelSelector; /** *

A cache that caches system setup state.

* when setUp state changed, the cache will be updated. * * @author guqing * @since 2.5.2 */ @Component @RequiredArgsConstructor public class DefaultInitializationStateGetter implements InitializationStateGetter { private final ReactiveExtensionClient client; private final AtomicBoolean userInitialized = new AtomicBoolean(false); private final AtomicBoolean dataInitialized = new AtomicBoolean(false); @Override public Mono userInitialized() { // If user is initialized, return true directly. if (userInitialized.get()) { return Mono.just(true); } return hasUser() .doOnNext(userInitialized::set); } @Override public Mono dataInitialized() { if (dataInitialized.get()) { return Mono.just(true); } return client.fetch(ConfigMap.class, SystemState.SYSTEM_STATES_CONFIGMAP) .map(config -> { SystemState systemState = SystemState.deserialize(config); return isTrue(systemState.getIsSetup()); }) .defaultIfEmpty(false) .doOnNext(dataInitialized::set); } private Mono hasUser() { var listOptions = new ListOptions(); listOptions.setLabelSelector(LabelSelector.builder() .notEq(User.HIDDEN_USER_LABEL, "true") .build() ); listOptions.setFieldSelector( FieldSelector.of(isNull("metadata.deletionTimestamp"))); return client.listBy(User.class, listOptions, PageRequestImpl.ofSize(1)) .map(result -> result.getTotal() > 0) .defaultIfEmpty(false); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/DefaultReactiveUrlDataBufferFetcher.java ================================================ package run.halo.app.infra; import java.net.URI; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.ExchangeStrategies; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.netty.http.client.HttpClient; /** *

A default implementation of {@link ReactiveUrlDataBufferFetcher}.

* * @author guqing * @since 2.6.0 */ @Component public class DefaultReactiveUrlDataBufferFetcher implements ReactiveUrlDataBufferFetcher { private final HttpClient httpClient = HttpClient.create() .followRedirect(true); private final ContentLengthFetcher contentLengthFetcher = new ContentLengthFetcher(); private final WebClient webClient = WebClient.builder() .clientConnector(new ReactorClientHttpConnector(httpClient)) .build(); @Override public Flux fetch(URI uri) { return webClient.get() .uri(uri) .accept(MediaType.APPLICATION_OCTET_STREAM) .retrieve() .bodyToFlux(DataBuffer.class); } @Override public Mono head(URI uri) { return contentLengthFetcher.fetchContentLength(uri); } static class ContentLengthFetcher { private final WebClient webClient; ContentLengthFetcher() { this.webClient = WebClient.builder() .exchangeStrategies(ExchangeStrategies.builder() .codecs(config -> config.defaultCodecs().maxInMemorySize(1)) .build()) .build(); } Mono fetchContentLength(URI url) { return webClient.get() .uri(url) .exchangeToMono(response -> { HttpHeaders headers = response.headers().asHttpHeaders(); return response.bodyToMono(byte[].class) .onErrorResume(ex -> Mono.empty()) .thenReturn(headers); }); } } } ================================================ FILE: application/src/main/java/run/halo/app/infra/DefaultSystemConfigFetcher.java ================================================ package run.halo.app.infra; import static run.halo.app.infra.SystemSetting.SYSTEM_CONFIG; import static run.halo.app.infra.SystemSetting.SYSTEM_CONFIG_DEFAULT; import com.fasterxml.jackson.core.JsonProcessingException; import java.time.Duration; import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationListener; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.convert.ConversionService; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.JsonParseException; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.infra.utils.SystemConfigUtils; import tools.jackson.databind.json.JsonMapper; @Component @RequiredArgsConstructor @Order(Ordered.HIGHEST_PRECEDENCE) class DefaultSystemConfigFetcher implements SystemConfigFetcher, ApplicationListener { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private final JsonMapper mapper; private final ReactiveExtensionClient extensionClient; private final ConversionService conversionService; private final AtomicReference> configMapCache = new AtomicReference<>(); private final Mono> configMapMono = Mono.defer(() -> { var currentValue = configMapCache.get(); if (currentValue != null) { return Mono.just(currentValue); } return computeSystemConfig().mapNotNull(configMap -> { if (configMapCache.compareAndSet(null, configMap.getData())) { return configMap.getData(); } else { return configMapCache.get(); } }).defaultIfEmpty(Map.of()); }).cacheInvalidateIf(configMap -> { var currentValue = configMapCache.get(); return currentValue == null || !currentValue.equals(configMap); }); @Override public void onApplicationEvent(SystemConfigChangedEvent event) { configMapCache.set(event.getNewData()); } @Override public Mono fetch(String key, Class type) { return getValuesInternal() .filter(map -> map.containsKey(key)) .map(map -> map.get(key)) .mapNotNull(stringValue -> { if (conversionService.canConvert(String.class, type)) { return conversionService.convert(stringValue, type); } return mapper.readValue(stringValue, type); }); } @Override public Mono getBasic() { return fetch(SystemSetting.Basic.GROUP, SystemSetting.Basic.class) .switchIfEmpty(Mono.fromSupplier(SystemSetting.Basic::new)); } @Override public Mono fetchComment() { return fetch(SystemSetting.Comment.GROUP, SystemSetting.Comment.class) .switchIfEmpty(Mono.fromSupplier(SystemSetting.Comment::new)); } @Override public Mono fetchPost() { return fetch(SystemSetting.Post.GROUP, SystemSetting.Post.class) .switchIfEmpty(Mono.fromSupplier(SystemSetting.Post::new)); } @Override public Mono fetchRouteRules() { return fetch(SystemSetting.ThemeRouteRules.GROUP, SystemSetting.ThemeRouteRules.class); } @NonNull private Mono> getValuesInternal() { return configMapMono; } @Override public Mono> getConfig() { return configMapMono; } /** * Load the system config map from the extension client. * * @return latest configMap from {@link ReactiveExtensionClient} without any cache. */ @Override public Mono getConfigMap() { return extensionClient.fetch(ConfigMap.class, SYSTEM_CONFIG); } /** * Gets the system config map without any cache. * * @return load configMap from {@link ReactiveExtensionClient} */ @Override public Optional getConfigMapBlocking() { return getConfigMap().blockOptional(BLOCKING_TIMEOUT); } private Mono computeSystemConfig() { var getOverrideConfigMap = extensionClient.fetch(ConfigMap.class, SYSTEM_CONFIG); var getDefaultConfigMap = extensionClient.fetch(ConfigMap.class, SYSTEM_CONFIG_DEFAULT) .switchIfEmpty(Mono.fromSupplier(() -> { var defaultConfigMap = new ConfigMap(); defaultConfigMap.setData(Map.of()); return defaultConfigMap; })); return Mono.zip(getDefaultConfigMap, getOverrideConfigMap, (defaultConfigMap, overrideConfigMap) -> { try { return SystemConfigUtils.mergeConfigMap(defaultConfigMap, overrideConfigMap); } catch (JsonProcessingException e) { throw new JsonParseException(e); } }); } /** * Gets the config map cache. Only for test use. * * @return the config map cache */ AtomicReference> getConfigMapCache() { return configMapCache; } /** * Gets the config map mono. Only for test use. * * @return the config map mono */ Mono> getConfigMapMono() { return configMapMono; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/DefaultSystemVersionSupplier.java ================================================ package run.halo.app.infra; import com.github.zafarkhaja.semver.Version; import java.util.Objects; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.info.BuildProperties; import org.springframework.stereotype.Component; /** * Default implementation of system version supplier. * * @author guqing * @since 2.0.0 */ @Component public class DefaultSystemVersionSupplier implements SystemVersionSupplier { private static final String DEFAULT_VERSION = "0.0.0"; private final ObjectProvider buildProperties; public DefaultSystemVersionSupplier(ObjectProvider buildProperties) { this.buildProperties = buildProperties; } @Override public Version get() { var properties = buildProperties.getIfUnique(); if (properties == null) { return Version.parse(DEFAULT_VERSION); } var projectVersion = Objects.toString(properties.getVersion(), DEFAULT_VERSION); return Version.parse(projectVersion); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/DefaultThemeRootGetter.java ================================================ package run.halo.app.infra; import java.nio.file.Path; import org.springframework.stereotype.Component; import run.halo.app.infra.properties.HaloProperties; @Component public class DefaultThemeRootGetter implements ThemeRootGetter { private final HaloProperties haloProps; public DefaultThemeRootGetter(HaloProperties haloProps) { this.haloProps = haloProps; } @Override public Path get() { return haloProps.getWorkDir().resolve("themes"); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/ExtensionInitializedEvent.java ================================================ package run.halo.app.infra; import org.springframework.context.ApplicationEvent; /** * ExtensionInitializedEvent is fired after extensions have been initialized completely. * * @author johnniang */ public class ExtensionInitializedEvent extends ApplicationEvent { public ExtensionInitializedEvent(Object source) { super(source); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/ExtensionResourceInitializer.java ================================================ package run.halo.app.infra; import java.io.IOException; import java.time.Duration; import java.util.HashSet; import java.util.List; import java.util.Set; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.SmartLifecycle; import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Unstructured; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.utils.YamlUnstructuredLoader; /** *

Extension resources initializer.

*

Check whether {@link HaloProperties#getInitialExtensionLocations()} is configured * When the system ready, and load resources according to it to creates {@link Unstructured}

* * @author guqing * @since 2.0.0 */ @Slf4j @Component public class ExtensionResourceInitializer implements SmartLifecycle { private volatile boolean running; public static final Set REQUIRED_EXTENSION_LOCATIONS = Set.of("classpath:/extensions/*.yaml", "classpath:/extensions/*.yml"); private final HaloProperties haloProperties; private final ReactiveExtensionClient extensionClient; private final ApplicationEventPublisher eventPublisher; public ExtensionResourceInitializer(HaloProperties haloProperties, ReactiveExtensionClient extensionClient, ApplicationEventPublisher eventPublisher) { this.haloProperties = haloProperties; this.extensionClient = extensionClient; this.eventPublisher = eventPublisher; } @Override public void start() { if (running) { return; } running = true; var locations = new HashSet(); if (!haloProperties.isRequiredExtensionDisabled()) { locations.addAll(REQUIRED_EXTENSION_LOCATIONS); } if (haloProperties.getInitialExtensionLocations() != null) { locations.addAll(haloProperties.getInitialExtensionLocations()); } if (CollectionUtils.isEmpty(locations)) { return; } Flux.fromIterable(locations) .doOnNext(location -> log.debug("Trying to initialize extension resources from location: {}", location)) .map(this::listResources) .distinct() .flatMapIterable(resources -> resources) .doOnNext(resource -> log.debug("Initializing extension resource from location: {}", resource)) .map(resource -> new YamlUnstructuredLoader(resource).load()) .flatMapIterable(extensions -> extensions) .doOnNext(extension -> { if (log.isDebugEnabled()) { log.debug("Initializing extension resource: {}/{}", extension.groupVersionKind(), extension.getMetadata().getName()); } }) .flatMap(this::createOrUpdate) .doOnNext(extension -> { if (log.isDebugEnabled()) { log.debug("Initialized extension resource: {}/{}", extension.groupVersionKind(), extension.getMetadata().getName()); } }) .then() .block(Duration.ofMinutes(1)); eventPublisher.publishEvent(new ExtensionInitializedEvent(this)); } @Override public void stop() { if (!running) { return; } running = false; } @Override public boolean isRunning() { return running; } @Override public int getPhase() { return InitializationPhase.EXTENSION_RESOURCES.getPhase(); } private Mono createOrUpdate(Unstructured extension) { return Mono.just(extension) .flatMap(ext -> extensionClient.fetch(extension.groupVersionKind(), extension.getMetadata().getName())) .flatMap(existingExt -> { if (ExtensionUtil.hasDoNotOverwriteLabel(existingExt)) { log.debug("Extension {} is marked as do-not-overwrite, skipping update", existingExt.getMetadata().getName() ); // skip update return Mono.just(existingExt); } // force update extension.getMetadata().setVersion(existingExt.getMetadata().getVersion()); return extensionClient.update(extension); }) .switchIfEmpty(Mono.defer(() -> { if (ExtensionUtil.isDeleted(extension)) { // skip deleted extension return Mono.empty(); } return extensionClient.create(extension); })); } private List listResources(String location) { var resolver = new PathMatchingResourcePatternResolver(); try { return List.of(resolver.getResources(location)); } catch (IOException ie) { throw new IllegalArgumentException("Invalid extension location: " + location, ie); } } } ================================================ FILE: application/src/main/java/run/halo/app/infra/ExternalUrlChangedEvent.java ================================================ package run.halo.app.infra; import java.net.URL; import lombok.Getter; import org.springframework.context.ApplicationEvent; /** * Event triggered when the external URL of the application changes. * * @author johnniang * @since 2.21.0 */ public class ExternalUrlChangedEvent extends ApplicationEvent { @Getter private final URL externalUrl; public ExternalUrlChangedEvent(Object source, URL externalUrl) { super(source); this.externalUrl = externalUrl; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/InitializationPhase.java ================================================ package run.halo.app.infra; /** * Phase of system initialization. * * @author johnniang */ public enum InitializationPhase { FIRST(Integer.MIN_VALUE), SCHEME(Integer.MIN_VALUE + 100), EXTENSION_RESOURCES, THEME_ROUTER_FUNCTIONS, GC_CONTROLLER, CONTROLLERS, LAST(Integer.MAX_VALUE), ; private static final int GAP = 100; private final int phase; InitializationPhase() { this.phase = ordinal() * GAP; } InitializationPhase(int phase) { this.phase = phase; } public int getPhase() { return phase; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/InitializationStateGetter.java ================================================ package run.halo.app.infra; import reactor.core.publisher.Mono; /** *

A interface that get system initialization state.

* * @author guqing * @since 2.9.0 */ public interface InitializationStateGetter { /** * Check if system user is initialized. * * @return true if system user is initialized, false otherwise. */ Mono userInitialized(); /** * Check if system basic data is initialized. * * @return true if system basic data is initialized, false otherwise. */ Mono dataInitialized(); } ================================================ FILE: application/src/main/java/run/halo/app/infra/ReactiveExtensionPaginatedOperator.java ================================================ package run.halo.app.infra; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.extension.Extension; import run.halo.app.extension.ListOptions; /** * Reactive extension paginated operator to handle extensions by pagination. * * @author guqing * @since 2.15.0 */ public interface ReactiveExtensionPaginatedOperator { /** *

Deletes all data, including any new entries added during the execution of this method.

*

This method continuously monitors and removes data that appears throughout its runtime, * ensuring that even data created during the deletion process is also removed.

*/ Mono deleteContinuously(Class type, ListOptions listOptions); /** *

Deletes only the data that existed at the start of the operation.

*

This method takes a snapshot of the data at the beginning and deletes only that dataset; * any data added after the method starts will not be affected or removed.

*/ Flux deleteInitialBatch(Class type, ListOptions listOptions); /** *

Note that: This method can not be used for deletion operation, because * deletion operation will change the total records.

*/ Flux list(Class type, ListOptions listOptions); } ================================================ FILE: application/src/main/java/run/halo/app/infra/ReactiveExtensionPaginatedOperatorImpl.java ================================================ package run.halo.app.infra; import static run.halo.app.extension.index.query.Queries.isNull; import java.time.Duration; import java.time.Instant; import lombok.RequiredArgsConstructor; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.extension.Extension; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; @Component @RequiredArgsConstructor public class ReactiveExtensionPaginatedOperatorImpl implements ReactiveExtensionPaginatedOperator { private static final int DEFAULT_PAGE_SIZE = 200; private final ReactiveExtensionClient client; @Override public Mono deleteContinuously(Class type, ListOptions listOptions) { var pageRequest = createPageRequest(); return cleanupContinuously(type, listOptions, pageRequest); } private Mono cleanupContinuously(Class type, ListOptions listOptions, PageRequest pageRequest) { // forever loop first page until no more to delete return pageBy(type, listOptions, pageRequest) .flatMap(page -> Flux.fromIterable(page.getItems()) .flatMap(client::delete) .then(page.hasNext() ? cleanupContinuously(type, listOptions, pageRequest) : Mono.empty()) ); } @Override public Flux deleteInitialBatch(Class type, ListOptions listOptions) { var pageRequest = createPageRequest(); var newFieldQuery = listOptions.getFieldSelector() .andQuery(isNull("metadata.deletionTimestamp")); listOptions.setFieldSelector(newFieldQuery); final Instant now = Instant.now(); return pageBy(type, listOptions, pageRequest) // forever loop first page until no more to delete .expand(result -> result.hasNext() ? pageBy(type, listOptions, pageRequest) : Mono.empty()) .flatMap(result -> Flux.fromIterable(result.getItems())) .takeWhile(item -> shouldTakeNext(item, now)) .flatMap(this::deleteWithRetry); } static boolean shouldTakeNext(E item, Instant now) { var creationTimestamp = item.getMetadata().getCreationTimestamp(); return creationTimestamp.isBefore(now) || creationTimestamp.equals(now); } @SuppressWarnings("unchecked") Mono deleteWithRetry(E item) { return client.delete(item) .onErrorResume(OptimisticLockingFailureException.class, e -> attemptToDelete((Class) item.getClass(), item.getMetadata().getName())); } private Mono attemptToDelete(Class type, String name) { return Mono.defer(() -> client.fetch(type, name) .flatMap(client::delete) ) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)); } @Override public Flux list(Class type, ListOptions listOptions) { var pageRequest = createPageRequest(); return list(type, listOptions, pageRequest); } /** * Paginated list all items to avoid memory overflow. *
     * 1. Retrieve data multiple times until all data is consumed.
     * 2. Fetch next page if current page has more data and consumed records is less than total
     * records.
     * 3. Take while consumed records is less than total records.
     * 4. totalRecords from first page to ensure new inserted data will not be counted in during
     * querying to avoid infinite loop.
     * 
*/ private Flux list(Class type, ListOptions listOptions, PageRequest pageRequest) { final var now = Instant.now(); return pageBy(type, listOptions, pageRequest) .expand(result -> { if (result.hasNext()) { // fetch next page var nextPage = nextPage(result, pageRequest.getSort()); return pageBy(type, listOptions, nextPage); } else { return Mono.empty(); } }) .flatMap(page -> Flux.fromIterable(page.getItems())) .takeWhile(item -> shouldTakeNext(item, now)); } static PageRequest nextPage(ListResult result, Sort sort) { return PageRequestImpl.of(result.getPage() + 1, result.getSize(), sort); } private PageRequest createPageRequest() { return PageRequestImpl.of(1, DEFAULT_PAGE_SIZE, Sort.by("metadata.creationTimestamp", "metadata.name")); } private Mono> pageBy(Class type, ListOptions listOptions, PageRequest pageRequest) { return client.listBy(type, listOptions, pageRequest); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/ReactiveUrlDataBufferFetcher.java ================================================ package run.halo.app.infra; import java.net.URI; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpHeaders; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** *

{@link DataBuffer} stream fetcher from uri.

* * @author guqing * @since 2.6.0 */ public interface ReactiveUrlDataBufferFetcher { /** *

Fetch data buffer flux from uri.

* * @param uri uri to fetch * @return data buffer flux */ Flux fetch(URI uri); /** *

Get head of the uri.

* * @param uri uri to fetch * @return response entity */ Mono head(URI uri); } ================================================ FILE: application/src/main/java/run/halo/app/infra/SchemeInitializer.java ================================================ package run.halo.app.infra; import static java.util.Objects.requireNonNullElse; import static run.halo.app.core.extension.Role.ROLE_AGGREGATE_LABEL_PREFIX; import static run.halo.app.core.extension.content.Comment.CommentOwner.ownerIdentity; import static run.halo.app.extension.index.IndexAttributeFactory.simpleAttribute; import com.fasterxml.jackson.core.type.TypeReference; import java.time.Instant; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.springframework.context.SmartLifecycle; import org.springframework.stereotype.Component; import run.halo.app.content.Stats; import run.halo.app.core.attachment.extension.LocalThumbnail; import run.halo.app.core.attachment.extension.Thumbnail; import run.halo.app.core.extension.AnnotationSetting; import run.halo.app.core.extension.AuthProvider; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.Device; import run.halo.app.core.extension.Menu; import run.halo.app.core.extension.MenuItem; import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.RememberMeToken; import run.halo.app.core.extension.ReverseProxy; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.User; import run.halo.app.core.extension.UserConnection; import run.halo.app.core.extension.UserConnection.UserConnectionSpec; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Attachment.AttachmentSpec; import run.halo.app.core.extension.attachment.Attachment.AttachmentStatus; import run.halo.app.core.extension.attachment.Group; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.attachment.PolicyTemplate; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Category.CategorySpec; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Comment.CommentSpec; import run.halo.app.core.extension.content.Comment.CommentStatus; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Post.PostSpec; import run.halo.app.core.extension.content.Post.PostStatus; import run.halo.app.core.extension.content.Post.VisibleEnum; import run.halo.app.core.extension.content.Reply; import run.halo.app.core.extension.content.Reply.ReplySpec; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.core.extension.content.SinglePage.SinglePageSpec; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.core.extension.content.Tag; import run.halo.app.core.extension.content.Tag.TagSpec; import run.halo.app.core.extension.content.Tag.TagStatus; import run.halo.app.core.extension.notification.Notification; import run.halo.app.core.extension.notification.Notification.NotificationSpec; import run.halo.app.core.extension.notification.NotificationTemplate; import run.halo.app.core.extension.notification.NotifierDescriptor; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.ReasonType; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.core.extension.notification.Subscription.InterestReason; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.MetadataOperator; import run.halo.app.extension.SchemeManager; import run.halo.app.extension.Secret; import run.halo.app.extension.index.IndexSpec; import run.halo.app.extension.index.IndexSpecs; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.migration.Backup; import run.halo.app.plugin.extensionpoint.ExtensionDefinition; import run.halo.app.plugin.extensionpoint.ExtensionPointDefinition; import run.halo.app.security.PersonalAccessToken; @Component class SchemeInitializer implements SmartLifecycle { private final SchemeManager schemeManager; private volatile boolean running; public SchemeInitializer(SchemeManager schemeManager) { this.schemeManager = schemeManager; } @Override public void start() { if (running) { return; } running = true; schemeManager.register(Role.class, is -> { is.add(IndexSpecs.multi("labels.aggregateToRoles", String.class) .indexFunc(role -> Optional.ofNullable(role.getMetadata().getLabels()).map( labels -> labels.keySet().stream() .filter(key -> key.startsWith(ROLE_AGGREGATE_LABEL_PREFIX)) .filter(key -> Boolean.parseBoolean(labels.get(key))) .map(key -> StringUtils.removeStart(key, ROLE_AGGREGATE_LABEL_PREFIX)) .collect(Collectors.toSet())).orElseGet(Set::of) ) ); }); // plugin.halo.run schemeManager.register(Plugin.class, is -> { is.add(IndexSpecs.single("spec.displayName", String.class) .indexFunc(plugin -> Optional.ofNullable(plugin.getSpec()) .map(Plugin.PluginSpec::getDisplayName) .orElse(null) ) ); is.add(IndexSpecs.single("spec.description", String.class) .indexFunc(plugin -> Optional.ofNullable(plugin.getSpec()) .map(Plugin.PluginSpec::getDescription) .orElse(null) ) ); is.add(IndexSpecs.single("spec.enabled", Boolean.class) .indexFunc(plugin -> Optional.ofNullable(plugin.getSpec()) .map(Plugin.PluginSpec::getEnabled) .orElse(false) ) .nullable(false) ); }); schemeManager.register(ExtensionPointDefinition.class, indexSpecs -> { indexSpecs.add(IndexSpecs.single( "spec.className", String.class ) .indexFunc(definition -> definition.getSpec().getClassName()) ); }); schemeManager.register(ExtensionDefinition.class, indexSpecs -> { indexSpecs.add(IndexSpecs.single( "spec.extensionPointName", String.class ) .indexFunc(definition -> definition.getSpec().getExtensionPointName()) ); }); schemeManager.register(RoleBinding.class, is -> { is.add(IndexSpecs.single("roleRef.name", String.class) .indexFunc(roleBinding -> roleBinding.getRoleRef().getName()) ); is.add(IndexSpecs.multi("subjects", String.class) .indexFunc(roleBinding -> roleBinding.getSubjects().stream() .map(RoleBinding.Subject::toString) .collect(Collectors.toSet()) ) ); }); schemeManager.register(User.class, indexSpecs -> { indexSpecs.add(IndexSpecs.single("spec.displayName", String.class) .indexFunc(user -> user.getSpec().getDisplayName()) ); indexSpecs.add(IndexSpecs.single("spec.emailVerified", Boolean.class) .indexFunc(user -> user.getSpec().isEmailVerified()) ); indexSpecs.add(IndexSpecs.single("spec.email", String.class) .indexFunc(user -> Optional.ofNullable(user.getSpec().getEmail()) .map(String::toLowerCase) .orElse(null) ) ); indexSpecs.add(IndexSpecs.multi( User.USER_RELATED_ROLES_INDEX, String.class ) .indexFunc(user -> Optional.ofNullable(user.getMetadata()) .map(MetadataOperator::getAnnotations) .map(annotations -> annotations.get(User.ROLE_NAMES_ANNO)) .filter(StringUtils::isNotBlank) .map(rolesJson -> JsonUtils.jsonToObject( rolesJson, new TypeReference>() { }) ) .orElseGet(Set::of) ) ); indexSpecs.add(IndexSpecs.single("spec.disabled", Boolean.class) .indexFunc(user -> requireNonNullElse(user.getSpec().getDisabled(), Boolean.FALSE)) .nullable(false) ); }); schemeManager.register(ReverseProxy.class); schemeManager.register(Setting.class); schemeManager.register(AnnotationSetting.class, indexSpecs -> indexSpecs.add( IndexSpecs.single("spec.targetRef", String.class) .indexFunc(annotationSetting -> Optional.ofNullable(annotationSetting.getSpec()) .map(AnnotationSetting.AnnotationSettingSpec::getTargetRef) .map(ref -> ref.group() + "/" + ref.kind()) .orElse(null) ) )); schemeManager.register(ConfigMap.class); schemeManager.register(Secret.class); schemeManager.register(Theme.class); schemeManager.register(Menu.class); schemeManager.register(MenuItem.class); schemeManager.register(Post.class, indexSpecs -> { indexSpecs.add(IndexSpecs.single("spec.title", String.class) .indexFunc(post -> Optional.ofNullable(post.getSpec()) .map(PostSpec::getTitle) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.slug", String.class) .indexFunc(post -> Optional.ofNullable(post.getSpec()) .map(PostSpec::getSlug) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.publishTime", Instant.class) .indexFunc(post -> Optional.ofNullable(post.getSpec()) .map(PostSpec::getPublishTime) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.owner", String.class) .indexFunc(post -> Optional.ofNullable(post.getSpec()) .map(PostSpec::getOwner) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.deleted", Boolean.class) .indexFunc(post -> Optional.ofNullable(post.getSpec()) .map(PostSpec::getDeleted) .orElse(false) ).nullable(false) ); indexSpecs.add(IndexSpecs.single("spec.pinned", Boolean.class) .indexFunc(post -> Optional.ofNullable(post.getSpec()) .map(PostSpec::getPinned) .orElse(false) ) .nullable(false) ); indexSpecs.add(IndexSpecs.single("spec.priority", Integer.class) .indexFunc(post -> Optional.ofNullable(post.getSpec()) .map(PostSpec::getPriority) .orElse(0) ) .nullable(false) ); indexSpecs.add(IndexSpecs.single("spec.visible", VisibleEnum.class) .indexFunc(post -> Optional.ofNullable(post.getSpec()) .map(PostSpec::getVisible) .orElse(null) ) ); indexSpecs.add(IndexSpecs.multi("spec.tags", String.class) .indexFunc( post -> Optional.ofNullable(post.getSpec()) .map(PostSpec::getTags) .map(Set::copyOf) .orElse(Set.of()) ) ); indexSpecs.add(IndexSpecs.multi("spec.categories", String.class) .indexFunc(post -> Optional.ofNullable(post.getSpec()) .map(PostSpec::getCategories) .map(Set::copyOf) .orElse(Set.of()) ) ); indexSpecs.add(IndexSpecs.multi("status.contributors", String.class) .indexFunc(post -> Optional.ofNullable(post.getStatus()) .map(PostStatus::getContributors) .map(Set::copyOf).orElse(Set.of()) ) ); indexSpecs.add(IndexSpecs.single("status.phase", String.class) .indexFunc(post -> Optional.ofNullable(post.getStatus()) .map(PostStatus::getPhase) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("status.excerpt", String.class) .indexFunc(post -> Optional.ofNullable(post.getStatus()) .map(PostStatus::getExcerpt) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("status.lastModifyTime", Instant.class) .indexFunc(post -> Optional.ofNullable(post.getStatus()) .map(PostStatus::getLastModifyTime) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("status.hideFromList", Boolean.class) .indexFunc(post -> Optional.ofNullable(post.getStatus()) .map(Post.PostStatus::getHideFromList) .orElse(false) ) ); indexSpecs.add(IndexSpecs.single( Post.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, Boolean.class ) .indexFunc(post -> { var version = post.getMetadata().getVersion(); var status = post.getStatus(); if (status == null) { return true; } var observedVersion = status.getObservedVersion(); return observedVersion == null || observedVersion < version; // do not care about the false case so return null to avoid indexing }) ); indexSpecs.add(IndexSpecs.single("stats.visit", Long.class) .indexFunc(post -> Optional.ofNullable(post.getMetadata().getAnnotations()) .map(a -> a.get(Post.STATS_ANNO)) .filter(StringUtils::isNotBlank) .map(json -> JsonUtils.jsonToObject(json, Stats.class)) .map(Stats::getVisit) .map(i -> (long) i) .orElse(0L) ) .nullable(false) ); indexSpecs.add(IndexSpecs.single("stats.totalComment", Long.class) .indexFunc(post -> Optional.ofNullable(post.getMetadata().getAnnotations()) .map(a -> a.get(Post.STATS_ANNO)) .filter(StringUtils::isNotBlank) .map(json -> JsonUtils.jsonToObject(json, Stats.class)) .map(Stats::getTotalComment) .map(i -> (long) i) .orElse(0L) ) .nullable(false) ); }); schemeManager.register(Category.class, indexSpecs -> { indexSpecs.add(IndexSpecs.single("spec.slug", String.class) .indexFunc(category -> Optional.ofNullable(category.getSpec()) .map(CategorySpec::getSlug) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.priority", Integer.class) .indexFunc(category -> Optional.ofNullable(category.getSpec()) .map(CategorySpec::getPriority) .orElse(0) ) ); indexSpecs.add(IndexSpecs.multi("spec.children", String.class) .indexFunc(category -> Optional.ofNullable(category.getSpec()) .map(CategorySpec::getChildren) .map(Set::copyOf) .orElse(Set.of()) ) ); indexSpecs.add(IndexSpecs.single("spec.hideFromList", Boolean.class) .indexFunc(category -> Optional.ofNullable(category.getSpec()) .map(CategorySpec::isHideFromList) .orElse(false) ) ); }); schemeManager.register(Tag.class, indexSpecs -> { indexSpecs.add(IndexSpecs.single("spec.displayName", String.class) .indexFunc(tag -> Optional.ofNullable(tag.getSpec()) .map(TagSpec::getDisplayName) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.slug", String.class) .indexFunc( tag -> Optional.ofNullable(tag.getSpec()) .map(TagSpec::getSlug) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("status.postCount", Integer.class) .indexFunc(tag -> Optional.ofNullable(tag.getStatus()) .map(TagStatus::getPostCount) .orElse(0) ) ); indexSpecs.add(IndexSpecs.single( Tag.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, Boolean.class ) .indexFunc(tag -> { var version = tag.getMetadata().getVersion(); var status = tag.getStatus(); if (status == null) { return true; } var observedVersion = status.getObservedVersion(); return observedVersion == null || observedVersion < version; // do not care about the false case so return null to avoid indexing }) ); }); schemeManager.register(Snapshot.class, indexSpecs -> { indexSpecs.add(IndexSpecs.single("spec.subjectRef", String.class) .indexFunc(snapshot -> Optional.ofNullable(snapshot.getSpec()) .map(Snapshot.SnapShotSpec::getSubjectRef) .map(Snapshot::toSubjectRefKey) .orElse(null) ) ); }); schemeManager.register(Comment.class, indexSpecs -> { indexSpecs.add(IndexSpecs.single("spec.creationTime", Instant.class) .indexFunc(comment -> Optional.ofNullable(comment.getSpec()) .map(CommentSpec::getCreationTime) .orElseGet(() -> comment.getMetadata().getCreationTimestamp()) ) .nullable(false) ); indexSpecs.add(IndexSpecs.single("spec.approved", Boolean.class) .indexFunc(comment -> Optional.ofNullable(comment.getSpec()) .map(CommentSpec::getApproved) .orElse(false) ) .nullable(false) ); indexSpecs.add(IndexSpecs.single("spec.owner", String.class) .indexFunc(comment -> Optional.ofNullable(comment.getSpec()) .map(CommentSpec::getOwner) .map(owner -> ownerIdentity(owner.getKind(), owner.getName())) .orElse(null) ) .nullable(true) ); indexSpecs.add(IndexSpecs.single("spec.subjectRef", String.class) .indexFunc(comment -> Optional.ofNullable(comment.getSpec()) .map(CommentSpec::getSubjectRef) .map(Comment::toSubjectRefKey) .orElse(null) ) .nullable(true) ); indexSpecs.add(IndexSpecs.single("spec.top", Boolean.class) .indexFunc(comment -> Optional.ofNullable(comment.getSpec()) .map(CommentSpec::getTop) .orElse(false) ) .nullable(false) ); indexSpecs.add(IndexSpecs.single("spec.hidden", Boolean.class) .indexFunc(comment -> Optional.ofNullable(comment.getSpec()) .map(CommentSpec::getHidden) .orElse(false) ) .nullable(false) ); indexSpecs.add(IndexSpecs.single("spec.priority", Integer.class) .indexFunc(comment -> Optional.ofNullable(comment.getSpec()) .filter(spec -> Boolean.TRUE.equals(spec.getTop())) .map(Comment.BaseCommentSpec::getPriority) .orElse(0) ) .nullable(false) ); indexSpecs.add(IndexSpecs.single("spec.raw", String.class) .indexFunc(comment -> Optional.ofNullable(comment.getSpec()) .map(CommentSpec::getRaw) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single( "status.lastReplyTime", Instant.class ) .indexFunc(comment -> Optional.ofNullable(comment.getStatus()) .map(CommentStatus::getLastReplyTime) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("status.replyCount", Integer.class) .indexFunc(comment -> Optional.ofNullable(comment.getStatus()) .map(CommentStatus::getReplyCount) .orElse(0) ) ); indexSpecs.add(IndexSpecs.single( Comment.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, Boolean.class ) .indexFunc(comment -> { var version = comment.getMetadata().getVersion(); var status = comment.getStatus(); if (status == null) { return true; } var observedVersion = status.getObservedVersion(); return observedVersion == null || observedVersion < version; // do not care about the false case so return null to avoid indexing }) ); }); schemeManager.register(Reply.class, indexSpecs -> { indexSpecs.add(IndexSpecs.single("spec.creationTime", Instant.class) .indexFunc(reply -> Optional.ofNullable(reply.getSpec()) .map(ReplySpec::getCreationTime) .orElse(reply.getMetadata().getCreationTimestamp()) ) ); indexSpecs.add(IndexSpecs.single("spec.commentName", String.class) .indexFunc(reply -> Optional.ofNullable(reply.getSpec()) .map(ReplySpec::getCommentName) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.hidden", Boolean.class) .indexFunc(reply -> Optional.ofNullable(reply.getSpec()) .map(ReplySpec::getHidden) .orElse(false) ) .nullable(false) ); indexSpecs.add(IndexSpecs.single("spec.approved", Boolean.class) .indexFunc(reply -> Optional.ofNullable(reply.getSpec()) .map(ReplySpec::getApproved) .orElse(false) ) .nullable(false) ); indexSpecs.add(IndexSpecs.single("spec.owner", String.class) .indexFunc(reply -> Optional.ofNullable(reply.getSpec()) .map(ReplySpec::getOwner) .map(owner -> ownerIdentity(owner.getKind(), owner.getName())) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single( Reply.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, Boolean.class ) .indexFunc(reply -> { var version = reply.getMetadata().getVersion(); var status = reply.getStatus(); if (status == null) { return true; } var observedVersion = status.getObservedVersion(); return observedVersion == null || observedVersion < version; // do not care about the false case so return null to avoid indexing }) ); }); schemeManager.register(SinglePage.class, is -> { is.add(IndexSpecs.single("spec.publishTime", Instant.class) .indexFunc(page -> Optional.ofNullable(page.getSpec()) .map(SinglePageSpec::getPublishTime) .orElse(null) ) ); is.add(IndexSpecs.single("spec.title", String.class) .indexFunc(page -> Optional.ofNullable(page.getSpec()) .map(SinglePageSpec::getTitle) .orElse(null) ) ); is.add(IndexSpecs.single("spec.slug", String.class) .indexFunc(page -> Optional.ofNullable(page.getSpec()) .map(SinglePageSpec::getSlug) .orElse(null) ) ); is.add(IndexSpecs.single("spec.deleted", Boolean.class) .indexFunc(page -> Optional.ofNullable(page.getSpec()) .map(SinglePageSpec::getDeleted) .orElse(false) ) .nullable(false) ); is.add(IndexSpecs.single("spec.visible", VisibleEnum.class) .indexFunc(page -> Optional.ofNullable(page.getSpec()) .map(SinglePageSpec::getVisible) .orElse(null) ) ); is.add(IndexSpecs.single("spec.pinned", Boolean.class) .indexFunc(page -> Optional.ofNullable(page.getSpec()) .map(SinglePageSpec::getPinned) .orElse(false) ) .nullable(false) ); is.add(IndexSpecs.single("spec.priority", Integer.class) .indexFunc(page -> Optional.ofNullable(page.getSpec()) .map(SinglePageSpec::getPriority) .orElse(0) ) .nullable(false) ); is.add(IndexSpecs.single("status.excerpt", String.class) .indexFunc(page -> Optional.ofNullable(page.getStatus()) .map(SinglePage.SinglePageStatus::getExcerpt) .orElse(null) ) ); is.add(IndexSpecs.single("status.phase", String.class) .indexFunc(page -> Optional.ofNullable(page.getStatus()) .map(SinglePage.SinglePageStatus::getPhase) .orElse(null) ) ); is.add(IndexSpecs.multi("status.contributors", String.class) .indexFunc(page -> Optional.ofNullable(page.getStatus()) .map(SinglePage.SinglePageStatus::getContributors) .map(Set::copyOf) .orElse(Set.of()) ) ); }); // storage.halo.run schemeManager.register(Group.class); schemeManager.register(Policy.class); schemeManager.register(Attachment.class, indexSpecs -> { indexSpecs.add(IndexSpecs.single("spec.displayName", String.class) .indexFunc(attachment -> Optional.ofNullable(attachment.getSpec()) .map(AttachmentSpec::getDisplayName) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.policyName", String.class) .indexFunc(attachment -> Optional.ofNullable(attachment.getSpec()) .map(AttachmentSpec::getPolicyName) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.groupName", String.class) .indexFunc(attachment -> Optional.ofNullable(attachment.getSpec()) .map(AttachmentSpec::getGroupName) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.mediaType", String.class) .indexFunc(attachment -> Optional.ofNullable(attachment.getSpec()) .map(AttachmentSpec::getMediaType) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.ownerName", String.class) .indexFunc(attachment -> Optional.ofNullable(attachment.getSpec()) .map(AttachmentSpec::getOwnerName) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.size", Long.class) .indexFunc(attachment -> Optional.ofNullable(attachment.getSpec()) .map(AttachmentSpec::getSize) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("status.permalink", String.class) .indexFunc(attachment -> Optional.ofNullable(attachment.getStatus()) .map(AttachmentStatus::getPermalink) .orElse(null) ) ); }); schemeManager.register(PolicyTemplate.class); schemeManager.register(Thumbnail.class, indexSpec -> { indexSpec.add(new IndexSpec() // see run.halo.app.core.attachment.ThumbnailMigration // .setUnique(true) .setName(Thumbnail.ID_INDEX) .setIndexFunc(simpleAttribute(Thumbnail.class, Thumbnail::idIndexFunc))); }); schemeManager.register(LocalThumbnail.class, indexSpec -> { // make sure image and size are unique indexSpec.add(new IndexSpec() // see run.halo.app.core.attachment.ThumbnailMigration // .setUnique(true) .setName(LocalThumbnail.UNIQUE_IMAGE_AND_SIZE_INDEX) .setIndexFunc( simpleAttribute(LocalThumbnail.class, LocalThumbnail::uniqueImageAndSize) ) ); indexSpec.add(new IndexSpec().setName("spec.imageSignature") .setIndexFunc(simpleAttribute(LocalThumbnail.class, thumbnail -> thumbnail.getSpec().getImageSignature()) ) ); indexSpec.add(new IndexSpec().setName("spec.thumbSignature").setUnique(true) .setIndexFunc(simpleAttribute(LocalThumbnail.class, thumbnail -> thumbnail.getSpec().getThumbSignature()) ) ); indexSpec.add(new IndexSpec().setName("status.phase").setIndexFunc( simpleAttribute(LocalThumbnail.class, thumbnail -> Optional.of(thumbnail.getStatus()) .map(LocalThumbnail.Status::getPhase) .map(LocalThumbnail.Phase::name) .orElse(null) ) )); }); // metrics.halo.run schemeManager.register(Counter.class); // auth.halo.run schemeManager.register(AuthProvider.class); schemeManager.register(UserConnection.class, is -> { is.add(IndexSpecs.single("spec.username", String.class) .indexFunc(connection -> Optional.ofNullable(connection.getSpec()) .map(UserConnectionSpec::getUsername) .orElse(null) ) ); is.add(IndexSpecs.single("spec.registrationId", String.class) .indexFunc(connection -> Optional.ofNullable(connection.getSpec()) .map(UserConnectionSpec::getRegistrationId) .orElse(null) ) ); is.add(IndexSpecs.single("spec.providerUserId", String.class) .indexFunc(connection -> Optional.ofNullable(connection.getSpec()) .map(UserConnectionSpec::getProviderUserId) .orElse(null) ) ); }); // security.halo.run schemeManager.register(PersonalAccessToken.class); schemeManager.register(RememberMeToken.class, indexSpecs -> { indexSpecs.add(IndexSpecs.single("spec.series", String.class) .indexFunc(token -> Optional.ofNullable(token.getSpec()) .map(RememberMeToken.Spec::getSeries) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.username", String.class) .indexFunc(token -> Optional.ofNullable(token.getSpec()) .map(RememberMeToken.Spec::getUsername) .orElse(null) ) ); }); schemeManager.register(Device.class, indexSpecs -> { indexSpecs.add(IndexSpecs.single("spec.principalName", String.class) .indexFunc(device -> Optional.ofNullable(device.getSpec()) .map(Device.Spec::getPrincipalName) .orElse(null) ) ); }); // migration.halo.run schemeManager.register(Backup.class); // notification.halo.run schemeManager.register(ReasonType.class); schemeManager.register(Reason.class); schemeManager.register(NotificationTemplate.class, indexSpecs -> { indexSpecs.add( IndexSpecs.single("spec.reasonSelector.reasonType", String.class).indexFunc(template -> Optional.ofNullable(template.getSpec()) .map(NotificationTemplate.Spec::getReasonSelector) .map(NotificationTemplate.ReasonSelector::getReasonType) .orElse(null) ) ); }); schemeManager.register(Subscription.class, indexSpecs -> { indexSpecs.add( IndexSpecs.single("spec.reason.reasonType", String.class) .indexFunc( sub -> Optional.ofNullable(sub.getSpec()).map(Subscription.Spec::getReason) .map(InterestReason::getReasonType) .orElse(null) ) ); indexSpecs.add( IndexSpecs.single("spec.reason.subject", String.class) .indexFunc(sub -> Optional.ofNullable(sub.getSpec()) .map(Subscription.Spec::getReason) .map(InterestReason::getSubject) .map(Object::toString) .orElse(null) ) ); indexSpecs.add( IndexSpecs.single("spec.reason.expression", String.class) .indexFunc(sub -> Optional.ofNullable(sub.getSpec()) .map(Subscription.Spec::getReason) .map(InterestReason::getExpression) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.subscriber", String.class) .indexFunc(sub -> Optional.ofNullable(sub.getSpec()) .map(Subscription.Spec::getSubscriber) .map(Object::toString) .orElse(null) ) ); }); schemeManager.register(NotifierDescriptor.class); schemeManager.register(Notification.class, indexSpecs -> { indexSpecs.add(IndexSpecs.single("spec.unread", Boolean.class) .indexFunc(notification -> Optional.ofNullable(notification.getSpec()) .map(NotificationSpec::isUnread) .orElse(false) ) ); indexSpecs.add(IndexSpecs.single("spec.reason", String.class) .indexFunc(notification -> Optional.ofNullable(notification.getSpec()) .map(NotificationSpec::getReason) .orElse(null) ) ); indexSpecs.add(IndexSpecs.single("spec.recipient", String.class) .indexFunc(notification -> Optional.ofNullable(notification.getSpec()) .map(NotificationSpec::getRecipient) .orElse(null) ) ); }); } @Override public void stop() { if (!running) { return; } running = false; schemeManager.schemes().forEach(schemeManager::unregister); } @Override public boolean isRunning() { return running; } @Override public int getPhase() { return InitializationPhase.SCHEME.getPhase(); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/SecureRequestMappingHandlerAdapter.java ================================================ package run.halo.app.infra; import org.springframework.lang.NonNull; import org.springframework.web.reactive.HandlerResult; import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerAdapter; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; /** * Secure request mapping handler adapter. * * @author johnniang * @since 2.20.0 */ public class SecureRequestMappingHandlerAdapter extends RequestMappingHandlerAdapter { @Override @NonNull public Mono handle( @NonNull ServerWebExchange exchange, @NonNull Object handler ) { return super.handle(new SecureServerWebExchange(exchange), handler); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/SecureServerRequest.java ================================================ package run.halo.app.infra; import org.springframework.lang.NonNull; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.support.ServerRequestWrapper; import org.springframework.web.server.ServerWebExchange; /** * Secure server request without application context available. * * @author johnniang * @since 2.20.0 */ public class SecureServerRequest extends ServerRequestWrapper { /** * Create a new {@code ServerRequestWrapper} that wraps the given request. * * @param delegate the request to wrap */ public SecureServerRequest(ServerRequest delegate) { super(delegate); } @Override @NonNull public ServerWebExchange exchange() { return new SecureServerWebExchange(super.exchange()); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/SecureServerWebExchange.java ================================================ package run.halo.app.infra; import org.springframework.context.ApplicationContext; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebExchangeDecorator; /** * Secure server web exchange without application context available. * * @author johnniang * @since 2.20.0 */ public class SecureServerWebExchange extends ServerWebExchangeDecorator { public SecureServerWebExchange(ServerWebExchange delegate) { super(delegate); } @Override public ApplicationContext getApplicationContext() { // Always return null to prevent access to application context return null; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/SystemConfigChangedEvent.java ================================================ package run.halo.app.infra; import java.util.Map; import lombok.Getter; import org.springframework.context.ApplicationEvent; /** * Event that is published when the system configuration changes. * * @author johnniang * @since 2.21.0 */ public class SystemConfigChangedEvent extends ApplicationEvent { /** * Old configuration data. Unmodifiable. */ @Getter private final Map oldData; /** * New configuration data. Unmodifiable. */ @Getter private final Map newData; public SystemConfigChangedEvent( Object source, Map oldData, Map newData ) { super(source); this.oldData = Map.copyOf(oldData); this.newData = Map.copyOf(newData); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/SystemConfigFetcher.java ================================================ package run.halo.app.infra; import java.util.Map; import java.util.Optional; import reactor.core.publisher.Mono; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ReactiveExtensionClient; /** * A fetcher that fetches the system configuration from the extension client. * If there are {@link ConfigMap}s named system-default and system at * the same time, the {@link ConfigMap} named system will be json merge patch to * {@link ConfigMap} named system-default * * @author guqing * @since 2.0.0 */ public interface SystemConfigFetcher { Mono fetch(String key, Class type); Mono getBasic(); Mono fetchComment(); Mono fetchPost(); Mono fetchRouteRules(); /** * Gets the system config values as a map(merged). Do not update this map directly. * * @return system config values map, cached, unmodifiable */ Mono> getConfig(); /** * Load the system config map from the extension client. * * @return latest configMap from {@link ReactiveExtensionClient} without any cache. */ Mono getConfigMap(); /** * Load the system config map from the extension client in a blocking way. * * @return latest configMap from {@link ReactiveExtensionClient} without any cache. */ Optional getConfigMapBlocking(); } ================================================ FILE: application/src/main/java/run/halo/app/infra/SystemConfigFirstExternalUrlSupplier.java ================================================ package run.halo.app.infra; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.time.Duration; import java.util.Optional; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.Nullable; import org.springframework.boot.webflux.autoconfigure.WebFluxProperties; import org.springframework.context.event.EventListener; import org.springframework.http.HttpRequest; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import reactor.core.Exceptions; import run.halo.app.infra.properties.HaloProperties; /** * Default implementation for getting external url from system config first, halo properties second. * * @author johnniang */ @Slf4j @Component class SystemConfigFirstExternalUrlSupplier implements ExternalUrlSupplier { private final HaloProperties haloProperties; private final WebFluxProperties webFluxProperties; private final SystemConfigFetcher systemConfigFetcher; @Nullable private URL externalUrl; public SystemConfigFirstExternalUrlSupplier(HaloProperties haloProperties, WebFluxProperties webFluxProperties, SystemConfigFetcher systemConfigFetcher) { this.haloProperties = haloProperties; this.webFluxProperties = webFluxProperties; this.systemConfigFetcher = systemConfigFetcher; } @EventListener void onExtensionInitialized(ExtensionInitializedEvent ignored) { refetchExternalUrl().ifPresent(externalUrl -> this.externalUrl = externalUrl); } @EventListener void onExternalUrlChanged(ExternalUrlChangedEvent event) { this.externalUrl = event.getExternalUrl(); } Optional refetchExternalUrl() { return systemConfigFetcher.getBasic() .mapNotNull(SystemSetting.Basic::getExternalUrl) .filter(StringUtils::hasText) .mapNotNull(externalUrlString -> { try { return URI.create(externalUrlString).toURL(); } catch (MalformedURLException e) { log.error(""" Cannot parse external URL {} from system config. Fallback to default \ external URL supplier from properties.\ """, externalUrlString, e); // For continuing the application startup, we need to return null here. return null; } }) .blockOptional(Duration.ofSeconds(10)); } @Override public URI get() { try { if (!haloProperties.isUseAbsolutePermalink()) { return URI.create(getBasePath()); } if (externalUrl != null) { return externalUrl.toURI(); } return haloProperties.getExternalUrl().toURI(); } catch (URISyntaxException e) { throw Exceptions.propagate(e); } } @Override public URL getURL(HttpRequest request) { if (this.externalUrl != null) { return this.externalUrl; } var externalUrl = haloProperties.getExternalUrl(); if (externalUrl != null) { return externalUrl; } try { externalUrl = request.getURI().resolve(getBasePath()).toURL(); } catch (MalformedURLException e) { throw new RuntimeException("Cannot parse request URI to URL.", e); } return externalUrl; } @Nullable @Override public URL getRaw() { return externalUrl != null ? externalUrl : haloProperties.getExternalUrl(); } private String getBasePath() { var basePath = webFluxProperties.getBasePath(); if (!StringUtils.hasText(basePath)) { basePath = "/"; } return basePath; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/SystemConfigInitializer.java ================================================ package run.halo.app.infra; import java.util.HashMap; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; @Slf4j @Component @RequiredArgsConstructor class SystemConfigInitializer { private final ReactiveExtensionClient client; @EventListener @Order(Ordered.HIGHEST_PRECEDENCE) Mono onApplicationEvent(ExtensionInitializedEvent ignored) { return client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) .switchIfEmpty(Mono.defer(() -> { log.info("Initializing system config..."); var configMap = new ConfigMap(); configMap.setMetadata(new Metadata()); configMap.getMetadata().setName(SystemSetting.SYSTEM_CONFIG); configMap.setData(new HashMap<>()); return client.create(configMap) .doOnSuccess(created -> { log.info("System config initialized: {}", created.getMetadata().getName()); }); })) .then(); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/SystemInfoGetterImpl.java ================================================ package run.halo.app.infra; import java.net.MalformedURLException; import java.net.URI; import java.net.URL; import java.util.Locale; import java.util.TimeZone; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.boot.web.server.autoconfigure.ServerProperties; import org.springframework.boot.webflux.autoconfigure.WebFluxProperties; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; @Component @RequiredArgsConstructor public class SystemInfoGetterImpl implements SystemInfoGetter { private final SystemConfigFetcher environmentFetcher; private final SystemVersionSupplier systemVersionSupplier; private final ExternalUrlSupplier externalUrlSupplier; private final ServerProperties serverProperties; private final WebFluxProperties webFluxProperties; @Override public Mono get() { var systemInfo = new SystemInfo() .setVersion(systemVersionSupplier.get()) .setUrl(getExternalUrl()) // TODO populate locale and timezone from system settings in the future .setLocale(Locale.getDefault()) .setTimeZone(TimeZone.getDefault()); var basicMono = environmentFetcher.fetch(SystemSetting.Basic.GROUP, SystemSetting.Basic.class) .doOnNext(basic -> systemInfo.setTitle(basic.getTitle()) .setSubtitle(basic.getSubtitle()) .setLogo(basic.getLogo()) .setFavicon(basic.getFavicon()) ); var seoMono = environmentFetcher.fetch(SystemSetting.Seo.GROUP, SystemSetting.Seo.class) .doOnNext(seo -> systemInfo.setSeo(new SystemInfo.SeoProp() .setBlockSpiders(BooleanUtils.isTrue(seo.blockSpiders)) .setKeywords(seo.getKeywords()) .setDescription(seo.getDescription()) )); var themeMono = environmentFetcher.fetch(SystemSetting.Theme.GROUP, SystemSetting.Theme.class) .doOnNext(theme -> systemInfo.setActivatedThemeName(theme.getActive())); return Mono.when(basicMono, seoMono, themeMono) .thenReturn(systemInfo); } private URL getExternalUrl() { var url = externalUrlSupplier.getRaw(); if (url != null) { return url; } var port = serverProperties.getPort(); var basePath = StringUtils.defaultIfBlank(webFluxProperties.getBasePath(), "/"); try { var uriStr = "http://localhost:" + port + basePath; return URI.create(StringUtils.removeEnd(uriStr, "/")).toURL(); } catch (MalformedURLException e) { // Should not happen throw new RuntimeException("Cannot create URL from server properties.", e); } } } ================================================ FILE: application/src/main/java/run/halo/app/infra/SystemState.java ================================================ package run.halo.app.infra; import com.fasterxml.jackson.databind.JsonNode; import com.github.fge.jsonpatch.JsonPatchException; import com.github.fge.jsonpatch.mergepatch.JsonMergePatch; import java.time.Duration; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; import java.util.function.Consumer; import lombok.Data; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.lang.NonNull; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.JsonParseException; import run.halo.app.infra.utils.JsonUtils; /** * A model for system state deserialize from {@link run.halo.app.extension.ConfigMap} * named {@code system-states}. * * @author guqing * @since 2.8.0 */ @Data public class SystemState { public static final String SYSTEM_STATES_CONFIGMAP = "system-states"; static final String GROUP = "states"; private Boolean isSetup; /** * Deserialize from {@link ConfigMap}. * * @return config map */ public static SystemState deserialize(@NonNull ConfigMap configMap) { Map data = configMap.getData(); if (data == null) { return new SystemState(); } return JsonUtils.jsonToObject(data.getOrDefault(GROUP, emptyJsonObject()), SystemState.class); } /** * Update modified system state to config map. * * @param systemState modified system state * @param configMap config map */ public static void update(@NonNull SystemState systemState, @NonNull ConfigMap configMap) { Map data = configMap.getData(); if (data == null) { data = new LinkedHashMap<>(); configMap.setData(data); } JsonNode modifiedJson = JsonUtils.mapper() .convertValue(systemState, JsonNode.class); // original JsonNode sourceJson = JsonUtils.jsonToObject(data.getOrDefault(GROUP, emptyJsonObject()), JsonNode.class); try { // patch JsonMergePatch jsonMergePatch = JsonMergePatch.fromJson(modifiedJson); // apply patch to original JsonNode patchedNode = jsonMergePatch.apply(sourceJson); data.put(GROUP, JsonUtils.objectToJson(patchedNode)); } catch (JsonPatchException e) { throw new JsonParseException(e); } } /** *

Update system state by the given {@link Consumer}.

*

if the system state config map does not exist, it will create a new one.

*/ public static Mono upsetSystemState(ReactiveExtensionClient client, Consumer consumer) { return Mono.defer(() -> client.fetch(ConfigMap.class, SYSTEM_STATES_CONFIGMAP) .switchIfEmpty(Mono.defer(() -> { ConfigMap configMap = new ConfigMap(); configMap.setMetadata(new Metadata()); configMap.getMetadata().setName(SYSTEM_STATES_CONFIGMAP); configMap.setData(new HashMap<>()); return client.create(configMap); })) .flatMap(configMap -> { SystemState systemState = deserialize(configMap); consumer.accept(systemState); update(systemState, configMap); return client.update(configMap); }) ) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance) ) .then(); } private static String emptyJsonObject() { return "{}"; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/ThemeRootGetter.java ================================================ package run.halo.app.infra; import java.nio.file.Path; import java.util.function.Supplier; /** * ThemeRootGetter allows us to get root path of themes. * * @author johnniang */ public interface ThemeRootGetter extends Supplier { } ================================================ FILE: application/src/main/java/run/halo/app/infra/actuator/DatabaseInfoContributor.java ================================================ package run.halo.app.infra.actuator; import io.r2dbc.spi.Connection; import io.r2dbc.spi.ConnectionFactory; import io.r2dbc.spi.ConnectionMetadata; import java.time.Duration; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.Nullable; import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.actuate.info.Info; import org.springframework.boot.actuate.info.InfoContributor; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.infra.utils.ReactiveUtils; @Slf4j @Component class DatabaseInfoContributor implements InfoContributor, InitializingBean { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private static final String DATABASE_INFO_KEY = "database"; private final ConnectionFactory connectionFactory; @Nullable private ConnectionMetadata connectionMetadata; public DatabaseInfoContributor(ConnectionFactory connectionFactory) { this.connectionFactory = connectionFactory; } @Override public void afterPropertiesSet() throws Exception { var connectionMetadata = Mono.usingWhen( this.connectionFactory.create(), connection -> Mono.just(connection.getMetadata()), Connection::close ) .blockOptional(BLOCKING_TIMEOUT) .orElseThrow(() -> new IllegalStateException("Unable to get database metadata")); if (log.isDebugEnabled()) { log.debug("Database Metadata initialized: name={}, version={}", connectionMetadata.getDatabaseProductName(), connectionMetadata.getDatabaseVersion()); } this.connectionMetadata = connectionMetadata; } @Override public void contribute(Info.Builder builder) { if (this.connectionMetadata != null) { builder.withDetail(DATABASE_INFO_KEY, Map.of( "name", this.connectionMetadata.getDatabaseProductName(), "version", this.connectionMetadata.getDatabaseVersion() )); } } } ================================================ FILE: application/src/main/java/run/halo/app/infra/actuator/GlobalInfo.java ================================================ package run.halo.app.infra.actuator; import java.net.URL; import java.util.Locale; import java.util.TimeZone; import lombok.Data; /** * Global info. * * @author johnniang * @since 2.20.0 */ @Data public class GlobalInfo { private URL externalUrl; private TimeZone timeZone; private Locale locale; private boolean allowAnonymousComments; private boolean allowRegistration; private String favicon; private String postSlugGenerationStrategy; private Boolean mustVerifyEmailOnRegistration; private String siteTitle; } ================================================ FILE: application/src/main/java/run/halo/app/infra/actuator/GlobalInfoEndpoint.java ================================================ package run.halo.app.infra.actuator; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpoint; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; /** * Global info endpoint. */ @WebEndpoint(id = "globalinfo") @Component class GlobalInfoEndpoint { private final GlobalInfoService globalInfoService; public GlobalInfoEndpoint(GlobalInfoService globalInfoService) { this.globalInfoService = globalInfoService; } @ReadOperation public Mono globalInfo() { return globalInfoService.getGlobalInfo(); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/actuator/GlobalInfoService.java ================================================ package run.halo.app.infra.actuator; import reactor.core.publisher.Mono; /** * Global info service. * * @author johnniang * @since 2.20.0 */ public interface GlobalInfoService { /** * Get global info. * * @return global info */ Mono getGlobalInfo(); } ================================================ FILE: application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java ================================================ package run.halo.app.infra.actuator; import java.util.ArrayList; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.TimeZone; import lombok.RequiredArgsConstructor; import org.reactivestreams.Publisher; import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; /** * Global info service implementation. * * @author johnniang * @since 2.20.0 */ @Service @RequiredArgsConstructor public class GlobalInfoServiceImpl implements GlobalInfoService { private final ObjectProvider systemConfigFetcher; private final ExternalUrlSupplier externalUrl; @Override public Mono getGlobalInfo() { final var info = new GlobalInfo(); info.setExternalUrl(externalUrl.getRaw()); info.setLocale(Locale.getDefault()); info.setTimeZone(TimeZone.getDefault()); var publishers = new ArrayList>(1); publishers.add(handleSettings(info)); return Mono.when(publishers).then(Mono.just(info)); } private Mono handleSettings(GlobalInfo info) { return Optional.ofNullable(systemConfigFetcher.getIfUnique()) .map(fetcher -> fetcher.getConfig() .doOnNext(config -> { handleCommentSetting(info, config); handleUserSetting(info, config); handleBasicSetting(info, config); handlePostSlugGenerationStrategy(info, config); }) .then() ) .orElseGet(Mono::empty); } private void handleCommentSetting(GlobalInfo info, Map config) { var comment = SystemSetting.get(config, SystemSetting.Comment.GROUP, SystemSetting.Comment.class); if (comment == null) { info.setAllowAnonymousComments(true); } else { info.setAllowAnonymousComments( comment.getSystemUserOnly() == null || !comment.getSystemUserOnly()); } } private void handleUserSetting(GlobalInfo info, Map config) { var userSetting = SystemSetting.get(config, SystemSetting.User.GROUP, SystemSetting.User.class); if (userSetting == null) { info.setAllowRegistration(false); info.setMustVerifyEmailOnRegistration(false); } else { info.setAllowRegistration(userSetting.isAllowRegistration()); info.setMustVerifyEmailOnRegistration(userSetting.isMustVerifyEmailOnRegistration()); } } private void handlePostSlugGenerationStrategy(GlobalInfo info, Map config) { var post = SystemSetting.get(config, SystemSetting.Post.GROUP, SystemSetting.Post.class); if (post != null) { info.setPostSlugGenerationStrategy(post.getSlugGenerationStrategy()); } } private void handleBasicSetting(GlobalInfo info, Map config) { var basic = SystemSetting.get(config, SystemSetting.Basic.GROUP, SystemSetting.Basic.class); if (basic != null) { info.setFavicon(basic.getFavicon()); info.setSiteTitle(basic.getTitle()); basic.useSystemLocale().ifPresent(info::setLocale); } } } ================================================ FILE: application/src/main/java/run/halo/app/infra/actuator/RestartEndpoint.java ================================================ package run.halo.app.infra.actuator; import java.io.Closeable; import java.io.IOException; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.actuate.endpoint.annotation.WriteOperation; import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpoint; import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationListener; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.Application; @WebEndpoint(id = "restart") @Component @Slf4j class RestartEndpoint implements ApplicationListener { private SpringApplication application; private String[] args; private ConfigurableApplicationContext context; @WriteOperation public Mono> restart() { return Mono.fromSupplier(() -> { var threadGroup = new ThreadGroup("RestartGroup"); var thread = new Thread(threadGroup, this::doRestart, "restartMain"); thread.setDaemon(false); thread.setContextClassLoader(Application.class.getClassLoader()); thread.start(); return Map.of("message", "Restarting"); }); } private synchronized void doRestart() { log.info("Restarting..."); if (this.context != null) { try { closeRecursively(this.context); var shutdownHandlers = SpringApplication.getShutdownHandlers(); if (shutdownHandlers instanceof Runnable runnable) { // clear closedContext in org.springframework.boot.SpringApplicationShutdownHook runnable.run(); } this.context = this.application.run(args); log.info("Restarted"); } catch (Throwable t) { log.error("Failed to restart.", t); } } } private static void closeRecursively(ApplicationContext ctx) { while (ctx != null) { if (ctx instanceof Closeable closeable) { try { closeable.close(); } catch (IOException e) { log.error("Cannot close context: {}", ctx.getId(), e); } } ctx = ctx.getParent(); } } @Override public void onApplicationEvent(ApplicationStartedEvent event) { if (this.context == null) { this.application = event.getSpringApplication(); this.args = event.getArgs(); this.context = event.getApplicationContext(); } } } ================================================ FILE: application/src/main/java/run/halo/app/infra/config/ExtensionConfiguration.java ================================================ package run.halo.app.infra.config; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.controller.DefaultControllerManager; @Configuration(proxyBeanMethods = false) public class ExtensionConfiguration { @Bean @ConditionalOnProperty( name = "halo.extension.controller.disabled", havingValue = "false", matchIfMissing = true ) DefaultControllerManager controllerManager(ExtensionClient client) { return new DefaultControllerManager(client); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/config/HaloConfiguration.java ================================================ package run.halo.app.infra.config; import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.search.lucene.LuceneSearchEngine; import tools.jackson.databind.MapperFeature; @EnableCaching @Configuration(proxyBeanMethods = false) @EnableAsync public class HaloConfiguration { @Bean JsonMapperBuilderCustomizer objectMapperCustomizer( ObjectProvider objectMapperProvider ) { return builder -> { builder.changeDefaultPropertyInclusion(v -> v.withValueInclusion(NON_NULL).withContentInclusion(NON_NULL) ); builder.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS); builder.addModule( new JacksonAdapterModule(objectMapperProvider::getIfAvailable) ); }; } @Bean @ConditionalOnProperty(prefix = "halo.search-engine.lucene", name = "enabled", havingValue = "true", matchIfMissing = true) LuceneSearchEngine luceneSearchEngine(HaloProperties haloProperties) throws IOException { return new LuceneSearchEngine(haloProperties.getWorkDir() .resolve("indices") .resolve("halo")); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/config/JacksonAdapterModule.java ================================================ package run.halo.app.infra.config; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.github.fge.jsonpatch.JsonPatch; import java.util.function.Supplier; import org.springframework.util.Assert; import run.halo.app.extension.JsonExtension; import tools.jackson.core.JacksonException; import tools.jackson.core.JsonGenerator; import tools.jackson.core.JsonParser; import tools.jackson.core.Version; import tools.jackson.databind.DeserializationContext; import tools.jackson.databind.SerializationContext; import tools.jackson.databind.ValueDeserializer; import tools.jackson.databind.ValueSerializer; import tools.jackson.databind.exc.InvalidFormatException; import tools.jackson.databind.exc.JsonNodeException; import tools.jackson.databind.module.SimpleModule; /** * Jackson module to adapt legacy {@link JsonNode} serialization and deserialization. * This module makes sure the plugin system using legacy Jackson can correctly * * @author johnniang * @since 2.23.0 */ public class JacksonAdapterModule extends SimpleModule { private final Supplier objectMapper; public JacksonAdapterModule(Supplier objectMapper) { super(JacksonAdapterModule.class.getSimpleName(), new Version(1, 0, 0, null, null, null)); this.objectMapper = objectMapper; addSerializer(new JsonNodeSerializer()); addSerializer(new JsonPatchSerializer()); addSerializer(new JsonExtensionSerializer()); addDeserializer(JsonNode.class, new JsonNodeDeserializer<>(JsonNode.class)); addDeserializer(ObjectNode.class, new JsonNodeDeserializer<>(ObjectNode.class)); addDeserializer(JsonExtension.class, new JsonExtensionDeSerializer()); } class JsonExtensionDeSerializer extends ValueDeserializer { @Override public JsonExtension deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException { var json = p.readValueAsTree().toString(); try { return objectMapper.get().readValue(json, JsonExtension.class); } catch (JsonProcessingException e) { throw InvalidFormatException.from(p, "Failed to deserialize JsonExtension"); } } } class JsonExtensionSerializer extends ValueSerializer { @Override public void serialize(JsonExtension value, JsonGenerator gen, SerializationContext ctxt) throws JacksonException { try { var raw = objectMapper.get().writeValueAsString(value); gen.writeRawValue(raw); } catch (JsonProcessingException e) { throw InvalidFormatException.from(gen, "Failed to serialize JsonExtension"); } } @Override public Class handledType() { return JsonExtension.class; } } class JsonPatchSerializer extends ValueSerializer { @Override public void serialize(JsonPatch value, JsonGenerator gen, SerializationContext ctxt) throws JacksonException { try { gen.writeRawValue(objectMapper.get().writeValueAsString(value)); } catch (JsonProcessingException e) { throw InvalidFormatException.from(gen, "Failed to serialize JsonPatch"); } } @Override public Class handledType() { return JsonPatch.class; } } class JsonNodeSerializer extends ValueSerializer { @Override public void serialize(JsonNode value, JsonGenerator gen, SerializationContext ctxt) throws JacksonException { try { gen.writeRawValue(objectMapper.get().writeValueAsString(value)); } catch (JsonProcessingException e) { throw InvalidFormatException.from(gen, "Failed to serialize JsonNode"); } } @Override public Class handledType() { return JsonNode.class; } } class JsonNodeDeserializer extends ValueDeserializer { private final Class type; JsonNodeDeserializer(Class type) { this.type = type; } @Override public T deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException { var json = p.readValueAsTree().toString(); var mapper = objectMapper.get(); Assert.notNull(mapper, "Legacy ObjectMapper must not be null"); try { return mapper.readValue(json, type); } catch (JsonProcessingException e) { throw JsonNodeException.from( p, "Failed to bridge legacy JSON node", e ); } } } } ================================================ FILE: application/src/main/java/run/halo/app/infra/config/R2dbcConfiguration.java ================================================ package run.halo.app.infra.config; import org.springframework.beans.BeansException; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.r2dbc.mapping.R2dbcMappingContext; @Configuration(proxyBeanMethods = false) class R2dbcConfiguration { /** * Modify R2DBC Mapping Context to disable force quote. * *

* In Spring Boot 4, the default * behavior is changed to enable force quote, which may cause issues with existing database * schemas that do not use quoted identifiers. * *

* See * this issue * for more details. * *

* Use static method to ensure that the BeanPostProcessor is registered before any * R2dbcMappingContext beans are initialized. * * @return the bean post processor */ @Bean static BeanPostProcessor r2dbcMappingContextQuoteModifier() { return new BeanPostProcessor() { @Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof R2dbcMappingContext mappingContext) { mappingContext.setForceQuote(false); } return bean; } }; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/config/SessionConfiguration.java ================================================ package run.halo.app.infra.config; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.session.autoconfigure.SessionProperties; import org.springframework.boot.web.server.autoconfigure.ServerProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.session.ReactiveFindByIndexNameSessionRepository; import org.springframework.session.ReactiveSessionRepository; import org.springframework.session.Session; import org.springframework.session.config.annotation.web.server.EnableSpringWebSession; import org.springframework.session.security.SpringSessionBackedReactiveSessionRegistry; import run.halo.app.security.session.InMemoryReactiveIndexedSessionRepository; import run.halo.app.security.session.ReactiveIndexedSessionRepository; /** * Configuration for Spring Web Session. * * @param the type of Session * @author johnniang * @since 2.22.11 */ @Configuration @EnableSpringWebSession class SessionConfiguration { @Bean SpringSessionBackedReactiveSessionRegistry reactiveSessionRegistry( ReactiveSessionRepository sessionRepository, ReactiveFindByIndexNameSessionRepository indexedSessionRepository ) { return new SpringSessionBackedReactiveSessionRegistry<>( sessionRepository, indexedSessionRepository ); } @Configuration @ConditionalOnProperty( value = "halo.session.store-type", havingValue = "in-memory", matchIfMissing = true ) static class InMemorySessionConfig { @Bean ReactiveIndexedSessionRepository inMemorySessionRepository( SessionProperties sessionProperties, ServerProperties serverProperties ) { var repository = new InMemoryReactiveIndexedSessionRepository(new ConcurrentHashMap<>()); var timeout = Optional.ofNullable(sessionProperties.getTimeout()) .orElseGet(() -> serverProperties.getReactive().getSession().getTimeout()); repository.setDefaultMaxInactiveInterval(timeout); return repository; } } } ================================================ FILE: application/src/main/java/run/halo/app/infra/config/SwaggerConfig.java ================================================ package run.halo.app.infra.config; import static org.springdoc.core.utils.Constants.SPRINGDOC_ENABLED; import io.swagger.v3.core.converter.ModelConverter; import io.swagger.v3.core.jackson.ModelResolver; import io.swagger.v3.core.jackson.TypeNameResolver; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import java.util.Set; import org.springdoc.core.customizers.GlobalOpenApiCustomizer; import org.springdoc.core.models.GroupedOpenApi; import org.springdoc.core.properties.SpringDocConfigProperties; import org.springdoc.core.providers.ObjectMapperProvider; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.info.BuildProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import run.halo.app.extension.router.JsonPatch; @Configuration @ConditionalOnProperty(name = SPRINGDOC_ENABLED, matchIfMissing = true) @ConditionalOnWebApplication public class SwaggerConfig { @Bean OpenAPI haloOpenApi(ObjectProvider buildPropertiesProvider, SpringDocConfigProperties docConfigProperties) { var buildProperties = buildPropertiesProvider.getIfAvailable(); var version = "unknown"; if (buildProperties != null) { version = buildProperties.getVersion(); } return new OpenAPI() .specVersion(docConfigProperties.getSpecVersion()) // See https://swagger.io/docs/specification/authentication/ for more. .components(new Components() .addSecuritySchemes("basicAuth", new SecurityScheme() .type(SecurityScheme.Type.HTTP) .scheme("basic")) .addSecuritySchemes("bearerAuth", new SecurityScheme() .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT")) ) .addSecurityItem(new SecurityRequirement() .addList("basicAuth") .addList("bearerAuth")) .info(new Info() .title("Halo") .version(version) ); } @Bean GlobalOpenApiCustomizer openApiCustomizer() { return openApi -> JsonPatch.addSchema(openApi.getComponents()); } @Bean GroupedOpenApi aggregatedV1alpha1Api() { return GroupedOpenApi.builder() .group("apis_aggregated.api_v1alpha1") .displayName("Aggregated API V1alpha1") .pathsToMatch( "/apis/*/v1alpha1/**", "/api/v1alpha1/**", "/login/**", "/system/setup" ) .build(); } @Bean GroupedOpenApi publicV1alpha1Api() { return GroupedOpenApi.builder() .group("apis_public.api_v1alpha1") .displayName("Public API V1alpha1") .pathsToMatch( "/apis/api.*/**" ) .pathsToExclude( "/apis/api.console.*/v1alpha1/**", // compatible with legacy issues "/apis/api.notification.halo.run/v1alpha1/userspaces/**", "/apis/api.notification.halo.run/v1alpha1/notifiers/**" ) .build(); } @Bean GroupedOpenApi consoleV1alpha1Api() { return GroupedOpenApi.builder() .group("apis_console.api_v1alpha1") .displayName("Console API V1alpha1") .pathsToMatch( "/apis/console.api.*/v1alpha1/**", "/apis/api.console.halo.run/v1alpha1/**" ) .build(); } @Bean GroupedOpenApi ucV1alpha1Api() { return GroupedOpenApi.builder() .group("apis_uc.api_v1alpha1") .displayName("User-center API V1alpha1") .pathsToMatch( "/apis/uc.api.*/v1alpha1/**", // compatible with legacy issues "/apis/api.notification.halo.run/v1alpha1/userspaces/**", "/apis/api.notification.halo.run/v1alpha1/notifiers/**" ) .build(); } @Bean GroupedOpenApi extensionV1alpha1Api() { return GroupedOpenApi.builder() .group("apis_extension.api_v1alpha1") .displayName("Extension API V1alpha1") .pathsToMatch( "/api/v1alpha1/**", "/apis/content.halo.run/v1alpha1/**", "/apis/theme.halo.run/v1alpha1/**", "/apis/security.halo.run/v1alpha1/**", "/apis/migration.halo.run/v1alpha1/**", "/apis/auth.halo.run/v1alpha1/**", "/apis/metrics.halo.run/v1alpha1/**", "/apis/storage.halo.run/v1alpha1/**", "/apis/plugin.halo.run/v1alpha1/**", "/apis/notification.halo.run/**", "/apis/migration.halo.run/**" ) .build(); } @Bean @Order(Ordered.HIGHEST_PRECEDENCE) ModelConverter customModelConverter(ObjectMapperProvider objectMapperProvider) { return new ModelResolver(objectMapperProvider.jsonMapper(), new CustomTypeNameResolver()); } static class CustomTypeNameResolver extends TypeNameResolver { @Override protected String nameForClass(Class cls, Set options) { // Obey the rule of keys that match the regular expression ^[a-zA-Z0-9\.\-_]+$. // See https://spec.openapis.org/oas/v3.0.3#fixed-fields-5 for more. return super.nameForClass(cls, options).replaceAll("\\$", "."); } } } ================================================ FILE: application/src/main/java/run/halo/app/infra/config/WebFluxConfig.java ================================================ package run.halo.app.infra.config; import static org.springframework.util.ResourceUtils.FILE_URL_PREFIX; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; import static org.springframework.web.reactive.function.server.RequestPredicates.path; import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal; import java.util.List; import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.web.WebProperties; import org.springframework.boot.webflux.autoconfigure.WebFluxRegistrations; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.CacheControl; import org.springframework.http.MediaType; import org.springframework.http.codec.CodecConfigurer; import org.springframework.http.codec.HttpMessageWriter; import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.http.codec.json.JacksonJsonDecoder; import org.springframework.http.codec.json.JacksonJsonEncoder; import org.springframework.lang.NonNull; import org.springframework.web.filter.reactive.ServerWebExchangeContextFilter; import org.springframework.web.filter.reactive.UrlHandlerFilter; import org.springframework.web.reactive.config.ResourceHandlerRegistration; import org.springframework.web.reactive.config.ResourceHandlerRegistry; import org.springframework.web.reactive.config.WebFluxConfigurer; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.resource.EncodedResourceResolver; import org.springframework.web.reactive.resource.PathResourceResolver; import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerAdapter; import org.springframework.web.reactive.result.view.ViewResolutionResultHandler; import org.springframework.web.reactive.result.view.ViewResolver; import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.core.attachment.thumbnail.LocalThumbnailService; import run.halo.app.core.attachment.thumbnail.ThumbnailResourceTransformer; import run.halo.app.core.endpoint.WebSocketHandlerMapping; import run.halo.app.core.endpoint.console.CustomEndpointsBuilder; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.infra.SecureRequestMappingHandlerAdapter; import run.halo.app.infra.properties.AttachmentProperties; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.ui.ProxyFilter; import run.halo.app.infra.ui.WebSocketRequestPredicate; import run.halo.app.infra.webfilter.AdditionalWebFilterChainProxy; import run.halo.app.infra.webfilter.LocaleChangeWebFilter; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.theme.UserLocaleRequestAttributeWriteFilter; import tools.jackson.databind.json.JsonMapper; @Configuration @RequiredArgsConstructor public class WebFluxConfig implements WebFluxConfigurer { private final JsonMapper jsonMapper; private final HaloProperties haloProp; private final WebProperties webProperties; private final ApplicationContext applicationContext; private final LocalThumbnailService localThumbnailService; private final AttachmentRootGetter attachmentRootGetter; @Bean WebFluxRegistrations webFluxRegistrations() { return new WebFluxRegistrations() { @Override public RequestMappingHandlerAdapter getRequestMappingHandlerAdapter() { // Because we have no chance to customize ServerWebExchangeMethodArgumentResolver, // we have to use SecureRequestMappingHandlerAdapter to replace a secure // ServerWebExchange. return new SecureRequestMappingHandlerAdapter(); } }; } @Bean ServerResponse.Context context(CodecConfigurer codec, ViewResolutionResultHandler resultHandler) { return new ServerResponse.Context() { @Override @NonNull public List> messageWriters() { return codec.getWriters(); } @Override @NonNull public List viewResolvers() { return resultHandler.getViewResolvers(); } }; } @Override public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) { // we need to customize the Jackson2Json[Decoder][Encoder] here to serialize and // deserialize special types, e.g.: Instant, LocalDateTime. So we use ObjectMapper // created by outside. configurer.defaultCodecs().jacksonJsonDecoder(new JacksonJsonDecoder(jsonMapper)); configurer.defaultCodecs().jacksonJsonEncoder(new JacksonJsonEncoder(jsonMapper)); } @Bean RouterFunction customEndpoints(ApplicationContext context) { var builder = new CustomEndpointsBuilder(); context.getBeansOfType(CustomEndpoint.class).values().forEach(builder::add); return builder.build(); } @Bean public WebSocketHandlerMapping webSocketHandlerMapping() { WebSocketHandlerMapping handlerMapping = new WebSocketHandlerMapping(); handlerMapping.setOrder(-2); return handlerMapping; } @Bean RouterFunction uiPageEndpoints() { var consolePagePredicate = path("/console/**") .and(accept(MediaType.TEXT_HTML)) .and(new WebSocketRequestPredicate().negate()); var ucPagePredicate = path("/uc/**") .and(accept(MediaType.TEXT_HTML)) .and(new WebSocketRequestPredicate().negate()); var consolePageHtml = applicationContext.getResource("classpath:/ui/console.html"); var ucPageHtml = applicationContext.getResource("classpath:/ui/uc.html"); return RouterFunctions.route() .GET(consolePagePredicate, request -> ServerResponse.ok() .cacheControl(CacheControl.noStore()) .bodyValue(consolePageHtml) ) .GET(ucPagePredicate, request -> ServerResponse.ok() .cacheControl(CacheControl.noStore()) .bodyValue(ucPageHtml) ) .build(); } @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { var attachmentsRoot = attachmentRootGetter.get(); var resourceProperties = webProperties.getResources(); var cacheControl = resourceProperties.getCache() .getCachecontrol() .toHttpCacheControl(); if (cacheControl == null) { cacheControl = CacheControl.empty(); } final var useLastModified = resourceProperties.getCache().isUseLastModified(); // Mandatory resource mapping var uploadRegistration = registry.addResourceHandler("/upload/**") .addResourceLocations(FILE_URL_PREFIX + attachmentsRoot.resolve("upload") + "/") .setUseLastModified(useLastModified) .setCacheControl(cacheControl); registry.addResourceHandler("/ui-assets/**") .addResourceLocations("classpath:/ui/ui-assets/") .setCacheControl(cacheControl) .setUseLastModified(useLastModified) .resourceChain(true) .addResolver(new EncodedResourceResolver()) .addResolver(new PathResourceResolver()); // Additional resource mappings var staticResources = haloProp.getAttachment().getResourceMappings(); for (AttachmentProperties.ResourceMapping staticResource : staticResources) { ResourceHandlerRegistration registration; if (Objects.equals(staticResource.getPathPattern(), "/upload/**")) { registration = uploadRegistration; } else { registration = registry.addResourceHandler(staticResource.getPathPattern()) .setCacheControl(cacheControl) .setUseLastModified(useLastModified); } for (String location : staticResource.getLocations()) { var path = attachmentsRoot.resolve(location); checkDirectoryTraversal(attachmentsRoot, path); registration.addResourceLocations(FILE_URL_PREFIX + path + "/"); } if (registration != uploadRegistration) { applyThumbnailChain(registration); } } applyThumbnailChain(uploadRegistration); var haloStaticPath = haloProp.getWorkDir().resolve("static"); registry.addResourceHandler("/**") .addResourceLocations(FILE_URL_PREFIX + haloStaticPath + "/") .addResourceLocations(resourceProperties.getStaticLocations()) .setCacheControl(cacheControl) .setUseLastModified(useLastModified) .resourceChain(true) .addResolver(new EncodedResourceResolver()) .addResolver(new PathResourceResolver()); } private void applyThumbnailChain(ResourceHandlerRegistration registration) { registration.resourceChain(false) .addTransformer( new ThumbnailResourceTransformer(localThumbnailService) ); } /** * Order of this filter is higher than * {@link LocaleChangeWebFilter} to allow change locale in dev * mode. * {@link UserLocaleRequestAttributeWriteFilter} is before {@link LocaleChangeWebFilter} to * obtain the locale */ @ConditionalOnProperty(name = "halo.ui.proxy.enabled", havingValue = "true") @Bean @Order(Ordered.HIGHEST_PRECEDENCE + 2) ProxyFilter uiProxyFilter() { return new ProxyFilter(haloProp.getUi().getProxy(), "/console/**", "/uc/**"); } /** * Create a WebFilterChainProxy for all AdditionalWebFilters. * *

The reason why the order is -101 is that the current * AdditionalWebFilterChainProxy should be executed before WebFilterChainProxy * and the order of WebFilterChainProxy is -100. * *

See {@code org.springframework.security.config.annotation.web.reactive * .WebFluxSecurityConfiguration#WEB_FILTER_CHAIN_FILTER_ORDER} for more * * @param extensionGetter extension getter. * @return additional web filter chain proxy. */ @Bean @Order(-101) AdditionalWebFilterChainProxy additionalWebFilterChainProxy(ExtensionGetter extensionGetter) { return new AdditionalWebFilterChainProxy(extensionGetter); } @Bean // We expect this filter to be executed before AdditionalWebFilterChainProxy @Order(-102) ServerWebExchangeContextFilter serverWebExchangeContextFilter() { return new ServerWebExchangeContextFilter(); } @Bean @Order(Ordered.HIGHEST_PRECEDENCE) UrlHandlerFilter urlHandlerFilter() { return UrlHandlerFilter .trailingSlashHandler("/**").mutateRequest() .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java ================================================ package run.halo.app.infra.config; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; import java.util.HashMap; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.DelegatingPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository; import org.springframework.security.web.server.savedrequest.ServerRequestCache; import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.UserService; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.security.DefaultUserDetailService; import run.halo.app.security.HaloServerRequestCache; import run.halo.app.security.authentication.CryptoService; import run.halo.app.security.authentication.SecurityConfigurer; import run.halo.app.security.authentication.impl.RsaKeyService; import run.halo.app.security.authorization.AuthorityUtils; /** * Security configuration for WebFlux. * * @author johnniang */ @Configuration @EnableWebFluxSecurity @EnableReactiveMethodSecurity @RequiredArgsConstructor public class WebServerSecurityConfig { @Bean SecurityWebFilterChain filterChain(ServerHttpSecurity http, ObjectProvider securityConfigurers, ServerSecurityContextRepository securityContextRepository, HaloProperties haloProperties, ServerRequestCache serverRequestCache) { var pathMatcher = pathMatchers("/**"); var staticResourcesMatcher = pathMatchers(HttpMethod.GET, "/console/assets/**", "/uc/assets/**", "/themes/{themeName}/assets/{*resourcePaths}", "/plugins/{pluginName}/assets/**", "/webjars/**", "/js/**", "/styles/**", "/halo-tracker.js", "/images/**" ); var securityMatcher = new AndServerWebExchangeMatcher(pathMatcher, new NegatedServerWebExchangeMatcher(staticResourcesMatcher)); http.securityMatcher(securityMatcher) .anonymous(spec -> { spec.authorities(AuthorityUtils.ROLE_PREFIX + AnonymousUserConst.Role); spec.principal(AnonymousUserConst.PRINCIPAL); }) .securityContextRepository(securityContextRepository) .httpBasic(basic -> { if (haloProperties.getSecurity().getBasicAuth().isDisabled()) { basic.disable(); } }) .headers(headerSpec -> headerSpec .frameOptions(frameSpec -> { var frameOptions = haloProperties.getSecurity().getFrameOptions(); frameSpec.mode(frameOptions.getMode()); if (frameOptions.isDisabled()) { frameSpec.disable(); } }) .referrerPolicy(referrerPolicySpec -> referrerPolicySpec.policy( haloProperties.getSecurity().getReferrerOptions().getPolicy()) ) .hsts(hstsSpec -> hstsSpec.includeSubdomains(false)) ) .requestCache(spec -> spec.requestCache(serverRequestCache)); // Integrate with other configurers separately securityConfigurers.orderedStream() .forEach(securityConfigurer -> securityConfigurer.configure(http)); return http.build(); } @Bean ServerRequestCache serverRequestCache() { return new HaloServerRequestCache(); } @Bean ServerSecurityContextRepository securityContextRepository() { return new WebSessionServerSecurityContextRepository(); } @Bean DefaultUserDetailService userDetailsService(UserService userService, RoleService roleService, HaloProperties haloProperties) { var userDetailService = new DefaultUserDetailService(userService, roleService); var twoFactorAuthDisabled = haloProperties.getSecurity().getTwoFactorAuth().isDisabled(); userDetailService.setTwoFactorAuthDisabled(twoFactorAuthDisabled); return userDetailService; } @Bean @SuppressWarnings("deprecation") PasswordEncoder passwordEncoder() { // For removing the length limit of password, we have to create an argon2 password encoder // as default encoder. // When https://github.com/spring-projects/spring-security/issues/16879 resolved, // we can remove this code. var encodingId = "argon2@SpringSecurity_v5_8"; var encoders = new HashMap(); encoders.put(encodingId, Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()); encoders.put("bcrypt", new BCryptPasswordEncoder()); return new DelegatingPasswordEncoder(encodingId, encoders); } @Bean CryptoService cryptoService(HaloProperties haloProperties) { return new RsaKeyService(haloProperties.getWorkDir().resolve("keys")); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/AccessDeniedException.java ================================================ package run.halo.app.infra.exception; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; /** * AccessDeniedException will resolve i18n message and response 403 status. * * @author johnniang */ public class AccessDeniedException extends ResponseStatusException { public AccessDeniedException() { this("Access to the resource is forbidden"); } public AccessDeniedException(String reason) { this(reason, null, null); } public AccessDeniedException(String reason, String detailCode, Object[] detailArgs) { super(HttpStatus.FORBIDDEN, reason, null, detailCode, detailArgs); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/AttachmentAlreadyExistsException.java ================================================ package run.halo.app.infra.exception; import org.springframework.web.server.ServerWebInputException; /** * AttachmentAlreadyExistsException accepts filename parameter as detail message arguments. * * @author johnniang */ public class AttachmentAlreadyExistsException extends ServerWebInputException { public AttachmentAlreadyExistsException(String filename) { super("File " + filename + " already exists.", null, null, null, new Object[] {filename}); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/DuplicateNameException.java ================================================ package run.halo.app.infra.exception; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; public class DuplicateNameException extends ResponseStatusException { public DuplicateNameException() { this("Duplicate name detected"); } public DuplicateNameException(String reason) { this(reason, null); } public DuplicateNameException(String reason, Throwable cause) { this(reason, cause, null, null); } public DuplicateNameException(String reason, Throwable cause, String messageDetailCode, Object[] messageDetailArguments) { super(HttpStatus.BAD_REQUEST, reason, cause, messageDetailCode, messageDetailArguments); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/EmailAlreadyTakenException.java ================================================ package run.halo.app.infra.exception; import java.net.URI; import org.springframework.web.server.ServerWebInputException; /** * Exception thrown when email is already verified and taken. * * @author johnniang */ public class EmailAlreadyTakenException extends ServerWebInputException { public static final URI TYPE = URI.create("https://halo.run/errors/email-already-taken"); public EmailAlreadyTakenException(String reason) { super(reason); setType(TYPE); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/EmailVerificationFailed.java ================================================ package run.halo.app.infra.exception; import org.springframework.lang.Nullable; import org.springframework.web.server.ServerWebInputException; /** * Exception thrown when email verification failed. * * @author guqing * @since 2.11.0 */ public class EmailVerificationFailed extends ServerWebInputException { public EmailVerificationFailed() { super("Invalid verification code"); } public EmailVerificationFailed(String reason, @Nullable Throwable cause) { super(reason, null, cause); } public EmailVerificationFailed(String reason, @Nullable Throwable cause, @Nullable String messageDetailCode, @Nullable Object[] messageDetailArguments) { super(reason, null, cause, messageDetailCode, messageDetailArguments); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/Exceptions.java ================================================ package run.halo.app.infra.exception; import static org.springframework.core.annotation.MergedAnnotations.SearchStrategy.TYPE_HIERARCHY; import io.github.resilience4j.ratelimiter.RequestNotPermitted; import java.net.URI; import java.time.Instant; import java.util.Locale; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.context.MessageSource; import org.springframework.core.annotation.MergedAnnotations; import org.springframework.dao.ConcurrencyFailureException; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.ProblemDetail; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.server.ServerWebExchange; @Slf4j public enum Exceptions { ; public static final String DEFAULT_TYPE = "about:blank"; public static final String THEME_ALREADY_EXISTS_TYPE = "https://halo.run/probs/theme-alreay-exists"; public static final String INVALID_CREDENTIAL_TYPE = "https://halo.run/probs/invalid-credential"; public static final String REQUEST_NOT_PERMITTED_TYPE = "https://halo.run/probs/request-not-permitted"; public static final String CONFLICT_TYPE = "https://halo.run/probs/conflict"; /** * Non-ErrorResponse exception to type map. */ public static final Map, String> EXCEPTION_TYPE_MAP = Map.of( RequestNotPermitted.class, REQUEST_NOT_PERMITTED_TYPE, BadCredentialsException.class, INVALID_CREDENTIAL_TYPE ); public static ErrorResponse createErrorResponse(Throwable t, @Nullable HttpStatusCode status, ServerWebExchange exchange, MessageSource messageSource) { final ErrorResponse errorResponse; if (t instanceof ErrorResponse er) { errorResponse = er; } else { var er = handleConflictException(t); if (er == null) { er = handleException(t, status); } errorResponse = er; } var problemDetail = errorResponse.updateAndGetBody(messageSource, getLocale(exchange)); problemDetail.setInstance(exchange.getRequest().getURI()); problemDetail.setProperty("requestId", exchange.getRequest().getId()); problemDetail.setProperty("timestamp", Instant.now()); return errorResponse; } @NonNull private static ErrorResponse handleException(Throwable t, @Nullable HttpStatusCode status) { var responseStatusAnno = MergedAnnotations.from(t.getClass(), TYPE_HIERARCHY) .get(ResponseStatus.class); if (status == null) { status = responseStatusAnno.getValue("code", HttpStatus.class) .orElse(HttpStatus.INTERNAL_SERVER_ERROR); } var type = EXCEPTION_TYPE_MAP.getOrDefault(t.getClass(), DEFAULT_TYPE); var detail = responseStatusAnno.getValue("reason", String.class) .orElseGet(t::getMessage); var builder = ErrorResponse.builder(t, status, detail) .type(URI.create(type)); if (status.is5xxServerError()) { builder.detailMessageCode("problemDetail.internalServerError") .titleMessageCode("problemDetail.title.internalServerError"); } return builder.build(); } @Nullable private static ErrorResponse handleConflictException(Throwable t) { if (t instanceof ConcurrencyFailureException) { return ErrorResponse.builder(t, ProblemDetail.forStatus(HttpStatus.CONFLICT)) .type(URI.create(CONFLICT_TYPE)) .titleMessageCode("problemDetail.title.conflict") .detailMessageCode("problemDetail.conflict") .build(); } return null; } public static Locale getLocale(ServerWebExchange exchange) { var locale = exchange.getLocaleContext().getLocale(); return locale == null ? Locale.getDefault() : locale; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/FileSizeExceededException.java ================================================ package run.halo.app.infra.exception; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; public class FileSizeExceededException extends ResponseStatusException { public FileSizeExceededException(String reason, String messageDetailCode, Object[] messageDetailArguments) { this(reason, null, messageDetailCode, messageDetailArguments); } public FileSizeExceededException(String reason, Throwable cause, String messageDetailCode, Object[] messageDetailArguments) { super(HttpStatus.PAYLOAD_TOO_LARGE, reason, cause, messageDetailCode, messageDetailArguments); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/FileTypeNotAllowedException.java ================================================ package run.halo.app.infra.exception; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; public class FileTypeNotAllowedException extends ResponseStatusException { public FileTypeNotAllowedException(String reason, String messageDetailCode, Object[] messageDetailArguments) { this(reason, null, messageDetailCode, messageDetailArguments); } public FileTypeNotAllowedException(String reason, Throwable cause, String messageDetailCode, Object[] messageDetailArguments) { super(HttpStatus.UNSUPPORTED_MEDIA_TYPE, reason, cause, messageDetailCode, messageDetailArguments); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/NotFoundException.java ================================================ package run.halo.app.infra.exception; import org.springframework.http.HttpStatus; import org.springframework.lang.Nullable; import org.springframework.web.server.ResponseStatusException; /** * Not found exception. * * @author guqing * @since 2.0.0 */ public class NotFoundException extends ResponseStatusException { public NotFoundException(@Nullable String reason) { this(reason, null); } public NotFoundException(@Nullable String reason, @Nullable Throwable cause) { super(HttpStatus.NOT_FOUND, reason, cause); } public NotFoundException(@Nullable Throwable cause) { this(cause == null ? "" : cause.getMessage(), cause); } public NotFoundException(String messageDetailCode, Object[] messageDetailArgs, String reason) { super(HttpStatus.NOT_FOUND, reason, null, messageDetailCode, messageDetailArgs); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/OAuth2UserAlreadyBoundException.java ================================================ package run.halo.app.infra.exception; import org.springframework.web.server.ServerWebInputException; import run.halo.app.core.extension.UserConnection; /** * An exception that the user has been bound to another OAuth2 user. * * @author johnniang * @since 2.20.0 */ public class OAuth2UserAlreadyBoundException extends ServerWebInputException { public OAuth2UserAlreadyBoundException(UserConnection connection) { super("The user has been bound to another account", null, null, null, new Object[] { connection.getSpec().getUsername(), connection.getSpec().getProviderUserId(), connection.getSpec().getRegistrationId(), connection.getSpec().getUpdatedAt() }); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/PluginAlreadyExistsException.java ================================================ package run.halo.app.infra.exception; import java.net.URI; import org.springframework.web.server.ServerWebInputException; /** * PluginAlreadyExistsException indicates the provided plugin has already installed before. * * @author johnniang */ public class PluginAlreadyExistsException extends ServerWebInputException { public static final String PLUGIN_ALREADY_EXISTS_TYPE = "https://halo.run/probs/plugin-alreay-exists"; public PluginAlreadyExistsException(String pluginName) { super("Plugin already exists.", null, null, null, new Object[] {pluginName}); setType(URI.create(PLUGIN_ALREADY_EXISTS_TYPE)); getBody().setProperty("pluginName", pluginName); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/PluginDependenciesNotEnabledException.java ================================================ package run.halo.app.infra.exception; import java.net.URI; import java.util.List; import org.springframework.web.server.ServerWebInputException; /** * Plugin dependencies not enabled exception. * * @author johnniang */ public class PluginDependenciesNotEnabledException extends ServerWebInputException { public static final URI TYPE = URI.create("https://www.halo.run/probs/plugin-dependencies-not-enabled"); /** * Instantiates a new Plugin dependencies not enabled exception. * * @param dependencies dependencies that are not enabled */ public PluginDependenciesNotEnabledException(List dependencies) { super("Plugin dependencies are not fully enabled, please enable them first.", null, null, null, new Object[] {dependencies}); setType(TYPE); getBody().setProperty("dependencies", dependencies); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/PluginDependencyException.java ================================================ package run.halo.app.infra.exception; import java.net.URI; import java.util.List; import org.pf4j.DependencyResolver.WrongDependencyVersion; import org.springframework.web.server.ServerWebInputException; public abstract class PluginDependencyException extends ServerWebInputException { public PluginDependencyException(String reason) { super(reason); } public PluginDependencyException(String reason, Throwable cause) { super(reason, null, cause); } protected PluginDependencyException(String reason, Throwable cause, String messageDetailCode, Object[] messageDetailArguments) { super(reason, null, cause, messageDetailCode, messageDetailArguments); } public static class CyclicException extends PluginDependencyException { public static final String TYPE = "https://halo.run/probs/plugin-cyclic-dependency"; public CyclicException() { super("A cyclic dependency was detected."); setType(URI.create(TYPE)); } } public static class NotFoundException extends PluginDependencyException { public static final String TYPE = "https://halo.run/probs/plugin-dependencies-not-found"; public NotFoundException(List dependencies) { super("Dependencies were not found.", null, null, new Object[] {dependencies}); setType(URI.create(TYPE)); getBody().setProperty("dependencies", dependencies); } } public static class WrongVersionsException extends PluginDependencyException { public static final String TYPE = "https://halo.run/probs/plugin-dependencies-with-wrong-versions"; public WrongVersionsException(List versions) { super("Dependencies have wrong version.", null, null, new Object[] {versions}); setType(URI.create(TYPE)); getBody().setProperty("versions", versions); } } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/PluginDependentsNotDisabledException.java ================================================ package run.halo.app.infra.exception; import java.net.URI; import java.util.List; import org.springframework.web.server.ServerWebInputException; /** * Plugin dependents not disabled exception. * * @author johnniang */ public class PluginDependentsNotDisabledException extends ServerWebInputException { public static final URI TYPE = URI.create("https://www.halo.run/probs/plugin-dependents-not-disabled"); /** * Instantiates a new Plugin dependents not disabled exception. * * @param dependents dependents that are not disabled */ public PluginDependentsNotDisabledException(List dependents) { super("Plugin dependents are not fully disabled, please disable them first.", null, null, null, new Object[] {dependents}); setType(TYPE); getBody().setProperty("dependents", dependents); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/PluginInstallationException.java ================================================ package run.halo.app.infra.exception; import jakarta.validation.constraints.Null; import org.springframework.lang.Nullable; import org.springframework.web.server.ServerWebInputException; /** * {@link ServerWebInputException} subclass that indicates plugin installation failure. * * @author guqing * @since 2.0.0 */ public class PluginInstallationException extends ServerWebInputException { public PluginInstallationException(String reason, @Nullable String messageDetailCode, @Null Object[] messageDetailArguments) { super(reason, null, null, messageDetailCode, messageDetailArguments); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/PluginRuntimeIncompatibleException.java ================================================ package run.halo.app.infra.exception; import java.net.URI; import org.springframework.web.server.ServerErrorException; /** * Exception thrown when an incompatible plugin is detected. * This usually occurs when a plugin is not compatible with the current version of Halo at runtime. * * @author johnniang * @since 2.21.0 */ public class PluginRuntimeIncompatibleException extends ServerErrorException { private static final URI TYPE = URI.create("https://www.halo.run/probs/plugin-runtime-incompatible"); public PluginRuntimeIncompatibleException(Throwable cause) { super("Incompatible plugin detected.", cause); setType(TYPE); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/RateLimitExceededException.java ================================================ package run.halo.app.infra.exception; import java.net.URI; import org.springframework.http.HttpStatus; import org.springframework.lang.Nullable; import org.springframework.web.server.ResponseStatusException; public class RateLimitExceededException extends ResponseStatusException { public RateLimitExceededException(@Nullable Throwable cause) { super(HttpStatus.TOO_MANY_REQUESTS, "You have exceeded your quota", cause); setType(URI.create(Exceptions.REQUEST_NOT_PERMITTED_TYPE)); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java ================================================ package run.halo.app.infra.exception; import java.util.ArrayList; import java.util.List; import java.util.Locale; import lombok.Getter; import org.springframework.context.MessageSource; import org.springframework.context.MessageSourceResolvable; import org.springframework.http.ProblemDetail; import org.springframework.lang.Nullable; import org.springframework.validation.Errors; import org.springframework.web.server.ServerWebInputException; import org.springframework.web.util.BindErrorUtils; @Getter public class RequestBodyValidationException extends ServerWebInputException { private final Errors errors; public RequestBodyValidationException(Errors errors) { super("Validation failure", null, null, null, null); this.errors = errors; } @Override public ProblemDetail updateAndGetBody(MessageSource messageSource, Locale locale) { var detail = super.updateAndGetBody(messageSource, locale); detail.setProperty("errors", collectAllErrors(messageSource, locale)); return detail; } private List collectAllErrors(MessageSource messageSource, Locale locale) { var globalErrors = resolveErrors(errors.getGlobalErrors(), messageSource, locale); var fieldErrors = resolveErrors(errors.getFieldErrors(), messageSource, locale); var errors = new ArrayList(globalErrors.size() + fieldErrors.size()); errors.addAll(globalErrors); errors.addAll(fieldErrors); return errors; } @Override public Object[] getDetailMessageArguments(MessageSource messageSource, Locale locale) { return new Object[] { resolveErrors(errors.getGlobalErrors(), messageSource, locale), resolveErrors(errors.getFieldErrors(), messageSource, locale) }; } @Override public Object[] getDetailMessageArguments() { return new Object[] { resolveErrors(errors.getGlobalErrors(), null, Locale.getDefault()), resolveErrors(errors.getFieldErrors(), null, Locale.getDefault()) }; } private static List resolveErrors( List errors, @Nullable MessageSource messageSource, Locale locale) { return messageSource == null ? BindErrorUtils.resolve(errors).values().stream().toList() : BindErrorUtils.resolve(errors, messageSource, locale).values().stream().toList(); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/RequestRestrictedException.java ================================================ package run.halo.app.infra.exception; /** *

{@link RequestRestrictedException} indicates that the client's request was denied because * it did not meet certain required conditions.

*

Typically, this exception is thrown when a user attempts to perform an action that * requires prior approval or validation, such as replying to a comment that has not yet been * approved.

*

The server understands the request but refuses to process it due to the lack of * necessary approval.

* * @author guqing * @since 2.20.0 */ public class RequestRestrictedException extends AccessDeniedException { public RequestRestrictedException(String reason) { super(reason); } public RequestRestrictedException(String reason, String detailCode, Object[] detailArgs) { super(reason, detailCode, detailArgs); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/RestrictedNameException.java ================================================ package run.halo.app.infra.exception; import jakarta.validation.constraints.Null; import org.springframework.lang.Nullable; import org.springframework.web.server.ServerWebInputException; /** * Restricted name exception. * * @author lywq * @since 2025/10/30 11:47 **/ public class RestrictedNameException extends ServerWebInputException { public RestrictedNameException() { super("The name is restricted"); } public RestrictedNameException(String reason, @Nullable String messageDetailCode, @Null Object[] messageDetailArguments) { super(reason, null, null, messageDetailCode, messageDetailArguments); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/ThemeAlreadyExistsException.java ================================================ package run.halo.app.infra.exception; import java.net.URI; import org.springframework.lang.NonNull; import org.springframework.web.server.ServerWebInputException; /** * {@link ThemeAlreadyExistsException} indicates the provided theme has already installed before. * * @author guqing * @since 2.6.0 */ public class ThemeAlreadyExistsException extends ServerWebInputException { /** * Constructs a {@code ThemeAlreadyExistsException} with the given theme name. * * @param themeName theme name must not be blank */ public ThemeAlreadyExistsException(@NonNull String themeName) { super("Theme already exists.", null, null, "problemDetail.theme.install.alreadyExists", new Object[] {themeName}); setType(URI.create(Exceptions.THEME_ALREADY_EXISTS_TYPE)); getBody().setProperty("themeName", themeName); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/ThemeInstallationException.java ================================================ package run.halo.app.infra.exception; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; /** * @author guqing * @author johnniang * @since 2.0.0 */ public class ThemeInstallationException extends ResponseStatusException { public ThemeInstallationException(String reason, String detailCode, Object[] detailArgs) { super(HttpStatus.BAD_REQUEST, reason, null, detailCode, detailArgs); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/ThemeUninstallException.java ================================================ package run.halo.app.infra.exception; /** * @author guqing * @since 2.0.0 */ public class ThemeUninstallException extends RuntimeException { public ThemeUninstallException(String message) { super(message); } public ThemeUninstallException(String message, Throwable cause) { super(message, cause); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/ThemeUpgradeException.java ================================================ package run.halo.app.infra.exception; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; /** * ThemeUpgradeException will response bad request status if failed to upgrade theme. * * @author johnniang */ public class ThemeUpgradeException extends ResponseStatusException { public ThemeUpgradeException(String reason, String detailCode, Object[] detailArgs) { super(HttpStatus.BAD_REQUEST, reason, null, detailCode, detailArgs); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/UnsatisfiedAttributeValueException.java ================================================ package run.halo.app.infra.exception; import jakarta.validation.constraints.Null; import org.springframework.lang.Nullable; import org.springframework.web.server.ServerWebInputException; /** * {@link ServerWebInputException} subclass that indicates an unsatisfied * attribute value in request parameters. * * @author guqing * @since 2.2.0 */ public class UnsatisfiedAttributeValueException extends ServerWebInputException { public UnsatisfiedAttributeValueException(String reason) { super(reason); } public UnsatisfiedAttributeValueException(String reason, @Nullable String messageDetailCode, @Null Object[] messageDetailArguments) { super(reason, null, null, messageDetailCode, messageDetailArguments); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/UserNotFoundException.java ================================================ package run.halo.app.infra.exception; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; public class UserNotFoundException extends ResponseStatusException { public UserNotFoundException(String username) { super(HttpStatus.NOT_FOUND, "User " + username + " was not found", null, null, new Object[] {username}); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/handlers/HaloErrorConfiguration.java ================================================ package run.halo.app.infra.exception.handlers; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.web.WebProperties; import org.springframework.boot.webflux.autoconfigure.error.ErrorWebFluxAutoConfiguration; import org.springframework.boot.webflux.error.ErrorAttributes; import org.springframework.boot.webflux.error.ErrorWebExceptionHandler; import org.springframework.context.ApplicationContext; import org.springframework.context.MessageSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.web.reactive.result.view.ViewResolver; import org.springframework.web.server.WebExceptionHandler; /** * Configuration to render errors via a WebFlux * {@link WebExceptionHandler}. *
*
* See * {@link ErrorWebFluxAutoConfiguration} * for more. * * @author guqing * @author johnniang * @since 2.1.0 */ @Configuration public class HaloErrorConfiguration { /** * Customize the default {@link ErrorWebExceptionHandler}. */ @Bean @Order(-1) ErrorWebExceptionHandler errorWebExceptionHandler( ErrorAttributes errorAttributes, WebProperties webProperties, ObjectProvider viewResolvers, ServerCodecConfigurer serverCodecConfigurer, ApplicationContext applicationContext ) { var exceptionHandler = new HaloErrorWebExceptionHandler( errorAttributes, webProperties.getResources(), webProperties.getError(), applicationContext); exceptionHandler.setViewResolvers(viewResolvers.orderedStream().toList()); exceptionHandler.setMessageWriters(serverCodecConfigurer.getWriters()); exceptionHandler.setMessageReaders(serverCodecConfigurer.getReaders()); return exceptionHandler; } /** * Customize the default {@link ErrorAttributes}. */ @Bean ErrorAttributes errorAttributes(MessageSource messageSource) { return new ProblemDetailErrorAttributes(messageSource); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/handlers/HaloErrorWebExceptionHandler.java ================================================ package run.halo.app.infra.exception.handlers; import java.util.Map; import java.util.Optional; import org.springframework.boot.autoconfigure.web.ErrorProperties; import org.springframework.boot.autoconfigure.web.WebProperties; import org.springframework.boot.webflux.autoconfigure.error.DefaultErrorWebExceptionHandler; import org.springframework.boot.webflux.error.ErrorAttributes; import org.springframework.context.ApplicationContext; import org.springframework.http.MediaType; import org.springframework.http.ProblemDetail; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import reactor.util.context.Context; import run.halo.app.theme.ThemeContext; import run.halo.app.theme.ThemeResolver; import run.halo.app.theme.engine.ThemeTemplateAvailabilityProvider; public class HaloErrorWebExceptionHandler extends DefaultErrorWebExceptionHandler { private final ThemeTemplateAvailabilityProvider templateAvailabilityProvider; private final ThemeResolver themeResolver; /** * Create a new {@code DefaultErrorWebExceptionHandler} instance. * * @param errorAttributes the error attributes * @param resources the resources configuration properties * @param errorProperties the error configuration properties * @param applicationContext the current application context * @since 2.4.0 */ public HaloErrorWebExceptionHandler( ErrorAttributes errorAttributes, WebProperties.Resources resources, ErrorProperties errorProperties, ApplicationContext applicationContext) { super(errorAttributes, resources, errorProperties, applicationContext); this.templateAvailabilityProvider = applicationContext.getBean(ThemeTemplateAvailabilityProvider.class); this.themeResolver = applicationContext.getBean(ThemeResolver.class); } @Override protected int getHttpStatus(Map errorAttributes) { var problemDetail = (ProblemDetail) errorAttributes.get("error"); return problemDetail.getStatus(); } @Override protected Mono renderErrorResponse(ServerRequest request) { var errorAttributes = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL)); return ServerResponse.status(getHttpStatus(errorAttributes)) .contentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyValue(errorAttributes.get("error")); } @Override protected Mono renderErrorView(ServerRequest request) { return themeResolver.getTheme(request.exchange()) .flatMap(themeContext -> super.renderErrorView(request) .contextWrite(Context.of(ThemeContext.class, themeContext))); } @Override protected Mono renderErrorView(String viewName, ServerResponse.BodyBuilder responseBody, Map error) { return Mono.deferContextual(contextView -> { Optional themeContext = contextView.getOrEmpty(ThemeContext.class); if (themeContext.isPresent() && templateAvailabilityProvider.isTemplateAvailable(themeContext.get(), viewName)) { return responseBody.render(viewName, error); } return super.renderErrorView(viewName, responseBody, error); }); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/exception/handlers/ProblemDetailErrorAttributes.java ================================================ package run.halo.app.infra.exception.handlers; import static run.halo.app.infra.exception.Exceptions.createErrorResponse; import java.util.Map; import org.springframework.boot.web.error.ErrorAttributeOptions; import org.springframework.boot.webflux.error.DefaultErrorAttributes; import org.springframework.context.MessageSource; import org.springframework.web.reactive.function.server.ServerRequest; /** * See {@link DefaultErrorAttributes} for more. * * @author johnn */ public class ProblemDetailErrorAttributes extends DefaultErrorAttributes { private final MessageSource messageSource; public ProblemDetailErrorAttributes(MessageSource messageSource) { this.messageSource = messageSource; } @Override public Map getErrorAttributes(ServerRequest request, ErrorAttributeOptions options) { final var errAttributes = super.getErrorAttributes(request, options); var error = getError(request); var errorResponse = createErrorResponse(error, null, request.exchange(), messageSource); errAttributes.put("error", errorResponse.getBody()); return errAttributes; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/properties/AttachmentProperties.java ================================================ package run.halo.app.infra.properties; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.PositiveOrZero; import java.util.LinkedList; import java.util.List; import lombok.Data; import org.springframework.boot.context.properties.NestedConfigurationProperty; @Data public class AttachmentProperties { private List resourceMappings = new LinkedList<>(); @Valid @NestedConfigurationProperty private final ThumbnailProperties thumbnail = new ThumbnailProperties(); @Data public static class ThumbnailProperties { /** * Whether to disable thumbnail generation. */ private boolean disabled; /** * The concurrent threads for thumbnail generation. */ @Min(1) private Integer concurrentThreads; /** * The quality of generated thumbnails, value between 0.0 and 1.0. */ @PositiveOrZero @Max(1) private Double quality; } @Data public static class ResourceMapping { /** * Like: {@code /upload/**}. */ private String pathPattern; /** * The location is a relative path to attachments folder in working directory. */ private List locations; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/properties/CacheProperties.java ================================================ package run.halo.app.infra.properties; import lombok.Data; @Data public class CacheProperties { private boolean disabled; } ================================================ FILE: application/src/main/java/run/halo/app/infra/properties/ExtensionProperties.java ================================================ package run.halo.app.infra.properties; import lombok.Data; @Data public class ExtensionProperties { private Controller controller = new Controller(); @Data public static class Controller { private boolean disabled; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/properties/HaloProperties.java ================================================ package run.halo.app.infra.properties; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Path; import java.util.HashSet; import java.util.Set; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.validation.Errors; import org.springframework.validation.Validator; import org.springframework.validation.annotation.Validated; /** * @author guqing * @since 2022-04-12 */ @Data @ConfigurationProperties(prefix = "halo") @Validated public class HaloProperties implements Validator { @NotNull private Path workDir; /** * External URL must be a URL and it can be null. */ private URL externalUrl; /** * Indicates if we use absolute permalink to post, page, category, tag and so on. */ private boolean useAbsolutePermalink; private Set initialExtensionLocations = new HashSet<>(); /** * This property could stop initializing required Extensions defined in classpath. * See {@link run.halo.app.infra.ExtensionResourceInitializer#REQUIRED_EXTENSION_LOCATIONS} * for more. */ private boolean requiredExtensionDisabled; @Valid @NestedConfigurationProperty private final ExtensionProperties extension = new ExtensionProperties(); @Valid @NestedConfigurationProperty private final SecurityProperties security = new SecurityProperties(); @Valid @NestedConfigurationProperty private final UiProperties ui = new UiProperties(); @Valid @NestedConfigurationProperty private final ThemeProperties theme = new ThemeProperties(); @Valid @NestedConfigurationProperty private final AttachmentProperties attachment = new AttachmentProperties(); @Override public boolean supports(Class clazz) { return HaloProperties.class.isAssignableFrom(clazz); } @Override public void validate(Object target, Errors errors) { var props = (HaloProperties) target; var externalUrl = props.getExternalUrl(); if (props.isUseAbsolutePermalink() && externalUrl == null) { errors.rejectValue("externalUrl", "external-url.required.when-using-absolute-permalink", "External URL is required when property `use-absolute-permalink` is set to true."); } // check if the external URL is a http or https URL and is not an opaque URL. if (externalUrl != null && !isValidExternalUrl(externalUrl)) { errors.rejectValue("externalUrl", "external-url.invalid-format", "External URL must be a http or https URL."); } } private boolean isValidExternalUrl(URL externalUrl) { try { var uri = externalUrl.toURI(); return !uri.isOpaque() && uri.getAuthority() != null && Set.of("http", "https").contains(uri.getScheme()); } catch (URISyntaxException e) { return false; } } } ================================================ FILE: application/src/main/java/run/halo/app/infra/properties/JwtProperties.java ================================================ package run.halo.app.infra.properties; import jakarta.validation.constraints.NotNull; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; import org.springframework.core.io.Resource; import org.springframework.security.converter.RsaKeyConverters; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.util.Assert; import org.springframework.util.StreamUtils; import org.springframework.validation.annotation.Validated; /** * @author guqing * @author johnniang * @date 2022-04-12 */ @Validated public class JwtProperties { /** * URI that can either be an OpenID Connect discovery endpoint or an OAuth 2.0 * Authorization Server Metadata endpoint defined by RFC 8414. */ private String issuerUri; /** * JSON Web Algorithm used for verifying the digital signatures. */ private SignatureAlgorithm jwsAlgorithm; /** * Location of the file containing the public key used to verify a JWT. */ @NotNull private Resource publicKeyLocation; @NotNull private Resource privateKeyLocation; private final RSAPrivateKey privateKey; private final RSAPublicKey publicKey; public JwtProperties(String issuerUri, SignatureAlgorithm jwsAlgorithm, Resource publicKeyLocation, Resource privateKeyLocation) throws IOException { this.issuerUri = issuerUri; this.jwsAlgorithm = jwsAlgorithm; if (jwsAlgorithm == null) { this.jwsAlgorithm = SignatureAlgorithm.RS256; } this.publicKeyLocation = publicKeyLocation; this.privateKeyLocation = privateKeyLocation; //TODO initialize private and public keys at first startup. this.privateKey = this.readPrivateKey(); this.publicKey = this.readPublicKey(); } public String getIssuerUri() { return issuerUri; } public void setIssuerUri(String issuerUri) { this.issuerUri = issuerUri; } public SignatureAlgorithm getJwsAlgorithm() { return this.jwsAlgorithm; } public void setJwsAlgorithm(SignatureAlgorithm jwsAlgorithm) { this.jwsAlgorithm = jwsAlgorithm; } public Resource getPublicKeyLocation() { return this.publicKeyLocation; } public void setPublicKeyLocation(Resource publicKeyLocation) { this.publicKeyLocation = publicKeyLocation; } public Resource getPrivateKeyLocation() { return privateKeyLocation; } public void setPrivateKeyLocation(Resource privateKeyLocation) { this.privateKeyLocation = privateKeyLocation; } public RSAPrivateKey getPrivateKey() { return privateKey; } public RSAPublicKey getPublicKey() { return publicKey; } private RSAPublicKey readPublicKey() throws IOException { String key = "halo.security.oauth2.jwt.public-key-location"; Assert.notNull(this.publicKeyLocation, "PublicKeyLocation must not be null"); if (!this.publicKeyLocation.exists()) { throw new InvalidConfigurationPropertyValueException(key, this.publicKeyLocation, "Public key location does not exist"); } try (InputStream inputStream = this.publicKeyLocation.getInputStream()) { String source = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); return RsaKeyConverters.x509() .convert(new ByteArrayInputStream(source.getBytes())); } } private RSAPrivateKey readPrivateKey() throws IOException { String key = "halo.security.oauth2.jwt.private-key-location"; Assert.notNull(this.privateKeyLocation, "PrivateKeyLocation must not be null"); if (!this.privateKeyLocation.exists()) { throw new InvalidConfigurationPropertyValueException(key, this.privateKeyLocation, "Private key location does not exist"); } try (InputStream inputStream = this.privateKeyLocation.getInputStream()) { String source = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); return RsaKeyConverters.pkcs8() .convert(new ByteArrayInputStream(source.getBytes())); } } } ================================================ FILE: application/src/main/java/run/halo/app/infra/properties/ProxyProperties.java ================================================ package run.halo.app.infra.properties; import java.net.URI; import lombok.Data; @Data public class ProxyProperties { /** * Console endpoint in development environment to be proxied. e.g.: http://localhost:8090/ */ private URI endpoint; /** * Indicates if the proxy behaviour is enabled. Default is false */ private boolean enabled = false; } ================================================ FILE: application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java ================================================ package run.halo.app.infra.properties; import static org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN; import java.net.URI; import java.time.Duration; import java.util.ArrayList; import java.util.List; import lombok.Data; import org.springframework.boot.actuate.autoconfigure.endpoint.web.CorsEndpointProperties; import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy; import org.springframework.security.web.server.header.XFrameOptionsServerHttpHeadersWriter.Mode; @Data public class SecurityProperties { private final FrameOptions frameOptions = new FrameOptions(); private final ReferrerOptions referrerOptions = new ReferrerOptions(); private final CorsOptions corsOptions = new CorsOptions(); private final RememberMeOptions rememberMe = new RememberMeOptions(); private final TwoFactorAuthOptions twoFactorAuth = new TwoFactorAuthOptions(); private final BasicAuthOptions basicAuth = new BasicAuthOptions(); private final List passwordResetMethods = new ArrayList<>(); @Data public static class BasicAuthOptions { /** * Whether basic authentication is disabled. */ private boolean disabled = true; } @Data public static class TwoFactorAuthOptions { /** * Whether two-factor authentication is disabled. */ private boolean disabled; } @Data public static class CorsOptions { private boolean disabled; private final List configs = new ArrayList<>(); } @Data public static class CorsConfig { private String pathPattern; private CorsEndpointProperties config; } @Data public static class FrameOptions { private boolean disabled; private Mode mode = Mode.SAMEORIGIN; } @Data public static class ReferrerOptions { private ReferrerPolicy policy = STRICT_ORIGIN_WHEN_CROSS_ORIGIN; } @Data public static class RememberMeOptions { private Duration tokenValidity = Duration.ofDays(14); } @Data public static class PasswordResetMethod { private String name; private URI href; private URI icon; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/properties/ThemeProperties.java ================================================ package run.halo.app.infra.properties; import jakarta.validation.Valid; import lombok.Data; @Data public class ThemeProperties { @Valid private final Initializer initializer = new Initializer(); /** * Indicates whether the generator meta needs to be disabled. */ private boolean generatorMetaDisabled; @Data public static class Initializer { private boolean disabled = false; private String location = "classpath:themes/theme-earth.zip"; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/properties/UiProperties.java ================================================ package run.halo.app.infra.properties; import jakarta.validation.Valid; import lombok.Data; import org.springframework.boot.context.properties.NestedConfigurationProperty; @Data public class UiProperties { private String location = "classpath:/ui/"; @Valid @NestedConfigurationProperty private ProxyProperties proxy = new ProxyProperties(); } ================================================ FILE: application/src/main/java/run/halo/app/infra/ui/ProxyFilter.java ================================================ package run.halo.app.infra.ui; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import org.springframework.web.util.UriComponentsBuilder; import reactor.core.publisher.Mono; import run.halo.app.infra.properties.ProxyProperties; @Slf4j public class ProxyFilter implements WebFilter { private final ProxyProperties proxyProperties; private final ServerWebExchangeMatcher requestMatcher; private final WebClient webClient; public ProxyFilter(ProxyProperties proxyProperties, String... patterns) { this.proxyProperties = proxyProperties; var requestMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, patterns); requestMatcher = new AndServerWebExchangeMatcher(requestMatcher, new NegatedServerWebExchangeMatcher(new WebSocketServerWebExchangeMatcher())); this.requestMatcher = requestMatcher; this.webClient = WebClient.create(proxyProperties.getEndpoint().toString()); log.debug("Initialized ProxyFilter to proxy {} to endpoint {}", java.util.Arrays.toString(patterns), proxyProperties.getEndpoint()); } @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { return requestMatcher.matches(exchange) .filter(ServerWebExchangeMatcher.MatchResult::isMatch) .filter(matchResult -> isHtmlRequest(exchange)) .switchIfEmpty(chain.filter(exchange).then(Mono.empty())) .map(matchResult -> getProxyUri(exchange)) .doOnNext(uri -> { if (log.isTraceEnabled()) { log.trace("Proxy {} to {}", uri, proxyProperties.getEndpoint()); } }) .flatMap(uri -> webClient.get() .uri(uri) .headers(httpHeaders -> httpHeaders.addAll(exchange.getRequest().getHeaders())) .exchangeToMono(clientResponse -> { var response = exchange.getResponse(); var httpStatusCode = clientResponse.statusCode(); // set headers var httpHeaders = clientResponse.headers().asHttpHeaders(); response.getHeaders().putAll(httpHeaders); // set cookies response.getCookies().putAll(clientResponse.cookies()); // set status code response.setStatusCode(httpStatusCode); var contentLength = clientResponse.headers().contentLength().orElse(0L); if (httpStatusCode.is3xxRedirection() || httpStatusCode.equals(HttpStatus.NO_CONTENT) || contentLength == 0) { return Mono.empty(); } var body = clientResponse.bodyToFlux(DataBuffer.class); return response.writeWith(body); })); } private boolean isHtmlRequest(ServerWebExchange exchange) { var acceptHeaders = exchange.getRequest().getHeaders().getAccept(); if (acceptHeaders.isEmpty()) { return true; } return acceptHeaders.stream() .anyMatch(mediaType -> mediaType.isCompatibleWith(MediaType.TEXT_HTML)); } private String getProxyUri(ServerWebExchange exchange) { var requestPath = exchange.getRequest().getPath().pathWithinApplication().value(); return UriComponentsBuilder.fromUriString(getUiEntryPath(requestPath)) .queryParams(exchange.getRequest().getQueryParams()) .build() .toUriString(); } private String getUiEntryPath(String requestPath) { if (requestPath.startsWith("/console")) { return "/console"; } if (requestPath.startsWith("/uc")) { return "/uc"; } return requestPath; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/ui/WebSocketRequestPredicate.java ================================================ package run.halo.app.infra.ui; import static run.halo.app.infra.ui.WebSocketUtils.isWebSocketUpgrade; import org.springframework.web.reactive.function.server.RequestPredicate; import org.springframework.web.reactive.function.server.ServerRequest; public class WebSocketRequestPredicate implements RequestPredicate { @Override public boolean test(ServerRequest request) { var httpHeaders = request.exchange().getRequest().getHeaders(); return isWebSocketUpgrade(httpHeaders); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/ui/WebSocketServerWebExchangeMatcher.java ================================================ package run.halo.app.infra.ui; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult.match; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult.notMatch; import static run.halo.app.infra.ui.WebSocketUtils.isWebSocketUpgrade; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; public class WebSocketServerWebExchangeMatcher implements ServerWebExchangeMatcher { @Override public Mono matches(ServerWebExchange exchange) { return isWebSocketUpgrade(exchange.getRequest().getHeaders()) ? match() : notMatch(); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/ui/WebSocketUtils.java ================================================ package run.halo.app.infra.ui; import java.util.Objects; import org.springframework.http.HttpHeaders; public enum WebSocketUtils { ; public static boolean isWebSocketUpgrade(HttpHeaders headers) { // See io.netty.handler.codec.http.websocketx.extensions.WebSocketExtensionUtil // .isWebsocketUpgrade for more. var upgradeConnection = headers.getConnection().stream().map(String::toLowerCase) .anyMatch(conn -> Objects.equals(conn, "upgrade")); return headers.containsHeader(HttpHeaders.UPGRADE) && upgradeConnection && "websocket".equalsIgnoreCase(headers.getUpgrade()); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/utils/Base62Utils.java ================================================ package run.halo.app.infra.utils; import io.seruco.encoding.base62.Base62; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import org.apache.commons.lang3.StringUtils; /** *

Base62 tool class, which provides the encoding and decoding scheme of base62.

* * @author guqing * @since 2.0.0 */ public class Base62Utils { private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; private static final Base62 INSTANCE = Base62.createInstance(); public static String encode(String source) { return encode(source, DEFAULT_CHARSET); } /** * Base62 encode. * * @param source the encoded base62 string * @param charset the charset default is utf_8 * @return encoded string by base62 */ public static String encode(String source, Charset charset) { return encode(StringUtils.getBytes(source, charset)); } public static String encode(byte[] source) { return new String(INSTANCE.encode(source)); } /** * Base62 decode. * * @param base62Str the Base62 decoded string * @return decoded bytes */ public static byte[] decode(String base62Str) { return decode(StringUtils.getBytes(base62Str, DEFAULT_CHARSET)); } public static byte[] decode(byte[] base62bytes) { return INSTANCE.decode(base62bytes); } public static String decodeToString(String source) { return decodeToString(source, DEFAULT_CHARSET); } public static String decodeToString(String source, Charset charset) { return StringUtils.toEncodedString(decode(source), charset); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/utils/FileNameUtils.java ================================================ package run.halo.app.infra.utils; import com.google.common.io.Files; import java.util.function.Supplier; import java.util.regex.Pattern; import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; public final class FileNameUtils { private FileNameUtils() { } /** * Check whether the file name has an extension. * * @param filename is name of file. * @return True if file name has extension, otherwise false. */ public static boolean hasFileExtension(String filename) { if (filename == null || filename.isEmpty()) { return false; } var extensionRegex = ".*\\.[a-zA-Z0-9]+$"; return Pattern.matches(extensionRegex, filename); } public static String removeFileExtension(String filename, boolean removeAllExtensions) { if (filename == null || filename.isEmpty()) { return filename; } var extPattern = "(? * Case 1: halo.run -> halo-xyz.run * Case 2: .run -> xyz.run * Case 3: halo -> halo-xyz * * * @param filename is name of file. * @param length is for generating random string with specific length. * @return File name with random string. */ public static String randomFileName(String filename, int length) { return renameFilename( filename, () -> RandomStringUtils.secure().nextAlphabetic(length), false ); } public static String renameFilename( String filename, Supplier renameSupplier, boolean excludeBasename) { var nameWithoutExt = Files.getNameWithoutExtension(filename); var ext = Files.getFileExtension(filename); var rename = renameSupplier.get(); if (StringUtils.isBlank(nameWithoutExt)) { return rename + "." + ext; } if (StringUtils.isBlank(ext)) { if (excludeBasename) { return rename; } return nameWithoutExt + "-" + rename; } if (excludeBasename) { return rename + "." + ext; } return nameWithoutExt + "-" + rename + "." + ext; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/utils/FileUtils.java ================================================ package run.halo.app.infra.utils; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; import static org.springframework.core.io.buffer.DataBufferUtils.subscriberInputStream; import static org.springframework.util.FileSystemUtils.deleteRecursively; import java.io.Closeable; import java.io.IOException; import java.nio.file.CopyOption; import java.nio.file.DirectoryNotEmptyException; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; import java.util.Set; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.Nullable; import org.reactivestreams.Publisher; import org.springframework.core.io.Resource; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.lang.NonNull; import org.springframework.util.AntPathMatcher; import org.springframework.util.Assert; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; import run.halo.app.infra.exception.AccessDeniedException; /** * @author guqing * @since 2.0.0 */ @Slf4j public abstract class FileUtils { private FileUtils() { } /** * Unzip the given content to target path. Please note that no default scheduler will be used. * * @param content the zip content * @param targetPath the target path * @return a Mono signaling when unzip is complete */ public static Mono unzip(Publisher content, @NonNull Path targetPath) { return unzip(content, targetPath, null); } /** * Unzip the given content to target path. * * @param content the zip content * @param targetPath the target path * @param scheduler the scheduler * @return a Mono signaling when unzip is complete */ public static Mono unzip( Publisher content, @NonNull Path targetPath, @Nullable Scheduler scheduler ) { var unzip = Mono.fromCallable(() -> { try (var is = subscriberInputStream(content, 1); var zis = new ZipInputStream(is)) { log.debug("Unzipping to target path: {}", targetPath); unzip(zis, targetPath); log.debug("Unzipped to target path: {}", targetPath); } return null; }).then(); if (scheduler != null) { return unzip.subscribeOn(scheduler); } return unzip; } public static void unzip(@NonNull ZipInputStream zis, @NonNull Path targetPath) throws IOException { // 1. unzip file to folder // 2. return the folder path Assert.notNull(zis, "Zip input stream must not be null"); Assert.notNull(targetPath, "Target path must not be null"); // Create path if absent createIfAbsent(targetPath); // Folder must be empty ensureEmpty(targetPath); ZipEntry zipEntry = zis.getNextEntry(); while (zipEntry != null) { // Resolve the entry path Path entryPath = targetPath.resolve(zipEntry.getName()); checkDirectoryTraversal(targetPath, entryPath); if (Files.notExists(entryPath.getParent())) { Files.createDirectories(entryPath.getParent()); } if (zipEntry.isDirectory()) { // Create directory Files.createDirectory(entryPath); } else { // Copy file Files.copy(zis, entryPath); } zipEntry = zis.getNextEntry(); } } public static void zip(Path sourcePath, Path targetPath) throws IOException { try (var zos = new ZipOutputStream(Files.newOutputStream(targetPath))) { Files.walkFileTree(sourcePath, new SimpleFileVisitor<>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { checkDirectoryTraversal(sourcePath, file); var relativePath = sourcePath.relativize(file); var entry = new ZipEntry(relativePath.toString()); zos.putNextEntry(entry); Files.copy(file, zos); zos.closeEntry(); return super.visitFile(file, attrs); } }); } } public static void jar(Path sourcePath, Path targetPath) throws IOException { try (var jos = new JarOutputStream(Files.newOutputStream(targetPath))) { Files.walkFileTree(sourcePath, new SimpleFileVisitor<>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { checkDirectoryTraversal(sourcePath, file); var relativePath = sourcePath.relativize(file); var entry = new JarEntry(relativePath.toString()); jos.putNextEntry(entry); Files.copy(file, jos); jos.closeEntry(); return super.visitFile(file, attrs); } }); } } /** * Creates directories if absent. * * @param path path must not be null * @throws IOException io exception */ public static void createIfAbsent(@NonNull Path path) throws IOException { Assert.notNull(path, "Path must not be null"); if (Files.notExists(path)) { // Create directories Files.createDirectories(path); log.debug("Created directory: [{}]", path); } } /** * The given path must be empty. * * @param path path must not be null * @throws IOException io exception */ public static void ensureEmpty(@NonNull Path path) throws IOException { if (!isEmpty(path)) { throw new DirectoryNotEmptyException("Target directory: " + path + " was not empty"); } } /** * Checks if the given path is empty. * * @param path path must not be null * @return true if the given path is empty; false otherwise * @throws IOException io exception */ public static boolean isEmpty(@NonNull Path path) throws IOException { Assert.notNull(path, "Path must not be null"); if (!Files.isDirectory(path) || Files.notExists(path)) { return true; } try (Stream pathStream = Files.list(path)) { return pathStream.findAny().isEmpty(); } } public static void closeQuietly(final Closeable closeable) { closeQuietly(closeable, null); } /** * Closes the given {@link Closeable} as a null-safe operation while consuming IOException by * the given {@code consumer}. * * @param closeable The resource to close, may be null. * @param consumer Consumes the IOException thrown by {@link Closeable#close()}. */ public static void closeQuietly(final Closeable closeable, final Consumer consumer) { if (closeable != null) { try { closeable.close(); } catch (IOException e) { if (consumer != null) { consumer.accept(e); } } } } /** * Checks directory traversal vulnerability. * * @param parentPath parent path must not be null. * @param pathToCheck path to check must not be null */ public static void checkDirectoryTraversal(@NonNull Path parentPath, @NonNull Path pathToCheck) { Assert.notNull(parentPath, "Parent path must not be null"); Assert.notNull(pathToCheck, "Path to check must not be null"); if (pathToCheck.normalize().startsWith(parentPath)) { return; } throw new AccessDeniedException("Directory traversal detected: " + pathToCheck, "problemDetail.directoryTraversal", new Object[] {parentPath, pathToCheck}); } /** * Checks directory traversal vulnerability. * * @param parentPath parent path must not be null. * @param pathToCheck path to check must not be null */ public static void checkDirectoryTraversal(@NonNull String parentPath, @NonNull String pathToCheck) { checkDirectoryTraversal(Paths.get(parentPath), Paths.get(pathToCheck)); } /** * Checks directory traversal vulnerability. * * @param parentPath parent path must not be null. * @param pathToCheck path to check must not be null */ public static void checkDirectoryTraversal(@NonNull Path parentPath, @NonNull String pathToCheck) { checkDirectoryTraversal(parentPath, Paths.get(pathToCheck)); } /** * Delete folder recursively without exception throwing. * * @param root the root File to delete */ public static void deleteRecursivelyAndSilently(Path root) { try { var deleted = deleteRecursively(root); if (log.isDebugEnabled()) { log.debug("Delete {} result: {}", root, deleted); } } catch (IOException ignored) { // Ignore this error } } public static Mono deleteRecursivelyAndSilently( Path root, @Nullable Scheduler scheduler ) { var delete = Mono.fromSupplier(() -> { try { return deleteRecursively(root); } catch (IOException ignored) { return false; } }); if (scheduler != null) { return delete.subscribeOn(scheduler); } return delete; } public static Mono deleteFileSilently(Path file) { return deleteFileSilently(file, Schedulers.boundedElastic()); } public static Mono deleteFileSilently(Path file, Scheduler scheduler) { return Mono.fromSupplier( () -> { if (file == null || !Files.isRegularFile(file)) { return false; } try { return Files.deleteIfExists(file); } catch (IOException ignored) { return false; } }) .subscribeOn(scheduler); } public static void copyResource(Resource resource, Path path) { try (var inputStream = resource.getInputStream()) { Files.copy(inputStream, path, REPLACE_EXISTING); } catch (IOException e) { throw new RuntimeException(e); } } public static void copy(Path source, Path dest, CopyOption... options) { try { Files.copy(source, dest, options); } catch (IOException e) { throw new RuntimeException(e); } } public static void copyRecursively(Path src, Path target, Set excludes) throws IOException { var pathMatcher = new AntPathMatcher(); Predicate shouldExclude = path -> excludes.stream() .anyMatch(pattern -> pathMatcher.match(pattern, path.toString())); Files.walkFileTree(src, new SimpleFileVisitor<>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { if (!shouldExclude.test(src.relativize(file))) { Files.copy(file, target.resolve(src.relativize(file)), REPLACE_EXISTING); } return super.visitFile(file, attrs); } @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { if (shouldExclude.test(src.relativize(dir))) { return FileVisitResult.SKIP_SUBTREE; } Files.createDirectories(target.resolve(src.relativize(dir))); return super.preVisitDirectory(dir, attrs); } }); } public static Mono createTempDir(String prefix, @Nullable Scheduler scheduler) { var createTempDir = Mono.fromCallable(() -> Files.createTempDirectory(prefix)); if (scheduler != null) { return createTempDir.subscribeOn(scheduler); } return createTempDir; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/utils/HaloUtils.java ================================================ package run.halo.app.infra.utils; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.ZoneId; import java.util.function.UnaryOperator; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.core.io.ClassPathResource; import org.springframework.http.HttpHeaders; import org.springframework.util.Assert; import org.springframework.util.StreamUtils; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.util.InvalidUrlException; import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder.ParserType; import org.springframework.web.util.UriUtils; import run.halo.app.theme.router.ModelConst; /** * Halo utilities. * * @author guqing * @since 2.0.0 */ @Slf4j @UtilityClass public class HaloUtils { /** * Check if the request is an XMLHttpRequest. */ public static boolean isXhr(HttpHeaders headers) { return headers.getOrEmpty("X-Requested-With").contains("XMLHttpRequest"); } /** *

Read the file under the classpath as a string.

* * @param location the file location relative to classpath * @return file content */ public static String readClassPathResourceAsString(String location) { ClassPathResource classPathResource = new ClassPathResource(location); try (InputStream inputStream = classPathResource.getInputStream()) { return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); } catch (IOException e) { throw new IllegalArgumentException( String.format("Failed to read class path file as string from location [%s]", location), e); } } /** * Gets user-agent from server request. * * @param request server request * @return user-agent string if found, otherwise "unknown" */ public static String userAgentFrom(ServerRequest request) { HttpHeaders httpHeaders = request.headers().asHttpHeaders(); // https://en.wikipedia.org/wiki/User_agent String userAgent = httpHeaders.getFirst(HttpHeaders.USER_AGENT); if (StringUtils.isBlank(userAgent)) { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA userAgent = httpHeaders.getFirst("Sec-CH-UA"); } return StringUtils.defaultIfBlank(userAgent, "unknown"); } public static String getDayText(Instant instant) { Assert.notNull(instant, "Instant must not be null"); int dayValue = instant.atZone(ZoneId.systemDefault()).getDayOfMonth(); return StringUtils.leftPad(String.valueOf(dayValue), 2, '0'); } public static String getMonthText(Instant instant) { Assert.notNull(instant, "Instant must not be null"); int monthValue = instant.atZone(ZoneId.systemDefault()).getMonthValue(); return StringUtils.leftPad(String.valueOf(monthValue), 2, '0'); } public static String getYearText(Instant instant) { Assert.notNull(instant, "Instant must not be null"); return String.valueOf(instant.atZone(ZoneId.systemDefault()).getYear()); } /** * Mark the response as no cache. * * @return the server request operator */ public static UnaryOperator noCache() { return request -> { request.exchange().getAttributes().put(ModelConst.NO_CACHE, true); return request; }; } /** * Safely convert string to URI. This method will assume the input string is already encoded. * If failed, it will try to encode the string. * * @param uri the uri string * @return the uri */ public static URI safeToUri(String uri) { // try to decode first var decodedUri = UriUtils.decode(uri, StandardCharsets.UTF_8); UriComponentsBuilder uriBuilder; try { uriBuilder = UriComponentsBuilder.fromUriString(decodedUri); } catch (InvalidUrlException e) { uriBuilder = UriComponentsBuilder.fromUriString(decodedUri, ParserType.WHAT_WG); } return uriBuilder .build(false) .encode() .toUri(); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/utils/IpAddressUtils.java ================================================ package run.halo.app.infra.utils; import lombok.extern.slf4j.Slf4j; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.server.ServerRequest; /** * Ip address utils. * Code from internet. */ @Slf4j public class IpAddressUtils { public static final String UNKNOWN = "unknown"; private static final String[] IP_HEADER_NAMES = { "X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP", "CF-Connecting-IP", "HTTP_X_FORWARDED_FOR", "HTTP_X_FORWARDED", "HTTP_X_CLUSTER_CLIENT_IP", "HTTP_CLIENT_IP", "HTTP_FORWARDED_FOR", "HTTP_FORWARDED", "HTTP_VIA", "REMOTE_ADDR", }; /** * Gets the IP address from request. * * @param request is server http request * @return IP address if found, otherwise {@link #UNKNOWN}. */ public static String getClientIp(ServerHttpRequest request) { for (String header : IP_HEADER_NAMES) { String ipList = request.getHeaders().getFirst(header); if (StringUtils.hasText(ipList) && !UNKNOWN.equalsIgnoreCase(ipList)) { String[] ips = ipList.trim().split("[,;]"); for (String ip : ips) { if (StringUtils.hasText(ip) && !UNKNOWN.equalsIgnoreCase(ip)) { return ip; } } } } var remoteAddress = request.getRemoteAddress(); return remoteAddress == null || remoteAddress.isUnresolved() ? UNKNOWN : remoteAddress.getAddress().getHostAddress(); } /** * Gets the ip address from request. * * @param request http request * @return ip address if found, otherwise {@link #UNKNOWN}. */ public static String getIpAddress(ServerRequest request) { try { return getClientIp(request.exchange().getRequest()); } catch (Exception e) { log.warn("Failed to obtain client IP, and fallback to unknown.", e); return UNKNOWN; } } } ================================================ FILE: application/src/main/java/run/halo/app/infra/utils/ReactiveUtils.java ================================================ package run.halo.app.infra.utils; import java.time.Duration; import org.jspecify.annotations.Nullable; import org.springframework.lang.NonNull; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.context.Context; import reactor.util.context.ContextView; /** * Utility class for reactive. * * @author johnniang * @since 2.20.0 */ public enum ReactiveUtils { ; /** * Default timeout for blocking operation. */ public static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(10); /** * Resolve reactive value by blocking operation. * * @param value the normal value or reactive value * @return the resolved value */ @Nullable public static Object blockReactiveValue(@Nullable Object value) { return blockReactiveValue(value, DEFAULT_TIMEOUT); } /** * Resolve reactive value by blocking operation. * * @param value the normal value or reactive value * @return the resolved value */ @Nullable public static Object blockReactiveValue(@Nullable Object value, ContextView contextView) { return blockReactiveValue(value, contextView, DEFAULT_TIMEOUT); } /** * Resolve reactive value by blocking operation. * * @param value the normal value or reactive value * @param timeout the timeout of blocking operation * @return the resolved value */ @Nullable public static Object blockReactiveValue( @Nullable Object value, @Nullable ContextView contextView, @NonNull Duration timeout ) { if (value == null) { return null; } if (contextView == null) { contextView = Context.empty(); } Class clazz = value.getClass(); if (Mono.class.isAssignableFrom(clazz)) { return ((Mono) value).contextWrite(contextView).blockOptional(timeout).orElse(null); } if (Flux.class.isAssignableFrom(clazz)) { return ((Flux) value).contextWrite(contextView).collectList().block(timeout); } return value; } /** * Resolve reactive value by blocking operation. * * @param value the normal value or reactive value * @param timeout the timeout of blocking operation * @return the resolved value */ @Nullable public static Object blockReactiveValue(@Nullable Object value, @NonNull Duration timeout) { return blockReactiveValue(value, null, timeout); } /** * Check if the class is a reactive type. * * @param clazz the class to check * @return true if the class is a reactive type, false otherwise */ public static boolean isReactiveType(@NonNull Class clazz) { return Mono.class.isAssignableFrom(clazz) || Flux.class.isAssignableFrom(clazz); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/utils/SettingUtils.java ================================================ package run.halo.app.infra.utils; import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; import com.fasterxml.jackson.core.JsonProcessingException; import com.github.fge.jsonpatch.JsonPatchException; import com.github.fge.jsonpatch.mergepatch.JsonMergePatch; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; import org.springframework.lang.NonNull; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import run.halo.app.core.extension.Setting; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler.Result; import run.halo.app.extension.controller.RequeueException; import tools.jackson.core.JacksonException; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.node.ObjectNode; public enum SettingUtils { ; private static final JsonMapper MAPPER = JsonMapper.builder() .changeDefaultPropertyInclusion(v -> v.withValueInclusion(NON_NULL).withContentInclusion(NON_NULL) ) .build(); private static final String VALUE_FIELD = "value"; private static final String NAME_FIELD = "name"; /** * Read setting default value from {@link Setting} forms. * * @param setting {@link Setting} extension * @return a map of setting default value */ @NonNull public static Map settingDefinedDefaultValueMap(Setting setting) { List forms = setting.getSpec().getForms(); if (CollectionUtils.isEmpty(forms)) { return Map.of(); } Map data = new LinkedHashMap<>(); for (Setting.SettingForm form : forms) { String group = form.getGroup(); Map groupValue = form.getFormSchema().stream() .map(o -> MAPPER.convertValue(o, JsonNode.class)) .filter(jsonNode -> jsonNode.isObject() && jsonNode.has(NAME_FIELD) && jsonNode.has(VALUE_FIELD)) .map(jsonNode -> { String name = jsonNode.get(NAME_FIELD).asString(); JsonNode value = jsonNode.get(VALUE_FIELD); return Map.entry(name, value); }) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); data.put(group, MAPPER.writeValueAsString(groupValue)); } return data; } /** * Create or update config map by provided setting name and configMapName. * * @param client extension client * @param settingName a name for {@link Setting} * @param configMapName a name for {@link ConfigMap} */ public static void createOrUpdateConfigMap(ExtensionClient client, String settingName, String configMapName) { Assert.notNull(client, "Extension client must not be null"); Assert.hasText(settingName, "Setting name must not be blank"); Assert.hasText(configMapName, "Config map name must not be blank"); client.fetch(Setting.class, settingName) .ifPresentOrElse(setting -> { final var source = SettingUtils.settingDefinedDefaultValueMap(setting); client.fetch(ConfigMap.class, configMapName) .ifPresentOrElse(configMap -> { Map modified = Objects.requireNonNullElse(configMap.getData(), Map.of()); var copy = new HashMap<>(modified); var merged = SettingUtils.mergePatch(modified, source); configMap.setData(merged); if (!Objects.equals(copy, configMap.getData())) { client.update(configMap); } }, () -> { ConfigMap configMap = new ConfigMap(); configMap.setMetadata(new Metadata()); configMap.getMetadata().setName(configMapName); configMap.setData(source); client.create(configMap); }); }, () -> { // requeue if setting was not found throw new RequeueException(Result.requeue(null), "Theme setting %s was not found".formatted(settingName) ); }); } public static ConfigMap populateDefaultConfig(Setting setting, String configMapName) { var data = settingDefinedDefaultValueMap(setting); ConfigMap configMap = new ConfigMap(); configMap.setMetadata(new Metadata()); configMap.getMetadata().setName(configMapName); configMap.setData(data); return configMap; } /** * Construct a JsonMergePatch from a difference between two Maps and apply patch to * {@code source}. * * @param modified the modified object * @param source the source object * @return patched map object */ public static Map mergePatch(Map modified, Map source) { var modifiedJson = mapToJsonNode(modified); // original var sourceJson = mapToJsonNode(source); try { // patch var jsonMergePatch = JsonMergePatch.fromJson(modifiedJson); // apply patch to original var patchedNode = jsonMergePatch.apply(sourceJson); return jsonNodeToStringMap(patchedNode); } catch (JsonPatchException e) { throw new JsonParseException(e); } } /** * Convert {@link Setting} related configMap data to JsonNode. * * @param configMap {@link ConfigMap} instance * @return JsonNode */ public static ObjectNode settingConfigToJson(ConfigMap configMap) { if (configMap.getData() == null) { return MAPPER.createObjectNode(); } return mapToObjectNode(configMap.getData()); } /** * Convert the result of {@link #settingConfigToJson(ConfigMap)} in reverse to Map. * * @param node JsonNode object * @return {@link ConfigMap#getData()} */ public static Map settingConfigJsonToMap(ObjectNode node) { return jsonNodeToStringMap(node); } /** * Convert {@code Map} to * {@link com.fasterxml.jackson.databind.node.ObjectNode}. * * @param map source map * @return ObjectNode */ private static com.fasterxml.jackson.databind.node.ObjectNode mapToJsonNode( Map map) { var objectNode = JsonUtils.mapper().createObjectNode(); map.forEach((k, v) -> { if (v == null) { objectNode.putNull(k); return; } try { var value = JsonUtils.mapper().readTree(v); objectNode.set(k, value); } catch (JsonProcessingException ignored) { // ignore exception and put as text objectNode.put(k, v); } }); return objectNode; } private static ObjectNode mapToObjectNode(Map map) { var objectNode = MAPPER.createObjectNode(); map.forEach((k, v) -> { if (v == null) { objectNode.putNull(k); return; } try { var value = MAPPER.readTree(v); objectNode.set(k, value); } catch (JacksonException ignored) { // ignore exception and put as text objectNode.put(k, v); } }); return objectNode; } private static Map jsonNodeToStringMap( com.fasterxml.jackson.databind.JsonNode node ) { Map stringMap = new LinkedHashMap<>(); node.forEachEntry((k, v) -> { if (v == null || v.isNull() || v.isMissingNode()) { stringMap.put(k, null); return; } if (v.isTextual()) { stringMap.put(k, v.asText()); return; } if (v.isContainerNode()) { stringMap.put(k, v.toString()); return; } stringMap.put(k, v.asText()); }); return stringMap; } private static Map jsonNodeToStringMap(JsonNode node) { Map stringMap = new LinkedHashMap<>(); node.forEachEntry((k, v) -> { if (v == null || v.isNull() || v.isMissingNode()) { stringMap.put(k, null); return; } if (v.isString()) { stringMap.put(k, v.asString()); return; } if (v.isContainer()) { stringMap.put(k, v.toString()); return; } stringMap.put(k, v.asString()); }); return stringMap; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/utils/SortUtils.java ================================================ package run.halo.app.infra.utils; import java.util.List; import lombok.experimental.UtilityClass; import org.springframework.data.domain.Sort; import org.springframework.lang.NonNull; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; @UtilityClass public class SortUtils { static final String delimiter = ","; /** *

Resolve from direction params, e.g. "name,asc" or "name"

* * @param directionParams direction params * @return sort object */ public static Sort resolve(List directionParams) { if (CollectionUtils.isEmpty(directionParams)) { return Sort.unsorted(); } Sort.Order[] orders = new Sort.Order[directionParams.size()]; for (int i = 0; i < directionParams.size(); i++) { String[] parts = directionParams.get(i).split(delimiter); if (parts.length == 1) { orders[i] = new Sort.Order(Sort.Direction.ASC, parts[0]); } else { orders[i] = new Sort.Order(toDirection(parts[1]), parts[0]); } } return Sort.by(orders); } private static Sort.Direction toDirection(@NonNull String direction) { Assert.notNull(direction, "Direction must not be null"); if (direction.contains(" ")) { throw new IllegalArgumentException("Direction must not contain whitespace"); } return Sort.Direction.fromString(direction); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/utils/SystemConfigUtils.java ================================================ package run.halo.app.infra.utils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.hash.Hashing; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.Optional; import lombok.extern.slf4j.Slf4j; import org.springframework.util.CollectionUtils; import run.halo.app.core.extension.content.Constant; import run.halo.app.extension.ConfigMap; /** * Utility class for merging configuration maps containing JSON strings. * * @author johnniang * @since 2.22.0 */ @Slf4j public enum SystemConfigUtils { ; private static final ObjectMapper mapper = JsonUtils.mapper(); private static final String DATA_SNAPSHOT_ANNO = "halo.run/data-snapshot"; /** * Merge two configuration maps containing JSON strings. * * @param defaultMap the default configuration map * @param overrideMap the override configuration map * @return the merged configuration map * @throws JsonProcessingException if JSON processing fails */ public static Map mergeMap( Map defaultMap, Map overrideMap ) throws JsonProcessingException { if (CollectionUtils.isEmpty(defaultMap)) { return overrideMap; } if (CollectionUtils.isEmpty(overrideMap)) { return defaultMap; } var result = new HashMap<>(defaultMap); for (Map.Entry entry : overrideMap.entrySet()) { var group = entry.getKey(); var overrideJson = entry.getValue(); if (result.containsKey(group)) { // Perform a deep merge of the two JSON strings String defaultJson = result.get(group); result.put(group, mergeJsonStrings(defaultJson, overrideJson)); } else { // Key only exists in override map result.put(group, overrideJson); } } return result; } /** * Compute the merged ConfigMap from default and override ConfigMaps. * * @param defaultConfigMap the default ConfigMap * @param overrideConfigMap the override ConfigMap * @return the merged ConfigMap * @throws JsonProcessingException if JSON processing fails */ public static ConfigMap mergeConfigMap( ConfigMap defaultConfigMap, ConfigMap overrideConfigMap ) throws JsonProcessingException { var mergedData = mergeMap( defaultConfigMap.getData() != null ? defaultConfigMap.getData() : Map.of(), overrideConfigMap.getData() != null ? overrideConfigMap.getData() : Map.of() ); var merged = new ConfigMap(); merged.setMetadata(overrideConfigMap.getMetadata()); merged.setData(mergedData); return merged; } private static String mergeJsonStrings(String mainJson, String updateJson) throws JsonProcessingException { var mainNode = mapper.readTree(mainJson); var updateNode = mapper.readTree(updateJson); // This performs a deep merge into the mainNode var mergedNode = deepMerge(mainNode, updateNode); return mapper.writeValueAsString(mergedNode); } private static JsonNode deepMerge(JsonNode mainNode, JsonNode updateNode) { // If they aren't both objects, the update simply replaces the main if (!mainNode.isObject() || !updateNode.isObject()) { return updateNode; } var mainObject = (ObjectNode) mainNode; updateNode.properties().forEach(entry -> { String key = entry.getKey(); JsonNode value = entry.getValue(); if (mainObject.has(key)) { mainObject.set(key, deepMerge(mainObject.get(key), value)); } else { mainObject.set(key, value); } }); return mainObject; } /** * Populate checksum annotation in the ConfigMap. * * @param configMap the ConfigMap to populate checksum for * @return true if the checksum was updated, false otherwise */ public static boolean populateChecksum(ConfigMap configMap) { var toHash = Optional.ofNullable(configMap.getData()) .map(Objects::toString) .orElse(""); var checksum = Hashing.sha256().hashString(toHash, StandardCharsets.UTF_8) .toString(); var metadata = configMap.getMetadata(); var notChanged = Optional.ofNullable(metadata.getAnnotations()) .map(annotations -> annotations.get(Constant.CHECKSUM_CONFIG_ANNO)) .stream() .anyMatch(existingChecksum -> Objects.equals(checksum, existingChecksum)); if (notChanged) { log.debug("ConfigMap '{}' has not changed.", configMap.getMetadata().getName()); return false; } log.debug("ConfigMap '{}' has changed, updating checksum {}.", configMap.getMetadata().getName(), checksum); if (metadata.getAnnotations() == null) { metadata.setAnnotations(new HashMap<>()); } metadata.getAnnotations().put(Constant.CHECKSUM_CONFIG_ANNO, checksum); return true; } /** * Update data snapshot annotation in the ConfigMap. * * @param configMap the ConfigMap */ public static void updateDataSnapshot(ConfigMap configMap) { Optional.ofNullable(configMap.getData()) .map(JsonUtils::objectToJson) .ifPresent(dataJson -> { var metadata = configMap.getMetadata(); if (metadata.getAnnotations() == null) { metadata.setAnnotations(new HashMap<>()); } metadata.getAnnotations().put(DATA_SNAPSHOT_ANNO, dataJson); }); } /** * Get data snapshot from ConfigMap annotations. * * @param configMap the ConfigMap * @return the data snapshot */ public static Map getDataSnapshot(ConfigMap configMap) { return Optional.ofNullable(configMap.getMetadata().getAnnotations()) .map(annotations -> annotations.get(DATA_SNAPSHOT_ANNO)) .map(dataJson -> JsonUtils.jsonToObject( dataJson, new TypeReference>() { } )) .orElse(Map.of()); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/utils/VersionUtils.java ================================================ package run.halo.app.infra.utils; import com.github.zafarkhaja.semver.Version; import com.github.zafarkhaja.semver.expr.Expression; import lombok.experimental.UtilityClass; import org.apache.commons.lang3.StringUtils; import org.springframework.web.server.ServerWebInputException; @UtilityClass public class VersionUtils { /** * Check if this "requires" param satisfies for a given (system) version. * * @param version the version to check * @return true if version satisfies the "requires" or if requires was left blank */ public static boolean satisfiesRequires(String version, String requires) { String requiresVersion = StringUtils.trim(requires); // an exact version x.y.z will implicitly mean the same as >=x.y.z if (requiresVersion.matches("^\\d+\\.\\d+\\.\\d+$")) { // If exact versions are not allowed in requires, rewrite to >= expression requiresVersion = ">=" + requiresVersion; } return version.equals("0.0.0") || checkVersionConstraint(version, requiresVersion); } /** * Checks if a version satisfies the specified SemVer {@link Expression} string. * If the constraint is empty or null then the method returns true. * Constraint examples: {@code >2.0.0} (simple), {@code ">=1.4.0 & <1.6.0"} (range). * See * semver-expressions-api-ranges for more info. * * @param version the version to check * @param constraint the SemVer Expression string * @return true if version satisfies the constraint or if constraint was left blank */ public static boolean checkVersionConstraint(String version, String constraint) { try { return StringUtils.isBlank(constraint) || "*".equals(constraint) || Version.parse(version).satisfies(constraint); } catch (Exception e) { throw new ServerWebInputException("Illegal requires version expression.", null, e); } } } ================================================ FILE: application/src/main/java/run/halo/app/infra/utils/YamlUnstructuredLoader.java ================================================ package run.halo.app.infra.utils; import java.util.ArrayList; import java.util.List; import org.springframework.beans.factory.config.YamlProcessor; import org.springframework.core.io.Resource; import run.halo.app.extension.Unstructured; /** *

Process the content in yaml that matches the {@link DocumentMatcher} and convert it to an * unstructured list.

*

Multiple resources can be processed at one time.

*

The following specified key must be included before the resource can be processed: *

 *     apiVersion
 *     kind
 *     metadata.name
 * 
* Otherwise, skip it and continue to read the next resource. *

* * @author guqing * @since 2.0.0 */ public class YamlUnstructuredLoader extends YamlProcessor { private static final DocumentMatcher DEFAULT_UNSTRUCTURED_MATCHER = properties -> { if (properties.containsKey("apiVersion") && properties.containsKey("kind") && (properties.containsKey("metadata.name") || properties.containsKey("metadata.generateName"))) { return YamlProcessor.MatchStatus.FOUND; } return MatchStatus.NOT_FOUND; }; public YamlUnstructuredLoader(Resource... resources) { setResources(resources); setDocumentMatchers(DEFAULT_UNSTRUCTURED_MATCHER); } public List load() { List unstructuredList = new ArrayList<>(); process((properties, map) -> { Unstructured unstructured = JsonUtils.mapToObject(map, Unstructured.class); unstructuredList.add(unstructured); }); return unstructuredList; } } ================================================ FILE: application/src/main/java/run/halo/app/infra/webfilter/AdditionalWebFilterChainProxy.java ================================================ package run.halo.app.infra.webfilter; import lombok.Setter; import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.security.web.server.WebFilterChainProxy; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.security.AdditionalWebFilter; public class AdditionalWebFilterChainProxy implements WebFilter { private final ExtensionGetter extensionGetter; @Setter private WebFilterChainProxy.WebFilterChainDecorator filterChainDecorator; public AdditionalWebFilterChainProxy(ExtensionGetter extensionGetter) { this.extensionGetter = extensionGetter; this.filterChainDecorator = new WebFilterChainProxy.DefaultWebFilterChainDecorator(); } @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { return extensionGetter.getEnabledExtensions(AdditionalWebFilter.class) .sort(AnnotationAwareOrderComparator.INSTANCE) .cast(WebFilter.class) .collectList() .map(filters -> filterChainDecorator.decorate(chain, filters)) .flatMap(decoratedChain -> decoratedChain.filter(exchange)); } } ================================================ FILE: application/src/main/java/run/halo/app/infra/webfilter/LocaleChangeWebFilter.java ================================================ package run.halo.app.infra.webfilter; import static run.halo.app.theme.ThemeLocaleContextResolver.LANGUAGE_COOKIE_NAME; import java.util.Locale; import java.util.Objects; import java.util.Set; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; import org.springframework.lang.NonNull; import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; import run.halo.app.theme.ThemeLocaleContextResolver; import run.halo.app.theme.UserLocaleRequestAttributeWriteFilter; /** * {@link UserLocaleRequestAttributeWriteFilter} is before {@link LocaleChangeWebFilter} to * obtain the locale. */ @Component @Order(Ordered.HIGHEST_PRECEDENCE + 1) public class LocaleChangeWebFilter implements WebFilter { private final ServerWebExchangeMatcher matcher; private final ThemeLocaleContextResolver themeLocaleContextResolver; public LocaleChangeWebFilter(ThemeLocaleContextResolver themeLocaleContextResolver) { this.themeLocaleContextResolver = themeLocaleContextResolver; var pathMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/**"); var textHtmlMatcher = new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML); textHtmlMatcher.setIgnoredMediaTypes(Set.of(MediaType.ALL)); matcher = new AndServerWebExchangeMatcher(pathMatcher, textHtmlMatcher); } @Override @NonNull public Mono filter(ServerWebExchange exchange, @NonNull WebFilterChain chain) { return matcher.matches(exchange) .filter(MatchResult::isMatch) .doOnNext(result -> { var localeContext = themeLocaleContextResolver.resolveLocaleContext(exchange); var locale = localeContext.getLocale(); if (locale != null) { setLanguageCookieIfAbsent(exchange, locale); } }) .then(Mono.defer(() -> chain.filter(exchange))); } void setLanguageCookieIfAbsent(ServerWebExchange exchange, Locale locale) { var languageCookie = exchange.getRequest().getCookies().getFirst(LANGUAGE_COOKIE_NAME); if (languageCookie != null && Objects.equals(languageCookie.getValue(), locale.toLanguageTag())) { return; } var cookie = ResponseCookie.from(LANGUAGE_COOKIE_NAME, locale.toLanguageTag()) .path("/") .secure("https".equalsIgnoreCase(exchange.getRequest().getURI().getScheme())) .sameSite("Lax") .build(); exchange.getResponse().getCookies().set(LANGUAGE_COOKIE_NAME, cookie); } } ================================================ FILE: application/src/main/java/run/halo/app/migration/BackupFile.java ================================================ package run.halo.app.migration; import com.fasterxml.jackson.annotation.JsonIgnore; import java.nio.file.Path; import java.time.Instant; import lombok.Data; /** * Backup file. * * @author johnniang */ @Data public class BackupFile { @JsonIgnore private Path path; /** * Filename of backup file. */ private String filename; /** * Size of backup file. */ private long size; /** * Last modified time of backup file. */ private Instant lastModifiedTime; } ================================================ FILE: application/src/main/java/run/halo/app/migration/BackupReconciler.java ================================================ package run.halo.app.migration; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.isDeleted; import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import static run.halo.app.extension.controller.Reconciler.Result.doNotRetry; import static run.halo.app.migration.Constant.HOUSE_KEEPER_FINALIZER; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.Set; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import reactor.core.Exceptions; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler.Request; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.migration.Backup.Phase; @Slf4j @Component public class BackupReconciler implements Reconciler { private final ExtensionClient client; private final MigrationService migrationService; private Clock clock; public BackupReconciler(ExtensionClient client, MigrationService migrationService) { this.client = client; this.migrationService = migrationService; clock = Clock.systemDefaultZone(); } /** * Set clock. The method is only for unit test. * * @param clock is new clock */ void setClock(Clock clock) { this.clock = clock; } @Override public Result reconcile(Request request) { return client.fetch(Backup.class, request.name()) .map(backup -> { var metadata = backup.getMetadata(); var status = backup.getStatus(); var spec = backup.getSpec(); if (isDeleted(backup)) { if (removeFinalizers(metadata, Set.of(HOUSE_KEEPER_FINALIZER))) { migrationService.cleanup(backup).block(ReactiveUtils.DEFAULT_TIMEOUT); client.update(backup); } return doNotRetry(); } if (addFinalizers(metadata, Set.of(HOUSE_KEEPER_FINALIZER))) { client.update(backup); } if (Phase.PENDING.equals(status.getPhase())) { // Do backup try { status.setPhase(Phase.RUNNING); status.setStartTimestamp(Instant.now(clock)); updateStatus(request.name(), status); // Long period execution when backing up migrationService.backup(backup).block(Duration.ofMinutes(30)); status.setPhase(Phase.SUCCEEDED); status.setCompletionTimestamp(Instant.now(clock)); updateStatus(request.name(), status); } catch (Throwable t) { var unwrapped = Exceptions.unwrap(t); log.error("Failed to backup", unwrapped); // Only happen when shutting down status.setPhase(Phase.FAILED); if (unwrapped instanceof InterruptedException) { status.setFailureReason("Interrupted"); status.setFailureMessage("The backup process was interrupted."); } else { status.setFailureReason("SystemError"); status.setFailureMessage( "Something went wrong! Error message: " + unwrapped.getMessage()); } updateStatus(request.name(), status); } } // Only happen when failing to update status when interrupted if (Phase.RUNNING.equals(status.getPhase())) { status.setPhase(Phase.FAILED); status.setFailureReason("UnexpectedExit"); status.setFailureMessage("The backup process may exit abnormally."); updateStatus(request.name(), status); } // Check the expires at and requeue if necessary if (isTerminal(status.getPhase())) { var expiresAt = spec.getExpiresAt(); if (expiresAt != null) { var now = Instant.now(clock); if (now.isBefore(expiresAt)) { return new Result(true, Duration.between(now, expiresAt)); } client.delete(backup); } } return doNotRetry(); }).orElseGet(Result::doNotRetry); } private void updateStatus(String name, Backup.Status status) { client.fetch(Backup.class, name) .ifPresent(backup -> { backup.setStatus(status); client.update(backup); }); } private static boolean isTerminal(Phase phase) { return Phase.FAILED.equals(phase) || Phase.SUCCEEDED.equals(phase); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new Backup()) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/migration/MigrationEndpoint.java ================================================ package run.halo.app.migration; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; import java.util.Optional; import java.util.function.Supplier; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.data.util.Optionals; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.FormFieldPart; import org.springframework.http.codec.multipart.Part; import org.springframework.stereotype.Component; import org.springframework.util.MultiValueMap; import org.springframework.util.StreamUtils; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.ReactiveUrlDataBufferFetcher; @Component public class MigrationEndpoint implements CustomEndpoint { private final MigrationService migrationService; private final ReactiveExtensionClient client; private final ReactiveUrlDataBufferFetcher dataBufferFetcher; public MigrationEndpoint(MigrationService migrationService, ReactiveExtensionClient client, ReactiveUrlDataBufferFetcher dataBufferFetcher) { this.migrationService = migrationService; this.client = client; this.dataBufferFetcher = dataBufferFetcher; } @Override public RouterFunction endpoint() { var tag = "MigrationV1alpha1Console"; return SpringdocRouteBuilder.route() .GET("/backup-files", this::getBackups, builder -> builder.operationId("getBackupFiles") .tag(tag) .description("Get backup files from backup root.") .response(responseBuilder() .implementationArray(BackupFile.class) ) ) .GET("/backups/{name}/files/{filename}", request -> { var name = request.pathVariable("name"); return client.get(Backup.class, name) .flatMap(migrationService::download) .flatMap(backupResource -> ServerResponse.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + backupResource.getFilename() + "\"") .contentType(MediaType.APPLICATION_OCTET_STREAM) .bodyValue(backupResource)); }, builder -> builder .tag(tag) .operationId("DownloadBackups") .parameter(parameterBuilder() .name("name") .description("Backup name.") .required(true) .in(ParameterIn.PATH)) .parameter(parameterBuilder() .name("filename") .description("Backup filename.") .required(true) .in(ParameterIn.PATH)) .build()) .POST("/restorations", request -> request.multipartData() .map(RestoreRequest::new) .flatMap(restoreRequest -> { var content = getContent(restoreRequest) .switchIfEmpty(Mono.error(() -> new ServerWebInputException( "Please upload a file " + "or provide a download link or backup name."))); return migrationService.restore(content); }) .then(Mono.defer( () -> ServerResponse.ok().bodyValue("Restored successfully!") )), builder -> builder .tag(tag) .description("Restore backup by uploading file " + "or providing download link or backup name.") .operationId("RestoreBackup") .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) .schema(schemaBuilder().implementation(RestoreRequest.class)) ) ) .build()) .build(); } private Mono getBackups(ServerRequest request) { var backupFiles = migrationService.getBackupFiles(); return ServerResponse.ok().body(backupFiles, BackupFile.class); } private Flux getContent(RestoreRequest request) { Supplier>> contentFromFilename = () -> request.getFilename().map(filename -> migrationService.getBackupFile(filename) .map(BackupFile::getPath) .flatMapMany( path -> DataBufferUtils.read( path, DefaultDataBufferFactory.sharedInstance, StreamUtils.BUFFER_SIZE))); Supplier>> contentFromDownloadUrl = () -> request.getDownloadUrl() .map(downloadURL -> { try { var url = new URL(downloadURL); return dataBufferFetcher.fetch(url.toURI()); } catch (MalformedURLException e) { return Flux.error(new ServerWebInputException( "Invalid download URL: " + downloadURL)); } catch (URISyntaxException e) { // Should never happen return Flux.error(e); } }); Supplier>> contentFromUpload = () -> request.getFile() .map(Part::content); Supplier>> contentFromBackupName = () -> request.getBackupName() .map(backupName -> client.get(Backup.class, backupName) .flatMap(migrationService::download) .flatMapMany(resource -> DataBufferUtils.read(resource, DefaultDataBufferFactory.sharedInstance, StreamUtils.BUFFER_SIZE))); return Optionals.firstNonEmpty( contentFromUpload, contentFromDownloadUrl, contentFromBackupName, contentFromFilename ) .orElseGet(() -> Flux.error(new ServerWebInputException(""" Please upload a file or provide a download link or backup name or backup filename.\ """))); } @Schema(types = "object") public static class RestoreRequest { private final MultiValueMap multipart; public RestoreRequest(MultiValueMap multipart) { this.multipart = multipart; } @Schema(requiredMode = NOT_REQUIRED, name = "file", description = "Backup file.") public Optional getFile() { var part = multipart.getFirst("file"); if (part instanceof FilePart filePart) { return Optional.of(filePart); } return Optional.empty(); } @Schema(requiredMode = NOT_REQUIRED, name = "filename", description = """ Filename of backup file in backups root.\ """) public Optional getFilename() { var part = multipart.getFirst("filename"); if (part instanceof FormFieldPart filenamePart) { return Optional.of(filenamePart.value()) .filter(StringUtils::hasText); } return Optional.empty(); } @Schema(requiredMode = NOT_REQUIRED, name = "downloadUrl", description = "Remote backup HTTP URL.") public Optional getDownloadUrl() { var part = multipart.getFirst("downloadUrl"); if (part instanceof FormFieldPart downloadUrlPart) { return Optional.of(downloadUrlPart.value()) .filter(StringUtils::hasText); } return Optional.empty(); } @Schema(requiredMode = NOT_REQUIRED, name = "backupName", description = "Backup metadata name.") public Optional getBackupName() { var part = multipart.getFirst("backupName"); if (part instanceof FormFieldPart backupNamePart) { return Optional.of(backupNamePart.value()) .filter(StringUtils::hasText); } return Optional.empty(); } } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion( "console.api." + Constant.GROUP + "/" + Constant.VERSION); } } ================================================ FILE: application/src/main/java/run/halo/app/migration/MigrationService.java ================================================ package run.halo.app.migration; import org.reactivestreams.Publisher; import org.springframework.core.io.Resource; import org.springframework.core.io.buffer.DataBuffer; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public interface MigrationService { Mono backup(Backup backup); Mono download(Backup backup); Mono restore(Publisher content); /** * Clean up backup file. * * @param backup backup detail. * @return void publisher. */ Mono cleanup(Backup backup); /** * Gets backup files. * * @return backup files, sorted by last modified time. */ Flux getBackupFiles(); /** * Get backup file by filename. * * @param filename filename of backup file * @return backup file or empty if file is not found */ Mono getBackupFile(String filename); } ================================================ FILE: application/src/main/java/run/halo/app/migration/impl/MigrationServiceImpl.java ================================================ package run.halo.app.migration.impl; import static java.nio.file.Files.deleteIfExists; import static java.util.Comparator.comparing; import static org.apache.commons.io.FilenameUtils.isExtension; import static org.springframework.util.FileSystemUtils.copyRecursively; import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal; import static run.halo.app.infra.utils.FileUtils.copyRecursively; import static run.halo.app.infra.utils.FileUtils.createTempDir; import static run.halo.app.infra.utils.FileUtils.deleteRecursivelyAndSilently; import static run.halo.app.infra.utils.FileUtils.unzip; import com.fasterxml.jackson.core.util.MinimalPrettyPrinter; import com.fasterxml.jackson.databind.MappingIterator; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Locale; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Predicate; import java.util.stream.BaseStream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.reactivestreams.Publisher; import org.springframework.beans.factory.InitializingBean; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.ReactiveTransactionManager; import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.reactive.TransactionalOperator; import org.springframework.transaction.support.DefaultTransactionDefinition; import org.springframework.util.StringUtils; import org.springframework.web.server.ServerWebInputException; import reactor.core.Exceptions; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; import run.halo.app.extension.ExtensionStoreUtil; import run.halo.app.extension.Scheme; import run.halo.app.extension.store.ExtensionStore; import run.halo.app.extension.store.ExtensionStoreRepository; import run.halo.app.infra.BackupRootGetter; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.utils.FileUtils; import run.halo.app.migration.Backup; import run.halo.app.migration.BackupFile; import run.halo.app.migration.MigrationService; @Slf4j @Service @RequiredArgsConstructor class MigrationServiceImpl implements MigrationService, InitializingBean { private static final int BATCH_SIZE = 100; private static final String BACKUP_STORE_PREFIX = ExtensionStoreUtil.buildStoreNamePrefix(Scheme.buildFromType(Backup.class)) + "/"; private final ExtensionStoreRepository repository; private final HaloProperties haloProperties; private final BackupRootGetter backupRoot; private final ReactiveTransactionManager txManager; private final R2dbcEntityTemplate entityTemplate; private final Set excludes = Set.of( "**/.git/**", "**/node_modules/**", "backups/**", "db/**", "logs/**", "indices/**", "docker-compose.yaml", "docker-compose.yml", "mysql/**", "mysqlBackup/**", "**/.idea/**", "**/.vscode/**", "attachments/thumbnails/**" ); private final ObjectMapper objectMapper = JsonMapper.builder() .defaultPrettyPrinter(new MinimalPrettyPrinter()) .build(); private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter .ofPattern("yyyyMMddHHmmss") .withLocale(Locale.getDefault()) .withZone(ZoneId.systemDefault()); private final Scheduler scheduler = Schedulers.newBoundedElastic(10, 1_00, "migration-worker"); DateTimeFormatter getDateTimeFormatter() { return dateTimeFormatter; } ObjectMapper getObjectMapper() { return objectMapper; } Path getBackupsRoot() { return backupRoot.get(); } @Override public Mono backup(Backup backup) { return Mono.usingWhen( createTempDir("halo-full-backup-", scheduler), tempDir -> backupExtensions(tempDir) .then(Mono.defer(() -> backupWorkDir(tempDir))) .then(Mono.defer(() -> packageBackup(tempDir, backup))), tempDir -> deleteRecursivelyAndSilently(tempDir, scheduler) ); } @Override public Mono download(Backup backup) { return Mono.create(sink -> { var status = backup.getStatus(); if (!Backup.Phase.SUCCEEDED.equals(status.getPhase()) || status.getFilename() == null) { sink.error(new ServerWebInputException("Current backup is not downloadable.")); return; } var backupFile = getBackupsRoot().resolve(status.getFilename()); var resource = new FileSystemResource(backupFile); if (!resource.exists()) { sink.error( new NotFoundException("problemDetail.migration.backup.notFound", new Object[] {}, "Backup file doesn't exist or deleted.")); return; } sink.success(resource); }); } @Override public Mono restore(Publisher content) { var tx = TransactionalOperator.create(txManager); return Mono.usingWhen( createTempDir("halo-restore-", scheduler), tempDir -> unpackBackup(content, tempDir) .then(Mono.defer(() -> // This step skips index verification such as unique index. // In order to avoid index conflicts after recovery or // OptimisticLockingFailureException when updating the same record, // so we need to truncate all extension stores before saving(create or // update). repository.deleteAll() .then(restoreExtensions(tempDir)) .as(tx::transactional) )) .then(Mono.defer(() -> restoreWorkdir(tempDir))), tempDir -> deleteRecursivelyAndSilently(tempDir, scheduler) ); } @Override public Mono cleanup(Backup backup) { return Mono.create(sink -> { var status = backup.getStatus(); if (status == null || !StringUtils.hasText(status.getFilename())) { sink.success(); return; } var filename = status.getFilename(); var backupsRoot = getBackupsRoot(); var backupFile = backupsRoot.resolve(filename); try { checkDirectoryTraversal(backupsRoot, backupFile); deleteIfExists(backupFile); sink.success(); } catch (IOException e) { sink.error(e); } }).subscribeOn(scheduler); } @Override public Flux getBackupFiles() { return Flux.using( () -> Files.list(getBackupsRoot()), Flux::fromStream, BaseStream::close ) .filter(Files::isRegularFile) .filter(Files::isReadable) .filter(path -> isExtension(path.getFileName().toString(), "zip")) .map(this::toBackupFile) .sort(comparing(BackupFile::getLastModifiedTime).reversed() .thenComparing(BackupFile::getFilename) ) .subscribeOn(this.scheduler); } @Override public Mono getBackupFile(String filename) { return Mono.fromCallable(() -> { var backupsRoot = getBackupsRoot(); var backupFilePath = backupsRoot.resolve(filename); checkDirectoryTraversal(backupsRoot, backupFilePath); if (Files.notExists(backupFilePath)) { return null; } return toBackupFile(backupFilePath); }).subscribeOn(this.scheduler); } private BackupFile toBackupFile(Path path) { var backupFile = new BackupFile(); backupFile.setPath(path); backupFile.setFilename(path.getFileName().toString()); try { backupFile.setSize(Files.size(path)); backupFile.setLastModifiedTime(Files.getLastModifiedTime(path).toInstant()); return backupFile; } catch (IOException e) { throw Exceptions.propagate(e); } } private Mono restoreWorkdir(Path backupRoot) { return Mono.create(sink -> { try { var workdir = backupRoot.resolve("workdir"); if (Files.exists(workdir)) { copyRecursively(workdir, haloProperties.getWorkDir()); } sink.success(); } catch (IOException e) { sink.error(e); } }).subscribeOn(scheduler); } private Mono restoreExtensions(Path backupRoot) { var extensionsPath = backupRoot.resolve("extensions.data"); if (Files.notExists(extensionsPath)) { return Mono.error(new ServerWebInputException("Extensions data file not found.")); } var reader = objectMapper.readerFor(ExtensionStore.class); var total = new AtomicInteger(0); return Mono.>using( () -> reader.readValues(extensionsPath.toFile()), itr -> Flux.fromIterable(() -> itr) // reset version .filter(Predicate.not(MigrationServiceImpl::isInBlocklist)) .doOnNext(extensionStore -> extensionStore.setVersion(null)) .buffer(100) .flatMap(repository::saveAll) .doOnNext(store -> { var t = total.incrementAndGet(); if (t % BATCH_SIZE == 0) { log.info("Restored {} extension stores so far...", t); } if (log.isDebugEnabled()) { log.debug("Restored extension store: {}", store.getName()); } }) .then() .doOnSuccess(ignored -> log.info( "Extension stores restore completed, total {} record(s) restored.", total.get()) ), FileUtils::closeQuietly) .subscribeOn(scheduler); } private Mono unpackBackup(Publisher content, Path target) { return unzip(content, target, scheduler); } private Mono packageBackup(Path baseDir, Backup backup) { return Mono.fromCallable( () -> { var backupsFolder = getBackupsRoot(); Files.createDirectories(backupsFolder); var backupName = backup.getMetadata().getName(); var startTimestamp = backup.getStatus().getStartTimestamp(); var timePart = this.dateTimeFormatter.format(startTimestamp); var backupFile = backupsFolder.resolve(timePart + '-' + backupName + ".zip"); FileUtils.zip(baseDir, backupFile); backup.getStatus().setFilename(backupFile.getFileName().toString()); backup.getStatus().setSize(Files.size(backupFile)); return backupsFolder; } ).subscribeOn(scheduler).then(); } private Mono backupWorkDir(Path baseDir) { return Mono.fromCallable(() -> { var workdirPath = Files.createDirectory(baseDir.resolve("workdir")); copyRecursively(haloProperties.getWorkDir(), workdirPath, excludes); return workdirPath; }).subscribeOn(scheduler).then(); } private Mono backupExtensions(Path baseDir) { var total = new AtomicInteger(0); var excludes = new AtomicInteger(0); return Mono.fromCallable(() -> Files.createFile(baseDir.resolve("extensions.data"))) .subscribeOn(scheduler) .flatMap(extensionsPath -> Mono.usingWhen( Mono.fromCallable( () -> objectMapper.writerFor(ExtensionStore.class) .writeValuesAsArray(extensionsPath.toFile()) ) .subscribeOn(scheduler), seqWriter -> fetchAllExtensionStores(BATCH_SIZE) .filter(extensionStore -> { if (isInBlocklist(extensionStore)) { excludes.incrementAndGet(); return false; } return true; }) .buffer(100) .publishOn(scheduler) .concatMap(stores -> Mono.fromCallable(() -> { total.addAndGet(stores.size()); seqWriter.writeAll(stores); log.debug("Backed up {} extension stores so far...", total.get()); return null; })) .then() .doOnSuccess(ignored -> log.info( """ Extension stores backup completed, total {} record(s) backed up, \ {} record(s) excluded.""", total.get(), excludes.get() )), seqWriter -> Mono.fromCallable(() -> { seqWriter.flush(); FileUtils.closeQuietly(seqWriter); return null; }).subscribeOn(scheduler) )); } private Flux fetchAllExtensionStores(int batchSize) { var txDefinition = new DefaultTransactionDefinition(TransactionDefinition.withDefaults()); txDefinition.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); txDefinition.setReadOnly(true); var tx = TransactionalOperator.create(txManager, txDefinition); return entityTemplate.select(ExtensionStore.class) .withFetchSize(batchSize) .all() .as(tx::transactional); } @Override public void afterPropertiesSet() throws Exception { Files.createDirectories(getBackupsRoot()); } private static boolean isInBlocklist(ExtensionStore store) { return store.getName().startsWith(BACKUP_STORE_PREFIX); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/DefaultNotificationCenter.java ================================================ package run.halo.app.notification; import static org.apache.commons.lang3.StringUtils.defaultString; import java.util.HashMap; import java.util.Locale; import java.util.Optional; import lombok.Builder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.core.extension.notification.Notification; import run.halo.app.core.extension.notification.NotifierDescriptor; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.ReasonType; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.notification.endpoint.SubscriptionRouter; /** * A default implementation of {@link NotificationCenter}. * * @author guqing * @since 2.10.0 */ @Slf4j @Component @RequiredArgsConstructor public class DefaultNotificationCenter implements NotificationCenter { private final ReactiveExtensionClient client; private final NotificationSender notificationSender; private final NotifierConfigStore notifierConfigStore; private final ReasonNotificationTemplateSelector notificationTemplateSelector; private final UserNotificationPreferenceService userNotificationPreferenceService; private final NotificationTemplateRender notificationTemplateRender; private final SubscriptionRouter subscriptionRouter; private final RecipientResolver recipientResolver; private final SubscriptionService subscriptionService; private final SystemConfigFetcher environmentFetcher; @Override public Mono notify(Reason reason) { return recipientResolver.resolve(reason) .doOnNext(subscriber -> { log.debug("Dispatching notification to subscriber [{}] for reason [{}]", subscriber, reason.getMetadata().getName()); }) .flatMap(subscriber -> dispatchNotification(reason, subscriber)) .then(); } @Override public Mono subscribe(Subscription.Subscriber subscriber, Subscription.InterestReason reason) { return unsubscribe(subscriber, reason) .then(Mono.defer(() -> { var subscription = new Subscription(); subscription.setMetadata(new Metadata()); subscription.getMetadata().setGenerateName("subscription-"); subscription.setSpec(new Subscription.Spec()); subscription.getSpec().setUnsubscribeToken(Subscription.generateUnsubscribeToken()); subscription.getSpec().setSubscriber(subscriber); Subscription.InterestReason.ensureSubjectHasValue(reason); subscription.getSpec().setReason(reason); return client.create(subscription); })); } @Override public Mono unsubscribe(Subscription.Subscriber subscriber) { return subscriptionService.remove(subscriber).then(); } @Override public Mono unsubscribe(Subscription.Subscriber subscriber, Subscription.InterestReason reason) { return subscriptionService.remove(subscriber, reason).then(); } Flux getNotifiersBySubscriber(Subscriber subscriber, Reason reason) { var reasonType = reason.getSpec().getReasonType(); return userNotificationPreferenceService.getByUser(subscriber.name()) .map(UserNotificationPreference::getReasonTypeNotifier) .map(reasonTypeNotification -> reasonTypeNotification.getNotifiers(reasonType)) .flatMapMany(Flux::fromIterable); } Mono dispatchNotification(Reason reason, Subscriber subscriber) { return getNotifiersBySubscriber(subscriber, reason) .flatMap(notifierName -> client.fetch(NotifierDescriptor.class, notifierName)) .flatMap(descriptor -> prepareNotificationElement(subscriber, reason, descriptor)) .flatMap(element -> { var dispatchMono = sendNotification(element); if (subscriber.isAnonymous()) { return dispatchMono; } // create notification for user var innerNofificationMono = createNotification(element); return Mono.when(dispatchMono, innerNofificationMono); }) .then(); } Mono prepareNotificationElement(Subscriber subscriber, Reason reason, NotifierDescriptor descriptor) { return getLocaleFromSubscriber(subscriber) .flatMap(locale -> inferenceTemplate(reason, subscriber, locale)) .map(notificationContent -> NotificationElement.builder() .descriptor(descriptor) .reason(reason) .subscriber(subscriber) .reasonType(notificationContent.reasonType()) .notificationTitle(notificationContent.title()) .reasonAttributes(notificationContent.reasonAttributes()) .notificationRawBody(defaultString(notificationContent.rawBody())) .notificationHtmlBody(defaultString(notificationContent.htmlBody())) .build() ); } Mono sendNotification(NotificationElement notificationElement) { var descriptor = notificationElement.descriptor(); var subscriber = notificationElement.subscriber(); final var notifierExtName = descriptor.getSpec().getNotifierExtName(); return notificationContextFrom(notificationElement) .flatMap(notificationContext -> notificationSender.sendNotification(notifierExtName, notificationContext) .onErrorResume(throwable -> { log.error( "Failed to send notification to subscriber [{}] through notifier [{}]", subscriber, descriptor.getSpec().getDisplayName(), throwable); return Mono.empty(); }) ) .then(); } Mono createNotification(NotificationElement notificationElement) { var reason = notificationElement.reason(); var subscriber = notificationElement.subscriber(); return client.fetch(User.class, subscriber.name()) .flatMap(user -> { Notification notification = new Notification(); notification.setMetadata(new Metadata()); notification.getMetadata().setGenerateName("notification-"); notification.setSpec(new Notification.NotificationSpec()); notification.getSpec().setTitle(notificationElement.notificationTitle()); notification.getSpec().setRawContent(notificationElement.notificationRawBody()); notification.getSpec().setHtmlContent(notificationElement.notificationHtmlBody); notification.getSpec().setRecipient(subscriber.name()); notification.getSpec().setReason(reason.getMetadata().getName()); notification.getSpec().setUnread(true); return client.create(notification); }); } private ReasonAttributes toReasonAttributes(Reason reason) { var model = new ReasonAttributes(); var attributes = reason.getSpec().getAttributes(); if (attributes != null) { model.putAll(attributes); } return model; } Mono notificationContextFrom(NotificationElement element) { final var descriptorName = element.descriptor().getMetadata().getName(); final var reason = element.reason(); final var descriptor = element.descriptor(); final var subscriber = element.subscriber(); var messagePayload = new NotificationContext.MessagePayload(); messagePayload.setTitle(element.notificationTitle()); messagePayload.setRawBody(element.notificationRawBody()); messagePayload.setHtmlBody(element.notificationHtmlBody()); messagePayload.setAttributes(element.reasonAttributes()); var message = new NotificationContext.Message(); message.setRecipient(subscriber.name()); message.setPayload(messagePayload); message.setTimestamp(reason.getMetadata().getCreationTimestamp()); var reasonSubject = reason.getSpec().getSubject(); var subject = NotificationContext.Subject.builder() .apiVersion(reasonSubject.getApiVersion()) .kind(reasonSubject.getKind()) .title(reasonSubject.getTitle()) .url(reasonSubject.getUrl()) .build(); message.setSubject(subject); var notificationContext = new NotificationContext(); notificationContext.setMessage(message); return Mono.just(notificationContext) .flatMap(context -> { Mono receiverConfigMono = Optional.ofNullable(descriptor.getSpec().getReceiverSettingRef()) .map(ref -> notifierConfigStore.fetchReceiverConfig(descriptorName) .doOnNext(context::setReceiverConfig) .then() ) .orElse(Mono.empty()); Mono senderConfigMono = Optional.ofNullable(descriptor.getSpec().getSenderSettingRef()) .map(ref -> notifierConfigStore.fetchSenderConfig(descriptorName) .doOnNext(context::setSenderConfig) .then() ) .orElse(Mono.empty()); return Mono.when(receiverConfigMono, senderConfigMono) .thenReturn(context); }); } Mono inferenceTemplate(Reason reason, Subscriber subscriber, Locale locale) { var reasonTypeName = reason.getSpec().getReasonType(); return getReasonType(reasonTypeName) .flatMap(reasonType -> notificationTemplateSelector.select(reasonTypeName, locale) .flatMap(template -> { final var templateContent = template.getSpec().getTemplate(); var model = toReasonAttributes(reason); var subscriberInfo = new HashMap<>(); if (subscriber.isAnonymous()) { subscriberInfo.put("displayName", subscriber.getEmail().orElseThrow()); } else { subscriberInfo.put("displayName", "@" + subscriber.username()); } subscriberInfo.put("id", subscriber.name()); model.put("subscriber", subscriberInfo); var unsubscriptionMono = getUnsubscribeUrl(subscriber.subscriptionName()) .doOnNext(url -> model.put("unsubscribeUrl", url)); var builder = NotificationContent.builder() .reasonType(reasonType) .reasonAttributes(model); var titleMono = notificationTemplateRender .render(templateContent.getTitle(), model) .doOnNext(builder::title); var rawBodyMono = notificationTemplateRender .render(templateContent.getRawBody(), model) .doOnNext(builder::rawBody); var htmlBodyMono = notificationTemplateRender .render(templateContent.getHtmlBody(), model) .doOnNext(builder::htmlBody); return Mono.when(unsubscriptionMono, titleMono, rawBodyMono, htmlBodyMono) .then(Mono.fromSupplier(builder::build)); }) ); } @Builder record NotificationContent(String title, String rawBody, String htmlBody, ReasonType reasonType, ReasonAttributes reasonAttributes) { } Mono getUnsubscribeUrl(String subscriptionName) { return client.get(Subscription.class, subscriptionName) .map(subscriptionRouter::getUnsubscribeUrl); } @Builder record NotificationElement(ReasonType reasonType, Reason reason, Subscriber subscriber, NotifierDescriptor descriptor, String notificationTitle, String notificationRawBody, String notificationHtmlBody, ReasonAttributes reasonAttributes) { } Mono getReasonType(String reasonTypeName) { return client.get(ReasonType.class, reasonTypeName); } Mono getLocaleFromSubscriber(Subscriber subscriber) { // TODO get locale from subscriber return environmentFetcher.getBasic() .map(SystemSetting.Basic::useSystemLocale) .map(localeOpt -> localeOpt.orElse(Locale.getDefault())); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/DefaultNotificationReasonEmitter.java ================================================ package run.halo.app.notification; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import java.util.List; import java.util.function.Consumer; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import reactor.core.publisher.Mono; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.ReasonType; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.exception.NotFoundException; /** * A default {@link NotificationReasonEmitter} implementation. * * @author guqing * @since 2.10.0 */ @Component @RequiredArgsConstructor public class DefaultNotificationReasonEmitter implements NotificationReasonEmitter { private final ReactiveExtensionClient client; @Override public Mono emit(String reasonType, Consumer builder) { Assert.notNull(reasonType, "Reason type must not be empty."); var reason = createReason(reasonType, buildReasonPayload(builder)); return validateReason(reason) .then(Mono.defer(() -> client.create(reason))) .then(); } Mono validateReason(Reason reason) { String reasonTypeName = reason.getSpec().getReasonType(); return client.fetch(ReasonType.class, reasonTypeName) .switchIfEmpty(Mono.error(new NotFoundException( "ReasonType [" + reasonTypeName + "] not found, do you forget to register it?")) ) .doOnNext(reasonType -> { var valueMap = reason.getSpec().getAttributes(); nullSafeList(reasonType.getSpec().getProperties()) .forEach(property -> { if (property.isOptional()) { return; } if (valueMap.get(property.getName()) == null) { throw new IllegalArgumentException( "Reason property [" + property.getName() + "] is required."); } }); }) .then(); } List nullSafeList(List t) { return defaultIfNull(t, List.of()); } Reason createReason(String reasonType, ReasonPayload reasonData) { Reason reason = new Reason(); reason.setMetadata(new Metadata()); reason.getMetadata().setGenerateName("reason-"); reason.setSpec(new Reason.Spec()); if (reasonData.getAuthor() != null) { reason.getSpec().setAuthor(reasonData.getAuthor().name()); } reason.getSpec().setReasonType(reasonType); reason.getSpec().setSubject(reasonData.getSubject()); var reasonAttributes = new ReasonAttributes(); if (reasonData.getAttributes() != null) { reasonAttributes.putAll(reasonData.getAttributes()); } reason.getSpec().setAttributes(reasonAttributes); return reason; } ReasonPayload buildReasonPayload(Consumer reasonData) { var builder = ReasonPayload.builder(); reasonData.accept(builder); return builder.build(); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/DefaultNotificationSender.java ================================================ package run.halo.app.notification; import java.time.Duration; import java.time.Instant; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.context.SmartLifecycle; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.DefaultController; import run.halo.app.extension.controller.DefaultQueue; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.RequestQueue; import run.halo.app.plugin.extensionpoint.ExtensionDefinition; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** * A default {@link NotificationSender} implementation. * * @author guqing * @since 2.10.0 */ @Slf4j @Component public class DefaultNotificationSender implements NotificationSender, Reconciler, SmartLifecycle { private static final Duration TIMEOUT = Duration.ofSeconds(30); private final ReactiveExtensionClient client; private final ExtensionGetter extensionGetter; private final RequestQueue requestQueue; private final Controller controller; private boolean running = false; /** * Constructs a new notification sender with the given {@link ReactiveExtensionClient} and * {@link ExtensionGetter}. */ public DefaultNotificationSender(ReactiveExtensionClient client, ExtensionGetter extensionGetter) { this.client = client; this.extensionGetter = extensionGetter; requestQueue = new DefaultQueue<>(Instant::now); controller = this.setupWith(null); } @Override public Mono sendNotification(String notifierExtensionName, NotificationContext context) { return selectNotifier(notifierExtensionName) .flatMap(notifier -> Mono.fromRunnable( () -> { var item = new QueueItem(UUID.randomUUID().toString(), () -> notifier.notify(context).block(TIMEOUT), 0); requestQueue.addImmediately(item); }) .subscribeOn(Schedulers.boundedElastic()) ) .then(); } Mono selectNotifier(String notifierExtensionName) { return client.fetch(ExtensionDefinition.class, notifierExtensionName) .flatMap(extDefinition -> extensionGetter.getEnabledExtensions( ReactiveNotifier.class) .filter(notifier -> notifier.getClass().getName() .equals(extDefinition.getSpec().getClassName()) ) .next() ); } @Override public Result reconcile(QueueItem request) { if (request.getTimes() > 3) { log.error("Failed to send notification after retrying 3 times, discard it."); return Result.doNotRetry(); } log.debug("Executing send notification task, [{}] remaining to-do tasks", requestQueue.size()); request.setTimes(request.getTimes() + 1); request.getTask().run(); return Result.doNotRetry(); } @Override public Controller setupWith(ControllerBuilder builder) { return new DefaultController<>( this.getClass().getName(), this, requestQueue, null, Duration.ofMillis(100), Duration.ofSeconds(1000), 5 ); } @Override public void start() { controller.start(); running = true; } @Override public void stop() { running = false; controller.dispose(); } @Override public boolean isRunning() { return running; } /** *

Queue item for {@link #requestQueue}.

*

It holds a {@link Runnable} and a {@link #times} field.

*

{@link Runnable} used to send email when consuming.

*

{@link #times} will be used to record the number of * times the task has been executed, if retry three times on failure, it will be discarded.

*

It also holds a {@link #id} field, which is used to identify the item. queue item with * the same id is considered to be the same item to ensure that controller can * discard the existing item in the queue when item re-queued on failure.

*/ @Getter @AllArgsConstructor @EqualsAndHashCode(onlyExplicitlyIncluded = true) public static class QueueItem { @EqualsAndHashCode.Include private final String id; private final Runnable task; @Setter private int times; } } ================================================ FILE: application/src/main/java/run/halo/app/notification/DefaultNotificationService.java ================================================ package run.halo.app.notification; import java.time.Instant; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.notification.Notification; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.exception.AccessDeniedException; /** * A default implementation of {@link UserNotificationService}. * * @author guqing * @since 2.10.0 */ @Component @RequiredArgsConstructor public class DefaultNotificationService implements UserNotificationService { private final ReactiveExtensionClient client; @Override public Mono> listByUser(String username, UserNotificationQuery query) { return client.listBy(Notification.class, query.toListOptions(), query.toPageRequest()); } @Override public Mono markAsRead(String username, String name) { return client.fetch(Notification.class, name) .filter(notification -> isRecipient(notification, username)) .flatMap(notification -> { notification.getSpec().setUnread(false); notification.getSpec().setLastReadAt(Instant.now()); return client.update(notification); }); } @Override public Flux markSpecifiedAsRead(String username, List names) { return Flux.fromIterable(names) .flatMap(name -> markAsRead(username, name)) .map(notification -> notification.getMetadata().getName()); } @Override public Mono deleteByName(String username, String name) { return client.get(Notification.class, name) .doOnNext(notification -> { var recipient = notification.getSpec().getRecipient(); if (!username.equals(recipient)) { throw new AccessDeniedException( "You have no permission to delete this notification."); } }) .flatMap(client::delete); } static boolean isRecipient(Notification notification, String username) { Assert.notNull(notification, "Notification must not be null"); Assert.notNull(username, "Username must not be null"); return username.equals(notification.getSpec().getRecipient()); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/DefaultNotificationTemplateRender.java ================================================ package run.halo.app.notification; import static org.apache.commons.lang3.StringUtils.defaultString; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.Context; import org.thymeleaf.spring6.SpringTemplateEngine; import org.thymeleaf.templateresolver.StringTemplateResolver; import reactor.core.publisher.Mono; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; /** *

Default implementation of {@link NotificationTemplateRender}.

*

This implementation use {@link TemplateEngine} to render template, and the template engine * use {@link StringTemplateResolver} to resolve template, so the template * in {@link #render(String template, Map)} is template content.

*

Template syntax: * usingthymeleaf.html#textual-syntax *

* * @author guqing * @since 2.10.0 */ @Component @RequiredArgsConstructor public class DefaultNotificationTemplateRender implements NotificationTemplateRender { private static final TemplateEngine TEMPLATE_ENGINE = createTemplateEngine(); private final SystemConfigFetcher environmentFetcher; private final ExternalUrlSupplier externalUrlSupplier; @Override public Mono render(String template, Map model) { var context = new Context(Locale.getDefault(), model); var externalUrl = Optional.ofNullable(externalUrlSupplier.getRaw()) .map(url -> StringUtils.removeEnd(url.toString(), "/")) .orElse(StringUtils.EMPTY); var globalAttributeMono = getBasicSetting() .doOnNext(basic -> { var site = new HashMap<>(); site.put("title", basic.getTitle()); site.put("logo", basic.getLogo()); site.put("subtitle", basic.getSubtitle()); site.put("url", externalUrl); context.setVariable("site", site); }); return Mono.when(globalAttributeMono) .then(Mono.fromSupplier(() -> TEMPLATE_ENGINE.process(defaultString(template), context))); } static TemplateEngine createTemplateEngine() { var template = new SpringTemplateEngine(); template.setTemplateResolver(new StringTemplateResolver()); return template; } Mono getBasicSetting() { return environmentFetcher.fetch(SystemSetting.Basic.GROUP, SystemSetting.Basic.class); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/DefaultNotifierConfigStore.java ================================================ package run.halo.app.notification; import static run.halo.app.extension.MetadataUtil.SYSTEM_FINALIZER; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.HashMap; import java.util.Map; import java.util.Set; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Secret; import run.halo.app.infra.utils.JsonUtils; /** * A default implementation of {@link NotifierConfigStore}. * * @author guqing * @since 2.10.0 */ @Component @RequiredArgsConstructor public class DefaultNotifierConfigStore implements NotifierConfigStore { public static final String SECRET_NAME = "notifier-setting-secret"; public static final String RECEIVER_KEY = "receiver"; public static final String SENDER_KEY = "sender"; private final ReactiveExtensionClient client; @Override public Mono fetchReceiverConfig(String notifierDescriptorName) { return fetchConfig(notifierDescriptorName) .mapNotNull(setting -> (ObjectNode) setting.get(RECEIVER_KEY)) .defaultIfEmpty(JsonNodeFactory.instance.objectNode()); } @Override public Mono fetchSenderConfig(String notifierDescriptorName) { return fetchConfig(notifierDescriptorName) .mapNotNull(setting -> (ObjectNode) setting.get(SENDER_KEY)) .defaultIfEmpty(JsonNodeFactory.instance.objectNode()); } @Override public Mono saveReceiverConfig(String notifierDescriptorName, ObjectNode config) { return saveConfig(notifierDescriptorName, RECEIVER_KEY, config); } @Override public Mono saveSenderConfig(String notifierDescriptorName, ObjectNode config) { return saveConfig(notifierDescriptorName, SENDER_KEY, config); } Mono saveConfig(String notifierDescriptorName, String key, ObjectNode config) { return client.fetch(Secret.class, SECRET_NAME) .switchIfEmpty(Mono.defer(() -> { Secret secret = new Secret(); secret.setMetadata(new Metadata()); secret.getMetadata().setName(SECRET_NAME); secret.getMetadata().setFinalizers(Set.of(SYSTEM_FINALIZER)); secret.setStringData(new HashMap<>()); return client.create(secret); })) .flatMap(secret -> { if (secret.getStringData() == null) { secret.setStringData(new HashMap<>()); } Map map = secret.getStringData(); ObjectNode wrapperNode = JsonNodeFactory.instance.objectNode(); wrapperNode.set(key, config); map.put(resolveKey(notifierDescriptorName), JsonUtils.objectToJson(wrapperNode)); return client.update(secret); }) .then(); } Mono fetchConfig(String notifierDescriptorName) { return client.fetch(Secret.class, SECRET_NAME) .mapNotNull(Secret::getStringData) .mapNotNull(map -> map.get(resolveKey(notifierDescriptorName))) .filter(StringUtils::isNotBlank) .map(value -> JsonUtils.jsonToObject(value, ObjectNode.class)) .defaultIfEmpty(JsonNodeFactory.instance.objectNode()); } String resolveKey(String notifierDescriptorName) { return notifierDescriptorName + ".json"; } } ================================================ FILE: application/src/main/java/run/halo/app/notification/DefaultSubscriberEmailResolver.java ================================================ package run.halo.app.notification; import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.ReactiveExtensionClient; /** *

Default implementation of {@link SubscriberEmailResolver}.

*

If the subscriber is an anonymous subscriber, the email will be extracted from the * subscriber name.

*

An anonymous subscriber's name is in the format of {@code anonymous#email}.

* * @author guqing * @since 2.10.0 */ @Component @RequiredArgsConstructor public class DefaultSubscriberEmailResolver implements SubscriberEmailResolver { private final ReactiveExtensionClient client; @Override public Mono resolve(Subscription.Subscriber subscriber) { var identity = UserIdentity.of(subscriber.getName()); if (identity.isAnonymous()) { return Mono.fromSupplier(() -> getEmail(subscriber)); } return client.fetch(User.class, subscriber.getName()) .filter(user -> user.getSpec().isEmailVerified()) .mapNotNull(user -> user.getSpec().getEmail()); } @Override public Subscription.Subscriber ofEmail(String email) { if (StringUtils.isBlank(email)) { throw new IllegalArgumentException("Email must not be blank"); } var subscriber = new Subscription.Subscriber(); subscriber.setName(UserIdentity.anonymousWithEmail(email).name()); return subscriber; } @NonNull String getEmail(Subscription.Subscriber subscriber) { var identity = UserIdentity.of(subscriber.getName()); if (!identity.isAnonymous()) { throw new IllegalStateException("The subscriber is not an anonymous subscriber"); } return identity.getEmail() .filter(StringUtils::isNotBlank) .orElseThrow(() -> new IllegalStateException("The subscriber does not have an email")); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/EmailNotifier.java ================================================ package run.halo.app.notification; import com.fasterxml.jackson.databind.JsonNode; import java.util.concurrent.atomic.AtomicReference; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.data.util.Pair; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.mail.javamail.MimeMessagePreparator; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.notification.EmailSenderHelper.EmailSenderConfig; /** *

A notifier that can send email.

* * @author guqing * @see ReactiveNotifier * @see JavaMailSenderImpl * @since 2.10.0 */ @Slf4j @Component @RequiredArgsConstructor public class EmailNotifier implements ReactiveNotifier { private final SubscriberEmailResolver subscriberEmailResolver; private final NotificationTemplateRender notificationTemplateRender; private final EmailSenderHelper emailSenderHelper; private final AtomicReference> emailSenderConfigPairRef = new AtomicReference<>(); @Override public Mono notify(NotificationContext context) { JsonNode senderConfig = context.getSenderConfig(); var emailSenderConfig = JsonUtils.DEFAULT_JSON_MAPPER.convertValue(senderConfig, EmailSenderConfig.class); if (!emailSenderConfig.isEnable()) { log.debug("Email notifier is disabled, skip sending email."); return Mono.empty(); } JavaMailSender javaMailSender = getJavaMailSender(emailSenderConfig); String recipient = context.getMessage().getRecipient(); var subscriber = new Subscription.Subscriber(); subscriber.setName(recipient); var payload = context.getMessage().getPayload(); return subscriberEmailResolver.resolve(subscriber) .flatMap(toEmail -> { if (StringUtils.isBlank(toEmail)) { log.debug("Cannot resolve email for subscriber: [{}], skip sending email.", subscriber); return Mono.empty(); } var htmlMono = appendHtmlBodyFooter(payload.getAttributes()) .doOnNext(footer -> { if (StringUtils.isNotBlank(payload.getHtmlBody())) { payload.setHtmlBody(payload.getHtmlBody() + "\n" + footer); } }); var rawMono = appendRawBodyFooter(payload.getAttributes()) .doOnNext(footer -> { if (StringUtils.isNotBlank(payload.getRawBody())) { payload.setRawBody(payload.getRawBody() + "\n" + footer); } }); return Mono.when(htmlMono, rawMono) .thenReturn(toEmail); }) .map(toEmail -> getMimeMessagePreparator(toEmail, emailSenderConfig, payload)) .publishOn(Schedulers.boundedElastic()) .doOnNext(javaMailSender::send) .then(); } @NonNull private MimeMessagePreparator getMimeMessagePreparator(String toEmail, EmailSenderConfig emailSenderConfig, NotificationContext.MessagePayload payload) { return emailSenderHelper.createMimeMessagePreparator(emailSenderConfig, toEmail, payload.getTitle(), payload.getRawBody(), payload.getHtmlBody()); } JavaMailSender getJavaMailSender(EmailSenderConfig emailSenderConfig) { return emailSenderConfigPairRef.updateAndGet(pair -> { if (pair != null && pair.getFirst().equals(emailSenderConfig)) { return pair; } return Pair.of(emailSenderConfig, emailSenderHelper.createJavaMailSender(emailSenderConfig)); }).getSecond(); } Mono appendRawBodyFooter(ReasonAttributes attributes) { return notificationTemplateRender.render(""" --- 如果您不想再收到此类通知,点击链接退订: [(${unsubscribeUrl})] [(${site.title})] """, attributes); } Mono appendHtmlBodyFooter(ReasonAttributes attributes) { return notificationTemplateRender.render(""" """, attributes); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/EmailSenderHelper.java ================================================ package run.halo.app.notification; import lombok.Data; import lombok.NonNull; import org.apache.commons.lang3.StringUtils; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessagePreparator; public interface EmailSenderHelper { @NonNull JavaMailSender createJavaMailSender(EmailSenderConfig senderConfig); @NonNull MimeMessagePreparator createMimeMessagePreparator(EmailSenderConfig senderConfig, String toEmail, String subject, String raw, String html); @Data class EmailSenderConfig { private boolean enable; private String displayName; private String username; private String sender; private String password; private String host; private Integer port; private String encryption; /** * Gets email display name. * * @return display name if not blank, otherwise username. */ public String getDisplayName() { return StringUtils.defaultIfBlank(displayName, username); } /** * Gets email sender address. * * @return sender if not blank, otherwise username */ public String getSender() { return StringUtils.defaultIfBlank(sender, username); } } } ================================================ FILE: application/src/main/java/run/halo/app/notification/EmailSenderHelperImpl.java ================================================ package run.halo.app.notification; import java.nio.charset.StandardCharsets; import java.util.Properties; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.mail.javamail.MimeMessagePreparator; import org.springframework.stereotype.Component; /** *

A default implementation of {@link EmailSenderHelper}.

* * @author guqing * @since 2.14.0 */ @Slf4j @Component public class EmailSenderHelperImpl implements EmailSenderHelper { @Override @NonNull public JavaMailSender createJavaMailSender(EmailSenderConfig senderConfig) { JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl(); javaMailSender.setHost(senderConfig.getHost()); javaMailSender.setPort(senderConfig.getPort()); javaMailSender.setUsername(senderConfig.getUsername()); javaMailSender.setPassword(senderConfig.getPassword()); Properties props = javaMailSender.getJavaMailProperties(); props.put("mail.transport.protocol", "smtp"); props.put("mail.smtp.auth", "true"); if ("SSL".equals(senderConfig.getEncryption())) { props.put("mail.smtp.ssl.enable", "true"); } if ("TLS".equals(senderConfig.getEncryption())) { props.put("mail.smtp.starttls.enable", "true"); } if ("NONE".equals(senderConfig.getEncryption())) { props.put("mail.smtp.ssl.enable", "false"); props.put("mail.smtp.starttls.enable", "false"); } if (log.isDebugEnabled()) { props.put("mail.debug", "true"); } return javaMailSender; } @Override @NonNull public MimeMessagePreparator createMimeMessagePreparator(EmailSenderConfig senderConfig, String toEmail, String subject, String raw, String html) { return mimeMessage -> { MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, StandardCharsets.UTF_8.name()); helper.setFrom(senderConfig.getSender(), senderConfig.getDisplayName()); helper.setSubject(subject); helper.setText(raw, html); helper.setTo(toEmail); }; } } ================================================ FILE: application/src/main/java/run/halo/app/notification/LanguageUtils.java ================================================ package run.halo.app.notification; import java.util.ArrayList; import java.util.List; import java.util.Locale; import lombok.experimental.UtilityClass; import org.apache.commons.lang3.StringUtils; /** * Language utils to help us to compute the language. * * @author guqing * @since 2.10.0 */ @UtilityClass public class LanguageUtils { /** * Compute all the possible languages we should use: *_gl_ES-gheada, *_gl_ES, _gl... from a * given locale. * The first element of the list is "default" if it can not find the language, use default. * * @param locale locale * @return list of possible languages, from less specific to more specific. */ public static List computeLangFromLocale(Locale locale) { final List resourceNames = new ArrayList<>(5); if (StringUtils.isBlank(locale.getLanguage())) { throw new IllegalArgumentException( "Locale \"" + locale + "\" " + "cannot be used as it does not specify a language."); } resourceNames.add("default"); resourceNames.add(locale.getLanguage()); if (StringUtils.isNotBlank(locale.getCountry())) { resourceNames.add(locale.getLanguage() + "_" + locale.getCountry()); } if (StringUtils.isNotBlank(locale.getVariant())) { resourceNames.add( locale.getLanguage() + "_" + locale.getCountry() + "-" + locale.getVariant()); } return resourceNames; } } ================================================ FILE: application/src/main/java/run/halo/app/notification/NotificationSender.java ================================================ package run.halo.app.notification; import reactor.core.publisher.Mono; import run.halo.app.core.extension.notification.Reason; /** *

{@link NotificationSender} used to send notification.

*

Send notification is a time-consuming task, so we use a queue to send notification * asynchronously.

*

The caller may not be reactive, and in many cases it is blocking called * {@link NotificationCenter#notify(Reason)}, so here use the queue to ensure asynchronous * sending of notification without blocking the calling thread.

* * @author guqing * @since 2.10.0 */ @FunctionalInterface public interface NotificationSender { Mono sendNotification(String notifierExtensionName, NotificationContext context); } ================================================ FILE: application/src/main/java/run/halo/app/notification/NotificationTemplateRender.java ================================================ package run.halo.app.notification; import java.util.Map; import reactor.core.publisher.Mono; /** * {@link NotificationTemplateRender} is used to render the notification template. * * @author guqing * @since 2.10.0 */ public interface NotificationTemplateRender { Mono render(String template, Map context); } ================================================ FILE: application/src/main/java/run/halo/app/notification/NotificationTrigger.java ================================================ package run.halo.app.notification; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import java.time.Duration; import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import run.halo.app.core.extension.notification.Reason; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; /** *

Notification trigger for {@link Reason}.

*

Triggered when a new {@link Reason} is received, and then notify through * {@link NotificationCenter}.

*

It will add a finalizer to the {@link Reason} to avoid duplicate notification, In other * words, it will only notify once.

* * @author guqing * @since 2.10.0 */ @Slf4j @Component @RequiredArgsConstructor public class NotificationTrigger implements Reconciler { public static final String TRIGGERED_FINALIZER = "triggered"; private static final Duration TIMEOUT = Duration.ofMinutes(1); private final ExtensionClient client; private final NotificationCenter notificationCenter; @Override public Result reconcile(Request request) { client.fetch(Reason.class, request.name()).ifPresent(reason -> { if (ExtensionUtil.isDeleted(reason)) { if (removeFinalizers(reason.getMetadata(), Set.of(TRIGGERED_FINALIZER))) { client.update(reason); log.info("Cleaned up notification reason {}", request.name()); } return; } if (addFinalizers(reason.getMetadata(), Set.of(TRIGGERED_FINALIZER))) { // notifier onNewReasonReceived(reason); } // cleanup reason after notified client.delete(reason); }); return Result.doNotRetry(); } private void onNewReasonReceived(Reason reason) { var name = reason.getMetadata().getName(); log.info("Sending notification for reason: {}", name); notificationCenter.notify(reason).block(TIMEOUT); log.info("Notification sent for reason: {}", name); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new Reason()) .workerCount(10) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/NotifierConfigStore.java ================================================ package run.halo.app.notification; import com.fasterxml.jackson.databind.node.ObjectNode; import reactor.core.publisher.Mono; /** *

{@link NotifierConfigStore} to store notifier config.

*

It provides methods to fetch and save config for receiver and sender.

* * @author guqing * @since 2.10.0 */ public interface NotifierConfigStore { Mono fetchReceiverConfig(String notifierDescriptorName); Mono fetchSenderConfig(String notifierDescriptorName); Mono saveReceiverConfig(String notifierDescriptorName, ObjectNode config); Mono saveSenderConfig(String notifierDescriptorName, ObjectNode config); } ================================================ FILE: application/src/main/java/run/halo/app/notification/ReasonNotificationTemplateSelector.java ================================================ package run.halo.app.notification; import java.util.Locale; import reactor.core.publisher.Mono; import run.halo.app.core.extension.notification.NotificationTemplate; import run.halo.app.core.extension.notification.ReasonType; import run.halo.app.extension.Metadata; /** * Reason notification template selector to select notification template by reason type and locale. * * @author guqing * @see NotificationTemplate * @see ReasonType * @since 2.10.0 */ public interface ReasonNotificationTemplateSelector { /** * Select notification template by reason type and locale. *

Locale order is important: as we will let values from more specific to less specific (e.g. * a value for gl_ES will have more precedence than a value for gl).

*

If specific locale found and has multiple templates, we will order them by * {@link Metadata#getCreationTimestamp()} and return the latest one.

* * @param reasonType reason type * @param locale locale * @return notification template if found, or empty */ Mono select(String reasonType, Locale locale); } ================================================ FILE: application/src/main/java/run/halo/app/notification/ReasonNotificationTemplateSelectorImpl.java ================================================ package run.halo.app.notification; import static org.apache.commons.lang3.StringUtils.defaultIfBlank; import static run.halo.app.extension.index.query.Queries.equal; import java.util.Collections; import java.util.Comparator; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Sort; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.core.extension.notification.NotificationTemplate; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.selector.FieldSelector; /** * A default implementation of {@link ReasonNotificationTemplateSelector}. * * @author guqing * @since 2.10.0 */ @Component @RequiredArgsConstructor public class ReasonNotificationTemplateSelectorImpl implements ReasonNotificationTemplateSelector { private final ReactiveExtensionClient client; @Override public Mono select(String reasonType, Locale locale) { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of( equal("spec.reasonSelector.reasonType", reasonType)) ); return client.listAll(NotificationTemplate.class, listOptions, Sort.unsorted()) .collect(Collectors.groupingBy( getLanguageKey(), Collectors.maxBy(Comparator.comparing(t -> t.getMetadata().getCreationTimestamp())) )) .mapNotNull(map -> lookupTemplateByLocale(locale, map)); } @Nullable static NotificationTemplate lookupTemplateByLocale(Locale locale, Map> map) { return LanguageUtils.computeLangFromLocale(locale).stream() // reverse order to ensure that the variant is the first element and the default // is the last element .sorted(Collections.reverseOrder()) .map(key -> map.getOrDefault(key, Optional.empty())) .filter(Optional::isPresent) .map(Optional::get) .findFirst() .orElse(null); } @NonNull static Predicate matchReasonType(String reasonType) { return template -> template.getSpec().getReasonSelector().getReasonType() .equals(reasonType); } static Function getLanguageKey() { return template -> defaultIfBlank(template.getSpec().getReasonSelector().getLanguage(), "default"); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/RecipientResolver.java ================================================ package run.halo.app.notification; import reactor.core.publisher.Flux; import run.halo.app.core.extension.notification.Reason; public interface RecipientResolver { Flux resolve(Reason reason); } ================================================ FILE: application/src/main/java/run/halo/app/notification/RecipientResolverImpl.java ================================================ package run.halo.app.notification; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import com.google.common.base.Throwables; import java.util.Collections; import java.util.HashMap; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.context.expression.MapAccessor; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.expression.EvaluationContext; import org.springframework.expression.EvaluationException; import org.springframework.expression.Expression; import org.springframework.expression.ExpressionParser; import org.springframework.expression.ParseException; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.DataBindingPropertyAccessor; import org.springframework.expression.spel.support.SimpleEvaluationContext; import org.springframework.integration.json.JsonPropertyAccessor; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import reactor.core.publisher.Flux; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.Subscription; @Slf4j @Component @RequiredArgsConstructor public class RecipientResolverImpl implements RecipientResolver { private final ExpressionParser expressionParser = new SpelExpressionParser(); private final EvaluationContext evaluationContext = createEvaluationContext(); private final SubscriptionService subscriptionService; @Override public Flux resolve(Reason reason) { var reasonType = reason.getSpec().getReasonType(); return subscriptionService.listByPerPage(reasonType) .filter(this::isNotDisabled) .filter(subscription -> { var interestReason = subscription.getSpec().getReason(); if (hasSubject(interestReason)) { return subjectMatch(subscription, reason.getSpec().getSubject()); } else if (StringUtils.isNotBlank(interestReason.getExpression())) { return expressionMatch(subscription.getMetadata().getName(), interestReason.getExpression(), reason); } return false; }) .map(subscription -> { var id = UserIdentity.of(subscription.getSpec().getSubscriber().getName()); return new Subscriber(id, subscription.getMetadata().getName()); }) .distinct(Subscriber::name); } boolean hasSubject(Subscription.InterestReason interestReason) { return !Subscription.InterestReason.isFallbackSubject(interestReason.getSubject()); } boolean expressionMatch(String subscriptionName, String expressionStr, Reason reason) { try { Expression expression = expressionParser.parseExpression(expressionStr); var result = expression.getValue(evaluationContext, exprRootObject(reason), Boolean.class); return BooleanUtils.isTrue(result); } catch (ParseException | EvaluationException e) { log.debug("Failed to parse or evaluate expression for subscription [{}], skip it.", subscriptionName, Throwables.getRootCause(e)); return false; } } Map exprRootObject(Reason reason) { var map = new HashMap(3, 1); map.put("props", defaultIfNull(reason.getSpec().getAttributes(), new ReasonAttributes())); map.put("subject", reason.getSpec().getSubject()); map.put("author", reason.getSpec().getAuthor()); return Collections.unmodifiableMap(map); } static boolean subjectMatch(Subscription subscription, Reason.Subject reasonSubject) { Assert.notNull(subscription, "The subscription must not be null"); Assert.notNull(reasonSubject, "The reasonSubject must not be null"); final var sourceSubject = subscription.getSpec().getReason().getSubject(); var matchSubject = new Subscription.ReasonSubject(); matchSubject.setKind(reasonSubject.getKind()); matchSubject.setApiVersion(reasonSubject.getApiVersion()); if (StringUtils.isBlank(sourceSubject.getName())) { return sourceSubject.equals(matchSubject); } matchSubject.setName(reasonSubject.getName()); return sourceSubject.equals(matchSubject); } boolean isNotDisabled(Subscription subscription) { return !subscription.getSpec().isDisabled(); } EvaluationContext createEvaluationContext() { return SimpleEvaluationContext.forPropertyAccessors( DataBindingPropertyAccessor.forReadOnlyAccess(), new MapAccessor(), new JsonPropertyAccessor() ) .withConversionService(DefaultConversionService.getSharedInstance()) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/Subscriber.java ================================================ package run.halo.app.notification; import java.util.Optional; import org.springframework.util.Assert; import run.halo.app.infra.AnonymousUserConst; public record Subscriber(UserIdentity identity, String subscriptionName) { public Subscriber { Assert.notNull(identity, "The subscriber must not be null"); Assert.hasText(subscriptionName, "The subscription name must not be blank"); } public String name() { return identity.name(); } public String username() { return identity.isAnonymous() ? AnonymousUserConst.PRINCIPAL : identity.name(); } public boolean isAnonymous() { return identity.isAnonymous(); } public Optional getEmail() { return identity.getEmail(); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/SubscriberEmailResolver.java ================================================ package run.halo.app.notification; import reactor.core.publisher.Mono; import run.halo.app.core.extension.notification.Subscription; /** *

{@link SubscriberEmailResolver} used to resolve email from {@link Subscription.Subscriber} * .

* * @author guqing * @since 2.10.0 */ public interface SubscriberEmailResolver { Mono resolve(Subscription.Subscriber subscriber); /** * Creates an email subscriber from email. * * @param email email * @return email subscriber */ Subscription.Subscriber ofEmail(String email); } ================================================ FILE: application/src/main/java/run/halo/app/notification/SubscriptionService.java ================================================ package run.halo.app.notification; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.ListOptions; public interface SubscriptionService { /** *

List subscriptions by page one by one.only consume one page then next page will be * loaded.

*

Note that: result can not be used to delete the subscription, it is only used to query the * subscription.

*/ Flux listByPerPage(String reasonType); Mono remove(Subscription.Subscriber subscriber, Subscription.InterestReason interestReasons); Mono remove(Subscription.Subscriber subscriber); Mono remove(Subscription subscription); Flux removeBy(ListOptions listOptions); } ================================================ FILE: application/src/main/java/run/halo/app/notification/SubscriptionServiceImpl.java ================================================ package run.halo.app.notification; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.isNull; import static run.halo.app.extension.index.query.Queries.startsWith; import java.time.Duration; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.Condition; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.ReactiveExtensionPaginatedOperator; @Component @RequiredArgsConstructor public class SubscriptionServiceImpl implements SubscriptionService { private final ReactiveExtensionClient client; private final ReactiveExtensionPaginatedOperator paginatedOperator; @Override public Mono remove(Subscription.Subscriber subscriber, Subscription.InterestReason interestReason) { Assert.notNull(subscriber, "The subscriber must not be null"); Assert.notNull(interestReason, "The interest reason must not be null"); var reasonType = interestReason.getReasonType(); var expression = interestReason.getExpression(); var subject = interestReason.getSubject(); var listOptions = new ListOptions(); var fieldQuery = and(isNull("metadata.deletionTimestamp"), equal("spec.subscriber", subscriber.toString()), equal("spec.reason.reasonType", reasonType)); if (subject != null) { fieldQuery = and(fieldQuery, reasonSubjectMatch(subject)); } if (StringUtils.isNotBlank(expression)) { fieldQuery = and(fieldQuery, equal("spec.reason.expression", expression)); } listOptions.setFieldSelector(FieldSelector.of(fieldQuery)); return paginatedOperator.deleteInitialBatch(Subscription.class, listOptions).then(); } @Override public Mono remove(Subscription.Subscriber subscriber) { var listOptions = new ListOptions(); var fieldQuery = and(isNull("metadata.deletionTimestamp"), equal("spec.subscriber", subscriber.toString())); listOptions.setFieldSelector(FieldSelector.of(fieldQuery)); return paginatedOperator.deleteInitialBatch(Subscription.class, listOptions) .then(); } @Override public Mono remove(Subscription subscription) { return client.delete(subscription) .onErrorResume(OptimisticLockingFailureException.class, e -> attemptToDelete(subscription.getMetadata().getName())); } @Override public Flux removeBy(ListOptions listOptions) { return paginatedOperator.deleteInitialBatch(Subscription.class, listOptions); } @Override public Flux listByPerPage(String reasonType) { final var listOptions = new ListOptions(); var fieldQuery = and(isNull("metadata.deletionTimestamp"), equal("spec.reason.reasonType", reasonType)); listOptions.setFieldSelector(FieldSelector.of(fieldQuery)); return paginatedOperator.list(Subscription.class, listOptions); } private Mono attemptToDelete(String subscriptionName) { return Mono.defer(() -> client.fetch(Subscription.class, subscriptionName) .flatMap(client::delete) ) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)); } Condition reasonSubjectMatch(Subscription.ReasonSubject reasonSubject) { Assert.notNull(reasonSubject, "The reasonSubject must not be null"); if (StringUtils.isNotBlank(reasonSubject.getName())) { return equal("spec.reason.subject", reasonSubject.toString()); } var matchAllSubject = new Subscription.ReasonSubject(); matchAllSubject.setKind(reasonSubject.getKind()); matchAllSubject.setApiVersion(reasonSubject.getApiVersion()); return startsWith("spec.reason.subject", matchAllSubject.toString()); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/UserNotificationPreference.java ================================================ package run.halo.app.notification; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import java.util.HashMap; import java.util.Set; import lombok.Data; import lombok.Getter; import org.springframework.lang.NonNull; /** * Notification preference of user. * * @author guqing * @since 2.10.0 */ @Getter public class UserNotificationPreference { private static final String DEFAULT_NOTIFIER = "default-email-notifier"; private final ReasonTypeNotifier reasonTypeNotifier = new ReasonTypeNotifier(); public static class ReasonTypeNotifier extends HashMap { /** * Gets notifiers by reason type. * * @param reasonType reason type * @return if key of reasonType not exists, return default notifier, otherwise return the * notifiers */ @NonNull public Set getNotifiers(String reasonType) { var result = this.get(reasonType); return result == null ? Set.of(DEFAULT_NOTIFIER) : defaultIfNull(result.getNotifiers(), Set.of()); } } @Data public static class NotifierSetting { private Set notifiers; } } ================================================ FILE: application/src/main/java/run/halo/app/notification/UserNotificationPreferenceService.java ================================================ package run.halo.app.notification; import reactor.core.publisher.Mono; /** * User notification preference service. * * @author guqing * @since 2.10.0 */ public interface UserNotificationPreferenceService { Mono getByUser(String username); Mono saveByUser(String username, UserNotificationPreference userNotificationPreference); } ================================================ FILE: application/src/main/java/run/halo/app/notification/UserNotificationPreferenceServiceImpl.java ================================================ package run.halo.app.notification; import java.util.HashMap; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.JsonUtils; /** * User notification preference service implementation. * * @author guqing * @since 2.10.0 */ @Component @RequiredArgsConstructor public class UserNotificationPreferenceServiceImpl implements UserNotificationPreferenceService { public static final String NOTIFICATION_PREFERENCE = "notification"; private final ReactiveExtensionClient client; @Override public Mono getByUser(String username) { var configName = buildUserPreferenceConfigMapName(username); return client.fetch(ConfigMap.class, configName) .map(config -> { if (config.getData() == null) { return new UserNotificationPreference(); } String s = config.getData().get(NOTIFICATION_PREFERENCE); if (StringUtils.isNotBlank(s)) { return JsonUtils.jsonToObject(s, UserNotificationPreference.class); } return new UserNotificationPreference(); }) .defaultIfEmpty(new UserNotificationPreference()); } @Override public Mono saveByUser(String username, UserNotificationPreference userNotificationPreference) { var configName = buildUserPreferenceConfigMapName(username); return client.fetch(ConfigMap.class, configName) .switchIfEmpty(Mono.defer(() -> { var configMap = new ConfigMap(); configMap.setMetadata(new Metadata()); configMap.getMetadata().setName(configName); return client.create(configMap); })) .flatMap(config -> { if (config.getData() == null) { config.setData(new HashMap<>()); } config.getData().put(NOTIFICATION_PREFERENCE, JsonUtils.objectToJson(userNotificationPreference)); return client.update(config); }) .then(); } static String buildUserPreferenceConfigMapName(String username) { return "user-preferences-" + username; } } ================================================ FILE: application/src/main/java/run/halo/app/notification/UserNotificationQuery.java ================================================ package run.halo.app.notification; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToListOptions; import org.apache.commons.lang3.StringUtils; import org.springframework.web.server.ServerWebExchange; import run.halo.app.extension.ListOptions; import run.halo.app.extension.router.SortableRequest; /** * Notification query object for authenticated user. * * @author guqing * @since 2.10.0 */ public class UserNotificationQuery extends SortableRequest { private final String username; public UserNotificationQuery(ServerWebExchange exchange, String username) { super(exchange); this.username = username; } /** * Build a list options from the query object. */ @Override public ListOptions toListOptions() { var listOptions = labelAndFieldSelectorToListOptions(getLabelSelector(), getFieldSelector()); var builder = ListOptions.builder(listOptions); if (StringUtils.isNotBlank(username)) { builder.andQuery(equal("spec.recipient", username)); } return builder.build(); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/UserNotificationService.java ================================================ package run.halo.app.notification; import java.util.List; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.notification.Notification; import run.halo.app.extension.ListResult; /** * Notification service. * * @author guqing * @since 2.10.0 */ public interface UserNotificationService { /** * List notifications for the authenticated user. * * @param query query object * @return a page result of notifications */ Mono> listByUser(String username, UserNotificationQuery query); /** * Mark the specified notification as read. * * @param name notification name * @return read notification */ Mono markAsRead(String username, String name); /** * Mark the specified notifications as read. * * @param names the names of notifications * @return the names of read notification that has been marked as read */ Flux markSpecifiedAsRead(String username, List names); Mono deleteByName(String username, String name); } ================================================ FILE: application/src/main/java/run/halo/app/notification/endpoint/ConsoleNotifierEndpoint.java ================================================ package run.halo.app.notification.endpoint; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.oas.annotations.enums.ParameterIn; import lombok.RequiredArgsConstructor; import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.notification.NotifierConfigStore; /** * Custom notifier endpoint. * * @author guqing * @since 2.10.0 */ @Component @RequiredArgsConstructor public class ConsoleNotifierEndpoint implements CustomEndpoint { private final NotifierConfigStore notifierConfigStore; @Override public RouterFunction endpoint() { var tag = "NotifierV1alpha1Console"; return SpringdocRouteBuilder.route() .GET("/notifiers/{name}/sender-config", this::fetchSenderConfig, builder -> builder.operationId("FetchSenderConfig") .description("Fetch sender config of notifier") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Notifier name") .required(true) ) .response(responseBuilder().implementation(Object.class)) ) .POST("/notifiers/{name}/sender-config", this::saveSenderConfig, builder -> builder.operationId("SaveSenderConfig") .description("Save sender config of notifier") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Notifier name") .required(true) ) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder().implementation(Object.class)) ) ) .response(responseBuilder().implementation(Void.class)) ) .build(); } private Mono fetchSenderConfig(ServerRequest request) { var name = request.pathVariable("name"); return notifierConfigStore.fetchSenderConfig(name) .flatMap(config -> ServerResponse.ok().bodyValue(config)); } private Mono saveSenderConfig(ServerRequest request) { var name = request.pathVariable("name"); return request.bodyToMono(ObjectNode.class) .switchIfEmpty(Mono.error( () -> new ServerWebInputException("Request body must not be empty.")) ) .flatMap(jsonNode -> notifierConfigStore.saveSenderConfig(name, jsonNode)) .then(ServerResponse.ok().build()); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/endpoint/EmailConfigValidationEndpoint.java ================================================ package run.halo.app.notification.endpoint; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import io.swagger.v3.oas.annotations.media.Schema; import java.security.Principal; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.mail.MailException; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.notification.EmailSenderHelper; /** * Validation endpoint for email config. * * @author guqing * @since 2.14.0 */ @Slf4j @Component @RequiredArgsConstructor public class EmailConfigValidationEndpoint implements CustomEndpoint { private static final String EMAIL_SUBJECT = "测试邮件 - 验证邮箱连通性"; private static final String EMAIL_BODY = """ 你好!
这是一封测试邮件,旨在验证您的邮箱发件配置是否正确。
此邮件由系统自动发送,请勿回复。
祝好 """; private final EmailSenderHelper emailSenderHelper; private final ReactiveExtensionClient client; @Override public RouterFunction endpoint() { var tag = "NotifierV1alpha1Console"; return SpringdocRouteBuilder.route() .POST("/notifiers/default-email-notifier/verify-connection", this::verifyEmailSenderConfig, builder -> builder.operationId("VerifyEmailSenderConfig") .description("Verify email sender config.") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .implementation(ValidationRequest.class) ) .response(responseBuilder().implementation(Void.class)) ) .build(); } private Mono verifyEmailSenderConfig(ServerRequest request) { return request.bodyToMono(ValidationRequest.class) .switchIfEmpty( Mono.error(new ServerWebInputException("Required request body is missing.")) ) .flatMap(validationRequest -> getCurrentUserEmail() .flatMap(recipient -> { var mailSender = emailSenderHelper.createJavaMailSender(validationRequest); var message = emailSenderHelper.createMimeMessagePreparator(validationRequest, recipient, EMAIL_SUBJECT, EMAIL_BODY, EMAIL_BODY); try { mailSender.send(message); } catch (MailException e) { String errorMsg = "Failed to send email, please check your email configuration."; log.error(errorMsg, e); throw new ServerWebInputException(errorMsg, null, e); } return ServerResponse.ok().build(); }) ); } Mono getCurrentUserEmail() { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(Principal::getName) .flatMap(username -> client.fetch(User.class, username)) .flatMap(user -> { var email = user.getSpec().getEmail(); if (StringUtils.isBlank(email)) { return Mono.error(new ServerWebInputException( "Your email is missing, please set it in your profile.")); } return Mono.just(email); }); } @Data @EqualsAndHashCode(callSuper = true) @Schema(name = "EmailConfigValidationRequest") static class ValidationRequest extends EmailSenderHelper.EmailSenderConfig { } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("console.api.notification.halo.run/v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/endpoint/SubscriptionRouter.java ================================================ package run.halo.app.notification.endpoint; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.net.URI; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.context.annotation.Bean; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.util.UriComponentsBuilder; import reactor.core.publisher.Mono; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.ExternalUrlSupplier; /** * A router for {@link Subscription}. * * @author guqing * @since 2.10.0 */ @Component @RequiredArgsConstructor public class SubscriptionRouter { public static final String UNSUBSCRIBE_PATTERN = "/apis/api.notification.halo.run/v1alpha1/subscriptions/{name}/unsubscribe"; private final ExternalUrlSupplier externalUrlSupplier; private final ReactiveExtensionClient client; @Bean RouterFunction notificationSubscriptionRouter() { final var tag = "NotificationV1alpha1Public"; return SpringdocRouteBuilder.route() .GET(UNSUBSCRIBE_PATTERN, this::unsubscribe, builder -> { builder.operationId("Unsubscribe") .tag(tag) .description("Unsubscribe a subscription") .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Subscription name") .required(true) ).parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("token") .description("Unsubscribe token") .required(true) ) .response(responseBuilder().implementation(String.class)) .build(); }) .build(); } Mono unsubscribe(ServerRequest request) { var name = request.pathVariable("name"); var token = request.queryParam("token").orElse(""); return client.fetch(Subscription.class, name) .filter(subscription -> { var unsubscribeToken = subscription.getSpec().getUnsubscribeToken(); return StringUtils.equals(token, unsubscribeToken); }) .flatMap(client::delete) .then(Mono.defer(() -> ServerResponse.ok() .contentType(MediaType.TEXT_PLAIN) .bodyValue("Unsubscribe successfully.")) ); } /** * Gets unsubscribe url from the given subscription. * * @param subscription subscription must not be null * @return unsubscribe url */ public String getUnsubscribeUrl(Subscription subscription) { var name = subscription.getMetadata().getName(); var token = subscription.getSpec().getUnsubscribeToken(); var externalUrl = defaultIfNull(externalUrlSupplier.getRaw(), URI.create("/")); return UriComponentsBuilder.fromUriString(externalUrl.toString()) .path(UNSUBSCRIBE_PATTERN) .queryParam("token", token) .build(name) .toString(); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/endpoint/UserNotificationEndpoint.java ================================================ package run.halo.app.notification.endpoint; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.util.List; import java.util.function.Supplier; import lombok.RequiredArgsConstructor; import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RequestPredicates; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.notification.Notification; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListResult; import run.halo.app.notification.UserNotificationQuery; import run.halo.app.notification.UserNotificationService; /** * Custom notification endpoint to managing notification for authenticated user. * * @author guqing * @since 2.10.0 */ @Component @RequiredArgsConstructor public class UserNotificationEndpoint implements CustomEndpoint { private final UserNotificationService notificationService; @Override public RouterFunction endpoint() { return SpringdocRouteBuilder.route() .nest(RequestPredicates.path("/userspaces/{username}"), userspaceScopedApis()) .build(); } Supplier> userspaceScopedApis() { var tag = "NotificationV1alpha1Uc"; return () -> SpringdocRouteBuilder.route() .GET("/notifications", this::listNotification, builder -> { builder.operationId("ListUserNotifications") .description("List notifications for the authenticated user.") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("username") .description("Username") .required(true) ) .response(responseBuilder() .implementation(ListResult.generateGenericClass(Notification.class)) ); UserNotificationQuery.buildParameters(builder); } ) .PUT("/notifications/{name}/mark-as-read", this::markNotificationAsRead, builder -> builder.operationId("MarkNotificationAsRead") .description("Mark the specified notification as read.") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("username") .description("Username") .required(true) ) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Notification name") .required(true) ) .response(responseBuilder().implementation(Notification.class)) ) .PUT("/notifications/-/mark-specified-as-read", this::markNotificationsAsRead, builder -> builder.operationId("MarkNotificationsAsRead") .description("Mark the specified notifications as read.") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("username") .description("Username") .required(true) ) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() .implementation(MarkSpecifiedRequest.class)) ) ) .response(responseBuilder().implementationArray(String.class)) ) .DELETE("/notifications/{name}", this::deleteNotification, builder -> builder.operationId("DeleteSpecifiedNotification") .description("Delete the specified notification.") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("username") .description("Username") .required(true) ) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Notification name") .required(true) ) .response(responseBuilder().implementation(Notification.class)) ) .build(); } private Mono deleteNotification(ServerRequest request) { var name = request.pathVariable("name"); var username = request.pathVariable("username"); return notificationService.deleteByName(username, name) .flatMap(notification -> ServerResponse.ok().bodyValue(notification)); } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("api.notification.halo.run/v1alpha1"); } record MarkSpecifiedRequest(List names) { } private Mono listNotification(ServerRequest request) { var username = request.pathVariable("username"); var query = new UserNotificationQuery(request.exchange(), username); return notificationService.listByUser(username, query) .flatMap(notifications -> ServerResponse.ok().bodyValue(notifications)); } private Mono markNotificationAsRead(ServerRequest request) { var username = request.pathVariable("username"); var name = request.pathVariable("name"); return notificationService.markAsRead(username, name) .flatMap(notification -> ServerResponse.ok().bodyValue(notification)); } Mono markNotificationsAsRead(ServerRequest request) { var username = request.pathVariable("username"); return request.bodyToMono(MarkSpecifiedRequest.class) .flatMapMany( requestBody -> notificationService.markSpecifiedAsRead(username, requestBody.names)) .collectList() .flatMap(names -> ServerResponse.ok().bodyValue(names)); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/endpoint/UserNotificationPreferencesEndpoint.java ================================================ package run.halo.app.notification.endpoint; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import com.fasterxml.jackson.core.type.TypeReference; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Function; import java.util.function.Supplier; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.experimental.Accessors; import org.apache.commons.lang3.StringUtils; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RequestPredicates; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import reactor.util.function.Tuples; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.notification.NotifierDescriptor; import run.halo.app.core.extension.notification.ReasonType; import run.halo.app.extension.Comparators; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.notification.UserNotificationPreference; import run.halo.app.notification.UserNotificationPreferenceService; /** * Endpoint for user notification preferences. * * @author guqing * @since 2.10.0 */ @Component @RequiredArgsConstructor public class UserNotificationPreferencesEndpoint implements CustomEndpoint { private final ReactiveExtensionClient client; private final UserNotificationPreferenceService userNotificationPreferenceService; @Override public RouterFunction endpoint() { return SpringdocRouteBuilder.route() .nest(RequestPredicates.path("/userspaces/{username}"), userspaceScopedApis()) .build(); } Supplier> userspaceScopedApis() { var tag = "NotificationV1alpha1Uc"; return () -> SpringdocRouteBuilder.route() .GET("/notification-preferences", this::listNotificationPreferences, builder -> builder.operationId("ListUserNotificationPreferences") .description("List notification preferences for the authenticated user.") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("username") .description("Username") .required(true) ) .response(responseBuilder() .implementation(ReasonTypeNotifierMatrix.class) ) ) .POST("/notification-preferences", this::saveNotificationPreferences, builder -> builder.operationId("SaveUserNotificationPreferences") .description("Save notification preferences for the authenticated user.") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("username") .description("Username") .required(true) ) .requestBody(requestBodyBuilder() .implementation(ReasonTypeNotifierCollectionRequest.class) ) .response(responseBuilder().implementation(ReasonTypeNotifierMatrix.class)) ) .build(); } private Mono saveNotificationPreferences(ServerRequest request) { var username = request.pathVariable("username"); return request.bodyToMono(ReasonTypeNotifierCollectionRequest.class) .flatMap(requestBody -> { var reasonTypNotifiers = requestBody.reasonTypeNotifiers(); return userNotificationPreferenceService.getByUser(username) .flatMap(preference -> { var reasonTypeNotifierMap = preference.getReasonTypeNotifier(); reasonTypeNotifierMap.clear(); reasonTypNotifiers.forEach(reasonTypeNotifierRequest -> { var reasonType = reasonTypeNotifierRequest.getReasonType(); var notifiers = reasonTypeNotifierRequest.getNotifiers(); var notifierSetting = new UserNotificationPreference.NotifierSetting(); notifierSetting.setNotifiers( notifiers == null ? Set.of() : Set.copyOf(notifiers)); reasonTypeNotifierMap.put(reasonType, notifierSetting); }); return userNotificationPreferenceService.saveByUser(username, preference); }); }) .then(Mono.defer(() -> listReasonTypeNotifierMatrix(username) .flatMap(result -> ServerResponse.ok().bodyValue(result))) ); } private Mono listNotificationPreferences(ServerRequest request) { var username = request.pathVariable("username"); return listReasonTypeNotifierMatrix(username) .flatMap(matrix -> ServerResponse.ok().bodyValue(matrix)); } @NonNull private static Map toNameIndexMap(List collection, Function nameGetter) { Map indexMap = new HashMap<>(); for (int i = 0; i < collection.size(); i++) { var item = collection.get(i); indexMap.put(nameGetter.apply(item), i); } return indexMap; } Mono listReasonTypeNotifierMatrix(String username) { var listOptions = ListOptions.builder() .labelSelector() .notExists(MetadataUtil.HIDDEN_LABEL) .end() .build(); return client.listAll(ReasonType.class, listOptions, ExtensionUtil.defaultSort()) .map(ReasonTypeInfo::from) .collectList() .flatMap(reasonTypes -> client.list(NotifierDescriptor.class, null, Comparators.defaultComparator()) .map(notifier -> new NotifierInfo(notifier.getMetadata().getName(), notifier.getSpec().getDisplayName(), notifier.getSpec().getDescription()) ) .collectList() .map(notifiers -> { var matrix = new ReasonTypeNotifierMatrix() .setReasonTypes(reasonTypes) .setNotifiers(notifiers) .setStateMatrix(new boolean[reasonTypes.size()][notifiers.size()]); return Tuples.of(reasonTypes, matrix); }) ) .flatMap(tuple2 -> { var reasonTypes = tuple2.getT1(); var matrix = tuple2.getT2(); var reasonTypeIndexMap = toNameIndexMap(reasonTypes, ReasonTypeInfo::name); var notifierIndexMap = toNameIndexMap(matrix.getNotifiers(), NotifierInfo::name); var stateMatrix = matrix.getStateMatrix(); return userNotificationPreferenceService.getByUser(username) .doOnNext(preference -> { var reasonTypeNotifierMap = preference.getReasonTypeNotifier(); for (ReasonTypeInfo reasonType : reasonTypes) { var reasonTypeIndex = reasonTypeIndexMap.get(reasonType.name()); var notifierNames = reasonTypeNotifierMap.getNotifiers(reasonType.name()); for (String notifierName : notifierNames) { // Skip if the notifier enabled in the user preference does not // exist to avoid null index if (!notifierIndexMap.containsKey(notifierName)) { continue; } var notifierIndex = notifierIndexMap.get(notifierName); stateMatrix[reasonTypeIndex][notifierIndex] = true; } } }) .thenReturn(matrix); }); } @Data @Accessors(chain = true) static class ReasonTypeNotifierMatrix { private List reasonTypes; private List notifiers; private boolean[][] stateMatrix; } record ReasonTypeInfo(String name, String displayName, String description, Set uiPermissions) { public static ReasonTypeInfo from(ReasonType reasonType) { var uiPermissions = Optional.of(MetadataUtil.nullSafeAnnotations(reasonType)) .map(annotations -> annotations.get(Role.UI_PERMISSIONS_ANNO)) .filter(StringUtils::isNotBlank) .map(uiPermissionStr -> JsonUtils.jsonToObject(uiPermissionStr, new TypeReference>() { }) ) .orElse(Set.of()); return new ReasonTypeInfo(reasonType.getMetadata().getName(), reasonType.getSpec().getDisplayName(), reasonType.getSpec().getDescription(), uiPermissions); } } record NotifierInfo(String name, String displayName, String description) { } record ReasonTypeNotifierCollectionRequest( @Schema(requiredMode = REQUIRED) List reasonTypeNotifiers) { } @Data static class ReasonTypeNotifierRequest { private String reasonType; private List notifiers; } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("api.notification.halo.run/v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/notification/endpoint/UserNotifierEndpoint.java ================================================ package run.halo.app.notification.endpoint; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.oas.annotations.enums.ParameterIn; import lombok.RequiredArgsConstructor; import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.notification.NotifierConfigStore; /** * Notifier endpoint for user center. * * @author guqing * @since 2.10.0 */ @Component @RequiredArgsConstructor public class UserNotifierEndpoint implements CustomEndpoint { private final NotifierConfigStore notifierConfigStore; @Override public RouterFunction endpoint() { var tag = "NotifierV1alpha1Uc"; return SpringdocRouteBuilder.route() .GET("/notifiers/{name}/receiver-config", this::fetchReceiverConfig, builder -> builder.operationId("FetchReceiverConfig") .description("Fetch receiver config of notifier") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Notifier name") .required(true) ) .response(responseBuilder().implementation(Object.class)) ) .POST("/notifiers/{name}/receiver-config", this::saveReceiverConfig, builder -> builder.operationId("SaveReceiverConfig") .description("Save receiver config of notifier") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .description("Notifier name") .required(true) ) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder().implementation(Object.class)) ) ) .response(responseBuilder().implementation(Void.class)) ) .build(); } private Mono fetchReceiverConfig(ServerRequest request) { var name = request.pathVariable("name"); return notifierConfigStore.fetchReceiverConfig(name) .flatMap(config -> ServerResponse.ok().bodyValue(config)); } private Mono saveReceiverConfig(ServerRequest request) { var name = request.pathVariable("name"); return request.bodyToMono(ObjectNode.class) .switchIfEmpty(Mono.error( () -> new ServerWebInputException("Request body must not be empty.")) ) .flatMap(jsonNode -> notifierConfigStore.saveReceiverConfig(name, jsonNode)) .then(ServerResponse.ok().build()); } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("api.notification.halo.run/v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/AggregatedRouterFunction.java ================================================ package run.halo.app.plugin; import org.springframework.beans.factory.ObjectProvider; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.endpoint.console.CustomEndpointsBuilder; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.infra.SecureServerRequest; /** * Aggregated router function built from all custom endpoints. * * @author johnniang */ public class AggregatedRouterFunction implements RouterFunction { private final RouterFunction aggregated; public AggregatedRouterFunction(ObjectProvider customEndpoints) { var builder = new CustomEndpointsBuilder(); customEndpoints.orderedStream() .forEach(builder::add); this.aggregated = builder.build(); } @Override public Mono> route(ServerRequest request) { return aggregated.route(new SecureServerRequest(request)); } @Override public void accept(RouterFunctions.Visitor visitor) { this.aggregated.accept(visitor); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/BuiltInPluginsInitializer.java ================================================ package run.halo.app.plugin; import java.io.FileNotFoundException; import java.io.IOException; import java.nio.file.Path; import java.time.Duration; import java.util.HashMap; import java.util.HashSet; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationListener; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.core.io.support.ResourcePatternResolver; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import run.halo.app.core.extension.Plugin; import run.halo.app.extension.ExtensionClient; import run.halo.app.infra.ExtensionInitializedEvent; import run.halo.app.infra.exception.PluginAlreadyExistsException; @Slf4j @Component class BuiltInPluginsInitializer implements ApplicationListener { private static final String PRESETS_LOCATION_PATTERN = "classpath:/plugins/built-in/*.jar"; private final ExtensionClient client; private final PluginService pluginService; private ResourcePatternResolver resourcePatternResolver; private PluginFinder pluginFinder; BuiltInPluginsInitializer(ExtensionClient client, PluginService pluginService) { this.client = client; this.pluginService = pluginService; this.resourcePatternResolver = new PathMatchingResourcePatternResolver(); this.pluginFinder = new YamlPluginFinder(); } /** * Only for testing purpose. * * @param resourcePatternResolver resource pattern resolver */ void setResourcePatternResolver(ResourcePatternResolver resourcePatternResolver) { Assert.notNull(resourcePatternResolver, "ResourcePatternResolver must not be null"); this.resourcePatternResolver = resourcePatternResolver; } /** * Only for testing purpose. * * @param pluginFinder plugin finder */ void setPluginFinder(PluginFinder pluginFinder) { this.pluginFinder = pluginFinder; } @Override public void onApplicationEvent(ExtensionInitializedEvent event) { try { for (var resource : resourcePatternResolver.getResources(PRESETS_LOCATION_PATTERN)) { var filename = resource.getFilename(); if (filename == null) { continue; } var pluginPath = Path.of(resource.getURI()); var preflightPlugin = pluginFinder.find(pluginPath); var pluginName = preflightPlugin.getMetadata().getName(); log.info("Try to installing built-in plugin '{}'...", pluginName); var plugin = pluginService.install(pluginPath) .doOnNext(created -> { log.info("Built-in plugin '{}' has been installed.", created.getMetadata().getName()); }) .onErrorResume(PluginAlreadyExistsException.class, e -> { log.info("Built-in plugin '{}' already installed, trying to upgrade...", pluginName); return pluginService.upgrade(pluginName, pluginPath) .doOnNext(updated -> log.info("Built-in plugin '{}' has been upgraded.", updated.getMetadata().getName())); }) .blockOptional(Duration.ofSeconds(10)).orElseThrow( () -> new IllegalStateException( "Failed to install or upgrade built-in plugin '" + pluginName + "'" ) ); // try to update metadata to add system reserved label and finalizer var metadata = plugin.getMetadata(); metadata.setDeletionTimestamp(null); if (metadata.getLabels() == null) { metadata.setLabels(new HashMap<>()); } metadata.getLabels().put(Plugin.SYSTEM_RESERVED_LABEL_KEY, Boolean.TRUE.toString()); if (metadata.getFinalizers() == null) { metadata.setFinalizers(new HashSet<>()); } metadata.getFinalizers().add(Plugin.BUILT_IN_KEEPER_FINALIZER); client.update(plugin); } } catch (FileNotFoundException ignored) { // should never happen log.warn("No built-in plugins found."); } catch (IOException e) { throw new RuntimeException(e); } } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/DefaultDevelopmentPluginRepository.java ================================================ package run.halo.app.plugin; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import org.pf4j.DevelopmentPluginRepository; import org.springframework.util.CollectionUtils; /** *

A {@link org.pf4j.PluginRepository} implementation that can add fixed plugin paths for * development {@link org.pf4j.RuntimeMode#DEVELOPMENT}.

*

change {@link #deletePluginPath(Path)} to a no-op method.

* Note: This class is not thread-safe. * * @author guqing * @since 2.0.0 */ public class DefaultDevelopmentPluginRepository extends DevelopmentPluginRepository { private final List fixedPaths = new ArrayList<>(); public DefaultDevelopmentPluginRepository(Path... pluginsRoots) { super(pluginsRoots); } public DefaultDevelopmentPluginRepository(List pluginsRoots) { super(pluginsRoots); } public void setFixedPaths(List paths) { if (CollectionUtils.isEmpty(paths)) { return; } fixedPaths.clear(); fixedPaths.addAll(paths); } @Override public List getPluginPaths() { List paths = new ArrayList<>(fixedPaths); paths.addAll(super.getPluginPaths()); return paths; } @Override public boolean deletePluginPath(Path pluginPath) { // If the plugin path is not included in the fixed paths, // return false and give another repository a chance. // // Meanwhile, there is no need to physically delete the plugin here. return fixedPaths.remove(pluginPath); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/DefaultPluginApplicationContextFactory.java ================================================ package run.halo.app.plugin; import static org.springframework.util.ResourceUtils.CLASSPATH_URL_PREFIX; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; import lombok.extern.slf4j.Slf4j; import org.pf4j.PluginRuntimeException; import org.springframework.beans.factory.support.DefaultBeanNameGenerator; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.boot.env.PropertySourceLoader; import org.springframework.boot.env.YamlPropertySourceLoader; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationListener; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.context.event.EventListener; import org.springframework.core.ResolvableType; import org.springframework.core.env.PropertySource; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.stereotype.Controller; import org.springframework.util.StopWatch; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.Exceptions; import run.halo.app.core.endpoint.WebSocketEndpoint; import run.halo.app.core.endpoint.WebSocketEndpointManager; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.plugin.event.HaloPluginBeforeStopEvent; import run.halo.app.plugin.event.HaloPluginStartedEvent; import run.halo.app.plugin.event.HaloPluginStoppedEvent; import run.halo.app.plugin.event.SpringPluginStartedEvent; import run.halo.app.plugin.event.SpringPluginStoppedEvent; import run.halo.app.plugin.event.SpringPluginStoppingEvent; import run.halo.app.search.SearchService; import run.halo.app.theme.DefaultTemplateNameResolver; import run.halo.app.theme.ViewNameResolver; import run.halo.app.theme.finders.FinderRegistry; @Slf4j public class DefaultPluginApplicationContextFactory implements PluginApplicationContextFactory { private final SpringPluginManager pluginManager; public DefaultPluginApplicationContextFactory(SpringPluginManager pluginManager) { this.pluginManager = pluginManager; } /** * Create and refresh application context. Make sure the plugin has already loaded * before. * * @param pluginId plugin id * @return refresh application context for the plugin. */ @Override public ApplicationContext create(String pluginId) { log.debug("Preparing to create application context for plugin {}", pluginId); var sw = new StopWatch("CreateApplicationContextFor" + pluginId); sw.start("Create"); var pluginWrapper = pluginManager.getPlugin(pluginId); var classLoader = pluginWrapper.getPluginClassLoader(); /* * Manually creating a BeanFactory and setting the plugin's ClassLoader is necessary * to ensure that conditional annotations (e.g., @ConditionalOnClass) within the plugin * context can correctly load classes. * When PluginApplicationContext is created, its constructor initializes an * AnnotatedBeanDefinitionReader, which in turn creates a ConditionEvaluator. * ConditionEvaluator is responsible for evaluating conditional annotations such as * @ConditionalOnClass. * It relies on the BeanDefinitionRegistry's ClassLoader to load the classes specified in * the annotations. * Without explicitly setting the plugin's ClassLoader, the default application * ClassLoader is used, which fails to load classes from the plugin. * Therefore, a custom DefaultListableBeanFactory with the plugin ClassLoader is required * to resolve this issue. */ var beanFactory = new DefaultListableBeanFactory(); beanFactory.setBeanClassLoader(classLoader); var context = new PluginApplicationContext(pluginId, pluginManager, beanFactory); context.setBeanNameGenerator(DefaultBeanNameGenerator.INSTANCE); context.registerShutdownHook(); context.setParent(pluginManager.getSharedContext()); var resourceLoader = new DefaultResourceLoader(classLoader); context.setResourceLoader(resourceLoader); sw.stop(); sw.start("LoadPropertySources"); var mutablePropertySources = context.getEnvironment().getPropertySources(); resolvePropertySources(pluginId, resourceLoader) .forEach(mutablePropertySources::addLast); sw.stop(); sw.start("RegisterBeans"); beanFactory.registerSingleton("pluginWrapper", pluginWrapper); context.registerBean(AggregatedRouterFunction.class); if (pluginWrapper.getPlugin() instanceof SpringPlugin springPlugin) { beanFactory.registerSingleton("pluginContext", springPlugin.getPluginContext()); } var rootContext = pluginManager.getRootContext(); rootContext.getBeanProvider(ViewNameResolver.class) .ifAvailable(viewNameResolver -> { var templateNameResolver = new DefaultTemplateNameResolver(viewNameResolver, context); beanFactory.registerSingleton("templateNameResolver", templateNameResolver); }); rootContext.getBeanProvider(ReactiveExtensionClient.class) .ifUnique(client -> { context.registerBean("reactiveSettingFetcher", DefaultReactiveSettingFetcher.class); context.registerBean("settingFetcher", DefaultSettingFetcher.class); }); rootContext.getBeanProvider(PluginRequestMappingHandlerMapping.class) .ifAvailable(handlerMapping -> { var handlerMappingManager = new PluginHandlerMappingManager(pluginId, handlerMapping); beanFactory.registerSingleton("pluginHandlerMappingManager", handlerMappingManager); }); context.registerBean(PluginControllerManager.class); beanFactory.registerSingleton("springPluginStoppedEventAdapter", new SpringPluginStoppedEventAdapter(pluginId)); beanFactory.registerSingleton("haloPluginEventBridge", new HaloPluginEventBridge()); rootContext.getBeanProvider(FinderRegistry.class) .ifAvailable(finderRegistry -> { var finderManager = new FinderManager(pluginId, finderRegistry); beanFactory.registerSingleton("finderManager", finderManager); }); rootContext.getBeanProvider(WebSocketEndpointManager.class) .ifUnique(manager -> beanFactory.registerSingleton("pluginWebSocketEndpointManager", new PluginWebSocketEndpointManager(manager))); rootContext.getBeanProvider(PluginRouterFunctionRegistry.class) .ifUnique(registry -> { var pluginRouterFunctionManager = new PluginRouterFunctionManager(registry); beanFactory.registerSingleton( "pluginRouterFunctionManager", pluginRouterFunctionManager ); }); rootContext.getBeanProvider(SearchService.class) .ifUnique(searchService -> beanFactory.registerSingleton("searchService", searchService) ); sw.stop(); sw.start("LoadComponents"); var classNames = pluginManager.getExtensionClassNames(pluginId); classNames.stream() .map(className -> { try { return classLoader.loadClass(className); } catch (ClassNotFoundException e) { throw new PluginRuntimeException(String.format(""" Failed to load class %s for plugin %s.\ """, className, pluginId), e); } }) .forEach(clazzName -> context.registerBean(clazzName)); sw.stop(); log.debug("Created application context for plugin {}", pluginId); log.debug("Refreshing application context for plugin {}", pluginId); sw.start("Refresh"); // Set the context ClassLoader to the plugin ClassLoader to ensure that // any class loading operations performed by the context (e.g., initializing // bean definitions, loading class resources during static initialization) // use the correct ClassLoader. var previous = Thread.currentThread().getContextClassLoader(); try { Thread.currentThread().setContextClassLoader(classLoader); context.refresh(); } finally { // reset the class loader to previous one to prevent resource leak Thread.currentThread().setContextClassLoader(previous); } sw.stop(); log.debug("Refreshed application context for plugin {}", pluginId); if (log.isDebugEnabled()) { log.debug("\n{}", sw.prettyPrint(TimeUnit.MILLISECONDS)); } return context; } private static class FinderManager { private final String pluginId; private final FinderRegistry finderRegistry; private FinderManager(String pluginId, FinderRegistry finderRegistry) { this.pluginId = pluginId; this.finderRegistry = finderRegistry; } @EventListener public void onApplicationEvent(ContextClosedEvent ignored) { this.finderRegistry.unregister(this.pluginId); } @EventListener public void onApplicationEvent(ContextRefreshedEvent event) { this.finderRegistry.register(this.pluginId, event.getApplicationContext()); } } private static class PluginWebSocketEndpointManager { private final WebSocketEndpointManager manager; private List endpoints; private PluginWebSocketEndpointManager(WebSocketEndpointManager manager) { this.manager = manager; } @EventListener public void onApplicationEvent(ContextRefreshedEvent event) { var context = event.getApplicationContext(); this.endpoints = context.getBeanProvider(WebSocketEndpoint.class) .orderedStream() .toList(); manager.register(this.endpoints); } @EventListener public void onApplicationEvent(ContextClosedEvent ignored) { manager.unregister(this.endpoints); } } private static class PluginRouterFunctionManager { private final PluginRouterFunctionRegistry routerFunctionRegistry; private Collection> routerFunctions; private PluginRouterFunctionManager(PluginRouterFunctionRegistry routerFunctionRegistry) { this.routerFunctionRegistry = routerFunctionRegistry; } @EventListener public void onApplicationEvent(ContextClosedEvent ignored) { if (routerFunctions != null) { routerFunctionRegistry.unregister(routerFunctions); } } @EventListener public void onApplicationEvent(ContextRefreshedEvent event) { var routerFunctions = event.getApplicationContext() .>getBeanProvider( ResolvableType.forClassWithGenerics(RouterFunction.class, ServerResponse.class) ) .orderedStream() .toList(); routerFunctionRegistry.register(routerFunctions); this.routerFunctions = routerFunctions; } } private static class PluginHandlerMappingManager { private final String pluginId; private final PluginRequestMappingHandlerMapping handlerMapping; private PluginHandlerMappingManager(String pluginId, PluginRequestMappingHandlerMapping handlerMapping) { this.pluginId = pluginId; this.handlerMapping = handlerMapping; } @EventListener public void onApplicationEvent(ContextRefreshedEvent event) { var context = event.getApplicationContext(); context.getBeansWithAnnotation(Controller.class) .values() .forEach(controller -> handlerMapping.registerHandlerMethods(this.pluginId, controller) ); } @EventListener public void onApplicationEvent(ContextClosedEvent ignored) { handlerMapping.unregister(this.pluginId); } } private class SpringPluginStoppedEventAdapter implements ApplicationListener { private final String pluginId; private SpringPluginStoppedEventAdapter(String pluginId) { this.pluginId = pluginId; } @Override public void onApplicationEvent(ContextClosedEvent event) { var plugin = pluginManager.getPlugin(pluginId).getPlugin(); if (plugin instanceof SpringPlugin springPlugin) { event.getApplicationContext() .publishEvent(new SpringPluginStoppedEvent(this, springPlugin)); } } } private class HaloPluginEventBridge { @EventListener public void onApplicationEvent(SpringPluginStartedEvent event) { var pluginContext = event.getSpringPlugin().getPluginContext(); var pluginWrapper = pluginManager.getPlugin(pluginContext.getName()); pluginManager.getRootContext() .publishEvent(new HaloPluginStartedEvent(this, pluginWrapper)); } @EventListener public void onApplicationEvent(SpringPluginStoppingEvent event) { var pluginContext = event.getSpringPlugin().getPluginContext(); var pluginWrapper = pluginManager.getPlugin(pluginContext.getName()); pluginManager.getRootContext() .publishEvent(new HaloPluginBeforeStopEvent(this, pluginWrapper)); } @EventListener public void onApplicationEvent(SpringPluginStoppedEvent event) { var pluginContext = event.getSpringPlugin().getPluginContext(); var pluginWrapper = pluginManager.getPlugin(pluginContext.getName()); pluginManager.getRootContext() .publishEvent(new HaloPluginStoppedEvent(this, pluginWrapper)); } } private List> resolvePropertySources(String pluginId, ResourceLoader resourceLoader) { var haloProperties = pluginManager.getRootContext() .getBeanProvider(HaloProperties.class) .getIfAvailable(); if (haloProperties == null) { return List.of(); } var propertySourceLoader = new YamlPropertySourceLoader(); var propertySources = new ArrayList>(); var configsPath = haloProperties.getWorkDir().resolve("plugins").resolve("configs"); // resolve user defined config Stream.of( configsPath.resolve(pluginId + ".yaml"), configsPath.resolve(pluginId + ".yml") ) .map(path -> resourceLoader.getResource(path.toUri().toString())) .forEach(resource -> { var sources = loadPropertySources("user-defined-config", resource, propertySourceLoader); propertySources.addAll(sources); }); // resolve default config Stream.of( CLASSPATH_URL_PREFIX + "/config.yaml", CLASSPATH_URL_PREFIX + "/config.yml" ) .map(resourceLoader::getResource) .forEach(resource -> { var sources = loadPropertySources("default-config", resource, propertySourceLoader); propertySources.addAll(sources); }); return propertySources; } private List> loadPropertySources(String propertySourceName, Resource resource, PropertySourceLoader propertySourceLoader) { if (log.isDebugEnabled()) { log.debug("Loading property sources from {}", resource); } if (!resource.exists()) { return List.of(); } try { return propertySourceLoader.load(propertySourceName, resource); } catch (IOException e) { throw Exceptions.propagate(e); } } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/DefaultPluginGetter.java ================================================ package run.halo.app.plugin; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import run.halo.app.core.extension.Plugin; import run.halo.app.extension.ExtensionClient; import run.halo.app.infra.exception.NotFoundException; /** * Default implementation of {@link PluginGetter}. * * @author guqing * @since 2.17.0 */ @Component @RequiredArgsConstructor public class DefaultPluginGetter implements PluginGetter { private final ExtensionClient client; @Override public Plugin getPlugin(String name) { if (StringUtils.isBlank(name)) { throw new IllegalArgumentException("Plugin name must not be blank"); } return client.fetch(Plugin.class, name) .orElseThrow(() -> new NotFoundException("Plugin not found")); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/DefaultPluginRouterFunctionRegistry.java ================================================ package run.halo.app.plugin; import java.util.Collection; import java.util.concurrent.CopyOnWriteArraySet; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.infra.SecureServerRequest; import run.halo.app.infra.exception.PluginRuntimeIncompatibleException; /** * A composite {@link RouterFunction} implementation for plugin. * * @author guqing * @since 2.0.0 */ @Component public class DefaultPluginRouterFunctionRegistry implements RouterFunction, PluginRouterFunctionRegistry { private final Collection> routerFunctions; public DefaultPluginRouterFunctionRegistry() { this.routerFunctions = new CopyOnWriteArraySet<>(); } @Override @NonNull public Mono> route(@NonNull ServerRequest request) { var secureRequest = new SecureServerRequest(request); return Flux.fromIterable(this.routerFunctions) .concatMap(routerFunction -> { // wrap the handler function return routerFunction.route(secureRequest) .map(hf -> (HandlerFunction) serverRequest -> { try { return hf.handle(secureRequest); } catch (LinkageError le) { return Mono.error(new PluginRuntimeIncompatibleException(le)); } } ); }) .next(); } @Override public void accept(@NonNull RouterFunctions.Visitor visitor) { this.routerFunctions.forEach(routerFunction -> routerFunction.accept(visitor)); } @Override public void register(Collection> routerFunctions) { this.routerFunctions.addAll(routerFunctions); } @Override public void unregister(Collection> routerFunctions) { this.routerFunctions.removeAll(routerFunctions); } /** * Only for testing. * * @return maintained router functions. */ Collection> getRouterFunctions() { return routerFunctions; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/DefaultReactiveSettingFetcher.java ================================================ package run.halo.app.plugin; import static run.halo.app.extension.index.query.Queries.equal; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import java.time.Duration; import java.util.Map; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Predicate; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.jspecify.annotations.Nullable; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import reactor.core.publisher.Mono; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionMatcher; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.infra.utils.SystemConfigUtils; import tools.jackson.core.JacksonException; import tools.jackson.databind.json.JsonMapper; /** * A default implementation of {@link ReactiveSettingFetcher}. * * @author guqing * @since 2.0.0 */ @Slf4j class DefaultReactiveSettingFetcher implements ReactiveSettingFetcher, Reconciler, ApplicationContextAware { private static final Duration TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private final JsonMapper mapper = JsonMapper.builder().build(); private final ObjectMapper v2Mapper = JsonUtils.mapper(); private final ReactiveExtensionClient client; private final Mono configMapCache; private final AtomicBoolean isCacheInvalidated = new AtomicBoolean(false); /** * The application context of the plugin. */ private ApplicationContext applicationContext; private final String pluginName; private final String configMapName; public DefaultReactiveSettingFetcher( PluginContext pluginContext, ReactiveExtensionClient client ) { this.client = client; this.pluginName = pluginContext.getName(); this.configMapName = pluginContext.getConfigMapName(); this.configMapCache = Mono.defer(() -> { if (StringUtils.isBlank(configMapName)) { return Mono.empty(); } return client.fetch(ConfigMap.class, configMapName); }).cacheInvalidateIf(cm -> isCacheInvalidated.getAndSet(false)); } @Override public Mono fetch(String group, Class clazz) { return getSettingValue(group) .map(n -> mapper.convertValue(n, clazz)); } @Override public Mono get(String group) { return getValues().mapNotNull(m -> m.get(group)) .switchIfEmpty(Mono.fromSupplier(v2Mapper::createObjectNode)); } @Override public Mono getSettingValue(String group) { return getSettingValues().mapNotNull(m -> m.get(group)); } @Override public Mono> getValues() { return configMapCache.mapNotNull(ConfigMap::getData) .map(this::toJackson2JsonNodeMap) .defaultIfEmpty(Map.of()); } @Override public Mono> getSettingValues() { return configMapCache.mapNotNull(ConfigMap::getData) .map(this::toJackson3JsonNodeMap) .defaultIfEmpty(Map.of()); } private Map toJackson3JsonNodeMap( @Nullable Map data ) { if (data == null) { return Map.of(); } return data.entrySet().stream().collect(Collectors.toMap( Map.Entry::getKey, e -> toJackson3JsonNode(e.getValue()) )); } private Map toJackson2JsonNodeMap(@Nullable Map data) { if (data == null) { return Map.of(); } return data.entrySet().stream().collect(Collectors.toMap( Map.Entry::getKey, e -> toJackson2JsonNode(e.getValue()) )); } private tools.jackson.databind.JsonNode toJackson3JsonNode( @Nullable String json) { if (StringUtils.isBlank(json)) { return mapper.missingNode(); } try { return mapper.readTree(json); } catch (JacksonException ex) { log.error("Failed to parse plugin [{}] config json: [{}]", pluginName, json, ex); return mapper.missingNode(); } } private JsonNode toJackson2JsonNode(String json) { if (StringUtils.isBlank(json)) { return v2Mapper.missingNode(); } try { return v2Mapper.readTree(json); } catch (JsonProcessingException e) { // ignore log.error("Failed to parse plugin [{}] config json: [{}]", pluginName, json, e); } return JsonNodeFactory.instance.missingNode(); } static String buildCacheKey(String pluginName) { return "plugin-" + pluginName + "-configmap"; } @Override public Result reconcile(Request request) { return client.fetch(ConfigMap.class, configMapName) .filter(Predicate.not(ExtensionUtil::isDeleted)) .flatMap(cm -> { // get data snapshot var snapshot = SystemConfigUtils.getDataSnapshot(cm); if (SystemConfigUtils.populateChecksum(cm)) { // if config map is changed SystemConfigUtils.updateDataSnapshot(cm); return client.update(cm).then(Mono.fromCallable(() -> { this.isCacheInvalidated.set(true); applicationContext.publishEvent(PluginConfigUpdatedEvent.builder() .source(this) .oldConfig(toJackson2JsonNodeMap(snapshot)) .newConfig(toJackson2JsonNodeMap(cm.getData())) .oldSettingValues(toJackson3JsonNodeMap(snapshot)) .newSettingValues(toJackson3JsonNodeMap(cm.getData())) .build()); return null; })); } return Mono.empty(); }).blockOptional(TIMEOUT).orElse(null); } @Override public Controller setupWith(ControllerBuilder builder) { if (StringUtils.isBlank(configMapName)) { // Disable the controller if the config map name is not set return builder .extension(new ConfigMap()) .syncAllOnStart(false) .onAddMatcher(extension -> false) .onUpdateMatcher(extension -> false) .onDeleteMatcher(extension -> false) .build(); } ExtensionMatcher matcher = extension -> Objects.equals(extension.getMetadata().getName(), configMapName); return builder .extension(new ConfigMap()) .syncAllOnStart(true) .syncAllListOptions(ListOptions.builder() .fieldQuery(equal("metadata.name", configMapName)) .build()) .onAddMatcher(matcher) .onUpdateMatcher(matcher) .onDeleteMatcher(matcher) .build(); } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/DefaultSettingFetcher.java ================================================ package run.halo.app.plugin; import com.fasterxml.jackson.databind.JsonNode; import java.time.Duration; import java.util.Map; import java.util.Objects; import java.util.Optional; import org.springframework.lang.NonNull; import run.halo.app.extension.ConfigMap; import run.halo.app.infra.utils.ReactiveUtils; /** *

A value fetcher for plugin form configuration.

* * @author guqing * @since 2.0.0 */ class DefaultSettingFetcher implements SettingFetcher { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private final ReactiveSettingFetcher delegateFetcher; public DefaultSettingFetcher(ReactiveSettingFetcher reactiveSettingFetcher) { this.delegateFetcher = reactiveSettingFetcher; } @NonNull @Override public Optional fetch(String group, Class clazz) { return delegateFetcher.fetch(group, clazz) .blockOptional(BLOCKING_TIMEOUT); } @NonNull @Override public JsonNode get(String group) { return Objects.requireNonNull(delegateFetcher.get(group).block(BLOCKING_TIMEOUT)); } @Override public tools.jackson.databind.JsonNode getSettingValue(String group) { return delegateFetcher.getSettingValue(group).block(BLOCKING_TIMEOUT); } /** * Get values from {@link ConfigMap}. * * @return a unmodifiable map of values(non-null). */ @NonNull @Override public Map getValues() { return Objects.requireNonNull(delegateFetcher.getValues().block(BLOCKING_TIMEOUT)); } @Override public Map getSettingValues() { return delegateFetcher.getSettingValues().block(BLOCKING_TIMEOUT); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/DefaultSpringPlugin.java ================================================ package run.halo.app.plugin; import org.jspecify.annotations.NonNull; import org.pf4j.Plugin; import org.springframework.context.ApplicationContext; import org.springframework.context.ConfigurableApplicationContext; import run.halo.app.plugin.event.SpringPluginStartedEvent; import run.halo.app.plugin.event.SpringPluginStartingEvent; import run.halo.app.plugin.event.SpringPluginStoppingEvent; /** * Default implementation of {@link SpringPlugin}. * * @author johnniang * @since 2.22.0 */ class DefaultSpringPlugin extends Plugin implements SpringPlugin { private ApplicationContext context; private Plugin delegate; private final PluginApplicationContextFactory contextFactory; private final PluginContext pluginContext; public DefaultSpringPlugin(PluginApplicationContextFactory contextFactory, PluginContext pluginContext) { this.contextFactory = contextFactory; this.pluginContext = pluginContext; } @Override public void start() { log.info("Preparing starting plugin {}", pluginContext.getName()); var pluginId = pluginContext.getName(); var previous = Thread.currentThread().getContextClassLoader(); try { // initialize context this.context = contextFactory.create(pluginId); Thread.currentThread().setContextClassLoader(this.context.getClassLoader()); log.info("Application context {} for plugin {} is created", this.context, pluginId); var pluginOpt = context.getBeanProvider(Plugin.class) .stream() .findFirst(); log.info("Before publishing plugin starting event for plugin {}", pluginId); context.publishEvent(new SpringPluginStartingEvent(this, this)); log.info("After publishing plugin starting event for plugin {}", pluginId); if (pluginOpt.isPresent()) { this.delegate = pluginOpt.get(); log.info("Starting {} for plugin {}", this.delegate, pluginId); this.delegate.start(); log.info("Started {} for plugin {}", this.delegate, pluginId); } log.info("Before publishing plugin started event for plugin {}", pluginId); context.publishEvent(new SpringPluginStartedEvent(this, this)); log.info("After publishing plugin started event for plugin {}", pluginId); } catch (Throwable t) { // try to stop plugin for cleaning resources if something went wrong log.error( "Cleaning up plugin resources for plugin {} due to not being able to start plugin.", pluginId); this.stop(); // propagate exception to invoker. throw t; } finally { Thread.currentThread().setContextClassLoader(previous); } } @Override public void stop() { var previous = Thread.currentThread().getContextClassLoader(); try { if (context != null) { Thread.currentThread().setContextClassLoader(context.getClassLoader()); log.info("Before publishing plugin stopping event for plugin {}", pluginContext.getName()); context.publishEvent(new SpringPluginStoppingEvent(this, this)); log.info("After publishing plugin stopping event for plugin {}", pluginContext.getName()); } if (this.delegate != null) { log.info("Stopping {} for plugin {}", this.delegate, pluginContext.getName()); this.delegate.stop(); log.info("Stopped {} for plugin {}", this.delegate, pluginContext.getName()); } } finally { Thread.currentThread().setContextClassLoader(previous); if (context instanceof ConfigurableApplicationContext configurableContext) { log.info("Closing plugin context for plugin {}", pluginContext.getName()); configurableContext.close(); log.info("Closed plugin context for plugin {}", pluginContext.getName()); } // reset application context log.info("Reset plugin context for plugin {}", pluginContext.getName()); context = null; } } @Override public void delete() { if (delegate != null) { delegate.delete(); } this.delegate = null; } @Override @NonNull public ApplicationContext getApplicationContext() { if (context == null) { throw new IllegalStateException(""" Plugin has not been started or has already been stopped; \ ApplicationContext is not available. """); } return context; } @Override @NonNull public PluginContext getPluginContext() { return pluginContext; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/DevPluginLoader.java ================================================ package run.halo.app.plugin; import java.nio.file.Files; import java.nio.file.Path; import org.pf4j.DevelopmentPluginLoader; import org.pf4j.PluginDescriptor; import org.pf4j.PluginManager; public class DevPluginLoader extends DevelopmentPluginLoader { private final PluginProperties pluginProperties; public DevPluginLoader( PluginManager pluginManager, PluginProperties pluginProperties ) { super(pluginManager); this.pluginProperties = pluginProperties; } @Override public ClassLoader loadPlugin(Path pluginPath, PluginDescriptor pluginDescriptor) { var classesDirectories = pluginProperties.getClassesDirectories(); if (classesDirectories != null) { classesDirectories.forEach( classesDirectory -> pluginClasspath.addClassesDirectories(classesDirectory) ); } var libDirectories = pluginProperties.getLibDirectories(); if (libDirectories != null) { libDirectories.forEach( libDirectory -> pluginClasspath.addJarsDirectories(libDirectory) ); } return super.loadPlugin(pluginPath, pluginDescriptor); } @Override public boolean isApplicable(Path pluginPath) { // Currently we only support a plugin loading from directory in dev mode. return Files.isDirectory(pluginPath); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/HaloPluginManager.java ================================================ package run.halo.app.plugin; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Stack; import lombok.extern.slf4j.Slf4j; import org.pf4j.CompoundPluginLoader; import org.pf4j.CompoundPluginRepository; import org.pf4j.DefaultPluginManager; import org.pf4j.DefaultPluginRepository; import org.pf4j.DefaultPluginStatusProvider; import org.pf4j.ExtensionFactory; import org.pf4j.ExtensionFinder; import org.pf4j.JarPluginLoader; import org.pf4j.JarPluginRepository; import org.pf4j.PluginDescriptorFinder; import org.pf4j.PluginFactory; import org.pf4j.PluginLoader; import org.pf4j.PluginRepository; import org.pf4j.PluginState; import org.pf4j.PluginStateEvent; import org.pf4j.PluginStateListener; import org.pf4j.PluginStatusProvider; import org.pf4j.PluginWrapper; import org.springframework.beans.factory.InitializingBean; import org.springframework.context.ApplicationContext; import org.springframework.data.util.Lazy; import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.plugin.event.PluginStartedEvent; /** * PluginManager to hold the main ApplicationContext. * It provides methods for managing the plugin lifecycle. * * @author guqing * @author johnniang * @since 2.0.0 */ @Slf4j class HaloPluginManager extends DefaultPluginManager implements SpringPluginManager, InitializingBean { private final ApplicationContext rootContext; private Lazy sharedContext; private final PluginProperties pluginProperties; private final PluginsRootGetter pluginsRootGetter; private final SystemVersionSupplier systemVersionSupplier; public HaloPluginManager(ApplicationContext rootContext, PluginProperties pluginProperties, SystemVersionSupplier systemVersionSupplier, PluginsRootGetter pluginsRootGetter) { this.pluginProperties = pluginProperties; this.rootContext = rootContext; this.pluginsRootGetter = pluginsRootGetter; this.systemVersionSupplier = systemVersionSupplier; } @Override protected void initialize() { // Leave the implementation empty because the super#initialize eagerly initializes // components before properties set. } @Override public void afterPropertiesSet() throws Exception { super.runtimeMode = pluginProperties.getRuntimeMode(); this.sharedContext = Lazy.of(() -> SharedApplicationContextFactory.create(rootContext)); setExactVersionAllowed(pluginProperties.isExactVersionAllowed()); setSystemVersion(systemVersionSupplier.get().toStableVersion().toString()); super.initialize(); // the listener must be after the super#initialize addPluginStateListener(new PluginStartedListener()); } @Override protected ExtensionFactory createExtensionFactory() { return new SpringExtensionFactory(this); } @Override protected ExtensionFinder createExtensionFinder() { var finder = new SpringComponentsFinder(this); addPluginStateListener(finder); return finder; } @Override protected PluginFactory createPluginFactory() { var contextFactory = new DefaultPluginApplicationContextFactory(this); var pluginGetter = rootContext.getBean(PluginGetter.class); return new SpringPluginFactory(contextFactory, pluginGetter); } @Override protected PluginDescriptorFinder createPluginDescriptorFinder() { return new YamlPluginDescriptorFinder(); } @Override protected PluginLoader createPluginLoader() { var compoundLoader = new CompoundPluginLoader(); compoundLoader.add(new DevPluginLoader(this, this.pluginProperties), this::isDevelopment); compoundLoader.add(new JarPluginLoader(this)); return compoundLoader; } @Override protected PluginStatusProvider createPluginStatusProvider() { if (PropertyPluginStatusProvider.isPropertySet(pluginProperties)) { return new PropertyPluginStatusProvider(pluginProperties); } // Only plugins root is writeable return new DefaultPluginStatusProvider(pluginsRootGetter.get()); } @Override protected PluginRepository createPluginRepository() { var developmentPluginRepository = new DefaultDevelopmentPluginRepository(getPluginsRoots()); developmentPluginRepository .setFixedPaths(pluginProperties.getFixedPluginPath()); return new CompoundPluginRepository() .add(developmentPluginRepository, this::isDevelopment) .add(new JarPluginRepository(getPluginsRoots())) .add(new DefaultPluginRepository(getPluginsRoots())); } @Override protected List createPluginsRoot() { return List.of(pluginsRootGetter.get()); } @Override public void startPlugins() { throw new UnsupportedOperationException( "The operation of starting all plugins is not supported." ); } @Override public void stopPlugins() { throw new UnsupportedOperationException( "The operation of stopping all plugins is not supported." ); } @Override public ApplicationContext getRootContext() { return rootContext; } @Override public ApplicationContext getSharedContext() { return sharedContext.get(); } @Override public List getDependents(String pluginId) { if (getPlugin(pluginId) == null) { return List.of(); } var dependents = new ArrayList(); var stack = new Stack(); dependencyResolver.getDependents(pluginId).forEach(stack::push); while (!stack.isEmpty()) { var dependent = stack.pop(); var pluginWrapper = getPlugin(dependent); if (pluginWrapper != null) { dependents.add(pluginWrapper); dependencyResolver.getDependents(dependent).forEach(stack::push); } } return dependents; } @Override public List startedPlugins() { return List.copyOf(super.getStartedPlugins()) .stream() // Make sure the plugin is really started .filter(p -> p.getPluginState().isStarted()) .toList(); } /** * Listener for plugin started event. * * @author johnniang * @since 2.17.0 */ private static class PluginStartedListener implements PluginStateListener { @Override public void pluginStateChanged(PluginStateEvent event) { if (PluginState.STARTED.equals(event.getPluginState())) { var plugin = event.getPlugin().getPlugin(); if (plugin instanceof SpringPlugin springPlugin) { try { springPlugin.getApplicationContext() .publishEvent(new PluginStartedEvent(this)); } catch (Throwable t) { var pluginId = event.getPlugin().getPluginId(); log.warn("Error while publishing plugin started event for plugin {}", pluginId, t); } } } } } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/HaloSharedEventDelegator.java ================================================ package run.halo.app.plugin; import java.util.Objects; import lombok.Getter; import org.springframework.context.ApplicationEvent; /** * The event that delegates a shared event in core into all started plugins. * * @author johnniang * @since 2.17 */ @Getter class HaloSharedEventDelegator extends ApplicationEvent { private final ApplicationEvent delegate; public HaloSharedEventDelegator(Object source, ApplicationEvent delegate) { super(source); this.delegate = delegate; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } HaloSharedEventDelegator that = (HaloSharedEventDelegator) o; return Objects.equals(delegate, that.delegate); } @Override public int hashCode() { return Objects.hashCode(delegate); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/OptionalDependentResolver.java ================================================ package run.halo.app.plugin; import java.util.ArrayList; import java.util.List; import org.pf4j.PluginDependency; import org.pf4j.PluginDescriptor; import org.pf4j.util.DirectedGraph; /** *

Pass in a list of started plugin names to resolve dependency relationships, and return a * list of plugin names that depend on the specified plugin.

*

Do not consider the problem of circular dependency, because all the plugins that have been * started must not have this problem.

*/ public class OptionalDependentResolver { private final DirectedGraph dependentsGraph; private final List plugins; public OptionalDependentResolver(List startedPlugins) { this.plugins = startedPlugins; this.dependentsGraph = new DirectedGraph<>(); resolve(); } private void resolve() { // populate graphs for (PluginDescriptor plugin : plugins) { addPlugin(plugin); } } public List getOptionalDependents(String pluginId) { return new ArrayList<>(dependentsGraph.getNeighbors(pluginId)); } private void addPlugin(PluginDescriptor descriptor) { String pluginId = descriptor.getPluginId(); List dependencies = descriptor.getDependencies(); if (dependencies.isEmpty()) { dependentsGraph.addVertex(pluginId); } else { boolean edgeAdded = false; for (PluginDependency dependency : dependencies) { if (dependency.isOptional()) { edgeAdded = true; dependentsGraph.addEdge(dependency.getPluginId(), pluginId); } } // Register the plugin without dependencies, if all of its dependencies are optional. if (!edgeAdded) { dependentsGraph.addVertex(pluginId); } } } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginApplicationContext.java ================================================ package run.halo.app.plugin; import java.util.List; import java.util.concurrent.locks.StampedLock; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.context.ApplicationEvent; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.core.ResolvableType; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.util.Assert; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import run.halo.app.extension.GroupVersionKind; /** * The generic IOC container for plugins. * The plugin-classes loaded through the same plugin-classloader will be put into the same * {@link PluginApplicationContext} for bean creation. * * @author guqing * @since 2.0.0 */ public class PluginApplicationContext extends AnnotationConfigApplicationContext { private final GvkExtensionMapping gvkExtensionMapping = new GvkExtensionMapping(); private final String pluginId; private final SpringPluginManager pluginManager; public PluginApplicationContext(String pluginId, SpringPluginManager pluginManager, DefaultListableBeanFactory beanFactory) { super(beanFactory); this.pluginId = pluginId; this.pluginManager = pluginManager; } public String getPluginId() { return pluginId; } /** * Gets the gvk-extension mapping. * It is thread safe * * @param gvk the group-kind-version * @param extensionName extension resources name */ public void addExtensionMapping(GroupVersionKind gvk, String extensionName) { gvkExtensionMapping.addExtensionMapping(gvk, extensionName); } /** * Gets the extension names by gvk. * It is thread safe * * @param gvk the group-kind-version * @return a immutable list of extension names */ public List getExtensionNames(GroupVersionKind gvk) { return List.copyOf(gvkExtensionMapping.getExtensionNames(gvk)); } public MultiValueMap extensionNamesMapping() { return gvkExtensionMapping.extensionNamesMapping(); } static class GvkExtensionMapping { private final StampedLock sl = new StampedLock(); private final MultiValueMap extensionNamesMapping = new LinkedMultiValueMap<>(); public void addAllExtensionMapping(GroupVersionKind gvk, List extensionNames) { long stamp = sl.writeLock(); try { extensionNamesMapping.addAll(gvk, extensionNames); } finally { sl.unlockWrite(stamp); } } public void addExtensionMapping(GroupVersionKind gvk, String extensionName) { long stamp = sl.writeLock(); try { extensionNamesMapping.add(gvk, extensionName); } finally { sl.unlockWrite(stamp); } } public List getExtensionNames(GroupVersionKind gvk) { Assert.notNull(gvk, "The gvk must not be null"); long stamp = sl.tryOptimisticRead(); List values = extensionNamesMapping.get(gvk); if (!sl.validate(stamp)) { // Check if another write lock occurs after the optimistic read lock // If so, escalate lock to a pessimistic lock stamp = sl.readLock(); try { return extensionNamesMapping.get(gvk); } finally { sl.unlockRead(stamp); } } return values; } public MultiValueMap extensionNamesMapping() { return new LinkedMultiValueMap<>(extensionNamesMapping); } public void clear() { extensionNamesMapping.clear(); } } @Override protected void publishEvent(Object event, ResolvableType typeHint) { if (event instanceof ApplicationEvent applicationEvent && AnnotationUtils.findAnnotation(event.getClass(), SharedEvent.class) != null) { // publish event via root context var delegateEvent = new PluginSharedEventDelegator(this, applicationEvent); pluginManager.getRootContext().publishEvent(delegateEvent); return; } // unwrap event if needed var originalEvent = event; if (event instanceof HaloSharedEventDelegator delegator) { originalEvent = delegator.getDelegate(); } super.publishEvent(originalEvent, typeHint); } @Override protected void onClose() { // For subclasses: do nothing by default. super.onClose(); gvkExtensionMapping.clear(); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginApplicationContextFactory.java ================================================ package run.halo.app.plugin; import org.springframework.context.ApplicationContext; public interface PluginApplicationContextFactory { /** * Create and refresh application context. * * @param pluginId plugin id * @return refresh application context for the plugin. */ ApplicationContext create(String pluginId); } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java ================================================ package run.halo.app.plugin; import static run.halo.app.plugin.resources.BundleResourceUtils.getJsBundleResource; import java.io.FileNotFoundException; import java.io.IOException; import java.net.URISyntaxException; import java.time.Instant; import lombok.extern.slf4j.Slf4j; import org.pf4j.PluginManager; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.web.WebProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.accept.RequestedContentTypeResolver; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemVersionSupplier; /** * Plugin autoconfiguration for Spring Boot. * * @author guqing * @see PluginProperties */ @Slf4j @Configuration @EnableConfigurationProperties(PluginProperties.class) public class PluginAutoConfiguration { @Bean public PluginRequestMappingHandlerMapping pluginRequestMappingHandlerMapping( @Qualifier("webFluxContentTypeResolver") RequestedContentTypeResolver requestedContentTypeResolver ) { PluginRequestMappingHandlerMapping mapping = new PluginRequestMappingHandlerMapping(); mapping.setContentTypeResolver(requestedContentTypeResolver); mapping.setOrder(-1); return mapping; } @Bean public SpringPluginManager pluginManager(ApplicationContext context, SystemVersionSupplier systemVersionSupplier, PluginProperties pluginProperties, PluginsRootGetter pluginsRootGetter) throws FileNotFoundException, URISyntaxException { return new HaloPluginManager( context, pluginProperties, systemVersionSupplier, pluginsRootGetter ); } @Bean public RouterFunction pluginJsBundleRoute(PluginManager pluginManager, WebProperties webProperties) { var cacheProperties = webProperties.getResources().getCache(); return RouterFunctions.route() .GET("/plugins/{name}/assets/console/{*resource}", request -> { String pluginName = request.pathVariable("name"); String fileName = request.pathVariable("resource"); var jsBundle = getJsBundleResource(pluginManager, pluginName, fileName); if (jsBundle == null || !jsBundle.exists()) { return ServerResponse.notFound().build(); } var useLastModified = cacheProperties.isUseLastModified(); var bodyBuilder = ServerResponse.ok() .cacheControl(cacheProperties.getCachecontrol().toHttpCacheControl()); try { if (useLastModified) { var lastModified = Instant.ofEpochMilli(jsBundle.lastModified()); return request.checkNotModified(lastModified) .switchIfEmpty(Mono.defer(() -> bodyBuilder.lastModified(lastModified) .body(BodyInserters.fromResource(jsBundle)))); } return bodyBuilder.body(BodyInserters.fromResource(jsBundle)); } catch (IOException e) { throw new RuntimeException(e); } }) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginBeforeStopSyncListener.java ================================================ package run.halo.app.plugin; import java.time.Duration; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.exception.SchemeNotFoundException; import run.halo.app.plugin.event.HaloPluginBeforeStopEvent; /** * Synchronization listener executed by the plugin before it is stopped. * * @author guqing * @since 2.0.0 */ @Slf4j @Component class PluginBeforeStopSyncListener { private static final Duration CLEANUP_TIMEOUT = Duration.ofMinutes(1); private final ReactiveExtensionClient client; public PluginBeforeStopSyncListener(ReactiveExtensionClient client) { this.client = client; } @EventListener void onApplicationEvent(@NonNull HaloPluginBeforeStopEvent event) { var pluginWrapper = event.getPlugin(); var p = pluginWrapper.getPlugin(); if (!(p instanceof SpringPlugin springPlugin)) { return; } var applicationContext = springPlugin.getApplicationContext(); if (!(applicationContext instanceof PluginApplicationContext pluginApplicationContext)) { return; } cleanUpPluginExtensionResources(pluginApplicationContext).block(CLEANUP_TIMEOUT); } private Mono cleanUpPluginExtensionResources(PluginApplicationContext context) { var gvkExtensionNames = context.extensionNamesMapping(); return Flux.fromIterable(gvkExtensionNames.entrySet()) .flatMap(entry -> Flux.fromIterable(entry.getValue()) .flatMap(extensionName -> client.fetch(entry.getKey(), extensionName) .onErrorComplete(SchemeNotFoundException.class) .filter(e -> !ExtensionUtil.hasDoNotOverwriteLabel(e)) .flatMap(client::delete) .retryWhen(Retry.backoff(10, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance) ) ) .flatMap(e -> waitForDeleted(e.groupVersionKind(), e.getMetadata().getName()))) .then(); } private Mono waitForDeleted(GroupVersionKind gvk, String name) { return client.fetch(gvk, name) .flatMap(e -> { if (log.isDebugEnabled()) { log.debug("Wait for {}/{} deleted", gvk, name); } return Mono.error(new IllegalStateException("Wait for extension deleted")); }) .retryWhen(Retry.backoff(10, Duration.ofMillis(100)) .filter(IllegalStateException.class::isInstance) ) .then() .doOnSuccess(v -> { if (log.isDebugEnabled()) { log.debug("{}/{} was deleted successfully.", gvk, name); } }); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginConst.java ================================================ package run.halo.app.plugin; /** * Plugin constants. * * @author guqing * @since 2.0.0 */ public interface PluginConst { /** * Plugin metadata labels key. */ String PLUGIN_NAME_LABEL_NAME = "plugin.halo.run/plugin-name"; String SYSTEM_PLUGIN_NAME = "system"; String RELOAD_ANNO = "plugin.halo.run/reload"; String REQUEST_TO_UNLOAD_LABEL = "plugin.halo.run/request-to-unload"; String PLUGIN_PATH = "plugin.halo.run/plugin-path"; String RUNTIME_MODE_ANNO = "plugin.halo.run/runtime-mode"; static String assetsRoutePrefix(String pluginName) { return "/plugins/" + pluginName + "/assets/"; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginControllerManager.java ================================================ package run.halo.app.plugin; import static org.springframework.core.ResolvableType.forClassWithGenerics; import java.util.concurrent.ConcurrentHashMap; import org.springframework.context.event.EventListener; import reactor.core.Disposable; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.plugin.event.SpringPluginStartedEvent; import run.halo.app.plugin.event.SpringPluginStoppingEvent; public class PluginControllerManager { private final ConcurrentHashMap controllers; private final ExtensionClient client; public PluginControllerManager(ExtensionClient client) { this.client = client; controllers = new ConcurrentHashMap<>(); } @EventListener public void onApplicationEvent(SpringPluginStartedEvent event) { event.getSpringPlugin().getApplicationContext() .>getBeanProvider( forClassWithGenerics(Reconciler.class, Reconciler.Request.class)) .orderedStream() .forEach(this::start); } @EventListener public void onApplicationEvent(SpringPluginStoppingEvent event) throws Exception { controllers.values() .forEach(Disposable::dispose); controllers.clear(); } private void start(Reconciler reconciler) { var builder = new ControllerBuilder(reconciler, client); var controller = reconciler.setupWith(builder); controllers.put(reconciler.getClass().getName(), controller); controller.start(); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginDevelopmentInitializer.java ================================================ package run.halo.app.plugin; import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations; import java.nio.file.Path; import java.time.Duration; import lombok.extern.slf4j.Slf4j; import org.pf4j.PluginManager; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.ApplicationListener; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.extension.Plugin; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.ReactiveUtils; /** * @author guqing * @since 2.0.0 */ @Slf4j @Component public class PluginDevelopmentInitializer implements ApplicationListener { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private final PluginManager pluginManager; private final PluginProperties pluginProperties; private final ReactiveExtensionClient extensionClient; public PluginDevelopmentInitializer(PluginManager pluginManager, PluginProperties pluginProperties, ReactiveExtensionClient extensionClient) { this.pluginManager = pluginManager; this.pluginProperties = pluginProperties; this.extensionClient = extensionClient; } @Override public void onApplicationEvent(@NonNull ApplicationReadyEvent ignored) { if (!pluginManager.isDevelopment()) { return; } createFixedPluginIfNecessary(); } private void createFixedPluginIfNecessary() { for (Path path : pluginProperties.getFixedPluginPath()) { Plugin plugin = new YamlPluginFinder().find(path); extensionClient.fetch(Plugin.class, plugin.getMetadata().getName()) .flatMap(persistent -> { plugin.getMetadata().setVersion(persistent.getMetadata().getVersion()); nullSafeAnnotations(plugin).put(PluginConst.RUNTIME_MODE_ANNO, "dev"); return extensionClient.update(plugin); }) .switchIfEmpty(Mono.defer(() -> { nullSafeAnnotations(plugin).put(PluginConst.RUNTIME_MODE_ANNO, "dev"); return extensionClient.create(plugin); })) .retryWhen(Retry.backoff(10, Duration.ofMillis(100)) .filter(t -> t instanceof OptimisticLockingFailureException)) .block(BLOCKING_TIMEOUT); } } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginExtensionLoaderUtils.java ================================================ package run.halo.app.plugin; import java.io.FileNotFoundException; import java.io.IOException; import java.net.URLClassLoader; import java.util.Objects; import java.util.function.Predicate; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.data.util.Predicates; import run.halo.app.core.extension.Setting; import run.halo.app.extension.Unstructured; @Slf4j public class PluginExtensionLoaderUtils { static final String EXTENSION_LOCATION_PATTERN = "classpath:extensions/*.{ext:yaml|yml}"; public static Predicate isSetting(String settingName) { if (StringUtils.isBlank(settingName)) { return Predicates.isFalse(); } var settingGk = Setting.GVK.groupKind(); return unstructured -> { var gk = unstructured.groupVersionKind().groupKind(); var name = unstructured.getMetadata().getName(); return Objects.equals(settingName, name) && Objects.equals(settingGk, gk); }; } public static Resource[] lookupExtensions(ClassLoader classLoader) { if (log.isDebugEnabled()) { log.debug("Trying to lookup extensions from {}", classLoader); } if (classLoader instanceof URLClassLoader urlClassLoader) { var urls = urlClassLoader.getURLs(); // The parent class loader must be null here because we don't want to // get any resources from parent class loader. classLoader = new URLClassLoader(urls, null); } var resolver = new PathMatchingResourcePatternResolver(classLoader); try { var resources = resolver.getResources(EXTENSION_LOCATION_PATTERN); if (log.isDebugEnabled()) { log.debug("Looked up {} resources(s) from {}", resources.length, classLoader); } return resources; } catch (FileNotFoundException ignored) { // Ignore the exception only if extensions folder was not found. } catch (IOException e) { throw new RuntimeException(String.format(""" Failed to get extension resources while resolving plugin setting \ in class loader %s.\ """, classLoader), e); } return new Resource[] {}; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginFinder.java ================================================ package run.halo.app.plugin; import java.nio.file.Path; import org.jspecify.annotations.NonNull; import org.pf4j.PluginRuntimeException; import run.halo.app.core.extension.Plugin; /** * The plugin finder to find plugin manifest from given plugin path. * * @author johnniang * @since 2.22.0 */ public interface PluginFinder { /** * Finds plugin manifest by given plugin path. * * @param pluginPath the plugin path * @return the found plugin * @throws PluginRuntimeException if any error occurs during finding plugin */ @NonNull Plugin find(@NonNull Path pluginPath); } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginGetter.java ================================================ package run.halo.app.plugin; import run.halo.app.core.extension.Plugin; import run.halo.app.infra.exception.NotFoundException; /** * An interface to get {@link Plugin} by name. * * @author guqing * @since 2.17.0 */ @FunctionalInterface public interface PluginGetter { /** * Get plugin by name. * * @param name plugin name must not be null * @return plugin * @throws IllegalArgumentException if plugin name is null * @throws NotFoundException if plugin not found */ Plugin getPlugin(String name); } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginNotFoundException.java ================================================ package run.halo.app.plugin; import run.halo.app.infra.exception.NotFoundException; /** * Exception for plugin not found. * * @author guqing * @since 2.0.0 */ public class PluginNotFoundException extends NotFoundException { public PluginNotFoundException(String message) { super(message); } public PluginNotFoundException(Throwable cause) { super(cause); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginProperties.java ================================================ package run.halo.app.plugin; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import lombok.Data; import org.pf4j.RuntimeMode; import org.springframework.boot.context.properties.ConfigurationProperties; /** * Properties for plugin. * * @author guqing * @see PluginAutoConfiguration */ @Data @ConfigurationProperties(prefix = "halo.plugin") public class PluginProperties { public static final String GRADLE_LIBS_DIR = "build/libs"; /** * Auto start plugin when main app is ready. */ private boolean autoStartPlugin = true; /** * The default plugin path is obtained through file scanning. * In the development mode, you can specify the plugin path as the project directory. */ private List fixedPluginPath = new ArrayList<>(); /** * Plugins disabled by default. */ private String[] disabledPlugins = new String[0]; /** * Plugins enabled by default, prior to `disabledPlugins`. */ private String[] enabledPlugins = new String[0]; /** * Set to true to allow requires expression to be exactly x.y.z. The default is false, meaning * that using an exact version x.y.z will implicitly mean the same as >=x.y.z. */ private boolean exactVersionAllowed = false; /** * Extended Plugin Class Directory. */ private List classesDirectories = new ArrayList<>(); /** * Extended Plugin Jar Directory. */ private List libDirectories = new ArrayList<>(List.of(GRADLE_LIBS_DIR)); /** * Runtime Mode:development/deployment. */ private RuntimeMode runtimeMode = RuntimeMode.DEPLOYMENT; } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginRequestMappingHandlerMapping.java ================================================ package run.halo.app.plugin; import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; import org.springframework.aop.support.AopUtils; import org.springframework.core.MethodIntrospector; import org.springframework.stereotype.Controller; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.reactive.result.method.RequestMappingInfo; import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping; import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; import run.halo.app.extension.GroupVersion; /** * An extension of {@link RequestMappingInfoHandlerMapping} that creates * {@link RequestMappingInfo} instances from class-level and method-level * {@link RequestMapping} annotations used by plugin. * * @author guqing * @since 2.0.0 */ public class PluginRequestMappingHandlerMapping extends RequestMappingHandlerMapping { private final MultiValueMap pluginMappingInfo = new LinkedMultiValueMap<>(); @Override protected void initHandlerMethods() { // Parent method will scan beans in the ApplicationContext // detect and register handler methods. // but this is superfluous for this class. } /** * Register handler methods according to the plugin id and the controller(annotated * {@link Controller}) bean. * * @param pluginId plugin id to be registered * @param handler controller bean */ public void registerHandlerMethods(String pluginId, Object handler) { Class handlerType = (handler instanceof String beanName ? obtainApplicationContext().getType(beanName) : handler.getClass()); if (handlerType != null) { final Class userType = ClassUtils.getUserClass(handlerType); Map methods = MethodIntrospector.selectMethods(userType, (MethodIntrospector.MetadataLookup) method -> getPluginMappingForMethod(pluginId, method, userType)); if (logger.isTraceEnabled()) { logger.trace(formatMappings(userType, methods)); } else if (mappingsLogger.isDebugEnabled()) { mappingsLogger.debug(formatMappings(userType, methods)); } methods.forEach((method, mapping) -> { Method invocableMethod = AopUtils.selectInvocableMethod(method, userType); registerHandlerMethod(handler, invocableMethod, mapping); pluginMappingInfo.add(pluginId, mapping); }); } } private String formatMappings(Class userType, Map methods) { String packageName = ClassUtils.getPackageName(userType); String formattedType = (StringUtils.hasText(packageName) ? Arrays.stream(packageName.split("\\.")) .map(packageSegment -> packageSegment.substring(0, 1)) .collect(Collectors.joining(".", "", "." + userType.getSimpleName())) : userType.getSimpleName()); Function methodFormatter = method -> Arrays.stream(method.getParameterTypes()) .map(Class::getSimpleName) .collect(Collectors.joining(",", "(", ")")); return methods.entrySet().stream() .map(e -> { Method method = e.getKey(); return e.getValue() + ": " + method.getName() + methodFormatter.apply(method); }) .collect(Collectors.joining("\n\t", "\n\t" + formattedType + ":" + "\n\t", "")); } /** * Remove handler methods and mapping based on plugin id. * * @param pluginId plugin id */ public void unregister(String pluginId) { Assert.notNull(pluginId, "The pluginId must not be null."); if (!pluginMappingInfo.containsKey(pluginId)) { return; } pluginMappingInfo.remove(pluginId).forEach(this::unregisterMapping); } protected List getMappings(String pluginId) { List requestMappingInfos = pluginMappingInfo.get(pluginId); if (requestMappingInfos == null) { return Collections.emptyList(); } return List.copyOf(requestMappingInfos); } protected RequestMappingInfo getPluginMappingForMethod(String pluginId, Method method, Class handlerType) { RequestMappingInfo info = super.getMappingForMethod(method, handlerType); if (info != null) { ApiVersion apiVersion = handlerType.getAnnotation(ApiVersion.class); if (apiVersion == null) { return info; } info = RequestMappingInfo.paths(buildPrefix(pluginId, apiVersion.value())).build() .combine(info); } return info; } protected String buildPrefix(String pluginName, String apiVersion) { GroupVersion groupVersion = GroupVersion.parseAPIVersion(apiVersion); if (StringUtils.hasText(groupVersion.group())) { // apis/{group}/{version} return String.format("/apis/%s/%s", groupVersion.group(), groupVersion.version()); } // apis/api.plugin.halo.run/{version}/plugins/{pluginName} return String.format("/apis/api.plugin.halo.run/%s/plugins/%s", groupVersion.version(), pluginName); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginRouterFunctionRegistry.java ================================================ package run.halo.app.plugin; import java.util.Collection; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; public interface PluginRouterFunctionRegistry { void register(Collection> routerFunctions); void unregister(Collection> routerFunctions); } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginService.java ================================================ package run.halo.app.plugin; import java.nio.file.Path; import java.util.List; import java.util.function.Predicate; import org.pf4j.PluginWrapper; import org.springframework.core.io.Resource; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Plugin; public interface PluginService { Mono installPresetPlugins(); /** * Installs a plugin from a temporary Jar path. * * @param path is temporary jar path. Do not set the plugin home at here. * @return created plugin. */ Mono install(Path path); Mono upgrade(String name, Path path); /** *

Reload a plugin by name.

* Note that this method will set spec.enabled to true it means that the plugin * will be started. * * @param name plugin name * @return an updated plugin reloaded from plugin path * @throws ServerWebInputException if plugin not found by the given name * @see Plugin.PluginSpec#setEnabled(Boolean) */ Mono reload(String name); /** * Uglify js bundle from all enabled plugins to a single js bundle string. * * @return uglified js bundle */ Flux uglifyJsBundle(); /** * Uglify css bundle from all enabled plugins to a single css bundle string. * * @return uglified css bundle */ Flux uglifyCssBundle(); /** *

Generate js/css bundle version for cache control.

* This method will list all enabled plugins version and sign it to a string. * * @return signed js/css bundle version by all enabled plugins version. */ Mono generateBundleVersion(); /** * Retrieves the JavaScript bundle for all enabled plugins. * *

This method combines the JavaScript bundles of all enabled plugins into a single bundle * and returns a representation of this bundle as a resource. * If the JavaScript bundle already exists and is up-to-date, the existing resource is * returned; otherwise, a new JavaScript bundle is generated. * *

Note: This method may perform IO operations and could potentially block, so it should be * used in a non-blocking environment. * * @param version The version of the CSS bundle to retrieve. * @return A {@code Mono} object representing the JavaScript bundle. When this * {@code Mono} is subscribed to, it emits the JavaScript bundle resource if successful, or * an error signal if an error occurs. */ Mono getJsBundle(String version); /** * Retrieves the CSS bundle for all enabled plugins. * *

This method combines the CSS bundles of all enabled plugins into a single bundle and * returns a representation of this bundle as a resource. * If the CSS bundle already exists and is up-to-date, the existing resource is returned; * otherwise, a new CSS bundle is generated. * *

Note: This method may perform IO operations and could potentially block, so it should be * used in a non-blocking environment. * * @param version The version of the CSS bundle to retrieve. * @return A {@code Mono} object representing the CSS bundle. When this {@code Mono * } is subscribed to, it emits the CSS bundle resource if successful, or an error signal if * an error occurs. */ Mono getCssBundle(String version); /** * Enables or disables a plugin by name. * * @param pluginName plugin name * @param requestToEnable request to enable or disable * @param wait wait for plugin to be enabled or disabled * @return updated plugin */ Mono changeState(String pluginName, boolean requestToEnable, boolean wait); /** * Gets required dependencies of the given plugin. * * @param plugin the plugin to get dependencies from * {@link Plugin.PluginSpec#getPluginDependencies()} * @param predicate the predicate to filter by {@link PluginWrapper},such as enabled or disabled * @return plugin names of required dependencies */ List getRequiredDependencies(Plugin plugin, Predicate predicate); /** * Get started plugin names. * * @return started plugin names */ Flux getStartedPluginNames(); } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginServiceImpl.java ================================================ package run.halo.app.plugin; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; import static java.nio.file.StandardOpenOption.CREATE; import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING; import static org.pf4j.PluginState.STARTED; import static run.halo.app.plugin.PluginConst.RELOAD_ANNO; import com.github.zafarkhaja.semver.Version; import com.google.common.hash.HashCode; import com.google.common.hash.Hashing; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Clock; import java.time.Duration; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Predicate; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.pf4j.DependencyResolver; import org.pf4j.PluginDependency; import org.pf4j.PluginDescriptor; import org.pf4j.PluginWrapper; import org.reactivestreams.Publisher; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.core.io.WritableResource; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.FileSystemUtils; import org.springframework.util.StreamUtils; import org.springframework.web.server.ServerWebInputException; import reactor.core.Exceptions; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; import run.halo.app.core.extension.Plugin; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.infra.exception.PluginAlreadyExistsException; import run.halo.app.infra.exception.PluginDependenciesNotEnabledException; import run.halo.app.infra.exception.PluginDependencyException; import run.halo.app.infra.exception.PluginDependentsNotDisabledException; import run.halo.app.infra.exception.PluginInstallationException; import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.utils.FileUtils; import run.halo.app.infra.utils.VersionUtils; import run.halo.app.plugin.resources.BundleResourceUtils; @Slf4j @Component public class PluginServiceImpl implements PluginService, InitializingBean, DisposableBean { private static final String PRESET_LOCATION_PREFIX = "classpath:/presets/plugins/"; private static final String PRESETS_LOCATION_PATTERN = PRESET_LOCATION_PREFIX + "*.jar"; private final ReactiveExtensionClient client; private final SystemVersionSupplier systemVersion; private final PluginsRootGetter pluginsRootGetter; private final SpringPluginManager pluginManager; private final BundleCache jsBundleCache; private final BundleCache cssBundleCache; private Path tempDir; private final Scheduler scheduler = Schedulers.boundedElastic(); private Clock clock = Clock.systemUTC(); public PluginServiceImpl(ReactiveExtensionClient client, SystemVersionSupplier systemVersion, PluginsRootGetter pluginsRootGetter, SpringPluginManager pluginManager) { this.client = client; this.systemVersion = systemVersion; this.pluginsRootGetter = pluginsRootGetter; this.pluginManager = pluginManager; this.jsBundleCache = new BundleCache(".js"); this.cssBundleCache = new BundleCache(".css"); } /** * The method is only for testing. * * @param clock new clock */ void setClock(Clock clock) { Assert.notNull(clock, "Clock must not be null"); this.clock = clock; } @Override public Mono installPresetPlugins() { return getPresetJars() .flatMap(path -> this.install(path) .onErrorResume(PluginAlreadyExistsException.class, e -> Mono.empty()) .flatMap(plugin -> FileUtils.deleteFileSilently(path) .thenReturn(plugin) ) ) .flatMap(this::enablePlugin) .subscribeOn(Schedulers.boundedElastic()) .then(); } private Mono enablePlugin(Plugin plugin) { plugin.getSpec().setEnabled(true); return client.update(plugin) .onErrorResume(OptimisticLockingFailureException.class, e -> enablePlugin(plugin.getMetadata().getName()) ); } private Mono enablePlugin(String name) { return Mono.defer(() -> client.get(Plugin.class, name) .flatMap(plugin -> { plugin.getSpec().setEnabled(true); return client.update(plugin); }) ) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)); } @Override public Mono install(Path path) { return findPluginManifest(path) .doOnNext(plugin -> { // validate the plugin version satisfiesRequiresVersion(plugin); checkDependencies(plugin); }) .flatMap(pluginInPath -> client.fetch(Plugin.class, pluginInPath.getMetadata().getName()) .flatMap(oldPlugin -> Mono.error( new PluginAlreadyExistsException(oldPlugin.getMetadata().getName()))) .switchIfEmpty(Mono.defer( () -> copyToPluginHome(pluginInPath) .flatMap(this::findPluginManifest) .doOnNext(p -> { // Disable auto enable after installation p.getSpec().setEnabled(false); }) .flatMap(client::create)) )); } private void checkDependencies(Plugin plugin) { var resolvedPlugins = new ArrayList<>(pluginManager.getResolvedPlugins()); var pluginDescriptors = new ArrayList(resolvedPlugins.size() + 1); resolvedPlugins.stream() .map(PluginWrapper::getDescriptor) .forEach(pluginDescriptors::add); var pluginDescriptor = YamlPluginDescriptorFinder.convert(plugin); pluginDescriptors.add(pluginDescriptor); var deptResolver = new DependencyResolver(pluginManager.getVersionManager()); var result = deptResolver.resolve(pluginDescriptors); if (result.hasCyclicDependency()) { throw new PluginDependencyException.CyclicException(); } var notFoundDependencies = result.getNotFoundDependencies(); if (!CollectionUtils.isEmpty(notFoundDependencies)) { throw new PluginDependencyException.NotFoundException(notFoundDependencies); } var wrongVersionDependencies = result.getWrongVersionDependencies(); if (!CollectionUtils.isEmpty(wrongVersionDependencies)) { throw new PluginDependencyException.WrongVersionsException(wrongVersionDependencies); } } @Override public Mono upgrade(String name, Path path) { return findPluginManifest(path) .doOnNext(plugin -> { satisfiesRequiresVersion(plugin); checkDependencies(plugin); }) .flatMap(pluginInPath -> { // pre-check the plugin in the path Assert.notNull(pluginInPath.statusNonNull().getLoadLocation(), "plugin.status.load-location must not be null"); if (!Objects.equals(name, pluginInPath.getMetadata().getName())) { return Mono.error(new ServerWebInputException( "The provided plugin " + pluginInPath.getMetadata().getName() + " didn't match the given plugin " + name)); } // check if the plugin exists return client.fetch(Plugin.class, name) .switchIfEmpty(Mono.error(() -> new ServerWebInputException( "The given plugin with name " + name + " was not found."))) // copy plugin into plugin home .flatMap(oldPlugin -> copyToPluginHome(pluginInPath).thenReturn(oldPlugin)) .doOnNext(oldPlugin -> updatePlugin(oldPlugin, pluginInPath)) .flatMap(client::update); }); } @Override public Mono reload(String name) { return client.get(Plugin.class, name) .flatMap(oldPlugin -> { if (oldPlugin.getStatus() == null || oldPlugin.getStatus().getLoadLocation() == null) { return Mono.error(new IllegalStateException( "Load location of plugin has not been populated.")); } var loadLocation = oldPlugin.getStatus().getLoadLocation(); var loadPath = Path.of(loadLocation); return findPluginManifest(loadPath) .doOnNext(newPlugin -> updatePlugin(oldPlugin, newPlugin)) .thenReturn(oldPlugin); }) .flatMap(client::update); } @Override public Flux uglifyJsBundle() { var startedPlugins = pluginManager.startedPlugins(); var dataBufferFactory = DefaultDataBufferFactory.sharedInstance; var end = Mono.fromSupplier( () -> { var sb = new StringBuilder("this.enabledPlugins = ["); var iterator = startedPlugins.iterator(); while (iterator.hasNext()) { var plugin = iterator.next(); sb.append(""" {"name":"%s","version":"%s"}\ """ .formatted( plugin.getPluginId(), plugin.getDescriptor().getVersion() ) ); if (iterator.hasNext()) { sb.append(','); } } sb.append(']'); return dataBufferFactory.wrap(sb.toString().getBytes(StandardCharsets.UTF_8)); }); var body = Flux.fromIterable(startedPlugins) .sort(Comparator.comparing(PluginWrapper::getPluginId)) .flatMapSequential(pluginWrapper -> { var pluginId = pluginWrapper.getPluginId(); return getBundleResource(pluginId, BundleResourceUtils.JS_BUNDLE) .flatMapMany(resource -> { var head = Mono.fromSupplier( () -> dataBufferFactory.wrap( ("// Generated from plugin " + pluginId + "\n").getBytes() )); var content = DataBufferUtils.read( resource, dataBufferFactory, StreamUtils.BUFFER_SIZE ); var tail = Mono.fromSupplier(() -> dataBufferFactory.wrap("\n".getBytes())); return Flux.concat(head, content, tail); }); }); return Flux.concat(body, end); } @Override public Flux uglifyCssBundle() { return Flux.fromIterable(pluginManager.startedPlugins()) .sort(Comparator.comparing(PluginWrapper::getPluginId)) .flatMapSequential(pluginWrapper -> { var pluginId = pluginWrapper.getPluginId(); var dataBufferFactory = DefaultDataBufferFactory.sharedInstance; return getBundleResource(pluginId, BundleResourceUtils.CSS_BUNDLE) .flatMapMany(resource -> { var head = Mono.fromSupplier(() -> dataBufferFactory.wrap( ("/* Generated from plugin " + pluginId + " */\n").getBytes() )); var content = DataBufferUtils.read( resource, dataBufferFactory, StreamUtils.BUFFER_SIZE); var tail = Mono.fromSupplier(() -> dataBufferFactory.wrap("\n".getBytes())); return Flux.concat(head, content, tail); }); }); } private Mono getBundleResource(String pluginName, String bundleName) { return Mono.fromSupplier( () -> BundleResourceUtils.getJsBundleResource(pluginManager, pluginName, bundleName) ) .filter(Resource::isReadable); } @Override public Mono generateBundleVersion() { if (pluginManager.isDevelopment()) { return Mono.just(String.valueOf(clock.instant().toEpochMilli())); } return Flux.fromIterable(pluginManager.startedPlugins()) .sort(Comparator.comparing(PluginWrapper::getPluginId)) .map(pw -> pw.getPluginId() + ':' + pw.getDescriptor().getVersion()) .collect(Collectors.joining()) .map(Hashing.sha256()::hashUnencodedChars) .map(HashCode::toString); } @Override public Mono getJsBundle(String version) { return jsBundleCache.computeIfAbsent(version, this.uglifyJsBundle()); } @Override public Mono getCssBundle(String version) { return cssBundleCache.computeIfAbsent(version, this.uglifyCssBundle()); } @Override public Mono changeState(String pluginName, boolean requestToEnable, boolean wait) { var updatedPlugin = Mono.defer(() -> client.get(Plugin.class, pluginName)) .flatMap(plugin -> { if (!Objects.equals(requestToEnable, plugin.getSpec().getEnabled())) { // preflight check if (requestToEnable) { // make sure the dependencies are enabled var notStartedDependencies = getRequiredDependencies(plugin, pw -> pw == null || !Objects.equals(STARTED, pw.getPluginState()) ); if (!CollectionUtils.isEmpty(notStartedDependencies)) { return Mono.error( new PluginDependenciesNotEnabledException(notStartedDependencies) ); } } else { // make sure the dependents are disabled var dependents = pluginManager.getDependents(pluginName); var notDisabledDependents = dependents.stream() .filter( dependent -> Objects.equals(STARTED, dependent.getPluginState()) ) .map(PluginWrapper::getPluginId) .toList(); if (!CollectionUtils.isEmpty(notDisabledDependents)) { return Mono.error( new PluginDependentsNotDisabledException(notDisabledDependents) ); } } plugin.getSpec().setEnabled(requestToEnable); log.debug("Updating plugin {} state to {}", pluginName, requestToEnable); return client.update(plugin); } log.debug("Checking plugin {} state, no need to update", pluginName); return Mono.just(plugin); }); if (wait) { // if we want to wait the state of plugin to be updated updatedPlugin = updatedPlugin .flatMap(plugin -> { var phase = plugin.statusNonNull().getPhase(); if (requestToEnable) { // if we request to enable the plugin if (!(Plugin.Phase.STARTED.equals(phase) || Plugin.Phase.FAILED.equals(phase))) { return Mono.error(UnexpectedPluginStateException::new); } } else { // if we request to disable the plugin if (Plugin.Phase.STARTED.equals(phase)) { return Mono.error(UnexpectedPluginStateException::new); } } return Mono.just(plugin); }) .retryWhen( Retry.backoff(10, Duration.ofMillis(100)) .filter(UnexpectedPluginStateException.class::isInstance) .doBeforeRetry(signal -> log.debug("Waiting for plugin {} to meet expected state", pluginName) ) ) .doOnSuccess(plugin -> { log.info("Plugin {} met expected state {}", pluginName, plugin.statusNonNull().getPhase()); }); } return updatedPlugin; } @Override public List getRequiredDependencies(Plugin plugin, Predicate predicate) { return getPluginDependency(plugin) .stream() .filter(dependency -> !dependency.isOptional()) .map(PluginDependency::getPluginId) .filter(dependencyPlugin -> { var pluginWrapper = pluginManager.getPlugin(dependencyPlugin); return predicate.test(pluginWrapper); }) .sorted() .toList(); } @Override public Flux getStartedPluginNames() { return Flux.fromIterable(pluginManager.startedPlugins()) .map(PluginWrapper::getPluginId); } private static List getPluginDependency(Plugin plugin) { return plugin.getSpec().getPluginDependencies().keySet() .stream() .map(PluginDependency::new) .toList(); } Mono findPluginManifest(Path path) { return Mono.fromSupplier( () -> { final var pluginFinder = new YamlPluginFinder(); return pluginFinder.find(path); }) .onErrorMap(e -> new PluginInstallationException("Failed to parse the plugin manifest", "problemDetail.plugin.missingManifest", null) ); } @Override public void afterPropertiesSet() throws Exception { this.tempDir = Files.createTempDirectory("halo-plugin-bundle"); } @Override public void destroy() throws Exception { FileSystemUtils.deleteRecursively(this.tempDir); } /** * Set temporary directory for plugin bundle. * * @param tempDir temporary directory. */ void setTempDir(Path tempDir) { this.tempDir = tempDir; } /** * Copy plugin into plugin home. * * @param plugin is a staging plugin. * @return new path in plugin home. */ private Mono copyToPluginHome(Plugin plugin) { return Mono.fromCallable( () -> { var fileName = PluginUtils.generateFileName(plugin); var pluginRoot = pluginsRootGetter.get(); try { Files.createDirectories(pluginRoot); } catch (IOException e) { throw Exceptions.propagate(e); } var pluginFilePath = pluginRoot.resolve(fileName); FileUtils.checkDirectoryTraversal(pluginRoot, pluginFilePath); // move the plugin jar file to the plugin root // replace the old plugin jar file if exists var path = Path.of(plugin.getStatus().getLoadLocation()); FileUtils.copy(path, pluginFilePath, REPLACE_EXISTING); return pluginFilePath; }) .subscribeOn(Schedulers.boundedElastic()) .doOnNext(loadLocation -> { // reset load location and annotation PLUGIN_PATH plugin.getStatus().setLoadLocation(loadLocation.toUri()); var annotations = plugin.getMetadata().getAnnotations(); if (annotations == null) { annotations = new HashMap<>(); plugin.getMetadata().setAnnotations(annotations); } annotations.put(PluginConst.PLUGIN_PATH, loadLocation.toString()); }); } private void satisfiesRequiresVersion(Plugin newPlugin) { Assert.notNull(newPlugin, "The plugin must not be null."); Version version = systemVersion.get(); // validate the plugin version // only use the nominal system version to compare, the format is like MAJOR.MINOR.PATCH String systemVersion = version.toStableVersion().toString(); String requires = newPlugin.getSpec().getRequires(); if (!VersionUtils.satisfiesRequires(systemVersion, requires)) { throw new UnsatisfiedAttributeValueException(String.format( "Plugin requires a minimum system version of [%s], but the current version is " + "[%s].", requires, systemVersion), "problemDetail.plugin.version.unsatisfied.requires", new String[] {requires, systemVersion}); } } private Flux getPresetJars() { var resolver = new PathMatchingResourcePatternResolver(); try { var resources = resolver.getResources(PRESETS_LOCATION_PATTERN); return Flux.fromArray(resources) .mapNotNull(resource -> { var filename = resource.getFilename(); if (StringUtils.isBlank(filename)) { return null; } var path = tempDir.resolve(filename); FileUtils.copyResource(resource, path); return path; }); } catch (IOException e) { log.debug("Failed to load preset plugins: {}", e.getMessage()); return Flux.empty(); } } private static void updatePlugin(Plugin oldPlugin, Plugin newPlugin) { var oldMetadata = oldPlugin.getMetadata(); var newMetadata = newPlugin.getMetadata(); // merge labels if (!CollectionUtils.isEmpty(newMetadata.getLabels())) { var labels = oldMetadata.getLabels(); if (labels == null) { labels = new HashMap<>(); oldMetadata.setLabels(labels); } labels.putAll(newMetadata.getLabels()); } var annotations = oldMetadata.getAnnotations(); if (annotations == null) { annotations = new HashMap<>(); oldMetadata.setAnnotations(annotations); } // merge annotations if (!CollectionUtils.isEmpty(newMetadata.getAnnotations())) { annotations.putAll(newMetadata.getAnnotations()); } // request to reload annotations.put(RELOAD_ANNO, newPlugin.getStatus().getLoadLocation().toString()); // apply spec and keep enabled request var enabled = oldPlugin.getSpec().getEnabled(); oldPlugin.setSpec(newPlugin.getSpec()); oldPlugin.getSpec().setEnabled(enabled); } class BundleCache { private final String suffix; private final AtomicBoolean writing = new AtomicBoolean(); private volatile Resource resource; BundleCache(String suffix) { this.suffix = suffix; } Mono computeIfAbsent(String version, Publisher content) { var filename = buildBundleFilename(version, suffix); if (isResourceMatch(resource, filename)) { return Mono.just(resource); } return generateBundleVersion() .flatMap(newVersion -> { var newFilename = buildBundleFilename(newVersion, suffix); if (isResourceMatch(this.resource, newFilename)) { // if the resource was not changed, just return it return Mono.just(resource); } if (writing.compareAndSet(false, true)) { return Mono.justOrEmpty(this.resource) // double check of the resource .filter(res -> isResourceMatch(res, newFilename)) .switchIfEmpty(Mono.using( () -> { if (!Files.exists(tempDir)) { Files.createDirectories(tempDir); } return tempDir.resolve(newFilename); }, path -> DataBufferUtils.write(content, path, CREATE, TRUNCATE_EXISTING) .then(Mono.fromSupplier( () -> new FileSystemResource(path) )), path -> { if (shouldCleanUp(path)) { // clean up old resource cleanUp(this.resource); } }) .subscribeOn(scheduler) .doOnNext(newResource -> this.resource = newResource) ) .doFinally(signalType -> writing.set(false)); } else { return Mono.defer(() -> { if (this.writing.get()) { log.debug("Waiting for the bundle file {} to be written", filename); return Mono.empty(); } log.debug("Waited the bundle file {} to be written", filename); return Mono.just(this.resource); }).repeatWhenEmpty(100, count -> { // retry after 100ms return count.delayElements(Duration.ofMillis(100)); }); } }); } private boolean shouldCleanUp(Path newPath) { if (this.resource == null || !this.resource.exists()) { return false; } try { var oldPath = this.resource.getFile().toPath(); return !oldPath.equals(newPath); } catch (IOException e) { return false; } } private static void cleanUp(Resource resource) { if (resource instanceof WritableResource wr && wr.isWritable() && wr.isFile()) { try { Files.deleteIfExists(wr.getFile().toPath()); } catch (IOException e) { log.warn("Failed to delete old bundle file {}", wr.getFilename(), e); } } } private static boolean isResourceMatch(Resource resource, String filename) { return resource != null && resource.exists() && resource.isFile() && Objects.equals(filename, resource.getFilename()); } } private static String buildBundleFilename(String v, String suffix) { Assert.notNull(v, "Version must not be null"); Assert.notNull(suffix, "Suffix must not be null"); return v + suffix; } private static class UnexpectedPluginStateException extends RuntimeException { } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginSharedEventDelegator.java ================================================ package run.halo.app.plugin; import java.util.Objects; import lombok.Getter; import org.springframework.context.ApplicationEvent; import org.springframework.lang.NonNull; /** * The event that delegates to another shared event published by a plugin. * * @author johnniang * @since 2.17 */ @Getter class PluginSharedEventDelegator extends ApplicationEvent { /** * The delegate event. */ private final ApplicationEvent delegate; public PluginSharedEventDelegator(@NonNull Object source, @NonNull ApplicationEvent delegate) { super(source); this.delegate = delegate; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } PluginSharedEventDelegator that = (PluginSharedEventDelegator) o; return Objects.equals(delegate, that.delegate); } @Override public int hashCode() { return Objects.hashCode(delegate); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginStartedListener.java ================================================ package run.halo.app.plugin; import static run.halo.app.plugin.PluginConst.PLUGIN_NAME_LABEL_NAME; import static run.halo.app.plugin.PluginExtensionLoaderUtils.isSetting; import static run.halo.app.plugin.PluginExtensionLoaderUtils.lookupExtensions; import java.time.Duration; import java.util.HashMap; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Plugin; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Unstructured; import run.halo.app.infra.utils.YamlUnstructuredLoader; import run.halo.app.plugin.event.HaloPluginStartedEvent; /** * TODO Optimized Unstructured loading. * * @author guqing * @since 2.0.0 */ @Slf4j @Component class PluginStartedListener { private static final Duration TIMEOUT = Duration.ofMinutes(1); private final ReactiveExtensionClient client; public PluginStartedListener(ReactiveExtensionClient extensionClient) { this.client = extensionClient; } private Mono createOrUpdate(Unstructured unstructured) { var name = unstructured.getMetadata().getName(); return client.fetch(unstructured.groupVersionKind(), name) .doOnNext(old -> unstructured.getMetadata().setVersion(old.getMetadata().getVersion())) .map(ignored -> unstructured) .flatMap(extension -> { if (ExtensionUtil.hasDoNotOverwriteLabel(extension)) { log.debug("Skip updating extension {} due to do-not-overwrite label", extension.getMetadata().getName()); return Mono.just(extension); } return client.update(extension); }) .switchIfEmpty(Mono.defer(() -> client.create(unstructured))); } @EventListener void onApplicationEvent(HaloPluginStartedEvent event) { var pluginWrapper = event.getPlugin(); var p = pluginWrapper.getPlugin(); if (!(p instanceof SpringPlugin springPlugin)) { return; } var applicationContext = springPlugin.getApplicationContext(); if (!(applicationContext instanceof PluginApplicationContext pluginApplicationContext)) { return; } var pluginName = pluginWrapper.getPluginId(); client.get(Plugin.class, pluginName) .flatMap(plugin -> Flux.fromStream( () -> { log.debug("Collecting extensions for plugin {}", pluginName); var resources = lookupExtensions(pluginWrapper.getPluginClassLoader()); var loader = new YamlUnstructuredLoader(resources); var settingName = plugin.getSpec().getSettingName(); // TODO The load method may be over memory consumption. return loader.load() .stream() .filter(isSetting(settingName).negate()); }) .doOnNext(unstructured -> { var name = unstructured.getMetadata().getName(); pluginApplicationContext .addExtensionMapping(unstructured.groupVersionKind(), name); var labels = unstructured.getMetadata().getLabels(); if (labels == null) { labels = new HashMap<>(); unstructured.getMetadata().setLabels(labels); } labels.put(PLUGIN_NAME_LABEL_NAME, plugin.getMetadata().getName()); }) .flatMap(this::createOrUpdate) .then() ) .block(TIMEOUT); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginUtils.java ================================================ package run.halo.app.plugin; import java.util.Objects; import lombok.experimental.UtilityClass; import org.apache.commons.lang3.StringUtils; import org.springframework.util.Assert; import org.springframework.web.server.ServerWebInputException; import run.halo.app.core.extension.Plugin; @UtilityClass public class PluginUtils { public static String generateFileName(Plugin plugin) { Assert.notNull(plugin, "The plugin must not be null."); Assert.notNull(plugin.getMetadata(), "The plugin metadata must not be null."); Assert.notNull(plugin.getSpec(), "The plugin spec must not be null."); String version = plugin.getSpec().getVersion(); if (StringUtils.isBlank(version)) { throw new ServerWebInputException("The plugin version must not be blank."); } return String.format("%s-%s.jar", plugin.getMetadata().getName(), version); } /** * Determine if the plugin is in development mode. Currently, we detect it from annotations. * * @param plugin is a manifest about plugin. * @return true if the plugin is in development mode; false otherwise. */ public static boolean isDevelopmentMode(Plugin plugin) { var annotations = plugin.getMetadata().getAnnotations(); return annotations != null && Objects.equals("dev", annotations.get(PluginConst.RUNTIME_MODE_ANNO)); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PluginsRootGetterImpl.java ================================================ package run.halo.app.plugin; import java.nio.file.Path; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import run.halo.app.infra.properties.HaloProperties; /** * Default implementation of {@link PluginsRootGetter}. * * @author johnniang */ @Component public class PluginsRootGetterImpl implements PluginsRootGetter { private final HaloProperties haloProperties; public PluginsRootGetterImpl(HaloProperties haloProperties) { this.haloProperties = haloProperties; } @Override @NonNull public Path get() { return haloProperties.getWorkDir().resolve("plugins"); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/PropertyPluginStatusProvider.java ================================================ package run.halo.app.plugin; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import org.pf4j.PluginStatusProvider; import org.thymeleaf.util.ArrayUtils; /** * An implementation for PluginStatusProvider. The enabled plugins are read * from {@code halo.plugin.enabled-plugins} properties in application.yaml * and the disabled plugins are read from {@code halo.plugin.disabled-plugins} * in application.yaml. * * @author guqing * @since 2.0.0 */ public class PropertyPluginStatusProvider implements PluginStatusProvider { private final List enabledPlugins; private final List disabledPlugins; public PropertyPluginStatusProvider(PluginProperties pluginProperties) { this.enabledPlugins = pluginProperties.getEnabledPlugins() != null ? Arrays.asList(pluginProperties.getEnabledPlugins()) : new ArrayList<>(); this.disabledPlugins = pluginProperties.getDisabledPlugins() != null ? Arrays.asList(pluginProperties.getDisabledPlugins()) : new ArrayList<>(); } public static boolean isPropertySet(PluginProperties pluginProperties) { return !ArrayUtils.isEmpty(pluginProperties.getEnabledPlugins()) && !ArrayUtils.isEmpty(pluginProperties.getDisabledPlugins()); } @Override public boolean isPluginDisabled(String pluginId) { if (disabledPlugins.contains(pluginId)) { return true; } return !enabledPlugins.isEmpty() && !enabledPlugins.contains(pluginId); } @Override public void disablePlugin(String pluginId) { if (isPluginDisabled(pluginId)) { return; } disabledPlugins.add(pluginId); enabledPlugins.remove(pluginId); } @Override public void enablePlugin(String pluginId) { if (!isPluginDisabled(pluginId)) { return; } enabledPlugins.add(pluginId); disabledPlugins.remove(pluginId); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/SharedApplicationContextFactory.java ================================================ package run.halo.app.plugin; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import org.springframework.cache.CacheManager; import org.springframework.context.ApplicationContext; import org.springframework.context.support.GenericApplicationContext; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.savedrequest.ServerRequestCache; import run.halo.app.content.PostContentService; import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.DefaultSchemeManager; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.BackupRootGetter; import run.halo.app.infra.ExternalLinkProcessor; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemInfoGetter; import run.halo.app.notification.NotificationCenter; import run.halo.app.notification.NotificationReasonEmitter; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.security.LoginHandlerEnhancer; import run.halo.app.security.authentication.CryptoService; /** * Utility for creating shared application context. * * @author guqing * @author johnniang * @since 2.12.0 */ public enum SharedApplicationContextFactory { ; public static ApplicationContext create(ApplicationContext rootContext) { // TODO Optimize creation timing var sharedContext = new GenericApplicationContext(); sharedContext.registerShutdownHook(); var beanFactory = sharedContext.getBeanFactory(); // register shared object here var extensionClient = rootContext.getBean(ExtensionClient.class); var reactiveExtensionClient = rootContext.getBean(ReactiveExtensionClient.class); beanFactory.registerSingleton("extensionClient", extensionClient); beanFactory.registerSingleton("reactiveExtensionClient", reactiveExtensionClient); DefaultSchemeManager defaultSchemeManager = rootContext.getBean(DefaultSchemeManager.class); beanFactory.registerSingleton("schemeManager", defaultSchemeManager); beanFactory.registerSingleton("externalUrlSupplier", rootContext.getBean(ExternalUrlSupplier.class)); beanFactory.registerSingleton("serverSecurityContextRepository", rootContext.getBean(ServerSecurityContextRepository.class)); beanFactory.registerSingleton("attachmentService", rootContext.getBean(AttachmentService.class)); beanFactory.registerSingleton("backupRootGetter", rootContext.getBean(BackupRootGetter.class)); beanFactory.registerSingleton("notificationReasonEmitter", rootContext.getBean(NotificationReasonEmitter.class)); beanFactory.registerSingleton("notificationCenter", rootContext.getBean(NotificationCenter.class)); beanFactory.registerSingleton("externalLinkProcessor", rootContext.getBean(ExternalLinkProcessor.class)); beanFactory.registerSingleton("postContentService", rootContext.getBean(PostContentService.class)); beanFactory.registerSingleton("cacheManager", rootContext.getBean(CacheManager.class)); beanFactory.registerSingleton("loginHandlerEnhancer", rootContext.getBean(LoginHandlerEnhancer.class)); rootContext.getBeanProvider(PluginsRootGetter.class) .ifUnique(pluginsRootGetter -> beanFactory.registerSingleton("pluginsRootGetter", pluginsRootGetter) ); beanFactory.registerSingleton("extensionGetter", rootContext.getBean(ExtensionGetter.class)); rootContext.getBeanProvider(CryptoService.class) .ifUnique( cryptoService -> beanFactory.registerSingleton("cryptoService", cryptoService) ); rootContext.getBeanProvider(RateLimiterRegistry.class) .ifUnique(rateLimiterRegistry -> beanFactory.registerSingleton("rateLimiterRegistry", rateLimiterRegistry) ); // Authentication plugins may need this RequestCache to handle successful login redirect rootContext.getBeanProvider(ServerRequestCache.class) .ifUnique(serverRequestCache -> beanFactory.registerSingleton("serverRequestCache", serverRequestCache) ); rootContext.getBeanProvider(UserService.class) .ifUnique(userService -> beanFactory.registerSingleton("userService", userService)); rootContext.getBeanProvider(RoleService.class) .ifUnique(roleService -> beanFactory.registerSingleton("roleService", roleService)); rootContext.getBeanProvider(ReactiveUserDetailsService.class) .ifUnique(userDetailsService -> beanFactory.registerSingleton("userDetailsService", userDetailsService) ); rootContext.getBeanProvider(SystemInfoGetter.class) .ifUnique(systemInfoGetter -> beanFactory.registerSingleton("systemInfoGetter", systemInfoGetter) ); // TODO add more shared instance here sharedContext.refresh(); return sharedContext; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/SharedEventDispatcher.java ================================================ package run.halo.app.plugin; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.Lifecycle; import org.springframework.context.event.EventListener; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class SharedEventDispatcher { private final SpringPluginManager pluginManager; private final ApplicationEventPublisher publisher; @EventListener(ApplicationEvent.class) void onApplicationEvent(ApplicationEvent event) { if (AnnotationUtils.findAnnotation(event.getClass(), SharedEvent.class) == null) { return; } // we should copy the plugins list to avoid ConcurrentModificationException var startedPlugins = pluginManager.startedPlugins(); // broadcast event to all started plugins except the publisher for (var startedPlugin : startedPlugins) { var plugin = startedPlugin.getPlugin(); if (!(plugin instanceof SpringPlugin springPlugin)) { continue; } var context = springPlugin.getApplicationContext(); // make sure the context is running before publishing the event if (context instanceof Lifecycle lifecycle && lifecycle.isRunning()) { context.publishEvent(new HaloSharedEventDelegator(this, event)); } } } @EventListener(PluginSharedEventDelegator.class) void onApplicationEvent(PluginSharedEventDelegator event) { publisher.publishEvent(event.getDelegate()); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/SpringComponentsFinder.java ================================================ package run.halo.app.plugin; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.nio.charset.StandardCharsets; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import lombok.extern.slf4j.Slf4j; import org.pf4j.AbstractExtensionFinder; import org.pf4j.PluginManager; import org.pf4j.PluginState; import org.pf4j.PluginStateEvent; import org.pf4j.PluginWrapper; import org.pf4j.processor.ExtensionStorage; /** *

The spring component finder. it will read {@code META-INF/plugin-components.idx} file in * plugin to obtain the class name that needs to be registered in the plugin IOC.

*

Reading index files directly is much faster than dynamically scanning class components when * the plugin is enabled.

* * @author guqing * @since 2.0.0 */ @Slf4j public class SpringComponentsFinder extends AbstractExtensionFinder { public static final String EXTENSIONS_RESOURCE = "META-INF/plugin-components.idx"; public SpringComponentsFinder(PluginManager pluginManager) { super(pluginManager); entries = new ConcurrentHashMap<>(); } @Override public Map> readClasspathStorages() { throw new UnsupportedOperationException(); } @Override public Map> readPluginsStorages() { throw new UnsupportedOperationException(); } private Set readPluginStorage(PluginWrapper pluginWrapper) { var pluginId = pluginWrapper.getPluginId(); log.debug("Reading extensions storage from plugin '{}'", pluginId); var bucket = new HashSet(); try { log.debug("Read '{}'", EXTENSIONS_RESOURCE); var classLoader = pluginWrapper.getPluginClassLoader(); try (var resourceStream = classLoader.getResourceAsStream(EXTENSIONS_RESOURCE)) { if (resourceStream == null) { log.debug("Cannot find '{}'", EXTENSIONS_RESOURCE); } else { collectExtensions(resourceStream, bucket); } } debugExtensions(bucket); } catch (IOException e) { log.error("Failed to read components from " + EXTENSIONS_RESOURCE, e); } return bucket; } private void collectExtensions(InputStream inputStream, Set bucket) throws IOException { try (Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) { ExtensionStorage.read(reader, bucket); } } @Override public void pluginStateChanged(PluginStateEvent event) { var pluginState = event.getPluginState(); String pluginId = event.getPlugin().getPluginId(); if (pluginState == PluginState.UNLOADED) { entries.remove(pluginId); } else if (pluginState == PluginState.CREATED || pluginState == PluginState.RESOLVED) { entries.computeIfAbsent(pluginId, id -> readPluginStorage(event.getPlugin())); } } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/SpringExtensionFactory.java ================================================ package run.halo.app.plugin; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.util.Comparator; import java.util.Optional; import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.pf4j.Extension; import org.pf4j.ExtensionFactory; import org.pf4j.PluginManager; import org.pf4j.PluginWrapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.context.ApplicationContext; import org.springframework.lang.Nullable; /** *

Basic implementation of an extension factory.

*

Uses Springs {@link AutowireCapableBeanFactory} to instantiate a given extension class.

*

All kinds of {@link Autowired} are supported (see example below). If no * {@link ApplicationContext} is * available (this is the case if either the related plugin is not a {@link BasePlugin} or the * given plugin manager is not a {@link HaloPluginManager}), standard Java reflection will be used * to instantiate an extension.

*

Creates a new extension instance every time a request is done.

* Example of supported autowire modes: *
{@code
 *     @Extension
 *     public class Foo implements ExtensionPoint {
 *
 *         private final Bar bar;       // Constructor injection
 *         private Baz baz;             // Setter injection
 *         @Autowired
 *         private Qux qux;             // Field injection
 *
 *         @Autowired
 *         public Foo(final Bar bar) {
 *             this.bar = bar;
 *         }
 *
 *         @Autowired
 *         public void setBaz(final Baz baz) {
 *             this.baz = baz;
 *         }
 *     }
 * }
* * @author guqing * @since 2.0.0 */ @Slf4j @RequiredArgsConstructor public class SpringExtensionFactory implements ExtensionFactory { /** * The plugin manager is used for retrieving a plugin from a given extension class and as a * fallback supplier of an application context. */ protected final PluginManager pluginManager; @Override @Nullable public T create(Class extensionClass) { return getPluginApplicationContextBy(extensionClass) .map(context -> context.getBean(extensionClass)) .orElseGet(() -> createWithoutSpring(extensionClass)); } /** * Creates an instance of the given class object by using standard Java reflection. * * @param extensionClass The class annotated with {@code @}{@link Extension}. * @param The type for that an instance should be created. * @return an instantiated extension. * @throws IllegalArgumentException if the given class object has no public constructor. * @throws RuntimeException if the called constructor cannot be instantiated with {@code * null}-parameters. */ @SuppressWarnings("unchecked") protected T createWithoutSpring(final Class extensionClass) throws IllegalArgumentException { final Constructor constructor = getPublicConstructorWithShortestParameterList(extensionClass) // An extension class is required to have at least one public constructor. .orElseThrow( () -> new IllegalArgumentException("Extension class '" + nameOf(extensionClass) + "' must have at least one public constructor.")); try { if (log.isTraceEnabled()) { log.trace("Instantiate '" + nameOf(extensionClass) + "' by calling '" + constructor + "'with standard Java reflection."); } // Creating the instance by calling the constructor with null-parameters (if there // are any). return (T) constructor.newInstance(nullParameters(constructor)); } catch (InstantiationException | IllegalAccessException | InvocationTargetException ex) { // If one of these exceptions is thrown it it most likely because of NPE inside the // called constructor and // not the reflective call itself as we precisely searched for a fitting constructor. log.error(ex.getMessage(), ex); throw new RuntimeException( "Most likely this exception is thrown because the called constructor (" + constructor + ")" + " cannot handle 'null' parameters. Original message was: " + ex.getMessage(), ex); } } private Optional> getPublicConstructorWithShortestParameterList( final Class extensionClass) { return Stream.of(extensionClass.getConstructors()) .min(Comparator.comparing(Constructor::getParameterCount)); } private Object[] nullParameters(final Constructor constructor) { return new Object[constructor.getParameterCount()]; } protected Optional getPluginApplicationContextBy( final Class extensionClass) { return Optional.ofNullable(this.pluginManager.whichPlugin(extensionClass)) .map(PluginWrapper::getPlugin) .filter(SpringPlugin.class::isInstance) .map(plugin -> (SpringPlugin) plugin) .map(SpringPlugin::getApplicationContext); } private String nameOf(final Class clazz) { return clazz.getName(); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/SpringPlugin.java ================================================ package run.halo.app.plugin; import org.jspecify.annotations.NonNull; import org.springframework.context.ApplicationContext; /** * Spring based plugin. * * @author johnniang * @since 2.22.0 */ public interface SpringPlugin { /** * Gets plugin context. * * @return plugin context */ @NonNull PluginContext getPluginContext(); /** * Gets application context of the plugin. * * @return application context of the plugin * @throws IllegalStateException if the application context is not ready yet */ @NonNull ApplicationContext getApplicationContext(); } ================================================ FILE: application/src/main/java/run/halo/app/plugin/SpringPluginFactory.java ================================================ package run.halo.app.plugin; import lombok.extern.slf4j.Slf4j; import org.pf4j.Plugin; import org.pf4j.PluginFactory; import org.pf4j.PluginWrapper; /** * The default implementation for PluginFactory. *

Get a {@link BasePlugin} instance from the {@link PluginApplicationContext}.

* * @author guqing * @author johnniang * @since 2.0.0 */ @Slf4j public class SpringPluginFactory implements PluginFactory { private final PluginApplicationContextFactory contextFactory; private final PluginGetter pluginGetter; public SpringPluginFactory(PluginApplicationContextFactory contextFactory, PluginGetter pluginGetter) { this.contextFactory = contextFactory; this.pluginGetter = pluginGetter; } @Override public Plugin create(PluginWrapper pluginWrapper) { var plugin = pluginGetter.getPlugin(pluginWrapper.getPluginId()); var pluginContext = PluginContext.builder() .name(pluginWrapper.getPluginId()) .configMapName(plugin.getSpec().getConfigMapName()) .version(pluginWrapper.getDescriptor().getVersion()) .runtimeMode(pluginWrapper.getRuntimeMode()) .build(); return new DefaultSpringPlugin(contextFactory, pluginContext); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/SpringPluginManager.java ================================================ package run.halo.app.plugin; import java.util.List; import org.jspecify.annotations.NonNull; import org.pf4j.PluginManager; import org.pf4j.PluginWrapper; import org.springframework.context.ApplicationContext; /** * Plugin manager for Spring-based applications. * * @author johnniang * @since 2.12.0 */ public interface SpringPluginManager extends PluginManager { /** * Gets the root application context. * * @return the root application context */ @NonNull ApplicationContext getRootContext(); /** * Get the shared application context among plugins. * * @return the shared application context */ @NonNull ApplicationContext getSharedContext(); /** * Get all dependents recursively. * * @param pluginId plugin id * @return a list of plugin wrapper. The order of the list is from the farthest dependent to * the nearest dependent. * @since 2.16.0 */ @NonNull List getDependents(@NonNull String pluginId); /** * Gets all started plugins. * * @return a list of started plugins. Mutable. * @apiNote The plugin inside this list may not be really started */ @Override List getStartedPlugins(); /** * Gets all really started plugins. * * @return a list of really started plugins. Immutable. */ List startedPlugins(); } ================================================ FILE: application/src/main/java/run/halo/app/plugin/YamlPluginDescriptorFinder.java ================================================ package run.halo.app.plugin; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.pf4j.DefaultPluginDescriptor; import org.pf4j.PluginDependency; import org.pf4j.PluginDescriptor; import org.pf4j.PluginDescriptorFinder; import org.pf4j.util.FileUtils; import org.springframework.util.CollectionUtils; import run.halo.app.core.extension.Plugin; /** * Find a plugin descriptor for a plugin path. * * @author guqing * @see DefaultPluginDescriptor * @since 2.0.0 */ @Slf4j class YamlPluginDescriptorFinder implements PluginDescriptorFinder { private final YamlPluginFinder yamlPluginFinder; public YamlPluginDescriptorFinder() { yamlPluginFinder = new YamlPluginFinder(); } @Override public boolean isApplicable(Path pluginPath) { return Files.exists(pluginPath) && (Files.isDirectory(pluginPath) || FileUtils.isJarFile(pluginPath)); } @Override public PluginDescriptor find(Path pluginPath) { Plugin plugin = yamlPluginFinder.find(pluginPath); return convert(plugin); } public static PluginDescriptor convert(Plugin plugin) { var pluginId = plugin.getMetadata().getName(); var spec = plugin.getSpec(); var author = spec.getAuthor(); var provider = (author == null ? StringUtils.EMPTY : author.getName()); var defaultPluginDescriptor = new DefaultPluginDescriptor(pluginId, spec.getDescription(), BasePlugin.class.getName(), spec.getVersion(), spec.getRequires(), provider, joinLicense(spec.getLicense())); // add dependencies spec.getPluginDependencies().forEach((pluginDepName, versionRequire) -> { PluginDependency dependency = new PluginDependency(String.format("%s@%s", pluginDepName, versionRequire)); defaultPluginDescriptor.addDependency(dependency); }); return defaultPluginDescriptor; } private static String joinLicense(List licenses) { if (CollectionUtils.isEmpty(licenses)) { return StringUtils.EMPTY; } return licenses.stream() .map(Plugin.License::getName) .collect(Collectors.joining(",")); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/YamlPluginFinder.java ================================================ package run.halo.app.plugin; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NonNull; import org.pf4j.DevelopmentPluginClasspath; import org.pf4j.PluginRuntimeException; import org.pf4j.util.FileUtils; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import run.halo.app.core.extension.Plugin; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.Unstructured; import run.halo.app.infra.utils.YamlUnstructuredLoader; /** *

Reading plugin descriptor data from plugin.yaml.

* Example: *
 * apiVersion: v1alpha1
 * kind: Plugin
 * metadata:
 *   name: plugin-1
 *   labels:
 *     extensions.guqing.xyz/category: attachment
 * spec:
 *   # 'version' is a valid semantic version string (see semver.org).
 *   version: 0.0.1
 *   requires: ">=2.0.0"
 *   author: guqing
 *   logo: example.com/logo.png
 *   pluginClass: xyz.guqing.plugin.potatoes.PotatoesApp
 *   pluginDependencies:
 *    "plugin-2": 1.0.0
 *   # 'homepage' usually links to the GitHub repository of the plugin
 *   homepage: example.com
 *   # 'displayName' explains what the plugin does in only a few words
 *   displayName: "a name to show"
 *   description: "Tell me more about this plugin."
 *   license:
 *     - name: MIT
 * 
* * @author guqing * @since 2.0.0 */ @Slf4j public class YamlPluginFinder implements PluginFinder { static final DevelopmentPluginClasspath PLUGIN_CLASSPATH = new DevelopmentPluginClasspath(); public static final String DEFAULT_PROPERTIES_FILE_NAME = "plugin.yaml"; private final String propertiesFileName; public YamlPluginFinder() { this(DEFAULT_PROPERTIES_FILE_NAME); } public YamlPluginFinder(String propertiesFileName) { this.propertiesFileName = propertiesFileName; } @Override @NonNull public Plugin find(@NonNull Path pluginPath) { Plugin plugin = readPluginDescriptor(pluginPath); if (plugin.getStatus() == null) { Plugin.PluginStatus pluginStatus = new Plugin.PluginStatus(); pluginStatus.setPhase(Plugin.Phase.PENDING); pluginStatus.setLoadLocation(pluginPath.toUri()); plugin.setStatus(pluginStatus); } MetadataUtil.nullSafeAnnotations(plugin) .put(PluginConst.PLUGIN_PATH, pluginPath.toString()); return plugin; } protected Plugin readPluginDescriptor(Path pluginPath) { Path propertiesPath = null; try { propertiesPath = getManifestPath(pluginPath, propertiesFileName); if (propertiesPath == null) { throw new PluginRuntimeException("Cannot find the plugin manifest path"); } log.debug("Lookup plugin descriptor in '{}'", propertiesPath); if (Files.notExists(propertiesPath)) { throw new PluginRuntimeException("Cannot find '{}' path", propertiesPath); } Resource propertyResource = new FileSystemResource(propertiesPath); return unstructuredToPlugin(propertyResource); } finally { FileUtils.closePath(propertiesPath); } } protected Plugin unstructuredToPlugin(Resource propertyResource) { YamlUnstructuredLoader yamlUnstructuredLoader = new YamlUnstructuredLoader(propertyResource); List unstructuredList = yamlUnstructuredLoader.load(); if (unstructuredList.size() != 1) { throw new PluginRuntimeException("Unable to find plugin descriptor file '{}'", propertiesFileName); } Unstructured unstructured = unstructuredList.get(0); return Unstructured.OBJECT_MAPPER.convertValue(unstructured, Plugin.class); } protected Path getManifestPath(Path pluginPath, String propertiesFileName) { if (Files.isDirectory(pluginPath)) { for (String location : PLUGIN_CLASSPATH.getClassesDirectories()) { var path = pluginPath.resolve(location).resolve(propertiesFileName); Resource propertyResource = new FileSystemResource(path); if (propertyResource.exists()) { return path; } } throw new PluginRuntimeException( "Unable to find plugin descriptor file: " + DEFAULT_PROPERTIES_FILE_NAME); } else { // it's a jar file try { return FileUtils.getPath(pluginPath, propertiesFileName); } catch (IOException e) { throw new PluginRuntimeException(e); } } } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/event/HaloPluginBeforeStopEvent.java ================================================ package run.halo.app.plugin.event; import org.pf4j.PluginWrapper; import org.springframework.context.ApplicationEvent; /** * @author guqing * @since 2.0.0 */ public class HaloPluginBeforeStopEvent extends ApplicationEvent { private final PluginWrapper plugin; public HaloPluginBeforeStopEvent(Object source, PluginWrapper plugin) { super(source); this.plugin = plugin; } public PluginWrapper getPlugin() { return plugin; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/event/HaloPluginStartedEvent.java ================================================ package run.halo.app.plugin.event; import lombok.Getter; import org.pf4j.PluginWrapper; import org.springframework.context.ApplicationEvent; import org.springframework.util.Assert; /** * This event will be published to application context once plugin is started. * * @author guqing */ @Getter public class HaloPluginStartedEvent extends ApplicationEvent { private final PluginWrapper plugin; public HaloPluginStartedEvent(Object source, PluginWrapper plugin) { super(source); Assert.notNull(plugin, "Plugin must not be null."); this.plugin = plugin; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/event/HaloPluginStoppedEvent.java ================================================ package run.halo.app.plugin.event; import org.pf4j.PluginState; import org.pf4j.PluginWrapper; import org.springframework.context.ApplicationEvent; /** * This event will be published to plugin application context once plugin is stopped. * * @author guqing * @date 2021-11-02 */ public class HaloPluginStoppedEvent extends ApplicationEvent { private final PluginWrapper plugin; public HaloPluginStoppedEvent(Object source, PluginWrapper plugin) { super(source); this.plugin = plugin; } public PluginWrapper getPlugin() { return plugin; } public PluginState getPluginState() { return plugin.getPluginState(); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/event/SpringPluginStartedEvent.java ================================================ package run.halo.app.plugin.event; import org.springframework.context.ApplicationEvent; import run.halo.app.plugin.SpringPlugin; public class SpringPluginStartedEvent extends ApplicationEvent { private final SpringPlugin springPlugin; public SpringPluginStartedEvent(Object source, SpringPlugin springPlugin) { super(source); this.springPlugin = springPlugin; } public SpringPlugin getSpringPlugin() { return springPlugin; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/event/SpringPluginStartingEvent.java ================================================ package run.halo.app.plugin.event; import org.springframework.context.ApplicationEvent; import run.halo.app.plugin.SpringPlugin; public class SpringPluginStartingEvent extends ApplicationEvent { private final SpringPlugin springPlugin; public SpringPluginStartingEvent(Object source, SpringPlugin springPlugin) { super(source); this.springPlugin = springPlugin; } public SpringPlugin getSpringPlugin() { return springPlugin; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/event/SpringPluginStoppedEvent.java ================================================ package run.halo.app.plugin.event; import org.springframework.context.ApplicationEvent; import run.halo.app.plugin.SpringPlugin; public class SpringPluginStoppedEvent extends ApplicationEvent { private final SpringPlugin springPlugin; public SpringPluginStoppedEvent(Object source, SpringPlugin springPlugin) { super(source); this.springPlugin = springPlugin; } public SpringPlugin getSpringPlugin() { return springPlugin; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/event/SpringPluginStoppingEvent.java ================================================ package run.halo.app.plugin.event; import org.springframework.context.ApplicationEvent; import run.halo.app.plugin.SpringPlugin; public class SpringPluginStoppingEvent extends ApplicationEvent { private final SpringPlugin springPlugin; public SpringPluginStoppingEvent(Object source, SpringPlugin springPlugin) { super(source); this.springPlugin = springPlugin; } public SpringPlugin getSpringPlugin() { return springPlugin; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/extensionpoint/AbstractDefinitionGetter.java ================================================ package run.halo.app.plugin.extensionpoint; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.DisposableBean; import run.halo.app.extension.Extension; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; @RequiredArgsConstructor abstract class AbstractDefinitionGetter implements Reconciler, DisposableBean { protected final ConcurrentMap cache = new ConcurrentHashMap<>(); private final ExtensionClient client; private final E watchType; abstract void putCache(E definition); @Override @SuppressWarnings("unchecked") public Result reconcile(Request request) { client.fetch((Class) watchType.getClass(), request.name()) .ifPresent(this::putCache); return Result.doNotRetry(); } @Override public Controller setupWith(ControllerBuilder builder) { return builder.extension(watchType) .syncAllOnStart(true) .build(); } @Override public void destroy() throws Exception { cache.clear(); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetter.java ================================================ package run.halo.app.plugin.extensionpoint; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.pf4j.ExtensionPoint; import org.pf4j.PluginWrapper; import org.springframework.beans.factory.BeanFactory; import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting.ExtensionPointEnabled; import run.halo.app.plugin.SpringPlugin; import run.halo.app.plugin.SpringPluginManager; @Slf4j @Component @RequiredArgsConstructor public class DefaultExtensionGetter implements ExtensionGetter { private final SystemConfigFetcher systemConfigFetcher; private final SpringPluginManager pluginManager; private final BeanFactory beanFactory; private final ExtensionDefinitionGetter extensionDefinitionGetter; private final ExtensionPointDefinitionGetter extensionPointDefinitionGetter; @Override public Flux getExtensions(Class extensionPoint) { return Flux.fromIterable(lookExtensions(extensionPoint)) .concatWith( Flux.fromStream(() -> beanFactory.getBeanProvider(extensionPoint).orderedStream()) ) .sort(new AnnotationAwareOrderComparator()); } @Override public List getExtensionList(Class extensionPoint) { var extensions = new LinkedList(); extensions.addAll(lookExtensions(extensionPoint)); extensions.addAll(beanFactory.getBeanProvider(extensionPoint).orderedStream().toList()); extensions.sort(new AnnotationAwareOrderComparator()); return extensions; } @Override public Mono getEnabledExtension(Class extensionPoint) { return getEnabledExtensions(extensionPoint).next(); } @Override public Flux getEnabledExtensions( Class extensionPoint) { return fetchExtensionPointDefinition(extensionPoint) .flatMapMany(epd -> { var epdName = epd.getMetadata().getName(); var type = epd.getSpec().getType(); if (type == ExtensionPointDefinition.ExtensionPointType.SINGLETON) { return getEnabledExtensions(epdName, extensionPoint).take(1); } // TODO If the type is sortable, may need to process the returned order. return getEnabledExtensions(epdName, extensionPoint); }); } private Flux getEnabledExtensions(String epdName, Class extensionPoint) { return systemConfigFetcher.fetch(ExtensionPointEnabled.GROUP, ExtensionPointEnabled.class) .switchIfEmpty(Mono.fromSupplier(ExtensionPointEnabled::new)) .flatMapMany(enabled -> { var extensionDefNames = enabled.getOrDefault(epdName, null); if (extensionDefNames == null) { // get all extensions if not specified return Flux.defer(() -> getExtensions(extensionPoint)); } var extensions = getExtensions(extensionPoint).cache(); return Flux.fromIterable(extensionDefNames) .flatMapSequential(extensionDefinitionGetter::get) .flatMapSequential(extensionDef -> { var className = extensionDef.getSpec().getClassName(); return extensions.filter( extension -> Objects.equals(extension.getClass().getName(), className) ); }); }); } private Mono fetchExtensionPointDefinition( Class extensionPoint) { return extensionPointDefinitionGetter.getByClassName(extensionPoint.getName()); } @NonNull protected List lookExtensions(Class type) { List beans = new ArrayList<>(); // avoid concurrent modification var startedPlugins = pluginManager.startedPlugins(); for (PluginWrapper startedPlugin : startedPlugins) { if (startedPlugin.getPlugin() instanceof SpringPlugin springPlugin) { var pluginApplicationContext = springPlugin.getApplicationContext(); try { pluginApplicationContext.getBeansOfType(type) .forEach((name, bean) -> beans.add(bean)); } catch (Throwable e) { // Ignore log.error("Error while looking for extensions of type {}", type, e); } } else { var extensions = pluginManager.getExtensions(type, startedPlugin.getPluginId()); beans.addAll(extensions); } } return beans; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionDefinition.java ================================================ package run.halo.app.plugin.extensionpoint; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; /** * Extension definition. * An {@link ExtensionDefinition} is a type of metadata that provides additional information about * an extension. An extension is a way to add new functionality to an existing class, structure, * enumeration, or protocol type without needing to subclass it. * * @author guqing * @since 2.4.0 */ @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @GVK(group = "plugin.halo.run", version = "v1alpha1", kind = "ExtensionDefinition", singular = "extensiondefinition", plural = "extensiondefinitions") public class ExtensionDefinition extends AbstractExtension { @Schema(requiredMode = REQUIRED) private ExtensionSpec spec; @Data public static class ExtensionSpec { @Schema(requiredMode = REQUIRED) private String className; @Schema(requiredMode = REQUIRED) private String extensionPointName; @Schema(requiredMode = REQUIRED) private String displayName; private String description; private String icon; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionDefinitionGetter.java ================================================ package run.halo.app.plugin.extensionpoint; import reactor.core.publisher.Mono; public interface ExtensionDefinitionGetter { /** * Gets extension definition by extension definition name. * * @param name extension definition name */ Mono get(String name); } ================================================ FILE: application/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionDefinitionGetterImpl.java ================================================ package run.halo.app.plugin.extensionpoint; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.extension.ExtensionClient; @Component public class ExtensionDefinitionGetterImpl extends AbstractDefinitionGetter implements ExtensionDefinitionGetter { public ExtensionDefinitionGetterImpl(ExtensionClient client) { super(client, new ExtensionDefinition()); } @Override public Mono get(String name) { return Mono.fromSupplier(() -> cache.get(name)); } @Override void putCache(ExtensionDefinition definition) { cache.put(definition.getMetadata().getName(), definition); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionPointDefinition.java ================================================ package run.halo.app.plugin.extensionpoint; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; /** * Extension point definition. * An {@link ExtensionPointDefinition} is a concept used in Halo to allow for the * dynamic extension of system. It defines a location within Halo where * additional functionality can be added through the use of plugins or extensions. * * @author guqing * @since 2.4.0 */ @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @GVK(group = "plugin.halo.run", version = "v1alpha1", kind = "ExtensionPointDefinition", singular = "extensionpointdefinition", plural = "extensionpointdefinitions") public class ExtensionPointDefinition extends AbstractExtension { @Schema(requiredMode = REQUIRED) private ExtensionPointSpec spec; @Data public static class ExtensionPointSpec { @Schema(requiredMode = REQUIRED) private String className; @Schema(requiredMode = REQUIRED) private String displayName; @Schema(requiredMode = REQUIRED) private ExtensionPointType type; private String description; private String icon; } /** *

Types of extension points include.

* There are several types: *
    *
  • Singleton extension point: means that only one implementation class of the extension * point can be enabled. It is generally used for global core extension points, such as global * logging components. When using a singleton extension point, it is necessary to ensure that * only one implementation class is enabled, otherwise unexpected issues may occur.
  • *
  • Multi-instance extension point: means that there can be multiple implementation * classes of the extension point enabled, and the execution order of each implementation * class may be different. It is generally used for specific business logic extension points, * such as the selection of data sources or the use of caches. When using a multi-instance * extension point, it is necessary to consider the dependency relationship and execution * order between each implementation class to ensure the correctness of the business logic.
  • *
  • Ordered extension point: means that multiple implementation classes of the extension * point can be enabled, but they need to be executed in a specified order. It is generally * used in scenarios that require strict control of execution order, such as the execution * order of message listeners. When using an ordered extension point, it is necessary to * assign a priority for each implementation class to ensure that they can be executed in the * correct order.
  • *
  • Conditional extension point: means that multiple implementation classes of the extension * point can be enabled, but they need to meet specific conditions to be executed. For * example, some implementation classes can only be executed under specific operating systems * or specific runtime environments. When using a conditional extension point, it is * necessary to define appropriate conditions according to the actual scenario to ensure the * correctness and availability of the extension point.
  • *
* There are two kinds of definitions for the time being: SINGLETON and MULTI_INSTANCE. */ public enum ExtensionPointType { SINGLETON, MULTI_INSTANCE; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionPointDefinitionGetter.java ================================================ package run.halo.app.plugin.extensionpoint; import reactor.core.publisher.Mono; public interface ExtensionPointDefinitionGetter { /** * Gets extension point definition by extension point class. *

Retrieve by filedSelector: spec.className

* * @param className extension point class name */ Mono getByClassName(String className); } ================================================ FILE: application/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionPointDefinitionGetterImpl.java ================================================ package run.halo.app.plugin.extensionpoint; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.extension.ExtensionClient; @Component public class ExtensionPointDefinitionGetterImpl extends AbstractDefinitionGetter implements ExtensionPointDefinitionGetter { public ExtensionPointDefinitionGetterImpl(ExtensionClient client) { super(client, new ExtensionPointDefinition()); } @Override public Mono getByClassName(String className) { return Mono.fromSupplier(() -> cache.get(className)); } @Override void putCache(ExtensionPointDefinition definition) { var className = definition.getSpec().getClassName(); cache.put(className, definition); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/resources/BundleResourceUtils.java ================================================ package run.halo.app.plugin.resources; import org.pf4j.PluginManager; import org.pf4j.PluginWrapper; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.Resource; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import run.halo.app.infra.utils.FileUtils; import run.halo.app.infra.utils.PathUtils; /** * Plugin bundle resources utils. * * @author guqing * @since 2.0.0 */ public abstract class BundleResourceUtils { private static final String CONSOLE_BUNDLE_LOCATION = "console"; public static final String JS_BUNDLE = "main.js"; public static final String CSS_BUNDLE = "style.css"; /** * Gets js bundle resource by plugin name in console location. * * @return js bundle resource if exists, otherwise null */ @Nullable public static Resource getJsBundleResource(PluginManager pluginManager, String pluginName, String bundleName) { Assert.hasText(pluginName, "The pluginName must not be blank"); Assert.hasText(bundleName, "Bundle name must not be blank"); DefaultResourceLoader resourceLoader = getResourceLoader(pluginManager, pluginName); if (resourceLoader == null) { return null; } String path = PathUtils.combinePath(CONSOLE_BUNDLE_LOCATION, bundleName); String simplifyPath = StringUtils.cleanPath(path); FileUtils.checkDirectoryTraversal("/" + CONSOLE_BUNDLE_LOCATION, simplifyPath); Resource resource = resourceLoader.getResource(simplifyPath); return resource.exists() ? resource : null; } @Nullable public static DefaultResourceLoader getResourceLoader(PluginManager pluginManager, String pluginName) { Assert.notNull(pluginManager, "Plugin manager must not be null"); PluginWrapper plugin = pluginManager.getPlugin(pluginName); if (plugin == null) { return null; } return new DefaultResourceLoader(plugin.getPluginClassLoader()); } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionFactory.java ================================================ package run.halo.app.plugin.resources; import static org.springframework.http.MediaType.ALL; import static org.springframework.web.reactive.function.server.RequestPredicates.GET; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; import java.io.FileNotFoundException; import java.io.IOException; import java.time.Instant; import java.util.List; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.pf4j.PluginManager; import org.springframework.boot.autoconfigure.web.WebProperties; import org.springframework.context.ApplicationContext; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.http.CacheControl; import org.springframework.http.server.PathContainer; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.resource.NoResourceFoundException; import org.springframework.web.util.pattern.PathPatternParser; import reactor.core.publisher.Mono; import run.halo.app.core.extension.ReverseProxy; import run.halo.app.core.extension.ReverseProxy.FileReverseProxyProvider; import run.halo.app.core.extension.ReverseProxy.ReverseProxyRule; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.infra.utils.PathUtils; import run.halo.app.plugin.PluginConst; /** *

Plugin's reverse proxy router factory.

*

It creates a {@link RouterFunction} based on the ReverseProxy rule configured by * the plugin.

* * @author guqing * @since 2.0.0 */ @Slf4j @Component @AllArgsConstructor public class ReverseProxyRouterFunctionFactory { private final PluginManager pluginManager; private final ApplicationContext applicationContext; private final WebProperties webProperties; /** *

Create {@link RouterFunction} according to the {@link ReverseProxy} custom resource * configuration of the plugin.

*

Note that: returns {@code Null} if the plugin does not have a {@link ReverseProxy} custom * resource.

* * @param pluginName plugin name(nullable if system) * @return A reverse proxy RouterFunction handle(nullable) */ @Nullable public RouterFunction create(ReverseProxy reverseProxy, String pluginName) { return createReverseProxyRouterFunction(reverseProxy, nullSafePluginName(pluginName)); } @Nullable private RouterFunction createReverseProxyRouterFunction( ReverseProxy reverseProxy, @NonNull String pluginName) { Assert.notNull(reverseProxy, "The reverseProxy must not be null."); var rules = getReverseProxyRules(reverseProxy); var cacheProperties = webProperties.getResources().getCache(); var useLastModified = cacheProperties.isUseLastModified(); var cacheControl = cacheProperties.getCachecontrol().toHttpCacheControl(); if (cacheControl == null) { cacheControl = CacheControl.empty(); } var finalCacheControl = cacheControl; return rules.stream().map(rule -> { String routePath = buildRoutePath(pluginName, rule); log.debug("Plugin [{}] registered reverse proxy route path [{}]", pluginName, routePath); return RouterFunctions.route(GET(routePath).and(accept(ALL)), request -> { var resource = loadResourceByFileRule(pluginName, rule, request); if (!resource.exists()) { return Mono.error(new NoResourceFoundException(request.uri(), routePath)); } if (!useLastModified) { return ServerResponse.ok() .cacheControl(finalCacheControl) .body(BodyInserters.fromResource(resource)); } Instant lastModified; try { lastModified = Instant.ofEpochMilli(resource.lastModified()); } catch (IOException e) { if (e instanceof FileNotFoundException) { return Mono.error( new NoResourceFoundException(request.uri(), routePath) ); } return Mono.error(e); } return request.checkNotModified(lastModified) .switchIfEmpty(Mono.defer( () -> ServerResponse.ok() .cacheControl(finalCacheControl) .lastModified(lastModified) .body(BodyInserters.fromResource(resource))) ); }); }).reduce(RouterFunction::and).orElse(null); } private String nullSafePluginName(String pluginName) { return pluginName == null ? PluginConst.SYSTEM_PLUGIN_NAME : pluginName; } private List getReverseProxyRules(ReverseProxy reverseProxy) { return reverseProxy.getRules(); } public static String buildRoutePath(String pluginId, ReverseProxyRule reverseProxyRule) { return PathUtils.combinePath(PluginConst.assetsRoutePrefix(pluginId), reverseProxyRule.path()); } /** *

File load rule: if the directory is configured but the file name is not configured, it * means access through wildcards. Otherwise, if only the file name is configured, this * method only returns the file pointed to by the rule.

*

You should only use {@link Resource#getInputStream()} to get resource content instead of * {@link Resource#getFile()},the resource is loaded from the plugin jar file using a * specific plugin class loader; if you use {@link Resource#getFile()}, you cannot get the * file.

*

Note that a returned Resource handle does not imply an existing resource; you need to * invoke {@link Resource#exists()} to check for existence

* * @param pluginName plugin to load file by name * @param rule reverse proxy rule * @param request client request * @return a Resource handle for the specified resource location by the plugin(never null); */ @NonNull private Resource loadResourceByFileRule(String pluginName, ReverseProxyRule rule, ServerRequest request) { Assert.notNull(rule.file(), "File rule must not be null."); FileReverseProxyProvider file = rule.file(); String directory = file.directory(); // Decision file name String filename; String configuredFilename = file.filename(); if (StringUtils.isNotBlank(configuredFilename)) { filename = configuredFilename; } else { String routePath = buildRoutePath(pluginName, rule); PathContainer pathContainer = PathPatternParser.defaultInstance.parse(routePath) .extractPathWithinPattern(PathContainer.parsePath(request.path())); filename = pathContainer.value(); } String filePath = PathUtils.combinePath(directory, filename); return getResourceLoader(pluginName).getResource(filePath); } private ResourceLoader getResourceLoader(String pluginName) { if (PluginConst.SYSTEM_PLUGIN_NAME.equals(pluginName)) { return applicationContext; } DefaultResourceLoader resourceLoader = BundleResourceUtils.getResourceLoader(pluginManager, pluginName); if (resourceLoader == null) { throw new NotFoundException("Plugin [" + pluginName + "] not found."); } return resourceLoader; } } ================================================ FILE: application/src/main/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionRegistry.java ================================================ package run.halo.app.plugin.resources; import com.google.common.collect.LinkedHashMultimap; import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.locks.StampedLock; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.core.extension.ReverseProxy; import run.halo.app.plugin.PluginRouterFunctionRegistry; /** * A registry for {@link RouterFunction} of plugin. * * @author guqing * @since 2.0.0 */ @Component public class ReverseProxyRouterFunctionRegistry { private final PluginRouterFunctionRegistry pluginRouterFunctionRegistry; private final ReverseProxyRouterFunctionFactory reverseProxyRouterFunctionFactory; private final StampedLock lock = new StampedLock(); private final Map> proxyNameRouterFunctionRegistry = new HashMap<>(); private final LinkedHashMultimap pluginIdReverseProxyMap = LinkedHashMultimap.create(); public ReverseProxyRouterFunctionRegistry( PluginRouterFunctionRegistry pluginRouterFunctionRegistry, ReverseProxyRouterFunctionFactory reverseProxyRouterFunctionFactory) { this.pluginRouterFunctionRegistry = pluginRouterFunctionRegistry; this.reverseProxyRouterFunctionFactory = reverseProxyRouterFunctionFactory; } /** * Register reverse proxy router function. * * @param pluginId plugin id * @param reverseProxy reverse proxy */ public void register(String pluginId, ReverseProxy reverseProxy) { Assert.notNull(pluginId, "The plugin id must not be null."); final String proxyName = reverseProxy.getMetadata().getName(); long stamp = lock.writeLock(); try { pluginIdReverseProxyMap.put(pluginId, proxyName); var routerFunction = reverseProxyRouterFunctionFactory.create(reverseProxy, pluginId); if (routerFunction != null) { proxyNameRouterFunctionRegistry.put(proxyName, routerFunction); pluginRouterFunctionRegistry.register(Set.of(routerFunction)); } } finally { lock.unlockWrite(stamp); } } /** * Only for test. */ int reverseProxySize(String pluginId) { Set names = pluginIdReverseProxyMap.get(pluginId); return names.size(); } /** * Remove reverse proxy router function by pluginId and reverse proxy name. */ public void remove(String pluginId, String reverseProxyName) { long stamp = lock.writeLock(); try { pluginIdReverseProxyMap.remove(pluginId, reverseProxyName); var removedRouterFunction = proxyNameRouterFunctionRegistry.remove(reverseProxyName); if (removedRouterFunction != null) { pluginRouterFunctionRegistry.unregister(Set.of(removedRouterFunction)); } } finally { lock.unlockWrite(stamp); } } } ================================================ FILE: application/src/main/java/run/halo/app/search/HaloDocumentEventsListener.java ================================================ package run.halo.app.search; import java.time.Duration; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.search.event.HaloDocumentAddRequestEvent; import run.halo.app.search.event.HaloDocumentDeleteRequestEvent; import run.halo.app.search.event.HaloDocumentRebuildRequestEvent; @Component public class HaloDocumentEventsListener { private final ExtensionGetter extensionGetter; private int bufferSize; public HaloDocumentEventsListener(ExtensionGetter extensionGetter) { this.extensionGetter = extensionGetter; this.bufferSize = 200; } /** * Only for testing. * * @param bufferSize new buffer size for rebuilding indices */ void setBufferSize(int bufferSize) { this.bufferSize = bufferSize; } @EventListener @Async void onApplicationEvent(HaloDocumentRebuildRequestEvent event) { getSearchEngine() .doOnNext(SearchEngine::deleteAll) .flatMap(searchEngine -> extensionGetter.getExtensions(HaloDocumentsProvider.class) .flatMap(HaloDocumentsProvider::fetchAll) .buffer(this.bufferSize) .doOnNext(searchEngine::addOrUpdate) .then()) .blockOptional(Duration.ofMinutes(1)); } @EventListener @Async void onApplicationEvent(HaloDocumentAddRequestEvent event) { getSearchEngine() .doOnNext(searchEngine -> searchEngine.addOrUpdate(event.getDocuments())) .then() .blockOptional(Duration.ofMinutes(1)); } @EventListener @Async void onApplicationEvent(HaloDocumentDeleteRequestEvent event) { getSearchEngine() .doOnNext(searchEngine -> searchEngine.deleteDocument(event.getDocIds())) .then() .blockOptional(Duration.ofMinutes(1)); } private Mono getSearchEngine() { return extensionGetter.getEnabledExtension(SearchEngine.class) .filter(SearchEngine::available) .switchIfEmpty(Mono.error(SearchEngineUnavailableException::new)); } } ================================================ FILE: application/src/main/java/run/halo/app/search/IndexEndpoint.java ================================================ package run.halo.app.search; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; @Component public class IndexEndpoint implements CustomEndpoint { private static final String API_VERSION = "api.halo.run/v1alpha1"; private final SearchService searchService; public IndexEndpoint(SearchService searchService) { this.searchService = searchService; } @Override public RouterFunction endpoint() { final var tag = "IndexV1alpha1Public"; return SpringdocRouteBuilder.route() .POST("/indices/-/search", this::indicesSearch, builder -> builder.operationId("IndicesSearch") .tag(tag) .description("Search indices.") .requestBody(requestBodyBuilder().implementation(SearchOption.class) .description(""" Please note that the "filterPublished", "filterExposed" and \ "filterRecycled" fields are ignored in this endpoint.\ """) ) .response(responseBuilder().implementation(SearchResult.class)) ) .build(); } private Mono indicesSearch(ServerRequest serverRequest) { return serverRequest.bodyToMono(SearchOption.class) .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required."))) .flatMap(this::performSearch) .flatMap(result -> ServerResponse.ok().bodyValue(result)); } private Mono performSearch(SearchOption option) { option.setFilterExposed(true); option.setFilterPublished(true); option.setFilterRecycled(false); return searchService.search(option); } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion(API_VERSION); } } ================================================ FILE: application/src/main/java/run/halo/app/search/IndicesEndpoint.java ================================================ package run.halo.app.search; import lombok.extern.slf4j.Slf4j; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.search.event.HaloDocumentRebuildRequestEvent; @Component @Slf4j public class IndicesEndpoint implements CustomEndpoint { private static final String API_VERSION = "api.console.halo.run/v1alpha1"; private final ApplicationEventPublisher eventPublisher; public IndicesEndpoint(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } @Override public RouterFunction endpoint() { final var tag = "IndicesV1alpha1Console"; return SpringdocRouteBuilder.route() .POST("/indices/-/rebuild", this::rebuildIndices, builder -> builder.operationId("RebuildAllIndices") .tag(tag) .description("Rebuild all indices") ) .build(); } private Mono rebuildIndices(ServerRequest serverRequest) { return Mono.fromRunnable( () -> eventPublisher.publishEvent(new HaloDocumentRebuildRequestEvent(this)) ).then(ServerResponse.accepted().build()); } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion(API_VERSION); } } ================================================ FILE: application/src/main/java/run/halo/app/search/SearchEngineUnavailableException.java ================================================ package run.halo.app.search; import org.springframework.web.server.ServerWebInputException; /** * Search engine unavailable exception. * * @author johnniang */ public class SearchEngineUnavailableException extends ServerWebInputException { public SearchEngineUnavailableException() { super("Search Engine is unavailable."); } } ================================================ FILE: application/src/main/java/run/halo/app/search/SearchServiceImpl.java ================================================ package run.halo.app.search; import org.springframework.stereotype.Service; import org.springframework.validation.Validator; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import run.halo.app.infra.exception.RequestBodyValidationException; import run.halo.app.plugin.extensionpoint.ExtensionGetter; @Service public class SearchServiceImpl implements SearchService { private final Validator validator; private final ExtensionGetter extensionGetter; public SearchServiceImpl(Validator validator, ExtensionGetter extensionGetter) { this.validator = validator; this.extensionGetter = extensionGetter; } @Override public Mono search(SearchOption option) { // validate the option var errors = validator.validateObject(option); if (errors.hasErrors()) { return Mono.error(new RequestBodyValidationException(errors)); } return extensionGetter.getEnabledExtension(SearchEngine.class) .filter(SearchEngine::available) .switchIfEmpty(Mono.error(SearchEngineUnavailableException::new)) .flatMap(searchEngine -> Mono.fromSupplier(() -> searchEngine.search(option) ).subscribeOn(Schedulers.boundedElastic())); } } ================================================ FILE: application/src/main/java/run/halo/app/search/lucene/LuceneSearchEngine.java ================================================ package run.halo.app.search.lucene; import static org.apache.lucene.document.Field.Store.YES; import static org.apache.lucene.index.IndexWriterConfig.OpenMode.CREATE_OR_APPEND; import static org.apache.lucene.search.BooleanClause.Occur.FILTER; import static org.apache.lucene.search.BooleanClause.Occur.MUST; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.nio.file.Path; import java.time.Instant; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Optional; import lombok.extern.slf4j.Slf4j; import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.analysis.charfilter.HTMLStripCharFilterFactory; import org.apache.lucene.analysis.cjk.CJKBigramFilterFactory; import org.apache.lucene.analysis.cjk.CJKWidthCharFilterFactory; import org.apache.lucene.analysis.cjk.CJKWidthFilterFactory; import org.apache.lucene.analysis.core.LowerCaseFilterFactory; import org.apache.lucene.analysis.custom.CustomAnalyzer; import org.apache.lucene.analysis.standard.StandardTokenizerFactory; import org.apache.lucene.document.Document; import org.apache.lucene.document.FieldType; import org.apache.lucene.document.LongField; import org.apache.lucene.document.StoredField; import org.apache.lucene.document.StringField; import org.apache.lucene.document.TextField; import org.apache.lucene.index.DocValuesType; import org.apache.lucene.index.IndexNotFoundException; import org.apache.lucene.index.IndexWriter; import org.apache.lucene.index.IndexWriterConfig; import org.apache.lucene.index.Term; import org.apache.lucene.queryparser.flexible.core.QueryNodeException; import org.apache.lucene.queryparser.flexible.standard.StandardQueryParser; import org.apache.lucene.search.BooleanQuery; import org.apache.lucene.search.FuzzyQuery; import org.apache.lucene.search.IndexSearcher; import org.apache.lucene.search.SearcherManager; import org.apache.lucene.search.Sort; import org.apache.lucene.search.TermInSetQuery; import org.apache.lucene.search.TermQuery; import org.apache.lucene.search.highlight.Highlighter; import org.apache.lucene.search.highlight.InvalidTokenOffsetsException; import org.apache.lucene.search.highlight.QueryTermScorer; import org.apache.lucene.search.highlight.SimpleHTMLFormatter; import org.apache.lucene.store.Directory; import org.apache.lucene.store.FSDirectory; import org.apache.lucene.util.BytesRef; import org.apache.lucene.util.IOUtils; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; import org.springframework.core.convert.converter.Converter; import org.springframework.lang.NonNull; import org.springframework.util.StopWatch; import org.springframework.util.StringUtils; import reactor.core.Exceptions; import run.halo.app.search.HaloDocument; import run.halo.app.search.SearchEngine; import run.halo.app.search.SearchOption; import run.halo.app.search.SearchResult; @Slf4j public class LuceneSearchEngine implements SearchEngine, InitializingBean, DisposableBean { private final Path indexRootDir; private final Converter haloDocumentConverter = new HaloDocumentConverter(); private final Converter documentConverter = new DocumentConverter(); private Analyzer analyzer; private volatile SearcherManager searcherManager; private Directory directory; public LuceneSearchEngine(Path indexRootDir) { this.indexRootDir = indexRootDir; } @Override public boolean available() { return true; } @Override public void addOrUpdate(Iterable haloDocs) { var docs = new LinkedList(); var terms = new LinkedList(); haloDocs.forEach(haloDoc -> { var doc = this.haloDocumentConverter.convert(haloDoc); terms.add(new BytesRef(haloDoc.getId())); docs.add(doc); }); var deleteQuery = new TermInSetQuery("id", terms); var writerConfig = new IndexWriterConfig(this.analyzer) .setOpenMode(CREATE_OR_APPEND); synchronized (this) { try (var indexWriter = new IndexWriter(this.directory, writerConfig)) { indexWriter.updateDocuments(deleteQuery, docs); } catch (IOException e) { throw Exceptions.propagate(e); } finally { this.refreshSearcherManager(); } } } @Override public void deleteDocument(Iterable haloDocIds) { var terms = new LinkedList(); haloDocIds.forEach(haloDocId -> terms.add(new BytesRef(haloDocId))); var deleteQuery = new TermInSetQuery("id", terms); var writerConfig = new IndexWriterConfig(this.analyzer) .setOpenMode(CREATE_OR_APPEND); synchronized (this) { try (var indexWriter = new IndexWriter(this.directory, writerConfig)) { indexWriter.deleteDocuments(deleteQuery); } catch (IOException e) { throw Exceptions.propagate(e); } finally { this.refreshSearcherManager(); } } } @Override public void deleteAll() { var writerConfig = new IndexWriterConfig(this.analyzer) .setOpenMode(CREATE_OR_APPEND); synchronized (this) { try (var indexWriter = new IndexWriter(this.directory, writerConfig)) { indexWriter.deleteAll(); } catch (IOException e) { throw Exceptions.propagate(e); } finally { this.refreshSearcherManager(); } } } @Override public SearchResult search(SearchOption option) { IndexSearcher searcher = null; var sm = obtainSearcherManager(); if (sm.isEmpty()) { // indicate the index is empty var emptyResult = new SearchResult(); emptyResult.setKeyword(option.getKeyword()); emptyResult.setLimit(option.getLimit()); emptyResult.setTotal(0L); emptyResult.setHits(List.of()); return emptyResult; } try { searcher = searcherManager.acquire(); var queryParser = new StandardQueryParser(analyzer); queryParser.setMultiFields(new String[] {"title", "description", "content"}); queryParser.setFieldsBoost(Map.of("title", 1.0f, "description", 0.5f, "content", 0.2f)); queryParser.setFuzzyMinSim(FuzzyQuery.defaultMaxEdits); queryParser.setFuzzyPrefixLength(FuzzyQuery.defaultPrefixLength); var keyword = option.getKeyword(); var query = queryParser.parse(keyword, null); var queryBuilder = new BooleanQuery.Builder() .add(query, MUST); var filterExposed = option.getFilterExposed(); if (filterExposed != null) { queryBuilder.add( new TermQuery(new Term("exposed", filterExposed.toString())), FILTER ); } var filterRecycled = option.getFilterRecycled(); if (filterRecycled != null) { queryBuilder.add( new TermQuery(new Term("recycled", filterRecycled.toString())), FILTER ); } var filterPublished = option.getFilterPublished(); if (filterPublished != null) { queryBuilder.add( new TermQuery(new Term("published", filterPublished.toString())), FILTER ); } Optional.ofNullable(option.getIncludeTypes()) .filter(types -> !types.isEmpty()) .ifPresent(types -> { var typeTerms = types.stream() .distinct() .map(BytesRef::new) .toList(); queryBuilder.add(new TermInSetQuery("type", typeTerms), FILTER); }); Optional.ofNullable(option.getIncludeOwnerNames()) .filter(ownerNames -> !ownerNames.isEmpty()) .ifPresent(ownerNames -> { var ownerTerms = ownerNames.stream() .distinct() .map(BytesRef::new) .toList(); queryBuilder.add(new TermInSetQuery("ownerName", ownerTerms), FILTER); }); Optional.ofNullable(option.getIncludeTagNames()) .filter(tagNames -> !tagNames.isEmpty()) .ifPresent(tagNames -> tagNames .stream() .distinct() .forEach(tagName -> queryBuilder.add(new TermQuery(new Term("tag", tagName)), FILTER) )); Optional.ofNullable(option.getIncludeCategoryNames()) .filter(categoryNames -> !categoryNames.isEmpty()) .ifPresent(categoryNames -> categoryNames .stream() .distinct() .forEach(categoryName -> queryBuilder.add(new TermQuery(new Term("category", categoryName)), FILTER) )); var finalQuery = queryBuilder.build(); var limit = option.getLimit(); var stopWatch = new StopWatch("SearchWatch"); stopWatch.start("search " + keyword); var hits = searcher.search(finalQuery, limit, Sort.RELEVANCE); stopWatch.stop(); var formatter = new SimpleHTMLFormatter(option.getHighlightPreTag(), option.getHighlightPostTag()); var queryScorer = new QueryTermScorer(query); var highlighter = new Highlighter(formatter, queryScorer); var haloDocs = new ArrayList(hits.scoreDocs.length); for (var hit : hits.scoreDocs) { var doc = searcher.storedFields().document(hit.doc); var haloDoc = documentConverter.convert(doc); var title = doc.get("title"); var hlTitle = highlighter.getBestFragment(this.analyzer, "title", title); if (!StringUtils.hasText(hlTitle)) { hlTitle = title; } var description = doc.get("description"); String hlDescription = null; if (description != null) { hlDescription = highlighter.getBestFragment(this.analyzer, "description", description); } var content = doc.get("content"); var hlContent = highlighter.getBestFragment(this.analyzer, "content", content); haloDoc.setTitle(hlTitle); haloDoc.setDescription(hlDescription); haloDoc.setContent(hlContent); haloDocs.add(haloDoc); } var result = new SearchResult(); result.setHits(haloDocs); result.setTotal(hits.totalHits.value()); result.setKeyword(keyword); result.setLimit(limit); result.setProcessingTimeMillis(stopWatch.getTotalTimeMillis()); return result; } catch (IOException | QueryNodeException | InvalidTokenOffsetsException e) { throw new RuntimeException(e); } finally { if (searcher != null) { try { searcherManager.release(searcher); } catch (IOException e) { log.error("Failed to release searcher", e); } } } } @Override public void afterPropertiesSet() throws Exception { this.analyzer = CustomAnalyzer.builder() .withTokenizer(StandardTokenizerFactory.class) .addCharFilter(HTMLStripCharFilterFactory.NAME) .addCharFilter(CJKWidthCharFilterFactory.NAME) .addTokenFilter(LowerCaseFilterFactory.NAME) .addTokenFilter(CJKWidthFilterFactory.NAME) .addTokenFilter(CJKBigramFilterFactory.NAME) .build(); this.directory = FSDirectory.open(this.indexRootDir); log.info("Initialized lucene search engine"); } Optional obtainSearcherManager() { if (this.searcherManager != null) { return Optional.of(this.searcherManager); } synchronized (this) { // double check if (this.searcherManager != null) { return Optional.of(this.searcherManager); } try { this.searcherManager = new SearcherManager(this.directory, null); return Optional.of(this.searcherManager); } catch (IndexNotFoundException e) { log.warn("Index not ready for creating searcher manager"); } catch (IOException e) { log.error("Failed to create searcher manager", e); } return Optional.empty(); } } private void refreshSearcherManager() { this.obtainSearcherManager().ifPresent(sm -> { try { sm.maybeRefreshBlocking(); } catch (IOException e) { log.warn("Failed to refresh searcher", e); } }); } Directory getDirectory() { return directory; } Analyzer getAnalyzer() { return analyzer; } void setDirectory(Directory directory) { this.directory = directory; } void setSearcherManager(SearcherManager searcherManager) { this.searcherManager = searcherManager; } void setAnalyzer(Analyzer analyzer) { this.analyzer = analyzer; } Converter getHaloDocumentConverter() { return haloDocumentConverter; } Converter getDocumentConverter() { return documentConverter; } @Override public void destroy() throws Exception { var closers = new ArrayList(4); if (this.analyzer != null) { closers.add(this.analyzer); } if (this.searcherManager != null) { closers.add(this.searcherManager); } if (this.directory != null) { closers.add(this.directory); } IOUtils.close(closers); this.analyzer = null; this.searcherManager = null; this.directory = null; log.info("Destroyed lucene search engine"); } private static class HaloDocumentConverter implements Converter { @Override @NonNull public Document convert(HaloDocument haloDoc) { var doc = new Document(); doc.add(new StringField("id", haloDoc.getId(), YES)); doc.add(new StringField("name", haloDoc.getMetadataName(), YES)); doc.add(new StringField("type", haloDoc.getType(), YES)); doc.add(new StringField("ownerName", haloDoc.getOwnerName(), YES)); var categories = haloDoc.getCategories(); if (categories != null) { categories.forEach(category -> doc.add(new StringField("category", category, YES))); } var tags = haloDoc.getTags(); if (tags != null) { tags.forEach(tag -> doc.add(new StringField("tag", tag, YES))); } doc.add(new TextField("title", haloDoc.getTitle(), YES)); if (haloDoc.getDescription() != null) { doc.add(new TextField("description", haloDoc.getDescription(), YES)); } doc.add(new TextField("content", haloDoc.getContent(), YES)); doc.add(new StringField("recycled", Boolean.toString(haloDoc.isRecycled()), YES)); doc.add(new StringField("exposed", Boolean.toString(haloDoc.isExposed()), YES)); doc.add(new StringField("published", Boolean.toString(haloDoc.isPublished()), YES)); var annotations = haloDoc.getAnnotations(); if (annotations != null) { try (var baos = new ByteArrayOutputStream(); var oos = new ObjectOutputStream(baos)) { oos.writeObject(annotations); var type = new FieldType(); type.setStored(true); type.setTokenized(false); type.setDocValuesType(DocValuesType.BINARY); type.freeze(); doc.add(new StoredField("annotations", new BytesRef(baos.toByteArray()), type)); } catch (IOException e) { throw new RuntimeException(e); } } var creationTimestamp = haloDoc.getCreationTimestamp(); doc.add(new LongField("creationTimestamp", creationTimestamp.toEpochMilli(), YES)); var updateTimestamp = haloDoc.getUpdateTimestamp(); if (updateTimestamp != null) { doc.add(new LongField("updateTimestamp", updateTimestamp.toEpochMilli(), YES)); } doc.add(new StringField("permalink", haloDoc.getPermalink(), YES)); return doc; } } private static class DocumentConverter implements Converter { @Override @NonNull public HaloDocument convert(Document doc) { var haloDoc = new HaloDocument(); haloDoc.setId(doc.get("id")); haloDoc.setType(doc.get("type")); haloDoc.setMetadataName(doc.get("name")); haloDoc.setTitle(doc.get("title")); haloDoc.setDescription(doc.get("description")); haloDoc.setPermalink(doc.get("permalink")); haloDoc.setOwnerName(doc.get("ownerName")); haloDoc.setCategories(List.of(doc.getValues("category"))); haloDoc.setTags(List.of(doc.getValues("tag"))); haloDoc.setRecycled(getBooleanValue(doc, "recycled", false)); haloDoc.setPublished(getBooleanValue(doc, "published", false)); haloDoc.setExposed(getBooleanValue(doc, "exposed", false)); var annotationsBytesRef = doc.getBinaryValue("annotations"); if (annotationsBytesRef != null) { try (var bais = new ByteArrayInputStream(annotationsBytesRef.bytes); var ois = new ObjectInputStream(bais)) { @SuppressWarnings("unchecked") var annotations = (Map) ois.readObject(); haloDoc.setAnnotations(annotations); } catch (IOException | ClassNotFoundException e) { throw new RuntimeException(e); } } var creationTimestamp = doc.getField("creationTimestamp").numericValue().longValue(); haloDoc.setCreationTimestamp(Instant.ofEpochMilli(creationTimestamp)); var updateTimestampField = doc.getField("updateTimestamp"); if (updateTimestampField != null) { var updateTimestamp = updateTimestampField.numericValue().longValue(); haloDoc.setUpdateTimestamp(Instant.ofEpochMilli(updateTimestamp)); } // handle content later return haloDoc; } private static boolean getBooleanValue(Document doc, String fieldName, boolean defaultValue) { var boolStr = doc.get(fieldName); return boolStr == null ? defaultValue : Boolean.parseBoolean(boolStr); } } } ================================================ FILE: application/src/main/java/run/halo/app/search/post/PostEventsListener.java ================================================ package run.halo.app.search.post; import static run.halo.app.search.post.PostHaloDocumentsProvider.POST_DOCUMENT_TYPE; import static run.halo.app.search.post.PostHaloDocumentsProvider.convert; import java.util.List; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.content.PostService; import run.halo.app.core.extension.content.Post; import run.halo.app.event.post.PostDeletedEvent; import run.halo.app.event.post.PostUpdatedEvent; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.search.event.HaloDocumentAddRequestEvent; import run.halo.app.search.event.HaloDocumentDeleteRequestEvent; @Component public class PostEventsListener { private final ApplicationEventPublisher publisher; private final PostService postService; private final ReactiveExtensionClient client; public PostEventsListener( ApplicationEventPublisher publisher, PostService postService, ReactiveExtensionClient client) { this.publisher = publisher; this.postService = postService; this.client = client; } @EventListener Mono onApplicationEvent(PostUpdatedEvent event) { return addOrUpdateOrDelete(event.getName()); } @EventListener void onApplicationEvent(PostDeletedEvent event) { delete(event.getName()); } private Mono addOrUpdateOrDelete(String postName) { return client.fetch(Post.class, postName) .flatMap(post -> { if (ExtensionUtil.isDeleted(post)) { // if the post is deleted permanently, delete it. return Mono.fromRunnable(() -> delete(postName)); } // convert the post into halo document and add it to the search engine. return postService.getReleaseContent(post) .map(content -> convert(post, content)) .doOnNext(haloDoc -> publisher.publishEvent( new HaloDocumentAddRequestEvent(this, List.of(haloDoc)) )); }) .then(); } private void delete(String postName) { publisher.publishEvent( new HaloDocumentDeleteRequestEvent(this, List.of(POST_DOCUMENT_TYPE + '-' + postName)) ); } } ================================================ FILE: application/src/main/java/run/halo/app/search/post/PostHaloDocumentsProvider.java ================================================ package run.halo.app.search.post; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.content.ContentWrapper; import run.halo.app.content.PostService; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListOptions; import run.halo.app.extension.index.query.Queries; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.ReactiveExtensionPaginatedOperator; import run.halo.app.search.HaloDocument; import run.halo.app.search.HaloDocumentsProvider; @Component public class PostHaloDocumentsProvider implements HaloDocumentsProvider { public static final String POST_DOCUMENT_TYPE = "post.content.halo.run"; private final ReactiveExtensionPaginatedOperator paginatedOperator; private final PostService postService; public PostHaloDocumentsProvider(ReactiveExtensionPaginatedOperator paginatedOperator, PostService postService) { this.paginatedOperator = paginatedOperator; this.postService = postService; } @Override public Flux fetchAll() { // make sure the posts are published, public visible and not deleted. var options = new ListOptions(); var noteDeleted = Queries.isNull("metadata.deletionTimestamp"); options.setFieldSelector(FieldSelector.of(noteDeleted)); // get content return paginatedOperator.list(Post.class, options) .flatMap(post -> postService.getReleaseContent(post) .switchIfEmpty(Mono.fromSupplier(() -> ContentWrapper.builder() .content("") .raw("") .rawType("") .build())) .map(contentWrapper -> convert(post, contentWrapper)) ); } @Override public String getType() { return POST_DOCUMENT_TYPE; } /** * Converts post to HaloDocument. * * @param post post detail * @param content post content * @return halo document */ public static HaloDocument convert(Post post, ContentWrapper content) { var haloDoc = new HaloDocument(); var spec = post.getSpec(); haloDoc.setMetadataName(post.getMetadata().getName()); haloDoc.setType(POST_DOCUMENT_TYPE); haloDoc.setId(POST_DOCUMENT_TYPE + '-' + post.getMetadata().getName()); haloDoc.setTitle(spec.getTitle()); haloDoc.setDescription(post.getStatus().getExcerpt()); haloDoc.setPublished(Post.isPublished(post.getMetadata())); haloDoc.setRecycled(Post.isRecycled(post.getMetadata())); haloDoc.setExposed(Post.isPublic(spec)); haloDoc.setContent(content.getContent()); haloDoc.setTags(spec.getTags()); haloDoc.setCategories(spec.getCategories()); haloDoc.setOwnerName(spec.getOwner()); haloDoc.setUpdateTimestamp(spec.getPublishTime()); haloDoc.setCreationTimestamp(post.getMetadata().getCreationTimestamp()); haloDoc.setPermalink(post.getStatus().getPermalink()); return haloDoc; } } ================================================ FILE: application/src/main/java/run/halo/app/security/AuthProviderService.java ================================================ package run.halo.app.security; import java.util.List; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.AuthProvider; /** * A service for {@link AuthProvider}. * * @author guqing * @since 2.4.0 */ public interface AuthProviderService { Mono enable(String name); Mono disable(String name); Mono> listAll(); /** * Return a list of enabled AuthProviders sorted by priority. */ Flux getEnabledProviders(); } ================================================ FILE: application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java ================================================ package run.halo.app.security; import java.time.Duration; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.experimental.Accessors; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.ObjectProvider; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.lang.NonNull; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; import run.halo.app.core.extension.AuthProvider; import run.halo.app.core.extension.UserConnection; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.Queries; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.JsonUtils; /** * A default implementation of {@link AuthProviderService}. * * @author guqing * @since 2.4.0 */ @Component @RequiredArgsConstructor public class AuthProviderServiceImpl implements AuthProviderService { private final ReactiveExtensionClient client; private final ObjectProvider environmentFetcherProvider; @Override public Mono enable(String name) { return client.get(AuthProvider.class, name) .flatMap(authProvider -> updateAuthProviderEnabled(name, true) .thenReturn(authProvider) ); } @Override public Mono disable(String name) { return client.get(AuthProvider.class, name) // privileged auth provider cannot be disabled .filter(authProvider -> !privileged(authProvider)) .flatMap(authProvider -> updateAuthProviderEnabled(name, false) .thenReturn(authProvider) ); } @Override public Mono> listAll() { var listOptions = ListOptions.builder() .andQuery(ExtensionUtil.notDeleting()) .build(); var allProvidersMono = client.listAll(AuthProvider.class, listOptions, ExtensionUtil.defaultSort()) .map(this::convertTo) .collectList() .subscribeOn(Schedulers.boundedElastic()); var boundProvidersMono = listMyConnections() .map(connection -> connection.getSpec().getRegistrationId()) .collect(Collectors.toSet()) .subscribeOn(Schedulers.boundedElastic()); return Mono.zip(allProvidersMono, boundProvidersMono, fetchProviderStates()) .map(tuple3 -> { var allProviders = tuple3.getT1(); var boundProviderNames = tuple3.getT2(); var stateMap = tuple3.getT3().stream() .collect(Collectors.toMap(SystemSetting.AuthProviderState::getName, Function.identity())); return allProviders.stream() .peek(authProvider -> { authProvider.setIsBound( boundProviderNames.contains(authProvider.getName())); authProvider.setEnabled(false); // set enabled state and priority var state = stateMap.get(authProvider.getName()); if (state != null) { authProvider.setEnabled(state.isEnabled()); authProvider.setPriority(state.getPriority()); } }) .sorted(Comparator.comparingInt(ListedAuthProvider::getPriority) .thenComparing(ListedAuthProvider::getName)) .toList(); }); } @Override public Flux getEnabledProviders() { return fetchProviderStates().flatMapMany(states -> { var namePriorityMap = states.stream() // filter enabled providers .filter(SystemSetting.AuthProviderState::isEnabled) .collect(Collectors.toMap(SystemSetting.AuthProviderState::getName, SystemSetting.AuthProviderState::getPriority)); var listOptions = ListOptions.builder() .andQuery(Queries.in("metadata.name", namePriorityMap.keySet())) .andQuery(ExtensionUtil.notDeleting()) .build(); return client.listAll(AuthProvider.class, listOptions, ExtensionUtil.defaultSort()) .map(provider -> new AuthProviderWithPriority() .setAuthProvider(provider) .setPriority(namePriorityMap.getOrDefault( provider.getMetadata().getName(), 0) ) ) .sort(AuthProviderWithPriority::compareTo) .map(AuthProviderWithPriority::getAuthProvider); }); } @Data @Accessors(chain = true) static class AuthProviderWithPriority implements Comparable { private AuthProvider authProvider; private int priority; public String getName() { return authProvider.getMetadata().getName(); } @Override public int compareTo(@NonNull AuthProviderWithPriority o) { return Comparator.comparingInt(AuthProviderWithPriority::getPriority) .thenComparing(AuthProviderWithPriority::getName) .compare(this, o); } } private Mono> fetchProviderStates() { return getSystemConfigMap() .map(AuthProviderServiceImpl::getAuthProviderConfig) .map(SystemSetting.AuthProvider::getStates) .defaultIfEmpty(List.of()) .subscribeOn(Schedulers.boundedElastic()); } Flux listMyConnections() { return ReactiveSecurityContextHolder.getContext() .map(securityContext -> securityContext.getAuthentication().getName()) .flatMapMany(username -> { var listOptions = ListOptions.builder() .andQuery(Queries.equal("spec.username", username)) .andQuery(ExtensionUtil.notDeleting()) .build(); return client.listAll(UserConnection.class, listOptions, ExtensionUtil.defaultSort()); }); } private ListedAuthProvider convertTo(AuthProvider authProvider) { return ListedAuthProvider.builder() .name(authProvider.getMetadata().getName()) .displayName(authProvider.getSpec().getDisplayName()) .logo(authProvider.getSpec().getLogo()) .website(authProvider.getSpec().getWebsite()) .description(authProvider.getSpec().getDescription()) .authenticationUrl(authProvider.getSpec().getAuthenticationUrl()) .helpPage(authProvider.getSpec().getHelpPage()) .bindingUrl(authProvider.getSpec().getBindingUrl()) .unbindingUrl(authProvider.getSpec().getUnbindUrl()) .supportsBinding(supportsBinding(authProvider)) .authType(authProvider.getSpec().getAuthType()) .isBound(false) .enabled(false) .privileged(privileged(authProvider)) .build(); } private static boolean supportsBinding(AuthProvider authProvider) { return BooleanUtils.TRUE.equals(MetadataUtil.nullSafeLabels(authProvider) .get(AuthProvider.AUTH_BINDING_LABEL)); } private boolean privileged(AuthProvider authProvider) { return BooleanUtils.TRUE.equals(MetadataUtil.nullSafeLabels(authProvider) .get(AuthProvider.PRIVILEGED_LABEL)); } @NonNull private static SystemSetting.AuthProvider getAuthProviderConfig(ConfigMap configMap) { if (configMap.getData() == null) { configMap.setData(new HashMap<>()); } final Map data = configMap.getData(); String providerGroup = data.get(SystemSetting.AuthProvider.GROUP); SystemSetting.AuthProvider authProvider; if (StringUtils.isBlank(providerGroup)) { authProvider = new SystemSetting.AuthProvider(); } else { authProvider = JsonUtils.jsonToObject(providerGroup, SystemSetting.AuthProvider.class); } if (authProvider.getStates() == null) { authProvider.setStates(new ArrayList<>()); } return authProvider; } private Mono updateAuthProviderEnabled(String name, boolean enabled) { return Mono.defer(() -> getSystemConfigMap() .flatMap(configMap -> { var providerConfig = getAuthProviderConfig(configMap); var stateToFoundOpt = providerConfig.getStates() .stream() .filter(state -> state.getName().equals(name)) .findFirst(); if (stateToFoundOpt.isEmpty()) { var state = new SystemSetting.AuthProviderState() .setName(name) .setEnabled(enabled); providerConfig.getStates().add(state); } else { stateToFoundOpt.get().setEnabled(enabled); } configMap.getData().put(SystemSetting.AuthProvider.GROUP, JsonUtils.objectToJson(providerConfig)); return client.update(configMap); }) ) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)); } private Mono getSystemConfigMap() { var systemFetcher = environmentFetcherProvider.getIfUnique(); if (systemFetcher == null) { return Mono.error( new IllegalStateException("No SystemConfigurableEnvironmentFetcher found")); } return systemFetcher.getConfigMap(); } } ================================================ FILE: application/src/main/java/run/halo/app/security/CorsConfigurer.java ================================================ package run.halo.app.security; import com.google.common.net.HttpHeaders; import java.util.List; import org.springframework.core.annotation.Order; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.stereotype.Component; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.reactive.CorsConfigurationSource; import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.properties.SecurityProperties; import run.halo.app.security.authentication.SecurityConfigurer; @Component @Order(0) public class CorsConfigurer implements SecurityConfigurer { private final SecurityProperties.CorsOptions corsOptions; public CorsConfigurer(HaloProperties haloProperties) { corsOptions = haloProperties.getSecurity().getCorsOptions(); } @Override public void configure(ServerHttpSecurity http) { http.cors(spec -> { if (corsOptions.isDisabled()) { spec.disable(); return; } spec.configurationSource(apiCorsConfigSource()); }); } CorsConfigurationSource apiCorsConfigSource() { var source = new UrlBasedCorsConfigurationSource(); // additional CORS configuration this.corsOptions.getConfigs().forEach(corsConfig -> source.registerCorsConfiguration( corsConfig.getPathPattern(), corsConfig.getConfig().toCorsConfiguration() )); // default CORS configuration var configuration = new CorsConfiguration(); configuration.setAllowedOriginPatterns(List.of("*")); configuration.setAllowedHeaders( List.of(HttpHeaders.AUTHORIZATION, HttpHeaders.CONTENT_TYPE, HttpHeaders.ACCEPT, "X-XSRF-TOKEN", HttpHeaders.COOKIE)); configuration.setAllowCredentials(true); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH")); source.registerCorsConfiguration("/api/**", configuration); source.registerCorsConfiguration("/apis/**", configuration); return source; } } ================================================ FILE: application/src/main/java/run/halo/app/security/CsrfConfigurer.java ================================================ package run.halo.app.security; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; import org.springframework.core.annotation.Order; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository; import org.springframework.security.web.server.csrf.CsrfWebFilter; import org.springframework.security.web.server.csrf.XorServerCsrfTokenRequestAttributeHandler; import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.security.authentication.SecurityConfigurer; import run.halo.app.security.authentication.pat.PatAuthenticationConverter; @Component @Order(0) class CsrfConfigurer implements SecurityConfigurer { @Override public void configure(ServerHttpSecurity http) { var csrfMatcher = new AndServerWebExchangeMatcher( CsrfWebFilter.DEFAULT_CSRF_MATCHER, new NegatedServerWebExchangeMatcher(pathMatchers( "/api/**", "/apis/**", "/actuator/**", "/system/setup" )), new NegatedServerWebExchangeMatcher(patAuthMatcher()) ); http.csrf(csrfSpec -> csrfSpec .csrfTokenRepository(new CookieServerCsrfTokenRepository()) .csrfTokenRequestHandler(new XorServerCsrfTokenRequestAttributeHandler()) .requireCsrfProtectionMatcher(csrfMatcher)); } private static ServerWebExchangeMatcher patAuthMatcher() { var patConverter = new PatAuthenticationConverter(); return exchange -> patConverter.convert(exchange) .flatMap(a -> ServerWebExchangeMatcher.MatchResult.match()) .switchIfEmpty(Mono.defer(ServerWebExchangeMatcher.MatchResult::notMatch)); } } ================================================ FILE: application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java ================================================ package run.halo.app.security; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.server.ServerAuthenticationEntryPoint; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationEntryPoint; import org.springframework.security.web.server.savedrequest.ServerRequestCache; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.infra.utils.HaloUtils; /** * Default authentication entry point. * See * https://datatracker.ietf.org/doc/html/rfc7235#section-4.1 * for more. * * @author johnniang */ public class DefaultServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint { private final ServerWebExchangeMatcher xhrMatcher = exchange -> { if (HaloUtils.isXhr(exchange.getRequest().getHeaders())) { return MatchResult.match(); } return MatchResult.notMatch(); }; private final RedirectServerAuthenticationEntryPoint redirectEntryPoint; public DefaultServerAuthenticationEntryPoint(ServerRequestCache serverRequestCache) { var entryPoint = new RedirectServerAuthenticationEntryPoint("/login?authentication_required"); entryPoint.setRequestCache(serverRequestCache); this.redirectEntryPoint = entryPoint; } @Override public Mono commence(ServerWebExchange exchange, AuthenticationException ex) { return xhrMatcher.matches(exchange) .filter(MatchResult::isMatch) .switchIfEmpty( Mono.defer(() -> this.redirectEntryPoint.commence(exchange, ex).then(Mono.empty())) ) .flatMap(match -> Mono.defer( () -> { var response = exchange.getResponse(); var wwwAuthenticate = "FormLogin realm=\"console\""; response.getHeaders().set(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate); response.setStatusCode(HttpStatus.UNAUTHORIZED); return response.setComplete(); }).then(Mono.empty()) ); } } ================================================ FILE: application/src/main/java/run/halo/app/security/DefaultSuperAdminInitializer.java ================================================ package run.halo.app.security; import java.time.Instant; import java.util.List; import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.RoleBinding.RoleRef; import run.halo.app.core.extension.RoleBinding.Subject; import run.halo.app.core.extension.User; import run.halo.app.core.extension.User.UserSpec; import run.halo.app.extension.Metadata; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; @Slf4j @Component @RequiredArgsConstructor public class DefaultSuperAdminInitializer implements SuperAdminInitializer { private final ReactiveExtensionClient client; private final PasswordEncoder passwordEncoder; @Override public Mono initialize(InitializationParam param) { return client.fetch(User.class, param.getUsername()) .switchIfEmpty(Mono.defer(() -> client.create( createAdmin(param.getUsername(), param.getPassword(), param.getEmail()))) .flatMap(admin -> { var binding = bindAdminAndSuperRole(admin); return client.create(binding).thenReturn(admin); }) ) .then(); } RoleBinding bindAdminAndSuperRole(User admin) { String adminUserName = admin.getMetadata().getName(); var metadata = new Metadata(); String name = String.join("-", adminUserName, SUPER_ROLE_NAME, "binding"); metadata.setName(name); var roleRef = new RoleRef(); roleRef.setName(SUPER_ROLE_NAME); roleRef.setApiGroup(Role.GROUP); roleRef.setKind(Role.KIND); var subject = new Subject(); subject.setName(adminUserName); subject.setApiGroup(admin.groupVersionKind().group()); subject.setKind(admin.groupVersionKind().kind()); var roleBinding = new RoleBinding(); roleBinding.setMetadata(metadata); roleBinding.setRoleRef(roleRef); roleBinding.setSubjects(List.of(subject)); return roleBinding; } User createAdmin(String username, String password, String email) { var metadata = new Metadata(); metadata.setName(username); metadata.setFinalizers(Set.of(MetadataUtil.SYSTEM_FINALIZER)); var spec = new UserSpec(); spec.setDisplayName("Administrator"); spec.setDisabled(false); spec.setRegisteredAt(Instant.now()); spec.setTwoFactorAuthEnabled(false); spec.setEmail(email); spec.setPassword(passwordEncoder.encode(password)); var user = new User(); user.setMetadata(metadata); user.setSpec(spec); return user; } } ================================================ FILE: application/src/main/java/run/halo/app/security/DefaultUserDetailService.java ================================================ package run.halo.app.security; import static java.util.Objects.requireNonNullElse; import static run.halo.app.security.authorization.AuthorityUtils.ANONYMOUS_ROLE_NAME; import static run.halo.app.security.authorization.AuthorityUtils.AUTHENTICATED_ROLE_NAME; import static run.halo.app.security.authorization.AuthorityUtils.ROLE_PREFIX; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import reactor.core.publisher.Mono; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.UserService; import run.halo.app.infra.exception.UserNotFoundException; import run.halo.app.security.authentication.login.HaloUser; import run.halo.app.security.authentication.twofactor.TwoFactorUtils; @Slf4j public class DefaultUserDetailService implements ReactiveUserDetailsService, ReactiveUserDetailsPasswordService { private final UserService userService; private final RoleService roleService; /** * Indicates whether two-factor authentication is disabled. */ @Setter private boolean twoFactorAuthDisabled; public DefaultUserDetailService(UserService userService, RoleService roleService) { this.userService = userService; this.roleService = roleService; } @Override public Mono updatePassword(UserDetails user, String newPassword) { return userService.updatePassword(user.getUsername(), newPassword) .map(u -> withNewPassword(user, newPassword)); } @Override public Mono findByUsername(String username) { var getUser = Mono.defer(() -> { var isEmail = username.contains("@"); if (isEmail) { log.debug("Try to authenticate by email: {}", username); return userService.findUserByVerifiedEmail(username); } else { log.debug("Try to authenticate by username: {}", username); return userService.getUser(username); } }); return getUser.switchIfEmpty(Mono.error(() -> new UserNotFoundException(username))) .onErrorMap(UserNotFoundException.class, ignored -> new BadCredentialsException("Invalid Credentials")) .flatMap(user -> { var name = user.getMetadata().getName(); var userBuilder = User.withUsername(name) .password(user.getSpec().getPassword()) .disabled(requireNonNullElse(user.getSpec().getDisabled(), false)); var setAuthorities = roleService.getRolesByUsername(name) // every authenticated user should have authenticated and anonymous roles. .concatWithValues(AUTHENTICATED_ROLE_NAME, ANONYMOUS_ROLE_NAME) .map(roleName -> new SimpleGrantedAuthority(ROLE_PREFIX + roleName)) .distinct() .collectList() .doOnNext(userBuilder::authorities); return setAuthorities.then(Mono.fromSupplier(() -> { var twoFactorAuthSettings = TwoFactorUtils.getTwoFactorAuthSettings(user); return new HaloUser.Builder(userBuilder.build()) .twoFactorAuthEnabled( (!twoFactorAuthDisabled) && twoFactorAuthSettings.isAvailable() ) .totpEncryptedSecret(user.getSpec().getTotpEncryptedSecret()) .build(); })); }); } private UserDetails withNewPassword(UserDetails userDetails, String newPassword) { return User.withUserDetails(userDetails) .password(newPassword) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java ================================================ package run.halo.app.security; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.anyExchange; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; import java.util.ArrayList; import org.springframework.context.MessageSource; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.oauth2.server.resource.web.access.server.BearerTokenServerAccessDeniedHandler; import org.springframework.security.oauth2.server.resource.web.server.authentication.ServerBearerTokenAuthenticationConverter; import org.springframework.security.web.server.DelegatingServerAuthenticationEntryPoint; import org.springframework.security.web.server.authentication.AuthenticationConverterServerWebExchangeMatcher; import org.springframework.security.web.server.authorization.HttpStatusServerAccessDeniedHandler; import org.springframework.security.web.server.authorization.ServerWebExchangeDelegatingServerAccessDeniedHandler; import org.springframework.security.web.server.savedrequest.ServerRequestCache; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.security.authentication.SecurityConfigurer; import run.halo.app.security.authentication.twofactor.TwoFactorAuthenticationEntryPoint; @Component @Order(0) public class ExceptionSecurityConfigurer implements SecurityConfigurer { private final MessageSource messageSource; private final ServerResponse.Context context; private final ServerRequestCache serverRequestCache; public ExceptionSecurityConfigurer(MessageSource messageSource, ServerResponse.Context context, ServerRequestCache serverRequestCache) { this.messageSource = messageSource; this.context = context; this.serverRequestCache = serverRequestCache; } @Override public void configure(ServerHttpSecurity http) { http.exceptionHandling(exception -> { var accessDeniedHandlers = new ArrayList( 3 ); accessDeniedHandlers.add( new ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry( new AuthenticationConverterServerWebExchangeMatcher( new ServerBearerTokenAuthenticationConverter() ), new BearerTokenServerAccessDeniedHandler() )); accessDeniedHandlers.add( new ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry( pathMatchers(HttpMethod.GET, "/login", "/signup"), new RedirectAccessDeniedHandler("/uc") )); accessDeniedHandlers.add( new ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry( anyExchange(), new HttpStatusServerAccessDeniedHandler(HttpStatus.FORBIDDEN) ) ); var entryPoints = new ArrayList(2); entryPoints.add(new DelegatingServerAuthenticationEntryPoint.DelegateEntry( TwoFactorAuthenticationEntryPoint.MATCHER, new TwoFactorAuthenticationEntryPoint(messageSource, context) )); entryPoints.add(new DelegatingServerAuthenticationEntryPoint.DelegateEntry( anyExchange(), new DefaultServerAuthenticationEntryPoint(serverRequestCache) )); exception.authenticationEntryPoint( new DelegatingServerAuthenticationEntryPoint(entryPoints) ) .accessDeniedHandler( new ServerWebExchangeDelegatingServerAccessDeniedHandler(accessDeniedHandlers) ); }); } } ================================================ FILE: application/src/main/java/run/halo/app/security/HaloRedirectAuthenticationSuccessHandler.java ================================================ package run.halo.app.security; import static run.halo.app.security.HaloServerRequestCache.uriInApplication; import java.net.URI; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.web.server.DefaultServerRedirectStrategy; import org.springframework.security.web.server.ServerRedirectStrategy; import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; import reactor.core.publisher.Mono; /** * This class is responsible for handling the redirection after a successful authentication. * It checks if a valid 'redirect_uri' query parameter is present in the request. If it is, * the user is redirected to the specified URI. Otherwise, the user is redirected to a default * location. * * @author johnniang */ @Slf4j public class HaloRedirectAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler { private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); private final URI location; public HaloRedirectAuthenticationSuccessHandler(String location) { this.location = URI.create(location); } @Override public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) { var request = webFilterExchange.getExchange().getRequest(); var redirectUriQuery = request.getQueryParams() .getFirst("redirect_uri"); if (redirectUriQuery == null || redirectUriQuery.isBlank()) { return redirectStrategy.sendRedirect(webFilterExchange.getExchange(), location); } var redirectUri = uriInApplication(request, URI.create(redirectUriQuery)); if (log.isDebugEnabled()) { log.debug( "Redirecting to: {} after switching to {}", redirectUri, authentication.getName() ); } return redirectStrategy.sendRedirect( webFilterExchange.getExchange(), URI.create(redirectUri) ); } } ================================================ FILE: application/src/main/java/run/halo/app/security/HaloServerRequestCache.java ================================================ package run.halo.app.security; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; import java.net.URI; import java.util.Collections; import java.util.Objects; import org.apache.commons.lang3.StringUtils; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.server.RequestPath; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.security.web.server.savedrequest.WebSessionServerRequestCache; import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebSession; import reactor.core.publisher.Mono; /** * Halo server request cache implementation for saving redirect URI from query. * * @author johnniang */ public class HaloServerRequestCache extends WebSessionServerRequestCache { /** * Currently, we have no idea to customize the sessionAttributeName in * WebSessionServerRequestCache, so we have to copy the attr into here. */ private static final String DEFAULT_SAVED_REQUEST_ATTR = "SPRING_SECURITY_SAVED_REQUEST"; private static final String REDIRECT_URI_QUERY = "redirect_uri"; private final String sessionAttrName = DEFAULT_SAVED_REQUEST_ATTR; public HaloServerRequestCache() { super(); setSaveRequestMatcher(createDefaultRequestMatcher()); } @Override public Mono saveRequest(ServerWebExchange exchange) { var redirectUriQuery = exchange.getRequest().getQueryParams().getFirst(REDIRECT_URI_QUERY); if (StringUtils.isNotBlank(redirectUriQuery)) { // the query value is decoded, so we don't need to decode it again var redirectUri = URI.create(redirectUriQuery); return saveRedirectUri(exchange, redirectUri); } return super.saveRequest(exchange); } @Override public Mono getRedirectUri(ServerWebExchange exchange) { return super.getRedirectUri(exchange); } @Override public Mono removeMatchingRequest(ServerWebExchange exchange) { return getRedirectUri(exchange) .flatMap(redirectUri -> { if (redirectUri.getFragment() != null) { var redirectUriInApplication = uriInApplication(exchange.getRequest(), redirectUri, false); var uriInApplication = uriInApplication(exchange.getRequest(), exchange.getRequest().getURI()); // compare the path and query only if (!Objects.equals(redirectUriInApplication, uriInApplication)) { return Mono.empty(); } // remove the exchange return exchange.getSession().map(WebSession::getAttributes) .doOnNext(attributes -> attributes.remove(this.sessionAttrName)) .thenReturn(exchange.getRequest()); } return super.removeMatchingRequest(exchange); }); } private Mono saveRedirectUri(ServerWebExchange exchange, URI redirectUri) { var redirectUriInApplication = uriInApplication(exchange.getRequest(), redirectUri); return exchange.getSession() .map(WebSession::getAttributes) .doOnNext(attributes -> attributes.put(this.sessionAttrName, redirectUriInApplication)) .then(); } public static String uriInApplication(ServerHttpRequest request, URI uri) { return uriInApplication(request, uri, true); } public static String uriInApplication( ServerHttpRequest request, URI uri, boolean appendFragment ) { var path = RequestPath.parse(uri, request.getPath().contextPath().value()); var query = uri.getRawQuery(); var fragment = uri.getRawFragment(); return path.pathWithinApplication().value() + (query == null ? "" : "?" + query) + (fragment == null || !appendFragment ? "" : "#" + fragment); } private static ServerWebExchangeMatcher createDefaultRequestMatcher() { var get = pathMatchers(HttpMethod.GET, "/**"); var notFavicon = new NegatedServerWebExchangeMatcher( pathMatchers( "/favicon.*", "/login/**", "/signup/**", "/password-reset/**", "/challenges/**", "/oauth2/**", "/social/**" )); var html = new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML); html.setIgnoredMediaTypes(Collections.singleton(MediaType.ALL)); return new AndServerWebExchangeMatcher(get, notFavicon, html); } } ================================================ FILE: application/src/main/java/run/halo/app/security/HaloUserDetails.java ================================================ package run.halo.app.security; import org.springframework.security.core.userdetails.UserDetails; public interface HaloUserDetails extends UserDetails { /** * Checks if two-factor authentication is enabled. * * @return true if two-factor authentication is enabled, false otherwise. */ boolean isTwoFactorAuthEnabled(); /** * Gets the encrypted secret of TOTP. * * @return encrypted secret of TOTP. */ String getTotpEncryptedSecret(); } ================================================ FILE: application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java ================================================ package run.halo.app.security; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; import java.net.URI; import java.util.Set; import lombok.Getter; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.lang.NonNull; import org.springframework.security.web.server.DefaultServerRedirectStrategy; import org.springframework.security.web.server.ServerRedirectStrategy; import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; import run.halo.app.infra.InitializationStateGetter; /** * A web filter that will redirect user to set up page if system is not initialized. * * @author guqing * @since 2.5.2 */ @Component class InitializeRedirectionWebFilter implements WebFilter { private final URI location = URI.create("/system/setup"); private final ServerWebExchangeMatcher redirectMatcher; private final InitializationStateGetter initializationStateGetter; @Getter private ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); InitializeRedirectionWebFilter(InitializationStateGetter initializationStateGetter) { this.initializationStateGetter = initializationStateGetter; var html = new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML); html.setIgnoredMediaTypes(Set.of(MediaType.ALL)); this.redirectMatcher = new AndServerWebExchangeMatcher( pathMatchers(HttpMethod.GET, "/", "/console/**", "/uc/**", "/login", "/signup"), html ); } @Override @NonNull public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { return redirectMatcher.matches(exchange) .flatMap(matched -> { if (!matched.isMatch()) { return chain.filter(exchange); } return initializationStateGetter.userInitialized() .defaultIfEmpty(false) .flatMap(initialized -> { if (initialized) { return chain.filter(exchange); } // Redirect to set up page if system is not initialized. return redirectStrategy.sendRedirect(exchange, location); }); }); } public void setRedirectStrategy(ServerRedirectStrategy redirectStrategy) { Assert.notNull(redirectStrategy, "redirectStrategy cannot be null"); this.redirectStrategy = redirectStrategy; } } ================================================ FILE: application/src/main/java/run/halo/app/security/ListedAuthProvider.java ================================================ package run.halo.app.security; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; import run.halo.app.core.extension.AuthProvider; /** * A listed value object for {@link run.halo.app.core.extension.AuthProvider}. * * @author guqing * @since 2.4.0 */ @Data @Builder public class ListedAuthProvider { @Schema(requiredMode = REQUIRED) String name; @Schema(requiredMode = REQUIRED) String displayName; String description; String logo; String website; String authenticationUrl; String helpPage; String bindingUrl; String unbindingUrl; AuthProvider.AuthType authType; Boolean isBound; Boolean enabled; int priority; Boolean supportsBinding; Boolean privileged; } ================================================ FILE: application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java ================================================ package run.halo.app.security; import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.core.user.service.UserLoginOrLogoutProcessing; import run.halo.app.security.authentication.oauth2.OAuth2LoginHandlerEnhancer; import run.halo.app.security.authentication.rememberme.RememberMeRequestCache; import run.halo.app.security.authentication.rememberme.RememberMeServices; import run.halo.app.security.authentication.rememberme.WebSessionRememberMeRequestCache; import run.halo.app.security.device.DeviceService; /** * A default implementation for {@link LoginHandlerEnhancer} to handle device management and * remember me. * * @author guqing * @since 2.17.0 */ @Component @RequiredArgsConstructor public class LoginHandlerEnhancerImpl implements LoginHandlerEnhancer { private final RememberMeServices rememberMeServices; private final DeviceService deviceService; private final RememberMeRequestCache rememberMeRequestCache = new WebSessionRememberMeRequestCache(); private final OAuth2LoginHandlerEnhancer oauth2LoginHandlerEnhancer; private final UserLoginOrLogoutProcessing userLoginOrLogoutProcessing; @Override public Mono onLoginSuccess(ServerWebExchange exchange, Authentication successfulAuthentication) { return Mono.when( rememberMeServices.loginSuccess(exchange, successfulAuthentication), deviceService.loginSuccess(exchange, successfulAuthentication), rememberMeRequestCache.removeRememberMe(exchange), oauth2LoginHandlerEnhancer.loginSuccess(exchange, successfulAuthentication), userLoginOrLogoutProcessing.loginProcessing(successfulAuthentication.getName()) ); } @Override public Mono onLoginFailure(ServerWebExchange exchange, AuthenticationException exception) { return rememberMeServices.loginFail(exchange); } } ================================================ FILE: application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java ================================================ package run.halo.app.security; import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll; import java.net.URI; import java.util.ArrayList; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.lang.NonNull; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.web.server.DefaultServerRedirectStrategy; import org.springframework.security.web.server.ServerRedirectStrategy; import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.logout.DelegatingServerLogoutHandler; import org.springframework.security.web.server.authentication.logout.SecurityContextServerLogoutHandler; import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.savedrequest.ServerRequestCache; import org.springframework.security.web.server.savedrequest.WebSessionServerRequestCache; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.user.service.UserLoginOrLogoutProcessing; import run.halo.app.core.user.service.UserService; import run.halo.app.infra.actuator.GlobalInfoService; import run.halo.app.security.authentication.SecurityConfigurer; import run.halo.app.theme.router.ModelConst; @Component @RequiredArgsConstructor @Order(0) class LogoutSecurityConfigurer implements SecurityConfigurer { private final ApplicationContext applicationContext; private final UserLoginOrLogoutProcessing userLoginOrLogoutProcessing; private final ServerRequestCache serverRequestCache = new HaloServerRequestCache(); private final ServerSecurityContextRepository securityContextRepository; @Override public void configure(ServerHttpSecurity http) { http.logout(logout -> logout .logoutHandler(getLogoutHandler()) .logoutSuccessHandler(new LogoutSuccessHandler()) ); } private ServerLogoutHandler getLogoutHandler() { var defaultLogoutHandler = new SecurityContextServerLogoutHandler(); defaultLogoutHandler.setSecurityContextRepository(securityContextRepository); var logoutHandlers = new ArrayList(); logoutHandlers.add(defaultLogoutHandler); applicationContext.getBeanProvider(ServerLogoutHandler.class) .forEach(logoutHandlers::add); if (logoutHandlers.size() == 1) { return logoutHandlers.getFirst(); } return new DelegatingServerLogoutHandler(logoutHandlers); } @Bean RouterFunction logoutPage( UserService userService, GlobalInfoService globalInfoService ) { return RouterFunctions.route() .GET("/logout", request -> { var user = ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(Authentication::getName) .flatMap(userService::getUser); var exchange = request.exchange(); var contextPath = exchange.getRequest().getPath().contextPath().value(); return ServerResponse.ok().render("logout", Map.of( "globalInfo", globalInfoService.getGlobalInfo(), "action", contextPath + "/logout", "user", user )); }) .before(request -> { request.exchange().getAttributes().put(ModelConst.NO_CACHE, true); return request; }) .filter((request, next) -> // Save request before handling the logout serverRequestCache.saveRequest(request.exchange()).then(next.handle(request)) ) .build(); } private class LogoutSuccessHandler implements ServerLogoutSuccessHandler { private final ServerLogoutSuccessHandler defaultHandler; public LogoutSuccessHandler() { var redirectHandler = new RequestCacheRedirectLogoutSuccessHandler(); redirectHandler.setRequestCache(serverRequestCache); this.defaultHandler = redirectHandler; } @Override public Mono onLogoutSuccess(WebFilterExchange exchange, Authentication authentication) { return userLoginOrLogoutProcessing.logoutProcessing(authentication.getName()) .then(ignoringMediaTypeAll(MediaType.APPLICATION_JSON) .matches(exchange.getExchange()) .filter(ServerWebExchangeMatcher.MatchResult::isMatch) .switchIfEmpty(Mono.defer(() -> defaultHandler.onLogoutSuccess(exchange, authentication).then(Mono.empty()) )) .flatMap(match -> { var response = exchange.getExchange().getResponse(); response.setStatusCode(HttpStatus.NO_CONTENT); return response.setComplete(); }) ); } } private static class RequestCacheRedirectLogoutSuccessHandler implements ServerLogoutSuccessHandler { private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); private URI location = URI.create("/login?logout"); private ServerRequestCache requestCache = new WebSessionServerRequestCache(); public RequestCacheRedirectLogoutSuccessHandler() { } public RequestCacheRedirectLogoutSuccessHandler(String location) { this.location = URI.create(location); } public void setRequestCache(@NonNull ServerRequestCache requestCache) { Assert.notNull(requestCache, "requestCache cannot be null"); this.requestCache = requestCache; } @Override public Mono onLogoutSuccess( WebFilterExchange exchange, Authentication authentication ) { return this.requestCache.getRedirectUri(exchange.getExchange()) .defaultIfEmpty(this.location) .flatMap(location -> this.redirectStrategy.sendRedirect(exchange.getExchange(), location) ); } } } ================================================ FILE: application/src/main/java/run/halo/app/security/RedirectAccessDeniedHandler.java ================================================ package run.halo.app.security; import java.net.URI; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.server.DefaultServerRedirectStrategy; import org.springframework.security.web.server.ServerRedirectStrategy; import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; /** * Redirect access denied handler. * * @author johnniang * @since 2.20.0 */ public class RedirectAccessDeniedHandler implements ServerAccessDeniedHandler { private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); private final URI redirectUri; public RedirectAccessDeniedHandler(String redirectUri) { this.redirectUri = URI.create(redirectUri); } @Override public Mono handle(ServerWebExchange exchange, AccessDeniedException denied) { return redirectStrategy.sendRedirect(exchange, redirectUri); } } ================================================ FILE: application/src/main/java/run/halo/app/security/SecurityWebFiltersConfigurer.java ================================================ package run.halo.app.security; import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.ANONYMOUS_AUTHENTICATION; import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.AUTHENTICATION; import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.FIRST; import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.FORM_LOGIN; import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.HTTP_BASIC; import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.LAST; import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.OAUTH2_AUTHORIZATION_CODE; import lombok.Setter; import org.pf4j.ExtensionPoint; import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.core.annotation.Order; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.web.server.WebFilterChainProxy; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.security.authentication.SecurityConfigurer; @Component // Specific an order here to control the order or security configurer initialization @Order(100) public class SecurityWebFiltersConfigurer implements SecurityConfigurer { private final ExtensionGetter extensionGetter; public SecurityWebFiltersConfigurer(ExtensionGetter extensionGetter) { this.extensionGetter = extensionGetter; } @Override public void configure(ServerHttpSecurity http) { http .addFilterAt( new SecurityWebFilterChainProxy(BeforeSecurityWebFilter.class), FIRST ) .addFilterAt( new SecurityWebFilterChainProxy(HttpBasicSecurityWebFilter.class), HTTP_BASIC ) .addFilterAt( new SecurityWebFilterChainProxy(FormLoginSecurityWebFilter.class), FORM_LOGIN ) .addFilterAt( new SecurityWebFilterChainProxy(AuthenticationSecurityWebFilter.class), AUTHENTICATION ) .addFilterAt( new SecurityWebFilterChainProxy(AnonymousAuthenticationSecurityWebFilter.class), ANONYMOUS_AUTHENTICATION ) .addFilterAt( new SecurityWebFilterChainProxy(OAuth2AuthorizationCodeSecurityWebFilter.class), OAUTH2_AUTHORIZATION_CODE ) .addFilterAt( new SecurityWebFilterChainProxy(AfterSecurityWebFilter.class), LAST ) ; } public class SecurityWebFilterChainProxy implements WebFilter { @Setter private WebFilterChainProxy.WebFilterChainDecorator filterChainDecorator; private final Class extensionPointClass; public SecurityWebFilterChainProxy(Class extensionPointClass) { this.extensionPointClass = extensionPointClass; this.filterChainDecorator = new WebFilterChainProxy.DefaultWebFilterChainDecorator(); } @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { return extensionGetter.getExtensions(this.extensionPointClass) .sort(AnnotationAwareOrderComparator.INSTANCE) .cast(WebFilter.class) .collectList() .map(filters -> filterChainDecorator.decorate(chain, filters)) .flatMap(decoratedChain -> decoratedChain.filter(exchange)); } } } ================================================ FILE: application/src/main/java/run/halo/app/security/SuperAdminInitializer.java ================================================ package run.halo.app.security; import lombok.Builder; import lombok.Data; import reactor.core.publisher.Mono; import run.halo.app.security.authorization.AuthorityUtils; /** * Super admin initializer. * * @author guqing * @since 2.9.0 */ public interface SuperAdminInitializer { String SUPER_ROLE_NAME = AuthorityUtils.SUPER_ROLE_NAME; /** * Initialize super admin. * * @param param super admin initialization param */ Mono initialize(InitializationParam param); @Data @Builder class InitializationParam { private String username; private String password; private String email; } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/SecurityConfigurer.java ================================================ package run.halo.app.security.authentication; import org.springframework.security.config.web.server.ServerHttpSecurity; public interface SecurityConfigurer { void configure(ServerHttpSecurity http); } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/WebExchangeMatchers.java ================================================ package run.halo.app.security.authentication; import java.util.Set; import org.springframework.http.MediaType; import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; public enum WebExchangeMatchers { ; public static ServerWebExchangeMatcher ignoringMediaTypeAll(MediaType... matchingMediaTypes) { var matcher = new MediaTypeServerWebExchangeMatcher(matchingMediaTypes); matcher.setIgnoredMediaTypes(Set.of(MediaType.ALL)); return matcher; } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/exception/TooManyRequestsException.java ================================================ package run.halo.app.security.authentication.exception; import org.springframework.lang.Nullable; import org.springframework.security.core.AuthenticationException; import run.halo.app.infra.exception.RateLimitExceededException; /** * Too many requests exception while authenticating. Because * {@link RateLimitExceededException} is not a subclass of * {@link AuthenticationException}, we need to create a new exception class to map it. * * @author johnniang * @since 2.20.0 */ public class TooManyRequestsException extends AuthenticationException { public TooManyRequestsException(@Nullable Throwable throwable) { super("Too many requests.", throwable); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/exception/TwoFactorAuthException.java ================================================ package run.halo.app.security.authentication.exception; import org.springframework.security.core.AuthenticationException; public class TwoFactorAuthException extends AuthenticationException { public TwoFactorAuthException(String msg, Throwable cause) { super(msg, cause); } public TwoFactorAuthException(String msg) { super(msg); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/impl/RsaKeyService.java ================================================ package run.halo.app.security.authentication.impl; import static com.nimbusds.jose.jwk.KeyOperation.SIGN; import static com.nimbusds.jose.jwk.KeyOperation.VERIFY; import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.KeyUse; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.security.InvalidKeyException; import java.security.KeyFactory; import java.security.KeyPair; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.Set; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.InitializingBean; import org.springframework.security.crypto.codec.Hex; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import run.halo.app.security.authentication.CryptoService; import run.halo.app.security.authentication.login.InvalidEncryptedMessageException; @Slf4j public class RsaKeyService implements CryptoService, InitializingBean { public static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding"; public static final String ALGORITHM = "RSA"; private final Path keysRoot; private KeyPair keyPair; private String keyId; private JWK jwk; public RsaKeyService(Path dir) { this.keysRoot = dir; } @Override public void afterPropertiesSet() throws JOSEException { this.keyPair = this.getRsaKeyPairOrCreate(); this.keyId = sha256(keyPair.getPrivate().getEncoded()); this.jwk = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()) .privateKey(keyPair.getPrivate()) .keyUse(KeyUse.SIGNATURE) .keyOperations(Set.of(SIGN, VERIFY)) .keyIDFromThumbprint() .algorithm(JWSAlgorithm.RS256) .build(); } private KeyPair getRsaKeyPairOrCreate() { var privKeyPath = keysRoot.resolve("pat_id_rsa"); var pubKeyPath = keysRoot.resolve("pat_id_rsa.pub"); try { if (Files.exists(privKeyPath) && Files.exists(pubKeyPath)) { log.debug("Skip initializing RSA Keys for PAT due to existence."); var keyFactory = KeyFactory.getInstance(ALGORITHM); var privKeyBytes = Files.readAllBytes(privKeyPath); var privKeySpec = new PKCS8EncodedKeySpec(privKeyBytes); var privKey = keyFactory.generatePrivate(privKeySpec); var pubKeyBytes = Files.readAllBytes(pubKeyPath); var pubKeySpec = new X509EncodedKeySpec(pubKeyBytes); var pubKey = keyFactory.generatePublic(pubKeySpec); return new KeyPair(pubKey, privKey); } if (Files.notExists(keysRoot)) { Files.createDirectories(keysRoot); } Files.createFile(privKeyPath); Files.createFile(pubKeyPath); log.info("Generating RSA keys for PAT."); var rsaKey = new RSAKeyGenerator(4096).generate(); var pubKey = rsaKey.toRSAPublicKey(); var privKey = rsaKey.toRSAPrivateKey(); Files.write(privKeyPath, privKey.getEncoded(), TRUNCATE_EXISTING); Files.write(pubKeyPath, pubKey.getEncoded(), TRUNCATE_EXISTING); log.info("Wrote RSA keys for PAT into {} and {}", privKeyPath, pubKeyPath); return new KeyPair(pubKey, privKey); } catch (JOSEException | IOException | InvalidKeySpecException | NoSuchAlgorithmException e) { throw new RuntimeException("Failed to generate or read RSA key pair", e); } } @Override public Mono decrypt(byte[] encryptedMessage) { return Mono.just(this.keyPair) .map(KeyPair::getPrivate) .flatMap(privateKey -> { try { var cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, privateKey); return Mono.just(cipher.doFinal(encryptedMessage)); } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) { return Mono.error(new RuntimeException( "Failed to read private key or the key was invalid.", e )); } catch (IllegalBlockSizeException | BadPaddingException e) { return Mono.error(new InvalidEncryptedMessageException( "Invalid encrypted message." )); } }) .subscribeOn(Schedulers.boundedElastic()); } @Override public Mono readPublicKey() { return Mono.just(keyPair) .map(KeyPair::getPublic) .map(PublicKey::getEncoded); } @Override public String getKeyId() { return this.keyId; } @Override public JWK getJwk() { return this.jwk; } private static String sha256(byte[] data) { try { var md = MessageDigest.getInstance("SHA-256"); return new String(Hex.encode(md.digest(data))); } catch (NoSuchAlgorithmException e) { // should never happen throw new RuntimeException("Cannot obtain SHA-256 algorithm for message digest.", e); } } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/login/HaloUser.java ================================================ package run.halo.app.security.authentication.login; import java.util.Collection; import java.util.Objects; import org.springframework.security.core.CredentialsContainer; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.util.Assert; import run.halo.app.security.HaloUserDetails; public class HaloUser implements HaloUserDetails, CredentialsContainer { private final UserDetails delegate; private final boolean twoFactorAuthEnabled; private String totpEncryptedSecret; public HaloUser(UserDetails delegate, boolean twoFactorAuthEnabled, String totpEncryptedSecret) { Assert.notNull(delegate, "Delegate user must not be null"); this.delegate = delegate; this.twoFactorAuthEnabled = twoFactorAuthEnabled; this.totpEncryptedSecret = totpEncryptedSecret; } @Override public Collection getAuthorities() { return delegate.getAuthorities(); } @Override public String getPassword() { return delegate.getPassword(); } @Override public String getUsername() { return delegate.getUsername(); } @Override public boolean isAccountNonExpired() { return delegate.isAccountNonExpired(); } @Override public boolean isAccountNonLocked() { return delegate.isAccountNonLocked(); } @Override public boolean isCredentialsNonExpired() { return delegate.isCredentialsNonExpired(); } @Override public boolean isEnabled() { return delegate.isEnabled(); } @Override public void eraseCredentials() { if (delegate instanceof CredentialsContainer container) { container.eraseCredentials(); } this.totpEncryptedSecret = null; } @Override public boolean equals(Object obj) { if (obj instanceof HaloUser user) { return Objects.equals(this.delegate, user.delegate); } return false; } @Override public int hashCode() { return this.delegate.hashCode(); } @Override public boolean isTwoFactorAuthEnabled() { return this.twoFactorAuthEnabled; } @Override public String getTotpEncryptedSecret() { return this.totpEncryptedSecret; } public static class Builder { private final UserDetails user; private boolean twoFactorAuthEnabled; private String totpEncryptedSecret; public Builder(UserDetails user) { this.user = user; } public Builder twoFactorAuthEnabled(boolean twoFactorAuthEnabled) { this.twoFactorAuthEnabled = twoFactorAuthEnabled; return this; } public Builder totpEncryptedSecret(String totpEncryptedSecret) { this.totpEncryptedSecret = totpEncryptedSecret; return this; } public HaloUserDetails build() { return new HaloUser(user, twoFactorAuthEnabled, totpEncryptedSecret); } } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/login/InvalidEncryptedMessageException.java ================================================ package run.halo.app.security.authentication.login; /** * InvalidEncryptedMessageException indicates the encrypted message is invalid. * * @author johnniang */ public class InvalidEncryptedMessageException extends RuntimeException { public InvalidEncryptedMessageException(String message) { super(message); } public InvalidEncryptedMessageException(String message, Throwable cause) { super(message, cause); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java ================================================ package run.halo.app.security.authentication.login; import static java.nio.charset.StandardCharsets.UTF_8; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import io.github.resilience4j.ratelimiter.RequestNotPermitted; import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; import java.util.Base64; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.web.server.authentication.ServerFormLoginAuthenticationConverter; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.infra.utils.IpAddressUtils; import run.halo.app.security.authentication.CryptoService; import run.halo.app.security.authentication.exception.TooManyRequestsException; @Slf4j public class LoginAuthenticationConverter extends ServerFormLoginAuthenticationConverter { private final CryptoService cryptoService; private final RateLimiterRegistry rateLimiterRegistry; public LoginAuthenticationConverter(CryptoService cryptoService, RateLimiterRegistry rateLimiterRegistry) { this.cryptoService = cryptoService; this.rateLimiterRegistry = rateLimiterRegistry; } @Override public Mono convert(ServerWebExchange exchange) { return super.convert(exchange) // validate the password .flatMap(token -> { if (token.getCredentials() == null) { return Mono.error(new BadCredentialsException("Empty credentials.")); } var credentials = (String) token.getCredentials(); byte[] credentialsBytes; try { credentialsBytes = Base64.getDecoder().decode(credentials); } catch (IllegalArgumentException e) { // the credentials are not in valid Base64 scheme return Mono.error(new BadCredentialsException("Invalid Base64 scheme.")); } return cryptoService.decrypt(credentialsBytes) .onErrorMap(InvalidEncryptedMessageException.class, error -> new BadCredentialsException("Invalid credential.", error)) .map(decryptedCredentials -> new UsernamePasswordAuthenticationToken( token.getPrincipal(), new String(decryptedCredentials, UTF_8))); }) .transformDeferred(createIpBasedRateLimiter(exchange)) // We have to remap the exception to an AuthenticationException // for using in failure handler .onErrorMap(RequestNotPermitted.class, TooManyRequestsException::new); } private RateLimiterOperator createIpBasedRateLimiter(ServerWebExchange exchange) { var clientIp = IpAddressUtils.getClientIp(exchange.getRequest()); var rateLimiter = rateLimiterRegistry.rateLimiter("authentication-from-ip-" + clientIp, "authentication"); if (log.isDebugEnabled()) { var metrics = rateLimiter.getMetrics(); log.debug( "Authentication with Rate Limiter: {}, available permissions: {}, number of " + "waiting threads: {}", rateLimiter, metrics.getAvailablePermissions(), metrics.getNumberOfWaitingThreads()); } return RateLimiterOperator.of(rateLimiter); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java ================================================ package run.halo.app.security.authentication.login; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import io.micrometer.observation.ObservationRegistry; import org.springframework.context.MessageSource; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.ObservationReactiveAuthenticationManager; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.AuthenticationWebFilter; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.security.HaloUserDetails; import run.halo.app.security.LoginHandlerEnhancer; import run.halo.app.security.authentication.CryptoService; import run.halo.app.security.authentication.SecurityConfigurer; import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; @Component @Order(0) public class LoginSecurityConfigurer implements SecurityConfigurer { private final ObservationRegistry observationRegistry; private final ReactiveUserDetailsService userDetailsService; private final ReactiveUserDetailsPasswordService passwordService; private final PasswordEncoder passwordEncoder; private final ServerSecurityContextRepository securityContextRepository; private final CryptoService cryptoService; private final ExtensionGetter extensionGetter; private final ServerResponse.Context context; private final MessageSource messageSource; private final RateLimiterRegistry rateLimiterRegistry; private final LoginHandlerEnhancer loginHandlerEnhancer; public LoginSecurityConfigurer(ObservationRegistry observationRegistry, ReactiveUserDetailsService userDetailsService, ReactiveUserDetailsPasswordService passwordService, PasswordEncoder passwordEncoder, ServerSecurityContextRepository securityContextRepository, CryptoService cryptoService, ExtensionGetter extensionGetter, ServerResponse.Context context, MessageSource messageSource, RateLimiterRegistry rateLimiterRegistry, LoginHandlerEnhancer loginHandlerEnhancer) { this.observationRegistry = observationRegistry; this.userDetailsService = userDetailsService; this.passwordService = passwordService; this.passwordEncoder = passwordEncoder; this.securityContextRepository = securityContextRepository; this.cryptoService = cryptoService; this.extensionGetter = extensionGetter; this.context = context; this.messageSource = messageSource; this.rateLimiterRegistry = rateLimiterRegistry; this.loginHandlerEnhancer = loginHandlerEnhancer; } @Override public void configure(ServerHttpSecurity http) { var filter = new AuthenticationWebFilter(authenticationManager()) { @Override protected Mono onAuthenticationSuccess(Authentication authentication, WebFilterExchange webFilterExchange) { // check if 2FA is enabled after authenticating successfully. if (authentication.getPrincipal() instanceof HaloUserDetails userDetails && userDetails.isTwoFactorAuthEnabled()) { authentication = new TwoFactorAuthentication(authentication); } return super.onAuthenticationSuccess(authentication, webFilterExchange); } }; var requiresMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login"); var handler = new UsernamePasswordHandler(context, messageSource, loginHandlerEnhancer); var authConverter = new LoginAuthenticationConverter(cryptoService, rateLimiterRegistry); filter.setRequiresAuthenticationMatcher(requiresMatcher); filter.setAuthenticationFailureHandler(handler); filter.setAuthenticationSuccessHandler(handler); filter.setServerAuthenticationConverter(authConverter); filter.setSecurityContextRepository(securityContextRepository); http.addFilterAt(filter, SecurityWebFiltersOrder.FORM_LOGIN); } ReactiveAuthenticationManager authenticationManager() { var manager = new UsernamePasswordDelegatingAuthenticationManager(extensionGetter, defaultAuthenticationManager()); return new ObservationReactiveAuthenticationManager(observationRegistry, manager); } ReactiveAuthenticationManager defaultAuthenticationManager() { var manager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService); manager.setPasswordEncoder(passwordEncoder); manager.setUserDetailsPasswordService(passwordService); return manager; } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/login/PublicKeyRouteBuilder.java ================================================ package run.halo.app.security.authentication.login; import java.util.Base64; import lombok.Data; import org.springdoc.core.fn.builders.apiresponse.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.security.authentication.CryptoService; public class PublicKeyRouteBuilder { private final CryptoService cryptoService; public PublicKeyRouteBuilder(CryptoService cryptoService) { this.cryptoService = cryptoService; } /** * Builds public key router function. * * @return public key router function. */ public RouterFunction build() { return SpringdocRouteBuilder.route() .GET("/login/public-key", request -> cryptoService.readPublicKey() .flatMap(publicKey -> { var base64Format = Base64.getEncoder().encodeToString(publicKey); var response = new PublicKeyResponse(); response.setBase64Format(base64Format); return ServerResponse.ok() .bodyValue(response); }), builder -> builder.operationId("GetPublicKey") .description("Read public key for encrypting password.") .tag("Login") .response(Builder.responseBuilder() .implementation(PublicKeyResponse.class))).build(); } @Data public static class PublicKeyResponse { private String base64Format; } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java ================================================ package run.halo.app.security.authentication.login; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import reactor.core.publisher.Mono; import run.halo.app.plugin.extensionpoint.ExtensionGetter; @Slf4j public class UsernamePasswordDelegatingAuthenticationManager implements ReactiveAuthenticationManager { private final ExtensionGetter extensionGetter; private final ReactiveAuthenticationManager defaultAuthenticationManager; public UsernamePasswordDelegatingAuthenticationManager(ExtensionGetter extensionGetter, ReactiveAuthenticationManager defaultAuthenticationManager) { this.extensionGetter = extensionGetter; this.defaultAuthenticationManager = defaultAuthenticationManager; } @Override public Mono authenticate(Authentication authentication) { return extensionGetter .getEnabledExtensions(UsernamePasswordAuthenticationManager.class) .next() .flatMap(authenticationManager -> authenticationManager.authenticate(authentication) .doOnError(t -> log.error( "failed to authenticate with {}, fallback to default username password " + "authentication.", authenticationManager.getClass(), t) ) .onErrorResume( t -> !(t instanceof AuthenticationException), t -> Mono.empty() ) ) .switchIfEmpty( Mono.defer(() -> defaultAuthenticationManager.authenticate(authentication)) ); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java ================================================ package run.halo.app.security.authentication.login; import static org.springframework.http.HttpStatus.UNAUTHORIZED; import static org.springframework.http.MediaType.APPLICATION_JSON; import static run.halo.app.infra.exception.Exceptions.createErrorResponse; import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll; import java.net.URI; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.context.MessageSource; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.DisabledException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.CredentialsContainer; import org.springframework.security.web.server.DefaultServerRedirectStrategy; import org.springframework.security.web.server.ServerRedirectStrategy; import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler; import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.web.ErrorResponse; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.security.LoginHandlerEnhancer; import run.halo.app.security.authentication.exception.TooManyRequestsException; import run.halo.app.security.authentication.rememberme.RememberMeRequestCache; import run.halo.app.security.authentication.rememberme.WebSessionRememberMeRequestCache; import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; @Slf4j public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandler, ServerAuthenticationFailureHandler { private final ServerResponse.Context context; private final MessageSource messageSource; private final LoginHandlerEnhancer loginHandlerEnhancer; private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); @Setter private RememberMeRequestCache rememberMeRequestCache = new WebSessionRememberMeRequestCache(); private final ServerAuthenticationSuccessHandler defaultSuccessHandler = new RedirectServerAuthenticationSuccessHandler("/uc"); public UsernamePasswordHandler(ServerResponse.Context context, MessageSource messageSource, LoginHandlerEnhancer loginHandlerEnhancer) { this.context = context; this.messageSource = messageSource; this.loginHandlerEnhancer = loginHandlerEnhancer; } @Override public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, AuthenticationException exception) { var exchange = webFilterExchange.getExchange(); return loginHandlerEnhancer.onLoginFailure(exchange, exception) .then(ignoringMediaTypeAll(APPLICATION_JSON) .matches(exchange) .filter(ServerWebExchangeMatcher.MatchResult::isMatch) .switchIfEmpty(Mono.defer( () -> { var location = URI.create("/login?error&method=local"); if (exception instanceof DisabledException) { location = URI.create("/login?error=account-disabled&method=local"); } if (exception instanceof BadCredentialsException) { location = URI.create("/login?error=invalid-credential&method=local"); } if (exception instanceof TooManyRequestsException) { location = URI.create("/login?error=rate-limit-exceeded&method=local"); } return redirectStrategy.sendRedirect(exchange, location); }).then(Mono.empty()) ) .flatMap(matchResult -> handleAuthenticationException(exception, exchange))); } @Override public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) { if (authentication instanceof TwoFactorAuthentication) { return rememberMeRequestCache.saveRememberMe(webFilterExchange.getExchange()) // Do not use RedirectServerAuthenticationSuccessHandler to redirect // because it will use request cache to redirect .then(redirectStrategy.sendRedirect(webFilterExchange.getExchange(), URI.create("/challenges/two-factor/totp")) ); } if (authentication instanceof CredentialsContainer container) { container.eraseCredentials(); } ServerWebExchangeMatcher xhrMatcher = exchange -> { if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With") .contains("XMLHttpRequest")) { return ServerWebExchangeMatcher.MatchResult.match(); } return ServerWebExchangeMatcher.MatchResult.notMatch(); }; var exchange = webFilterExchange.getExchange(); return loginHandlerEnhancer.onLoginSuccess(webFilterExchange.getExchange(), authentication) .then(xhrMatcher.matches(exchange) .filter(ServerWebExchangeMatcher.MatchResult::isMatch) .switchIfEmpty(Mono.defer( () -> defaultSuccessHandler.onAuthenticationSuccess(webFilterExchange, authentication) .then(Mono.empty()))) .flatMap(isXhr -> ServerResponse.ok() .bodyValue(authentication.getPrincipal()) .flatMap(response -> response.writeTo(exchange, context)))); } private Mono handleAuthenticationException(Throwable exception, ServerWebExchange exchange) { var errorResponse = createErrorResponse(exception, UNAUTHORIZED, exchange, messageSource); return writeErrorResponse(errorResponse, exchange); } private Mono writeErrorResponse(ErrorResponse errorResponse, ServerWebExchange exchange) { return ServerResponse.status(errorResponse.getStatusCode()) .contentType(APPLICATION_JSON) .bodyValue(errorResponse.getBody()) .flatMap(response -> response.writeTo(exchange, context)); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/oauth2/DefaultOAuth2LoginHandlerEnhancer.java ================================================ package run.halo.app.security.authentication.oauth2; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.AuthenticationTrustResolver; import org.springframework.security.authentication.AuthenticationTrustResolverImpl; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.core.user.service.UserConnectionService; /** * Default implementation of {@link OAuth2LoginHandlerEnhancer}. * * @author johnniang * @since 2.20.0 */ @Slf4j @Component public class DefaultOAuth2LoginHandlerEnhancer implements OAuth2LoginHandlerEnhancer { private final UserConnectionService connectionService; @Setter private OAuth2AuthenticationTokenCache oauth2TokenCache = new WebSessionOAuth2AuthenticationTokenCache(); private final AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl(); public DefaultOAuth2LoginHandlerEnhancer(UserConnectionService connectionService) { this.connectionService = connectionService; } @Override public Mono loginSuccess(ServerWebExchange exchange, Authentication authentication) { if (!authenticationTrustResolver.isFullyAuthenticated(authentication)) { // Should never happen // Remove token directly if not fully authenticated return oauth2TokenCache.removeToken(exchange).then(); } return oauth2TokenCache.getToken(exchange) .flatMap(oauth2Token -> { var oauth2User = oauth2Token.getPrincipal(); var username = authentication.getName(); var registrationId = oauth2Token.getAuthorizedClientRegistrationId(); return connectionService.updateUserConnectionIfPresent(registrationId, oauth2User) .doOnNext(connection -> { if (log.isDebugEnabled()) { log.debug( "User connection already exists, skip creating. connection: [{}]", connection ); } }) .switchIfEmpty(Mono.defer(() -> connectionService.createUserConnection( username, registrationId, oauth2User ))) .then(oauth2TokenCache.removeToken(exchange)); }); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/oauth2/MapOAuth2AuthenticationFilter.java ================================================ package run.halo.app.security.authentication.oauth2; import static run.halo.app.security.authentication.oauth2.HaloOAuth2AuthenticationToken.authenticated; import java.net.URI; import lombok.Setter; import org.springframework.security.authentication.AuthenticationTrustResolver; import org.springframework.security.authentication.AuthenticationTrustResolverImpl; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.web.server.DefaultServerRedirectStrategy; import org.springframework.security.web.server.ServerRedirectStrategy; import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.logout.SecurityContextServerLogoutHandler; import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; import run.halo.app.core.user.service.UserConnectionService; import run.halo.app.security.LoginHandlerEnhancer; /** * A filter to map OAuth2 authentication to authenticated user. * * @author johnniang * @since 2.20.0 */ class MapOAuth2AuthenticationFilter implements WebFilter { private static final String PRE_AUTHENTICATION = MapOAuth2AuthenticationFilter.class.getName() + ".PRE_AUTHENTICATION"; private final UserConnectionService connectionService; private final ServerSecurityContextRepository securityContextRepository; @Setter private OAuth2AuthenticationTokenCache authenticationCache = new WebSessionOAuth2AuthenticationTokenCache(); private final ReactiveUserDetailsService userDetailsService; private final ServerLogoutHandler logoutHandler; private final LoginHandlerEnhancer loginHandlerEnhancer; private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); @Setter private AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl(); public MapOAuth2AuthenticationFilter( ServerSecurityContextRepository securityContextRepository, UserConnectionService connectionService, ReactiveUserDetailsService userDetailsService, LoginHandlerEnhancer loginHandlerEnhancer) { this.connectionService = connectionService; this.securityContextRepository = securityContextRepository; this.userDetailsService = userDetailsService; this.loginHandlerEnhancer = loginHandlerEnhancer; var logoutHandler = new SecurityContextServerLogoutHandler(); logoutHandler.setSecurityContextRepository(securityContextRepository); this.logoutHandler = logoutHandler; } @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .filter(authenticationTrustResolver::isAuthenticated) .doOnNext( // cache the pre-authentication authentication -> exchange.getAttributes().put(PRE_AUTHENTICATION, authentication) ) .then(chain.filter(exchange)) .then(Mono.defer(() -> ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .filter(OAuth2AuthenticationToken.class::isInstance) .cast(OAuth2AuthenticationToken.class) .flatMap(oauth2Token -> { var registrationId = oauth2Token.getAuthorizedClientRegistrationId(); var oauth2User = oauth2Token.getPrincipal(); // check the connection return connectionService.updateUserConnectionIfPresent( registrationId, oauth2User ) .switchIfEmpty(Mono.defer(() -> { var preAuthenticationObject = exchange.getAttribute(PRE_AUTHENTICATION); if (preAuthenticationObject instanceof Authentication preAuth && authenticationTrustResolver.isAuthenticated(preAuth)) { // check the authentication again // try to bind the user automatically return connectionService.createUserConnection( preAuth.getName(), registrationId, oauth2User ); } // save the OAuth2Authentication into session return authenticationCache.saveToken(exchange, oauth2Token) .then(Mono.defer(() -> { var webFilterExchange = new WebFilterExchange(exchange, chain); // clear the security context return logoutHandler.logout(webFilterExchange, oauth2Token); })) .then(Mono.defer(() -> redirectStrategy.sendRedirect(exchange, URI.create("/login?oauth2_bind") ))) // skip handling .then(Mono.empty()); })) // user bound and remap the authentication .flatMap(connection -> userDetailsService.findByUsername(connection.getSpec().getUsername()) ) .map(userDetails -> authenticated(userDetails, oauth2Token)) .flatMap(haloOAuth2Token -> { var securityContext = new SecurityContextImpl(haloOAuth2Token); return securityContextRepository.save(exchange, securityContext) .then( loginHandlerEnhancer.onLoginSuccess(exchange, haloOAuth2Token) ); // because this happens after the filter, there is no need to // write SecurityContext to the context }); }) .then()) ); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2AuthenticationTokenCache.java ================================================ package run.halo.app.security.authentication.oauth2; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; /** * OAuth2 authentication token cache. Saving OAuth2AuthenticationToken is mainly for further binding * to Halo user. * * @author johnniang * @since 2.20.0 */ public interface OAuth2AuthenticationTokenCache { /** * Save OAuth2AuthenticationToken into cache. * * @param exchange Server web exchange * @param oauth2Token OAuth2AuthenticationToken * @return empty */ Mono saveToken(ServerWebExchange exchange, OAuth2AuthenticationToken oauth2Token); /** * Get OAuth2AuthenticationToken from cache. * * @param exchange Server web exchange * @return an {@link OAuth2AuthenticationToken} if present, empty otherwise */ Mono getToken(ServerWebExchange exchange); /** * Remove OAuth2AuthenticationToken from cache. * * @param exchange Server web exchange * @return empty */ Mono removeToken(ServerWebExchange exchange); } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2LoginHandlerEnhancer.java ================================================ package run.halo.app.security.authentication.oauth2; import org.springframework.security.core.Authentication; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; /** * OAuth2 login handler enhancer. * * @author johnniang * @since 2.20.0 */ public interface OAuth2LoginHandlerEnhancer { Mono loginSuccess(ServerWebExchange exchange, Authentication authentication); } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2SecurityConfigurer.java ================================================ package run.halo.app.security.authentication.oauth2; import org.springframework.core.annotation.Order; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.stereotype.Component; import run.halo.app.core.user.service.UserConnectionService; import run.halo.app.security.LoginHandlerEnhancer; import run.halo.app.security.authentication.SecurityConfigurer; /** * OAuth2 security configurer. * * @author johnniang * @since 2.20.0 */ @Component @Order(0) class OAuth2SecurityConfigurer implements SecurityConfigurer { private final ServerSecurityContextRepository securityContextRepository; private final UserConnectionService connectionService; private final ReactiveUserDetailsService userDetailsService; private final LoginHandlerEnhancer loginHandlerEnhancer; public OAuth2SecurityConfigurer(ServerSecurityContextRepository securityContextRepository, UserConnectionService connectionService, ReactiveUserDetailsService userDetailsService, LoginHandlerEnhancer loginHandlerEnhancer) { this.securityContextRepository = securityContextRepository; this.connectionService = connectionService; this.userDetailsService = userDetailsService; this.loginHandlerEnhancer = loginHandlerEnhancer; } @Override public void configure(ServerHttpSecurity http) { var mapOAuth2Filter = new MapOAuth2AuthenticationFilter( securityContextRepository, connectionService, userDetailsService, loginHandlerEnhancer ); http.addFilterBefore(mapOAuth2Filter, SecurityWebFiltersOrder.AUTHENTICATION); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/oauth2/WebSessionOAuth2AuthenticationTokenCache.java ================================================ package run.halo.app.security.authentication.oauth2; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; /** * WebSession cache implementation of {@link OAuth2AuthenticationTokenCache}. * * @author johnniang * @since 2.20.0 */ public class WebSessionOAuth2AuthenticationTokenCache implements OAuth2AuthenticationTokenCache { private static final String SESSION_ATTRIBUTE_KEY = OAuth2AuthenticationTokenCache.class + ".OAUTH2_TOKEN"; @Override public Mono saveToken(ServerWebExchange exchange, OAuth2AuthenticationToken oauth2Token) { return exchange.getSession() .doOnNext(session -> { session.getAttributes().put(SESSION_ATTRIBUTE_KEY, oauth2Token); }) .then(); } @Override public Mono getToken(ServerWebExchange exchange) { return exchange.getSession() .mapNotNull(session -> session.getAttribute(SESSION_ATTRIBUTE_KEY)) .filter(OAuth2AuthenticationToken.class::isInstance) .cast(OAuth2AuthenticationToken.class); } @Override public Mono removeToken(ServerWebExchange exchange) { return exchange.getSession() .doOnNext(session -> session.getAttributes().remove(SESSION_ATTRIBUTE_KEY)) .then(); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationConverter.java ================================================ package run.halo.app.security.authentication.pat; import static run.halo.app.security.PersonalAccessToken.PAT_TOKEN_PREFIX; import org.apache.commons.lang3.StringUtils; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; import org.springframework.security.oauth2.server.resource.web.server.authentication.ServerBearerTokenAuthenticationConverter; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; /** * PAT authentication converter. * * @author johnniang * @since 2.20.4 */ public class PatAuthenticationConverter extends ServerBearerTokenAuthenticationConverter { @Override public Mono convert(ServerWebExchange exchange) { return super.convert(exchange) .cast(BearerTokenAuthenticationToken.class) .map(BearerTokenAuthenticationToken::getToken) .filter(token -> StringUtils.startsWith(token, PAT_TOKEN_PREFIX)) .map(token -> StringUtils.removeStart(token, PAT_TOKEN_PREFIX)) .map(BearerTokenAuthenticationToken::new); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationManager.java ================================================ package run.halo.app.security.authentication.pat; import static org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder.withJwkSource; import static run.halo.app.security.authorization.AuthorityUtils.ANONYMOUS_ROLE_NAME; import static run.halo.app.security.authorization.AuthorityUtils.AUTHENTICATED_ROLE_NAME; import static run.halo.app.security.authorization.AuthorityUtils.ROLE_PREFIX; import com.nimbusds.jwt.JWTClaimNames; import java.time.Clock; import java.time.Duration; import java.util.ArrayList; import java.util.Objects; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.security.PersonalAccessToken; import run.halo.app.security.authentication.CryptoService; import run.halo.app.security.authorization.AuthorityUtils; class PatAuthenticationManager implements ReactiveAuthenticationManager { /** * Minimal duration gap of personal access token update. */ private static final Duration MIN_UPDATE_GAP = Duration.ofMinutes(1); private final JwtReactiveAuthenticationManager delegate; private final ReactiveExtensionClient client; private final CryptoService cryptoService; private Clock clock; public PatAuthenticationManager(ReactiveExtensionClient client, CryptoService cryptoService) { this.client = client; this.cryptoService = cryptoService; this.delegate = getDelegate(); this.clock = Clock.systemDefaultZone(); } private JwtReactiveAuthenticationManager getDelegate() { var jwtDecoder = withJwkSource(signedJWT -> Flux.just(cryptoService.getJwk())) .build(); return new JwtReactiveAuthenticationManager(jwtDecoder); } /** * Set new clock. Only for testing. * * @param clock new clock */ void setClock(Clock clock) { this.clock = clock; } @Override public Mono authenticate(Authentication authentication) { return delegate.authenticate(authentication) .cast(JwtAuthenticationToken.class) .flatMap(this::checkAndRebuild); } private Mono checkAndRebuild(JwtAuthenticationToken jat) { var jwt = jat.getToken(); var patName = jwt.getClaimAsString("pat_name"); var jwtId = jwt.getClaimAsString(JWTClaimNames.JWT_ID); if (patName == null || jwtId == null) { // Not a valid PAT return Mono.error(new InvalidBearerTokenException("Missing claim pat_name or jti")); } return client.fetch(PersonalAccessToken.class, patName) .switchIfEmpty( Mono.error(() -> new DisabledException("Personal access token has been deleted.")) ) .flatMap(pat -> patChecks(pat, jwtId).and(updateLastUsed(patName)).thenReturn(pat)) .map(pat -> { // Make sure the authorities modifiable var authorities = new ArrayList<>(jat.getAuthorities()); authorities.add(new SimpleGrantedAuthority(ROLE_PREFIX + ANONYMOUS_ROLE_NAME)); authorities.add(new SimpleGrantedAuthority(ROLE_PREFIX + AUTHENTICATED_ROLE_NAME)); var roles = pat.getSpec().getRoles(); if (roles != null) { roles.stream() .map(role -> AuthorityUtils.ROLE_PREFIX + role) .map(SimpleGrantedAuthority::new) .forEach(authorities::add); } return new JwtAuthenticationToken(jat.getToken(), authorities, jat.getName()); }); } private Mono updateLastUsed(String patName) { // we try our best to update the last used timestamp. // the now should be outside the retry cycle because we don't want a fresh timestamp at // every retry. var now = clock.instant(); return Mono.defer( // we have to obtain a fresh PAT and retry the update. () -> client.fetch(PersonalAccessToken.class, patName) .filter(pat -> { var lastUsed = pat.getSpec().getLastUsed(); if (lastUsed == null) { return true; } var diff = Duration.between(lastUsed, now); return !diff.minus(MIN_UPDATE_GAP).isNegative(); }) .doOnNext(pat -> pat.getSpec().setLastUsed(now)) .flatMap(client::update) ) .retryWhen(Retry.backoff(3, Duration.ofMillis(50)) .filter(OptimisticLockingFailureException.class::isInstance)) .onErrorComplete() .then(); } private Mono patChecks(PersonalAccessToken pat, String tokenId) { if (ExtensionUtil.isDeleted(pat)) { return Mono.error( new InvalidBearerTokenException("Personal access token is being deleted.")); } var spec = pat.getSpec(); if (!Objects.equals(spec.getTokenId(), tokenId)) { return Mono.error(new InvalidBearerTokenException( "Token ID does not match the token ID of personal access token.")); } if (spec.isRevoked()) { return Mono.error(new InvalidBearerTokenException("Token has been revoked.")); } return Mono.empty(); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/pat/PatEndpoint.java ================================================ package run.halo.app.security.authentication.pat; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route; import static org.springframework.web.reactive.function.server.RequestPredicates.path; import io.swagger.v3.oas.annotations.enums.ParameterIn; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.security.PersonalAccessToken; @Component class PatEndpoint implements CustomEndpoint { private final UserScopedPatHandler patHandler; public PatEndpoint(UserScopedPatHandler patHandler) { this.patHandler = patHandler; } @Override public RouterFunction endpoint() { var tag = PersonalAccessToken.KIND + "V1alpha1Uc"; return route().nest(path("/personalaccesstokens"), () -> route() .POST(patHandler::create, builder -> builder .tag(tag) .operationId("GeneratePat") .description("Generate a PAT.") .requestBody(requestBodyBuilder() .required(true) .implementation(PersonalAccessToken.class)) .response(responseBuilder().implementation(PersonalAccessToken.class)) ) .GET(patHandler::list, builder -> builder .tag(tag) .operationId("ObtainPats") .description("Obtain PAT list.") .response(responseBuilder() .implementationArray(PersonalAccessToken.class) ) ) .GET("/{name}", patHandler::get, builder -> builder .tag(tag) .operationId("ObtainPat") .description("Obtain a PAT.") .parameter(parameterBuilder() .in(ParameterIn.PATH) .required(true) .name("name"))) .PUT("/{name}/actions/revocation", patHandler::revoke, builder -> builder.tag(tag) .operationId("RevokePat") .description("Revoke a PAT") .parameter(parameterBuilder() .in(ParameterIn.PATH) .required(true) .name("name")) ) .PUT("/{name}/actions/restoration", patHandler::restore, builder -> builder.tag(tag) .operationId("RestorePat") .description("Restore a PAT.") .parameter(parameterBuilder() .in(ParameterIn.PATH) .required(true) .name("name") ) ) .DELETE("/{name}", patHandler::delete, builder -> builder.tag(tag) .operationId("DeletePat") .description("Delete a PAT") .parameter(parameterBuilder() .in(ParameterIn.PATH) .required(true) .name("name") )) .build() ) .build(); } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("uc.api.security.halo.run/v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/pat/PatSecurityConfigurer.java ================================================ package run.halo.app.security.authentication.pat; import org.springframework.core.annotation.Order; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.oauth2.server.resource.web.server.BearerTokenServerAuthenticationEntryPoint; import org.springframework.security.web.server.authentication.AuthenticationWebFilter; import org.springframework.security.web.server.authentication.ServerAuthenticationEntryPointFailureHandler; import org.springframework.stereotype.Component; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.security.authentication.CryptoService; import run.halo.app.security.authentication.SecurityConfigurer; /** * PAT security configurer. * * @author johnniang * @since 2.20.4 */ @Component // Specific an order here to control the order or security configurer initialization @Order(0) class PatSecurityConfigurer implements SecurityConfigurer { private final ReactiveExtensionClient client; private final CryptoService cryptoService; public PatSecurityConfigurer(ReactiveExtensionClient client, CryptoService cryptoService) { this.client = client; this.cryptoService = cryptoService; } @Override public void configure(ServerHttpSecurity http) { var filter = new AuthenticationWebFilter(new PatAuthenticationManager(client, cryptoService)); filter.setServerAuthenticationConverter(new PatAuthenticationConverter()); var entryPoint = new BearerTokenServerAuthenticationEntryPoint(); var failureHandler = new ServerAuthenticationEntryPointFailureHandler(entryPoint); filter.setAuthenticationFailureHandler(failureHandler); http.addFilterAt(filter, SecurityWebFiltersOrder.AUTHENTICATION); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandler.java ================================================ package run.halo.app.security.authentication.pat; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; public interface UserScopedPatHandler { Mono create(ServerRequest request); Mono list(ServerRequest request); Mono get(ServerRequest request); Mono revoke(ServerRequest request); Mono delete(ServerRequest request); Mono restore(ServerRequest request); } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandlerImpl.java ================================================ package run.halo.app.security.authentication.pat; import static run.halo.app.extension.Comparators.compareCreationTimestamp; import java.util.HashMap; import java.util.Objects; import java.util.function.Predicate; import org.springframework.security.authentication.AuthenticationTrustResolver; import org.springframework.security.authentication.AuthenticationTrustResolverImpl; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.user.service.PatService; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.exception.AccessDeniedException; import run.halo.app.security.PersonalAccessToken; @Service class UserScopedPatHandlerImpl implements UserScopedPatHandler { private static final String ACCESS_TOKEN_ANNO_NAME = "security.halo.run/access-token"; private final ReactiveExtensionClient client; private final PatService patService; private final AuthenticationTrustResolver authTrustResolver = new AuthenticationTrustResolverImpl(); public UserScopedPatHandlerImpl(ReactiveExtensionClient client, PatService patService) { this.client = client; this.patService = patService; } private Mono mustBeAuthenticated(Mono authentication) { return authentication.filter(authTrustResolver::isAuthenticated) // Non-username-password authentication could not access the API at any time. .switchIfEmpty(Mono.error(AccessDeniedException::new)); } @Override public Mono create(ServerRequest request) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .transform(this::mustBeAuthenticated) .flatMap(auth -> request.bodyToMono(PersonalAccessToken.class)) .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Missing request body."))) .flatMap(patService::create) .flatMap(pat -> patService.generateToken(pat) .doOnNext(token -> { if (pat.getMetadata().getAnnotations() == null) { pat.getMetadata().setAnnotations(new HashMap<>()); } pat.getMetadata().getAnnotations() .put(ACCESS_TOKEN_ANNO_NAME, token); }) .thenReturn(pat) ) .flatMap(pat -> ServerResponse.ok().bodyValue(pat)); } @Override public Mono list(ServerRequest request) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .flatMap(auth -> { Predicate predicate = pat -> Objects.equals(auth.getName(), pat.getSpec().getUsername()); var pats = client.list(PersonalAccessToken.class, predicate, compareCreationTimestamp(false)); return ServerResponse.ok().body(pats, PersonalAccessToken.class); }); } @Override public Mono get(ServerRequest request) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .flatMap(auth -> { var name = request.pathVariable("name"); return patService.get(name, auth.getName()); }) .flatMap(pat -> ServerResponse.ok().bodyValue(pat)); } @Override public Mono revoke(ServerRequest request) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .flatMap(auth -> { var name = request.pathVariable("name"); return patService.revoke(name, auth.getName()); }) .flatMap(revokedPat -> ServerResponse.ok().bodyValue(revokedPat)); } @Override public Mono delete(ServerRequest request) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .flatMap(auth -> { var name = request.pathVariable("name"); return patService.delete(name, auth.getName()); }) .flatMap(pat -> ServerResponse.ok().bodyValue(pat)); } @Override public Mono restore(ServerRequest request) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .transform(this::mustBeAuthenticated) .flatMap(auth -> { var name = request.pathVariable("name"); return patService.restore(name, auth.getName()); }) .flatMap(pat -> ServerResponse.ok().bodyValue(pat)); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/CookieSignatureKeyResolver.java ================================================ package run.halo.app.security.authentication.rememberme; import reactor.core.publisher.Mono; @FunctionalInterface public interface CookieSignatureKeyResolver { Mono resolveSigningKey(); } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/DefaultCookieSignatureKeyResolver.java ================================================ package run.halo.app.security.authentication.rememberme; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.security.authentication.CryptoService; @Component @RequiredArgsConstructor public class DefaultCookieSignatureKeyResolver implements CookieSignatureKeyResolver { private final CryptoService cryptoService; @Override public Mono resolveSigningKey() { return Mono.fromSupplier(cryptoService::getKeyId); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/PersistentRememberMeTokenRepository.java ================================================ package run.halo.app.security.authentication.rememberme; import java.time.Instant; import org.springframework.lang.NonNull; import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken; import reactor.core.publisher.Mono; public interface PersistentRememberMeTokenRepository { Mono createNewToken(PersistentRememberMeToken token); Mono updateToken(String series, String tokenValue, Instant lastUsed); Mono getTokenForSeries(String seriesId); Mono removeUserTokens(String username); Mono removeToken(@NonNull String series); } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/PersistentRememberMeTokenRepositoryImpl.java ================================================ package run.halo.app.security.authentication.rememberme; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.isNull; import java.time.Duration; import java.time.Instant; import java.util.Date; import lombok.RequiredArgsConstructor; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.lang.NonNull; import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.extension.RememberMeToken; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.ReactiveExtensionPaginatedOperatorImpl; /** * Extension based persistent remember me token repository implementation. * * @see RememberMeToken */ @Component @RequiredArgsConstructor public class PersistentRememberMeTokenRepositoryImpl implements PersistentRememberMeTokenRepository { private final ReactiveExtensionClient client; private final ReactiveExtensionPaginatedOperatorImpl paginatedOperator; @Override public Mono createNewToken(PersistentRememberMeToken token) { var rememberMeToken = new RememberMeToken(); var metadata = new Metadata(); rememberMeToken.setMetadata(metadata); metadata.setGenerateName("token-"); var creationTime = Instant.ofEpochMilli(token.getDate().getTime()); metadata.setCreationTimestamp(creationTime); rememberMeToken.setSpec(new RememberMeToken.Spec()); rememberMeToken.getSpec() .setUsername(token.getUsername()) .setSeries(token.getSeries()) .setTokenValue(token.getTokenValue()); return client.create(rememberMeToken).then(); } @Override public Mono updateToken(String series, String tokenValue, Instant lastUsed) { return Mono.defer(() -> getTokenExtensionForSeries(series) .flatMap(token -> { token.getSpec().setTokenValue(tokenValue) .setLastUsed(lastUsed); return client.update(token); }) ) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)) .then(); } @Override public Mono getTokenForSeries(String seriesId) { return getTokenExtensionForSeries(seriesId) .map(token -> new PersistentRememberMeToken( token.getSpec().getUsername(), token.getSpec().getSeries(), token.getSpec().getTokenValue(), new Date(token.getMetadata().getCreationTimestamp().toEpochMilli()) )); } @Override public Mono removeUserTokens(String username) { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of(equal("spec.username", username))); return paginatedOperator.deleteInitialBatch(RememberMeToken.class, listOptions).then(); } @Override public Mono removeToken(@NonNull String series) { return getTokenExtensionForSeries(series) .flatMap(client::delete) .then(); } private Mono getTokenExtensionForSeries(String seriesId) { var listOptions = ListOptions.builder() .fieldQuery(and(equal("spec.series", seriesId), isNull("metadata.deletionTimestamp") )) .build(); return client.listBy(RememberMeToken.class, listOptions, PageRequestImpl.ofSize(1)) .flatMap(result -> Mono.justOrEmpty(ListResult.first(result))); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/PersistentTokenBasedRememberMeServices.java ================================================ package run.halo.app.security.authentication.rememberme; import java.security.SecureRandom; import java.time.Instant; import java.util.Arrays; import java.util.Base64; import java.util.Date; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.rememberme.CookieTheftException; import org.springframework.security.web.authentication.rememberme.InvalidCookieException; import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException; import org.springframework.security.web.server.WebFilterExchange; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; /** *

{@link RememberMeServices} implementation based on Barry Jaspan's Improved * Persistent Login Cookie Best Practice.

*

There is a slight modification to the described approach, in that the username is not * stored as part of the cookie but obtained from the persistent store via an * implementation of {@link PersistentTokenRepository}. The latter should place a unique * constraint on the series identifier, so that it is impossible for the same identifier * to be allocated to two different users.

*

User management such as changing passwords, removing users and setting user status * should be combined with maintenance of the user's persistent tokens.

*

* Note that while this class will use the date a token was created to check whether a * presented cookie is older than the configured tokenValiditySeconds property * and deny authentication in this case, it will not delete these tokens from storage. A * suitable batch process should be run periodically to remove expired tokens from the * database. *

* * @author guqing * @see * PersistentTokenBasedRememberMeServices * @since 2.17.0 */ @Slf4j @Setter @Component public class PersistentTokenBasedRememberMeServices extends TokenBasedRememberMeServices implements RememberMeServices { public static final String REMEMBER_ME_SERIES_REQUEST_NAME = "remember-me-series"; public static final int DEFAULT_SERIES_LENGTH = 16; public static final int DEFAULT_TOKEN_LENGTH = 16; private final SecureRandom random; private final int seriesLength = DEFAULT_SERIES_LENGTH; private final int tokenLength = DEFAULT_TOKEN_LENGTH; private final PersistentRememberMeTokenRepository tokenRepository; public PersistentTokenBasedRememberMeServices( CookieSignatureKeyResolver cookieSignatureKeyResolver, ReactiveUserDetailsService userDetailsService, RememberMeCookieResolver rememberMeCookieResolver, PersistentRememberMeTokenRepository tokenRepository) { super(cookieSignatureKeyResolver, userDetailsService, rememberMeCookieResolver); this.random = new SecureRandom(); this.tokenRepository = tokenRepository; } @Override protected Mono processAutoLoginCookie(String[] cookieTokens, ServerWebExchange exchange) { if (cookieTokens.length != 2) { throw new InvalidCookieException( "Cookie token did not contain " + 2 + " tokens, but contained '" + Arrays.asList(cookieTokens) + "'"); } String presentedSeries = cookieTokens[0]; String presentedToken = cookieTokens[1]; return this.tokenRepository.getTokenForSeries(presentedSeries) // No series match, so we can't authenticate using this cookie .switchIfEmpty(Mono.error(new RememberMeAuthenticationException( "No persistent token found for series id: " + presentedSeries)) ) .flatMap(token -> { // We have a match for this user/series combination if (!presentedToken.equals(token.getTokenValue())) { // Token doesn't match series value. Delete all logins for this user and throw // an exception to warn them. return this.tokenRepository.removeUserTokens(token.getUsername()) .then(Mono.error(new CookieTheftException( "Invalid remember-me token (Series/token) mismatch. Implies previous " + "cookie theft" + " attack."))); } if (isTokenExpired(token)) { return Mono.error( new RememberMeAuthenticationException("Remember-me login has expired")); } // Token also matches, so login is valid. Update the token value, keeping the // *same* series number. log.debug("Refreshing persistent login token for user '{}', series '{}'", token.getUsername(), token.getSeries()); var newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), token.getTokenValue(), new Date()); return Mono.just(newToken); }) .flatMap(newToken -> updateToken(newToken) .doOnSuccess(unused -> addCookie(newToken, exchange)) .onErrorMap(ex -> { log.error("Failed to update token: ", ex); return new RememberMeAuthenticationException( "Autologin failed due to data access problem"); }) .then(getUserDetailsService().findByUsername(newToken.getUsername())) ); } private boolean isTokenExpired(PersistentRememberMeToken token) { return isTokenExpired(token.getDate().getTime() + getTokenValidityMillis()); } private Mono updateToken(PersistentRememberMeToken newToken) { return this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), dateToInstant(newToken.getDate())); } Instant dateToInstant(Date date) { return Instant.ofEpochMilli(date.getTime()); } /** * Creates a new persistent login token with a new series number, stores the data in * the persistent token repository and adds the corresponding cookie to the response. */ @Override protected Mono onLoginSuccess(ServerWebExchange exchange, Authentication successfulAuthentication) { String username = successfulAuthentication.getName(); log.debug("Creating new persistent login for user {}", username); PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, generateSeriesData(), generateTokenData(), new Date()); return this.tokenRepository.createNewToken(persistentToken) .doOnSuccess(unused -> addCookie(persistentToken, exchange)) .onErrorResume(Throwable.class, ex -> { log.error("Failed to save persistent token ", ex); return Mono.empty(); }); } @Override protected Mono onLogout(WebFilterExchange exchange, Authentication authentication) { if (authentication != null) { return this.tokenRepository.removeUserTokens(authentication.getName()); } return Mono.empty(); } private void addCookie(PersistentRememberMeToken token, ServerWebExchange exchange) { setCookie(new String[] {token.getSeries(), token.getTokenValue()}, exchange); exchange.getAttributes().put(REMEMBER_ME_SERIES_REQUEST_NAME, token.getSeries()); } protected String generateSeriesData() { byte[] newSeries = new byte[this.seriesLength]; this.random.nextBytes(newSeries); return new String(Base64.getEncoder().encode(newSeries)); } protected String generateTokenData() { byte[] newToken = new byte[this.tokenLength]; this.random.nextBytes(newToken); return new String(Base64.getEncoder().encode(newToken)); } private long getTokenValidityMillis() { return rememberMeCookieResolver.getCookieMaxAge().toMillis(); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeAuthenticationManager.java ================================================ package run.halo.app.security.authentication.rememberme; import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.InitializingBean; import org.springframework.context.MessageSource; import org.springframework.context.MessageSourceAware; import org.springframework.context.support.MessageSourceAccessor; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authentication.RememberMeAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.SpringSecurityMessageSource; import org.springframework.util.Assert; import reactor.core.publisher.Mono; @RequiredArgsConstructor public class RememberMeAuthenticationManager implements ReactiveAuthenticationManager, InitializingBean, MessageSourceAware { private final CookieSignatureKeyResolver cookieSignatureKeyResolver; protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); @Override public Mono authenticate(Authentication authentication) { if (authentication instanceof RememberMeAuthenticationToken rememberMeAuthenticationToken) { return doAuthenticate(rememberMeAuthenticationToken); } return Mono.empty(); } @Override public void afterPropertiesSet() { Assert.notNull(this.messages, "A message source must be set"); } @Override public void setMessageSource(@NonNull MessageSource messageSource) { this.messages = new MessageSourceAccessor(messageSource); } private Mono doAuthenticate(RememberMeAuthenticationToken token) { return cookieSignatureKeyResolver.resolveSigningKey() .flatMap(key -> { if (key.hashCode() != token.getKeyHash()) { return Mono.error(new BadCredentialsException(badCredentialMessage())); } return Mono.just(token); }); } private String badCredentialMessage() { return this.messages.getMessage("RememberMeAuthenticationProvider.incorrectKey", "The presented RememberMeAuthenticationToken does not contain the expected key"); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeConfigurer.java ================================================ package run.halo.app.security.authentication.rememberme; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; import lombok.RequiredArgsConstructor; import org.springframework.core.annotation.Order; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.web.server.authentication.AuthenticationWebFilter; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.stereotype.Component; import run.halo.app.security.authentication.SecurityConfigurer; @Component @RequiredArgsConstructor @Order(0) public class RememberMeConfigurer implements SecurityConfigurer { private final RememberMeServices rememberMeServices; private final ServerSecurityContextRepository securityContextRepository; private final CookieSignatureKeyResolver cookieSignatureKeyResolver; @Override public void configure(ServerHttpSecurity http) { var authManager = new RememberMeAuthenticationManager(cookieSignatureKeyResolver); var filter = new AuthenticationWebFilter(authManager); filter.setSecurityContextRepository(securityContextRepository); filter.setAuthenticationFailureHandler( (exchange, exception) -> rememberMeServices.loginFail(exchange.getExchange()) ); filter.setServerAuthenticationConverter(rememberMeServices::autoLogin); filter.setRequiresAuthenticationMatcher( exchange -> ReactiveSecurityContextHolder.getContext() .flatMap(securityContext -> MatchResult.notMatch()) .switchIfEmpty(MatchResult.match()) ); http.addFilterAt(filter, SecurityWebFiltersOrder.AUTHENTICATION); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeCookieResolver.java ================================================ package run.halo.app.security.authentication.rememberme; import java.time.Duration; import org.springframework.http.HttpCookie; import org.springframework.lang.Nullable; import org.springframework.web.server.ServerWebExchange; public interface RememberMeCookieResolver { @Nullable HttpCookie resolveRememberMeCookie(ServerWebExchange exchange); void setRememberMeCookie(ServerWebExchange exchange, String value); void expireCookie(ServerWebExchange exchange); String getCookieName(); Duration getCookieMaxAge(); } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeCookieResolverImpl.java ================================================ package run.halo.app.security.authentication.rememberme; import java.time.Duration; import lombok.Getter; import org.springframework.http.HttpCookie; import org.springframework.http.ResponseCookie; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.web.server.ServerWebExchange; import run.halo.app.infra.properties.HaloProperties; @Getter @Component public class RememberMeCookieResolverImpl implements RememberMeCookieResolver { public static final String SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY = "remember-me"; private final String cookieName = SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY; private final Duration cookieMaxAge; public RememberMeCookieResolverImpl(HaloProperties haloProperties) { this.cookieMaxAge = haloProperties.getSecurity().getRememberMe().getTokenValidity(); } @Override @Nullable public HttpCookie resolveRememberMeCookie(ServerWebExchange exchange) { return exchange.getRequest().getCookies().getFirst(getCookieName()); } @Override public void setRememberMeCookie(ServerWebExchange exchange, String value) { Assert.notNull(value, "'value' is required"); exchange.getResponse().getCookies() .set(getCookieName(), initCookie(exchange, value).build()); } @Override public void expireCookie(ServerWebExchange exchange) { ResponseCookie cookie = initCookie(exchange, "").maxAge(0).build(); exchange.getResponse().getCookies().set(this.cookieName, cookie); } private ResponseCookie.ResponseCookieBuilder initCookie(ServerWebExchange exchange, String value) { return ResponseCookie.from(this.cookieName, value) .path(exchange.getRequest().getPath().contextPath().value() + "/") .maxAge(getCookieMaxAge()) .httpOnly(true) .secure("https".equalsIgnoreCase(exchange.getRequest().getURI().getScheme())) .sameSite("Lax"); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeRequestCache.java ================================================ package run.halo.app.security.authentication.rememberme; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; /** * An interface for caching remember-me parameter in request for further handling. Especially * useful for two-factor authentication. * * @author johnniang * @since 2.20.0 */ public interface RememberMeRequestCache { /** * Save remember-me parameter or form into cache. * * @param exchange exchange * @return empty to return */ Mono saveRememberMe(ServerWebExchange exchange); /** * Check if remember-me parameter exists in cache. * * @param exchange exchange * @return true if remember-me exists, false otherwise */ Mono isRememberMe(ServerWebExchange exchange); /** * Remove remember-me parameter from cache. * * @param exchange exchange * @return empty to return */ Mono removeRememberMe(ServerWebExchange exchange); } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeServices.java ================================================ package run.halo.app.security.authentication.rememberme; import org.springframework.security.core.Authentication; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; public interface RememberMeServices { Mono autoLogin(ServerWebExchange exchange); Mono loginFail(ServerWebExchange exchange); Mono loginSuccess(ServerWebExchange exchange, Authentication successfulAuthentication); } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeTokenRevoker.java ================================================ package run.halo.app.security.authentication.rememberme; import java.time.Duration; import lombok.RequiredArgsConstructor; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import run.halo.app.event.user.PasswordChangedEvent; import run.halo.app.infra.utils.ReactiveUtils; /** * Remember me token revoker. *

* Listen to password changed event and revoke remember me token. *

* Maybe you should consider revoke remember me token when user logout or username changed. * * @author guqing * @since 2.17.0 */ @Component @RequiredArgsConstructor public class RememberMeTokenRevoker { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private final PersistentRememberMeTokenRepository tokenRepository; @Async @EventListener(PasswordChangedEvent.class) public void onPasswordChanged(PasswordChangedEvent event) { tokenRepository.removeUserTokens(event.getUsername()) .block(BLOCKING_TIMEOUT); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/RememberTokenCleaner.java ================================================ package run.halo.app.security.authentication.rememberme; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.isNull; import static run.halo.app.extension.index.query.Queries.lessThan; import java.time.Duration; import java.time.Instant; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import run.halo.app.core.extension.RememberMeToken; import run.halo.app.extension.ListOptions; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.ReactiveExtensionPaginatedOperator; import run.halo.app.infra.utils.ReactiveUtils; /** * A cleaner for remember me tokens that cleans up expired tokens periodically. * * @author guqing * @since 2.17.0 */ @Slf4j @Component @RequiredArgsConstructor public class RememberTokenCleaner { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private final ReactiveExtensionPaginatedOperator paginatedOperator; private final RememberMeCookieResolver rememberMeCookieResolver; /** * Clean up expired tokens every day at 3:00 AM. */ @Scheduled(cron = "0 0 3 * * ?") public void cleanUpExpiredTokens() { paginatedOperator.deleteInitialBatch(RememberMeToken.class, getExpiredTokensListOptions()) .then().block(BLOCKING_TIMEOUT); log.info("Expired remember me tokens have been cleaned up."); } ListOptions getExpiredTokensListOptions() { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of( and(isNull("metadata.deletionTimestamp"), lessThan("metadata.creationTimestamp", getExpirationThreshold().toString()) ) )); return listOptions; } protected Instant getExpirationThreshold() { return Instant.now().minus(getTokenValidity()); } protected Duration getTokenValidity() { return rememberMeCookieResolver.getCookieMaxAge(); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java ================================================ package run.halo.app.security.authentication.rememberme; import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.Instant; import java.util.Arrays; import java.util.Base64; import java.util.Date; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.AccountStatusException; import org.springframework.security.authentication.AccountStatusUserDetailsChecker; import org.springframework.security.authentication.RememberMeAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsChecker; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.codec.Hex; import org.springframework.security.crypto.codec.Utf8; import org.springframework.security.web.authentication.rememberme.CookieTheftException; import org.springframework.security.web.authentication.rememberme.InvalidCookieException; import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException; import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; import org.springframework.util.StringUtils; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; /** *

An {@link org.springframework.security.core.userdetails.UserDetailsService} is required * by this implementation, so that it can construct a valid Authentication * from the returned {@link org.springframework.security.core.userdetails.UserDetails}.

*

This is also necessary so that the user's password is available and can be checked as * part of the encoded cookie.

*

The cookie encoded by this implementation adopts the following form: *

 * username + ":" + expiryTime + ":" + algorithmName + ":"
 *   + algorithmHex(username + ":" + expiryTime + ":" + password + ":" + key)
 * 
*

* * @see org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices */ @Slf4j @Setter @Getter @RequiredArgsConstructor public class TokenBasedRememberMeServices implements ServerLogoutHandler, RememberMeServices { public static final String DEFAULT_ALGORITHM = "SHA-256"; private static final String DELIMITER = ":"; protected final CookieSignatureKeyResolver cookieSignatureKeyResolver; private final ReactiveUserDetailsService userDetailsService; protected final RememberMeCookieResolver rememberMeCookieResolver; private UserDetailsChecker userDetailsChecker = new AccountStatusUserDetailsChecker(); private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); private RememberMeRequestCache rememberMeRequestCache = new WebSessionRememberMeRequestCache(); private static boolean equals(String expected, String actual) { byte[] expectedBytes = bytesUtf8(expected); byte[] actualBytes = bytesUtf8(actual); return MessageDigest.isEqual(expectedBytes, actualBytes); } private static byte[] bytesUtf8(String s) { return (s != null) ? Utf8.encode(s) : null; } @Override public Mono autoLogin(ServerWebExchange exchange) { var rememberMeCookie = rememberMeCookieResolver.resolveRememberMeCookie(exchange); if (rememberMeCookie == null) { return Mono.empty(); } log.debug("Remember-me cookie detected"); return Mono.defer( () -> { String[] cookieTokens = decodeCookie(rememberMeCookie.getValue()); return processAutoLoginCookie(cookieTokens, exchange); }) .flatMap(user -> { this.userDetailsChecker.check(user); log.debug("Remember-me cookie accepted"); return createSuccessfulAuthentication(exchange, user); }) .onErrorResume(ex -> handleError(exchange, ex)); } private Mono handleError(ServerWebExchange exchange, Throwable ex) { cancelCookie(exchange); if (ex instanceof CookieTheftException) { log.error("Cookie theft detected", ex); return Mono.error(ex); } else if (ex instanceof UsernameNotFoundException) { log.debug("Remember-me login was valid but corresponding user not found.", ex); } else if (ex instanceof InvalidCookieException) { log.debug("Invalid remember-me cookie: {}", ex.getMessage()); } else if (ex instanceof AccountStatusException) { log.debug("Invalid UserDetails: {}", ex.getMessage()); } else if (ex instanceof RememberMeAuthenticationException) { log.debug(ex.getMessage()); } return Mono.empty(); } protected void cancelCookie(ServerWebExchange exchange) { rememberMeCookieResolver.expireCookie(exchange); } protected Mono processAutoLoginCookie(String[] cookieTokens, ServerWebExchange exchange) { if (!isValidCookieTokensLength(cookieTokens)) { throw new InvalidCookieException( "Cookie token did not contain 3 or 4 tokens, but contained '" + Arrays.asList( cookieTokens) + "'"); } long tokenExpiryTime = getTokenExpiryTime(cookieTokens); if (isTokenExpired(tokenExpiryTime)) { throw new InvalidCookieException( "Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime) + "'; current time is '" + new Date() + "')"); } // Check the user exists. Defer lookup until after expiry time checked, to // possibly avoid expensive database call. return getUserDetailsService().findByUsername(cookieTokens[0]) .switchIfEmpty(Mono.error(new UsernameNotFoundException("User '" + cookieTokens[0] + "' not found"))) .flatMap(userDetails -> { // Check signature of token matches remaining details. Must do this after user // lookup, as we need the DAO-derived password. If efficiency was a major issue, // just add in a UserCache implementation, but recall that this method is usually // only called once per HttpSession - if the token is valid, it will cause // SecurityContextHolder population, whilst if invalid, will cause the cookie to // be cancelled. String actualTokenSignature; String actualAlgorithm = DEFAULT_ALGORITHM; // If the cookie value contains the algorithm, we use that algorithm to check the // signature if (cookieTokens.length == 4) { actualTokenSignature = cookieTokens[3]; actualAlgorithm = cookieTokens[2]; } else { actualTokenSignature = cookieTokens[2]; } return makeTokenSignature(tokenExpiryTime, userDetails.getUsername(), userDetails.getPassword(), actualAlgorithm) .doOnNext(expectedTokenSignature -> { if (!equals(expectedTokenSignature, actualTokenSignature)) { throw new InvalidCookieException( "Cookie contained signature '" + actualTokenSignature + "' but expected '" + expectedTokenSignature + "'"); } }) .thenReturn(userDetails); }); } protected boolean isTokenExpired(long tokenExpiryTime) { return tokenExpiryTime < System.currentTimeMillis(); } private long getTokenExpiryTime(String[] cookieTokens) { try { return Long.parseLong(cookieTokens[1]); } catch (NumberFormatException nfe) { throw new InvalidCookieException( "Cookie token[1] did not contain a valid number (contained '" + cookieTokens[1] + "')"); } } protected Mono createSuccessfulAuthentication(ServerWebExchange exchange, UserDetails user) { return getKey() .map(key -> new RememberMeAuthenticationToken(key, user, this.authoritiesMapper.mapAuthorities(user.getAuthorities())) ); } private boolean isValidCookieTokensLength(String[] cookieTokens) { return cookieTokens.length == 3 || cookieTokens.length == 4; } @Override public Mono loginFail(ServerWebExchange exchange) { log.debug("Interactive login attempt was unsuccessful."); cancelCookie(exchange); return rememberMeRequestCache.saveRememberMe(exchange); } @Override public Mono loginSuccess(ServerWebExchange exchange, Authentication successfulAuthentication) { return rememberMeRequestCache.isRememberMe(exchange) .filter(Boolean::booleanValue) .switchIfEmpty(Mono.fromRunnable(() -> { log.debug("Remember-me login not requested."); })) .flatMap(rememberMe -> onLoginSuccess(exchange, successfulAuthentication)); } protected Mono onLoginSuccess(ServerWebExchange exchange, Authentication successfulAuthentication) { return Mono.defer(() -> retrieveUsernamePassword(successfulAuthentication)) .flatMap(pair -> { var username = pair.username(); var password = pair.password(); var expiryTimeMs = calculateExpireTime(exchange, successfulAuthentication); return makeTokenSignature(expiryTimeMs, username, password, DEFAULT_ALGORITHM) .doOnNext(signatureValue -> { setCookie( new String[] {username, Long.toString(expiryTimeMs), DEFAULT_ALGORITHM, signatureValue}, exchange); if (log.isDebugEnabled()) { log.debug("Added remember-me cookie for user '{}', expiry: '{}'", username, new Date(expiryTimeMs)); } }); }) .then(); } private Mono retrieveUsernamePassword( Authentication successfulAuthentication) { return Mono.defer(() -> { String username = retrieveUserName(successfulAuthentication); String password = retrievePassword(successfulAuthentication); // If unable to find a username and password, just abort as // TokenBasedRememberMeServices is // unable to construct a valid token in this case. if (!StringUtils.hasLength(username)) { log.debug("Unable to retrieve username"); return Mono.empty(); } if (!StringUtils.hasLength(password)) { return getUserDetailsService().findByUsername(username) .flatMap(user -> { String existingPassword = user.getPassword(); if (!StringUtils.hasLength(existingPassword)) { log.debug("Unable to obtain password for user: {}", username); return Mono.empty(); } return Mono.just(new UsernamePassword(username, existingPassword)); }); } return Mono.just(new UsernamePassword(username, password)); }); } void setCookie(String[] cookieTokens, ServerWebExchange exchange) { String cookieValue = encodeCookie(cookieTokens); rememberMeCookieResolver.setRememberMeCookie(exchange, cookieValue); } protected long calculateExpireTime(ServerWebExchange exchange, Authentication authentication) { var tokenLifetime = rememberMeCookieResolver.getCookieMaxAge().toSeconds(); return Instant.now().plusSeconds(tokenLifetime).toEpochMilli(); } protected String[] decodeCookie(String cookieValue) throws InvalidCookieException { int paddingCount = 4 - (cookieValue.length() % 4); if (paddingCount < 4) { char[] padding = new char[paddingCount]; Arrays.fill(padding, '='); cookieValue += new String(padding); } String cookieAsPlainText; try { cookieAsPlainText = new String(Base64.getDecoder().decode(cookieValue.getBytes())); } catch (IllegalArgumentException ex) { throw new InvalidCookieException( "Cookie token was not Base64 encoded; value was '" + cookieValue + "'"); } String[] tokens = StringUtils.delimitedListToStringArray(cookieAsPlainText, DELIMITER); for (int i = 0; i < tokens.length; i++) { tokens[i] = URLDecoder.decode(tokens[i], StandardCharsets.UTF_8); } return tokens; } /** * Inverse operation of decodeCookie. * * @param cookieTokens the tokens to be encoded. * @return base64 encoding of the tokens concatenated with the ":" delimiter. */ protected String encodeCookie(String[] cookieTokens) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < cookieTokens.length; i++) { sb.append(URLEncoder.encode(cookieTokens[i], StandardCharsets.UTF_8)); if (i < cookieTokens.length - 1) { sb.append(DELIMITER); } } String value = sb.toString(); sb = new StringBuilder(new String(Base64.getEncoder().encode(value.getBytes()))); while (sb.charAt(sb.length() - 1) == '=') { sb.deleteCharAt(sb.length() - 1); } return sb.toString(); } protected Mono makeTokenSignature(long tokenExpiryTime, String username, String password, String algorithm) { return getKey() .handle((key, sink) -> { String data = username + ":" + tokenExpiryTime + ":" + password + ":" + key; try { MessageDigest digest = MessageDigest.getInstance(algorithm); sink.next(new String(Hex.encode(digest.digest(data.getBytes())))); } catch (NoSuchAlgorithmException ex) { sink.error( new IllegalStateException("No " + algorithm + " algorithm available!")); } }); } protected String retrieveUserName(Authentication authentication) { if (isInstanceOfUserDetails(authentication)) { return ((UserDetails) authentication.getPrincipal()).getUsername(); } return authentication.getPrincipal().toString(); } protected String retrievePassword(Authentication authentication) { if (isInstanceOfUserDetails(authentication)) { return ((UserDetails) authentication.getPrincipal()).getPassword(); } if (authentication.getCredentials() != null) { return authentication.getCredentials().toString(); } return null; } private boolean isInstanceOfUserDetails(Authentication authentication) { return authentication.getPrincipal() instanceof UserDetails; } protected Mono getKey() { return cookieSignatureKeyResolver.resolveSigningKey(); } @Override public Mono logout(WebFilterExchange exchange, Authentication authentication) { if (log.isDebugEnabled()) { log.debug("Logout of user {}", (authentication != null) ? authentication.getName() : "Unknown"); } return loginFail(exchange.getExchange()).then(onLogout(exchange, authentication)); } protected Mono onLogout(WebFilterExchange exchange, Authentication authentication) { return Mono.empty(); } record UsernamePassword(String username, String password) { } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/rememberme/WebSessionRememberMeRequestCache.java ================================================ package run.halo.app.security.authentication.rememberme; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebSession; import reactor.core.publisher.Mono; /** * An implementation of {@link RememberMeRequestCache} that stores remember-me parameter in * {@link WebSession}. * * @author johnniang * @since 2.20.0 */ public class WebSessionRememberMeRequestCache implements RememberMeRequestCache { private static final String SESSION_ATTRIBUTE_NAME = RememberMeRequestCache.class + ".REMEMBER_ME"; private static final String DEFAULT_PARAMETER = "remember-me"; @Override public Mono saveRememberMe(ServerWebExchange exchange) { return resolveFromQuery(exchange) .switchIfEmpty(resolveFromForm(exchange)) .flatMap(rememberMe -> exchange.getSession().doOnNext( session -> session.getAttributes().put(SESSION_ATTRIBUTE_NAME, rememberMe)) ) .then(); } @Override public Mono isRememberMe(ServerWebExchange exchange) { return resolveFromQuery(exchange) .switchIfEmpty(resolveFromForm(exchange)) .switchIfEmpty(resolveFromSession(exchange)) .defaultIfEmpty(false); } @Override public Mono removeRememberMe(ServerWebExchange exchange) { return exchange.getSession() .doOnNext(session -> session.getAttributes().remove(SESSION_ATTRIBUTE_NAME)) .then(); } private Mono resolveFromQuery(ServerWebExchange exchange) { return Mono.justOrEmpty(exchange.getRequest().getQueryParams().getFirst(DEFAULT_PARAMETER)) .map(Boolean::parseBoolean); } private Mono resolveFromForm(ServerWebExchange exchange) { return exchange.getFormData() .mapNotNull(form -> form.getFirst(DEFAULT_PARAMETER)) .map(Boolean::parseBoolean); } private Mono resolveFromSession(ServerWebExchange exchange) { return exchange.getSession() .mapNotNull(session -> session.getAttribute(SESSION_ATTRIBUTE_NAME)) .filter(Boolean.class::isInstance) .cast(Boolean.class); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/twofactor/TotpAuthenticationSuccessHandler.java ================================================ package run.halo.app.security.authentication.twofactor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; import org.springframework.security.web.server.savedrequest.ServerRequestCache; import reactor.core.publisher.Mono; import run.halo.app.security.LoginHandlerEnhancer; @Slf4j public class TotpAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler { private final LoginHandlerEnhancer loginEnhancer; private final ServerAuthenticationSuccessHandler successHandler; public TotpAuthenticationSuccessHandler(LoginHandlerEnhancer loginEnhancer, ServerRequestCache serverRequestCache) { this.loginEnhancer = loginEnhancer; var successHandler = new RedirectServerAuthenticationSuccessHandler("/uc"); successHandler.setRequestCache(serverRequestCache); this.successHandler = successHandler; } @Override public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) { return loginEnhancer.onLoginSuccess(webFilterExchange.getExchange(), authentication) .then(successHandler.onAuthenticationSuccess(webFilterExchange, authentication)); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthEndpoint.java ================================================ package run.halo.app.security.authentication.twofactor; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route; import static org.springframework.web.reactive.function.server.RequestPredicates.path; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.net.URI; import lombok.Data; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import org.springframework.validation.Validator; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import org.springframework.web.util.UriComponentsBuilder; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.ValidationUtils; import run.halo.app.infra.exception.AccessDeniedException; import run.halo.app.infra.exception.RequestBodyValidationException; import run.halo.app.security.authentication.twofactor.totp.TotpAuthService; @Component public class TwoFactorAuthEndpoint implements CustomEndpoint { private final ReactiveExtensionClient client; private final UserService userService; private final TotpAuthService totpAuthService; private final Validator validator; private final PasswordEncoder passwordEncoder; private final ExternalUrlSupplier externalUrl; public TwoFactorAuthEndpoint(ReactiveExtensionClient client, UserService userService, TotpAuthService totpAuthService, Validator validator, PasswordEncoder passwordEncoder, ExternalUrlSupplier externalUrl) { this.client = client; this.userService = userService; this.totpAuthService = totpAuthService; this.validator = validator; this.passwordEncoder = passwordEncoder; this.externalUrl = externalUrl; } @Override public RouterFunction endpoint() { var tag = "TwoFactorAuthV1alpha1Uc"; return route().nest(path("/authentications/two-factor"), () -> route() .GET("/settings", this::getTwoFactorSettings, builder -> builder.operationId("GetTwoFactorAuthenticationSettings") .tag(tag) .description("Get Two-factor authentication settings.") .response(responseBuilder().implementation(TwoFactorAuthSettings.class))) .PUT("/settings/enabled", this::enableTwoFactor, builder -> builder.operationId("EnableTwoFactor") .tag(tag) .description("Enable Two-factor authentication") .requestBody(requestBodyBuilder().implementation(PasswordRequest.class)) .response(responseBuilder().implementation(TwoFactorAuthSettings.class))) .PUT("/settings/disabled", this::disableTwoFactor, builder -> builder.operationId("DisableTwoFactor") .tag(tag) .description("Disable Two-factor authentication") .requestBody(requestBodyBuilder().implementation(PasswordRequest.class)) .response(responseBuilder().implementation(TwoFactorAuthSettings.class))) .POST("/totp", this::configureTotp, builder -> builder.operationId("ConfigurerTotp") .tag(tag) .description("Configure a TOTP") .requestBody(requestBodyBuilder().implementation(TotpRequest.class)) .response(responseBuilder().implementation(TwoFactorAuthSettings.class))) .DELETE("/totp/-", this::deleteTotp, builder -> builder.operationId("DeleteTotp") .tag(tag) .requestBody(requestBodyBuilder().implementation(PasswordRequest.class)) .response(responseBuilder().implementation(TwoFactorAuthSettings.class))) .GET("/totp/auth-link", this::getTotpAuthLink, builder -> builder.operationId("GetTotpAuthLink") .tag(tag) .description("Get TOTP auth link, including secret") .response(responseBuilder().implementation(TotpAuthLinkResponse.class))) .build() ).build(); } private Mono deleteTotp(ServerRequest request) { var totpDeleteRequestMono = request.bodyToMono(PasswordRequest.class) .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required"))) .doOnNext(passwordRequest -> this.validateRequest(passwordRequest, "passwordRequest", request) ); var twoFactorAuthSettings = totpDeleteRequestMono.flatMap(passwordRequest -> getCurrentUser() .filter(user -> { var rawPassword = passwordRequest.getPassword(); var encodedPassword = user.getSpec().getPassword(); return this.passwordEncoder.matches(rawPassword, encodedPassword); }) .switchIfEmpty( Mono.error(() -> new ServerWebInputException("Invalid password"))) .doOnNext(user -> { var spec = user.getSpec(); spec.setTotpEncryptedSecret(null); }) .flatMap(client::update) .map(TwoFactorUtils::getTwoFactorAuthSettings)); return ServerResponse.ok().body(twoFactorAuthSettings, TwoFactorAuthSettings.class); } @Data public static class PasswordRequest { @NotBlank private String password; } private Mono disableTwoFactor(ServerRequest request) { return toggleTwoFactor(request, false); } private Mono enableTwoFactor(ServerRequest request) { return toggleTwoFactor(request, true); } private Mono toggleTwoFactor(ServerRequest request, boolean enabled) { return request.bodyToMono(PasswordRequest.class) .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required"))) .doOnNext(passwordRequest -> this.validateRequest(passwordRequest, "passwordRequest", request)) .flatMap(passwordRequest -> getCurrentUser() .filter(user -> { var encodedPassword = user.getSpec().getPassword(); var rawPassword = passwordRequest.getPassword(); return passwordEncoder.matches(rawPassword, encodedPassword); }) .switchIfEmpty( Mono.error(() -> new ServerWebInputException("Invalid password"))) .doOnNext(user -> user.getSpec().setTwoFactorAuthEnabled(enabled)) .flatMap(client::update) .map(TwoFactorUtils::getTwoFactorAuthSettings)) .flatMap(twoFactorAuthSettings -> ServerResponse.ok().bodyValue(twoFactorAuthSettings)); } private Mono getTotpAuthLink(ServerRequest request) { var authLinkResponse = getCurrentUser() .map(user -> { var username = user.getMetadata().getName(); var url = externalUrl.getURL(request.exchange().getRequest()); var authority = url.getAuthority(); var authKeyId = username + ":" + authority; var rawSecret = totpAuthService.generateTotpSecret(); var authLink = UriComponentsBuilder.fromUriString("otpauth://totp") .path(authKeyId) .queryParam("secret", rawSecret) .queryParam("digits", 6) .build().toUri(); var authLinkResp = new TotpAuthLinkResponse(); authLinkResp.setAuthLink(authLink); authLinkResp.setRawSecret(rawSecret); return authLinkResp; }); return ServerResponse.ok().body(authLinkResponse, TotpAuthLinkResponse.class); } @Data public static class TotpAuthLinkResponse { /** * QR Code with base64 encoded. */ private URI authLink; private String rawSecret; } private Mono configureTotp(ServerRequest request) { var totpRequestMono = request.bodyToMono(TotpRequest.class) .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required."))) .doOnNext(totpRequest -> this.validateRequest(totpRequest, "totp", request)); var configuredUser = totpRequestMono.flatMap(totpRequest -> { // validate password return getCurrentUser() .filter(user -> { var encodedPassword = user.getSpec().getPassword(); var rawPassword = totpRequest.getPassword(); return passwordEncoder.matches(rawPassword, encodedPassword); }) .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Invalid password"))) .doOnNext(user -> { // TimeBasedOneTimePasswordUtil. var rawSecret = totpRequest.getSecret(); int code; try { code = Integer.parseInt(totpRequest.getCode()); } catch (NumberFormatException e) { throw new ServerWebInputException("Invalid code"); } var validated = totpAuthService.validateTotp(rawSecret, code); if (!validated) { throw new ServerWebInputException("Invalid secret or code"); } var encryptedSecret = totpAuthService.encryptSecret(rawSecret); user.getSpec().setTotpEncryptedSecret(encryptedSecret); }) .flatMap(client::update); }); var twoFactorAuthSettings = configuredUser.map(TwoFactorUtils::getTwoFactorAuthSettings); return ServerResponse.ok().body(twoFactorAuthSettings, TwoFactorAuthSettings.class); } private void validateRequest(Object target, String name, ServerRequest request) { var bindingResult = ValidationUtils.validate(target, name, validator, request.exchange()); if (bindingResult.hasErrors()) { throw new RequestBodyValidationException(bindingResult); } } @Data public static class TotpRequest { @NotBlank private String secret; @NotNull private String code; @NotBlank private String password; } private Mono getTwoFactorSettings(ServerRequest request) { return getCurrentUser() .map(TwoFactorUtils::getTwoFactorAuthSettings) .flatMap(settings -> ServerResponse.ok().bodyValue(settings)); } private Mono getCurrentUser() { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .filter(TwoFactorAuthEndpoint::isAuthenticatedUser) .switchIfEmpty(Mono.error(AccessDeniedException::new)) .map(Authentication::getName) .flatMap(userService::getUser); } private static boolean isAuthenticatedUser(Authentication authentication) { return authentication != null && !(authentication instanceof AnonymousAuthenticationToken); } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("uc.api.security.halo.run/v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthRequiredException.java ================================================ package run.halo.app.security.authentication.twofactor; import java.net.URI; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; public class TwoFactorAuthRequiredException extends ResponseStatusException { private static final URI type = URI.create("https://halo.run/probs/2fa-required"); public TwoFactorAuthRequiredException(URI redirectURI) { super(HttpStatus.UNAUTHORIZED, "Two-factor authentication required"); setType(type); getBody().setProperty("redirectURI", redirectURI); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java ================================================ package run.halo.app.security.authentication.twofactor; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.web.server.authentication.AuthenticationWebFilter; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.savedrequest.ServerRequestCache; import org.springframework.stereotype.Component; import run.halo.app.security.LoginHandlerEnhancer; import run.halo.app.security.authentication.SecurityConfigurer; import run.halo.app.security.authentication.twofactor.totp.TotpAuthService; import run.halo.app.security.authentication.twofactor.totp.TotpAuthenticationManager; import run.halo.app.security.authentication.twofactor.totp.TotpCodeAuthenticationConverter; @Component @Order(0) public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer { private final ServerSecurityContextRepository securityContextRepository; private final TotpAuthService totpAuthService; private final LoginHandlerEnhancer loginHandlerEnhancer; private final ServerRequestCache serverRequestCache; public TwoFactorAuthSecurityConfigurer( ServerSecurityContextRepository securityContextRepository, TotpAuthService totpAuthService, LoginHandlerEnhancer loginHandlerEnhancer, ServerRequestCache serverRequestCache ) { this.securityContextRepository = securityContextRepository; this.totpAuthService = totpAuthService; this.loginHandlerEnhancer = loginHandlerEnhancer; this.serverRequestCache = serverRequestCache; } @Override public void configure(ServerHttpSecurity http) { var authManager = new TotpAuthenticationManager(totpAuthService); var filter = new AuthenticationWebFilter(authManager); filter.setRequiresAuthenticationMatcher( pathMatchers(HttpMethod.POST, "/challenges/two-factor/totp") ); filter.setSecurityContextRepository(securityContextRepository); filter.setServerAuthenticationConverter(new TotpCodeAuthenticationConverter()); filter.setAuthenticationSuccessHandler( new TotpAuthenticationSuccessHandler(loginHandlerEnhancer, serverRequestCache) ); filter.setAuthenticationFailureHandler( new RedirectServerAuthenticationFailureHandler("/challenges/two-factor/totp?error") ); http.addFilterAt(filter, SecurityWebFiltersOrder.AUTHENTICATION); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSettings.java ================================================ package run.halo.app.security.authentication.twofactor; import lombok.Data; @Data public class TwoFactorAuthSettings { private boolean enabled; private boolean emailVerified; private boolean totpConfigured; /** * Check if 2FA is available. * * @return true if 2FA is enabled and configured, false otherwise. */ public boolean isAvailable() { return enabled && totpConfigured; } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java ================================================ package run.halo.app.security.authentication.twofactor; import static run.halo.app.security.authorization.AuthorityUtils.ANONYMOUS_ROLE_NAME; import java.util.List; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; /** * Authentication token for two-factor authentication. * * @author johnniang */ public class TwoFactorAuthentication extends AbstractAuthenticationToken { private final Authentication previous; /** * Creates a token with the supplied array of authorities. * * @param previous the previous authentication */ public TwoFactorAuthentication(Authentication previous) { super(List.of(new SimpleGrantedAuthority(ANONYMOUS_ROLE_NAME))); this.previous = previous; } @Override public Object getCredentials() { return previous.getCredentials(); } @Override public Object getPrincipal() { return previous.getPrincipal(); } @Override public boolean isAuthenticated() { // for further authentication return false; } public Authentication getPrevious() { return previous; } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthenticationEntryPoint.java ================================================ package run.halo.app.security.authentication.twofactor; import java.net.URI; import org.springframework.context.MessageSource; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.server.DefaultServerRedirectStrategy; import org.springframework.security.web.server.ServerAuthenticationEntryPoint; import org.springframework.security.web.server.ServerRedirectStrategy; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.infra.exception.Exceptions; public class TwoFactorAuthenticationEntryPoint implements ServerAuthenticationEntryPoint { public static ServerWebExchangeMatcher MATCHER = exchange -> exchange.getPrincipal() .filter(TwoFactorAuthentication.class::isInstance) .flatMap(a -> ServerWebExchangeMatcher.MatchResult.match()) .switchIfEmpty(ServerWebExchangeMatcher.MatchResult.notMatch()); private static final URI REDIRECT_LOCATION = URI.create("/challenges/two-factor/totp"); /** * Because we don't want to cache the request before redirecting to the 2FA page, * ServerRedirectStrategy is used to redirect the request. */ private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); private final MessageSource messageSource; private final ServerResponse.Context context; private static final ServerWebExchangeMatcher XHR_MATCHER = exchange -> { if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With") .contains("XMLHttpRequest")) { return ServerWebExchangeMatcher.MatchResult.match(); } return ServerWebExchangeMatcher.MatchResult.notMatch(); }; public TwoFactorAuthenticationEntryPoint(MessageSource messageSource, ServerResponse.Context context) { this.messageSource = messageSource; this.context = context; } @Override public Mono commence(ServerWebExchange exchange, AuthenticationException ex) { return XHR_MATCHER.matches(exchange) .filter(ServerWebExchangeMatcher.MatchResult::isMatch) .switchIfEmpty( redirectStrategy.sendRedirect(exchange, REDIRECT_LOCATION).then(Mono.empty()) ) .flatMap(isXhr -> { var errorResponse = Exceptions.createErrorResponse( new TwoFactorAuthRequiredException(REDIRECT_LOCATION), null, exchange, messageSource); return ServerResponse.status(errorResponse.getStatusCode()) .bodyValue(errorResponse.getBody()) .flatMap(response -> response.writeTo(exchange, context)); }); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorUtils.java ================================================ package run.halo.app.security.authentication.twofactor; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import org.apache.commons.lang3.StringUtils; import run.halo.app.core.extension.User; public enum TwoFactorUtils { ; public static TwoFactorAuthSettings getTwoFactorAuthSettings(User user) { var spec = user.getSpec(); var tfaEnabled = defaultIfNull(spec.getTwoFactorAuthEnabled(), false); var emailVerified = spec.isEmailVerified(); var totpEncryptedSecret = spec.getTotpEncryptedSecret(); var settings = new TwoFactorAuthSettings(); settings.setEnabled(tfaEnabled); settings.setEmailVerified(emailVerified); settings.setTotpConfigured(StringUtils.isNotBlank(totpEncryptedSecret)); return settings; } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/twofactor/totp/DefaultTotpAuthService.java ================================================ package run.halo.app.security.authentication.twofactor.totp; import static com.j256.twofactorauth.TimeBasedOneTimePasswordUtil.generateBase32Secret; import static com.j256.twofactorauth.TimeBasedOneTimePasswordUtil.validateCurrentNumber; import static java.nio.file.StandardOpenOption.APPEND; import static java.nio.file.StandardOpenOption.CREATE; import static java.nio.file.StandardOpenOption.READ; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.UnrecoverableEntryException; import java.security.cert.CertificateException; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.codec.Hex; import org.springframework.security.crypto.encrypt.AesBytesEncryptor; import org.springframework.security.crypto.encrypt.BytesEncryptor; import org.springframework.security.crypto.keygen.KeyGenerators; import org.springframework.stereotype.Component; import run.halo.app.infra.properties.HaloProperties; @Slf4j @Component public class DefaultTotpAuthService implements TotpAuthService { private final BytesEncryptor encryptor; public DefaultTotpAuthService(HaloProperties haloProperties) { // init secret key var keysRoot = haloProperties.getWorkDir().resolve("keys"); this.encryptor = loadOrCreateEncryptor(keysRoot); } private BytesEncryptor loadOrCreateEncryptor(Path keysRoot) { try { if (Files.notExists(keysRoot)) { Files.createDirectories(keysRoot); } var keyStorePath = keysRoot.resolve("halo.keystore"); var password = "changeit".toCharArray(); var keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); if (Files.notExists(keyStorePath)) { keyStore.load(null, password); } else { try (var is = Files.newInputStream(keyStorePath, READ)) { keyStore.load(is, password); } } var alias = "totp-secret-key"; var entry = keyStore.getEntry(alias, new KeyStore.PasswordProtection(password)); SecretKey secretKey = null; if (entry instanceof KeyStore.SecretKeyEntry secretKeyEntry) { if ("AES".equalsIgnoreCase(secretKeyEntry.getSecretKey().getAlgorithm())) { secretKey = secretKeyEntry.getSecretKey(); } } if (secretKey == null) { var generator = KeyGenerator.getInstance("AES"); generator.init(128); secretKey = generator.generateKey(); var secretKeyEntry = new KeyStore.SecretKeyEntry(secretKey); keyStore.setEntry(alias, secretKeyEntry, new KeyStore.PasswordProtection(password)); try (var os = Files.newOutputStream(keyStorePath, CREATE, APPEND)) { keyStore.store(os, password); } } return new AesBytesEncryptor(secretKey, KeyGenerators.secureRandom(32), AesBytesEncryptor.CipherAlgorithm.GCM); } catch (IOException | KeyStoreException | CertificateException | NoSuchAlgorithmException | UnrecoverableEntryException e) { throw new RuntimeException("Failed to initialize AesBytesEncryptor", e); } } @Override public boolean validateTotp(String rawSecret, int code) { try { return validateCurrentNumber(rawSecret, code, 10 * 1000); } catch (GeneralSecurityException e) { log.warn("Error occurred when validate TOTP code", e); return false; } } @Override public String generateTotpSecret() { return generateBase32Secret(32); } @Override public String encryptSecret(String rawSecret) { return new String(Hex.encode(encryptor.encrypt(rawSecret.getBytes()))); } @Override public String decryptSecret(String encryptedSecret) { return new String(encryptor.decrypt(Hex.decode(encryptedSecret))); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthService.java ================================================ package run.halo.app.security.authentication.twofactor.totp; public interface TotpAuthService { boolean validateTotp(String rawSecret, int code); String generateTotpSecret(); String encryptSecret(String rawSecret); String decryptSecret(String encryptedSecret); } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationManager.java ================================================ package run.halo.app.security.authentication.twofactor.totp; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.CredentialsContainer; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import reactor.core.publisher.Mono; import run.halo.app.security.HaloUserDetails; import run.halo.app.security.authentication.exception.TwoFactorAuthException; import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; /** * TOTP authentication manager. * * @author johnniang */ @Slf4j public class TotpAuthenticationManager implements ReactiveAuthenticationManager { private final TotpAuthService totpAuthService; public TotpAuthenticationManager(TotpAuthService totpAuthService) { this.totpAuthService = totpAuthService; } @Override public Mono authenticate(Authentication authentication) { // it should be TotpAuthenticationToken var code = (Integer) authentication.getCredentials(); log.debug("Got TOTP code {}", code); // get user details return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .cast(TwoFactorAuthentication.class) .map(TwoFactorAuthentication::getPrevious) .flatMap(previousAuth -> { var principal = previousAuth.getPrincipal(); if (!(principal instanceof HaloUserDetails user)) { return Mono.error( new TwoFactorAuthException("Invalid authentication principal.") ); } var totpEncryptedSecret = user.getTotpEncryptedSecret(); if (StringUtils.isBlank(totpEncryptedSecret)) { return Mono.error( new TwoFactorAuthException("TOTP secret not configured.") ); } var rawSecret = totpAuthService.decryptSecret(totpEncryptedSecret); var validated = totpAuthService.validateTotp(rawSecret, code); if (!validated) { return Mono.error(new TwoFactorAuthException("Invalid TOTP code " + code)); } if (log.isDebugEnabled()) { log.debug( "TOTP authentication for {} with code {} successfully.", previousAuth.getName(), code); } if (previousAuth instanceof CredentialsContainer container) { container.eraseCredentials(); } return Mono.just(previousAuth); }); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationToken.java ================================================ package run.halo.app.security.authentication.twofactor.totp; import java.util.List; import org.springframework.security.authentication.AbstractAuthenticationToken; public class TotpAuthenticationToken extends AbstractAuthenticationToken { private final int code; public TotpAuthenticationToken(int code) { super(List.of()); this.code = code; } public int getCode() { return code; } @Override public Object getCredentials() { return getCode(); } @Override public Object getPrincipal() { return getCode(); } @Override public boolean isAuthenticated() { return false; } } ================================================ FILE: application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpCodeAuthenticationConverter.java ================================================ package run.halo.app.security.authentication.twofactor.totp; import org.apache.commons.lang3.StringUtils; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.security.authentication.exception.TwoFactorAuthException; import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; /** * TOTP code authentication converter. * * @author johnniang */ public class TotpCodeAuthenticationConverter implements ServerAuthenticationConverter { private final String codeParameter = "code"; @Override public Mono convert(ServerWebExchange exchange) { // Check the request is authenticated before. return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .filter(TwoFactorAuthentication.class::isInstance) .switchIfEmpty(Mono.error( () -> new TwoFactorAuthException( "MFA Authentication required." )) ) .flatMap(authentication -> exchange.getFormData()) .handle((formData, sink) -> { var codeStr = formData.getFirst(codeParameter); if (StringUtils.isBlank(codeStr)) { sink.error(new TwoFactorAuthException( "Empty code parameter." )); return; } try { var code = Integer.parseInt(codeStr); sink.next(new TotpAuthenticationToken(code)); } catch (NumberFormatException e) { sink.error(new TwoFactorAuthException( "Invalid code parameter " + codeStr + '.') ); } }); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authorization/Attributes.java ================================================ package run.halo.app.security.authorization; /** * Attributes is used by an Authorizer to get information about a request * that is used to make an authorization decision. * * @author guqing * @since 2.0.0 */ public interface Attributes { /** * @return the verb associated with API requests(this includes get, list, * watch, create, update, patch, delete, deletecollection, and proxy) * or the lower-cased HTTP verb associated with non-API requests(this * includes get, put, post, patch, and delete) */ String getVerb(); /** * @return when isReadOnly() == true, the request has no side effects, other than * caching, logging, and other incidentals. */ boolean isReadOnly(); /** * @return The kind of object, if a request is for a REST object. */ String getResource(); /** * @return the subresource being requested, if present. */ String getSubresource(); /** * @return the name of the object as parsed off the request. This will not be * present for all request types, but will be present for: get, update, delete */ String getName(); /** * @return The group of the resource, if a request is for a REST object. */ String getApiGroup(); /** * @return the version of the group requested, if a request is for a REST object. */ String getApiVersion(); /** * @return true for requests to API resources, like /api/v1/nodes, * and false for non-resource endpoints like /api, /healthz */ boolean isResourceRequest(); /** * @return returns the path of the request */ String getPath(); String getSubName(); String getUserSpace(); } ================================================ FILE: application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java ================================================ package run.halo.app.security.authorization; /** * @author guqing * @since 2.0.0 */ public class AttributesRecord implements Attributes { private final RequestInfo requestInfo; public AttributesRecord(RequestInfo requestInfo) { this.requestInfo = requestInfo; } @Override public String getVerb() { return requestInfo.getVerb(); } @Override public boolean isReadOnly() { String verb = requestInfo.getVerb(); return "get".equals(verb) || "list".equals(verb) || "watch".equals(verb); } @Override public String getResource() { return requestInfo.getResource(); } @Override public String getSubresource() { return requestInfo.getSubresource(); } @Override public String getName() { return requestInfo.getName(); } @Override public String getApiGroup() { return requestInfo.getApiGroup(); } @Override public String getApiVersion() { return requestInfo.getApiVersion(); } @Override public boolean isResourceRequest() { return requestInfo.isResourceRequest(); } @Override public String getPath() { return requestInfo.getPath(); } @Override public String getSubName() { return requestInfo.getSubName(); } @Override public String getUserSpace() { return requestInfo.getUserspace(); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java ================================================ package run.halo.app.security.authorization; import java.util.Collection; import java.util.Set; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.springframework.security.core.GrantedAuthority; /** * Utility methods for manipulating GrantedAuthority collection. * * @author johnniang */ public enum AuthorityUtils { ; public static final String SCOPE_PREFIX = "SCOPE_"; public static final String ROLE_PREFIX = "ROLE_"; public static final String SUPER_ROLE_NAME = "super-role"; public static final String AUTHENTICATED_ROLE_NAME = "authenticated"; public static final String ANONYMOUS_ROLE_NAME = "anonymous"; public static final String COMMENT_MANAGEMENT_ROLE_NAME = "role-template-manage-comments"; public static final String POST_CONTRIBUTOR_ROLE_NAME = "role-template-post-contributor"; public static final String THEME_MANAGEMENT_ROLE_NAME = "role-template-manage-themes"; /** * Converts an array of GrantedAuthority objects to a role set. * * @return a Set of the Strings obtained from each call to * GrantedAuthority.getAuthority() and filtered by prefix "ROLE_". */ public static Set authoritiesToRoles( Collection authorities) { return authorities.stream() .map(GrantedAuthority::getAuthority) .filter(authority -> StringUtils.startsWith(authority, ROLE_PREFIX)) .map(authority -> { authority = StringUtils.removeStart(authority, ROLE_PREFIX); return authority; }) .collect(Collectors.toSet()); } public static boolean containsSuperRole(Collection roles) { return roles.contains(SUPER_ROLE_NAME); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authorization/AuthorizationExchangeConfigurers.java ================================================ package run.halo.app.security.authorization; import java.util.Collections; import org.springframework.context.annotation.Bean; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.security.authentication.AuthenticationTrustResolver; import org.springframework.security.authentication.AuthenticationTrustResolverImpl; import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.AuthorizationResult; import org.springframework.security.core.Authentication; import org.springframework.security.web.server.authentication.SwitchUserWebFilter; import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.core.user.service.RoleService; import run.halo.app.security.authentication.SecurityConfigurer; import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; /** * Authorization exchange configurers. * * @author johnniang * @since 2.20.0 */ @Component class AuthorizationExchangeConfigurers { private final AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl(); @Bean @Order(0) SecurityConfigurer apiAuthorizationConfigurer(RoleService roleService) { return http -> http.authorizeExchange( spec -> spec.pathMatchers("/api/**", "/apis/**", "/actuator/**") .access(new RequestInfoAuthorizationManager(roleService))); } @Bean @Order(100) SecurityConfigurer unauthenticatedAuthorizationConfigurer() { return http -> http.authorizeExchange(spec -> { spec.pathMatchers(HttpMethod.GET, "/login", "/signup") .access((authentication, context) -> authentication.map( a -> !authenticationTrustResolver.isAuthenticated(a) ) .defaultIfEmpty(true) .map(AuthorizationDecision::new)); }); } @Bean @Order(200) SecurityConfigurer preAuthenticationAuthorizationConfigurer() { return http -> http.authorizeExchange(spec -> spec .pathMatchers("/login/impersonate") .hasRole(AuthorityUtils.SUPER_ROLE_NAME) .pathMatchers("/logout/impersonate") .hasAuthority(SwitchUserWebFilter.ROLE_PREVIOUS_ADMINISTRATOR) .pathMatchers("/challenges/**") .access((authentication, context) -> authentication.map(TwoFactorAuthentication.class::isInstance) .map(AuthorizationDecision::new) .switchIfEmpty(Mono.fromSupplier(() -> new AuthorizationDecision(false))) ) .pathMatchers( "/login/**", "/password-reset/**", "/signup" ) .permitAll() .pathMatchers("/logout") .access((authentication, context) -> authentication.map(a -> !authenticationTrustResolver.isAnonymous(a)) .map(AuthorizationDecision::new) .switchIfEmpty(Mono.fromSupplier(() -> new AuthorizationDecision(false))) ) ); } @Bean @Order(300) SecurityConfigurer authenticatedAuthorizationConfigurer() { // Anonymous user is not allowed return http -> http.authorizeExchange( spec -> spec.pathMatchers( "/console/**", "/uc/**" ) .authenticated() ); } @Bean @Order(400) SecurityConfigurer anonymousOrAuthenticatedAuthorizationConfigurer() { return http -> http.authorizeExchange( spec -> spec.matchers(createHtmlMatcher()).access((authentication, context) -> // we only need to check the authentication is authenticated // because we treat anonymous user as authenticated authentication.map(Authentication::isAuthenticated) .map(AuthorizationDecision::new) .switchIfEmpty(Mono.fromSupplier(() -> new AuthorizationDecision(false))) ) ); } @Bean @Order SecurityConfigurer permitAllAuthorizationConfigurer() { return http -> http.authorizeExchange(spec -> spec.anyExchange().permitAll()); } private static ServerWebExchangeMatcher createHtmlMatcher() { ServerWebExchangeMatcher get = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/**"); ServerWebExchangeMatcher notFavicon = new NegatedServerWebExchangeMatcher( ServerWebExchangeMatchers.pathMatchers("/favicon.*")); MediaTypeServerWebExchangeMatcher html = new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML); html.setIgnoredMediaTypes(Collections.singleton(MediaType.ALL)); return new AndServerWebExchangeMatcher(get, notFavicon, html); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authorization/AuthorizationRuleResolver.java ================================================ package run.halo.app.security.authorization; import org.springframework.security.core.Authentication; import reactor.core.publisher.Mono; /** * @author guqing * @since 2.0.0 */ public interface AuthorizationRuleResolver { Mono visitRules(Authentication authentication, RequestInfo requestInfo); } ================================================ FILE: application/src/main/java/run/halo/app/security/authorization/AuthorizingVisitor.java ================================================ package run.halo.app.security.authorization; import java.util.ArrayList; import java.util.List; import run.halo.app.core.extension.Role; /** * authorizing visitor short-circuits once allowed, and collects any resolution errors encountered. * * @author guqing * @since 2.0.0 */ public class AuthorizingVisitor implements RuleAccumulator { private final RbacRequestEvaluation requestEvaluation = new RbacRequestEvaluation(); private final Attributes requestAttributes; private boolean allowed; private String reason; private final List errors = new ArrayList<>(4); public AuthorizingVisitor(Attributes requestAttributes) { this.requestAttributes = requestAttributes; } @Override public boolean visit(String source, Role.PolicyRule rule, Throwable error) { if (rule != null && requestEvaluation.ruleAllows(requestAttributes, rule)) { this.allowed = true; this.reason = String.format("RBAC: allowed by %s", source); return false; } if (error != null) { this.errors.add(error); } return true; } public boolean isAllowed() { return allowed; } public String getReason() { return reason; } public List getErrors() { return errors; } } ================================================ FILE: application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java ================================================ package run.halo.app.security.authorization; import java.util.concurrent.atomic.AtomicBoolean; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.security.core.Authentication; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Mono; import run.halo.app.core.user.service.RoleService; /** * @author guqing * @since 2.0.0 */ @Data @Slf4j public class DefaultRuleResolver implements AuthorizationRuleResolver { private RoleService roleService; public DefaultRuleResolver(RoleService roleService) { this.roleService = roleService; } @Override public Mono visitRules(Authentication authentication, RequestInfo requestInfo) { var roleNames = AuthorityUtils.authoritiesToRoles(authentication.getAuthorities()); var record = new AttributesRecord(requestInfo); var visitor = new AuthorizingVisitor(record); // If the request is an userspace scoped request, // then we should check whether the user is the owner of the userspace. if (StringUtils.isNotBlank(requestInfo.getUserspace())) { if (!authentication.getName().equals(requestInfo.getUserspace())) { return Mono.fromSupplier(() -> { visitor.visit(null, null, null); return visitor; }); } } var stopVisiting = new AtomicBoolean(false); return roleService.listDependenciesFlux(roleNames) .filter(role -> !CollectionUtils.isEmpty(role.getRules())) .doOnNext(role -> { if (stopVisiting.get()) { return; } String roleName = role.getMetadata().getName(); var rules = role.getRules(); var source = roleBindingDescriber(roleName, authentication.getName()); for (var rule : rules) { if (!visitor.visit(source, rule, null)) { stopVisiting.set(true); return; } } }) .takeUntil(item -> stopVisiting.get()) .onErrorResume(t -> visitor.visit(null, null, t), t -> { log.error("Error occurred when visiting rules", t); //Do nothing here return Mono.empty(); }) .then(Mono.just(visitor)); } String roleBindingDescriber(String roleName, String subject) { return String.format("Binding role [%s] to [%s]", roleName, subject); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authorization/PolicyRuleList.java ================================================ package run.halo.app.security.authorization; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import run.halo.app.core.extension.Role; /** * @author guqing * @since 2.0.0 */ public class PolicyRuleList extends LinkedList { private final List errors = new ArrayList<>(4); /** * @return true if an error occurred when parsing PolicyRules */ public boolean hasErrors() { return !errors.isEmpty(); } public List getErrors() { return errors; } public PolicyRuleList addError(Throwable error) { errors.add(error); return this; } public PolicyRuleList addErrors(List errors) { this.errors.addAll(errors); return this; } } ================================================ FILE: application/src/main/java/run/halo/app/security/authorization/RbacRequestEvaluation.java ================================================ package run.halo.app.security.authorization; import java.util.List; import java.util.Objects; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import run.halo.app.core.extension.Role; /** * @author guqing * @since 2.0.0 */ public class RbacRequestEvaluation { interface WildCard { String APIGroupAll = "*"; String ResourceAll = "*"; String VerbAll = "*"; String NonResourceAll = "*"; } public boolean rulesAllow(Attributes requestAttributes, List rules) { for (Role.PolicyRule rule : rules) { if (ruleAllows(requestAttributes, rule)) { return true; } } return false; } protected boolean ruleAllows(Attributes requestAttributes, Role.PolicyRule rule) { if (requestAttributes.isResourceRequest()) { String combinedResource = requestAttributes.getResource(); if (StringUtils.isNotBlank(requestAttributes.getSubresource())) { combinedResource = requestAttributes.getResource() + "/" + requestAttributes.getSubresource(); } return verbMatches(rule, requestAttributes.getVerb()) && apiGroupMatches(rule, requestAttributes.getApiGroup()) && resourceMatches(rule, combinedResource, requestAttributes.getSubresource()) && resourceNameMatches(rule, combineResourceName(requestAttributes.getName(), requestAttributes.getSubName())); } return verbMatches(rule, requestAttributes.getVerb()) && nonResourceURLMatches(rule, requestAttributes.getPath()); } private String combineResourceName(String name, String subName) { if (StringUtils.isBlank(name)) { return subName; } if (StringUtils.isBlank(subName)) { return name; } return name + "/" + subName; } protected boolean verbMatches(Role.PolicyRule rule, String requestedVerb) { for (String ruleVerb : rule.getVerbs()) { if (Objects.equals(ruleVerb, WildCard.VerbAll)) { return true; } if (Objects.equals(ruleVerb, requestedVerb)) { return true; } } return false; } protected boolean apiGroupMatches(Role.PolicyRule rule, String requestedGroup) { for (String ruleGroup : rule.getApiGroups()) { if (Objects.equals(ruleGroup, WildCard.APIGroupAll)) { return true; } if (Objects.equals(ruleGroup, requestedGroup)) { return true; } } return false; } protected boolean resourceMatches(Role.PolicyRule rule, String combinedRequestedResource, String requestedSubresource) { for (String ruleResource : rule.getResources()) { // if everything is allowed, we match if (Objects.equals(ruleResource, WildCard.ResourceAll)) { return true; } // if we have an exact match, we match if (Objects.equals(ruleResource, combinedRequestedResource)) { return true; } // We can also match a */subresource. // if there isn't a subresource, then continue if (StringUtils.isBlank(requestedSubresource)) { continue; } // if the rule isn't in the format */subresource, then we don't match, continue if (StringUtils.length(ruleResource) == StringUtils.length(requestedSubresource) + 2 && StringUtils.startsWith(ruleResource, "*/") && StringUtils.startsWith(ruleResource, requestedSubresource)) { return true; } } return false; } protected boolean resourceNameMatches(Role.PolicyRule rule, String requestedName) { if (ArrayUtils.isEmpty(rule.getResourceNames())) { return true; } String[] requestedNameParts = ArrayUtils.nullToEmpty(StringUtils.split(requestedName, "/")); for (String ruleName : rule.getResourceNames()) { String[] patternParts = StringUtils.split(ruleName, "/"); for (int i = 0; i < patternParts.length; i++) { String patternPart = patternParts[i]; String textPart = StringUtils.EMPTY; if (requestedNameParts.length > i) { textPart = requestedNameParts[i]; } if (!matchPart(patternPart, textPart)) { return false; } } return true; } return false; } private static boolean matchPart(String patternPart, String textPart) { if (patternPart.equals("*")) { return true; } else if (patternPart.startsWith("*")) { return textPart.endsWith(patternPart.substring(1)); } else if (patternPart.endsWith("*")) { return textPart.startsWith(patternPart.substring(0, patternPart.length() - 1)); } else { return patternPart.equals(textPart); } } protected boolean nonResourceURLMatches(Role.PolicyRule rule, String requestedURL) { for (String ruleURL : rule.getNonResourceURLs()) { if (Objects.equals(ruleURL, WildCard.NonResourceAll)) { return true; } if (Objects.equals(ruleURL, requestedURL)) { return true; } if (StringUtils.endsWith(ruleURL, WildCard.NonResourceAll) && StringUtils.startsWith(requestedURL, StringUtils.stripEnd(ruleURL, WildCard.NonResourceAll))) { return true; } } return false; } } ================================================ FILE: application/src/main/java/run/halo/app/security/authorization/RequestInfo.java ================================================ package run.halo.app.security.authorization; import java.util.Objects; import lombok.Getter; import lombok.ToString; import org.apache.commons.lang3.StringUtils; import org.springframework.http.server.reactive.ServerHttpRequest; /** * RequestInfo holds information parsed from the {@link ServerHttpRequest}. * * @author guqing * @since 2.0.0 */ @Getter @ToString public class RequestInfo { boolean isResourceRequest; final String path; String namespace; String userspace; String verb; String apiPrefix; String apiGroup; String apiVersion; String resource; String name; String subresource; String subName; String[] parts; public RequestInfo(boolean isResourceRequest, String path, String verb) { this(isResourceRequest, path, null, null, verb, null, null, null, null, null, null, null, null); } public RequestInfo(boolean isResourceRequest, String path, String namespace, String userspace, String verb, String apiPrefix, String apiGroup, String apiVersion, String resource, String name, String subresource, String subName, String[] parts) { this.isResourceRequest = isResourceRequest; this.path = StringUtils.defaultString(path); this.namespace = StringUtils.defaultString(namespace); this.userspace = StringUtils.defaultString(userspace); this.verb = StringUtils.defaultString(verb); this.apiPrefix = StringUtils.defaultString(apiPrefix); this.apiGroup = StringUtils.defaultString(apiGroup); this.apiVersion = StringUtils.defaultString(apiVersion); this.resource = StringUtils.defaultString(resource); this.subresource = StringUtils.defaultString(subresource); this.subName = StringUtils.defaultString(subName); this.name = StringUtils.defaultString(name); this.parts = Objects.requireNonNullElseGet(parts, () -> new String[] {}); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java ================================================ package run.halo.app.security.authorization; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.AuthorizationResult; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.core.Authentication; import org.springframework.security.web.server.authorization.AuthorizationContext; import reactor.core.publisher.Mono; import run.halo.app.core.user.service.RoleService; @Slf4j public class RequestInfoAuthorizationManager implements ReactiveAuthorizationManager { private final AuthorizationRuleResolver ruleResolver; public RequestInfoAuthorizationManager(RoleService roleService) { this.ruleResolver = new DefaultRuleResolver(roleService); } @Override public Mono authorize(Mono authentication, AuthorizationContext context) { var request = context.getExchange().getRequest(); var requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); // We allow anonymous user to access some resources // so we don't invoke AuthenticationTrustResolver.isAuthenticated // to check if the user is authenticated return authentication.filter(Authentication::isAuthenticated) .flatMap(auth -> ruleResolver.visitRules(auth, requestInfo)) .doOnNext(visitor -> showErrorMessage(visitor.getErrors())) .map(AuthorizingVisitor::isAllowed) .defaultIfEmpty(false) .map(AuthorizationDecision::new); } private void showErrorMessage(List errors) { if (errors != null) { errors.forEach(error -> log.error("Access decision error", error)); } } } ================================================ FILE: application/src/main/java/run/halo/app/security/authorization/RequestInfoFactory.java ================================================ package run.halo.app.security.authorization; import java.util.Arrays; import java.util.Objects; import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.springframework.http.server.PathContainer; import org.springframework.http.server.reactive.ServerHttpRequest; import run.halo.app.infra.ui.WebSocketUtils; /** * Creates {@link RequestInfo} from {@link ServerHttpRequest}. * * @author guqing * @since 2.0.0 */ public class RequestInfoFactory { public static final RequestInfoFactory INSTANCE = new RequestInfoFactory(Set.of("api", "apis"), Set.of("api")); /** * without leading and trailing slashes. */ final Set apiPrefixes; /** * without leading and trailing slashes. */ final Set grouplessApiPrefixes; /** * special verbs no subresources. */ final Set specialVerbs; public RequestInfoFactory(Set apiPrefixes, Set grouplessApiPrefixes) { this(apiPrefixes, grouplessApiPrefixes, Set.of("proxy", "watch")); } public RequestInfoFactory(Set apiPrefixes, Set grouplessApiPrefixes, Set specialVerbs) { this.apiPrefixes = apiPrefixes; this.grouplessApiPrefixes = grouplessApiPrefixes; this.specialVerbs = specialVerbs; } /** *

newRequestInfo returns the information from the http request. If error is not occurred, * RequestInfo holds the information as best it is known before the failure * It handles both resource and non-resource requests and fills in all the pertinent * information.

*

for each.

* Valid Inputs: *

Resource paths

*
     * /apis/{api-group}/{version}/namespaces
     * /api/{version}/namespaces
     * /api/{version}/namespaces/{namespace}
     * /api/{version}/namespaces/{namespace}/{resource}
     * /api/{version}/namespaces/{namespace}/{resource}/{resourceName}
     * /api/{version}/userspaces/{userspace}/{resource}
     * /api/{version}/userspaces/{userspace}/{resource}/{resourceName}
     * /api/{version}/{resource}
     * /api/{version}/{resource}/{resourceName}
     * 
*

Special verbs without subresources:

*
     * /api/{version}/proxy/{resource}/{resourceName}
     * /api/{version}/proxy/namespaces/{namespace}/{resource}/{resourceName}
     * 
* *

Special verbs with subresources:

*
     * /api/{version}/watch/{resource}
     * /api/{version}/watch/namespaces/{namespace}/{resource}
     * 
* *

NonResource paths:

*
     * /apis/{api-group}/{version}
     * /apis/{api-group}
     * /apis
     * /api/{version}
     * /api
     * /healthz
     * 
* * @param request http request * @return request holds the information of both resource and non-resource requests */ public RequestInfo newRequestInfo(ServerHttpRequest request) { // non-resource request default PathContainer path = request.getPath().pathWithinApplication(); RequestInfo requestInfo = new RequestInfo(false, path.value(), request.getMethod().name().toLowerCase()); String[] currentParts = splitPath(path.value()); if (currentParts.length < 3) { // return a non-resource request return requestInfo; } if (!apiPrefixes.contains(currentParts[0])) { // return a non-resource request return requestInfo; } requestInfo.apiPrefix = currentParts[0]; currentParts = Arrays.copyOfRange(currentParts, 1, currentParts.length); if (!grouplessApiPrefixes.contains(requestInfo.apiPrefix)) { // one part (APIPrefix) has already been consumed, so this is actually "do we have // four parts?" if (currentParts.length < 3) { // return a non-resource request return requestInfo; } requestInfo.apiGroup = StringUtils.defaultString(currentParts[0]); currentParts = Arrays.copyOfRange(currentParts, 1, currentParts.length); } requestInfo.isResourceRequest = true; requestInfo.apiVersion = currentParts[0]; currentParts = Arrays.copyOfRange(currentParts, 1, currentParts.length); // handle input of form /{specialVerb}/* Set specialVerbs = Set.of("proxy", "watch"); if (specialVerbs.contains(currentParts[0])) { if (currentParts.length < 2) { throw new IllegalArgumentException( String.format("unable to determine kind and namespace from url, %s", request.getPath())); } requestInfo.verb = currentParts[0]; currentParts = Arrays.copyOfRange(currentParts, 1, currentParts.length); } else { requestInfo.verb = switch (request.getMethod().name().toUpperCase()) { case "POST" -> "create"; case "GET", "HEAD" -> "get"; case "PUT" -> "update"; case "PATCH" -> "patch"; case "DELETE" -> "delete"; default -> ""; }; } // URL forms: /namespaces/{namespace}/{kind}/*, where parts are adjusted to be relative // to kind Set namespaceSubresources = Set.of("status", "finalize"); if (Objects.equals(currentParts[0], "namespaces")) { if (currentParts.length > 1) { requestInfo.namespace = currentParts[1]; // if there is another step after the namespace name and it is not a known // namespace subresource // move currentParts to include it as a resource in its own right if (currentParts.length > 2 && !namespaceSubresources.contains(currentParts[2])) { currentParts = Arrays.copyOfRange(currentParts, 2, currentParts.length); } } } else if ("userspaces".equals(currentParts[0])) { if (currentParts.length > 1) { requestInfo.userspace = currentParts[1]; // if there is another step after the userspace name // move currentParts to include it as a resource in its own right if (currentParts.length > 2) { currentParts = Arrays.copyOfRange(currentParts, 2, currentParts.length); } } } else { requestInfo.userspace = ""; requestInfo.namespace = ""; } // parsing successful, so we now know the proper value for .Parts requestInfo.parts = currentParts; // special verbs no subresources // parts look like: resource/resourceName/subresource/other/stuff/we/don't/interpret if (requestInfo.parts.length >= 3 && !specialVerbs.contains( requestInfo.verb)) { requestInfo.subresource = requestInfo.parts[2]; // if there is another step after the subresource name and it is not a known if (requestInfo.parts.length >= 4) { requestInfo.subName = requestInfo.parts[3]; } } if (requestInfo.parts.length >= 2) { requestInfo.name = requestInfo.parts[1]; } if (requestInfo.parts.length >= 1) { requestInfo.resource = requestInfo.parts[0]; } // has name and no subresource but verb=create, then this is a non-resource request if (StringUtils.isNotBlank(requestInfo.name) && StringUtils.isBlank(requestInfo.subresource) && "create".equals(requestInfo.verb)) { requestInfo.isResourceRequest = false; } // if there's no name on the request and we thought it was a get before, then the actual // verb is a list or a watch if (requestInfo.name.isEmpty() && "get".equals(requestInfo.verb)) { var watch = request.getQueryParams().getFirst("watch"); if (Boolean.parseBoolean(watch)) { requestInfo.verb = "watch"; } else { requestInfo.verb = "list"; } } // if there's no name on the request and we thought it was a deleted before, then the // actual verb is deletecollection if (Objects.equals(requestInfo.verb, "delete")) { var deleteAll = request.getQueryParams().getFirst("all"); if (Boolean.parseBoolean(deleteAll)) { requestInfo.verb = "deletecollection"; } } if ("list".equals(requestInfo.verb) && WebSocketUtils.isWebSocketUpgrade(request.getHeaders())) { requestInfo.verb = "watch"; } return requestInfo; } private String[] splitPath(String path) { path = StringUtils.strip(path, "/"); if (StringUtils.isEmpty(path)) { return new String[] {}; } return StringUtils.split(path, "/"); } } ================================================ FILE: application/src/main/java/run/halo/app/security/authorization/RuleAccumulator.java ================================================ package run.halo.app.security.authorization; import run.halo.app.core.extension.Role; /** * @author guqing * @since 2.0.0 */ public interface RuleAccumulator { boolean visit(String source, Role.PolicyRule rule, Throwable err); } ================================================ FILE: application/src/main/java/run/halo/app/security/device/DeviceCookieResolver.java ================================================ package run.halo.app.security.device; import java.time.Duration; import org.springframework.http.HttpCookie; import org.springframework.lang.Nullable; import org.springframework.web.server.ServerWebExchange; public interface DeviceCookieResolver { @Nullable HttpCookie resolveCookie(ServerWebExchange exchange); void setCookie(ServerWebExchange exchange, String value); void expireCookie(ServerWebExchange exchange); String getCookieName(); Duration getCookieMaxAge(); } ================================================ FILE: application/src/main/java/run/halo/app/security/device/DeviceCookieResolverImpl.java ================================================ package run.halo.app.security.device; import java.time.Duration; import lombok.Getter; import org.springframework.http.HttpCookie; import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.web.server.ServerWebExchange; @Getter @Component public class DeviceCookieResolverImpl implements DeviceCookieResolver { public static final String DEVICE_COOKIE_KEY = "device_id"; private final String cookieName = DEVICE_COOKIE_KEY; private final Duration cookieMaxAge = Duration.ofDays(100); @Override public HttpCookie resolveCookie(ServerWebExchange exchange) { return exchange.getRequest().getCookies().getFirst(getCookieName()); } @Override public void setCookie(ServerWebExchange exchange, String value) { Assert.notNull(value, "'value' is required"); exchange.getResponse().getCookies() .set(getCookieName(), initCookie(exchange, value).build()); } @Override public void expireCookie(ServerWebExchange exchange) { ResponseCookie cookie = initCookie(exchange, "").maxAge(0).build(); exchange.getResponse().getCookies().set(this.cookieName, cookie); } private ResponseCookie.ResponseCookieBuilder initCookie(ServerWebExchange exchange, String value) { return ResponseCookie.from(this.cookieName, value) .path(exchange.getRequest().getPath().contextPath().value() + "/") .maxAge(getCookieMaxAge()) .httpOnly(true) .secure("https".equalsIgnoreCase(exchange.getRequest().getURI().getScheme())) .sameSite("Lax"); } } ================================================ FILE: application/src/main/java/run/halo/app/security/device/DeviceEndpoint.java ================================================ package run.halo.app.security.device; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static run.halo.app.extension.index.query.Queries.equal; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import java.security.Principal; import java.util.Comparator; import java.util.Map; import lombok.Builder; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.experimental.Accessors; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.session.ReactiveFindByIndexNameSessionRepository; import org.springframework.session.Session; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Device; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.selector.FieldSelector; /** * Device endpoint for user profile,every user can only manage their own devices. * * @author guqing * @since 2.17.0 */ @Component @RequiredArgsConstructor public class DeviceEndpoint implements CustomEndpoint { private final ReactiveExtensionClient client; private final ReactiveFindByIndexNameSessionRepository sessionRepository; private final DeviceService deviceService; @Override public RouterFunction endpoint() { final var tag = "DeviceV1alpha1Uc"; return SpringdocRouteBuilder.route() .GET("devices", this::listDevices, builder -> builder.operationId("ListDevices") .description("List all user devices") .tag(tag) .response(responseBuilder().implementationArray(DeviceDto.class)) ) .DELETE("devices/{deviceId}", this::revokeDevice, builder -> builder .operationId("RevokeDevice") .description("Revoke a own device") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("deviceId") .description("Device ID") .required(true) ) .response(responseBuilder() .responseCode(String.valueOf(HttpStatus.NO_CONTENT)) ) ) .build(); } private Mono revokeDevice(ServerRequest request) { final var deviceId = request.pathVariable("deviceId"); return principalName() .flatMap(principalName -> deviceService.revoke(principalName, deviceId)) .then(ServerResponse.noContent().build()); } private Mono listDevices(ServerRequest request) { return getRequestContext(request) .flatMapMany(context -> { var listOptions = new ListOptions(); var query = equal("spec.principalName", context.username()); listOptions.setFieldSelector(FieldSelector.of(query)); return client.listAll(Device.class, listOptions, Sort.by("metadata.creationTimestamp")) .map(device -> { var sessionId = device.getSpec().getSessionId(); var session = context.sessionMap().get(sessionId); if (session != null) { device.getSpec().setLastAccessedTime(session.getLastAccessedTime()); } return new DeviceDto() .setDevice(device) .setCurrentDevice(context.sessionId().equals(sessionId)) .setActive(session != null && !session.isExpired()); }) .sort(deviceDtoComparator()); }) .collectList() .flatMap(deviceDto -> ServerResponse.ok().bodyValue(deviceDto)); } Comparator deviceDtoComparator() { return Comparator.comparing(DeviceDto::isCurrentDevice) .thenComparing(DeviceDto::isActive) .thenComparing(DeviceDto::getDevice, Comparator.comparing(device -> { var accessedTime = device.getSpec().getLastAccessedTime(); return accessedTime == null ? device.getMetadata().getCreationTimestamp() : accessedTime; })) .reversed(); } private Mono getRequestContext(ServerRequest request) { return principalName() .flatMap(principalName -> { var builder = RequestContext.builder() .sessionMap(Map.of()) .username(principalName); var sessionMapMono = sessionRepository.findByPrincipalName(principalName) .doOnNext(builder::sessionMap); var sessionMono = request.exchange().getSession() .doOnNext(session -> builder.sessionId(session.getId())); return Mono.when(sessionMapMono, sessionMono) .then(Mono.fromSupplier(builder::build)); }); } @Builder record RequestContext(String username, String sessionId, Map sessionMap) { } Mono principalName() { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(Principal::getName); } @Data @Accessors(chain = true) @Schema(name = "UserDevice") static class DeviceDto { @Schema(requiredMode = REQUIRED) private Device device; @Schema(requiredMode = REQUIRED) boolean currentDevice; @Schema(requiredMode = REQUIRED) boolean active; } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("uc.api.security.halo.run/v1alpha1"); } } ================================================ FILE: application/src/main/java/run/halo/app/security/device/DeviceReconciler.java ================================================ package run.halo.app.security.device; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.isDeleted; import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import static run.halo.app.extension.index.query.Queries.equal; import java.time.Duration; import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Sort; import org.springframework.session.ReactiveSessionRepository; import org.springframework.stereotype.Component; import run.halo.app.core.extension.Device; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.utils.ReactiveUtils; @Component @RequiredArgsConstructor public class DeviceReconciler implements Reconciler { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private static final int MAX_DEVICES = 10; static final String FINALIZER_NAME = "device-protection"; private final ReactiveSessionRepository sessionRepository; private final ExtensionClient client; @Override public Result reconcile(Request request) { client.fetch(Device.class, request.name()) .ifPresent(device -> { if (isDeleted(device)) { if (removeFinalizers(device.getMetadata(), Set.of(FINALIZER_NAME))) { sessionRepository.deleteById(device.getSpec().getSessionId()) .block(BLOCKING_TIMEOUT); client.update(device); } return; } if (addFinalizers(device.getMetadata(), Set.of(FINALIZER_NAME))) { client.update(device); } revokeInactiveDevices(device.getSpec().getPrincipalName()); }); return Result.doNotRetry(); } private void revokeInactiveDevices(String principalName) { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of( equal("spec.principalName", principalName)) ); client.listAll(Device.class, listOptions, Sort.by("metadata.creationTimestamp").descending()) .stream() .skip(MAX_DEVICES) .filter(device -> sessionRepository.findById(device.getSpec().getSessionId()) .blockOptional(BLOCKING_TIMEOUT) .isEmpty() ) .forEach(client::delete); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new Device()) .syncAllOnStart(false) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/security/device/DeviceSecurityConfigurer.java ================================================ package run.halo.app.security.device; import lombok.RequiredArgsConstructor; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.stereotype.Component; import run.halo.app.security.authentication.SecurityConfigurer; @Component @RequiredArgsConstructor class DeviceSecurityConfigurer implements SecurityConfigurer { private final DeviceService deviceService; @Override public void configure(ServerHttpSecurity http) { var filter = new DeviceSessionFilter(deviceService); http.addFilterAfter(filter, SecurityWebFiltersOrder.REACTOR_CONTEXT); } } ================================================ FILE: application/src/main/java/run/halo/app/security/device/DeviceServiceImpl.java ================================================ package run.halo.app.security.device; import static run.halo.app.extension.ExtensionUtil.defaultSort; import static run.halo.app.infra.utils.IpAddressUtils.getClientIp; import static run.halo.app.security.authentication.rememberme.PersistentTokenBasedRememberMeServices.REMEMBER_ME_SERIES_REQUEST_NAME; import java.security.Principal; import java.time.Duration; import java.time.Instant; import java.util.UUID; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.AuthenticationTrustResolver; import org.springframework.security.authentication.AuthenticationTrustResolverImpl; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.session.ReactiveSessionRepository; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.extension.Device; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.Queries; import run.halo.app.security.authentication.rememberme.PersistentRememberMeTokenRepository; @Slf4j @Component @RequiredArgsConstructor public class DeviceServiceImpl implements DeviceService { private final ReactiveExtensionClient client; private final DeviceCookieResolver deviceCookieResolver; private final ReactiveSessionRepository sessionRepository; private final ApplicationEventPublisher eventPublisher; private final PersistentRememberMeTokenRepository rememberMeTokenRepository; private final AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl(); @Override public Mono loginSuccess(ServerWebExchange exchange, Authentication authentication) { return updateExistingDevice(exchange, authentication) .switchIfEmpty(createDevice(exchange, authentication) .flatMap(client::create) .doOnNext(device -> { deviceCookieResolver.setCookie(exchange, device.getMetadata().getName()); eventPublisher.publishEvent(new NewDeviceLoginEvent(this, device)); }) ) .then(); } @Override public Mono changeSessionId(ServerWebExchange exchange) { var deviceIdCookie = deviceCookieResolver.resolveCookie(exchange); if (deviceIdCookie == null) { return Mono.empty(); } return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .filter(trustResolver::isAuthenticated) .map(Principal::getName) .flatMap(username -> { var deviceId = deviceIdCookie.getValue(); return updateWithRetry(deviceId, username, device -> { var oldSessionId = device.getSpec().getSessionId(); return exchange.getSession() .filter(session -> !session.getId().equals(oldSessionId)) .flatMap(session -> { device.getSpec().setSessionId(session.getId()); device.getSpec().setLastAccessedTime(session.getLastAccessTime()); return sessionRepository.deleteById(oldSessionId); }) .thenReturn(device); }).then(); }); } private Mono updateWithRetry(String deviceId, String username, Function> updateFunction) { return Mono.defer(() -> client.fetch(Device.class, deviceId) .filter(device -> device.getSpec().getPrincipalName().equals(username)) .flatMap(updateFunction) .flatMap(client::update) ) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)); } private Mono updateExistingDevice(ServerWebExchange exchange, Authentication authentication) { var deviceIdCookie = deviceCookieResolver.resolveCookie(exchange); if (deviceIdCookie == null) { return Mono.empty(); } var principalName = authentication.getName(); return updateWithRetry(deviceIdCookie.getValue(), principalName, (Device existingDevice) -> { var sessionId = existingDevice.getSpec().getSessionId(); return exchange.getSession() .flatMap(session -> { var userAgent = exchange.getRequest().getHeaders().getFirst(HttpHeaders.USER_AGENT); var deviceUa = existingDevice.getSpec().getUserAgent(); if (!StringUtils.equals(deviceUa, userAgent)) { // User agent changed, create a new device return Mono.empty(); } return Mono.just(session); }) .flatMap(session -> { if (session.getId().equals(sessionId)) { return Mono.just(session); } return sessionRepository.deleteById(sessionId).thenReturn(session); }) .map(session -> { existingDevice.getSpec().setSessionId(session.getId()); existingDevice.getSpec().setLastAccessedTime(session.getLastAccessTime()); existingDevice.getSpec().setLastAuthenticatedTime(Instant.now()); return existingDevice; }) .flatMap(this::removeRememberMeToken); }); } @Override public Mono revoke(String principalName, String deviceId) { return client.fetch(Device.class, deviceId) .filter(device -> device.getSpec().getPrincipalName().equals(principalName)) .flatMap(this::removeRememberMeToken) .flatMap(client::delete) .flatMap(revoked -> sessionRepository.deleteById(revoked.getSpec().getSessionId())); } @Override public Mono revoke(String username) { var listOptions = ListOptions.builder() .andQuery(Queries.equal("spec.principalName", username)) .build(); return client.listAll(Device.class, listOptions, defaultSort()) .flatMap(this::removeRememberMeToken) .flatMap(device -> sessionRepository.deleteById(device.getSpec().getSessionId()) .thenReturn(device) ) .flatMap(client::delete) .then(); } private Mono removeRememberMeToken(Device device) { var seriesId = device.getSpec().getRememberMeSeriesId(); if (StringUtils.isBlank(seriesId)) { return Mono.just(device); } log.debug("Removing remember-me token for seriesId: {}", seriesId); return rememberMeTokenRepository.removeToken(seriesId) .thenReturn(device); } Mono createDevice(ServerWebExchange exchange, Authentication authentication) { Assert.notNull(authentication, "Authentication must not be null."); return Mono.fromSupplier( () -> { var device = new Device(); device.setMetadata(new Metadata()); device.getMetadata().setName(generateDeviceId()); var userAgent = exchange.getRequest().getHeaders().getFirst(HttpHeaders.USER_AGENT); var deviceInfo = DeviceInfo.parse(userAgent); device.setSpec(new Device.Spec() .setUserAgent(userAgent) .setPrincipalName(authentication.getName()) .setLastAuthenticatedTime(Instant.now()) .setIpAddress(getClientIp(exchange.getRequest())) .setRememberMeSeriesId( exchange.getAttribute(REMEMBER_ME_SERIES_REQUEST_NAME)) ); device.getStatus() .setOs(deviceInfo.os()) .setBrowser(deviceInfo.browser()); return device; }) .flatMap(device -> exchange.getSession() .doOnNext(session -> { device.getSpec().setSessionId(session.getId()); device.getSpec().setLastAccessedTime(session.getLastAccessTime()); }) .thenReturn(device) ); } String generateDeviceId() { return UUID.randomUUID().toString() .replace("-", "").toLowerCase(); } record DeviceInfo(String browser, String os) { static final String UNKNOWN = "Unknown"; static final Pattern BROWSER_REGEX = Pattern.compile("(MSIE|Trident|Edge|Edg|OPR|Opera|Chrome|Safari|Firefox" + "|FxiOS|SamsungBrowser|UCBrowser|UCWEB|CriOS|Silk|Raven\\|Raven\\|)", Pattern.CASE_INSENSITIVE); static final Pattern BROWSER_VERSION_REGEX = Pattern.compile("(?:version/|chrome/|firefox/|safari/|msie " + "|rv:|opr/|edg/|ucbrowser/|samsungbrowser/|crios/|silk/)(\\d+\\.\\d+)", Pattern.CASE_INSENSITIVE); static final Pattern OS_REGEX = Pattern.compile( "(Windows NT|Mac OS X|Android|Linux|iPhone|iPad|Windows Phone|OpenHarmony)"); static final Pattern[] osRegexes = { Pattern.compile("Windows NT (\\d+\\.\\d+)"), Pattern.compile("Mac OS X (\\d+[\\._]\\d+([\\._]\\d+)?)"), Pattern.compile("iPhone OS (\\d+_\\d+(_\\d+)?)"), Pattern.compile("Android (\\d+\\.\\d+(\\.\\d+)?)"), Pattern.compile("OpenHarmony (\\d+\\.\\d+(\\.\\d+)?)") }; public static DeviceInfo parse(String userAgent) { return new DeviceInfo(concat(parseBrowser(userAgent).name(), parseBrowser(userAgent).version()), concat(parseOperatingSystem(userAgent).name(), parseOperatingSystem(userAgent).version()) ); } private static Pair parseBrowser(String userAgent) { Matcher matcher = BROWSER_REGEX.matcher(userAgent); if (matcher.find()) { String browserName = matcher.group(1); matcher = BROWSER_VERSION_REGEX.matcher(userAgent); if (matcher.find()) { String browserVersion = matcher.group(1); return new Pair(browserName, browserVersion); } else { return new Pair(browserName, null); } } else { return new Pair(UNKNOWN, null); } } record Pair(String name, String version) { } private static Pair parseOperatingSystem(String userAgent) { Matcher matcher = OS_REGEX.matcher(userAgent); var osName = UNKNOWN; if (matcher.find()) { osName = matcher.group(1); } var osVersion = parseOsVersion(userAgent); return new Pair(osName, osVersion); } private static String parseOsVersion(String userAgent) { for (Pattern pattern : osRegexes) { Matcher matcher = pattern.matcher(userAgent); if (matcher.find()) { return matcher.group(1).replace("_", "."); } } return ""; } private static String concat(String name, String version) { return StringUtils.isBlank(version) ? name : name + " " + version; } } } ================================================ FILE: application/src/main/java/run/halo/app/security/device/DeviceSessionFilter.java ================================================ package run.halo.app.security.device; import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.lang.NonNull; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; @Slf4j @RequiredArgsConstructor class DeviceSessionFilter implements WebFilter { private final DeviceService deviceService; @Override @NonNull public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { return exchange.getSession().flatMap(session -> { var previousId = session.getId(); return chain.filter(exchange) .then(Mono.defer(() -> { var currentId = session.getId(); if (Objects.equals(previousId, currentId)) { return Mono.empty(); } // only when session id changed log.debug("Session ID changed from {} to {}, updating device info.", previousId, currentId); return deviceService.changeSessionId(exchange); })); }); } } ================================================ FILE: application/src/main/java/run/halo/app/security/device/NewDeviceLoginEvent.java ================================================ package run.halo.app.security.device; import lombok.Getter; import org.springframework.context.ApplicationEvent; import run.halo.app.core.extension.Device; @Getter public class NewDeviceLoginEvent extends ApplicationEvent { private final Device device; public NewDeviceLoginEvent(Object source, Device device) { super(source); this.device = device; } } ================================================ FILE: application/src/main/java/run/halo/app/security/device/NewDeviceLoginListener.java ================================================ package run.halo.app.security.device; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import lombok.RequiredArgsConstructor; import org.springframework.context.event.EventListener; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Device; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.notification.NotificationCenter; import run.halo.app.notification.NotificationReasonEmitter; import run.halo.app.notification.ReasonAttributes; import run.halo.app.notification.UserIdentity; /** *

Sends a notification when a new device login,It listens for {@link NewDeviceLoginEvent} * asynchronously.

* * @author guqing * @since 2.17.0 */ @Component @RequiredArgsConstructor public class NewDeviceLoginListener { private static final String REASON_TYPE = "new-device-login"; private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss O").withZone(ZoneOffset.systemDefault()); private final NotificationCenter notificationCenter; private final NotificationReasonEmitter notificationReasonEmitter; @EventListener Mono onApplicationEvent(@NonNull NewDeviceLoginEvent event) { return subscribeForNewDeviceLoginReason(event.getDevice()) .then(sendNewDeviceNotification(event.getDevice())); } Mono sendNewDeviceNotification(Device device) { return notificationReasonEmitter.emit(REASON_TYPE, builder -> { var attributes = new ReasonAttributes(); attributes.put("principalName", device.getSpec().getPrincipalName()); attributes.put("os", device.getStatus().getOs()); attributes.put("browser", device.getStatus().getBrowser()); attributes.put("ipAddress", device.getSpec().getIpAddress()); attributes.put("loginTime", DATE_TIME_FORMATTER.format(device.getSpec().getLastAuthenticatedTime())); builder.attributes(attributes) .author(UserIdentity.of(device.getSpec().getPrincipalName())) .subject(Reason.Subject.builder() .apiVersion(Device.GROUP + "/" + Device.VERSION) .kind(Device.KIND) .name(device.getMetadata().getName()) .title("在新设备上登录") .build()); }); } Mono subscribeForNewDeviceLoginReason(Device device) { var principalName = device.getSpec().getPrincipalName(); var subscriber = new Subscription.Subscriber(); subscriber.setName(principalName); var reason = new Subscription.InterestReason(); reason.setReasonType(REASON_TYPE); reason.setExpression("props.principalName == '%s'".formatted(principalName)); return notificationCenter.subscribe(subscriber, reason) .then(); } } ================================================ FILE: application/src/main/java/run/halo/app/security/jackson2/HaloOAuth2AuthenticationTokenMixin.java ================================================ package run.halo.app.security.jackson2; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonTypeInfo; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import run.halo.app.security.authentication.oauth2.HaloOAuth2AuthenticationToken; /** * Mixin for {@link HaloOAuth2AuthenticationToken}. * * @author johnniang * @since 2.20.0 */ @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE) @JsonIgnoreProperties(ignoreUnknown = true) abstract class HaloOAuth2AuthenticationTokenMixin { @JsonCreator HaloOAuth2AuthenticationTokenMixin( @JsonProperty("userDetails") UserDetails userDetails, @JsonProperty("original") OAuth2AuthenticationToken original ) { } } ================================================ FILE: application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java ================================================ package run.halo.app.security.jackson2; import com.fasterxml.jackson.core.Version; import com.fasterxml.jackson.databind.module.SimpleModule; import org.springframework.security.jackson2.SecurityJackson2Modules; import org.springframework.security.web.authentication.switchuser.SwitchUserGrantedAuthority; import run.halo.app.security.authentication.login.HaloUser; import run.halo.app.security.authentication.oauth2.HaloOAuth2AuthenticationToken; import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; /** * Halo security Jackson2 module. * * @author johnniang */ public class HaloSecurityJackson2Module extends SimpleModule { public HaloSecurityJackson2Module() { super(HaloSecurityJackson2Module.class.getName(), new Version(1, 0, 0, null, null, null)); } @Override public void setupModule(SetupContext context) { SecurityJackson2Modules.enableDefaultTyping(context.getOwner()); context.setMixInAnnotations(HaloUser.class, HaloUserMixin.class); context.setMixInAnnotations( TwoFactorAuthentication.class, TwoFactorAuthenticationMixin.class ); context.setMixInAnnotations( HaloOAuth2AuthenticationToken.class, HaloOAuth2AuthenticationTokenMixin.class ); context.setMixInAnnotations( SwitchUserGrantedAuthority.class, SwitchUserGrantedAuthorityMixIn.class ); } } ================================================ FILE: application/src/main/java/run/halo/app/security/jackson2/HaloUserMixin.java ================================================ package run.halo.app.security.jackson2; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonTypeInfo; import org.springframework.security.core.userdetails.UserDetails; @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE) @JsonIgnoreProperties(ignoreUnknown = true) abstract class HaloUserMixin { HaloUserMixin(@JsonProperty("delegate") UserDetails delegate, @JsonProperty("twoFactorAuthEnabled") boolean twoFactorAuthEnabled, @JsonProperty("totpEncryptedSecret") String totpEncryptedSecret) { } } ================================================ FILE: application/src/main/java/run/halo/app/security/jackson2/SwitchUserGrantedAuthorityMixIn.java ================================================ /* * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package run.halo.app.security.jackson2; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonTypeInfo; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.switchuser.SwitchUserGrantedAuthority; import org.springframework.security.web.jackson2.WebServletJackson2Module; /** * Jackson mixin class to serialize/deserialize {@link SwitchUserGrantedAuthority}. * This class is copied from repository spring-projects/spring-security. * * @author Markus Heiden * @see WebServletJackson2Module * @see org.springframework.security.jackson2.SecurityJackson2Modules * @since 6.3 */ @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) @JsonAutoDetect( fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE ) @JsonIgnoreProperties(ignoreUnknown = true) abstract class SwitchUserGrantedAuthorityMixIn { @JsonCreator SwitchUserGrantedAuthorityMixIn( @JsonProperty("role") String role, @JsonProperty("source") Authentication source ) { } } ================================================ FILE: application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java ================================================ package run.halo.app.security.jackson2; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonTypeInfo; import org.springframework.security.core.Authentication; /** * This mixin class is used to serialize/deserialize TwoFactorAuthentication. * * @author johnniang */ @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE) @JsonIgnoreProperties(ignoreUnknown = true) abstract class TwoFactorAuthenticationMixin { @JsonCreator TwoFactorAuthenticationMixin( @JsonProperty("previous") Authentication previous ) { } } ================================================ FILE: application/src/main/java/run/halo/app/security/preauth/DefaultPasswordResetAvailabilityProviders.java ================================================ package run.halo.app.security.preauth; import java.util.List; import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.properties.SecurityProperties; import run.halo.app.infra.properties.SecurityProperties.PasswordResetMethod; /** * Default password reset availability providers. * * @author johnniang * @since 2.20.0 */ @Component public class DefaultPasswordResetAvailabilityProviders implements PasswordResetAvailabilityProviders { private final SecurityProperties securityProperties; private final List providers; public DefaultPasswordResetAvailabilityProviders(HaloProperties haloProperties, ObjectProvider providers) { this.securityProperties = haloProperties.getSecurity(); this.providers = providers.orderedStream().toList(); } @Override public Flux getAvailableMethods() { return Flux.fromIterable(securityProperties.getPasswordResetMethods()) .filterWhen(method -> providers.stream() .filter(provider -> provider.support(method.getName())) .findFirst() .map(provider -> provider.isAvailable(method)) .orElseGet(() -> Mono.just(false)) ); } } ================================================ FILE: application/src/main/java/run/halo/app/security/preauth/EmailPasswordResetAvailabilityProvider.java ================================================ package run.halo.app.security.preauth; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.infra.properties.SecurityProperties; /** * Email password reset availability provider. * * @author johnniang * @since 2.20.0 */ @Component public class EmailPasswordResetAvailabilityProvider implements PasswordResetAvailabilityProvider { @Override public Mono isAvailable(SecurityProperties.PasswordResetMethod method) { // TODO Check the email notifier is available in the future return Mono.just(true); } @Override public boolean support(String name) { return "email".equals(name); } } ================================================ FILE: application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProvider.java ================================================ package run.halo.app.security.preauth; import reactor.core.publisher.Mono; import run.halo.app.infra.properties.SecurityProperties; /** * Password reset availability provider. * * @author johnniang * @since 2.20.0 */ public interface PasswordResetAvailabilityProvider { /** * Check if the password reset method is available. * * @param method password reset method * @return true if available, false otherwise */ Mono isAvailable(SecurityProperties.PasswordResetMethod method); /** * Check if the provider supports the name. * * @param name password reset method name * @return true if supports, false otherwise */ boolean support(String name); } ================================================ FILE: application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProviders.java ================================================ package run.halo.app.security.preauth; import reactor.core.publisher.Flux; import run.halo.app.infra.properties.SecurityProperties.PasswordResetMethod; /** * Password reset availability providers. * * @author johnniang * @since 2.20.0 */ public interface PasswordResetAvailabilityProviders { /** * Get available password reset methods. * * @return available password reset methods */ Flux getAvailableMethods(); /** * Get other available password reset methods. * * @param methodName method name * @return other available password reset methods */ default Flux getOtherAvailableMethods(String methodName) { return getAvailableMethods().filter(method -> !method.getName().equals(methodName)); } } ================================================ FILE: application/src/main/java/run/halo/app/security/preauth/PreAuthEmailPasswordResetEndpoint.java ================================================ package run.halo.app.security.preauth; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; import static org.springframework.web.reactive.function.server.RequestPredicates.path; import static run.halo.app.infra.ValidationUtils.validate; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import io.github.resilience4j.ratelimiter.RequestNotPermitted; import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import java.net.URI; import java.util.HashMap; import java.util.Map; import java.util.Objects; import lombok.Data; import org.springframework.context.MessageSource; import org.springframework.context.annotation.Bean; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.stereotype.Component; import org.springframework.validation.Validator; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.user.service.EmailPasswordRecoveryService; import run.halo.app.core.user.service.InvalidResetTokenException; import run.halo.app.infra.ValidationUtils; import run.halo.app.infra.actuator.GlobalInfoService; import run.halo.app.infra.utils.HaloUtils; import run.halo.app.infra.utils.IpAddressUtils; /** * Pre-auth password reset endpoint. * * @author johnniang * @since 2.20.0 */ @Component class PreAuthEmailPasswordResetEndpoint { private static final String SEND_TEMPLATE = "password-reset/email/send"; private static final String RESET_TEMPLATE = "password-reset/email/reset"; private final RateLimiterRegistry rateLimiterRegistry; public PreAuthEmailPasswordResetEndpoint( RateLimiterRegistry rateLimiterRegistry ) { this.rateLimiterRegistry = rateLimiterRegistry; } @Bean @Order(Ordered.HIGHEST_PRECEDENCE + 100) RouterFunction preAuthPasswordResetEndpoints( GlobalInfoService globalInfoService, PasswordResetAvailabilityProviders availabilityProviders, MessageSource messageSource, EmailPasswordRecoveryService emailService, Validator validator ) { return RouterFunctions.nest(path("/password-reset/email"), RouterFunctions.route() .GET("", request -> request.bind(SendForm.class) .flatMap(sendForm -> ServerResponse.ok().render(SEND_TEMPLATE, Map.of( "otherMethods", availabilityProviders.getOtherAvailableMethods("email"), "globalInfo", globalInfoService.getGlobalInfo(), "form", sendForm ))) ) .GET("/{resetToken}", request -> { var token = request.pathVariable("resetToken"); return request.bind(ResetForm.class) .flatMap(resetForm -> { var model = new HashMap(); model.put("form", resetForm); model.put("globalInfo", globalInfoService.getGlobalInfo()); return emailService.getValidResetToken(token) .flatMap(resetToken -> { // TODO Check the 2FA of the user model.put("username", resetToken.username()); return ServerResponse.ok().render(RESET_TEMPLATE, model); }) .transformDeferred(rateLimiterForPasswordResetVerification( request.exchange().getRequest() )) .onErrorResume(InvalidResetTokenException.class, e -> ServerResponse.status(HttpStatus.FOUND) .location(URI.create( "/password-reset/email?error=invalid_reset_token") ) .build() ) .onErrorResume(RequestNotPermitted.class, e -> { model.put("error", "rate_limit_exceeded"); return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS) .render(RESET_TEMPLATE, model); }); }); } ) .POST("/{resetToken}", request -> { var token = request.pathVariable("resetToken"); return request.bind(ResetForm.class) .flatMap(resetForm -> emailService.getValidResetToken(token) .flatMap(resetToken -> { var bindingResult = validate(resetForm, validator, request.exchange()); var model = bindingResult.getModel(); model.put("globalInfo", globalInfoService.getGlobalInfo()); model.put("username", resetToken.username()); if (!Objects.equals( resetForm.getPassword(), resetForm.getConfirmPassword() )) { bindingResult.rejectValue( "confirmPassword", "validation.error.password.confirmPassword.mismatch", "Password and confirm password mismatch" ); } if (bindingResult.hasErrors()) { return ServerResponse.badRequest().render(RESET_TEMPLATE, model); } return emailService.changePassword(resetForm.getPassword(), token) .then(ServerResponse.status(HttpStatus.FOUND) .location(URI.create("/login?password_reset")) .build() ) .transformDeferred(rateLimiterForPasswordResetVerification( request.exchange().getRequest() )) .onErrorResume(RequestNotPermitted.class, e -> { model.put("error", "rate_limit_exceeded"); return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS) .render(RESET_TEMPLATE, model); }); }) .onErrorResume(InvalidResetTokenException.class, e -> ServerResponse.status(HttpStatus.FOUND) .location(URI.create( "/password-reset/email?error=invalid_reset_token" )) .build() ) ); }) .POST("", contentType(MediaType.APPLICATION_FORM_URLENCODED), request -> request.bind(SendForm.class) .flatMap(sendForm -> { // validate the send form var bindingResult = validate(sendForm, validator, request.exchange()); var model = bindingResult.getModel(); model.put("globalInfo", globalInfoService.getGlobalInfo()); if (bindingResult.hasErrors()) { return ServerResponse.badRequest().render(SEND_TEMPLATE, model); } var email = sendForm.getEmail().toLowerCase(); return emailService.sendPasswordResetEmail(email) .then(Mono.defer(() -> { model.put("sent", true); return ServerResponse.ok().render(SEND_TEMPLATE, model); })) .transformDeferred(rateLimiterForSendPasswordResetEmail( request.exchange().getRequest() )) .onErrorResume(RequestNotPermitted.class, e -> { model.put("error", "rate_limit_exceeded"); return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS) .render(SEND_TEMPLATE, model); }); }) ) .before(HaloUtils.noCache()) .build()); } RateLimiterOperator rateLimiterForSendPasswordResetEmail(ServerHttpRequest request) { var clientIp = IpAddressUtils.getClientIp(request); var rateLimiterKey = "send-password-reset-email-from-" + clientIp; var rateLimiter = rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-password-reset-email"); return RateLimiterOperator.of(rateLimiter); } RateLimiterOperator rateLimiterForPasswordResetVerification(ServerHttpRequest request) { var clientIp = IpAddressUtils.getClientIp(request); var rateLimiterKey = "password-reset-email-verify-from-" + clientIp; var rateLimiter = rateLimiterRegistry.rateLimiter(rateLimiterKey, "password-reset-verification"); return RateLimiterOperator.of(rateLimiter); } @Data static class ResetForm { @NotBlank @Pattern( regexp = ValidationUtils.PASSWORD_REGEX, message = "{validation.error.password.pattern}" ) @Size(min = 5, max = 257) private String password; @NotBlank private String confirmPassword; } @Data static class SendForm { @NotBlank @Email private String email; } } ================================================ FILE: application/src/main/java/run/halo/app/security/preauth/PreAuthLoginEndpoint.java ================================================ package run.halo.app.security.preauth; import static org.springframework.web.reactive.function.server.RequestPredicates.path; import java.net.URI; import java.util.Base64; import java.util.Map; import java.util.Objects; import java.util.Optional; import org.apache.commons.lang3.StringUtils; import org.springframework.context.annotation.Bean; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.security.web.server.savedrequest.ServerRequestCache; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.AuthProvider; import run.halo.app.infra.actuator.GlobalInfoService; import run.halo.app.infra.utils.HaloUtils; import run.halo.app.plugin.PluginConst; import run.halo.app.security.AuthProviderService; import run.halo.app.security.HaloServerRequestCache; import run.halo.app.security.authentication.CryptoService; import run.halo.app.security.authentication.rememberme.RememberMeRequestCache; import run.halo.app.security.authentication.rememberme.WebSessionRememberMeRequestCache; /** * Pre-auth login endpoints. * * @author johnniang * @since 2.20.0 */ @Component class PreAuthLoginEndpoint { private final CryptoService cryptoService; private final GlobalInfoService globalInfoService; private final AuthProviderService authProviderService; private final ServerRequestCache serverRequestCache = new HaloServerRequestCache(); private final RememberMeRequestCache rememberMeRequestCache = new WebSessionRememberMeRequestCache(); PreAuthLoginEndpoint(CryptoService cryptoService, GlobalInfoService globalInfoService, AuthProviderService authProviderService) { this.cryptoService = cryptoService; this.globalInfoService = globalInfoService; this.authProviderService = authProviderService; } @Bean @Order(Ordered.HIGHEST_PRECEDENCE + 100) RouterFunction preAuthLoginEndpoints() { return RouterFunctions.nest(path("/login"), RouterFunctions.route() .GET("", request -> { var exchange = request.exchange(); var contextPath = exchange.getRequest().getPath().contextPath().value(); var publicKey = cryptoService.readPublicKey() .map(key -> Base64.getEncoder().encodeToString(key)); var globalInfo = globalInfoService.getGlobalInfo().cache(); var authProviders = authProviderService.getEnabledProviders().cache(); var allFormProviders = authProviders .filter(ap -> AuthProvider.AuthType.FORM.equals(ap.getSpec().getAuthType())) .cache(); var authProvider = Mono.justOrEmpty(request.queryParam("method")) .flatMap(method -> allFormProviders .filter(ap -> Objects.equals(method, ap.getMetadata().getName())) .next() .switchIfEmpty(Mono.error( () -> new ServerWebInputException("Invalid login method " + method)) ) ) .switchIfEmpty(allFormProviders.next()) .cache(); var fragmentTemplateName = authProvider.map(ap -> { var templateName = "login_" + ap.getMetadata().getName(); return Optional.ofNullable(ap.getMetadata().getLabels()) .map(labels -> labels.get(PluginConst.PLUGIN_NAME_LABEL_NAME)) .filter(StringUtils::isNotBlank) .map(pluginName -> String.join(":", "plugin", pluginName, templateName)) .orElse(templateName); }); var socialAuthProviders = authProviders .filter(ap -> !AuthProvider.AuthType.FORM.equals(ap.getSpec().getAuthType())) .cache(); var formAuthProviders = allFormProviders .filterWhen(ap -> authProvider .map(provider -> !Objects.equals(provider.getMetadata().getName(), ap.getMetadata().getName()) ) ) .cache(); return serverRequestCache.saveRequest(exchange).then(Mono.defer(() -> ServerResponse.ok().render("login", Map.of( "action", contextPath + "/login", "publicKey", publicKey, "globalInfo", globalInfo, "authProvider", authProvider, "fragmentTemplateName", fragmentTemplateName, "socialAuthProviders", socialAuthProviders, "formAuthProviders", formAuthProviders, "rememberMe", rememberMeRequestCache.isRememberMe(exchange) // TODO Add more models here )) )); }) .POST("/social/{authProviderName}", request -> { var authProviderName = request.pathVariable("authProviderName"); return authProviderService.getEnabledProviders() .filter(ap -> Objects.equals(authProviderName, ap.getMetadata().getName())) .filter(ap -> !AuthProvider.AuthType.FORM.equals(ap.getSpec().getAuthType())) .next() .switchIfEmpty(Mono.error(() -> new ServerWebInputException( "Auth provider " + authProviderName + " not found or not enabled." ))) .flatMap(ap -> { var authenticationUrl = ap.getSpec().getAuthenticationUrl(); return rememberMeRequestCache.saveRememberMe(request.exchange()) .then(Mono.defer(() -> ServerResponse.status(HttpStatus.FOUND) .location(URI.create(authenticationUrl)) .build() )); }); }) .before(HaloUtils.noCache()) .build()); } } ================================================ FILE: application/src/main/java/run/halo/app/security/preauth/PreAuthSignUpEndpoint.java ================================================ package run.halo.app.security.preauth; import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; import static org.springframework.web.reactive.function.server.RequestPredicates.path; import static run.halo.app.infra.ValidationUtils.validate; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import io.github.resilience4j.ratelimiter.RequestNotPermitted; import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import java.net.URI; import lombok.Data; import org.springframework.context.annotation.Bean; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.stereotype.Component; import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.FieldError; import org.springframework.validation.Validator; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.user.service.EmailVerificationService; import run.halo.app.core.user.service.SignUpData; import run.halo.app.core.user.service.UserService; import run.halo.app.infra.actuator.GlobalInfoService; import run.halo.app.infra.exception.DuplicateNameException; import run.halo.app.infra.exception.EmailAlreadyTakenException; import run.halo.app.infra.exception.EmailVerificationFailed; import run.halo.app.infra.exception.RateLimitExceededException; import run.halo.app.infra.exception.RequestBodyValidationException; import run.halo.app.infra.exception.RestrictedNameException; import run.halo.app.infra.utils.HaloUtils; import run.halo.app.infra.utils.IpAddressUtils; /** * Pre-auth sign up endpoint. * * @author johnniang * @since 2.20.0 */ @Component class PreAuthSignUpEndpoint { private final GlobalInfoService globalInfoService; private final Validator validator; private final UserService userService; private final EmailVerificationService emailVerificationService; private final RateLimiterRegistry rateLimiterRegistry; PreAuthSignUpEndpoint(GlobalInfoService globalInfoService, Validator validator, UserService userService, EmailVerificationService emailVerificationService, RateLimiterRegistry rateLimiterRegistry) { this.globalInfoService = globalInfoService; this.validator = validator; this.userService = userService; this.emailVerificationService = emailVerificationService; this.rateLimiterRegistry = rateLimiterRegistry; } @Bean @Order(Ordered.HIGHEST_PRECEDENCE + 100) RouterFunction preAuthSignUpEndpoints() { return RouterFunctions.nest(path("/signup"), RouterFunctions.route() .GET("", request -> { var signUpData = new SignUpData(); var bindingResult = new BeanPropertyBindingResult(signUpData, "form"); var model = bindingResult.getModel(); model.put("globalInfo", globalInfoService.getGlobalInfo()); return ServerResponse.ok().render("signup", model); }) .POST( "", contentType(APPLICATION_FORM_URLENCODED), request -> request.bind(SignUpData.class) .flatMap(signUpData -> { // sign up var bindingResult = validate(signUpData, validator, request.exchange()); var model = bindingResult.getModel(); model.put("globalInfo", globalInfoService.getGlobalInfo()); if (bindingResult.hasErrors()) { return ServerResponse.ok().render("signup", model); } return userService.signUp(signUpData) .flatMap(user -> ServerResponse.status(HttpStatus.FOUND) .location(URI.create("/login?signup")) .build() ) .doOnError(t -> { model.put("error", "unknown"); model.put("errorMessage", t.getMessage()); }) .doOnError(EmailVerificationFailed.class, e -> { bindingResult.addError(new FieldError("form", "emailCode", signUpData.getEmailCode(), true, new String[] {"signup.error.email-code.invalid"}, null, "Invalid Email Code")); } ) .doOnError(EmailAlreadyTakenException.class, e -> { bindingResult.addError(new FieldError("form", "email", signUpData.getEmail(), true, new String[] {"signup.error.email.already-taken"}, null, "Email Already Taken")); }) .doOnError(RateLimitExceededException.class, e -> model.put("error", "rate-limit-exceeded") ) .doOnError(DuplicateNameException.class, e -> model.put("error", "duplicate-username") ) .doOnError(RestrictedNameException.class, e -> model.put("error", "restricted-username") ) .onErrorResume(e -> ServerResponse.ok().render("signup", model)); }) ) .POST("/send-email-code", contentType(APPLICATION_JSON), request -> request.bodyToMono(SendEmailCodeBody.class) .flatMap(body -> { var bindingResult = validate(body, "body", validator, request.exchange()); if (bindingResult.hasErrors()) { return Mono.error(new RequestBodyValidationException(bindingResult)); } var email = body.getEmail(); return emailVerificationService.sendRegisterVerificationCode(email) .transformDeferred( rateLimiterForSendingEmailCode(request.exchange().getRequest()) ) .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); }) .then(ServerResponse.accepted().build()) ) .before(HaloUtils.noCache()) .build()); } private RateLimiterOperator rateLimiterForSendingEmailCode(ServerHttpRequest request) { var clientIp = IpAddressUtils.getClientIp(request); var rateLimiterKey = "send-email-code-for-signing-up-from-" + clientIp; var rateLimiter = rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-email-verification-code"); return RateLimiterOperator.of(rateLimiter); } @Data public static class SendEmailCodeBody { @Email @NotBlank String email; } } ================================================ FILE: application/src/main/java/run/halo/app/security/preauth/PreAuthTwoFactorEndpoint.java ================================================ package run.halo.app.security.preauth; import java.util.Map; import org.springframework.context.annotation.Bean; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.infra.actuator.GlobalInfoService; import run.halo.app.infra.utils.HaloUtils; /** * Pre-auth two-factor endpoints. * * @author johnniang * @since 2.20.0 */ @Component class PreAuthTwoFactorEndpoint { @Bean @Order(Ordered.HIGHEST_PRECEDENCE + 100) RouterFunction preAuthTwoFactorEndpoints(GlobalInfoService globalInfoService) { return RouterFunctions.route() .GET("/challenges/two-factor/totp", request -> ServerResponse.ok().render("challenges/two-factor/totp", Map.of( "globalInfo", globalInfoService.getGlobalInfo() )) ) .before(HaloUtils.noCache()) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/security/preauth/SystemSetupEndpoint.java ================================================ package run.halo.app.security.preauth; import static io.r2dbc.spi.ConnectionFactoryOptions.DRIVER; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; import static org.springframework.web.reactive.function.server.RequestPredicates.path; import static run.halo.app.infra.ValidationUtils.validate; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.Properties; import lombok.Data; import lombok.RequiredArgsConstructor; import org.hibernate.validator.constraints.URL; import org.springdoc.core.fn.builders.content.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.config.PlaceholderConfigurerSupport; import org.springframework.boot.r2dbc.autoconfigure.R2dbcConnectionDetails; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.io.ClassPathResource; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.util.InMemoryResource; import org.springframework.stereotype.Component; import org.springframework.util.PropertyPlaceholderHelper; import org.springframework.util.StreamUtils; import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.BindingResult; import org.springframework.validation.Validator; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Unstructured; import run.halo.app.infra.ExternalUrlChangedEvent; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.InitializationStateGetter; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.SystemState; import run.halo.app.infra.ValidationUtils; import run.halo.app.infra.exception.RequestBodyValidationException; import run.halo.app.infra.utils.HaloUtils; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.YamlUnstructuredLoader; import run.halo.app.plugin.PluginService; import run.halo.app.security.SuperAdminInitializer; import run.halo.app.theme.service.ThemeService; @Component @RequiredArgsConstructor public class SystemSetupEndpoint { static final String SETUP_TEMPLATE = "setup"; static final PropertyPlaceholderHelper PROPERTY_PLACEHOLDER_HELPER = new PropertyPlaceholderHelper( PlaceholderConfigurerSupport.DEFAULT_PLACEHOLDER_PREFIX, PlaceholderConfigurerSupport.DEFAULT_PLACEHOLDER_SUFFIX ); private final InitializationStateGetter initializationStateGetter; private final SystemConfigFetcher systemConfigFetcher; private final SuperAdminInitializer superAdminInitializer; private final ReactiveExtensionClient client; private final PluginService pluginService; private final ThemeService themeService; private final Validator validator; private final ObjectProvider connectionDetails; private final ExternalUrlSupplier externalUrl; private final ApplicationEventPublisher eventPublisher; @Bean @Order(Ordered.HIGHEST_PRECEDENCE + 100) RouterFunction setupPageRouter() { final var tag = "SystemV1alpha1Public"; return SpringdocRouteBuilder.route() .GET(path("/system/setup").and(accept(MediaType.TEXT_HTML)), this::setupPage, builder -> builder.operationId("JumpToSetupPage") .description("Jump to setup page") .tag(tag) .response(responseBuilder() .content(Builder.contentBuilder() .mediaType(MediaType.TEXT_HTML_VALUE)) .implementation(String.class) ) ) .POST("/system/setup", contentType(MediaType.APPLICATION_FORM_URLENCODED), this::setup, builder -> builder .operationId("SetupSystem") .description("Setup system") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(schemaBuilder() .implementation(SetupRequest.class)) ) ) .response(responseBuilder() .responseCode(String.valueOf(HttpStatus.NO_CONTENT.value())) .implementation(Void.class) ) ) .before(HaloUtils.noCache()) .build(); } private Mono setup(ServerRequest request) { return initializationStateGetter.userInitialized() .filter(initialized -> !initialized) .flatMap(ignored -> request.bind(SetupRequest.class) .flatMap(setupRequest -> { // validate it var bindingResult = validate(setupRequest, validator, request.exchange()); if (bindingResult.hasErrors()) { return handleValidationErrors(bindingResult, request); } return doInitialization(setupRequest).then(handleSetupSuccessfully(request)); }) ) .switchIfEmpty(redirectToConsole()); } private static Mono handleSetupSuccessfully(ServerRequest request) { if (isHtmlRequest(request)) { return redirectToConsole(); } return ServerResponse.noContent().build(); } private Mono handleValidationErrors(BindingResult bindingResult, ServerRequest request) { if (isHtmlRequest(request)) { var model = bindingResult.getModel(); model.put("usingH2database", usingH2database()); return ServerResponse.status(HttpStatus.BAD_REQUEST) .render(SETUP_TEMPLATE, model); } return Mono.error(new RequestBodyValidationException(bindingResult)); } private static boolean isHtmlRequest(ServerRequest request) { return request.headers().accept().contains(MediaType.TEXT_HTML) && !HaloUtils.isXhr(request.headers().asHttpHeaders()); } private static Mono redirectToConsole() { return ServerResponse.status(HttpStatus.FOUND).location(URI.create("/console")).build(); } private Mono doInitialization(SetupRequest body) { var superUserMono = superAdminInitializer.initialize( SuperAdminInitializer.InitializationParam.builder() .username(body.getUsername()) .password(body.getPassword()) .email(body.getEmail()) .build() ) .subscribeOn(Schedulers.boundedElastic()); var basicConfigMono = Mono.defer(() -> systemConfigFetcher.getConfigMap() .flatMap(configMap -> { mergeToBasicConfig(body, configMap); return client.update(configMap); }) ) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(t -> t instanceof OptimisticLockingFailureException) ) .subscribeOn(Schedulers.boundedElastic()) .then(Mono.fromCallable(() -> { eventPublisher.publishEvent( new ExternalUrlChangedEvent(this, URI.create(body.getExternalUrl()).toURL()) ); return null; })); return Mono.when( basicConfigMono, superUserMono, initializeNecessaryData(body.getUsername()), pluginService.installPresetPlugins(), themeService.installPresetTheme() ) .then(SystemState.upsetSystemState(client, state -> state.setIsSetup(true))); } private Mono initializeNecessaryData(String username) { return loadPresetExtensions(username) .concatMap(client::create) .subscribeOn(Schedulers.boundedElastic()) .then(); } private static void mergeToBasicConfig(SetupRequest body, ConfigMap configMap) { Map data = configMap.getData(); if (data == null) { data = new LinkedHashMap<>(); configMap.setData(data); } String basic = data.getOrDefault(SystemSetting.Basic.GROUP, "{}"); var basicSetting = JsonUtils.jsonToObject(basic, SystemSetting.Basic.class); basicSetting.setTitle(body.getSiteTitle()); basicSetting.setLanguage(body.getLanguage()); basicSetting.setExternalUrl(body.getExternalUrl()); data.put(SystemSetting.Basic.GROUP, JsonUtils.objectToJson(basicSetting)); } private Mono setupPage(ServerRequest request) { return initializationStateGetter.userInitialized() .flatMap(initialized -> { if (initialized) { return redirectToConsole(); } var setupRequest = new SetupRequest(); setupRequest.setExternalUrl( externalUrl.getURL(request.exchange().getRequest()).toString() ); var bindingResult = new BeanPropertyBindingResult(setupRequest, "form"); var model = bindingResult.getModel(); model.put("usingH2database", usingH2database()); return ServerResponse.ok().render(SETUP_TEMPLATE, model); }); } private boolean usingH2database() { var rcd = connectionDetails.getIfUnique(); if (rcd == null) { // If no R2dbcConnectionDetails is available, we assume H2(mem) is used. return true; } var options = rcd.getConnectionFactoryOptions(); return Optional.ofNullable(options.getValue(DRIVER)) .map(Object::toString) .map("h2"::equalsIgnoreCase) .orElse(false); } @Data static class SetupRequest { @Schema(requiredMode = REQUIRED, minLength = 4, maxLength = 63) @NotBlank @Size(min = 4, max = 63) @Pattern(regexp = ValidationUtils.NAME_REGEX, message = "{validation.error.username.pattern}") private String username; @Schema(requiredMode = REQUIRED, minLength = 5, maxLength = 257) @NotBlank @Pattern(regexp = ValidationUtils.PASSWORD_REGEX, message = "{validation.error.password.pattern}") @Size(min = 5, max = 257) private String password; @Email private String email; @NotBlank @Size(max = 80) private String siteTitle; @Pattern(regexp = "^(zh-CN|zh-TW|en|es)$") private String language; @NotNull @URL(regexp = "^(http|https).*") private String externalUrl; } Flux loadPresetExtensions(String username) { return Mono.fromCallable( () -> { // read initial-data.yaml to string var classPathResource = new ClassPathResource("initial-data.yaml"); String rawContent = StreamUtils.copyToString(classPathResource.getInputStream(), StandardCharsets.UTF_8); // build properties var properties = new Properties(); properties.setProperty("username", username); properties.setProperty("timestamp", Instant.now().toString()); // replace placeholders var processedContent = PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders(rawContent, properties); // load yaml to unstructured var stringResource = new InMemoryResource(processedContent.getBytes(StandardCharsets.UTF_8)); var loader = new YamlUnstructuredLoader(stringResource); return loader.load(); }) .flatMapMany(Flux::fromIterable) .subscribeOn(Schedulers.boundedElastic()); } } ================================================ FILE: application/src/main/java/run/halo/app/security/session/InMemoryReactiveIndexedSessionRepository.java ================================================ package run.halo.app.security.session; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import java.time.Duration; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import org.springframework.beans.factory.DisposableBean; import org.springframework.session.DelegatingIndexResolver; import org.springframework.session.IndexResolver; import org.springframework.session.MapSession; import org.springframework.session.PrincipalNameIndexResolver; import org.springframework.session.ReactiveMapSessionRepository; import org.springframework.session.Session; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public class InMemoryReactiveIndexedSessionRepository extends ReactiveMapSessionRepository implements ReactiveIndexedSessionRepository, DisposableBean { final IndexResolver indexResolver = new DelegatingIndexResolver<>(new PrincipalNameIndexResolver<>(PRINCIPAL_NAME_INDEX_NAME)); private final ConcurrentMap> sessionIdIndexMap = new ConcurrentHashMap<>(); private final ConcurrentMap> indexSessionIdMap = new ConcurrentHashMap<>(); /** * Prevent other requests from being parsed and acquiring the session during its deletion, * which could result in an unintended renewal. Currently, it acts as a buffer, and having a * slightly prolonged expiration period is sufficient. */ private final Cache invalidateSessionIds = CacheBuilder.newBuilder() .expireAfterWrite(Duration.ofMinutes(10)) .maximumSize(10_000) .build(); public InMemoryReactiveIndexedSessionRepository(Map sessions) { super(sessions); } @Override public Mono save(MapSession session) { if (invalidateSessionIds.getIfPresent(session.getId()) != null) { return this.deleteById(session.getId()); } return super.save(session) .then(updateIndex(session)); } @Override public Mono deleteById(String id) { return removeIndex(id) .then(Mono.defer(() -> { invalidateSessionIds.put(id, true); return super.deleteById(id); })); } @Override public Mono> findByIndexNameAndIndexValue(String indexName, String indexValue) { var indexKey = new IndexKey(indexName, indexValue); return Flux.fromStream((() -> indexSessionIdMap.getOrDefault(indexKey, Set.of()).stream())) .flatMap(this::findById) .collectMap(Session::getId); } @Override public Mono> findByPrincipalName(String principalName) { return this.findByIndexNameAndIndexValue(PRINCIPAL_NAME_INDEX_NAME, principalName); } @Override public void destroy() { sessionIdIndexMap.clear(); indexSessionIdMap.clear(); invalidateSessionIds.invalidateAll(); } Mono removeIndex(String sessionId) { return getIndexes(sessionId) .doOnNext(indexKey -> indexSessionIdMap.computeIfPresent(indexKey, (key, sessionIdSet) -> { sessionIdSet.remove(sessionId); return sessionIdSet.isEmpty() ? null : sessionIdSet; }) ) .then(Mono.defer(() -> { sessionIdIndexMap.remove(sessionId); return Mono.empty(); })) .then(); } Mono updateIndex(MapSession session) { return removeIndex(session.getId()) .then(Mono.defer(() -> { if (!session.getId().equals(session.getOriginalId())) { return removeIndex(session.getOriginalId()); } return Mono.empty(); })) .then(Mono.defer(() -> { indexResolver.resolveIndexesFor(session) .forEach((name, value) -> { IndexKey indexKey = new IndexKey(name, value); indexSessionIdMap.computeIfAbsent(indexKey, unusedSet -> ConcurrentHashMap.newKeySet()) .add(session.getId()); // Update sessionIdIndexMap sessionIdIndexMap.computeIfAbsent(session.getId(), unusedSet -> ConcurrentHashMap.newKeySet()) .add(indexKey); }); return Mono.empty(); })) .then(); } Flux getIndexes(String sessionId) { return Flux.fromIterable(sessionIdIndexMap.getOrDefault(sessionId, Set.of())); } /** * For testing purpose. */ ConcurrentMap> getSessionIdIndexMap() { return sessionIdIndexMap; } /** * For testing purpose. */ ConcurrentMap> getIndexSessionIdMap() { return indexSessionIdMap; } record IndexKey(String attributeName, String attributeValue) { } } ================================================ FILE: application/src/main/java/run/halo/app/security/session/ReactiveIndexedSessionRepository.java ================================================ package run.halo.app.security.session; import org.springframework.session.ReactiveFindByIndexNameSessionRepository; import org.springframework.session.ReactiveSessionRepository; import org.springframework.session.Session; public interface ReactiveIndexedSessionRepository extends ReactiveSessionRepository, ReactiveFindByIndexNameSessionRepository { } ================================================ FILE: application/src/main/java/run/halo/app/security/session/SessionInvalidationListener.java ================================================ package run.halo.app.security.session; import java.time.Duration; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.session.ReactiveFindByIndexNameSessionRepository; import org.springframework.session.ReactiveSessionRepository; import org.springframework.session.Session; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import run.halo.app.event.user.PasswordChangedEvent; import run.halo.app.infra.utils.ReactiveUtils; @Component @RequiredArgsConstructor public class SessionInvalidationListener { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private final ReactiveFindByIndexNameSessionRepository indexedSessionRepository; private final ReactiveSessionRepository sessionRepository; @Async @EventListener public void onPasswordChanged(PasswordChangedEvent event) { String username = event.getUsername(); // Invalidate session invalidateUserSessions(username); } private void invalidateUserSessions(String username) { indexedSessionRepository.findByPrincipalName(username) .map(Map::keySet) .flatMapMany(Flux::fromIterable) .flatMap(sessionRepository::deleteById) .then() .block(BLOCKING_TIMEOUT); } } ================================================ FILE: application/src/main/java/run/halo/app/security/switchuser/SwitchUserConfigurer.java ================================================ package run.halo.app.security.switchuser; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler; import org.springframework.security.web.server.authentication.SwitchUserWebFilter; import org.springframework.stereotype.Component; import run.halo.app.security.HaloRedirectAuthenticationSuccessHandler; import run.halo.app.security.authentication.SecurityConfigurer; /** * Switch user configurer. * * @author johnniang */ @Component class SwitchUserConfigurer implements SecurityConfigurer { private final ReactiveUserDetailsService userDetailsService; SwitchUserConfigurer(ReactiveUserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } @Override public void configure(ServerHttpSecurity http) { var successHandler = new HaloRedirectAuthenticationSuccessHandler("/console"); var failureHandler = new RedirectServerAuthenticationFailureHandler("/login?error=impersonate"); var filter = new SwitchUserWebFilter(userDetailsService, successHandler, failureHandler); http.addFilterAfter(filter, SecurityWebFiltersOrder.AUTHORIZATION); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/CompositeTemplateResolver.java ================================================ package run.halo.app.theme; import static java.util.Comparator.comparing; import static java.util.Comparator.naturalOrder; import static java.util.Comparator.nullsLast; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; import org.thymeleaf.IEngineConfiguration; import org.thymeleaf.templateresolver.ITemplateResolver; import org.thymeleaf.templateresolver.TemplateResolution; import run.halo.app.infra.exception.NotFoundException; /** * Composite template resolver to control execution flow of multiple template resolvers. * * @author johnniang */ class CompositeTemplateResolver implements ITemplateResolver { private final List resolvers; public CompositeTemplateResolver(Collection resolvers) { this.resolvers = Optional.ofNullable(resolvers).orElseGet(List::of) .stream() .distinct() // we keep the same order comparison as // org.thymeleaf.EngineConfiguration.TemplateResolverComparator .sorted(comparing(ITemplateResolver::getOrder, nullsLast(naturalOrder()))) .toList(); } @Override public String getName() { return resolvers.stream() .map(ITemplateResolver::getName) .collect(Collectors.joining(", ")); } @Override public Integer getOrder() { // null order means to be the end of the resolvers. return null; } @Override public TemplateResolution resolveTemplate(IEngineConfiguration configuration, String ownerTemplate, String template, Map templateResolutionAttributes) { return resolvers.stream() .map(resolver -> resolver.resolveTemplate( configuration, ownerTemplate, template, templateResolutionAttributes) ) .filter(Objects::nonNull) .findFirst() .orElseThrow(() -> new NotFoundException("Template " + template + " was not found.")); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/DefaultTemplateEnum.java ================================================ package run.halo.app.theme; /** * @author guqing * @since 2.0.0 */ public enum DefaultTemplateEnum { INDEX("index"), CATEGORIES("categories"), CATEGORY("category"), ARCHIVES("archives"), POST("post"), TAG("tag"), TAGS("tags"), SINGLE_PAGE("page"), AUTHOR("author"); private final String value; DefaultTemplateEnum(String value) { this.value = value; } public String getValue() { return value; } } ================================================ FILE: application/src/main/java/run/halo/app/theme/DefaultTemplateNameResolver.java ================================================ package run.halo.app.theme; import static run.halo.app.plugin.PluginConst.SYSTEM_PLUGIN_NAME; import org.apache.commons.lang3.StringUtils; import org.springframework.context.ApplicationContext; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.plugin.PluginApplicationContext; /** * A default implementation of {@link TemplateNameResolver}, It will be provided for plugins to * resolve template name. * * @author guqing * @since 2.11.0 */ public class DefaultTemplateNameResolver implements TemplateNameResolver { private final ApplicationContext applicationContext; private final ViewNameResolver viewNameResolver; public DefaultTemplateNameResolver(ViewNameResolver viewNameResolver, ApplicationContext applicationContext) { this.applicationContext = applicationContext; this.viewNameResolver = viewNameResolver; } @Override public Mono resolveTemplateNameOrDefault(ServerWebExchange exchange, String name) { if (applicationContext instanceof PluginApplicationContext pluginApplicationContext) { var pluginName = pluginApplicationContext.getPluginId(); return this.resolveTemplateNameOrDefault(exchange, name, pluginClassPathTemplate(pluginName, name)); } return resolveTemplateNameOrDefault(exchange, name, pluginClassPathTemplate(SYSTEM_PLUGIN_NAME, name)); } @Override public Mono resolveTemplateNameOrDefault(ServerWebExchange exchange, String name, String defaultName) { return viewNameResolver.resolveViewNameOrDefault(exchange, name, defaultName); } @Override public Mono isTemplateAvailableInTheme(ServerWebExchange exchange, String name) { return this.resolveTemplateNameOrDefault(exchange, name, "") .filter(StringUtils::isNotBlank) .hasElement(); } String pluginClassPathTemplate(String pluginName, String templateName) { return "plugin:" + pluginName + ":" + templateName; } } ================================================ FILE: application/src/main/java/run/halo/app/theme/DefaultViewNameResolver.java ================================================ package run.halo.app.theme; import java.nio.file.Files; import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.boot.thymeleaf.autoconfigure.ThymeleafProperties; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; /** * The {@link DefaultViewNameResolver} is used to resolve view name. * * @author guqing * @since 2.0.0 */ @Component @AllArgsConstructor public class DefaultViewNameResolver implements ViewNameResolver { private static final String TEMPLATES = "templates"; private final ThemeResolver themeResolver; private final ThymeleafProperties thymeleafProperties; /** * Resolves view name. * If the {@param #name} cannot be resolved to the view, the {@param #defaultName} is returned. */ @Override public Mono resolveViewNameOrDefault(ServerWebExchange exchange, String name, String defaultName) { if (StringUtils.isBlank(name)) { return Mono.justOrEmpty(defaultName); } return themeResolver.getTheme(exchange) .mapNotNull(themeContext -> { String templateResourceName = computeResourceName(name); var resourcePath = themeContext.getPath() .resolve(TEMPLATES) .resolve(templateResourceName); return Files.exists(resourcePath) ? name : defaultName; }) .switchIfEmpty(Mono.justOrEmpty(defaultName)); } @Override public Mono resolveViewNameOrDefault(ServerRequest request, String name, String defaultName) { return resolveViewNameOrDefault(request.exchange(), name, defaultName); } String computeResourceName(String name) { Assert.notNull(name, "Name must not be null"); return StringUtils.endsWith(name, thymeleafProperties.getSuffix()) ? name : name + thymeleafProperties.getSuffix(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/HaloViewResolver.java ================================================ package run.halo.app.theme; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; import org.attoparser.ParseException; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.thymeleaf.autoconfigure.ThymeleafProperties; import org.springframework.context.ApplicationContext; import org.springframework.core.Ordered; import org.springframework.http.MediaType; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.unit.DataSize; import org.springframework.web.ErrorResponse; import org.springframework.web.reactive.result.view.View; import org.springframework.web.server.ServerWebExchange; import org.thymeleaf.exceptions.TemplateInputException; import org.thymeleaf.exceptions.TemplateProcessingException; import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveView; import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveViewResolver; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.theme.finders.FinderRegistry; import run.halo.app.theme.router.ModelConst; @Component("thymeleafReactiveViewResolver") public class HaloViewResolver extends ThymeleafReactiveViewResolver implements InitializingBean { private final FinderRegistry finderRegistry; private final ThymeleafProperties thymeleafProperties; public HaloViewResolver(FinderRegistry finderRegistry, ThymeleafProperties thymeleafProperties) { this.finderRegistry = finderRegistry; this.thymeleafProperties = thymeleafProperties; } @Override protected Mono loadView(String viewName, Locale locale) { return super.loadView(viewName, locale) .cast(HaloView.class) .map(view -> { // populate finders to view static variables finderRegistry.getFinders().forEach(view::addStaticVariable); return view; }); } @Override public void afterPropertiesSet() throws Exception { setViewClass(HaloView.class); var map = PropertyMapper.get(); map.from(thymeleafProperties::getEncoding) .to(this::setDefaultCharset); map.from(thymeleafProperties::getExcludedViewNames) .to(this::setExcludedViewNames); map.from(thymeleafProperties::getViewNames) .to(this::setViewNames); var reactive = thymeleafProperties.getReactive(); map.from(reactive::getMediaTypes) .to(this::setSupportedMediaTypes); map.from(reactive::getFullModeViewNames) .to(this::setFullModeViewNames); map.from(reactive::getChunkedModeViewNames) .to(this::setChunkedModeViewNames); map.from(reactive::getMaxChunkSize) .asInt(DataSize::toBytes) .when(size -> size > 0) .to(this::setResponseMaxChunkSizeBytes); setOrder(Ordered.LOWEST_PRECEDENCE - 5); } public static class HaloView extends ThymeleafReactiveView { public static final String CONTEXT_VIEW_KEY = "reactorContextView"; @Autowired private TemplateEngineManager engineManager; @Autowired private ThemeResolver themeResolver; @Override public Mono render(Map model, MediaType contentType, ServerWebExchange exchange) { return themeResolver.getTheme(exchange).flatMap(theme -> { // calculate the engine before rendering setTemplateEngine(engineManager.getTemplateEngine(theme)); var noCache = (Boolean) exchange.getAttributes() .getOrDefault(ModelConst.NO_CACHE, false); exchange.getAttributes() .put(ModelConst.POWERED_BY_HALO_TEMPLATE_ENGINE, !noCache); return super.render(model, contentType, exchange) .onErrorMap(TemplateProcessingException.class::isInstance, tee -> { if (tee instanceof TemplateInputException) { // map the error response exception while fragment not found return Optional.of(tee) .map(Throwable::getCause) .filter(ParseException.class::isInstance) .map(Throwable::getCause) .filter(TemplateProcessingException.class::isInstance) .map(Throwable::getCause) .filter(ErrorResponse.class::isInstance) .orElse(tee); } // map the error response exception while template not found return Optional.of(tee) .map(Throwable::getCause) .filter(ErrorResponse.class::isInstance) .orElse(tee); }); }); } @Override @NonNull protected Mono> getModelAttributes(Map model, @NonNull ServerWebExchange exchange) { Mono> contextBasedStaticVariables = getContextBasedStaticVariables(exchange); Mono> modelAttributes = super.getModelAttributes(model, exchange); return Mono.deferContextual( contextView -> Flux.merge(modelAttributes, contextBasedStaticVariables) .collectList() .map(modelMapList -> { Map result = new HashMap<>(); modelMapList.forEach(result::putAll); return result; }) .doOnNext(attributes -> attributes.put(CONTEXT_VIEW_KEY, contextView)) ); } @NonNull private Mono> getContextBasedStaticVariables( ServerWebExchange exchange) { ApplicationContext applicationContext = obtainApplicationContext(); return Mono.just(new HashMap()) .flatMap(staticVariables -> { List>> monoList = applicationContext.getBeansOfType( ViewContextBasedVariablesAcquirer.class) .values() .stream() .map(acquirer -> acquirer.acquire(exchange)) .toList(); return Flux.merge(monoList) .collectList() .map(modelList -> { Map mergedModel = new HashMap<>(); modelList.forEach(mergedModel::putAll); return mergedModel; }) .map(mergedModel -> { staticVariables.putAll(mergedModel); return staticVariables; }); }); } } } ================================================ FILE: application/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java ================================================ package run.halo.app.theme; import static run.halo.app.theme.HaloViewResolver.HaloView.CONTEXT_VIEW_KEY; import java.util.Optional; import org.thymeleaf.context.IExpressionContext; import org.thymeleaf.spring6.expression.SPELVariableExpressionEvaluator; import org.thymeleaf.standard.expression.IStandardVariableExpression; import org.thymeleaf.standard.expression.IStandardVariableExpressionEvaluator; import org.thymeleaf.standard.expression.StandardExpressionExecutionContext; import reactor.util.context.ContextView; import run.halo.app.infra.utils.ReactiveUtils; /** * Reactive SPEL variable expression evaluator. * * @author guqing * @since 2.0.0 */ public class ReactiveSpelVariableExpressionEvaluator implements IStandardVariableExpressionEvaluator { private final IStandardVariableExpressionEvaluator delegate; public static final ReactiveSpelVariableExpressionEvaluator INSTANCE = new ReactiveSpelVariableExpressionEvaluator(); public ReactiveSpelVariableExpressionEvaluator(IStandardVariableExpressionEvaluator delegate) { this.delegate = delegate; } public ReactiveSpelVariableExpressionEvaluator() { this(SPELVariableExpressionEvaluator.INSTANCE); } @Override public Object evaluate(IExpressionContext context, IStandardVariableExpression expression, StandardExpressionExecutionContext expContext) { var returnValue = delegate.evaluate(context, expression, expContext); var contextView = (ContextView) Optional.ofNullable(context.getVariable(CONTEXT_VIEW_KEY)) .filter(ContextView.class::isInstance) .orElse(null); return Optional.ofNullable(returnValue) .map(value -> ReactiveUtils.blockReactiveValue(value, contextView)) .orElse(null); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java ================================================ package run.halo.app.theme; import java.util.Map; import lombok.AllArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.theme.finders.vo.SiteSettingVo; /** * Site setting variables acquirer. * * @author guqing * @since 2.0.0 */ @Component @AllArgsConstructor public class SiteSettingVariablesAcquirer implements ViewContextBasedVariablesAcquirer { private final SystemConfigFetcher environmentFetcher; private final ExternalUrlSupplier externalUrlSupplier; private final SystemVersionSupplier systemVersionSupplier; @Override public Mono> acquire(ServerWebExchange exchange) { return environmentFetcher.getConfig() .map(config -> { var siteSettingVo = SiteSettingVo.from(config) .withUrl(externalUrlSupplier.getURL(exchange.getRequest())) .withVersion(systemVersionSupplier.get().toString()); return Map.of("site", siteSettingVo); }); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/TemplateEngineManager.java ================================================ package run.halo.app.theme; import lombok.NonNull; import org.pf4j.PluginManager; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.thymeleaf.autoconfigure.ThymeleafProperties; import org.springframework.stereotype.Component; import org.springframework.util.ConcurrentLruCache; import org.thymeleaf.dialect.IDialect; import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine; import org.thymeleaf.spring6.dialect.SpringStandardDialect; import org.thymeleaf.standard.expression.IStandardVariableExpressionEvaluator; import org.thymeleaf.templateresolver.FileTemplateResolver; import org.thymeleaf.templateresolver.ITemplateResolver; import reactor.core.publisher.Mono; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.theme.dialect.HaloProcessorDialect; import run.halo.app.theme.engine.HaloTemplateEngine; import run.halo.app.theme.engine.PluginClassloaderTemplateResolver; import run.halo.app.theme.message.ThemeMessageResolver; /** *

The {@link TemplateEngineManager} uses an {@link ConcurrentLruCache LRU cache} to manage * theme's {@link ISpringWebFluxTemplateEngine}.

*

The default limit size of the {@link ConcurrentLruCache LRU cache} is * {@link TemplateEngineManager#CACHE_SIZE_LIMIT} to prevent unnecessary memory occupation.

*

If theme's {@link ISpringWebFluxTemplateEngine} already exists, it returns.

*

Otherwise, it checks whether the theme exists and creates the * {@link ISpringWebFluxTemplateEngine} into the LRU cache according to the {@link ThemeContext} * .

*

It is thread safe.

* * @author johnniang * @author guqing * @since 2.0.0 */ @Component public class TemplateEngineManager { private static final int CACHE_SIZE_LIMIT = 5; private final ConcurrentLruCache engineCache; private final ThymeleafProperties thymeleafProperties; private final ExternalUrlSupplier externalUrlSupplier; private final PluginManager pluginManager; private final ObjectProvider templateResolvers; private final ObjectProvider dialects; private final ThemeResolver themeResolver; public TemplateEngineManager(ThymeleafProperties thymeleafProperties, ExternalUrlSupplier externalUrlSupplier, PluginManager pluginManager, ObjectProvider templateResolvers, ObjectProvider dialects, ThemeResolver themeResolver) { this.thymeleafProperties = thymeleafProperties; this.externalUrlSupplier = externalUrlSupplier; this.pluginManager = pluginManager; this.templateResolvers = templateResolvers; this.dialects = dialects; this.themeResolver = themeResolver; engineCache = new ConcurrentLruCache<>(CACHE_SIZE_LIMIT, this::templateEngineGenerator); } public ISpringWebFluxTemplateEngine getTemplateEngine(ThemeContext theme) { CacheKey cacheKey = buildCacheKey(theme); return engineCache.get(cacheKey); } public Mono clearCache(String themeName) { return themeResolver.getThemeContext(themeName) .doOnNext(themeContext -> engineCache.remove(buildCacheKey(themeContext))) .then(); } /** * TemplateEngine LRU cache key. * * @param name from {@link #context} * @param active from {@link #context} * @param context must not be null */ private record CacheKey(String name, boolean active, ThemeContext context) { } CacheKey buildCacheKey(ThemeContext context) { return new CacheKey(context.getName(), context.isActive(), context); } private ISpringWebFluxTemplateEngine templateEngineGenerator(CacheKey cacheKey) { var engine = new HaloTemplateEngine(new ThemeMessageResolver(cacheKey.context())); engine.setEnableSpringELCompiler(thymeleafProperties.isEnableSpringElCompiler()); engine.setLinkBuilder(new ThemeLinkBuilder(cacheKey.context(), externalUrlSupplier)); engine.setRenderHiddenMarkersBeforeCheckboxes( thymeleafProperties.isRenderHiddenMarkersBeforeCheckboxes()); var mainResolver = haloTemplateResolver(); mainResolver.setPrefix(cacheKey.context().getPath().resolve("templates") + "/"); engine.addTemplateResolver(mainResolver); var pluginTemplateResolver = createPluginClassloaderTemplateResolver(); engine.addTemplateResolver(pluginTemplateResolver); // replace StandardDialect with SpringStandardDialect engine.setDialect(new SpringStandardDialect() { @Override public IStandardVariableExpressionEvaluator getVariableExpressionEvaluator() { return ReactiveSpelVariableExpressionEvaluator.INSTANCE; } }); engine.addDialect(new HaloProcessorDialect()); templateResolvers.orderedStream().forEach(engine::addTemplateResolver); // we collect all template resolvers and add them into composite template resolver // to control the resolution flow var compositeTemplateResolver = new CompositeTemplateResolver(engine.getTemplateResolvers()); engine.setTemplateResolver(compositeTemplateResolver); dialects.orderedStream().forEach(engine::addDialect); return engine; } @NonNull private PluginClassloaderTemplateResolver createPluginClassloaderTemplateResolver() { var pluginTemplateResolver = new PluginClassloaderTemplateResolver(pluginManager); pluginTemplateResolver.setPrefix(thymeleafProperties.getPrefix()); pluginTemplateResolver.setSuffix(thymeleafProperties.getSuffix()); pluginTemplateResolver.setTemplateMode(thymeleafProperties.getMode()); pluginTemplateResolver.setOrder(1); if (thymeleafProperties.getEncoding() != null) { pluginTemplateResolver.setCharacterEncoding(thymeleafProperties.getEncoding().name()); } return pluginTemplateResolver; } FileTemplateResolver haloTemplateResolver() { final var resolver = new FileTemplateResolver(); resolver.setTemplateMode(thymeleafProperties.getMode()); resolver.setPrefix(thymeleafProperties.getPrefix()); resolver.setSuffix(thymeleafProperties.getSuffix()); resolver.setCacheable(thymeleafProperties.isCache()); resolver.setCheckExistence(thymeleafProperties.isCheckTemplate()); if (thymeleafProperties.getEncoding() != null) { resolver.setCharacterEncoding(thymeleafProperties.getEncoding().name()); } return resolver; } } ================================================ FILE: application/src/main/java/run/halo/app/theme/ThemeContext.java ================================================ package run.halo.app.theme; import java.nio.file.Path; import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; /** * @author guqing * @since 2.0.0 */ @Data @Builder @EqualsAndHashCode(of = "name") public class ThemeContext { public static final String THEME_PREVIEW_PARAM_NAME = "preview-theme"; private String name; private Path path; private boolean active; } ================================================ FILE: application/src/main/java/run/halo/app/theme/ThemeContextBasedVariablesAcquirer.java ================================================ package run.halo.app.theme; import java.util.Map; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.theme.finders.ThemeFinder; /** * Theme context based variables acquirer. * * @author guqing * @since 2.0.0 */ @Component public class ThemeContextBasedVariablesAcquirer implements ViewContextBasedVariablesAcquirer { private final ThemeFinder themeFinder; private final ThemeResolver themeResolver; public ThemeContextBasedVariablesAcquirer(ThemeFinder themeFinder, ThemeResolver themeResolver) { this.themeFinder = themeFinder; this.themeResolver = themeResolver; } @Override public Mono> acquire(ServerWebExchange exchange) { return themeResolver.getTheme(exchange) .flatMap(themeContext -> { String name = themeContext.getName(); return themeFinder.getByName(name); }) .map(themeVo -> Map.of("theme", themeVo)) .defaultIfEmpty(Map.of()); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/ThemeLinkBuilder.java ================================================ package run.halo.app.theme; import java.net.URI; import java.net.URISyntaxException; import org.apache.commons.lang3.StringUtils; import org.springframework.lang.NonNull; import org.springframework.web.util.UriComponentsBuilder; import org.thymeleaf.context.IExpressionContext; import org.thymeleaf.linkbuilder.StandardLinkBuilder; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.utils.PathUtils; /** * @author guqing * @since 2.0.0 */ public class ThemeLinkBuilder extends StandardLinkBuilder { public static final String THEME_ASSETS_PREFIX = "/assets"; public static final String THEME_PREVIEW_PREFIX = "/themes"; private final ThemeContext theme; private final ExternalUrlSupplier externalUrlSupplier; public ThemeLinkBuilder(ThemeContext theme, ExternalUrlSupplier externalUrlSupplier) { this.theme = theme; this.externalUrlSupplier = externalUrlSupplier; } @Override protected String processLink(IExpressionContext context, String link) { if (link == null || !linkInSite(externalUrlSupplier.get(), link)) { return link; } if (StringUtils.isBlank(link)) { link = "/"; } if (isAssetsRequest(link)) { return PathUtils.combinePath(THEME_PREVIEW_PREFIX, theme.getName(), link); } // not assets link if (theme.isActive()) { return link; } return UriComponentsBuilder.fromUriString(link) .queryParam(ThemeContext.THEME_PREVIEW_PARAM_NAME, theme.getName()) .build().toString(); } static boolean linkInSite(@NonNull URI externalUri, @NonNull String link) { if (!PathUtils.isAbsoluteUri(link)) { // relative uri is always in site return true; } try { URI requestUri = new URI(link); return StringUtils.equals(externalUri.getAuthority(), requestUri.getAuthority()); } catch (URISyntaxException e) { // ignore this link } return false; } private boolean isAssetsRequest(String link) { String assetsPrefix = externalUrlSupplier.get().resolve(THEME_ASSETS_PREFIX).toString(); return link.startsWith(assetsPrefix) || link.startsWith(THEME_ASSETS_PREFIX); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java ================================================ package run.halo.app.theme; import java.util.Locale; import java.util.Optional; import java.util.TimeZone; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.LocaleUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.context.i18n.LocaleContext; import org.springframework.context.i18n.SimpleTimeZoneAwareLocaleContext; import org.springframework.http.HttpCookie; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.adapter.WebHttpHandlerBuilder; import org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver; /** * @author guqing * @since 2.0.0 */ @Slf4j @Component(WebHttpHandlerBuilder.LOCALE_CONTEXT_RESOLVER_BEAN_NAME) public class ThemeLocaleContextResolver extends AcceptHeaderLocaleContextResolver { public static final String LANGUAGE_PARAMETER_NAME = "language"; public static final String LANGUAGE_COOKIE_NAME = LANGUAGE_PARAMETER_NAME; public static final String TIME_ZONE_COOKIE_NAME = "time_zone"; @Override @NonNull public LocaleContext resolveLocaleContext(@NonNull ServerWebExchange exchange) { var request = exchange.getRequest(); var locale = getLocaleFromQueryParameter(request) .or(() -> getLocaleFromCookie(request)) .or(() -> UserLocaleRequestAttributeWriteFilter.getUserLocale(request)) .orElseGet(() -> super.resolveLocaleContext(exchange).getLocale()); if (LocaleUtils.isLanguageUndetermined(locale)) { locale = null; } var timeZone = getTimeZoneFromCookie(request) .orElseGet(TimeZone::getDefault); return new SimpleTimeZoneAwareLocaleContext(locale, timeZone); } private Optional getLocaleFromCookie(ServerHttpRequest request) { return Optional.ofNullable(request.getCookies().getFirst(LANGUAGE_COOKIE_NAME)) .map(HttpCookie::getValue) .filter(StringUtils::isNotBlank) .map(Locale::forLanguageTag); } private Optional getLocaleFromQueryParameter(ServerHttpRequest request) { return Optional.ofNullable(request.getQueryParams().getFirst(LANGUAGE_PARAMETER_NAME)) .filter(StringUtils::isNotBlank) .map(Locale::forLanguageTag); } private Optional getTimeZoneFromCookie(ServerHttpRequest request) { return Optional.ofNullable(request.getCookies().getFirst(TIME_ZONE_COOKIE_NAME)) .map(HttpCookie::getValue) .filter(StringUtils::isNotBlank) .map(TimeZone::getTimeZone); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/ThemeResolver.java ================================================ package run.halo.app.theme; import static run.halo.app.security.authorization.AuthorityUtils.authoritiesToRoles; import java.util.Collection; import java.util.Set; import lombok.AllArgsConstructor; import lombok.Builder; import org.apache.commons.lang3.StringUtils; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.core.user.service.RoleService; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting.Theme; import run.halo.app.infra.SystemSetting.ThemeRouteRules; import run.halo.app.infra.ThemeRootGetter; import run.halo.app.security.authorization.AuthorityUtils; /** * @author johnniang * @since 2.0.0 */ @Component @AllArgsConstructor public class ThemeResolver { private final SystemConfigFetcher environmentFetcher; private final ThemeRootGetter themeRoot; private final RoleService roleService; public Mono getThemeContext(String themeName) { Assert.hasText(themeName, "Theme name cannot be empty"); var path = themeRoot.get().resolve(themeName); return Mono.just(ThemeContext.builder().name(themeName).path(path)) .flatMap(builder -> environmentFetcher.fetch(Theme.GROUP, Theme.class) .mapNotNull(Theme::getActive) .map(activatedTheme -> { boolean active = StringUtils.equals(activatedTheme, themeName); return builder.active(active); }) .defaultIfEmpty(builder.active(false)) ) .map(ThemeContext.ThemeContextBuilder::build); } public Mono getTheme(ServerWebExchange exchange) { return fetchThemeFromExchange(exchange) .switchIfEmpty(Mono.defer(() -> fetchActivationState() .map(themeState -> { var activatedTheme = themeState.activatedTheme(); var builder = ThemeContext.builder(); var themeName = exchange.getRequest().getQueryParams() .getFirst(ThemeContext.THEME_PREVIEW_PARAM_NAME); if (StringUtils.isBlank(themeName) || !themeState.supportsPreviewTheme()) { themeName = activatedTheme; } boolean active = StringUtils.equals(activatedTheme, themeName); var path = themeRoot.get().resolve(themeName); return builder.name(themeName) .path(path) .active(active) .build(); }) .doOnNext(themeContext -> exchange.getAttributes().put(ThemeContext.class.getName(), themeContext)) )); } public Mono fetchThemeFromExchange(ServerWebExchange exchange) { return Mono.justOrEmpty(exchange) .map(ServerWebExchange::getAttributes) .filter(attrs -> attrs.containsKey(ThemeContext.class.getName())) .map(attrs -> attrs.get(ThemeContext.class.getName())) .cast(ThemeContext.class); } private Mono fetchActivationState() { var builder = ThemeActivationState.builder(); var activatedMono = environmentFetcher.fetch(Theme.GROUP, Theme.class) .map(Theme::getActive) .switchIfEmpty(Mono.error(() -> new IllegalArgumentException("No theme activated"))) .doOnNext(builder::activatedTheme); var preivewDisabledMono = environmentFetcher.fetch(ThemeRouteRules.GROUP, ThemeRouteRules.class) .map(ThemeRouteRules::isDisableThemePreview) .defaultIfEmpty(false) .doOnNext(builder::previewDisabled); var themeManageMono = ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .filter(au -> !AnonymousUserConst.isAnonymousUser(au.getName())) .flatMap(au -> supportsPreviewTheme(authoritiesToRoles(au.getAuthorities()))) .doOnNext(builder::hasThemeManagementRole); return Mono.when(activatedMono, preivewDisabledMono, themeManageMono) .then(Mono.fromSupplier(builder::build)); } private Mono supportsPreviewTheme(Collection authorities) { return roleService.contains(authorities, Set.of(AuthorityUtils.THEME_MANAGEMENT_ROLE_NAME)) .defaultIfEmpty(false); } @Builder record ThemeActivationState(String activatedTheme, boolean previewDisabled, boolean hasThemeManagementRole) { private boolean supportsPreviewTheme() { if (hasThemeManagementRole) { return true; } return !previewDisabled; } } } ================================================ FILE: application/src/main/java/run/halo/app/theme/UserLocaleRequestAttributeWriteFilter.java ================================================ package run.halo.app.theme; import java.util.Locale; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; @Component @Order(Ordered.HIGHEST_PRECEDENCE) @RequiredArgsConstructor public class UserLocaleRequestAttributeWriteFilter implements WebFilter { public static final String USER_LOCALE_ATTRIBUTE = UserLocaleRequestAttributeWriteFilter.class.getName() + ".USER_LOCALE_ATTRIBUTE"; private final SystemConfigFetcher environmentFetcher; @Override @NonNull public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { return environmentFetcher.getBasic() .map(SystemSetting.Basic::useSystemLocale) .doOnNext(localeOpt -> localeOpt .ifPresent(locale -> exchange.getAttributes().put(USER_LOCALE_ATTRIBUTE, locale)) ) .then(chain.filter(exchange)); } public static Optional getUserLocale(ServerHttpRequest request) { return Optional.ofNullable((Locale) request.getAttributes() .get(USER_LOCALE_ATTRIBUTE)); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/ViewContextBasedVariablesAcquirer.java ================================================ package run.halo.app.theme; import java.util.Map; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @FunctionalInterface public interface ViewContextBasedVariablesAcquirer { Mono> acquire(ServerWebExchange exchange); } ================================================ FILE: application/src/main/java/run/halo/app/theme/ViewNameResolver.java ================================================ package run.halo.app.theme; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; /** * The {@link ViewNameResolver} is used to resolve view name if the view name cannot be resolved * to the view, the default view name is returned. * * @author guqing * @since 2.10.2 */ public interface ViewNameResolver { Mono resolveViewNameOrDefault(ServerWebExchange exchange, String name, String defaultName); Mono resolveViewNameOrDefault(ServerRequest request, String name, String defaultName); } ================================================ FILE: application/src/main/java/run/halo/app/theme/config/ThemeConfiguration.java ================================================ package run.halo.app.theme.config; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.info.BuildProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect; import run.halo.app.theme.dialect.GeneratorMetaProcessor; import run.halo.app.theme.dialect.HaloSpringSecurityDialect; import run.halo.app.theme.dialect.LinkExpressionObjectDialect; import run.halo.app.theme.dialect.TemplateHeadProcessor; /** * @author guqing * @since 2.0.0 */ @Configuration public class ThemeConfiguration { @Bean LinkExpressionObjectDialect linkExpressionObjectDialect() { return new LinkExpressionObjectDialect(); } @Bean SpringSecurityDialect springSecurityDialect( ServerSecurityContextRepository securityContextRepository, ObjectProvider expressionHandler) { return new HaloSpringSecurityDialect(securityContextRepository, expressionHandler); } @Bean @ConditionalOnProperty(name = "halo.theme.generator-meta-disabled", havingValue = "false", matchIfMissing = true) TemplateHeadProcessor generatorMetaProcessor(ObjectProvider buildProperties) { return new GeneratorMetaProcessor(buildProperties); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/config/ThemeWebFluxConfigurer.java ================================================ package run.halo.app.theme.config; import java.nio.file.Path; import java.util.List; import java.util.Map; import org.apache.commons.lang3.StringUtils; import org.springframework.boot.autoconfigure.web.WebProperties; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.CacheControl; import org.springframework.stereotype.Component; import org.springframework.web.reactive.HandlerMapping; import org.springframework.web.reactive.config.ResourceHandlerRegistry; import org.springframework.web.reactive.config.WebFluxConfigurer; import org.springframework.web.reactive.resource.AbstractResourceResolver; import org.springframework.web.reactive.resource.EncodedResourceResolver; import org.springframework.web.reactive.resource.ResourceResolverChain; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.infra.ThemeRootGetter; import run.halo.app.infra.utils.FileUtils; @Component public class ThemeWebFluxConfigurer implements WebFluxConfigurer { private final ThemeRootGetter themeRootGetter; private final WebProperties.Resources resourcesProperties; public ThemeWebFluxConfigurer(ThemeRootGetter themeRootGetter, WebProperties webProperties) { this.themeRootGetter = themeRootGetter; this.resourcesProperties = webProperties.getResources(); } @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { var cacheControl = resourcesProperties.getCache().getCachecontrol().toHttpCacheControl(); if (cacheControl == null) { cacheControl = CacheControl.empty(); } var useLastModified = resourcesProperties.getCache().isUseLastModified(); registry.addResourceHandler("/themes/{themeName}/assets/{*resourcePaths}") .setCacheControl(cacheControl) .setUseLastModified(useLastModified) .resourceChain(true) .addResolver(new EncodedResourceResolver()) .addResolver(new ThemePathResourceResolver(themeRootGetter.get())); } /** * Theme path resource resolver. The resolver is used to resolve theme assets from the request * path. * * @author johnniang */ private static class ThemePathResourceResolver extends AbstractResourceResolver { private final Path themeRoot; private ThemePathResourceResolver(Path themeRoot) { this.themeRoot = themeRoot; } @Override protected Mono resolveResourceInternal(ServerWebExchange exchange, String requestPath, List locations, ResourceResolverChain chain) { if (exchange == null) { return Mono.empty(); } Map requiredAttribute = exchange.getRequiredAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); var themeName = requiredAttribute.get("themeName"); var resourcePaths = requiredAttribute.get("resourcePaths"); if (StringUtils.isAnyBlank(themeName, resourcePaths)) { return Mono.empty(); } var assetsPath = themeRoot.resolve(themeName + "/templates/assets/" + resourcePaths); FileUtils.checkDirectoryTraversal(themeRoot, assetsPath); var location = new FileSystemResource(assetsPath); if (!location.isReadable()) { return Mono.empty(); } return Mono.just(location); } @Override protected Mono resolveUrlPathInternal(String resourceUrlPath, List locations, ResourceResolverChain chain) { throw new UnsupportedOperationException(); } } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java ================================================ package run.halo.app.theme.dialect; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IProcessableElementTag; import org.thymeleaf.processor.element.AbstractElementTagProcessor; import org.thymeleaf.processor.element.IElementTagStructureHandler; import org.thymeleaf.templatemode.TemplateMode; /** *

Comment element tag processor.

*

Replace the comment tag <halo:comment /> with the given content.

* * @author guqing * @see CommentEnabledVariableProcessor * @since 2.0.0 */ public class CommentElementTagProcessor extends AbstractElementTagProcessor { private static final String TAG_NAME = "comment"; private static final int PRECEDENCE = 1000; /** * Constructor footer element tag processor with HTML mode, dialect prefix, comment tag name. * * @param dialectPrefix dialect prefix */ public CommentElementTagProcessor(final String dialectPrefix) { super( TemplateMode.HTML, // This processor will apply only to HTML mode dialectPrefix, // Prefix to be applied to name for matching TAG_NAME, // Tag name: match specifically this tag true, // Apply dialect prefix to tag name null, // No attribute name: will match by tag name false, // No prefix to be applied to attribute name PRECEDENCE); // Precedence (inside dialect's own precedence) } @Override protected void doProcess(ITemplateContext context, IProcessableElementTag tag, IElementTagStructureHandler structureHandler) { var commentWidget = (CommentWidget) context.getVariable( CommentEnabledVariableProcessor.COMMENT_WIDGET_OBJECT_VARIABLE); if (commentWidget == null) { structureHandler.replaceWith("", false); return; } commentWidget.render(SecureTemplateContextWrapper.wrap(context), tag, structureHandler); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/CommentEnabledVariableProcessor.java ================================================ package run.halo.app.theme.dialect; import static org.apache.commons.lang3.BooleanUtils.isFalse; import static org.apache.commons.lang3.BooleanUtils.isTrue; import java.time.Duration; import java.util.Optional; import org.springframework.context.ApplicationContext; import org.springframework.core.convert.support.DefaultConversionService; import org.thymeleaf.context.Contexts; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.context.IWebContext; import org.thymeleaf.model.ITemplateEnd; import org.thymeleaf.model.ITemplateStart; import org.thymeleaf.processor.templateboundaries.AbstractTemplateBoundariesProcessor; import org.thymeleaf.processor.templateboundaries.ITemplateBoundariesStructureHandler; import org.thymeleaf.spring6.context.SpringContextUtils; import org.thymeleaf.standard.StandardDialect; import org.thymeleaf.templatemode.TemplateMode; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** * Comment enabled variable processor. *

Compute comment enabled state and set it to the model when the template is start rendering

*

It is not suitable for scenarios where there are multiple comment components on the same page * and some of them need to be controlled to be closed.

* * @author guqing * @since 2.9.0 */ public class CommentEnabledVariableProcessor extends AbstractTemplateBoundariesProcessor { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; public static final String COMMENT_WIDGET_OBJECT_VARIABLE = CommentWidget.class.getName(); public static final String COMMENT_ENABLED_MODEL_ATTRIBUTE = "haloCommentEnabled"; public CommentEnabledVariableProcessor() { super(TemplateMode.HTML, StandardDialect.PROCESSOR_PRECEDENCE); } @Override public void doProcessTemplateStart(ITemplateContext context, ITemplateStart templateStart, ITemplateBoundariesStructureHandler structureHandler) { getCommentWidget(context).ifPresentOrElse(commentWidget -> { populateAllowCommentAttribute(context, true); structureHandler.setLocalVariable(COMMENT_WIDGET_OBJECT_VARIABLE, commentWidget); }, () -> populateAllowCommentAttribute(context, false)); } @Override public void doProcessTemplateEnd(ITemplateContext context, ITemplateEnd templateEnd, ITemplateBoundariesStructureHandler structureHandler) { structureHandler.removeLocalVariable(COMMENT_WIDGET_OBJECT_VARIABLE); } static void populateAllowCommentAttribute(ITemplateContext context, boolean allowComment) { if (Contexts.isWebContext(context)) { IWebContext webContext = Contexts.asWebContext(context); webContext.getExchange() .setAttributeValue(COMMENT_ENABLED_MODEL_ATTRIBUTE, allowComment); } } static Optional getCommentWidget(ITemplateContext context) { final ApplicationContext appCtx = SpringContextUtils.getApplicationContext(context); SystemConfigFetcher environmentFetcher = appCtx.getBean(SystemConfigFetcher.class); var commentSetting = environmentFetcher.fetchComment() .blockOptional(BLOCKING_TIMEOUT) .orElseThrow(); var globalEnabled = isTrue(commentSetting.getEnable()); if (!globalEnabled) { return Optional.empty(); } if (Contexts.isWebContext(context)) { IWebContext webContext = Contexts.asWebContext(context); Object attributeValue = webContext.getExchange() .getAttributeValue(CommentWidget.ENABLE_COMMENT_ATTRIBUTE); Boolean enabled = DefaultConversionService.getSharedInstance() .convert(attributeValue, Boolean.class); if (isFalse(enabled)) { return Optional.empty(); } } ExtensionGetter extensionGetter = appCtx.getBean(ExtensionGetter.class); return extensionGetter.getEnabledExtensions(CommentWidget.class) .next() .blockOptional(BLOCKING_TIMEOUT); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessor.java ================================================ package run.halo.app.theme.dialect; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static org.apache.commons.lang3.StringUtils.defaultIfBlank; import static org.thymeleaf.model.AttributeValueQuotes.DOUBLE; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.web.util.HtmlUtils; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IModel; import org.thymeleaf.model.IModelFactory; import org.thymeleaf.model.ITemplateEvent; import org.thymeleaf.processor.element.IElementModelStructureHandler; import reactor.core.publisher.Mono; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.SinglePageFinder; import run.halo.app.theme.router.ModelConst; /** *

The head html snippet injection processor for content template such as post * and page.

* * @author guqing * @since 2.0.0 */ @Component @Order(1) @AllArgsConstructor public class ContentTemplateHeadProcessor implements TemplateHeadProcessor { private static final String POST_NAME_VARIABLE = "name"; private final PostFinder postFinder; private final SinglePageFinder singlePageFinder; @Override public Mono process(ITemplateContext context, IModel model, IElementModelStructureHandler structureHandler) { Mono nameMono = Mono.justOrEmpty((String) context.getVariable(POST_NAME_VARIABLE)); Mono>> htmlMetasMono = Mono.empty(); if (isPostTemplate(context)) { htmlMetasMono = nameMono.flatMap(postFinder::getByName) .map(post -> { List> htmlMetas = post.getSpec().getHtmlMetas(); String excerpt = post.getStatus() == null ? null : post.getStatus().getExcerpt(); return excerptToMetaDescriptionIfAbsent(htmlMetas, excerpt); }); } else if (isPageTemplate(context)) { htmlMetasMono = nameMono.flatMap(singlePageFinder::getByName) .map(page -> { List> htmlMetas = page.getSpec().getHtmlMetas(); String excerpt = page.getStatus() == null ? null : page.getStatus().getExcerpt(); return excerptToMetaDescriptionIfAbsent(htmlMetas, excerpt); }); } return htmlMetasMono .doOnNext( htmlMetas -> buildMetas(context.getModelFactory(), htmlMetas).forEach(model::add) ) .then(); } static List> excerptToMetaDescriptionIfAbsent( List> htmlMetas, String excerpt) { String excerptNullSafe = StringUtils.defaultString(excerpt); final String excerptSafe = HtmlUtils.htmlEscape(excerptNullSafe); List> metas = new ArrayList<>(defaultIfNull(htmlMetas, List.of())); metas.stream() .filter(map -> Meta.DESCRIPTION.equals(map.get(Meta.NAME))) .distinct() .findFirst() .ifPresentOrElse(map -> map.put(Meta.CONTENT, defaultIfBlank(map.get(Meta.CONTENT), excerptSafe)), () -> { Map map = new HashMap<>(); map.put(Meta.NAME, Meta.DESCRIPTION); map.put(Meta.CONTENT, excerptSafe); metas.add(map); }); return metas; } interface Meta { String DESCRIPTION = "description"; String NAME = "name"; String CONTENT = "content"; } private List buildMetas(IModelFactory modelFactory, List> metas) { return metas.stream() .map(metaMap -> modelFactory.createStandaloneElementTag("meta", metaMap, DOUBLE, false, true) ).collect(Collectors.toList()); } private boolean isPostTemplate(ITemplateContext context) { return DefaultTemplateEnum.POST.getValue() .equals(context.getVariable(ModelConst.TEMPLATE_ID)); } private boolean isPageTemplate(ITemplateContext context) { return DefaultTemplateEnum.SINGLE_PAGE.getValue() .equals(context.getVariable(ModelConst.TEMPLATE_ID)); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/DefaultFaviconHeadProcessor.java ================================================ package run.halo.app.theme.dialect; import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IModel; import org.thymeleaf.model.IModelFactory; import org.thymeleaf.processor.element.IElementModelStructureHandler; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; /** * Theme template head tag snippet injection processor for favicon. * * @author guqing * @since 2.0.0 */ @Component @AllArgsConstructor public class DefaultFaviconHeadProcessor implements TemplateHeadProcessor { private final SystemConfigFetcher fetcher; @Override public Mono process(ITemplateContext context, IModel model, IElementModelStructureHandler structureHandler) { return fetchBasicSetting() .filter(basic -> StringUtils.isNotBlank(basic.getFavicon())) .map(basic -> { IModelFactory modelFactory = context.getModelFactory(); model.add(modelFactory.createText(faviconSnippet(basic.getFavicon()))); return model; }) .then(); } private String faviconSnippet(String favicon) { return String.format("\n", favicon); } private Mono fetchBasicSetting() { return fetcher.fetch(SystemSetting.Basic.GROUP, SystemSetting.Basic.class); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/DefaultLinkExpressionFactory.java ================================================ package run.halo.app.theme.dialect; import java.util.Set; import org.thymeleaf.context.IExpressionContext; import org.thymeleaf.exceptions.TemplateProcessingException; import org.thymeleaf.expression.IExpressionObjectFactory; import org.thymeleaf.linkbuilder.ILinkBuilder; import org.thymeleaf.util.Validate; import run.halo.app.theme.ThemeLinkBuilder; /** * A default implementation of {@link IExpressionObjectFactory}. * * @author guqing * @since 2.0.0 */ public class DefaultLinkExpressionFactory implements IExpressionObjectFactory { private static final String THEME_EVALUATION_VARIABLE_NAME = "theme"; @Override public Set getAllExpressionObjectNames() { return Set.of(THEME_EVALUATION_VARIABLE_NAME); } @Override public Object buildObject(IExpressionContext context, String expressionObjectName) { if (THEME_EVALUATION_VARIABLE_NAME.equals(expressionObjectName)) { return new ThemeLinkExpressObject(context); } return null; } @Override public boolean isCacheable(String expressionObjectName) { return THEME_EVALUATION_VARIABLE_NAME.equals(expressionObjectName); } public static class ThemeLinkExpressObject { private final ILinkBuilder linkBuilder; private final IExpressionContext context; /** * Construct an expression object that provides a set of methods to handle link in * Javascript or HTML through {@link IExpressionContext}. * * @param context expression context */ public ThemeLinkExpressObject(IExpressionContext context) { Validate.notNull(context, "Context cannot be null"); this.context = context; Set linkBuilders = context.getConfiguration().getLinkBuilders(); linkBuilder = linkBuilders.stream() .findFirst() .orElseThrow(() -> new TemplateProcessingException("Link builder not found")); } public String assets(String path) { String assetsPath = ThemeLinkBuilder.THEME_ASSETS_PREFIX + path; return linkBuilder.buildLink(context, assetsPath, null); } public String route(String path) { return linkBuilder.buildLink(context, path, null); } } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/DuplicateMetaTagProcessor.java ================================================ package run.halo.app.theme.dialect; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import lombok.AllArgsConstructor; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IModel; import org.thymeleaf.model.IProcessableElementTag; import org.thymeleaf.model.ITemplateEvent; import org.thymeleaf.model.IText; import org.thymeleaf.processor.element.IElementModelStructureHandler; import reactor.core.publisher.Mono; /** *

This processor will remove the duplicate meta tag with the same name in head tag and only * keep the last one.

*

This processor will be executed last.

* * @author guqing * @since 2.0.0 */ @Order @Component @AllArgsConstructor public class DuplicateMetaTagProcessor implements TemplateHeadProcessor { static final Pattern META_PATTERN = Pattern.compile("]+?name=\"([^\"]+)\"[^>]*>\\n*"); @Override public Mono process(ITemplateContext context, IModel model, IElementModelStructureHandler structureHandler) { IModel newModel = context.getModelFactory().createModel(); Map uniqueMetaTags = new LinkedHashMap<>(); List otherModel = new ArrayList<>(); for (int i = 0; i < model.size(); i++) { ITemplateEvent templateEvent = model.get(i); // If the current node is a text node, it is processed separately. // Because the text node may contain multiple meta tags. if (templateEvent instanceof IText textNode) { String text = textNode.getText(); Matcher matcher = META_PATTERN.matcher(text); while (matcher.find()) { String tagLine = matcher.group(0); String nameAttribute = matcher.group(1); // create a new text node to replace the original text node // replace multiple line breaks with one line break IText metaTagNode = context.getModelFactory() .createText(tagLine.replaceAll("\\n+", "\n")); uniqueMetaTags.put(nameAttribute, new IndexedModel(i, metaTagNode)); text = text.replace(tagLine, ""); } // put the rest of the text into the other model IText otherText = context.getModelFactory() .createText(text); otherModel.add(new IndexedModel(i, otherText)); continue; } if (templateEvent instanceof IProcessableElementTag tag) { var indexedModel = new IndexedModel(i, tag); if ("meta".equals(tag.getElementCompleteName())) { var attribute = tag.getAttribute("name"); if (attribute != null) { uniqueMetaTags.put(attribute.getValue(), indexedModel); continue; } } } otherModel.add(new IndexedModel(i, templateEvent)); } otherModel.addAll(uniqueMetaTags.values()); otherModel.stream().sorted(Comparator.comparing(IndexedModel::index)) .map(IndexedModel::templateEvent) .forEach(newModel::add); model.reset(); model.addModel(newModel); return Mono.empty(); } record IndexedModel(int index, ITemplateEvent templateEvent) { } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/EvaluationContextEnhancer.java ================================================ package run.halo.app.theme.dialect; import static run.halo.app.theme.HaloViewResolver.HaloView.CONTEXT_VIEW_KEY; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; import org.springframework.core.convert.TypeDescriptor; import org.springframework.expression.AccessException; import org.springframework.expression.EvaluationContext; import org.springframework.expression.MethodExecutor; import org.springframework.expression.MethodResolver; import org.springframework.expression.PropertyAccessor; import org.springframework.expression.TypedValue; import org.springframework.expression.spel.CompilablePropertyAccessor; import org.springframework.expression.spel.support.ReflectivePropertyAccessor; import org.springframework.integration.json.JsonPropertyAccessor; import org.springframework.lang.Nullable; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.ITemplateEnd; import org.thymeleaf.model.ITemplateStart; import org.thymeleaf.processor.templateboundaries.AbstractTemplateBoundariesProcessor; import org.thymeleaf.processor.templateboundaries.ITemplateBoundariesStructureHandler; import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; import org.thymeleaf.standard.StandardDialect; import org.thymeleaf.templatemode.TemplateMode; import reactor.util.context.ContextView; import run.halo.app.infra.utils.ReactiveUtils; /** * Enhance the evaluation context to support reactive types. * * @author guqing * @author johnniang * @since 2.20.0 */ public class EvaluationContextEnhancer extends AbstractTemplateBoundariesProcessor { private static final int PRECEDENCE = StandardDialect.PROCESSOR_PRECEDENCE; private static final JsonPropertyAccessor JSON_PROPERTY_ACCESSOR = new JsonPropertyAccessor(); public EvaluationContextEnhancer() { super(TemplateMode.HTML, PRECEDENCE); } @Override public void doProcessTemplateStart(ITemplateContext context, ITemplateStart templateStart, ITemplateBoundariesStructureHandler structureHandler) { var evluationContextObject = context.getVariable( ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME ); if (evluationContextObject instanceof ThymeleafEvaluationContext evaluationContext) { evaluationContext.addPropertyAccessor(JSON_PROPERTY_ACCESSOR); ReactiveReflectivePropertyAccessor.wrap(evaluationContext); ReactiveMethodResolver.wrap(evaluationContext, context); } } @Override public void doProcessTemplateEnd(ITemplateContext context, ITemplateEnd templateEnd, ITemplateBoundariesStructureHandler structureHandler) { // nothing to do } /** * A {@link PropertyAccessor} that wraps the original {@link ReflectivePropertyAccessor} and * blocks the reactive value. */ private static class ReactiveReflectivePropertyAccessor extends ReflectivePropertyAccessor { private final ReflectivePropertyAccessor delegate; private ReactiveReflectivePropertyAccessor(ReflectivePropertyAccessor delegate) { this.delegate = delegate; } @Override public boolean canRead(EvaluationContext context, Object target, String name) throws AccessException { if (target == null) { // For backward compatibility return true; } return this.delegate.canRead(context, target, name); } @Override public TypedValue read(EvaluationContext context, Object target, String name) throws AccessException { if (target == null) { // For backward compatibility return TypedValue.NULL; } var typedValue = delegate.read(context, target, name); return Optional.of(typedValue) .filter(tv -> Objects.nonNull(tv.getValue()) && Objects.nonNull(tv.getTypeDescriptor()) && ReactiveUtils.isReactiveType(tv.getTypeDescriptor().getType()) ) .map(tv -> new TypedValue(ReactiveUtils.blockReactiveValue(tv.getValue()))) .orElse(typedValue); } @Override public boolean canWrite(EvaluationContext context, Object target, String name) throws AccessException { return delegate.canWrite(context, target, name); } @Override public void write(EvaluationContext context, Object target, String name, Object newValue) throws AccessException { delegate.write(context, target, name, newValue); } @Override public Class[] getSpecificTargetClasses() { return delegate.getSpecificTargetClasses(); } @Override public PropertyAccessor createOptimalAccessor(EvaluationContext context, Object target, String name) { var optimalAccessor = delegate.createOptimalAccessor(context, target, name); if (optimalAccessor instanceof CompilablePropertyAccessor optimalPropertyAccessor) { if (ReactiveUtils.isReactiveType(optimalPropertyAccessor.getPropertyType())) { return this; } return optimalPropertyAccessor; } return this; } static void wrap(ThymeleafEvaluationContext evaluationContext) { var wrappedPropertyAccessors = evaluationContext.getPropertyAccessors() .stream() .map(propertyAccessor -> { if (propertyAccessor instanceof ReflectivePropertyAccessor reflectiveAccessor) { return new ReactiveReflectivePropertyAccessor(reflectiveAccessor); } return propertyAccessor; }) // make the list mutable .collect(Collectors.toCollection(ArrayList::new)); evaluationContext.setPropertyAccessors(wrappedPropertyAccessors); } @Override public boolean equals(Object obj) { return delegate.equals(obj); } @Override public int hashCode() { return delegate.hashCode(); } } /** * A {@link MethodResolver} that wraps the original {@link MethodResolver} and blocks the * reactive value. * * @param delegate the original {@link MethodResolver} * @param templateContext the template context */ private record ReactiveMethodResolver(MethodResolver delegate, ITemplateContext templateContext) implements MethodResolver { @Override @Nullable public MethodExecutor resolve(EvaluationContext context, Object targetObject, String name, List argumentTypes) throws AccessException { var executor = delegate.resolve(context, targetObject, name, argumentTypes); return Optional.ofNullable(executor) .map(methodExecutor -> new ReactiveMethodExecutor(methodExecutor, templateContext)) .orElse(null); } static void wrap(ThymeleafEvaluationContext evaluationContext, ITemplateContext context) { var wrappedMethodResolvers = evaluationContext.getMethodResolvers() .stream() .map( methodResolver -> new ReactiveMethodResolver(methodResolver, context) ) // make the list mutable .collect(Collectors.toCollection(ArrayList::new)); evaluationContext.setMethodResolvers(wrappedMethodResolvers); } } /** * A {@link MethodExecutor} that wraps the original {@link MethodExecutor} and blocks the * reactive value. * * @param delegate the original {@link MethodExecutor} * @param templateContext the template context */ private record ReactiveMethodExecutor(MethodExecutor delegate, ITemplateContext templateContext) implements MethodExecutor { @Override public TypedValue execute(EvaluationContext context, Object target, Object... arguments) throws AccessException { var typedValue = delegate.execute(context, target, arguments); return Optional.of(typedValue) .filter(tv -> Objects.nonNull(tv.getValue()) && Objects.nonNull(tv.getTypeDescriptor()) && ReactiveUtils.isReactiveType(tv.getTypeDescriptor().getType()) ) .map(tv -> { var contextView = (ContextView) Optional.ofNullable( templateContext.getVariable(CONTEXT_VIEW_KEY) ) .filter(ContextView.class::isInstance) .orElse(null); return new TypedValue( ReactiveUtils.blockReactiveValue(tv.getValue(), contextView) ); }) .orElse(typedValue); } } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/GeneratorMetaProcessor.java ================================================ package run.halo.app.theme.dialect; import static org.thymeleaf.model.AttributeValueQuotes.DOUBLE; import java.util.Map; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.info.BuildProperties; import org.springframework.core.annotation.Order; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IModel; import org.thymeleaf.processor.element.IElementModelStructureHandler; import reactor.core.publisher.Mono; /** * Processor for generating generator meta. * Set the order to 0 for removing the meta in later TemplateHeadProcessor. * * @author johnniang */ @Order(0) public class GeneratorMetaProcessor implements TemplateHeadProcessor { private final String generatorValue; public GeneratorMetaProcessor(ObjectProvider buildProperties) { this.generatorValue = "Halo " + buildProperties.stream().findFirst() .map(BuildProperties::getVersion) .orElse("Unknown"); } @Override public Mono process(ITemplateContext context, IModel model, IElementModelStructureHandler structureHandler) { return Mono.fromRunnable(() -> { var modelFactory = context.getModelFactory(); var generatorMeta = modelFactory.createStandaloneElementTag("meta", Map.of("name", "generator", "content", generatorValue), DOUBLE, false, true); model.add(generatorMeta); }); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java ================================================ package run.halo.app.theme.dialect; import static org.thymeleaf.spring6.context.SpringContextUtils.getApplicationContext; import java.time.Duration; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IModel; import org.thymeleaf.model.ITemplateEvent; import org.thymeleaf.processor.element.AbstractElementModelProcessor; import org.thymeleaf.processor.element.IElementModelStructureHandler; import org.thymeleaf.templatemode.TemplateMode; import reactor.core.publisher.Flux; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** * Global head injection processor. * * @author guqing * @since 2.0.0 */ public class GlobalHeadInjectionProcessor extends AbstractElementModelProcessor { private static final Duration BLOCKING_TIMEOUT = Duration.ofSeconds(10); /** * Inserting tag will re-trigger this processor, in order to avoid the loop out trigger, * this flag is required to prevent the loop problem. */ private static final String PROCESS_FLAG = GlobalHeadInjectionProcessor.class.getName() + ".PROCESSED"; private static final String TAG_NAME = "head"; private static final int PRECEDENCE = 1000; public GlobalHeadInjectionProcessor(final String dialectPrefix) { super( TemplateMode.HTML, // This processor will apply only to HTML mode dialectPrefix, // Prefix to be applied to name for matching TAG_NAME, // Tag name: match specifically this tag false, // Apply dialect prefix to tag name null, // No attribute name: will match by tag name false, // No prefix to be applied to attribute name PRECEDENCE); // Precedence (inside dialect's own precedence) } @Override protected void doProcess(ITemplateContext context, IModel model, IElementModelStructureHandler structureHandler) { if (context.containsVariable(InjectionExcluderProcessor.EXCLUDE_INJECTION_VARIABLE)) { return; } // note that this is important!! Object processedAlready = context.getVariable(PROCESS_FLAG); if (processedAlready != null) { return; } structureHandler.setLocalVariable(PROCESS_FLAG, true); // handle tag if (model.size() < 2) { return; } /* * Create the DOM structure that will be substituting our custom tag. * The headline will be shown inside a '
' tag, and so this must * be created first and then a Text node must be added to it. */ IModel modelToInsert = model.cloneModel(); // close tag final ITemplateEvent closeHeadTag = modelToInsert.get(modelToInsert.size() - 1); modelToInsert.remove(modelToInsert.size() - 1); // open tag final ITemplateEvent openHeadTag = modelToInsert.get(0); modelToInsert.remove(0); // apply processors to modelToInsert getTemplateHeadProcessors(context) .concatMap(processor -> processor.process( SecureTemplateContextWrapper.wrap(context), modelToInsert, structureHandler) ) .then() .block(BLOCKING_TIMEOUT); // reset model to insert model.reset(); model.add(openHeadTag); model.addModel(modelToInsert); model.add(closeHeadTag); } private Flux getTemplateHeadProcessors(ITemplateContext context) { var extensionGetter = getApplicationContext(context).getBeanProvider(ExtensionGetter.class) .getIfUnique(); if (extensionGetter == null) { return Flux.empty(); } return extensionGetter.getExtensions(TemplateHeadProcessor.class); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/GlobalSeoProcessor.java ================================================ package run.halo.app.theme.dialect; import static run.halo.app.theme.Constant.META_DESCRIPTION_VARIABLE_NAME; import java.util.LinkedHashMap; import lombok.AllArgsConstructor; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.AttributeValueQuotes; import org.thymeleaf.model.IModel; import org.thymeleaf.model.IModelFactory; import org.thymeleaf.processor.element.IElementModelStructureHandler; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; /** * Inject code to the template head tag according to the global seo settings. * * @author guqing * @see SystemSetting.Seo * @since 2.0.0 */ @Order(Ordered.HIGHEST_PRECEDENCE + 1) @Component @AllArgsConstructor class GlobalSeoProcessor implements TemplateHeadProcessor { private final SystemConfigFetcher environmentFetcher; @Override public Mono process(ITemplateContext context, IModel model, IElementModelStructureHandler structureHandler) { return environmentFetcher.fetch(SystemSetting.Seo.GROUP, SystemSetting.Seo.class) .switchIfEmpty(Mono.fromSupplier(SystemSetting.Seo::new)) .doOnNext(seo -> { IModelFactory modelFactory = context.getModelFactory(); if (Boolean.TRUE.equals(seo.getBlockSpiders())) { var attributes = LinkedHashMap.newLinkedHashMap(2); attributes.put("name", "robots"); attributes.put("content", "noindex"); var metaTag = modelFactory.createStandaloneElementTag( "meta", attributes, AttributeValueQuotes.DOUBLE, false, true ); model.add(metaTag); return; } var seoMetaDescription = context.getVariable(META_DESCRIPTION_VARIABLE_NAME); if (seoMetaDescription instanceof String description && !description.isBlank()) { var attributes = LinkedHashMap.newLinkedHashMap(2); attributes.put("name", "description"); attributes.put("content", description); var metaTag = modelFactory.createStandaloneElementTag( "meta", attributes, AttributeValueQuotes.DOUBLE, false, true ); model.add(metaTag); } }) .then(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/HaloExpressionObjectFactory.java ================================================ package run.halo.app.theme.dialect; import java.util.Set; import org.thymeleaf.context.IExpressionContext; import org.thymeleaf.expression.IExpressionObjectFactory; import run.halo.app.theme.dialect.expression.Annotations; /** * Builds the expression objects to be used by Halo dialects. * * @author guqing * @since 2.0.0 */ public class HaloExpressionObjectFactory implements IExpressionObjectFactory { public static final String ANNOTATIONS_EXPRESSION_OBJECT_NAME = "annotations"; protected static final Set ALL_EXPRESSION_OBJECT_NAMES = Set.of( ANNOTATIONS_EXPRESSION_OBJECT_NAME); private static final Annotations ANNOTATIONS = new Annotations(); @Override public Set getAllExpressionObjectNames() { return ALL_EXPRESSION_OBJECT_NAMES; } @Override public Object buildObject(IExpressionContext context, String expressionObjectName) { if (ANNOTATIONS_EXPRESSION_OBJECT_NAME.equals(expressionObjectName)) { return ANNOTATIONS; } return null; } @Override public boolean isCacheable(String expressionObjectName) { return true; } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/HaloPostTemplateHandler.java ================================================ package run.halo.app.theme.dialect; import static org.thymeleaf.spring6.context.SpringContextUtils.getApplicationContext; import java.time.Duration; import java.util.List; import java.util.Objects; import java.util.Optional; import org.springframework.lang.NonNull; import org.springframework.util.CollectionUtils; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.engine.AbstractTemplateHandler; import org.thymeleaf.model.IOpenElementTag; import org.thymeleaf.model.IProcessableElementTag; import org.thymeleaf.model.IStandaloneElementTag; import reactor.core.publisher.Mono; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** * Template post-handler. * * @author johnniang * @since 2.20.0 */ public class HaloPostTemplateHandler extends AbstractTemplateHandler { private List postProcessors = List.of(); @Override public void setContext(ITemplateContext context) { super.setContext(context); this.postProcessors = Optional.ofNullable(getApplicationContext(context)) .map(appContext -> appContext.getBeanProvider(ExtensionGetter.class).getIfUnique()) .map(extensionGetter -> extensionGetter.getExtensionList(ElementTagPostProcessor.class)) .orElseGet(List::of); } @Override public void handleStandaloneElement(IStandaloneElementTag standaloneElementTag) { var processedTag = handleElementTag(standaloneElementTag); super.handleStandaloneElement((IStandaloneElementTag) processedTag); } @Override public void handleOpenElement(IOpenElementTag openElementTag) { var processedTag = handleElementTag(openElementTag); super.handleOpenElement((IOpenElementTag) processedTag); } @NonNull private IProcessableElementTag handleElementTag( @NonNull IProcessableElementTag processableElementTag ) { IProcessableElementTag processedTag = processableElementTag; if (!CollectionUtils.isEmpty(postProcessors)) { var tagProcessorChain = Mono.just(processableElementTag); var context = getContext(); for (ElementTagPostProcessor elementTagPostProcessor : postProcessors) { tagProcessorChain = tagProcessorChain.flatMap( tag -> elementTagPostProcessor.process( SecureTemplateContextWrapper.wrap(context), tag) .defaultIfEmpty(tag) ); } processedTag = Objects.requireNonNull(tagProcessorChain.defaultIfEmpty(processableElementTag) .block(Duration.ofMinutes(1))); } return processedTag; } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java ================================================ package run.halo.app.theme.dialect; import static org.thymeleaf.templatemode.TemplateMode.HTML; import java.util.HashSet; import java.util.Set; import org.thymeleaf.dialect.AbstractProcessorDialect; import org.thymeleaf.dialect.IExpressionObjectDialect; import org.thymeleaf.dialect.IPostProcessorDialect; import org.thymeleaf.expression.IExpressionObjectFactory; import org.thymeleaf.postprocessor.IPostProcessor; import org.thymeleaf.postprocessor.PostProcessor; import org.thymeleaf.processor.IProcessor; import org.thymeleaf.standard.StandardDialect; /** * Thymeleaf processor dialect for Halo. * * @author guqing * @since 2.0.0 */ public class HaloProcessorDialect extends AbstractProcessorDialect implements IExpressionObjectDialect, IPostProcessorDialect { private static final String DIALECT_NAME = "haloThemeProcessorDialect"; private static final IExpressionObjectFactory HALO_EXPRESSION_OBJECTS_FACTORY = new HaloExpressionObjectFactory(); public HaloProcessorDialect() { // We will set this dialect the same "dialect processor" precedence as // the Standard Dialect, so that processor executions can interleave. super(DIALECT_NAME, "halo", StandardDialect.PROCESSOR_PRECEDENCE); } @Override public Set getProcessors(String dialectPrefix) { final Set processors = new HashSet(); // add more processors processors.add(new GlobalHeadInjectionProcessor(dialectPrefix)); processors.add(new TemplateFooterElementTagProcessor(dialectPrefix)); processors.add(new EvaluationContextEnhancer()); processors.add(new CommentElementTagProcessor(dialectPrefix)); processors.add(new CommentEnabledVariableProcessor()); processors.add(new InjectionExcluderProcessor()); return processors; } @Override public IExpressionObjectFactory getExpressionObjectFactory() { return HALO_EXPRESSION_OBJECTS_FACTORY; } @Override public int getDialectPostProcessorPrecedence() { return Integer.MAX_VALUE; } @Override public Set getPostProcessors() { return Set.of(new PostProcessor(HTML, HaloPostTemplateHandler.class, Integer.MAX_VALUE)); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java ================================================ package run.halo.app.theme.dialect; import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList; import static org.thymeleaf.extras.springsecurity6.dialect.processor.AuthorizeAttrProcessor.ATTR_NAME; import static org.thymeleaf.extras.springsecurity6.dialect.processor.AuthorizeAttrProcessor.ATTR_PRECEDENCE; import static run.halo.app.infra.AnonymousUserConst.PRINCIPAL; import static run.halo.app.infra.AnonymousUserConst.Role; import java.util.LinkedHashSet; import java.util.Optional; import java.util.Set; import java.util.function.Function; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.ObjectProvider; import org.springframework.security.access.expression.ExpressionUtils; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.util.MethodInvocationUtils; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.web.server.ServerWebExchange; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.engine.AttributeName; import org.thymeleaf.extras.springsecurity6.auth.AuthUtils; import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect; import org.thymeleaf.extras.springsecurity6.util.SpringSecurityContextUtils; import org.thymeleaf.extras.springsecurity6.util.SpringVersionSpecificUtils; import org.thymeleaf.extras.springsecurity6.util.SpringVersionUtils; import org.thymeleaf.model.IProcessableElementTag; import org.thymeleaf.processor.IProcessor; import org.thymeleaf.standard.processor.AbstractStandardConditionalVisibilityTagProcessor; import org.thymeleaf.templatemode.TemplateMode; import run.halo.app.security.authorization.AuthorityUtils; /** * HaloSpringSecurityDialect overwrites value of thymeleafSpringSecurityContext. * * @author johnniang */ public class HaloSpringSecurityDialect extends SpringSecurityDialect implements InitializingBean { private static final String SECURITY_CONTEXT_EXECUTION_ATTRIBUTE_NAME = "ThymeleafReactiveModelAdditions:" + SpringSecurityContextUtils.SECURITY_CONTEXT_MODEL_ATTRIBUTE_NAME; private final ServerSecurityContextRepository securityContextRepository; private final ObjectProvider expressionHandler; public HaloSpringSecurityDialect(ServerSecurityContextRepository securityContextRepository, ObjectProvider expressionHandler) { this.securityContextRepository = securityContextRepository; this.expressionHandler = expressionHandler; } @Override public void afterPropertiesSet() { if (!SpringVersionUtils.isSpringWebFluxPresent()) { return; } // We have to build an anonymous authentication token here because the token won't be saved // into repository during anonymous authentication. var anonymousAuthentication = new AnonymousAuthenticationToken( "fallback", PRINCIPAL, createAuthorityList(AuthorityUtils.ROLE_PREFIX + Role) ); var anonymousSecurityContext = new SecurityContextImpl(anonymousAuthentication); final Function secCtxInitializer = exchange -> securityContextRepository.load(exchange) .defaultIfEmpty(anonymousSecurityContext); // Just overwrite the value of the attribute getExecutionAttributes().put(SECURITY_CONTEXT_EXECUTION_ATTRIBUTE_NAME, secCtxInitializer); } @Override public Set getProcessors(String dialectPrefix) { LinkedHashSet processors = new LinkedHashSet<>(); processors.add( new HaloAuthorizeAttrProcessor(TemplateMode.HTML, dialectPrefix, ATTR_NAME) ); processors.addAll(super.getProcessors(dialectPrefix)); return processors; } public class HaloAuthorizeAttrProcessor extends AbstractStandardConditionalVisibilityTagProcessor { protected HaloAuthorizeAttrProcessor(TemplateMode templateMode, String dialectPrefix, String attrName) { super(templateMode, dialectPrefix, attrName, ATTR_PRECEDENCE - 10); } @Override protected boolean isVisible(ITemplateContext context, IProcessableElementTag tag, AttributeName attributeName, String attributeValue) { final String attrValue = (attributeValue == null ? null : attributeValue.trim()); if (attrValue == null || attrValue.isEmpty()) { return false; } final Authentication authentication = AuthUtils.getAuthenticationObject(context); if (authentication == null) { return false; } // resolve expr var expr = Optional.of(attributeValue) .filter(v -> v.startsWith("${") && v.endsWith("}")) .map(v -> v.substring(2, v.length() - 1)) .orElse(attributeValue); var expressionHandler = HaloSpringSecurityDialect.this.expressionHandler.getIfUnique(); if (expressionHandler == null) { // no expression handler found return false; } var expression = expressionHandler.getExpressionParser().parseExpression(expr); var methodInvocation = MethodInvocationUtils.createFromClass(this, HaloAuthorizeAttrProcessor.class, "dummyAuthorize", new Class[] {Authentication.class}, new Object[] {authentication} ); var evaluationContext = expressionHandler.createEvaluationContext(authentication, methodInvocation); var expressionObjects = context.getExpressionObjects(); var wrappedEvolutionContext = SpringVersionSpecificUtils.wrapEvaluationContext( evaluationContext, expressionObjects ); return ExpressionUtils.evaluateAsBoolean(expression, wrappedEvolutionContext); } /** * This method is only used to create a method invocation for the expression parser. * * @param authentication authentication object * @return result of authorization expression evaluation */ public Boolean dummyAuthorize(Authentication authentication) { throw new UnsupportedOperationException("Should not be called"); } } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/HaloTrackerProcessor.java ================================================ package run.halo.app.theme.dialect; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IModel; import org.thymeleaf.model.IModelFactory; import org.thymeleaf.processor.element.IElementModelStructureHandler; import reactor.core.publisher.Mono; import run.halo.app.extension.GroupVersionKind; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.utils.PathUtils; /** * Get {@link GroupVersionKind} and {@code plural} from the view model to construct tracker * script tag and insert it into the head tag. * * @author guqing * @since 2.0.0 */ @Component public class HaloTrackerProcessor implements TemplateHeadProcessor { public static final String SKIP_TRACKER = HaloTrackerProcessor.class.getName() + ".SKIP_TRACKER"; private final ExternalUrlSupplier externalUrlGetter; public HaloTrackerProcessor(ExternalUrlSupplier externalUrlGetter) { this.externalUrlGetter = externalUrlGetter; } @Override public Mono process(ITemplateContext context, IModel model, IElementModelStructureHandler structureHandler) { // Check if tracker should be skipped var isSkip = (Boolean) context.getVariable(SKIP_TRACKER); if (BooleanUtils.isTrue(isSkip)) { return Mono.empty(); } final IModelFactory modelFactory = context.getModelFactory(); return Mono.just(getTrackerScript(context)) .filter(StringUtils::isNotBlank) .map(trackerScript -> { model.add(modelFactory.createText(trackerScript)); return trackerScript; }) .then(); } private String getTrackerScript(ITemplateContext context) { String resourceName = (String) context.getVariable("name"); String externalUrl = externalUrlGetter.get().getPath(); Object groupVersionKind = context.getVariable("groupVersionKind"); Object plural = context.getVariable("plural"); if (groupVersionKind == null || plural == null) { return StringUtils.EMPTY; } if (!(groupVersionKind instanceof GroupVersionKind gvk)) { return StringUtils.EMPTY; } return trackerScript(externalUrl, gvk.group(), (String) plural, resourceName); } private String trackerScript(String externalUrl, String group, String plural, String name) { String jsSrc = PathUtils.combinePath(externalUrl, "/halo-tracker.js"); return """ """.formatted(jsSrc, group, plural, name); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/IndexSeoProcessor.java ================================================ package run.halo.app.theme.dialect; import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IModel; import org.thymeleaf.model.IModelFactory; import org.thymeleaf.processor.element.IElementModelStructureHandler; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.router.ModelConst; /** * Processor for index page SEO. * * @author ryanwang */ @Component @AllArgsConstructor class IndexSeoProcessor implements TemplateHeadProcessor { private final SystemConfigFetcher environmentFetcher; @Override public Mono process(ITemplateContext context, IModel model, IElementModelStructureHandler structureHandler) { if (!isIndexTemplate(context)) { return Mono.empty(); } return environmentFetcher.fetch(SystemSetting.Seo.GROUP, SystemSetting.Seo.class) .map(seo -> { IModelFactory modelFactory = context.getModelFactory(); String keywords = seo.getKeywords(); if (StringUtils.isNotBlank(keywords)) { String keywordsMeta = "\n"; model.add(modelFactory.createText(keywordsMeta)); } if (StringUtils.isNotBlank(seo.getDescription())) { String descriptionMeta = "\n"; model.add(modelFactory.createText(descriptionMeta)); } return model; }) .then(); } private boolean isIndexTemplate(ITemplateContext context) { return DefaultTemplateEnum.INDEX.getValue() .equals(context.getVariable(ModelConst.TEMPLATE_ID)); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/InjectionExcluderProcessor.java ================================================ package run.halo.app.theme.dialect; import java.util.Set; import java.util.regex.Pattern; import org.springframework.util.Assert; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.ITemplateEnd; import org.thymeleaf.model.ITemplateStart; import org.thymeleaf.processor.templateboundaries.AbstractTemplateBoundariesProcessor; import org.thymeleaf.processor.templateboundaries.ITemplateBoundariesProcessor; import org.thymeleaf.processor.templateboundaries.ITemplateBoundariesStructureHandler; import org.thymeleaf.standard.StandardDialect; import org.thymeleaf.templatemode.TemplateMode; /** *

Determine whether the current template being rendered needs to exclude the processor of * code injection. If it needs to be excluded, set a local variable.

*

Why do you need to set a local variable here instead of directly judging in the processor?

*

Because the processor will process the fragment, and if you need to exclude the login * .html template and the login.html is only a fragment, then the exclusion logic will * fail, so here use {@link ITemplateBoundariesProcessor} events are only fired for the * first-level template to solve this problem.

* * @author guqing * @since 2.20.0 */ public class InjectionExcluderProcessor extends AbstractTemplateBoundariesProcessor { public static final String EXCLUDE_INJECTION_VARIABLE = InjectionExcluderProcessor.class.getName() + ".EXCLUDE_INJECTION"; private final PageInjectionExcluder injectionExcluder = new PageInjectionExcluder(); public InjectionExcluderProcessor() { super(TemplateMode.HTML, StandardDialect.PROCESSOR_PRECEDENCE); } @Override public void doProcessTemplateStart(ITemplateContext context, ITemplateStart templateStart, ITemplateBoundariesStructureHandler structureHandler) { if (isExcluded(context)) { structureHandler.setLocalVariable(EXCLUDE_INJECTION_VARIABLE, true); } } @Override public void doProcessTemplateEnd(ITemplateContext context, ITemplateEnd templateEnd, ITemplateBoundariesStructureHandler structureHandler) { structureHandler.removeLocalVariable(EXCLUDE_INJECTION_VARIABLE); } /** * Check if the template will be rendered is excluded injection. * * @param context template context * @return true if the template is excluded, otherwise false */ boolean isExcluded(ITemplateContext context) { return injectionExcluder.isExcluded(context.getTemplateData().getTemplate()); } static class PageInjectionExcluder { private final Set exactMatches = Set.of( "login", "signup", "logout" ); private final Set regexPatterns = Set.of( Pattern.compile("error/.*"), Pattern.compile("challenges/.*"), Pattern.compile("password-reset/.*") ); public boolean isExcluded(String templateName) { Assert.notNull(templateName, "Template name must not be null"); if (exactMatches.contains(templateName)) { return true; } for (Pattern pattern : regexPatterns) { if (pattern.matcher(templateName).matches()) { return true; } } return false; } } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/LinkExpressionObjectDialect.java ================================================ package run.halo.app.theme.dialect; import org.thymeleaf.dialect.AbstractDialect; import org.thymeleaf.dialect.IExpressionObjectDialect; import org.thymeleaf.expression.IExpressionObjectFactory; /** * An expression object dialect for theme link. * * @author guqing * @since 2.0.0 */ public class LinkExpressionObjectDialect extends AbstractDialect implements IExpressionObjectDialect { private static final IExpressionObjectFactory LINK_EXPRESSION_OBJECTS_FACTORY = new DefaultLinkExpressionFactory(); public LinkExpressionObjectDialect() { super("themeLink"); } @Override public IExpressionObjectFactory getExpressionObjectFactory() { return LINK_EXPRESSION_OBJECTS_FACTORY; } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/SecureTemplateContext.java ================================================ package run.halo.app.theme.dialect; import static org.thymeleaf.spring6.expression.ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import org.thymeleaf.IEngineConfiguration; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.context.IdentifierSequences; import org.thymeleaf.engine.TemplateData; import org.thymeleaf.expression.IExpressionObjects; import org.thymeleaf.inline.IInliner; import org.thymeleaf.model.IModelFactory; import org.thymeleaf.model.IProcessableElementTag; import org.thymeleaf.templatemode.TemplateMode; /** * Secure template context. * * @author johnniang * @since 2.20.0 */ class SecureTemplateContext implements ITemplateContext { private static final Set DANGEROUS_VARIABLES = Set.of(THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME); private final ITemplateContext delegate; public SecureTemplateContext(ITemplateContext delegate) { this.delegate = delegate; } @Override public TemplateData getTemplateData() { return delegate.getTemplateData(); } @Override public TemplateMode getTemplateMode() { return delegate.getTemplateMode(); } @Override public List getTemplateStack() { return delegate.getTemplateStack(); } @Override public List getElementStack() { return delegate.getElementStack(); } @Override public Map getTemplateResolutionAttributes() { return delegate.getTemplateResolutionAttributes(); } @Override public IModelFactory getModelFactory() { return delegate.getModelFactory(); } @Override public boolean hasSelectionTarget() { return delegate.hasSelectionTarget(); } @Override public Object getSelectionTarget() { return delegate.getSelectionTarget(); } @Override public IInliner getInliner() { return delegate.getInliner(); } @Override public String getMessage( Class origin, String key, Object[] messageParameters, boolean useAbsentMessageRepresentation ) { return delegate.getMessage(origin, key, messageParameters, useAbsentMessageRepresentation); } @Override public String buildLink(String base, Map parameters) { return delegate.buildLink(base, parameters); } @Override public IdentifierSequences getIdentifierSequences() { return delegate.getIdentifierSequences(); } @Override public IEngineConfiguration getConfiguration() { return delegate.getConfiguration(); } @Override public IExpressionObjects getExpressionObjects() { return delegate.getExpressionObjects(); } @Override public Locale getLocale() { return delegate.getLocale(); } @Override public boolean containsVariable(String name) { if (DANGEROUS_VARIABLES.contains(name)) { return false; } return delegate.containsVariable(name); } @Override public Set getVariableNames() { return delegate.getVariableNames() .stream() .filter(name -> !DANGEROUS_VARIABLES.contains(name)) .collect(Collectors.toSet()); } @Override public Object getVariable(String name) { if (DANGEROUS_VARIABLES.contains(name)) { return null; } return delegate.getVariable(name); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } SecureTemplateContext that = (SecureTemplateContext) o; return Objects.equals(delegate, that.delegate); } @Override public int hashCode() { return Objects.hashCode(delegate); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/SecureTemplateContextWrapper.java ================================================ package run.halo.app.theme.dialect; import org.thymeleaf.context.Contexts; import org.thymeleaf.context.ITemplateContext; /** * Wrap the delegate template context to a secure template context according to whether it is a * WebContext. * * @author guqing * @since 2.20.4 */ public class SecureTemplateContextWrapper { /** * Wrap the delegate template context to a secure template context. */ static SecureTemplateContext wrap(ITemplateContext delegate) { if (Contexts.isWebContext(delegate)) { return new SecureTemplateWebContext(delegate); } return new SecureTemplateContext(delegate); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/SecureTemplateWebContext.java ================================================ package run.halo.app.theme.dialect; import org.springframework.context.ApplicationContext; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.context.IWebContext; import org.thymeleaf.web.IWebExchange; /** * Secure template web context. *

It's used to prevent some dangerous variables such as {@link ApplicationContext} from being * accessed. * * @author guqing * @see SecureTemplateContext * @since 2.20.4 */ class SecureTemplateWebContext extends SecureTemplateContext implements IWebContext { private final IWebContext delegate; /** * The delegate must be an instance of IWebContext to create a SecureTemplateWebContext. */ public SecureTemplateWebContext(ITemplateContext delegate) { super(delegate); if (delegate instanceof IWebContext webContext) { this.delegate = webContext; } else { throw new IllegalArgumentException("The delegate must be an instance of IWebContext"); } } @Override public IWebExchange getExchange() { return delegate.getExchange(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java ================================================ package run.halo.app.theme.dialect; import static org.thymeleaf.spring6.context.SpringContextUtils.getApplicationContext; import java.time.Duration; import org.springframework.context.ApplicationContext; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IModel; import org.thymeleaf.model.IProcessableElementTag; import org.thymeleaf.processor.element.AbstractElementTagProcessor; import org.thymeleaf.processor.element.IElementTagStructureHandler; import org.thymeleaf.spring6.context.SpringContextUtils; import org.thymeleaf.templatemode.TemplateMode; import reactor.core.publisher.Flux; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** *

Footer element tag processor.

*

Replace the footer tag <halo:footer /> with the contents of the footer * field of the global configuration item.

* * @author guqing * @since 2.0.0 */ public class TemplateFooterElementTagProcessor extends AbstractElementTagProcessor { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private static final String TAG_NAME = "footer"; private static final int PRECEDENCE = 1000; /** * Constructor footer element tag processor with HTML mode, dialect prefix, footer tag name. * * @param dialectPrefix dialect prefix */ public TemplateFooterElementTagProcessor(final String dialectPrefix) { super( TemplateMode.HTML, // This processor will apply only to HTML mode dialectPrefix, // Prefix to be applied to name for matching TAG_NAME, // Tag name: match specifically this tag true, // Apply dialect prefix to tag name null, // No attribute name: will match by tag name false, // No prefix to be applied to attribute name PRECEDENCE); // Precedence (inside dialect's own precedence) } @Override protected void doProcess(ITemplateContext context, IProcessableElementTag tag, IElementTagStructureHandler structureHandler) { if (context.containsVariable(InjectionExcluderProcessor.EXCLUDE_INJECTION_VARIABLE)) { return; } IModel modelToInsert = context.getModelFactory().createModel(); /* * Obtain the Spring application context. */ final ApplicationContext appCtx = SpringContextUtils.getApplicationContext(context); String globalFooterText = getGlobalFooterText(appCtx); modelToInsert.add(context.getModelFactory().createText(globalFooterText)); getTemplateFooterProcessors(context) .concatMap(processor -> processor.process( SecureTemplateContextWrapper.wrap(context), tag, structureHandler, modelToInsert) ) .then() .block(BLOCKING_TIMEOUT); structureHandler.replaceWith(modelToInsert, false); } private String getGlobalFooterText(ApplicationContext appCtx) { SystemConfigFetcher fetcher = appCtx.getBean(SystemConfigFetcher.class); return fetcher.fetch(SystemSetting.CodeInjection.GROUP, SystemSetting.CodeInjection.class) .map(SystemSetting.CodeInjection::getFooter) .block(BLOCKING_TIMEOUT); } private Flux getTemplateFooterProcessors(ITemplateContext context) { var extensionGetter = getApplicationContext(context).getBeanProvider(ExtensionGetter.class) .getIfUnique(); if (extensionGetter == null) { return Flux.empty(); } return extensionGetter.getExtensions(TemplateFooterProcessor.class); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/TemplateGlobalHeadProcessor.java ================================================ package run.halo.app.theme.dialect; import org.apache.commons.lang3.StringUtils; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IModel; import org.thymeleaf.model.IModelFactory; import org.thymeleaf.processor.element.IElementModelStructureHandler; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.router.ModelConst; /** *

Global custom head snippet injection for theme global setting.

*

Globally injected head snippet can be overridden by content template.

* * @author guqing * @since 2.0.0 */ @Order(Ordered.HIGHEST_PRECEDENCE + 2) @Component public class TemplateGlobalHeadProcessor implements TemplateHeadProcessor { private final SystemConfigFetcher fetcher; public TemplateGlobalHeadProcessor(SystemConfigFetcher fetcher) { this.fetcher = fetcher; } @Override public Mono process(ITemplateContext context, IModel model, IElementModelStructureHandler structureHandler) { final IModelFactory modelFactory = context.getModelFactory(); return fetchCodeInjection() .doOnNext(codeInjection -> { String globalHeader = codeInjection.getGlobalHead(); if (StringUtils.isNotBlank(globalHeader)) { model.add(modelFactory.createText(globalHeader + "\n")); } // add content head to model String contentHeader = codeInjection.getContentHead(); if (StringUtils.isNotBlank(contentHeader) && isContentTemplate(context)) { model.add(modelFactory.createText(contentHeader + "\n")); } }) .then(); } private Mono fetchCodeInjection() { return fetcher.fetch(SystemSetting.CodeInjection.GROUP, SystemSetting.CodeInjection.class); } private boolean isContentTemplate(ITemplateContext context) { String templateId = (String) context.getVariable(ModelConst.TEMPLATE_ID); return DefaultTemplateEnum.POST.getValue().equals(templateId) || DefaultTemplateEnum.SINGLE_PAGE.getValue().equals(templateId); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/dialect/expression/Annotations.java ================================================ package run.halo.app.theme.dialect.expression; import java.util.Map; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import run.halo.app.theme.finders.vo.ExtensionVoOperator; /** *

Expression Object for performing annotations operations inside Halo Extra Expressions.

* An object of this class is usually available in variable evaluation expressions with the name * #annotations. * * @author guqing * @since 2.0.2 */ public class Annotations { /** * Get annotation value from extension vo. * * @param extension extension vo * @param key the key of annotation * @return annotation value if exists, otherwise null */ @Nullable public String get(ExtensionVoOperator extension, String key) { Map annotations = extension.getMetadata().getAnnotations(); if (annotations == null) { return null; } return annotations.get(key); } /** * Returns the value to which the specified key is mapped, or defaultValue if * extension contains no mapping for the key. * * @param extension extension vo * @param key the key of annotation * @return annotation value if exists, otherwise defaultValue */ @NonNull public String getOrDefault(ExtensionVoOperator extension, String key, String defaultValue) { Map annotations = extension.getMetadata().getAnnotations(); if (annotations == null) { return defaultValue; } return annotations.getOrDefault(key, defaultValue); } /** * Check if the extension has the specified annotation. * * @param extension extension vo * @param key the key of annotation * @return true if the extension has the specified annotation, otherwise false */ public boolean contains(ExtensionVoOperator extension, String key) { Map annotations = extension.getMetadata().getAnnotations(); if (annotations == null) { return false; } return annotations.containsKey(key); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/endpoint/ThemeEndpoint.java ================================================ package run.halo.app.theme.endpoint; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import static org.springframework.http.HttpStatus.NO_CONTENT; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import java.net.URI; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; import java.util.List; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springdoc.core.fn.builders.operation.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.Part; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.user.service.SettingConfigService; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.IListRequest; import run.halo.app.infra.ReactiveUrlDataBufferFetcher; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.ThemeRootGetter; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.theme.TemplateEngineManager; import run.halo.app.theme.service.ThemeService; import run.halo.app.theme.service.ThemeUtils; import tools.jackson.databind.node.ObjectNode; /** * Endpoint for managing themes. * * @author guqing * @since 2.0.0 */ @Slf4j @Component @AllArgsConstructor public class ThemeEndpoint implements CustomEndpoint { private final ReactiveExtensionClient client; private final ThemeRootGetter themeRoot; private final ThemeService themeService; private final TemplateEngineManager templateEngineManager; private final SystemConfigFetcher systemEnvironmentFetcher; private final ReactiveUrlDataBufferFetcher urlDataBufferFetcher; private final SettingConfigService settingConfigService; @Override public RouterFunction endpoint() { var tag = "ThemeV1alpha1Console"; return SpringdocRouteBuilder.route() .POST("themes/install", contentType(MediaType.MULTIPART_FORM_DATA), this::install, builder -> builder.operationId("InstallTheme") .description("Install a theme by uploading a zip file.") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) .schema(schemaBuilder() .implementation(InstallRequest.class)) )) .response(responseBuilder() .implementation(Theme.class)) ) .POST("themes/-/install-from-uri", this::installFromUri, builder -> builder.operationId("InstallThemeFromUri") .description("Install a theme from uri.") .tag(tag) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(schemaBuilder() .implementation(InstallFromUriRequest.class)) )) .response(responseBuilder() .implementation(Theme.class)) ) .POST("themes/{name}/upgrade-from-uri", this::upgradeFromUri, builder -> builder.operationId("UpgradeThemeFromUri") .description("Upgrade a theme from uri.") .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name("name") .required(true) ) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(schemaBuilder() .implementation(UpgradeFromUriRequest.class)) )) .response(responseBuilder() .implementation(Theme.class)) ) .POST("themes/{name}/upgrade", this::upgrade, builder -> builder.operationId("UpgradeTheme") .description("Upgrade theme") .tag(tag) .parameter(parameterBuilder().in(ParameterIn.PATH).name("name").required(true)) .requestBody(requestBodyBuilder().required(true) .content(contentBuilder().mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) .schema(schemaBuilder().implementation(UpgradeRequest.class)))) .build()) .PUT("themes/{name}/reload", this::reloadTheme, builder -> builder.operationId("Reload") .description("Reload theme setting.") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(Theme.class)) ) .PUT("themes/{name}/reset-config", this::resetSettingConfig, builder -> builder.operationId("ResetThemeConfig") .description("Reset the configMap of theme setting.") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(ConfigMap.class)) ) .PUT("themes/{name}/json-config", this::updateThemeJsonConfig, builder -> builder.operationId("updateThemeJsonConfig") .description("Update the configMap of theme setting.") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder().mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(schemaBuilder().implementation(Object.class)))) .response(responseBuilder() .responseCode(String.valueOf(NO_CONTENT.value())) .implementation(Void.class)) ) .PUT("themes/{name}/activation", this::activateTheme, builder -> builder.operationId("activateTheme") .description("Activate a theme by name.") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(Theme.class)) ) .PUT("/themes/{name}/invalidate-cache", this::invalidateCache, builder -> builder.operationId("InvalidateCache") .description("Invalidate theme template cache.") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .responseCode(String.valueOf(NO_CONTENT.value())) ) ) .GET("themes", this::listThemes, builder -> { builder.operationId("ListThemes") .description("List themes.") .tag(tag) .response(responseBuilder() .implementation(ListResult.generateGenericClass(Theme.class))); ThemeQuery.buildParameters(builder); } ) .GET("themes/-/activation", this::fetchActivatedTheme, builder -> builder.operationId("fetchActivatedTheme") .description("Fetch the activated theme.") .tag(tag) .response(responseBuilder() .implementation(Theme.class)) ) .GET("themes/{name}/setting", this::fetchThemeSetting, builder -> builder.operationId("fetchThemeSetting") .description("Fetch setting of theme.") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder() .implementation(Setting.class)) ) .GET("themes/{name}/json-config", this::fetchThemeJsonConfig, builder -> builder.operationId("fetchThemeJsonConfig") .description( "Fetch converted json config of theme by configured configMapName.") .tag(tag) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) .required(true) .implementation(String.class) ) .response(responseBuilder().implementation(Object.class)) ) .build(); } private Mono fetchThemeJsonConfig(ServerRequest request) { return themeNameInPathVariableOrActivated(request) .flatMap(themeName -> client.fetch(Theme.class, themeName)) .mapNotNull(theme -> theme.getSpec().getConfigMapName()) .flatMap(settingConfigService::fetchConfig) .flatMap(json -> ServerResponse.ok().bodyValue(json)); } private Mono updateThemeJsonConfig(ServerRequest request) { final var themeName = request.pathVariable("name"); return client.fetch(Theme.class, themeName) .doOnNext(theme -> { String configMapName = theme.getSpec().getConfigMapName(); if (StringUtils.isBlank(configMapName)) { throw new ServerWebInputException( "Unable to complete the request because the theme configMapName is blank."); } }) .flatMap(theme -> { final var configMapName = theme.getSpec().getConfigMapName(); return request.bodyToMono(ObjectNode.class) .switchIfEmpty( Mono.error(new ServerWebInputException("Required request body is missing"))) .flatMap(configJsonData -> settingConfigService.upsertConfig(configMapName, configJsonData)); }) .then(ServerResponse.noContent().build()); } private Mono invalidateCache(ServerRequest request) { final var name = request.pathVariable("name"); return client.get(Theme.class, name) .flatMap(theme -> templateEngineManager.clearCache(name)) .then(ServerResponse.noContent().build()); } private Mono upgradeFromUri(ServerRequest request) { final var name = request.pathVariable("name"); var content = request.bodyToMono(UpgradeFromUriRequest.class) .map(UpgradeFromUriRequest::uri) .flatMapMany(urlDataBufferFetcher::fetch); return themeService.upgrade(name, content) .flatMap((updatedTheme) -> templateEngineManager.clearCache(updatedTheme.getMetadata().getName()) .thenReturn(updatedTheme) ) .flatMap(theme -> ServerResponse.ok().bodyValue(theme)); } private Mono installFromUri(ServerRequest request) { var content = request.bodyToMono(InstallFromUriRequest.class) .map(InstallFromUriRequest::uri) .flatMapMany(urlDataBufferFetcher::fetch); return themeService.install(content) .flatMap(theme -> ServerResponse.ok().bodyValue(theme)); } private Mono activateTheme(ServerRequest request) { final var activatedThemeName = request.pathVariable("name"); return client.fetch(Theme.class, activatedThemeName) .switchIfEmpty(Mono.error(new NotFoundException("Theme not found."))) .flatMap(theme -> themeService.fetchSystemSetting() .flatMap(themeSetting -> { // update active theme config themeSetting.setActive(activatedThemeName); return systemEnvironmentFetcher.getConfigMap() .filter(configMap -> configMap.getData() != null) .map(configMap -> { var themeConfigJson = JsonUtils.objectToJson(themeSetting); configMap.getData() .put(SystemSetting.Theme.GROUP, themeConfigJson); return configMap; }); }) .flatMap(client::update) .retryWhen(Retry.backoff(5, Duration.ofMillis(300)) .filter(OptimisticLockingFailureException.class::isInstance) ) .thenReturn(theme) ) .flatMap(activatedTheme -> ServerResponse.ok().bodyValue(activatedTheme)); } private Mono fetchActivatedTheme(ServerRequest request) { var activatedTheme = themeService.fetchActivatedTheme() .switchIfEmpty( Mono.error(() -> new NotFoundException("Activated theme not found or not set")) ); return ServerResponse.ok().body(activatedTheme, Theme.class); } private Mono fetchThemeSetting(ServerRequest request) { return themeNameInPathVariableOrActivated(request) .flatMap(name -> client.fetch(Theme.class, name)) .mapNotNull(theme -> theme.getSpec().getSettingName()) .flatMap(settingName -> client.fetch(Setting.class, settingName)) .flatMap(setting -> ServerResponse.ok().bodyValue(setting)); } private Mono themeNameInPathVariableOrActivated(ServerRequest request) { Assert.notNull(request, "request must not be null."); var themeName = request.pathVariable("name"); if ("-".equals(themeName)) { return themeService.fetchActivatedThemeName() .switchIfEmpty(Mono.error(() -> new ServerWebInputException( "No activated theme found, unable to proceed the request." ))); } return Mono.just(themeName); } public static class ThemeQuery extends IListRequest.QueryListRequest { public ThemeQuery(MultiValueMap queryParams) { super(queryParams); } @NonNull public Boolean getUninstalled() { return Boolean.parseBoolean(queryParams.getFirst("uninstalled")); } public static void buildParameters(Builder builder) { IListRequest.buildParameters(builder); builder.parameter(parameterBuilder() .name("uninstalled") .description("Whether to list uninstalled themes.") .in(ParameterIn.QUERY) .implementation(Boolean.class) .required(false)); } } // TODO Extract the method into ThemeService Mono listThemes(ServerRequest request) { MultiValueMap queryParams = request.queryParams(); ThemeQuery query = new ThemeQuery(queryParams); return Mono.defer(() -> { if (query.getUninstalled()) { return listUninstalled(query); } return client.list(Theme.class, null, null, query.getPage(), query.getSize()); }).flatMap(extensions -> ServerResponse.ok().bodyValue(extensions)); } public interface IUpgradeRequest { @Schema(requiredMode = REQUIRED, description = "Theme zip file.") FilePart getFile(); } public record UpgradeFromUriRequest(@Schema(requiredMode = REQUIRED) URI uri) { } public static class UpgradeRequest implements IUpgradeRequest { private final MultiValueMap multipartData; public UpgradeRequest(MultiValueMap multipartData) { this.multipartData = multipartData; } @Override public FilePart getFile() { var part = multipartData.getFirst("file"); if (!(part instanceof FilePart filePart)) { throw new ServerWebInputException("Invalid multipart type of file"); } if (!filePart.filename().endsWith(".zip")) { throw new ServerWebInputException("Only zip extension supported"); } return filePart; } } private Mono upgrade(ServerRequest request) { // validate the theme first var name = request.pathVariable("name"); return request.multipartData() .map(UpgradeRequest::new) .map(UpgradeRequest::getFile) .flatMap(filePart -> themeService.upgrade(name, filePart.content())) .flatMap((updatedTheme) -> templateEngineManager.clearCache(updatedTheme.getMetadata().getName()) .thenReturn(updatedTheme)) .flatMap(updatedTheme -> ServerResponse.ok().bodyValue(updatedTheme)); } Mono> listUninstalled(ThemeQuery query) { Path path = themeRoot.get(); return ThemeUtils.listAllThemesFromThemeDir(path) .collectList() .flatMap(this::filterUnInstalledThemes) .map(themes -> { Integer page = query.getPage(); Integer size = query.getSize(); List subList = ListResult.subList(themes, page, size); return new ListResult<>(page, size, themes.size(), subList); }); } private Mono> filterUnInstalledThemes(@NonNull List allThemes) { return client.list(Theme.class, null, null) .map(theme -> theme.getMetadata().getName()) .collectList() .map(installed -> allThemes.stream() .filter(theme -> !installed.contains(theme.getMetadata().getName())) .toList() ); } Mono reloadTheme(ServerRequest request) { String name = request.pathVariable("name"); return themeService.reloadTheme(name) .flatMap(theme -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(theme)); } Mono resetSettingConfig(ServerRequest request) { String name = request.pathVariable("name"); return themeService.resetSettingConfig(name) .flatMap(theme -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(theme)); } @Schema(name = "ThemeInstallRequest", types = "object") public static class InstallRequest { @Schema(hidden = true) private final MultiValueMap multipartData; public InstallRequest(MultiValueMap multipartData) { this.multipartData = multipartData; } @Schema(requiredMode = REQUIRED, description = "Theme zip file.") FilePart getFile() { Part part = multipartData.getFirst("file"); if (!(part instanceof FilePart file)) { throw new ServerWebInputException( "Invalid parameter of file, binary data is required"); } if (!Paths.get(file.filename()).toString().endsWith(".zip")) { throw new ServerWebInputException( "Invalid file type, only zip format is supported"); } return file; } } public record InstallFromUriRequest(@Schema(requiredMode = REQUIRED) URI uri) { } Mono install(ServerRequest request) { return request.multipartData() .map(InstallRequest::new) .map(InstallRequest::getFile) .flatMap(filePart -> themeService.install(filePart.content())) .flatMap(theme -> ServerResponse.ok().bodyValue(theme)); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/engine/DefaultThemeTemplateAvailabilityProvider.java ================================================ package run.halo.app.theme.engine; import java.nio.file.Files; import org.springframework.boot.thymeleaf.autoconfigure.ThymeleafProperties; import org.springframework.stereotype.Component; import run.halo.app.theme.ThemeContext; @Component public class DefaultThemeTemplateAvailabilityProvider implements ThemeTemplateAvailabilityProvider { private final ThymeleafProperties thymeleafProperties; public DefaultThemeTemplateAvailabilityProvider(ThymeleafProperties thymeleafProperties) { this.thymeleafProperties = thymeleafProperties; } @Override public boolean isTemplateAvailable(ThemeContext themeContext, String viewName) { var suffix = thymeleafProperties.getSuffix(); // Currently, we only support Path here. var path = themeContext.getPath().resolve("templates").resolve(viewName + suffix); return Files.exists(path); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/engine/HaloTemplateEngine.java ================================================ package run.halo.app.theme.engine; import java.nio.channels.ClosedByInterruptException; import java.nio.charset.Charset; import java.util.Set; import lombok.extern.slf4j.Slf4j; import org.reactivestreams.Publisher; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.http.MediaType; import org.thymeleaf.context.IContext; import org.thymeleaf.messageresolver.IMessageResolver; import org.thymeleaf.spring6.SpringWebFluxTemplateEngine; import reactor.core.Exceptions; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; /** * Default template engine implementation to be used in Halo. * * @author johnniang */ @Slf4j public class HaloTemplateEngine extends SpringWebFluxTemplateEngine { private final IMessageResolver messageResolver; public HaloTemplateEngine(IMessageResolver messageResolver) { this.messageResolver = messageResolver; } @Override protected void initializeSpringSpecific() { // Before initialization, thymeleaf will overwrite message resolvers. // So we need to add own message resolver at here. addMessageResolver(messageResolver); } @Override public Publisher processStream(String template, Set markupSelectors, IContext context, DataBufferFactory bufferFactory, MediaType mediaType, Charset charset, int responseMaxChunkSizeBytes) { var publisher = super.processStream(template, markupSelectors, context, bufferFactory, mediaType, charset, responseMaxChunkSizeBytes); // We have to subscribe on blocking thread, because some blocking operations will be present // while processing. if (publisher instanceof Mono mono) { return mono.subscribeOn(Schedulers.boundedElastic()) .doOnError(Exception.class, e -> this.logTemplateError(e, template)); } if (publisher instanceof Flux flux) { return flux.subscribeOn(Schedulers.boundedElastic()) .doOnError(Exception.class, e -> this.logTemplateError(e, template)); } return publisher; } private void logTemplateError(Exception e, String template) { if (Exceptions.unwrap(e.getCause()) instanceof InterruptedException) { log.warn("Interrupted while processing template: {}", template); } if (e.getCause() instanceof ClosedByInterruptException) { log.warn("Interrupted while outputting template: {}", template); } // other exceptions will be caught by error handler } } ================================================ FILE: application/src/main/java/run/halo/app/theme/engine/PluginClassloaderTemplateResolver.java ================================================ package run.halo.app.theme.engine; import static run.halo.app.plugin.PluginConst.SYSTEM_PLUGIN_NAME; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; import org.pf4j.PluginManager; import org.pf4j.PluginState; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.lang.Nullable; import org.thymeleaf.IEngineConfiguration; import org.thymeleaf.spring6.templateresource.SpringResourceTemplateResource; import org.thymeleaf.templateresolver.AbstractConfigurableTemplateResolver; import org.thymeleaf.templateresource.ITemplateResource; /** * Plugin classloader template resolver to resolve template by plugin classloader. * * @author guqing * @since 2.11.0 */ public class PluginClassloaderTemplateResolver extends AbstractConfigurableTemplateResolver { private final PluginManager haloPluginManager; static final Pattern PLUGIN_TEMPLATE_PATTERN = Pattern.compile("plugin:([A-Za-z0-9\\-.]+):(.+)"); /** * Create a new plugin classloader template resolver, not cacheable. * * @param pluginManager plugin manager must not be null */ public PluginClassloaderTemplateResolver(PluginManager pluginManager) { super(); this.haloPluginManager = pluginManager; setCacheable(false); } @Override protected ITemplateResource computeTemplateResource( final IEngineConfiguration configuration, final String ownerTemplate, final String template, final String resourceName, final String characterEncoding, final Map templateResolutionAttributes) { var matchResult = matchPluginTemplate(ownerTemplate, template); if (!matchResult.matches()) { return null; } String pluginName = matchResult.pluginName(); var classloader = getClassloaderByPlugin(pluginName); if (classloader == null) { return null; } var templateName = matchResult.templateName(); var ownerTemplateName = matchResult.ownerTemplateName(); String handledResourceName = computeResourceName(configuration, ownerTemplateName, templateName, getPrefix(), getSuffix(), getForceSuffix(), getTemplateAliases(), templateResolutionAttributes); var resource = new DefaultResourceLoader(classloader) .getResource(handledResourceName); return new SpringResourceTemplateResource(resource, characterEncoding); } MatchResult matchPluginTemplate(String ownerTemplate, String template) { boolean matches = false; String pluginName = null; String templateName = template; String ownerTemplateName = ownerTemplate; if (StringUtils.isNotBlank(ownerTemplate)) { Matcher ownerTemplateMatcher = PLUGIN_TEMPLATE_PATTERN.matcher(ownerTemplate); if (ownerTemplateMatcher.matches()) { matches = true; pluginName = ownerTemplateMatcher.group(1); ownerTemplateName = ownerTemplateMatcher.group(2); } } Matcher templateMatcher = PLUGIN_TEMPLATE_PATTERN.matcher(template); if (templateMatcher.matches()) { matches = true; pluginName = templateMatcher.group(1); templateName = templateMatcher.group(2); } return new MatchResult(pluginName, ownerTemplateName, templateName, matches); } record MatchResult(String pluginName, String ownerTemplateName, String templateName, boolean matches) { } @Nullable private ClassLoader getClassloaderByPlugin(String pluginName) { if (SYSTEM_PLUGIN_NAME.equals(pluginName)) { return this.getClass().getClassLoader(); } var pluginWrapper = haloPluginManager.getPlugin(pluginName); if (pluginWrapper == null || !PluginState.STARTED.equals(pluginWrapper.getPluginState())) { return null; } return pluginWrapper.getPluginClassLoader(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/engine/ThemeTemplateAvailabilityProvider.java ================================================ package run.halo.app.theme.engine; import run.halo.app.theme.ThemeContext; public interface ThemeTemplateAvailabilityProvider { boolean isTemplateAvailable(ThemeContext themeContext, String viewName); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/CategoryFinder.java ================================================ package run.halo.app.theme.finders; import java.util.Collection; import org.springframework.lang.Nullable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Category; import run.halo.app.extension.ListResult; import run.halo.app.theme.finders.vo.CategoryTreeVo; import run.halo.app.theme.finders.vo.CategoryVo; /** * A finder for {@link Category}. * * @author guqing * @since 2.0.0 */ public interface CategoryFinder { Mono getByName(String name); Flux getByNames(Collection names); Mono> list(@Nullable Integer page, @Nullable Integer size); Flux listAll(); Flux listAsTree(); Flux listAsTree(String name); Mono getParentByName(String name); Flux getBreadcrumbs(String name); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/CommentFinder.java ================================================ package run.halo.app.theme.finders; import java.util.Map; import org.springframework.lang.Nullable; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Comment; import run.halo.app.extension.ListResult; import run.halo.app.theme.finders.vo.CommentVo; import run.halo.app.theme.finders.vo.ReplyVo; /** * A finder for finding {@link Comment comments} in template. * * @author guqing * @since 2.0.0 */ public interface CommentFinder { Mono getByName(String name); Mono> list(@Nullable Map ref, @Nullable Integer page, @Nullable Integer size); Mono> listReply(String commentName, @Nullable Integer page, @Nullable Integer size); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/CommentPublicQueryService.java ================================================ package run.halo.app.theme.finders; import org.springframework.lang.Nullable; import reactor.core.publisher.Mono; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.Ref; import run.halo.app.theme.finders.vo.CommentVo; import run.halo.app.theme.finders.vo.CommentWithReplyVo; import run.halo.app.theme.finders.vo.ReplyVo; /** * comment finder. * * @author LIlGG */ public interface CommentPublicQueryService { Mono getByName(String name); Mono> list(Ref ref, @Nullable Integer page, @Nullable Integer size); Mono> list(Ref ref, @Nullable PageRequest pageRequest); Mono> convertToWithReplyVo(ListResult comments, int replySize); Mono> listReply(String commentName, @Nullable Integer page, @Nullable Integer size); Mono> listReply(String commentName, PageRequest pageRequest); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/ContributorFinder.java ================================================ package run.halo.app.theme.finders; import java.util.Collection; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.theme.finders.vo.ContributorVo; /** * A finder for {@link User}. */ public interface ContributorFinder { Mono getContributor(String name); Flux getContributors(Collection names); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/DefaultFinderRegistry.java ================================================ package run.halo.app.theme.finders; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.springframework.beans.factory.InitializingBean; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Component; /** * Finder registry for class annotated with {@link Finder}. * * @author guqing * @since 2.0.0 */ @Component public class DefaultFinderRegistry implements FinderRegistry, InitializingBean { private final Map> pluginFindersLookup = new ConcurrentHashMap<>(); private final Map finders = new ConcurrentHashMap<>(64); private final ApplicationContext applicationContext; public DefaultFinderRegistry(ApplicationContext applicationContext) { this.applicationContext = applicationContext; } Object get(String name) { return finders.get(name); } /** * Given a name, register a Finder for it. * * @param name the canonical name * @param finder the finder to be registered * @throws IllegalStateException if the name is already existing */ void putFinder(String name, Object finder) { if (finders.containsKey(name)) { throw new IllegalStateException( "Finder with name '" + name + "' is already registered"); } finders.put(name, finder); } /** * Register a finder. * * @param finder register a finder that annotated with {@link Finder} * @return the name of the finder */ String putFinder(Object finder) { var name = getFinderName(finder); this.putFinder(name, finder); return name; } private String getFinderName(Object finder) { var annotation = finder.getClass().getAnnotation(Finder.class); if (annotation == null) { // should never happen throw new IllegalStateException("Finder must be annotated with @Finder"); } String name = annotation.value(); if (name == null) { name = finder.getClass().getSimpleName(); } return name; } public void removeFinder(String name) { finders.remove(name); } public Map getFinders() { return Map.copyOf(finders); } @Override public void afterPropertiesSet() { // initialize finders from application context applicationContext.getBeansWithAnnotation(Finder.class) .forEach((beanName, finder) -> { var finderName = getFinderName(finder); this.putFinder(finderName, finder); }); } @Override public void register(String pluginId, ApplicationContext pluginContext) { pluginContext.getBeansWithAnnotation(Finder.class) .forEach((beanName, finder) -> { var finderName = getFinderName(finder); this.putFinder(finderName, finder); pluginFindersLookup .computeIfAbsent(pluginId, ignored -> new ArrayList<>()) .add(finderName); }); } @Override public void unregister(String pluginId) { var finderNames = pluginFindersLookup.remove(pluginId); if (finderNames != null) { finderNames.forEach(finders::remove); } } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/FinderRegistry.java ================================================ package run.halo.app.theme.finders; import java.util.Map; import org.springframework.context.ApplicationContext; /** * Finder registry for class annotated with {@link Finder}. * * @author guqing * @since 2.0.0 */ public interface FinderRegistry { Map getFinders(); void register(String pluginId, ApplicationContext pluginContext); void unregister(String pluginId); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/MenuFinder.java ================================================ package run.halo.app.theme.finders; import reactor.core.publisher.Mono; import run.halo.app.theme.finders.vo.MenuVo; /** * A finder for {@link run.halo.app.core.extension.Menu}. * * @author guqing * @since 2.0.0 */ public interface MenuFinder { Mono getByName(String name); Mono getPrimary(); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/PluginFinder.java ================================================ package run.halo.app.theme.finders; /** * A finder for {@link run.halo.app.core.extension.Plugin}. * * @author guqing * @since 2.0.0 */ public interface PluginFinder { boolean available(String pluginName); boolean available(String pluginName, String requiresVersion); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/PostFinder.java ================================================ package run.halo.app.theme.finders; import java.util.Map; import org.springframework.lang.Nullable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListResult; import run.halo.app.theme.finders.impl.PostFinderImpl.PostQuery; import run.halo.app.theme.finders.vo.ContentVo; import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.finders.vo.NavigationPostVo; import run.halo.app.theme.finders.vo.PostArchiveVo; import run.halo.app.theme.finders.vo.PostVo; /** * A finder for {@link Post}. * * @author guqing * @since 2.0.0 */ public interface PostFinder { /** *

Gets post detail by name.

* We ensure the post is public, non-deleted and published. * * @param postName is post name * @return post detail */ Mono getByName(String postName); Mono content(String postName); Mono cursor(String current); Flux listAll(); /** * Lists posts by query params. * * @param params query params see {@link PostQuery} */ Mono> list(Map params); Mono> list(@Nullable Integer page, @Nullable Integer size); Mono> listByCategory(@Nullable Integer page, @Nullable Integer size, String categoryName); Mono> listByTag(@Nullable Integer page, @Nullable Integer size, String tag); Mono> listByOwner(@Nullable Integer page, @Nullable Integer size, String owner); Mono> archives(Integer page, Integer size); Mono> archives(Integer page, Integer size, String year); Mono> archives(Integer page, Integer size, String year, String month); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/PostPublicQueryService.java ================================================ package run.halo.app.theme.finders; import java.util.List; import org.springframework.lang.NonNull; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.theme.ReactivePostContentHandler; import run.halo.app.theme.finders.vo.ContentVo; import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.finders.vo.PostVo; public interface PostPublicQueryService { /** * Lists public posts by the given list options and page request. * * @param listOptions additional list options * @param page page request must not be null * @return a list of listed post vo */ Mono> list(ListOptions listOptions, PageRequest page); /** * Converts post to listed post vo. * * @param post post must not be null * @return listed post vo */ Mono convertToListedVo(@NonNull Post post); Mono> convertToListedVos(List posts); /** * Converts {@link Post} to post vo and populate post content by the given snapshot name. *

This method will get post content by {@code snapshotName} and try to find * {@link ReactivePostContentHandler}s to extend the content

* * @param post post must not be null * @param snapshotName snapshot name must not be blank * @return converted post vo */ Mono convertToVo(Post post, String snapshotName); /** * Gets post content by post name. *

This method will get post released content by post name and try to find * {@link ReactivePostContentHandler}s to extend the content

* * @param postName post name must not be blank * @return post content for theme-side * @see ReactivePostContentHandler */ Mono getContent(String postName); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/SinglePageConversionService.java ================================================ package run.halo.app.theme.finders; import org.springframework.lang.NonNull; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.theme.ReactiveSinglePageContentHandler; import run.halo.app.theme.finders.vo.ContentVo; import run.halo.app.theme.finders.vo.ListedSinglePageVo; import run.halo.app.theme.finders.vo.SinglePageVo; /** * A service that converts {@link SinglePage} to {@link SinglePageVo}. * * @author guqing * @since 2.6.0 */ public interface SinglePageConversionService { /** * Converts the given {@link SinglePage} to {@link SinglePageVo} and populate content by * given snapshot name. * * @param singlePage the single page must not be null * @param snapshotName the snapshot name to get content must not be blank * @return the converted single page vo * @see #convertToVo(SinglePage) */ Mono convertToVo(SinglePage singlePage, String snapshotName); /** * Converts the given {@link SinglePage} to {@link SinglePageVo}. *

This method will query the additional information of the {@link SinglePageVo} needed to * populate.

*

This method will try to find {@link ReactiveSinglePageContentHandler}s to extend the * content.

* * @param singlePage the single page must not be null * @return the converted single page vo * @see #getContent(String) */ Mono convertToVo(@NonNull SinglePage singlePage); /** * Gets content by given page name. *

This method will get released content by page name and try to find * {@link ReactiveSinglePageContentHandler}s to extend the content.

* * @param pageName page name must not be blank * @return content of the specified page * @since 2.7.0 */ Mono getContent(String pageName); Mono convertToListedVo(SinglePage singlePage); Mono> listBy(ListOptions listOptions, PageRequest pageRequest); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/SinglePageFinder.java ================================================ package run.halo.app.theme.finders; import org.springframework.lang.Nullable; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.ListResult; import run.halo.app.theme.finders.vo.ContentVo; import run.halo.app.theme.finders.vo.ListedSinglePageVo; import run.halo.app.theme.finders.vo.SinglePageVo; /** * A finder for {@link SinglePage}. * * @author guqing * @since 2.0.0 */ public interface SinglePageFinder { Mono getByName(String pageName); Mono content(String pageName); Mono> list(@Nullable Integer page, @Nullable Integer size); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/SiteStatsFinder.java ================================================ package run.halo.app.theme.finders; import reactor.core.publisher.Mono; import run.halo.app.theme.finders.vo.SiteStatsVo; /** * Site statistics finder. * * @author guqing * @since 2.0.0 */ public interface SiteStatsFinder { Mono getStats(); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/TagFinder.java ================================================ package run.halo.app.theme.finders; import java.util.Collection; import java.util.List; import org.springframework.lang.Nullable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.ListResult; import run.halo.app.theme.finders.vo.TagVo; /** * A finder for {@link Tag}. * * @author guqing * @since 2.0.0 */ public interface TagFinder { Mono getByName(String name); Flux getByNames(Collection names); Mono> list(@Nullable Integer page, @Nullable Integer size); List convertToVo(List tags); Flux listAll(); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/ThemeFinder.java ================================================ package run.halo.app.theme.finders; import reactor.core.publisher.Mono; import run.halo.app.theme.finders.vo.ThemeVo; /** * A finder for theme. * * @author guqing * @since 2.0.0 */ public interface ThemeFinder { Mono activation(); Mono getByName(String themeName); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/ThumbnailFinder.java ================================================ package run.halo.app.theme.finders; import reactor.core.publisher.Mono; /** * A dialect expression for image thumbnail. * * @author guqing * @since 2.19.0 */ public interface ThumbnailFinder { /** * Generate thumbnail uri from given image uri and size. * * @param uri URI of the original image, must be encoded * @param size the size of thumbnail to generate * @return the generated thumbnail url */ Mono gen(String uri, String size); } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java ================================================ package run.halo.app.theme.finders.impl; import static run.halo.app.extension.index.query.Queries.notEqual; import java.time.Instant; import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Sort; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.content.CategoryService; import run.halo.app.core.extension.content.Category; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.Queries; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.theme.finders.CategoryFinder; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.vo.CategoryTreeVo; import run.halo.app.theme.finders.vo.CategoryVo; /** * A default implementation of {@link CategoryFinder}. * * @author guqing * @since 2.0.0 */ @Slf4j @Finder("categoryFinder") @RequiredArgsConstructor public class CategoryFinderImpl implements CategoryFinder { private final ReactiveExtensionClient client; private final CategoryService categoryService; @Override public Mono getByName(String name) { return client.fetch(Category.class, name) .map(CategoryVo::from); } @Override public Flux getByNames(Collection names) { if (CollectionUtils.isEmpty(names)) { return Flux.empty(); } var options = ListOptions.builder() .andQuery(Queries.in("metadata.name", names)) .build(); return client.listAll(Category.class, options, ExtensionUtil.defaultSort()) .map(CategoryVo::from); } static Sort defaultSort() { return Sort.by(Sort.Order.desc("spec.priority"), Sort.Order.desc("metadata.creationTimestamp"), Sort.Order.desc("metadata.name")); } @Override public Mono> list(Integer page, Integer size) { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of( notEqual("spec.hideFromList", BooleanUtils.TRUE) )); return client.listBy(Category.class, listOptions, PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size), defaultSort()) ) .map(list -> { List categoryVos = list.get() .map(CategoryVo::from) .collect(Collectors.toList()); return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), categoryVos); }) .defaultIfEmpty(new ListResult<>(page, size, 0L, List.of())); } @Override public Flux listAsTree() { return listAll() .collectList() .flatMapMany(list -> Flux.fromIterable(listToTree(list, null))); } @Override public Flux listAsTree(String name) { return listAllFor(name) .collectList() .flatMapMany(list -> Flux.fromIterable(listToTree(list, name))); } @Override public Flux listAll() { return client.listAll(Category.class, new ListOptions(), defaultSort()) .filter(category -> !category.getSpec().isHideFromList()) .map(CategoryVo::from); } private Flux listAllFor(String parentName) { return Mono.defer( () -> { if (StringUtils.isBlank(parentName)) { return Mono.just(false); } return categoryService.isCategoryHidden(parentName); }) .flatMapMany( isHidden -> client.listAll(Category.class, new ListOptions(), defaultSort()) .filter(category -> { if (isHidden) { return true; } return !category.getSpec().isHideFromList(); }) .map(CategoryVo::from) ); } private List listToTree(List categoryVos, @Nullable String name) { Map nameIdentityMap = categoryVos.stream() .map(CategoryTreeVo::from) .collect(Collectors.toMap(categoryVo -> categoryVo.getMetadata().getName(), Function.identity())); nameIdentityMap.forEach((nameKey, value) -> { List children = value.getSpec().getChildren(); if (children == null) { return; } for (String child : children) { CategoryTreeVo childNode = nameIdentityMap.get(child); if (childNode != null) { childNode.setParentName(nameKey); } } }); var tree = listToTree(nameIdentityMap.values(), name); recomputePostCount(tree); return tree; } private static List listToTree(Collection list, String name) { Map> parentNameIdentityMap = list.stream() .filter(categoryTreeVo -> categoryTreeVo.getParentName() != null) .collect(Collectors.groupingBy(CategoryTreeVo::getParentName)); list.forEach(node -> { // sort children List children = parentNameIdentityMap.getOrDefault(node.getMetadata().getName(), List.of()) .stream() .sorted(defaultTreeNodeComparator()) .toList(); node.setChildren(children); }); return list.stream() .filter(v -> StringUtils.isEmpty(name) ? v.getParentName() == null : StringUtils.equals(v.getMetadata().getName(), name)) .sorted(defaultTreeNodeComparator()) .collect(Collectors.toList()); } private CategoryTreeVo dummyVirtualRoot(List treeNodes) { Category.CategorySpec categorySpec = new Category.CategorySpec(); categorySpec.setSlug("/"); return CategoryTreeVo.builder() .metadata(new Metadata()) .spec(categorySpec) .postCount(0) .children(treeNodes) .metadata(new Metadata()) .build(); } void recomputePostCount(List treeNodes) { var rootNode = dummyVirtualRoot(treeNodes); recomputePostCount(rootNode); } private int recomputePostCount(CategoryTreeVo rootNode) { if (rootNode == null) { return 0; } int originalPostCount = rootNode.getPostCount(); for (var child : rootNode.getChildren()) { int childSum = recomputePostCount(child); if (!child.getSpec().isPreventParentPostCascadeQuery()) { rootNode.setPostCount(rootNode.getPostCount() + childSum); } } return rootNode.getSpec().isPreventParentPostCascadeQuery() ? originalPostCount : rootNode.getPostCount(); } static Comparator defaultTreeNodeComparator() { Function priority = category -> Objects.requireNonNullElse(category.getSpec().getPriority(), 0); Function creationTimestamp = category -> category.getMetadata().getCreationTimestamp(); Function name = category -> category.getMetadata().getName(); return Comparator.comparing(priority) .thenComparing(creationTimestamp) .thenComparing(name); } static Comparator defaultComparator() { Function priority = category -> Objects.requireNonNullElse(category.getSpec().getPriority(), 0); Function creationTimestamp = category -> category.getMetadata().getCreationTimestamp(); Function name = category -> category.getMetadata().getName(); return Comparator.comparing(priority) .thenComparing(creationTimestamp) .thenComparing(name) .reversed(); } @Override public Mono getParentByName(String name) { return categoryService.getParentByName(name) .map(CategoryVo::from); } @Override public Flux getBreadcrumbs(String name) { return listAllFor(name) .collectList() .map(list -> listToTree(list, null)) .flatMapMany(treeNodes -> { var rootNode = dummyVirtualRoot(treeNodes); var paths = new ArrayList(); findPathHelper(rootNode, name, paths); return Flux.fromIterable(paths); }); } private static boolean findPathHelper(CategoryTreeVo node, String targetName, List path) { Assert.notNull(targetName, "Target name must not be null"); if (node == null) { return false; } // null name is just a virtual root if (node.getMetadata().getName() != null) { path.add(CategoryTreeVo.toCategoryVo(node)); } // node maybe a virtual root node so it may have null name if (targetName.equals(node.getMetadata().getName())) { return true; } for (CategoryTreeVo child : node.getChildren()) { if (findPathHelper(child, targetName, path)) { return true; } } // if the target node is not in the current subtree, remove the current node to roll back if (!path.isEmpty()) { path.remove(path.size() - 1); } return false; } int pageNullSafe(Integer page) { return ObjectUtils.defaultIfNull(page, 1); } int sizeNullSafe(Integer page) { return ObjectUtils.defaultIfNull(page, 10); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java ================================================ package run.halo.app.theme.finders.impl; import java.util.Map; import lombok.RequiredArgsConstructor; import reactor.core.publisher.Mono; import run.halo.app.extension.ListResult; import run.halo.app.extension.Ref; import run.halo.app.theme.finders.CommentFinder; import run.halo.app.theme.finders.CommentPublicQueryService; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.vo.CommentVo; import run.halo.app.theme.finders.vo.ReplyVo; /** * A default implementation of {@link CommentFinder}. * * @author guqing * @since 2.0.0 */ @Finder("commentFinder") @RequiredArgsConstructor public class CommentFinderImpl implements CommentFinder { private final CommentPublicQueryService commentPublicQueryService; @Override public Mono getByName(String name) { return commentPublicQueryService.getByName(name); } @Override public Mono> list(Map map, Integer page, Integer size) { if (map == null) { return commentPublicQueryService.list(null, page, size); } Ref ref = new Ref(); ref.setGroup(map.get("group")); ref.setVersion(map.get("version")); ref.setKind(map.get("kind")); ref.setName(map.get("name")); return commentPublicQueryService.list(ref, page, size); } @Override public Mono> listReply(String commentName, Integer page, Integer size) { return commentPublicQueryService.listReply(commentName, page, size); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImpl.java ================================================ package run.halo.app.theme.finders.impl; import static java.nio.charset.StandardCharsets.UTF_8; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static run.halo.app.core.extension.content.Comment.CommentOwner.ownerIdentity; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.isNull; import static run.halo.app.extension.index.query.Queries.or; import com.google.common.hash.Hashing; import java.util.HashMap; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Function; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Sort; import org.springframework.lang.Nullable; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.content.comment.OwnerInfo; import run.halo.app.core.counter.CounterService; import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.theme.finders.CommentPublicQueryService; import run.halo.app.theme.finders.vo.CommentStatsVo; import run.halo.app.theme.finders.vo.CommentVo; import run.halo.app.theme.finders.vo.CommentWithReplyVo; import run.halo.app.theme.finders.vo.ExtensionVoOperator; import run.halo.app.theme.finders.vo.ReplyVo; /** * comment public query service implementation. * * @author LIlGG * @author guqing */ @Component @RequiredArgsConstructor public class CommentPublicQueryServiceImpl implements CommentPublicQueryService { private static final int DEFAULT_SIZE = 10; private static final String COMMENT_VIEW_PERMISSION = "role-template-view-comments"; private final ReactiveExtensionClient client; private final UserService userService; private final CounterService counterService; @Override public Mono getByName(String name) { return client.fetch(Comment.class, name) .flatMap(this::toCommentVo); } @Override public Mono> list(Ref ref, Integer page, Integer size) { return list(ref, PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size), defaultCommentSort())); } @Override public Mono> list(Ref ref, PageRequest pageParam) { var pageRequest = Optional.ofNullable(pageParam) .map(page -> page.withSort(page.getSort().and(defaultCommentSort()))) .orElseGet(() -> PageRequestImpl.ofSize(10)); return populateCommentListOptions(ref) .flatMap(listOptions -> client.listBy(Comment.class, listOptions, pageRequest)) .flatMap(listResult -> Flux.fromStream(listResult.get()) .map(this::toCommentVo) .flatMapSequential(Function.identity()) .collectList() .map(commentVos -> new ListResult<>(listResult.getPage(), listResult.getSize(), listResult.getTotal(), commentVos) ) ) .defaultIfEmpty(ListResult.emptyResult()); } @Override public Mono> convertToWithReplyVo(ListResult comments, int replySize) { return Flux.fromIterable(comments.getItems()) .flatMapSequential(commentVo -> { var commentName = commentVo.getMetadata().getName(); return listReply(commentName, 1, replySize) .map(replyList -> CommentWithReplyVo.from(commentVo) .setReplies(replyList) ); }) .collectList() .map(result -> new ListResult<>( comments.getPage(), comments.getSize(), comments.getTotal(), result) ); } @Override public Mono> listReply(String commentName, Integer page, Integer size) { return listReply(commentName, PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size), defaultReplySort())); } @Override public Mono> listReply(String commentName, PageRequest pageParam) { // check comment return client.get(Comment.class, commentName) .flatMap(this::populateReplyListOptions) .flatMap(listOptions -> { var pageRequest = Optional.ofNullable(pageParam) .map(page -> page.withSort(page.getSort().and(defaultReplySort()))) .orElse(PageRequestImpl.ofSize(0)); return client.listBy(Reply.class, listOptions, pageRequest) .flatMap(list -> Flux.fromStream(list.get().map(this::toReplyVo)) .flatMapSequential(Function.identity()) .collectList() .map(replyVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), replyVos)) ); }) .defaultIfEmpty(ListResult.emptyResult()); } Mono toCommentVo(Comment comment) { Comment.CommentOwner owner = comment.getSpec().getOwner(); return Mono.just(CommentVo.from(comment)) .flatMap(commentVo -> populateStats(Comment.class, commentVo) .doOnNext(commentVo::setStats) .thenReturn(commentVo)) .flatMap(commentVo -> getOwnerInfo(owner) .doOnNext(commentVo::setOwner) .thenReturn(commentVo) ) .flatMap(this::filterCommentSensitiveData); } private Mono filterCommentSensitiveData(CommentVo commentVo) { var owner = commentVo.getOwner(); commentVo.setOwner(OwnerInfo .builder() .displayName(owner.getDisplayName()) .avatar(owner.getAvatar()) .kind(owner.getKind()) .build()); commentVo.getSpec().setIpAddress(""); var specOwner = commentVo.getSpec().getOwner(); specOwner.setName(""); var email = owner.getEmail(); if (StringUtils.isNotBlank(email)) { var emailHash = Hashing.sha256() .hashString(email.toLowerCase(), UTF_8) .toString(); if (specOwner.getAnnotations() == null) { specOwner.setAnnotations(new HashMap<>(2)); } specOwner.getAnnotations() .put(Comment.CommentOwner.EMAIL_HASH_ANNO, emailHash); } if (specOwner.getAnnotations() != null) { specOwner.getAnnotations().remove("Email"); } return Mono.just(commentVo); } // @formatter:off private Mono populateStats(Class clazz, T vo) { return counterService.getByName(MeterUtils.nameOf(clazz, vo.getMetadata() .getName())) .map(counter -> CommentStatsVo.builder() .upvote(counter.getUpvote()) .build() ) .defaultIfEmpty(CommentStatsVo.empty()); } // @formatter:on Mono toReplyVo(Reply reply) { return Mono.just(ReplyVo.from(reply)) .flatMap(replyVo -> populateStats(Reply.class, replyVo) .doOnNext(replyVo::setStats) .thenReturn(replyVo)) .flatMap(replyVo -> getOwnerInfo(reply.getSpec().getOwner()) .doOnNext(replyVo::setOwner) .thenReturn(replyVo) ) .flatMap(this::filterReplySensitiveData); } private Mono filterReplySensitiveData(ReplyVo replyVo) { var owner = replyVo.getOwner(); replyVo.setOwner(OwnerInfo .builder() .displayName(owner.getDisplayName()) .avatar(owner.getAvatar()) .kind(owner.getKind()) .build()); replyVo.getSpec().setIpAddress(""); var specOwner = replyVo.getSpec().getOwner(); specOwner.setName(""); var email = owner.getEmail(); if (StringUtils.isNotBlank(email)) { var emailHash = Hashing.sha256() .hashString(email.toLowerCase(), UTF_8) .toString(); if (specOwner.getAnnotations() == null) { specOwner.setAnnotations(new HashMap<>(2)); } specOwner.getAnnotations() .put(Comment.CommentOwner.EMAIL_HASH_ANNO, emailHash); } if (specOwner.getAnnotations() != null) { specOwner.getAnnotations().remove("Email"); } return Mono.just(replyVo); } private Mono getOwnerInfo(Comment.CommentOwner owner) { if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) { return Mono.just(OwnerInfo.from(owner)); } return userService.getUserOrGhost(owner.getName()) .map(OwnerInfo::from); } private Mono populateCommentListOptions(@Nullable Ref ref) { return populateVisibleListOptions(null) .doOnNext(builder -> { if (ref != null) { builder.andQuery( equal("spec.subjectRef", Comment.toSubjectRefKey(ref))); } }) .map(ListOptions.ListOptionsBuilder::build); } private Mono populateVisibleListOptions( @Nullable Comment comment) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(Authentication::getName) .defaultIfEmpty(AnonymousUserConst.PRINCIPAL) .zipWith(userService.hasSufficientRoles(Set.of(COMMENT_VIEW_PERMISSION)) .defaultIfEmpty(false)) .flatMap(tuple2 -> { var username = tuple2.getT1(); var hasViewPermission = tuple2.getT2(); var commentHidden = false; var isCommentOwner = false; if (comment != null) { commentHidden = Boolean.TRUE.equals(comment.getSpec().getHidden()); var owner = comment.getSpec().getOwner(); isCommentOwner = owner != null && Objects.equals( ownerIdentity(owner.getKind(), owner.getName()), ownerIdentity(User.KIND, username) ); boolean hasPermission = (!commentHidden) || (hasViewPermission || isCommentOwner); if (ExtensionUtil.isDeleted(comment) || !hasPermission) { return Mono.error(new ServerWebInputException( "The comment was not found, hidden or deleted." )); } } var builder = ListOptions.builder(); builder.andQuery(isNull("metadata.deletionTimestamp")); var visibleQuery = and( equal("spec.hidden", BooleanUtils.FALSE), equal("spec.approved", BooleanUtils.TRUE) ); var isAnonymous = AnonymousUserConst.isAnonymousUser(username); if (isAnonymous) { builder.andQuery(visibleQuery); } else if (!(hasViewPermission || (commentHidden && isCommentOwner))) { builder.andQuery(or( equal("spec.owner", ownerIdentity(User.KIND, username)), visibleQuery )); } // View all replies if the user is not an anonymous user, has view permission // or is the comment owner. return Mono.just(builder); }); } private Mono populateReplyListOptions(Comment comment) { // The comment name must be equal to the comment name of the reply // is approved and not hidden return populateVisibleListOptions(comment) .doOnNext(builder -> builder.andQuery(equal("spec.commentName", comment.getMetadata().getName())) ) .map(ListOptions.ListOptionsBuilder::build); } static Sort defaultCommentSort() { return Sort.by(Sort.Order.desc("spec.top"), Sort.Order.asc("spec.priority"), Sort.Order.desc("spec.creationTime"), Sort.Order.asc("metadata.name") ); } static Sort defaultReplySort() { return Sort.by(Sort.Order.asc("spec.creationTime"), Sort.Order.asc("metadata.name") ); } int pageNullSafe(Integer page) { return defaultIfNull(page, 1); } int sizeNullSafe(Integer size) { return defaultIfNull(size, DEFAULT_SIZE); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/impl/ContributorFinderImpl.java ================================================ package run.halo.app.theme.finders.impl; import java.util.Collection; import lombok.RequiredArgsConstructor; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.user.service.UserService; import run.halo.app.theme.finders.ContributorFinder; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.vo.ContributorVo; /** * A default implementation of {@link ContributorFinder}. * * @author guqing * @since 2.0.0 */ @Finder("contributorFinder") @RequiredArgsConstructor public class ContributorFinderImpl implements ContributorFinder { private final UserService userService; @Override public Mono getContributor(String name) { return userService.getUserOrGhost(name).map(ContributorVo::from); } @Override public Flux getContributors(Collection names) { return userService.getUsersOrGhosts(names).map(ContributorVo::from); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/impl/MenuFinderImpl.java ================================================ package run.halo.app.theme.finders.impl; import java.time.Instant; import java.util.Collection; import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; import lombok.AllArgsConstructor; import org.springframework.util.CollectionUtils; import org.springframework.util.comparator.Comparators; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Menu; import run.halo.app.core.extension.MenuItem; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.MenuFinder; import run.halo.app.theme.finders.vo.MenuItemVo; import run.halo.app.theme.finders.vo.MenuVo; /** * A default implementation for {@link MenuFinder}. * * @author guqing * @since 2.0.0 */ @Finder("menuFinder") @AllArgsConstructor public class MenuFinderImpl implements MenuFinder { private final ReactiveExtensionClient client; private final SystemConfigFetcher environmentFetcher; @Override public Mono getByName(String name) { return listAsTree() .filter(menu -> menu.getMetadata().getName().equals(name)) .next() .switchIfEmpty(Mono.error( () -> new NotFoundException("Menu with name " + name + " not found"))); } @Override public Mono getPrimary() { return listAsTree().collectList() .flatMap(menuVos -> { if (CollectionUtils.isEmpty(menuVos)) { return Mono.empty(); } return environmentFetcher.fetch(SystemSetting.Menu.GROUP, SystemSetting.Menu.class) .map(SystemSetting.Menu::getPrimary) .map(primaryConfig -> menuVos.stream() .filter(menuVo -> menuVo.getMetadata().getName().equals(primaryConfig)) .findAny() .orElse(menuVos.get(0)) ) .defaultIfEmpty(menuVos.get(0)); }) .switchIfEmpty( Mono.error(() -> new NotFoundException("No primary menu found")) ); } Flux listAll() { return client.list(Menu.class, null, null) .map(MenuVo::from); } Flux listAsTree() { return listAllMenuItem() .collectList() .map(MenuFinderImpl::populateParentName) .flatMapMany(menuItemVos -> { List treeList = listToTree(menuItemVos); Map nameItemRootNodeMap = treeList.stream() .collect(Collectors.toMap(item -> item.getMetadata().getName(), Function.identity())); return listAll() .map(menuVo -> { LinkedHashSet menuItemNames = menuVo.getSpec().getMenuItems(); if (menuItemNames == null) { return menuVo.withMenuItems(List.of()); } List menuItems = menuItemNames.stream() .map(nameItemRootNodeMap::get) .filter(Objects::nonNull) .sorted(defaultTreeNodeComparator()) .toList(); return menuVo.withMenuItems(menuItems); }); }); } static List listToTree(Collection list) { Map> parentNameIdentityMap = list.stream() .filter(menuItemVo -> menuItemVo.getParentName() != null) .collect(Collectors.groupingBy(MenuItemVo::getParentName)); list.forEach(node -> { // sort children List children = parentNameIdentityMap.getOrDefault(node.getMetadata().getName(), List.of()) .stream() .sorted(defaultTreeNodeComparator()) .toList(); node.setChildren(children); }); return list.stream() .filter(v -> v.getParentName() == null) .collect(Collectors.toList()); } Flux listAllMenuItem() { return client.list(MenuItem.class, null, null) .map(MenuItemVo::from); } static Comparator defaultTreeNodeComparator() { Function priority = menuItem -> menuItem.getSpec().getPriority(); Function createTime = menuItem -> menuItem.getMetadata() .getCreationTimestamp(); Function name = menuItem -> menuItem.getMetadata().getName(); return Comparator.comparing(priority) .thenComparing(createTime, Comparators.nullsLow()) .thenComparing(name); } static Collection populateParentName(List menuItemVos) { Map nameIdentityMap = menuItemVos.stream() .collect(Collectors.toMap(menuItem -> menuItem.getMetadata().getName(), Function.identity())); nameIdentityMap.forEach((name, value) -> { LinkedHashSet children = value.getSpec().getChildren(); if (children == null) { return; } for (String child : children) { MenuItemVo childNode = nameIdentityMap.get(child); if (childNode != null) { childNode.setParentName(name); } } }); return nameIdentityMap.values(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/impl/PluginFinderImpl.java ================================================ package run.halo.app.theme.finders.impl; import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.pf4j.PluginManager; import org.pf4j.PluginState; import org.pf4j.PluginWrapper; import org.springframework.util.Assert; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.PluginFinder; /** * Plugin finder implementation. * * @author guqing * @since 2.0.0 */ @Finder("pluginFinder") @AllArgsConstructor public class PluginFinderImpl implements PluginFinder { private final PluginManager pluginManager; @Override public boolean available(String pluginName) { if (StringUtils.isBlank(pluginName)) { return false; } PluginWrapper pluginWrapper = pluginManager.getPlugin(pluginName); if (pluginWrapper == null) { return false; } return PluginState.STARTED.equals(pluginWrapper.getPluginState()); } @Override public boolean available(String pluginName, String requiresVersion) { Assert.notNull(requiresVersion, "Requires version must not be null."); if (!this.available(pluginName)) { return false; } var pluginWrapper = pluginManager.getPlugin(pluginName); var pluginVersion = pluginWrapper.getDescriptor().getVersion(); return pluginManager.getVersionManager() .checkVersionConstraint(pluginVersion, requiresVersion); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java ================================================ package run.halo.app.theme.finders.impl; import static run.halo.app.extension.PageRequestImpl.ofSize; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.in; import static run.halo.app.extension.index.query.Queries.notEqual; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; import lombok.AllArgsConstructor; import lombok.Data; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Sort; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.content.CategoryService; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.Condition; import run.halo.app.extension.index.query.Queries; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.extension.router.selector.LabelSelector; import run.halo.app.infra.utils.HaloUtils; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.SortUtils; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.PostPublicQueryService; import run.halo.app.theme.finders.vo.ContentVo; import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.finders.vo.NavigationPostVo; import run.halo.app.theme.finders.vo.PostArchiveVo; import run.halo.app.theme.finders.vo.PostArchiveYearMonthVo; import run.halo.app.theme.finders.vo.PostVo; import run.halo.app.theme.router.ReactiveQueryPostPredicateResolver; /** * A finder for {@link Post}. * * @author guqing * @since 2.0.0 */ @Finder("postFinder") @AllArgsConstructor public class PostFinderImpl implements PostFinder { private final ReactiveExtensionClient client; private final PostPublicQueryService postPublicQueryService; private final ReactiveQueryPostPredicateResolver postPredicateResolver; private final CategoryService categoryService; @Override public Mono getByName(String postName) { return postPredicateResolver.getPredicate() .flatMap(predicate -> client.get(Post.class, postName) .filter(predicate) .flatMap(post -> postPublicQueryService.convertToVo(post, post.getSpec().getReleaseSnapshot()) ) ); } @Override public Mono content(String postName) { return postPublicQueryService.getContent(postName); } static Sort defaultSort() { return Sort.by(Sort.Order.desc("spec.pinned"), Sort.Order.desc("spec.priority"), Sort.Order.desc("spec.publishTime"), Sort.Order.asc("metadata.name") ); } static Sort archiveSort() { return Sort.by(Sort.Order.desc("spec.publishTime"), Sort.Order.desc("metadata.name") ); } @Override public Mono cursor(String currentName) { return client.fetch(Post.class, currentName) // make sure the current post is published and has publishing time .filter(p -> Post.isPublished(p.getMetadata())) .filter(p -> p.getSpec() != null && p.getSpec().getPublishTime() != null) .flatMap(currentPost -> { var findPreviousPost = findPreviousPost(currentPost).map(Optional::of) .defaultIfEmpty(Optional.empty()); var findNextPost = findNextPost(currentPost).map(Optional::of) .defaultIfEmpty(Optional.empty()); return Mono.zip(findPreviousPost, findNextPost, (previous, next) -> NavigationPostVo.builder() .previous(previous.map(ListedPostVo::from).orElse(null)) .next(next.map(ListedPostVo::from).orElse(null)) .build() ); }) .switchIfEmpty(Mono.fromSupplier(NavigationPostVo::empty)); } private Mono findPreviousPost(Post currentPost) { var publishTime = currentPost.getSpec().getPublishTime(); return postPredicateResolver.getListOptions() .map(listOptions -> ListOptions.builder(listOptions) .andQuery(notHiddenPostQuery()) .andQuery(Queries.lessThan("spec.publishTime", publishTime)) .build() ) .flatMap(listOptions -> { var sort = Sort.by( Sort.Order.desc("spec.publishTime"), Sort.Order.desc("metadata.name") ); return client.listBy( Post.class, listOptions, ofSize(1).withSort(sort) ); }) .flatMap(listResult -> Mono.justOrEmpty(listResult.getItems().stream().findFirst())); } private Mono findNextPost(Post currentPost) { var publishTime = currentPost.getSpec().getPublishTime(); return postPredicateResolver.getListOptions() .map(listOptions -> ListOptions.builder(listOptions) .andQuery(notHiddenPostQuery()) .andQuery(Queries.greaterThan("spec.publishTime", publishTime)) .build() ) .flatMap(listOptions -> { var sort = Sort.by( Sort.Order.asc("spec.publishTime"), Sort.Order.asc("metadata.name") ); return client.listBy( Post.class, listOptions, ofSize(1).withSort(sort) ); }) .flatMap(listResult -> Mono.justOrEmpty(listResult.getItems().stream().findFirst())); } private static Condition notHiddenPostQuery() { return notEqual("status.hideFromList", BooleanUtils.TRUE); } @Override public Mono> list(Map params) { var query = Optional.ofNullable(params) .map(map -> JsonUtils.mapToObject(map, PostQuery.class)) .orElseGet(PostQuery::new); if (StringUtils.isNotBlank(query.getCategoryName())) { return listChildrenCategories(query.getCategoryName()) .map(category -> category.getMetadata().getName()) .collectList() .map(categoryNames -> ListOptions.builder(query.toListOptions()) .andQuery(in("spec.categories", categoryNames)) .build() ) .flatMap( listOptions -> postPublicQueryService.list(listOptions, query.toPageRequest())); } return postPublicQueryService.list(query.toListOptions(), query.toPageRequest()); } @Override public Mono> list(Integer page, Integer size) { var listOptions = ListOptions.builder() .fieldQuery(notHiddenPostQuery()) .build(); return postPublicQueryService.list(listOptions, getPageRequest(page, size)); } private PageRequestImpl getPageRequest(Integer page, Integer size) { return PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size), defaultSort()); } @Override public Mono> listByCategory(Integer page, Integer size, String categoryName) { return listChildrenCategories(categoryName) .map(category -> category.getMetadata().getName()) .collectList() .flatMap(categoryNames -> { var listOptions = new ListOptions(); var fieldQuery = in("spec.categories", categoryNames); listOptions.setFieldSelector(FieldSelector.of(fieldQuery)); return postPublicQueryService.list(listOptions, getPageRequest(page, size)); }); } private Flux listChildrenCategories(String categoryName) { if (StringUtils.isBlank(categoryName)) { return client.listAll(Category.class, new ListOptions(), Sort.by(Sort.Order.asc("metadata.creationTimestamp"), Sort.Order.desc("metadata.name"))); } return categoryService.listChildren(categoryName); } @Override public Mono> listByTag(Integer page, Integer size, String tag) { var fieldQuery = Queries.empty(); if (StringUtils.isNotBlank(tag)) { fieldQuery = fieldQuery.and(equal("spec.tags", tag)); } var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of(fieldQuery)); return postPublicQueryService.list(listOptions, getPageRequest(page, size)); } @Override public Mono> listByOwner(Integer page, Integer size, String owner) { var fieldQuery = Queries.empty(); if (StringUtils.isNotBlank(owner)) { fieldQuery = fieldQuery.and(equal("spec.owner", owner)); } var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of(fieldQuery)); return postPublicQueryService.list(listOptions, getPageRequest(page, size)); } @Override public Mono> archives(Integer page, Integer size) { return archives(page, size, null, null); } @Override public Mono> archives(Integer page, Integer size, String year) { return archives(page, size, year, null); } @Override public Mono> archives(Integer page, Integer size, String year, String month) { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of(notHiddenPostQuery())); var labelSelectorBuilder = LabelSelector.builder(); if (StringUtils.isNotBlank(year)) { labelSelectorBuilder.eq(Post.ARCHIVE_YEAR_LABEL, year); } if (StringUtils.isNotBlank(month)) { labelSelectorBuilder.eq(Post.ARCHIVE_MONTH_LABEL, month); } listOptions.setLabelSelector(labelSelectorBuilder.build()); var pageRequest = PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size), archiveSort()); return postPublicQueryService.list(listOptions, pageRequest) .map(list -> { Map> yearPosts = list.get() .collect(Collectors.groupingBy( post -> HaloUtils.getYearText(post.getSpec().getPublishTime()))); List postArchives = yearPosts.entrySet().stream() .map(entry -> { String key = entry.getKey(); // archives by month Map> monthPosts = entry.getValue().stream() .collect(Collectors.groupingBy( post -> HaloUtils.getMonthText(post.getSpec().getPublishTime()))); // convert to archive year month value objects List monthArchives = monthPosts.entrySet() .stream() .map(monthEntry -> PostArchiveYearMonthVo.builder() .posts(monthEntry.getValue()) .month(monthEntry.getKey()) .build() ) .sorted( Comparator.comparing(PostArchiveYearMonthVo::getMonth).reversed()) .toList(); return PostArchiveVo.builder() .year(String.valueOf(key)) .months(monthArchives) .build(); }) .sorted(Comparator.comparing(PostArchiveVo::getYear).reversed()) .toList(); return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), postArchives); }) .defaultIfEmpty(ListResult.emptyResult()); } @Override public Flux listAll() { return postPredicateResolver.getListOptions() .flatMapMany(listOptions -> client.listAll(Post.class, listOptions, defaultSort())) .collectList() .flatMap(postPublicQueryService::convertToListedVos) .flatMapMany(Flux::fromIterable); } static int pageNullSafe(Integer page) { return ObjectUtils.defaultIfNull(page, 1); } static int sizeNullSafe(Integer size) { return ObjectUtils.defaultIfNull(size, 10); } @Data public static class PostQuery { private Integer page; private Integer size; private String categoryName; private String tagName; private String owner; private List sort; public ListOptions toListOptions() { var builder = ListOptions.builder(); var hasQuery = false; if (StringUtils.isNotBlank(owner)) { builder.andQuery(equal("spec.owner", owner)); hasQuery = true; } if (StringUtils.isNotBlank(tagName)) { builder.andQuery(equal("spec.tags", tagName)); hasQuery = true; } // Exclude hidden posts when no query if (!hasQuery) { builder.fieldQuery(notHiddenPostQuery()); } return builder.build(); } public PageRequest toPageRequest() { return PageRequestImpl.of(pageNullSafe(getPage()), sizeNullSafe(getSize()), SortUtils.resolve(sort).and(defaultSort())); } } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java ================================================ package run.halo.app.theme.finders.impl; import static java.util.Objects.requireNonNullElse; import static java.util.Objects.requireNonNullElseGet; import static run.halo.app.core.counter.MeterUtils.nameOf; import static run.halo.app.core.user.service.UserService.GHOST_USER_NAME; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.function.Function; import lombok.RequiredArgsConstructor; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Mono; import run.halo.app.content.ContentWrapper; import run.halo.app.content.PostService; import run.halo.app.core.counter.CounterService; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.theme.ReactivePostContentHandler; import run.halo.app.theme.finders.CategoryFinder; import run.halo.app.theme.finders.ContributorFinder; import run.halo.app.theme.finders.PostPublicQueryService; import run.halo.app.theme.finders.TagFinder; import run.halo.app.theme.finders.vo.ContentVo; import run.halo.app.theme.finders.vo.ContributorVo; import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.finders.vo.PostVo; import run.halo.app.theme.finders.vo.StatsVo; import run.halo.app.theme.router.ReactiveQueryPostPredicateResolver; @Component @RequiredArgsConstructor public class PostPublicQueryServiceImpl implements PostPublicQueryService { private final ReactiveExtensionClient client; private final TagFinder tagFinder; private final CategoryFinder categoryFinder; private final ContributorFinder contributorFinder; private final CounterService counterService; private final PostService postService; private final ExtensionGetter extensionGetter; private final ReactiveQueryPostPredicateResolver postPredicateResolver; @Override public Mono> list(ListOptions queryOptions, PageRequest page) { return postPredicateResolver.getListOptions() .map(option -> { var fieldSelector = queryOptions.getFieldSelector(); if (fieldSelector != null) { option.setFieldSelector(option.getFieldSelector() .andQuery(fieldSelector.query())); } var labelSelector = queryOptions.getLabelSelector(); if (labelSelector != null) { option.setLabelSelector(option.getLabelSelector().and(labelSelector)); } return option; }) .flatMap(listOptions -> client.listBy(Post.class, listOptions, page)) .flatMap(list -> convertToListedVos(list.getItems()) .map( postVos -> new ListResult<>( list.getPage(), list.getSize(), list.getTotal(), postVos ) ) ) .defaultIfEmpty(ListResult.emptyResult()); } @Override public Mono convertToListedVo(@NonNull Post post) { Assert.notNull(post, "Post must not be null"); ListedPostVo postVo = ListedPostVo.from(post); postVo.setCategories(List.of()); postVo.setTags(List.of()); postVo.setContributors(List.of()); return Mono.just(postVo) .flatMap(lp -> populateStats(postVo) .doOnNext(lp::setStats) .thenReturn(lp) ) .flatMap(p -> { String owner = p.getSpec().getOwner(); return contributorFinder.getContributor(owner) .doOnNext(p::setOwner) .thenReturn(p); }) .flatMap(p -> { List tagNames = p.getSpec().getTags(); if (CollectionUtils.isEmpty(tagNames)) { return Mono.just(p); } return tagFinder.getByNames(tagNames) .collectList() .doOnNext(p::setTags) .thenReturn(p); }) .flatMap(p -> { List categoryNames = p.getSpec().getCategories(); if (CollectionUtils.isEmpty(categoryNames)) { return Mono.just(p); } return categoryFinder.getByNames(categoryNames) .collectList() .doOnNext(p::setCategories) .thenReturn(p); }) .flatMap(p -> contributorFinder.getContributors(p.getStatus().getContributors()) .collectList() .doOnNext(p::setContributors) .thenReturn(p) ) .defaultIfEmpty(postVo); } @Override public Mono> convertToListedVos(List posts) { var counterNames = new HashSet(posts.size()); var userNames = new HashSet(); var tagNames = new HashSet(); var categoryNames = new HashSet(); posts.forEach(post -> { counterNames.add(nameOf(Post.class, post.getMetadata().getName())); var spec = post.getSpec(); userNames.add(spec.getOwner()); var status = post.getStatus(); if (status != null && status.getContributors() != null) { userNames.addAll(status.getContributors()); } if (spec.getTags() != null) { tagNames.addAll(spec.getTags()); } if (spec.getCategories() != null) { categoryNames.addAll(spec.getCategories()); } }); var getCounters = counterService.getByNames(counterNames) .collectMap(counter -> counter.getMetadata().getName()); var getContributors = contributorFinder.getContributors(userNames) .collectMap(ContributorVo::getName); var getTags = tagFinder.getByNames(tagNames) .collectMap(tagVo -> tagVo.getMetadata().getName()); var getCategories = categoryFinder.getByNames(categoryNames) .collectMap(categoryVo -> categoryVo.getMetadata().getName()); return Mono.zip(getCounters, getContributors, getTags, getCategories) .map(tuple -> { var counters = tuple.getT1(); var contributors = tuple.getT2(); var tags = tuple.getT3(); var categories = tuple.getT4(); return posts.stream() .map(post -> { var vo = ListedPostVo.from(post); vo.setCategories(List.of()); vo.setTags(List.of()); vo.setContributors(List.of()); var spec = post.getSpec(); var status = post.getStatus(); var ghost = requireNonNullElseGet( contributors.get(GHOST_USER_NAME), ContributorVo::ghost ); vo.setOwner(requireNonNullElse(contributors.get(spec.getOwner()), ghost)); if (status != null && !CollectionUtils.isEmpty(status.getContributors())) { vo.setContributors(status.getContributors() .stream() .map(name -> requireNonNullElse(contributors.get(name), ghost)) .toList()); } if (!CollectionUtils.isEmpty(spec.getTags())) { vo.setTags(spec.getTags() .stream() .map(tags::get) .filter(Objects::nonNull) .toList()); } if (!CollectionUtils.isEmpty(spec.getCategories())) { vo.setCategories(spec.getCategories() .stream() .map(categories::get) .filter(Objects::nonNull) .toList()); } var counterName = nameOf(Post.class, post.getMetadata().getName()); var counter = counters.get(counterName); if (counter != null) { vo.setStats(StatsVo.builder() .visit(counter.getVisit()) .upvote(counter.getUpvote()) .comment(counter.getApprovedComment()) .build() ); } else { vo.setStats(StatsVo.empty()); } return vo; }) .toList(); }); } @Override public Mono convertToVo(Post post, String snapshotName) { final String baseSnapshotName = post.getSpec().getBaseSnapshot(); return convertToListedVo(post) .map(PostVo::from) .flatMap(postVo -> postService.getContent(snapshotName, baseSnapshotName) .flatMap(wrapper -> extendPostContent(post, wrapper)) .doOnNext(postVo::setContent) .thenReturn(postVo) ); } @Override public Mono getContent(String postName) { return postPredicateResolver.getPredicate() .flatMap(predicate -> client.get(Post.class, postName) .filter(predicate) ) .flatMap(post -> { String releaseSnapshot = post.getSpec().getReleaseSnapshot(); return postService.getContent(releaseSnapshot, post.getSpec().getBaseSnapshot()) .flatMap(wrapper -> extendPostContent(post, wrapper)); }); } @NonNull protected Mono extendPostContent(Post post, ContentWrapper wrapper) { Assert.notNull(post, "Post name must not be null"); Assert.notNull(wrapper, "Post content must not be null"); return extensionGetter.getEnabledExtensions(ReactivePostContentHandler.class) .reduce(Mono.fromSupplier(() -> ReactivePostContentHandler.PostContentContext.builder() .post(post) .content(wrapper.getContent()) .raw(wrapper.getRaw()) .rawType(wrapper.getRawType()) .build() ), (contentMono, handler) -> contentMono.flatMap(handler::handle) ) .flatMap(Function.identity()) .map(postContent -> ContentVo.builder() .content(postContent.getContent()) .raw(postContent.getRaw()) .build() ); } private Mono populateStats(T postVo) { return counterService.getByName(nameOf(Post.class, postVo.getMetadata() .getName()) ) .map(counter -> StatsVo.builder() .visit(counter.getVisit()) .upvote(counter.getUpvote()) .comment(counter.getApprovedComment()) .build() ) .defaultIfEmpty(StatsVo.empty()); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImpl.java ================================================ package run.halo.app.theme.finders.impl; import static org.springframework.data.domain.Sort.Order.asc; import static org.springframework.data.domain.Sort.Order.desc; import static run.halo.app.core.extension.content.Post.VisibleEnum.PUBLIC; import static run.halo.app.core.extension.content.SinglePage.PUBLISHED_LABEL; import static run.halo.app.extension.ExtensionUtil.notDeleting; import static run.halo.app.extension.index.query.Queries.equal; import java.util.List; import java.util.function.Function; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Sort; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.content.ContentWrapper; import run.halo.app.content.SinglePageService; import run.halo.app.core.counter.CounterService; import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.theme.ReactiveSinglePageContentHandler; import run.halo.app.theme.ReactiveSinglePageContentHandler.SinglePageContentContext; import run.halo.app.theme.finders.ContributorFinder; import run.halo.app.theme.finders.SinglePageConversionService; import run.halo.app.theme.finders.vo.ContentVo; import run.halo.app.theme.finders.vo.ListedSinglePageVo; import run.halo.app.theme.finders.vo.SinglePageVo; import run.halo.app.theme.finders.vo.StatsVo; /** * Default implementation of {@link SinglePageConversionService}. * * @author guqing * @since 2.6.0 */ @Component @RequiredArgsConstructor public class SinglePageConversionServiceImpl implements SinglePageConversionService { private final ReactiveExtensionClient client; private final SinglePageService singlePageService; private final ContributorFinder contributorFinder; private final CounterService counterService; private final ExtensionGetter extensionGetter; @Override public Mono convertToVo(SinglePage singlePage, String snapshotName) { return convert(singlePage, snapshotName); } @Override public Mono convertToVo(@NonNull SinglePage singlePage) { return convert(singlePage, singlePage.getSpec().getReleaseSnapshot()); } protected Mono extendPageContent(SinglePage singlePage, ContentWrapper wrapper) { Assert.notNull(singlePage, "SinglePage must not be null"); Assert.notNull(wrapper, "SinglePage content must not be null"); return extensionGetter.getEnabledExtensions( ReactiveSinglePageContentHandler.class) .reduce(Mono.fromSupplier(() -> SinglePageContentContext.builder() .singlePage(singlePage) .content(wrapper.getContent()) .raw(wrapper.getRaw()) .rawType(wrapper.getRawType()) .build() ), (contentMono, handler) -> contentMono.flatMap(handler::handle) ) .flatMap(Function.identity()) .map(pageContent -> ContentVo.builder() .content(pageContent.getContent()) .raw(pageContent.getRaw()) .build() ); } @Override public Mono getContent(String pageName) { return client.get(SinglePage.class, pageName) .flatMap(singlePage -> { String releaseSnapshot = singlePage.getSpec().getReleaseSnapshot(); String baseSnapshot = singlePage.getSpec().getBaseSnapshot(); return singlePageService.getContent(releaseSnapshot, baseSnapshot) .flatMap(wrapper -> extendPageContent(singlePage, wrapper)); }) .map(wrapper -> ContentVo.builder().content(wrapper.getContent()) .raw(wrapper.getRaw()).build()); } @Override public Mono convertToListedVo(SinglePage singlePage) { return Mono.fromSupplier( () -> { ListedSinglePageVo pageVo = ListedSinglePageVo.from(singlePage); pageVo.setContributors(List.of()); return pageVo; }) .flatMap(this::populateStats) .flatMap(this::populateContributors); } @Override public Mono> listBy(ListOptions listOptions, PageRequest pageRequest) { // rewrite list options var rewroteListOptions = ListOptions.builder(listOptions) .andQuery(notDeleting()) .andQuery(equal("spec.deleted", Boolean.FALSE.toString())) .andQuery(equal("spec.visible", PUBLIC.name())) .labelSelector() .eq(PUBLISHED_LABEL, Boolean.TRUE.toString()) .end() .build(); // rewrite sort var rewroteSort = pageRequest.getSort() .and(Sort.by( desc("spec.pinned"), asc("spec.priority") )); var rewrotePageRequest = PageRequestImpl.of(pageRequest.getPageNumber(), pageRequest.getPageSize(), rewroteSort); return client.listBy(SinglePage.class, rewroteListOptions, rewrotePageRequest) .flatMap(list -> Flux.fromStream(list.get()) .flatMapSequential(this::convertToListedVo) .collectList() .map(pageVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), pageVos) ) ); } Mono convert(SinglePage singlePage, String snapshotName) { Assert.notNull(singlePage, "Single page must not be null"); Assert.hasText(snapshotName, "Snapshot name must not be empty"); return Mono.just(singlePage) .map(page -> { SinglePageVo pageVo = SinglePageVo.from(page); pageVo.setContributors(List.of()); pageVo.setContent(ContentVo.empty()); return pageVo; }) .flatMap(this::populateStats) .flatMap(this::populateContributors) .flatMap(page -> { String baseSnapshot = page.getSpec().getBaseSnapshot(); return singlePageService.getContent(snapshotName, baseSnapshot) .flatMap(wrapper -> extendPageContent(singlePage, wrapper)) .doOnNext(page::setContent) .thenReturn(page); }) .flatMap(page -> contributorFinder.getContributor(page.getSpec().getOwner()) .doOnNext(page::setOwner) .thenReturn(page) ); } Mono populateStats(T pageVo) { String name = pageVo.getMetadata().getName(); return counterService.getByName(MeterUtils.nameOf(SinglePage.class, name)) .map(counter -> StatsVo.builder() .visit(counter.getVisit()) .upvote(counter.getUpvote()) .comment(counter.getApprovedComment()) .build() ) .doOnNext(pageVo::setStats) .thenReturn(pageVo); } Mono populateContributors(T pageVo) { List names = pageVo.getStatus().getContributors(); if (CollectionUtils.isEmpty(names)) { return Mono.just(pageVo); } return contributorFinder.getContributors(names) .collectList() .doOnNext(pageVo::setContributors) .thenReturn(pageVo); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java ================================================ package run.halo.app.theme.finders.impl; import java.security.Principal; import java.util.Objects; import java.util.function.Predicate; import lombok.AllArgsConstructor; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.SinglePageConversionService; import run.halo.app.theme.finders.SinglePageFinder; import run.halo.app.theme.finders.vo.ContentVo; import run.halo.app.theme.finders.vo.ListedSinglePageVo; import run.halo.app.theme.finders.vo.SinglePageVo; /** * A default implementation of {@link SinglePage}. * * @author guqing * @since 2.0.0 */ @Finder("singlePageFinder") @AllArgsConstructor public class SinglePageFinderImpl implements SinglePageFinder { private final ReactiveExtensionClient client; private final SinglePageConversionService singlePagePublicQueryService; @Override public Mono getByName(String pageName) { return client.get(SinglePage.class, pageName) .filterWhen(page -> queryPredicate().map(predicate -> predicate.test(page))) .flatMap(singlePagePublicQueryService::convertToVo); } @Override public Mono content(String pageName) { return singlePagePublicQueryService.getContent(pageName); } @Override public Mono> list(Integer page, Integer size) { return singlePagePublicQueryService.listBy( new ListOptions(), PageRequestImpl.of(page, size) ); } Mono> queryPredicate() { Predicate predicate = page -> page.isPublished() && Objects.equals(false, page.getSpec().getDeleted()); Predicate visiblePredicate = page -> Post.VisibleEnum.PUBLIC.equals(page.getSpec().getVisible()); return currentUserName() .map(username -> predicate.and( visiblePredicate.or(page -> username.equals(page.getSpec().getOwner()))) ) .defaultIfEmpty(predicate.and(visiblePredicate)); } Mono currentUserName() { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(Principal::getName) .filter(name -> !AnonymousUserConst.isAnonymousUser(name)); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/impl/SiteStatsFinderImpl.java ================================================ package run.halo.app.theme.finders.impl; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.isNull; import lombok.AllArgsConstructor; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.extension.router.selector.LabelSelector; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.SiteStatsFinder; import run.halo.app.theme.finders.vo.SiteStatsVo; /** * A default implementation of {@link SiteStatsFinder}. * * @author guqing * @since 2.0.0 */ @AllArgsConstructor @Finder("siteStatsFinder") public class SiteStatsFinderImpl implements SiteStatsFinder { private final ReactiveExtensionClient client; @Override public Mono getStats() { return client.list(Counter.class, null, null) .reduce(SiteStatsVo.empty(), (stats, counter) -> { stats.setVisit(stats.getVisit() + counter.getVisit()); stats.setComment(stats.getComment() + counter.getApprovedComment()); stats.setUpvote(stats.getUpvote() + counter.getUpvote()); return stats; }) .flatMap(siteStatsVo -> postCount() .doOnNext(siteStatsVo::setPost) .thenReturn(siteStatsVo) ) .flatMap(siteStatsVo -> categoryCount() .doOnNext(siteStatsVo::setCategory) .thenReturn(siteStatsVo)); } Mono postCount() { var listOptions = new ListOptions(); listOptions.setLabelSelector(LabelSelector.builder() .eq(Post.PUBLISHED_LABEL, "true") .build()); var fieldQuery = and( isNull("metadata.deletionTimestamp"), equal("spec.deleted", "false") ); listOptions.setFieldSelector(FieldSelector.of(fieldQuery)); return client.listBy(Post.class, listOptions, PageRequestImpl.ofSize(1)) .map(result -> (int) result.getTotal()); } Mono categoryCount() { return client.listBy(Category.class, new ListOptions(), PageRequestImpl.ofSize(1)) .map(ListResult::getTotal) .map(Long::intValue); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java ================================================ package run.halo.app.theme.finders.impl; import java.util.Collection; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; import org.apache.commons.lang3.ObjectUtils; import org.springframework.data.domain.Sort; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.Queries; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.TagFinder; import run.halo.app.theme.finders.vo.TagVo; /** * A default implementation of {@link TagFinder}. * * @author guqing * @since 2.0.0 */ @Finder("tagFinder") public class TagFinderImpl implements TagFinder { public static final Comparator DEFAULT_COMPARATOR = Comparator.comparing(tag -> tag.getMetadata().getCreationTimestamp()); private final ReactiveExtensionClient client; public TagFinderImpl(ReactiveExtensionClient client) { this.client = client; } @Override public Mono getByName(String name) { return client.fetch(Tag.class, name) .map(TagVo::from); } @Override public Flux getByNames(Collection names) { if (CollectionUtils.isEmpty(names)) { return Flux.empty(); } var options = ListOptions.builder() .andQuery(Queries.in("metadata.name", names)) .build(); return client.listAll(Tag.class, options, ExtensionUtil.defaultSort()) .map(TagVo::from); } @Override public Mono> list(Integer page, Integer size) { return listBy(new ListOptions(), PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size))); } @Override public List convertToVo(List tags) { if (CollectionUtils.isEmpty(tags)) { return List.of(); } return tags.stream() .map(TagVo::from) .collect(Collectors.toList()); } @Override public Flux listAll() { return client.listAll(Tag.class, new ListOptions(), Sort.by(Sort.Order.desc("metadata.creationTimestamp"))) .map(TagVo::from); } private Mono> listBy(ListOptions listOptions, PageRequest pageRequest) { return client.listBy(Tag.class, listOptions, pageRequest) .map(result -> { List tagVos = result.get() .map(TagVo::from) .collect(Collectors.toList()); return new ListResult<>(result.getPage(), result.getSize(), result.getTotal(), tagVos); }); } int pageNullSafe(Integer page) { return ObjectUtils.defaultIfNull(page, 1); } int sizeNullSafe(Integer size) { return ObjectUtils.defaultIfNull(size, 10); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/impl/ThemeFinderImpl.java ================================================ package run.halo.app.theme.finders.impl; import com.fasterxml.jackson.databind.JsonNode; import java.util.HashMap; import java.util.Map; import org.apache.commons.lang3.StringUtils; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Theme; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.ThemeFinder; import run.halo.app.theme.finders.vo.ThemeVo; /** * A default implementation for {@link ThemeFinder}. * * @author guqing * @since 2.0.0 */ @Finder("themeFinder") public class ThemeFinderImpl implements ThemeFinder { private final ReactiveExtensionClient client; private final SystemConfigFetcher environmentFetcher; public ThemeFinderImpl(ReactiveExtensionClient client, SystemConfigFetcher environmentFetcher) { this.client = client; this.environmentFetcher = environmentFetcher; } @Override public Mono activation() { return environmentFetcher.fetch(SystemSetting.Theme.GROUP, SystemSetting.Theme.class) .map(SystemSetting.Theme::getActive) .flatMap(themeName -> client.fetch(Theme.class, themeName)) .flatMap(theme -> themeWithConfig(ThemeVo.from(theme))); } @Override public Mono getByName(String themeName) { return client.fetch(Theme.class, themeName) .flatMap(theme -> themeWithConfig(ThemeVo.from(theme))); } private Mono themeWithConfig(ThemeVo themeVo) { if (StringUtils.isBlank(themeVo.getSpec().getConfigMapName())) { return Mono.just(themeVo); } return client.fetch(ConfigMap.class, themeVo.getSpec().getConfigMapName()) .map(configMap -> { Map config = new HashMap<>(); configMap.getData().forEach((k, v) -> { JsonNode jsonNode = JsonUtils.jsonToObject(v, JsonNode.class); config.put(k, jsonNode); }); JsonNode configJson = JsonUtils.mapToObject(config, JsonNode.class); return themeVo.withConfig(configJson); }) .defaultIfEmpty(themeVo); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/impl/ThumbnailFinderImpl.java ================================================ package run.halo.app.theme.finders.impl; import java.net.URI; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.core.attachment.thumbnail.ThumbnailService; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.ThumbnailFinder; @Slf4j @Finder("thumbnail") @RequiredArgsConstructor public class ThumbnailFinderImpl implements ThumbnailFinder { private final ThumbnailService thumbnailService; @Override public Mono gen(String uriStr, String size) { return Mono.fromCallable(() -> URI.create(uriStr)) .flatMap(uri -> thumbnailService.get(uri, ThumbnailSize.fromName(size))) .map(URI::toASCIIString) .onErrorComplete(IllegalArgumentException.class) .defaultIfEmpty(uriStr); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/CategoryTreeVo.java ================================================ package run.halo.app.theme.finders.vo; import java.util.List; import java.util.Objects; import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import org.springframework.util.Assert; import run.halo.app.core.extension.content.Category; import run.halo.app.extension.MetadataOperator; /** * A tree vo for {@link Category}. * * @author guqing * @since 2.0.0 */ @Data @Builder @ToString @EqualsAndHashCode public class CategoryTreeVo implements VisualizableTreeNode, ExtensionVoOperator { private MetadataOperator metadata; private Category.CategorySpec spec; private Category.CategoryStatus status; private List children; private String parentName; private Integer postCount; /** * Convert {@link CategoryVo} to {@link CategoryTreeVo}. * * @param category category value object * @return category tree value object */ public static CategoryTreeVo from(CategoryVo category) { Assert.notNull(category, "The category must not be null"); return CategoryTreeVo.builder() .metadata(category.getMetadata()) .spec(category.getSpec()) .status(category.getStatus()) .children(List.of()) .postCount(Objects.requireNonNullElse(category.getPostCount(), 0)) .build(); } /** * Convert {@link CategoryTreeVo} to {@link CategoryVo}. */ public static CategoryVo toCategoryVo(CategoryTreeVo categoryTreeVo) { Assert.notNull(categoryTreeVo, "The category tree vo must not be null"); return CategoryVo.builder() .metadata(categoryTreeVo.getMetadata()) .spec(categoryTreeVo.getSpec()) .status(categoryTreeVo.getStatus()) .postCount(categoryTreeVo.getPostCount()) .build(); } @Override public String nodeText() { return String.format("%s (%s)%s", getSpec().getDisplayName(), getPostCount(), spec.isPreventParentPostCascadeQuery() ? " (Independent)" : ""); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/CategoryVo.java ================================================ package run.halo.app.theme.finders.vo; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Value; import run.halo.app.core.extension.content.Category; import run.halo.app.extension.MetadataOperator; /** * A value object for {@link Category}. * * @author guqing * @since 2.0.0 */ @Value @Builder @EqualsAndHashCode public class CategoryVo implements ExtensionVoOperator { MetadataOperator metadata; Category.CategorySpec spec; Category.CategoryStatus status; Integer postCount; /** * Convert {@link Category} to {@link CategoryVo}. * * @param category category extension * @return category value object */ public static CategoryVo from(Category category) { return CategoryVo.builder() .metadata(category.getMetadata()) .spec(category.getSpec()) .status(category.getStatus()) .postCount(category.getStatusOrDefault().getVisiblePostCount()) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/CommentStatsVo.java ================================================ package run.halo.app.theme.finders.vo; import lombok.Builder; import lombok.Value; /** * comment stats value object. * * @author LIlGG * @since 2.0.0 */ @Value @Builder public class CommentStatsVo { Integer upvote; public static CommentStatsVo empty() { return CommentStatsVo.builder() .upvote(0) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java ================================================ package run.halo.app.theme.finders.vo; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; import run.halo.app.content.comment.OwnerInfo; import run.halo.app.core.extension.content.Comment; import run.halo.app.extension.MetadataOperator; /** * A value object for {@link Comment}. * * @author guqing * @since 2.0.0 */ @Data @Accessors(chain = true) @EqualsAndHashCode public class CommentVo implements ExtensionVoOperator { @Schema(requiredMode = REQUIRED) private MetadataOperator metadata; @Schema(requiredMode = REQUIRED) private Comment.CommentSpec spec; private Comment.CommentStatus status; @Schema(requiredMode = REQUIRED) private OwnerInfo owner; @Schema(requiredMode = REQUIRED) private CommentStatsVo stats; /** * Convert {@link Comment} to {@link CommentVo}. * * @param comment comment extension * @return a value object for {@link Comment} */ public static CommentVo from(Comment comment) { return new CommentVo() .setMetadata(comment.getMetadata()) .setSpec(comment.getSpec()) .setStatus(comment.getStatus()); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/CommentWithReplyVo.java ================================================ package run.halo.app.theme.finders.vo; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; import org.springframework.beans.BeanUtils; import run.halo.app.extension.ListResult; /** *

A value object for comment with reply.

* * @author guqing * @since 2.14.0 */ @Data @Accessors(chain = true) @EqualsAndHashCode(callSuper = true) public class CommentWithReplyVo extends CommentVo { private ListResult replies; /** * Convert {@link CommentVo} to {@link CommentWithReplyVo}. */ public static CommentWithReplyVo from(CommentVo commentVo) { var commentWithReply = new CommentWithReplyVo(); BeanUtils.copyProperties(commentVo, commentWithReply); commentWithReply.setReplies(ListResult.emptyResult()); return commentWithReply; } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/ContentVo.java ================================================ package run.halo.app.theme.finders.vo; import lombok.Builder; import lombok.ToString; import lombok.Value; import run.halo.app.core.extension.content.Snapshot; /** * A value object for Content from {@link Snapshot}. * * @author guqing * @since 2.0.0 */ @Value @ToString @Builder public class ContentVo { String raw; String content; /** * Empty content object. */ public static ContentVo empty() { return ContentVo.builder() .raw("") .content("") .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/ContributorVo.java ================================================ package run.halo.app.theme.finders.vo; import lombok.Builder; import lombok.ToString; import lombok.Value; import run.halo.app.core.extension.User; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.Metadata; import run.halo.app.extension.MetadataOperator; /** * A value object for {@link run.halo.app.core.extension.User}. * * @author guqing * @since 2.0.0 */ @Value @ToString @Builder public class ContributorVo implements ExtensionVoOperator { String name; String displayName; String avatar; String bio; String permalink; MetadataOperator metadata; /** * Convert {@link User} to {@link ContributorVo}. * * @param user user extension * @return contributor value object */ public static ContributorVo from(User user) { User.UserStatus status = user.getStatus(); String permalink = (status == null ? "" : status.getPermalink()); return builder().name(user.getMetadata().getName()) .displayName(user.getSpec().getDisplayName()) .avatar(user.getSpec().getAvatar()) .bio(user.getSpec().getBio()) .permalink(permalink) .metadata(user.getMetadata()) .build(); } /** * Create a ghost contributor. * * @return a ghost contributor value object */ public static ContributorVo ghost() { var metadata = new Metadata(); metadata.setName(UserService.GHOST_USER_NAME); return builder() .name("ghost") .displayName("Ghost") // .avatar("/images/ghost.png") .bio("A ghost user.") .permalink("/authors/ghost") .metadata(metadata) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/ListedPostVo.java ================================================ package run.halo.app.theme.finders.vo; import java.util.List; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import lombok.experimental.SuperBuilder; import org.springframework.util.Assert; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.MetadataOperator; /** * A value object for {@link Post}. * * @author guqing * @since 2.0.0 */ @Data @SuperBuilder @ToString @EqualsAndHashCode public class ListedPostVo implements ExtensionVoOperator { private MetadataOperator metadata; private Post.PostSpec spec; private Post.PostStatus status; private List categories; private List tags; private List contributors; private ContributorVo owner; private StatsVo stats; /** * Convert {@link Post} to {@link ListedPostVo}. * * @param post post extension * @return post value object */ public static ListedPostVo from(Post post) { Assert.notNull(post, "The post must not be null."); Post.PostSpec spec = post.getSpec(); Post.PostStatus postStatus = post.getStatusOrDefault(); return ListedPostVo.builder() .metadata(post.getMetadata()) .spec(spec) .status(postStatus) .categories(List.of()) .tags(List.of()) .contributors(List.of()) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/ListedSinglePageVo.java ================================================ package run.halo.app.theme.finders.vo; import java.util.List; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import lombok.experimental.SuperBuilder; import org.springframework.util.Assert; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.MetadataOperator; /** * A value object for {@link SinglePage}. * * @author guqing * @since 2.0.0 */ @Data @SuperBuilder @ToString @EqualsAndHashCode public class ListedSinglePageVo implements ExtensionVoOperator { private MetadataOperator metadata; private SinglePage.SinglePageSpec spec; private SinglePage.SinglePageStatus status; private StatsVo stats; private List contributors; private ContributorVo owner; /** * Convert {@link SinglePage} to {@link ListedSinglePageVo}. * * @param singlePage single page extension * @return special page value object */ public static ListedSinglePageVo from(SinglePage singlePage) { Assert.notNull(singlePage, "The singlePage must not be null."); SinglePage.SinglePageSpec spec = singlePage.getSpec(); SinglePage.SinglePageStatus pageStatus = singlePage.getStatus(); return ListedSinglePageVo.builder() .metadata(singlePage.getMetadata()) .spec(spec) .status(pageStatus) .contributors(List.of()) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/MenuItemVo.java ================================================ package run.halo.app.theme.finders.vo; import java.util.List; import lombok.Builder; import lombok.Data; import lombok.ToString; import org.apache.commons.lang3.StringUtils; import run.halo.app.core.extension.MenuItem; import run.halo.app.extension.MetadataOperator; /** * A value object for {@link MenuItem}. * * @author guqing * @since 2.0.0 */ @Data @ToString @Builder public class MenuItemVo implements VisualizableTreeNode, ExtensionVoOperator { MetadataOperator metadata; MenuItem.MenuItemSpec spec; MenuItem.MenuItemStatus status; List children; String parentName; /** * Gets menu item's display name. */ public String getDisplayName() { if (status != null && StringUtils.isNotBlank(status.getDisplayName())) { return status.getDisplayName(); } return spec.getDisplayName(); } /** * Convert {@link MenuItem} to {@link MenuItemVo}. * * @param menuItem menu item extension * @return menu item value object */ public static MenuItemVo from(MenuItem menuItem) { MenuItem.MenuItemStatus status = menuItem.getStatus(); return MenuItemVo.builder() .metadata(menuItem.getMetadata()) .spec(menuItem.getSpec()) .status(status) .children(List.of()) .build(); } @Override public String nodeText() { return getDisplayName(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/MenuVo.java ================================================ package run.halo.app.theme.finders.vo; import java.util.Iterator; import java.util.List; import lombok.Builder; import lombok.ToString; import lombok.Value; import lombok.With; import run.halo.app.core.extension.Menu; import run.halo.app.extension.MetadataOperator; /** * A value object for {@link Menu}. * * @author guqing * @since 2.0.0 */ @Value @ToString @Builder public class MenuVo implements ExtensionVoOperator { MetadataOperator metadata; Menu.Spec spec; @With List menuItems; /** * Convert {@link Menu} to {@link MenuVo}. * * @param menu menu extension * @return menu value object */ public static MenuVo from(Menu menu) { return builder() .metadata(menu.getMetadata()) .spec(menu.getSpec()) .menuItems(List.of()) .build(); } public void print(StringBuilder buffer) { buffer.append(getSpec().getDisplayName()); buffer.append('\n'); if (menuItems == null) { return; } for (Iterator it = menuItems.iterator(); it.hasNext(); ) { MenuItemVo next = it.next(); if (it.hasNext()) { next.print(buffer, "├── ", "│ "); } else { next.print(buffer, "└── ", " "); } } } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/NavigationPostVo.java ================================================ package run.halo.app.theme.finders.vo; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import org.jspecify.annotations.Nullable; /** * Post navigation vo to hold previous and next item. * * @param previous Previous post. It's publishing time is earlier than current post. * @param next Next post. It's publishing time is later than current post. * @author guqing * @author johnniang * @since 2.0.0 */ @Builder public record NavigationPostVo( @Schema(requiredMode = NOT_REQUIRED) @Nullable ListedPostVo previous, @Schema(requiredMode = NOT_REQUIRED) @Nullable ListedPostVo next ) { /** * Indicates whether it has next post. * * @return true if it has next post, false otherwise */ public boolean hasNext() { return next != null; } /** * Indicates whether it has previous post. * * @return true if it has previous post, false otherwise */ public boolean hasPrevious() { return previous != null; } public static NavigationPostVo empty() { return NavigationPostVo.builder().build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/PostArchiveVo.java ================================================ package run.halo.app.theme.finders.vo; import java.util.List; import lombok.Builder; import lombok.Value; /** * Post archives by year and month. * * @author guqing * @since 2.0.0 */ @Value @Builder public class PostArchiveVo { String year; List months; } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/PostArchiveYearMonthVo.java ================================================ package run.halo.app.theme.finders.vo; import java.util.List; import lombok.Builder; import lombok.Value; /** * Post archives by month. * * @author guqing * @since 2.0.0 */ @Value @Builder public class PostArchiveYearMonthVo { String month; List posts; } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/PostVo.java ================================================ package run.halo.app.theme.finders.vo; import java.util.List; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import lombok.experimental.SuperBuilder; import org.springframework.util.Assert; import run.halo.app.core.extension.content.Post; /** * A value object for {@link Post}. * * @author guqing * @since 2.0.0 */ @Data @SuperBuilder @ToString @EqualsAndHashCode(callSuper = true) public class PostVo extends ListedPostVo { private ContentVo content; /** * Convert {@link Post} to {@link PostVo}. * * @param post post extension * @return post value object */ public static PostVo from(Post post) { Assert.notNull(post, "The post must not be null."); Post.PostSpec spec = post.getSpec(); Post.PostStatus postStatus = post.getStatusOrDefault(); return PostVo.builder() .metadata(post.getMetadata()) .spec(spec) .status(postStatus) .categories(List.of()) .tags(List.of()) .contributors(List.of()) .content(new ContentVo(null, null)) .build(); } /** * Convert {@link Post} to {@link PostVo}. */ public static PostVo from(ListedPostVo postVo) { return builder() .metadata(postVo.getMetadata()) .spec(postVo.getSpec()) .status(postVo.getStatus()) .categories(postVo.getCategories()) .tags(postVo.getTags()) .contributors(postVo.getContributors()) .owner(postVo.getOwner()) .stats(postVo.getStats()) .content(new ContentVo("", "")) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/ReplyVo.java ================================================ package run.halo.app.theme.finders.vo; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import run.halo.app.content.comment.OwnerInfo; import run.halo.app.core.extension.content.Reply; import run.halo.app.extension.MetadataOperator; /** * A value object for {@link Reply}. * * @author guqing * @since 2.0.0 */ @Data @Builder @ToString @EqualsAndHashCode public class ReplyVo implements ExtensionVoOperator { @Schema(requiredMode = REQUIRED) private MetadataOperator metadata; @Schema(requiredMode = REQUIRED) private Reply.ReplySpec spec; @Schema(requiredMode = REQUIRED) private OwnerInfo owner; @Schema(requiredMode = REQUIRED) private CommentStatsVo stats; /** * Convert {@link Reply} to {@link ReplyVo}. * * @param reply reply extension * @return a value object for {@link Reply} */ public static ReplyVo from(Reply reply) { Reply.ReplySpec spec = reply.getSpec(); return ReplyVo.builder() .metadata(reply.getMetadata()) .spec(spec) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/SinglePageVo.java ================================================ package run.halo.app.theme.finders.vo; import java.util.List; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import lombok.experimental.SuperBuilder; import org.springframework.util.Assert; import run.halo.app.core.extension.content.SinglePage; /** * A value object for {@link SinglePage}. * * @author guqing * @since 2.0.0 */ @Data @SuperBuilder @ToString @EqualsAndHashCode(callSuper = true) public class SinglePageVo extends ListedSinglePageVo { private ContentVo content; /** * Convert {@link SinglePage} to {@link SinglePageVo}. * * @param singlePage single page extension * @return special page value object */ public static SinglePageVo from(SinglePage singlePage) { Assert.notNull(singlePage, "The singlePage must not be null."); SinglePage.SinglePageSpec spec = singlePage.getSpec(); SinglePage.SinglePageStatus pageStatus = singlePage.getStatus(); return SinglePageVo.builder() .metadata(singlePage.getMetadata()) .spec(spec) .status(pageStatus) .contributors(List.of()) .content(new ContentVo(null, null)) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java ================================================ package run.halo.app.theme.finders.vo; import java.net.URL; import java.util.Locale; import java.util.Map; import lombok.Builder; import lombok.With; import org.apache.commons.lang3.StringUtils; import org.springframework.util.Assert; import run.halo.app.extension.ConfigMap; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.SystemSetting.ThemeRouteRules; import run.halo.app.infra.utils.JsonUtils; /** * Site setting value object for theme. * * @author guqing * @since 2.0.0 */ @Builder public record SiteSettingVo( String title, @With URL url, @With String version, String subtitle, String logo, String favicon, String language, Boolean allowRegistration, PostSetting post, SeoSetting seo, Routes routes, CommentSetting comment ) { /** * Convert to system {@link ConfigMap} to {@link SiteSettingVo}. * * @param data config map data * @return site setting value object */ public static SiteSettingVo from(Map data) { Assert.notNull(data, "Config data must not be null"); SystemSetting.Basic basicSetting = toObject(data.get(SystemSetting.Basic.GROUP), SystemSetting.Basic.class); SystemSetting.User userSetting = toObject(data.get(SystemSetting.User.GROUP), SystemSetting.User.class); SystemSetting.Post postSetting = toObject(data.get(SystemSetting.Post.GROUP), SystemSetting.Post.class); SystemSetting.Seo seoSetting = toObject(data.get(SystemSetting.Seo.GROUP), SystemSetting.Seo.class); var routeRules = toObject(data.get(ThemeRouteRules.GROUP), ThemeRouteRules.class); SystemSetting.Comment commentSetting = toObject(data.get(SystemSetting.Comment.GROUP), SystemSetting.Comment.class); return builder() .title(basicSetting.getTitle()) .subtitle(basicSetting.getSubtitle()) .logo(basicSetting.getLogo()) .favicon(basicSetting.getFavicon()) .allowRegistration(userSetting.isAllowRegistration()) .language(basicSetting.useSystemLocale().orElse(Locale.getDefault()).toLanguageTag()) .post(PostSetting.builder() .postPageSize(postSetting.getPostPageSize()) .archivePageSize(postSetting.getArchivePageSize()) .categoryPageSize(postSetting.getCategoryPageSize()) .tagPageSize(postSetting.getTagPageSize()) .authorPageSize(postSetting.getAuthorPageSize()) .build()) .seo(SeoSetting.builder() .blockSpiders(seoSetting.getBlockSpiders()) .keywords(seoSetting.getKeywords()) .description(seoSetting.getDescription()) .build()) .comment(CommentSetting.builder() .enable(commentSetting.getEnable()) .requireReviewForNew(commentSetting.getRequireReviewForNew()) .systemUserOnly(commentSetting.getSystemUserOnly()) .build()) .routes(Routes.builder() .categoriesUri(StringUtils.prependIfMissing(routeRules.getCategories(), "/")) .tagsUri(StringUtils.prependIfMissing(routeRules.getTags(), "/")) .archivesUri(StringUtils.prependIfMissing(routeRules.getArchives(), "/")) .build() ) .build(); } private static T toObject(String json, Class type) { if (json == null) { // empty object json = "{}"; } return JsonUtils.jsonToObject(json, type); } @Builder public record PostSetting( Integer postPageSize, Integer archivePageSize, Integer categoryPageSize, Integer tagPageSize, Integer authorPageSize ) { } @Builder public record SeoSetting( Boolean blockSpiders, String keywords, String description ) { } @Builder public record CommentSetting( Boolean enable, Boolean systemUserOnly, Boolean requireReviewForNew ) { } @Builder public record Routes( String categoriesUri, String tagsUri, String archivesUri ) { } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/SiteStatsVo.java ================================================ package run.halo.app.theme.finders.vo; import lombok.Builder; import lombok.Data; /** * A value object for site stats. * * @author guqing * @since 2.0.0 */ @Data @Builder public class SiteStatsVo { private Integer visit; private Integer upvote; private Integer comment; private Integer post; private Integer category; public static SiteStatsVo empty() { return SiteStatsVo.builder() .visit(0) .upvote(0) .comment(0) .post(0) .category(0) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/StatsVo.java ================================================ package run.halo.app.theme.finders.vo; import lombok.Builder; import lombok.Value; /** * Stats value object. * * @author guqing * @since 2.0.0 */ @Value @Builder public class StatsVo { Integer visit; Integer upvote; Integer comment; public static StatsVo empty() { return StatsVo.builder() .visit(0) .upvote(0) .comment(0) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/TagVo.java ================================================ package run.halo.app.theme.finders.vo; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import lombok.Builder; import lombok.Value; import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.MetadataOperator; /** * A value object for {@link Tag}. */ @Value @Builder public class TagVo implements ExtensionVoOperator { MetadataOperator metadata; Tag.TagSpec spec; Tag.TagStatus status; Integer postCount; /** * Convert {@link Tag} to {@link TagVo}. * * @param tag tag extension * @return tag value object */ public static TagVo from(Tag tag) { Tag.TagSpec spec = tag.getSpec(); Tag.TagStatus status = tag.getStatusOrDefault(); return TagVo.builder() .metadata(tag.getMetadata()) .spec(spec) .status(status) .postCount(defaultIfNull(status.getVisiblePostCount(), 0)) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/ThemeVo.java ================================================ package run.halo.app.theme.finders.vo; import com.fasterxml.jackson.databind.JsonNode; import lombok.Builder; import lombok.ToString; import lombok.Value; import lombok.With; import run.halo.app.core.extension.Theme; import run.halo.app.extension.MetadataOperator; /** * A value object for {@link Theme}. * * @author guqing * @since 2.0.0 */ @Value @Builder @ToString public class ThemeVo implements ExtensionVoOperator { MetadataOperator metadata; Theme.ThemeSpec spec; @With JsonNode config; /** * Convert {@link Theme} to {@link ThemeVo}. * * @param theme theme extension * @return theme value object */ public static ThemeVo from(Theme theme) { return ThemeVo.builder() .metadata(theme.getMetadata()) .spec(theme.getSpec()) .config(null) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/UserVo.java ================================================ package run.halo.app.theme.finders.vo; import lombok.Builder; import lombok.Value; import org.apache.commons.lang3.ObjectUtils; import run.halo.app.core.extension.User; import run.halo.app.extension.MetadataOperator; import run.halo.app.infra.utils.JsonUtils; @Value @Builder public class UserVo implements ExtensionVoOperator { MetadataOperator metadata; User.UserSpec spec; User.UserStatus status; /** * Converts to {@link UserVo} from {@link User}. * * @param user user extension * @return user value object. */ public static UserVo from(User user) { User.UserStatus statusCopy = JsonUtils.deepCopy(ObjectUtils.defaultIfNull(user.getStatus(), new User.UserStatus())); User.UserSpec userSpecCopy = JsonUtils.deepCopy(user.getSpec()); userSpecCopy.setPassword("[PROTECTED]"); return UserVo.builder() .metadata(user.getMetadata()) .spec(userSpecCopy) .status(statusCopy) .build(); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/finders/vo/VisualizableTreeNode.java ================================================ package run.halo.app.theme.finders.vo; import java.util.Iterator; import java.util.List; /** * Show Tree Hierarchy. * * @author guqing * @since 2.0.0 */ public interface VisualizableTreeNode> { /** * Visualize tree node. */ default void print(StringBuilder buffer, String prefix, String childrenPrefix) { buffer.append(prefix); buffer.append(nodeText()); buffer.append('\n'); if (getChildren() == null) { return; } for (Iterator it = getChildren().iterator(); it.hasNext(); ) { T next = it.next(); if (it.hasNext()) { next.print(buffer, childrenPrefix + "├── ", childrenPrefix + "│ "); } else { next.print(buffer, childrenPrefix + "└── ", childrenPrefix + " "); } } } String nodeText(); List getChildren(); } ================================================ FILE: application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java ================================================ package run.halo.app.theme.message; import java.io.BufferedInputStream; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; import org.springframework.lang.Nullable; import org.thymeleaf.exceptions.TemplateInputException; import org.thymeleaf.exceptions.TemplateProcessingException; import org.thymeleaf.util.StringUtils; import run.halo.app.theme.ThemeContext; /** * @author guqing * @since 2.0.0 */ public class ThemeMessageResolutionUtils { private static final Map EMPTY_MESSAGES = Collections.emptyMap(); private static final String PROPERTIES_FILE_EXTENSION = ".properties"; private static final String LOCATION = "i18n"; @Nullable private static Reader messageReader(String messageResourceName, ThemeContext theme) throws FileNotFoundException { var themePath = theme.getPath(); File messageFile = themePath.resolve(messageResourceName).toFile(); if (!messageFile.exists()) { return null; } final InputStream inputStream = new FileInputStream(messageFile); return new BufferedReader(new InputStreamReader(new BufferedInputStream(inputStream))); } public static Map resolveMessagesForTemplate(final Locale locale, ThemeContext theme) { // Compute all the resource names we should use: *_gl_ES-gheada.properties, *_gl_ES // .properties, _gl.properties... // The order here is important: as we will let values from more specific files // overwrite those in less specific, // (e.g. a value for gl_ES will have more precedence than a value for gl). So we will // iterate these resource // names from less specific to more specific. final List messageResourceNames = computeMessageResourceNamesFromBase(locale); // Build the combined messages Map combinedMessages = null; for (final String messageResourceName : messageResourceNames) { try { final Reader messageResourceReader = messageReader(messageResourceName, theme); if (messageResourceReader != null) { final Properties messageProperties = readMessagesResource(messageResourceReader); if (messageProperties != null && !messageProperties.isEmpty()) { if (combinedMessages == null) { combinedMessages = new HashMap<>(20); } for (final Map.Entry propertyEntry : messageProperties.entrySet()) { combinedMessages.put((String) propertyEntry.getKey(), (String) propertyEntry.getValue()); } } } } catch (final IOException ignored) { // File might not exist, simply try the next one } } if (combinedMessages == null) { return EMPTY_MESSAGES; } return Collections.unmodifiableMap(combinedMessages); } private static List computeMessageResourceNamesFromBase(final Locale locale) { final List resourceNames = new ArrayList<>(5); if (StringUtils.isEmptyOrWhitespace(locale.getLanguage())) { throw new TemplateProcessingException( "Locale \"" + locale + "\" " + "cannot be used as it does not specify a language."); } resourceNames.add(getResourceName("default")); resourceNames.add(getResourceName(locale.getLanguage())); if (!StringUtils.isEmptyOrWhitespace(locale.getCountry())) { resourceNames.add( getResourceName(locale.getLanguage() + "_" + locale.getCountry())); } if (!StringUtils.isEmptyOrWhitespace(locale.getVariant())) { resourceNames.add(getResourceName( locale.getLanguage() + "_" + locale.getCountry() + "-" + locale.getVariant())); } return resourceNames; } private static String getResourceName(String name) { return LOCATION + "/" + name + PROPERTIES_FILE_EXTENSION; } private static Properties readMessagesResource(final Reader propertiesReader) { if (propertiesReader == null) { return null; } final Properties properties = new Properties(); try (propertiesReader) { // Note Properties#load(Reader) this is JavaSE 6 specific, but Thymeleaf 3.0 does // not support Java 5 anymore... properties.load(propertiesReader); } catch (final Exception e) { throw new TemplateInputException("Exception loading messages file", e); } // ignore errors closing return properties; } } ================================================ FILE: application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java ================================================ package run.halo.app.theme.message; import java.util.Collections; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Optional; import org.thymeleaf.messageresolver.StandardMessageResolver; import org.thymeleaf.templateresource.ITemplateResource; import run.halo.app.theme.ThemeContext; /** * @author guqing * @since 2.0.0 */ public class ThemeMessageResolver extends StandardMessageResolver { private final ThemeContext theme; public ThemeMessageResolver(ThemeContext theme) { this.theme = theme; } @Override protected Map resolveMessagesForTemplate(String template, ITemplateResource templateResource, Locale locale) { var properties = new HashMap(); Optional.ofNullable(ThemeMessageResolutionUtils.resolveMessagesForTemplate(locale, theme)) .ifPresent(properties::putAll); Optional.ofNullable(super.resolveMessagesForTemplate(template, templateResource, locale)) .ifPresent(properties::putAll); return Collections.unmodifiableMap(properties); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/DefaultQueryPostPredicateResolver.java ================================================ package run.halo.app.theme.router; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.isNull; import static run.halo.app.extension.index.query.Queries.or; import java.security.Principal; import java.util.Objects; import java.util.function.Predicate; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListOptions; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.extension.router.selector.LabelSelector; import run.halo.app.infra.AnonymousUserConst; /** * The default implementation of {@link ReactiveQueryPostPredicateResolver}. * * @author guqing * @since 2.9.0 */ @Component public class DefaultQueryPostPredicateResolver implements ReactiveQueryPostPredicateResolver { @Override public Mono> getPredicate() { Predicate predicate = post -> post.isPublished() && !ExtensionUtil.isDeleted(post) && Objects.equals(false, post.getSpec().getDeleted()); Predicate visiblePredicate = post -> Post.VisibleEnum.PUBLIC.equals(post.getSpec().getVisible()); return currentUserName() .map(username -> predicate.and( visiblePredicate.or(post -> username.equals(post.getSpec().getOwner()))) ) .defaultIfEmpty(predicate.and(visiblePredicate)); } @Override public Mono getListOptions() { var listOptions = new ListOptions(); listOptions.setLabelSelector(LabelSelector.builder() .eq(Post.PUBLISHED_LABEL, "true").build()); var fieldQuery = and( isNull("metadata.deletionTimestamp"), equal("spec.deleted", "false") ); var visibleQuery = equal("spec.visible", Post.VisibleEnum.PUBLIC.name()); return currentUserName() .map(username -> and(fieldQuery, or(visibleQuery, equal("spec.owner", username))) ) .defaultIfEmpty(and(fieldQuery, visibleQuery)) .map(query -> { listOptions.setFieldSelector(FieldSelector.of(query)); return listOptions; }); } Mono currentUserName() { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(Principal::getName) .filter(name -> !AnonymousUserConst.isAnonymousUser(name)); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/ExtensionPermalinkPatternUpdater.java ================================================ package run.halo.app.theme.router; import static run.halo.app.theme.utils.PatternUtils.normalizePattern; import static run.halo.app.theme.utils.PatternUtils.normalizePostPattern; import java.util.Map; import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.context.ApplicationListener; import org.springframework.data.domain.Sort; import org.springframework.lang.NonNull; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Constant; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.Extension; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataOperator; import run.halo.app.extension.MetadataUtil; import run.halo.app.infra.SystemConfigChangedEvent; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.SystemSetting.ThemeRouteRules; /** * {@link ExtensionPermalinkPatternUpdater} to update the value of key * {@link Constant#PERMALINK_PATTERN_ANNO} in {@link MetadataOperator#getAnnotations()} * of {@link Extension} when the pattern changed. * * @author guqing * @see Post * @see Category * @see Tag * @since 2.0.0 */ @Slf4j @Component @RequiredArgsConstructor class ExtensionPermalinkPatternUpdater implements ApplicationListener { private final ExtensionClient client; @Override @Async public void onApplicationEvent(@NonNull SystemConfigChangedEvent event) { var oldData = event.getOldData(); var newData = event.getNewData(); var oldRules = SystemSetting.get(oldData, ThemeRouteRules.GROUP, ThemeRouteRules.class); if (oldRules == null) { oldRules = ThemeRouteRules.empty(); } var newRules = SystemSetting.get(newData, ThemeRouteRules.GROUP, ThemeRouteRules.class); if (newRules == null) { newRules = ThemeRouteRules.empty(); } var archivesRuleChanged = !Objects.equals(oldRules.getArchives(), newRules.getArchives()); var postRuleChanged = !Objects.equals(oldRules.getPost(), newRules.getPost()); var categoriesRuleChanged = !Objects.equals(oldRules.getCategories(), newRules.getCategories()); if (categoriesRuleChanged) { var categoriesPattern = normalizePattern(newRules.getCategories()); updateCategoryPermalink(categoriesPattern); } var tagsRuleChanged = !Objects.equals(oldRules.getTags(), newRules.getTags()); if (tagsRuleChanged) { var tagsPattern = normalizePattern(newRules.getTags()); log.info("Update tag permalink pattern for tags change: {}", tagsPattern); updateTagPermalink(tagsPattern); } if (archivesRuleChanged || categoriesRuleChanged || postRuleChanged) { var postPattern = normalizePostPattern(newRules); updatePostPermalink(postPattern); } } private void updatePostPermalink(String pattern) { log.debug("Update post permalink by new policy [{}]", pattern); // TODO Optimize by batch update client.listAll(Post.class, new ListOptions(), Sort.unsorted()) .forEach(post -> updateIfPermalinkPatternChanged(post, pattern)); } private void updateIfPermalinkPatternChanged(AbstractExtension extension, String pattern) { Map annotations = MetadataUtil.nullSafeAnnotations(extension); String oldPattern = annotations.get(Constant.PERMALINK_PATTERN_ANNO); annotations.put(Constant.PERMALINK_PATTERN_ANNO, pattern); if (StringUtils.equals(oldPattern, pattern) && StringUtils.isNotBlank(oldPattern)) { return; } // update permalink pattern annotation client.update(extension); } private void updateCategoryPermalink(String pattern) { log.debug("Update category and categories permalink by new policy [{}]", pattern); client.listAll(Category.class, new ListOptions(), Sort.unsorted()) .forEach(category -> updateIfPermalinkPatternChanged(category, pattern)); } private void updateTagPermalink(String pattern) { log.debug("Update tag and tags permalink by new policy [{}]", pattern); client.listAll(Tag.class, new ListOptions(), Sort.unsorted()) .forEach(tag -> updateIfPermalinkPatternChanged(tag, pattern)); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/ModelMapUtils.java ================================================ package run.halo.app.theme.router; import java.util.HashMap; import java.util.Map; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.Scheme; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.dialect.CommentWidget; import run.halo.app.theme.finders.vo.PostVo; import run.halo.app.theme.finders.vo.SinglePageVo; /** * A util class for building model map. * * @author guqing * @since 2.6.0 */ public abstract class ModelMapUtils { private static final Scheme POST_SCHEME = Scheme.buildFromType(Post.class); private static final Scheme SINGLE_PAGE_SCHEME = Scheme.buildFromType(SinglePage.class); /** * Build post view model. * * @param postVo post vo * @return model map */ public static Map postModel(PostVo postVo) { Map model = new HashMap<>(); model.put("name", postVo.getMetadata().getName()); model.put(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.POST.getValue()); model.put("groupVersionKind", POST_SCHEME.groupVersionKind()); model.put("plural", POST_SCHEME.plural()); model.put("post", postVo); model.put(CommentWidget.ENABLE_COMMENT_ATTRIBUTE, postVo.getSpec().getAllowComment()); return model; } /** * Build single page view model. * * @param pageVo page vo * @return model map */ public static Map singlePageModel(SinglePageVo pageVo) { Map model = new HashMap<>(); model.put("name", pageVo.getMetadata().getName()); model.put("groupVersionKind", SINGLE_PAGE_SCHEME.groupVersionKind()); model.put("plural", SINGLE_PAGE_SCHEME.plural()); model.put(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.SINGLE_PAGE.getValue()); model.put("singlePage", pageVo); model.put(CommentWidget.ENABLE_COMMENT_ATTRIBUTE, pageVo.getSpec().getAllowComment()); return model; } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/PermalinkRuleChangedEvent.java ================================================ package run.halo.app.theme.router; import org.springframework.context.ApplicationEvent; import run.halo.app.theme.DefaultTemplateEnum; public class PermalinkRuleChangedEvent extends ApplicationEvent { private final DefaultTemplateEnum template; private final String oldRule; private final String rule; public PermalinkRuleChangedEvent(Object source, DefaultTemplateEnum template, String oldRule, String rule) { super(source); this.template = template; this.oldRule = oldRule; this.rule = rule; } public DefaultTemplateEnum getTemplate() { return template; } public String getOldRule() { return oldRule; } public String getRule() { return rule; } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/PreviewRouterFunction.java ================================================ package run.halo.app.theme.router; import java.security.Principal; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.content.PostService; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.ViewNameResolver; import run.halo.app.theme.dialect.HaloTrackerProcessor; import run.halo.app.theme.finders.PostPublicQueryService; import run.halo.app.theme.finders.SinglePageConversionService; import run.halo.app.theme.finders.vo.ContributorVo; import run.halo.app.theme.finders.vo.PostVo; /** *

Preview router for previewing posts and single pages.

* * @author guqing * @since 2.6.0 */ @Component @RequiredArgsConstructor public class PreviewRouterFunction { static final String SNAPSHOT_NAME_PARAM = "snapshotName"; private final ReactiveExtensionClient client; private final PostPublicQueryService postPublicQueryService; private final ViewNameResolver viewNameResolver; private final PostService postService; private final SinglePageConversionService singlePageConversionService; @Bean RouterFunction previewRouter() { return RouterFunctions.route() .GET("/preview/posts/{name}", this::previewPost) .GET("/preview/singlepages/{name}", this::previewSinglePage) .build(); } private Mono previewPost(ServerRequest request) { final var name = request.pathVariable("name"); return currentAuthenticatedUserName() .flatMap(principal -> client.fetch(Post.class, name)) .flatMap(post -> { String snapshotName = request.queryParam(SNAPSHOT_NAME_PARAM) .orElse(post.getSpec().getHeadSnapshot()); return convertToPostVo(post, snapshotName); }) .flatMap(post -> canPreview(post.getContributors()) .doOnNext(canPreview -> { if (!canPreview) { throw new NotFoundException("Post not found."); } }) .thenReturn(post) ) // Check permissions before throwing this exception .switchIfEmpty(Mono.error(() -> new NotFoundException("Post not found."))) .flatMap(postVo -> { String template = postVo.getSpec().getTemplate(); Map model = ModelMapUtils.postModel(postVo); // Mark as preview mode for downstream view processing request.exchange().getAttributes() .put(HaloTrackerProcessor.SKIP_TRACKER, Boolean.TRUE); return viewNameResolver.resolveViewNameOrDefault(request, template, DefaultTemplateEnum.POST.getValue()) .flatMap(templateName -> ServerResponse.ok().render(templateName, model)); }); } private Mono convertToPostVo(Post post, String snapshotName) { return postPublicQueryService.convertToVo(post, snapshotName) .doOnNext(postVo -> { // fake some attributes only for preview when they are not published Post.PostSpec spec = postVo.getSpec(); if (spec.getPublishTime() == null) { spec.setPublishTime(Instant.now()); } if (spec.getPublish() == null) { spec.setPublish(false); } Post.PostStatus status = postVo.getStatus(); if (status == null) { status = new Post.PostStatus(); postVo.setStatus(status); } if (status.getLastModifyTime() == null) { status.setLastModifyTime(Instant.now()); } }); } private Mono previewSinglePage(ServerRequest request) { final var name = request.pathVariable("name"); return currentAuthenticatedUserName() .flatMap(principal -> client.fetch(SinglePage.class, name)) .flatMap(singlePage -> { String snapshotName = request.queryParam(SNAPSHOT_NAME_PARAM) .orElse(singlePage.getSpec().getHeadSnapshot()); return singlePageConversionService.convertToVo(singlePage, snapshotName); }) .doOnNext(pageVo -> { // fake some attributes only for preview when they are not published SinglePage.SinglePageSpec spec = pageVo.getSpec(); if (spec.getPublishTime() == null) { spec.setPublishTime(Instant.now()); } if (spec.getPublish() == null) { spec.setPublish(false); } SinglePage.SinglePageStatus status = pageVo.getStatus(); if (status == null) { status = new SinglePage.SinglePageStatus(); pageVo.setStatus(status); } if (status.getLastModifyTime() == null) { status.setLastModifyTime(Instant.now()); } }) .flatMap(singlePageVo -> canPreview(singlePageVo.getContributors()) .doOnNext(canPreview -> { if (!canPreview) { throw new NotFoundException("Single page not found."); } }) .thenReturn(singlePageVo) ) // Check permissions before throwing this exception .switchIfEmpty(Mono.error(() -> new NotFoundException("Single page not found."))) .flatMap(singlePageVo -> { Map model = ModelMapUtils.singlePageModel(singlePageVo); // Mark as preview mode for downstream view processing request.exchange().getAttributes() .put(HaloTrackerProcessor.SKIP_TRACKER, Boolean.TRUE); String template = singlePageVo.getSpec().getTemplate(); return viewNameResolver.resolveViewNameOrDefault(request, template, DefaultTemplateEnum.SINGLE_PAGE.getValue()) .flatMap(viewName -> ServerResponse.ok().render(viewName, model)); }); } private Mono canPreview(List contributors) { Assert.notNull(contributors, "The contributors must not be null"); Set contributorNames = contributors.stream() .map(ContributorVo::getName) .collect(Collectors.toSet()); return currentAuthenticatedUserName() .map(contributorNames::contains) .defaultIfEmpty(false); } Mono currentAuthenticatedUserName() { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(Principal::getName) .filter(name -> !AnonymousUserConst.isAnonymousUser(name)); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/ReactiveQueryPostPredicateResolver.java ================================================ package run.halo.app.theme.router; import java.util.function.Predicate; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListOptions; /** * The reactive query post predicate resolver. * * @author guqing * @since 2.9.0 */ public interface ReactiveQueryPostPredicateResolver { Mono> getPredicate(); Mono getListOptions(); } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/SinglePageRoute.java ================================================ package run.halo.app.theme.router; import static org.springframework.web.reactive.function.server.RequestPredicates.methods; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.DisposableBean; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RequestPredicate; import org.springframework.web.reactive.function.server.RequestPredicates; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.i18n.LocaleContextResolver; import org.springframework.web.util.UriUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionOperator; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.ViewNameResolver; import run.halo.app.theme.finders.SinglePageFinder; /** * The {@link SinglePageRoute} for route request to specific template page.html. * * @author guqing * @since 2.0.0 */ @Component @RequiredArgsConstructor public class SinglePageRoute implements RouterFunction, Reconciler, DisposableBean { private Map> quickRouteMap = new ConcurrentHashMap<>(); private final ExtensionClient client; private final SinglePageFinder singlePageFinder; private final ViewNameResolver viewNameResolver; private final TitleVisibilityIdentifyCalculator titleVisibilityIdentifyCalculator; private final LocaleContextResolver localeContextResolver; @Override @NonNull public Mono> route(@NonNull ServerRequest request) { return Flux.fromIterable(routerFunctions()) .concatMap(routerFunction -> routerFunction.route(request)) .next(); } /** * Set quickRouteMap. This method is only for testing. * * @param quickRouteMap fresh quickRouteMap. */ void setQuickRouteMap(Map> quickRouteMap) { this.quickRouteMap = quickRouteMap; } @Override public void accept(@NonNull RouterFunctions.Visitor visitor) { routerFunctions().forEach(routerFunction -> routerFunction.accept(visitor)); } private List> routerFunctions() { return quickRouteMap.keySet().stream() .map(nameSlugPair -> { var routePath = singlePageRoute(nameSlugPair.slug()); return RouterFunctions.route(methods(HttpMethod.GET) .and(exactPath(routePath)) .and(RequestPredicates.accept(MediaType.TEXT_HTML)), handlerFunction(nameSlugPair.name())); }) .collect(Collectors.toList()); } private RequestPredicate exactPath(String path) { return request -> { var encodedRoutePath = UriUtils.encodePath(path, StandardCharsets.UTF_8); var requestPath = request.requestPath().pathWithinApplication().value(); return Objects.equals(requestPath, encodedRoutePath); }; } @Override public Result reconcile(Request request) { client.fetch(SinglePage.class, request.name()) .ifPresent(page -> { var nameSlugPair = NameSlugPair.from(page); if (ExtensionOperator.isDeleted(page)) { quickRouteMap.remove(nameSlugPair); return; } if (BooleanUtils.isTrue(page.getSpec().getDeleted())) { quickRouteMap.remove(nameSlugPair); } else { // put new one if (page.isPublished()) { quickRouteMap.put(nameSlugPair, handlerFunction(request.name())); } else { quickRouteMap.remove(nameSlugPair); } } }); return new Result(false, null); } @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new SinglePage()) .build(); } @Override public void destroy() throws Exception { quickRouteMap.clear(); } record NameSlugPair(String name, String slug) { public static NameSlugPair from(SinglePage page) { return new NameSlugPair(page.getMetadata().getName(), page.getSpec().getSlug()); } } String singlePageRoute(String slug) { return StringUtils.prependIfMissing(slug, "/"); } HandlerFunction handlerFunction(String name) { return request -> singlePageFinder.getByName(name) .doOnNext(singlePageVo -> { titleVisibilityIdentifyCalculator.calculateTitle( singlePageVo.getSpec().getTitle(), singlePageVo.getSpec().getVisible(), localeContextResolver.resolveLocaleContext(request.exchange()) .getLocale() ); }) .flatMap(singlePageVo -> { Map model = ModelMapUtils.singlePageModel(singlePageVo); String template = singlePageVo.getSpec().getTemplate(); return viewNameResolver.resolveViewNameOrDefault(request, template, DefaultTemplateEnum.SINGLE_PAGE.getValue()) .flatMap(viewName -> ServerResponse.ok().render(viewName, model)); }) .switchIfEmpty( Mono.error(new NotFoundException("Single page not found")) ); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/ThemeCompositeRouterFunction.java ================================================ package run.halo.app.theme.router; import static run.halo.app.theme.utils.PatternUtils.normalizePattern; import static run.halo.app.theme.utils.PatternUtils.normalizePostPattern; import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationListener; import org.springframework.context.SmartLifecycle; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigChangedEvent; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.SystemSetting.ThemeRouteRules; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.router.factories.ArchiveRouteFactory; import run.halo.app.theme.router.factories.AuthorPostsRouteFactory; import run.halo.app.theme.router.factories.CategoriesRouteFactory; import run.halo.app.theme.router.factories.CategoryPostRouteFactory; import run.halo.app.theme.router.factories.IndexRouteFactory; import run.halo.app.theme.router.factories.PostRouteFactory; import run.halo.app.theme.router.factories.TagPostRouteFactory; import run.halo.app.theme.router.factories.TagsRouteFactory; /** *

The combination router of theme templates is used to render theme templates, but does not * include page.html templates which is processed separately.

* * @author guqing * @see SinglePageRoute * @since 2.0.0 */ @Slf4j @Component @RequiredArgsConstructor public class ThemeCompositeRouterFunction implements RouterFunction, SmartLifecycle, ApplicationListener { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private final SystemConfigFetcher environmentFetcher; private final ArchiveRouteFactory archiveRouteFactory; private final PostRouteFactory postRouteFactory; private final CategoriesRouteFactory categoriesRouteFactory; private final CategoryPostRouteFactory categoryPostRouteFactory; private final TagPostRouteFactory tagPostRouteFactory; private final TagsRouteFactory tagsRouteFactory; private final AuthorPostsRouteFactory authorPostsRouteFactory; private final IndexRouteFactory indexRouteFactory; private List> cachedRouters = List.of(); private volatile boolean running; @Override public void onApplicationEvent(SystemConfigChangedEvent event) { var oldData = event.getOldData(); var newData = event.getNewData(); var oldRules = SystemSetting.get(oldData, ThemeRouteRules.GROUP, ThemeRouteRules.class); if (oldRules == null) { oldRules = ThemeRouteRules.empty(); } var newRules = SystemSetting.get(newData, ThemeRouteRules.GROUP, ThemeRouteRules.class); if (newRules == null) { newRules = ThemeRouteRules.empty(); } boolean rulesChanged = !Objects.equals(oldRules, newRules); if (rulesChanged) { log.info("Theme route rules changed, updating router functions..."); if (log.isDebugEnabled()) { log.debug("Old theme route rules: {}", oldRules); log.debug("New theme route rules: {}", newRules); } this.cachedRouters = routerFunctions(newRules); log.info("Theme route rules updated."); } } @Override @NonNull public Mono> route(@NonNull ServerRequest request) { return Flux.fromIterable(cachedRouters) .concatMap(routerFunction -> routerFunction.route(request)) .next(); } @Override public void accept(@NonNull RouterFunctions.Visitor visitor) { cachedRouters.forEach(routerFunction -> routerFunction.accept(visitor)); } private List> routerFunctions(ThemeRouteRules rules) { return transformedPatterns(rules).stream() .map(this::createRouterFunction) .toList(); } private List> routerFunctions() { return transformedPatterns().stream() .map(this::createRouterFunction) .toList(); } private RouterFunction createRouterFunction(RoutePattern routePattern) { return switch (routePattern.identifier()) { case POST -> postRouteFactory.create(routePattern.pattern()); case ARCHIVES -> archiveRouteFactory.create(routePattern.pattern()); case CATEGORIES -> categoriesRouteFactory.create(routePattern.pattern()); case CATEGORY -> categoryPostRouteFactory.create(routePattern.pattern()); case TAGS -> tagsRouteFactory.create(routePattern.pattern()); case TAG -> tagPostRouteFactory.create(routePattern.pattern()); case AUTHOR -> authorPostsRouteFactory.create(routePattern.pattern()); case INDEX -> indexRouteFactory.create(routePattern.pattern()); default -> throw new IllegalStateException("Unexpected value: " + routePattern.identifier()); }; } @Override public void start() { if (running) { return; } running = true; this.cachedRouters = routerFunctions(); } @Override public void stop() { if (!running) { return; } running = false; this.cachedRouters = List.of(); } @Override public boolean isRunning() { return running; } record RoutePattern(DefaultTemplateEnum identifier, String pattern) { } private List transformedPatterns(ThemeRouteRules rules) { List routePatterns = new ArrayList<>(); var archives = normalizePattern(rules.getArchives()); routePatterns.add(new RoutePattern(DefaultTemplateEnum.ARCHIVES, archives)); var categories = normalizePattern(rules.getCategories()); routePatterns.add(new RoutePattern(DefaultTemplateEnum.CATEGORIES, categories)); routePatterns.add(new RoutePattern(DefaultTemplateEnum.CATEGORY, categories)); var tags = normalizePattern(rules.getTags()); routePatterns.add(new RoutePattern(DefaultTemplateEnum.TAGS, tags)); routePatterns.add(new RoutePattern(DefaultTemplateEnum.TAG, tags)); var post = normalizePostPattern(rules); routePatterns.add(new RoutePattern(DefaultTemplateEnum.POST, post)); // Add the index route to the end to prevent conflict with the queryParam rule of the post routePatterns.add(new RoutePattern(DefaultTemplateEnum.AUTHOR, "")); routePatterns.add(new RoutePattern(DefaultTemplateEnum.INDEX, "/")); return routePatterns; } private List transformedPatterns() { var rules = environmentFetcher.fetch(ThemeRouteRules.GROUP, ThemeRouteRules.class) .blockOptional(BLOCKING_TIMEOUT) .orElseGet(ThemeRouteRules::empty); return transformedPatterns(rules); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/TitleVisibilityIdentifyCalculator.java ================================================ package run.halo.app.theme.router; import java.util.Locale; import lombok.AllArgsConstructor; import org.springframework.context.MessageSource; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import run.halo.app.core.extension.content.Post; @Component @AllArgsConstructor public class TitleVisibilityIdentifyCalculator { private final MessageSource messageSource; /** * Calculate title with visibility identification. * * @param title title must not be null * @param visibleEnum visibility enum */ public String calculateTitle(String title, Post.VisibleEnum visibleEnum, Locale locale) { Assert.notNull(title, "Title must not be null"); if (Post.VisibleEnum.PRIVATE.equals(visibleEnum)) { String identify = messageSource.getMessage( "title.visibility.identification.private", null, "", locale); return title + identify; } return title; } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/factories/ArchiveRouteFactory.java ================================================ package run.halo.app.theme.router.factories; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; import static run.halo.app.theme.router.PageUrlUtils.totalPage; import java.util.List; import java.util.Map; import lombok.AllArgsConstructor; import lombok.Data; import org.apache.commons.lang3.StringUtils; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RequestPredicate; import org.springframework.web.reactive.function.server.RequestPredicates; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.i18n.LocaleContextResolver; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.PathUtils; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.vo.PostArchiveVo; import run.halo.app.theme.router.ModelConst; import run.halo.app.theme.router.PageUrlUtils; import run.halo.app.theme.router.TitleVisibilityIdentifyCalculator; import run.halo.app.theme.router.UrlContextListResult; /** * The {@link ArchiveRouteFactory} for generate {@link RouterFunction} specific to the template * posts.html. * * @author guqing * @since 2.0.0 */ @Component @AllArgsConstructor public class ArchiveRouteFactory implements RouteFactory { private final PostFinder postFinder; private final SystemConfigFetcher environmentFetcher; private final TitleVisibilityIdentifyCalculator titleVisibilityIdentifyCalculator; private final LocaleContextResolver localeContextResolver; @Override public RouterFunction create(String prefix) { RequestPredicate requestPredicate = patterns(prefix).stream() .map(RequestPredicates::GET) .reduce(req -> false, RequestPredicate::or) .and(accept(MediaType.TEXT_HTML)); return RouterFunctions.route(requestPredicate, handlerFunction()); } HandlerFunction handlerFunction() { return request -> { String templateName = DefaultTemplateEnum.ARCHIVES.getValue(); return ServerResponse.ok() .render(templateName, Map.of("archives", archivePosts(request), ModelConst.TEMPLATE_ID, templateName) ); }; } private List patterns(String prefix) { return List.of( StringUtils.prependIfMissing(prefix, "/"), PathUtils.combinePath(prefix, "/page/{page:\\d+}"), PathUtils.combinePath(prefix, "/{year:\\d{4}}"), PathUtils.combinePath(prefix, "/{year:\\d{4}}/page/{page:\\d+}"), PathUtils.combinePath(prefix, "/{year:\\d{4}}/{month:\\d{2}}"), PathUtils.combinePath(prefix, "/{year:\\d{4}}/{month:\\d{2}}/page/{page:\\d+}") ); } private Mono> archivePosts(ServerRequest request) { ArchivePathVariables variables = ArchivePathVariables.from(request); int pageNum = pageNumInPathVariable(request); String requestPath = request.path(); return configuredPageSize(environmentFetcher, SystemSetting.Post::getArchivePageSize) .flatMap(pageSize -> postFinder.archives(pageNum, pageSize, variables.getYear(), variables.getMonth())) .doOnNext(list -> list.get() .map(PostArchiveVo::getMonths) .flatMap(List::stream) .flatMap(month -> month.getPosts().stream()) .forEach(postVo -> postVo.getSpec() .setTitle(titleVisibilityIdentifyCalculator.calculateTitle( postVo.getSpec().getTitle(), postVo.getSpec().getVisible(), localeContextResolver.resolveLocaleContext(request.exchange()) .getLocale()) ) ) ) .map(list -> new UrlContextListResult.Builder() .listResult(list) .nextUrl(PageUrlUtils.nextPageUrl(requestPath, totalPage(list))) .prevUrl(PageUrlUtils.prevPageUrl(requestPath)) .build()); } @Data static class ArchivePathVariables { String year; String month; String page; static ArchivePathVariables from(ServerRequest request) { Map variables = request.pathVariables(); return JsonUtils.mapToObject(variables, ArchivePathVariables.class); } } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/factories/AuthorPostsRouteFactory.java ================================================ package run.halo.app.theme.router.factories; import static org.springframework.web.reactive.function.server.RequestPredicates.GET; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; import static run.halo.app.theme.router.PageUrlUtils.totalPage; import java.util.Map; import java.util.Set; import lombok.AllArgsConstructor; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.i18n.LocaleContextResolver; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.security.authorization.AuthorityUtils; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.finders.vo.UserVo; import run.halo.app.theme.router.ModelConst; import run.halo.app.theme.router.PageUrlUtils; import run.halo.app.theme.router.TitleVisibilityIdentifyCalculator; import run.halo.app.theme.router.UrlContextListResult; /** * The {@link AuthorPostsRouteFactory} for generate {@link RouterFunction} specific to the template * index.html. * * @author guqing * @since 2.0.0 */ @Component @AllArgsConstructor public class AuthorPostsRouteFactory implements RouteFactory { private final PostFinder postFinder; private final ReactiveExtensionClient client; private final RoleService roleService; private SystemConfigFetcher environmentFetcher; private final TitleVisibilityIdentifyCalculator titleVisibilityIdentifyCalculator; private final LocaleContextResolver localeContextResolver; @Override public RouterFunction create(String pattern) { return RouterFunctions .route(GET("/authors/{name}").or(GET("/authors/{name}/page/{page:\\d+}")) .and(accept(MediaType.TEXT_HTML)), handlerFunction()); } HandlerFunction handlerFunction() { return request -> { String name = request.pathVariable("name"); return hasPostManageRole(name) .flatMap(hasPostManageRole -> { if (hasPostManageRole) { return ServerResponse.ok() .render(DefaultTemplateEnum.AUTHOR.getValue(), Map.of("author", getByName(name), "posts", postList(request, name), ModelConst.TEMPLATE_ID, DefaultTemplateEnum.AUTHOR.getValue() ) ); } return Mono.error(new NotFoundException("Author page not found.")); }); }; } protected Mono hasPostManageRole(String username) { return roleService.getRolesByUsername(username) .collectList() .flatMap(roles -> roleService.contains(roles, Set.of(AuthorityUtils.POST_CONTRIBUTOR_ROLE_NAME)) ) .defaultIfEmpty(false); } private Mono> postList(ServerRequest request, String name) { String path = request.path(); int pageNum = pageNumInPathVariable(request); return configuredPageSize(environmentFetcher, SystemSetting.Post::getAuthorPageSize) .flatMap(pageSize -> postFinder.listByOwner(pageNum, pageSize, name)) .doOnNext(list -> { list.getItems().forEach(listedPostVo -> { listedPostVo.getSpec().setTitle( titleVisibilityIdentifyCalculator.calculateTitle( listedPostVo.getSpec().getTitle(), listedPostVo.getSpec().getVisible(), localeContextResolver.resolveLocaleContext(request.exchange()) .getLocale()) ); }); }) .map(list -> new UrlContextListResult.Builder() .listResult(list) .nextUrl(PageUrlUtils.nextPageUrl(path, totalPage(list))) .prevUrl(PageUrlUtils.prevPageUrl(path)) .build()); } private Mono getByName(String name) { return client.fetch(User.class, name) .switchIfEmpty(Mono.error(() -> new NotFoundException("Author page not found."))) .map(UserVo::from); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/factories/CategoriesRouteFactory.java ================================================ package run.halo.app.theme.router.factories; import static org.springframework.web.reactive.function.server.RequestPredicates.GET; import java.util.Map; import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.finders.CategoryFinder; import run.halo.app.theme.router.ModelConst; /** * The {@link CategoriesRouteFactory} for generate {@link RouterFunction} specific to the * template * categories.html. * * @author guqing * @since 2.0.0 */ @Component @AllArgsConstructor public class CategoriesRouteFactory implements RouteFactory { private final CategoryFinder categoryFinder; @Override public RouterFunction create(String prefix) { return RouterFunctions.route(GET(StringUtils.prependIfMissing(prefix, "/")), handlerFunction()); } HandlerFunction handlerFunction() { return request -> ServerResponse.ok() .render(DefaultTemplateEnum.CATEGORIES.getValue(), Map.of("categories", categoryFinder.listAsTree(), ModelConst.TEMPLATE_ID, DefaultTemplateEnum.CATEGORIES.getValue())); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/factories/CategoryPostRouteFactory.java ================================================ package run.halo.app.theme.router.factories; import static org.springframework.web.reactive.function.server.RequestPredicates.GET; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.isNull; import static run.halo.app.theme.router.PageUrlUtils.totalPage; import java.util.HashMap; import java.util.Map; import lombok.AllArgsConstructor; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.i18n.LocaleContextResolver; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Category; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.infra.utils.PathUtils; import run.halo.app.theme.Constant; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.ViewNameResolver; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.vo.CategoryVo; import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.router.ModelConst; import run.halo.app.theme.router.PageUrlUtils; import run.halo.app.theme.router.TitleVisibilityIdentifyCalculator; import run.halo.app.theme.router.UrlContextListResult; /** * The {@link CategoryPostRouteFactory} for generate {@link RouterFunction} specific to the template * category.html. * * @author guqing * @since 2.0.0 */ @Component @AllArgsConstructor public class CategoryPostRouteFactory implements RouteFactory { private final PostFinder postFinder; private final SystemConfigFetcher environmentFetcher; private final ReactiveExtensionClient client; private final ViewNameResolver viewNameResolver; private final TitleVisibilityIdentifyCalculator titleVisibilityIdentifyCalculator; private final LocaleContextResolver localeContextResolver; @Override public RouterFunction create(String prefix) { return RouterFunctions.route(GET(PathUtils.combinePath(prefix, "/{slug}")) .or(GET(PathUtils.combinePath(prefix, "/{slug}/page/{page:\\d+}"))) .and(accept(MediaType.TEXT_HTML)), handlerFunction()); } HandlerFunction handlerFunction() { return request -> { String slug = request.pathVariable("slug"); return fetchBySlug(slug) .flatMap(categoryVo -> { Map model = new HashMap<>(); model.put(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.CATEGORY.getValue()); model.put("posts", postListByCategoryName(categoryVo.getMetadata().getName(), request)); model.put("category", categoryVo); model.put( Constant.META_DESCRIPTION_VARIABLE_NAME, categoryVo.getSpec().getDescription() ); String template = categoryVo.getSpec().getTemplate(); return viewNameResolver.resolveViewNameOrDefault(request, template, DefaultTemplateEnum.CATEGORY.getValue()) .flatMap(viewName -> ServerResponse.ok().render(viewName, model)); }) .switchIfEmpty( Mono.error(new NotFoundException("Category not found with slug: " + slug))); }; } Mono fetchBySlug(String slug) { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of( and( equal("spec.slug", slug), isNull("metadata.deletionTimestamp") ) )); return client.listBy(Category.class, listOptions, PageRequestImpl.ofSize(1)) .mapNotNull(result -> ListResult.first(result) .map(CategoryVo::from) .orElse(null) ); } private Mono> postListByCategoryName(String name, ServerRequest request) { String path = request.path(); int pageNum = pageNumInPathVariable(request); return configuredPageSize(environmentFetcher, SystemSetting.Post::getCategoryPageSize) .flatMap(pageSize -> postFinder.listByCategory(pageNum, pageSize, name)) .doOnNext(list -> list.forEach(postVo -> postVo.getSpec().setTitle( titleVisibilityIdentifyCalculator.calculateTitle( postVo.getSpec().getTitle(), postVo.getSpec().getVisible(), localeContextResolver.resolveLocaleContext(request.exchange()) .getLocale() ) ) )) .map(list -> new UrlContextListResult.Builder() .listResult(list) .nextUrl(PageUrlUtils.nextPageUrl(path, totalPage(list))) .prevUrl(PageUrlUtils.prevPageUrl(path)) .build() ); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/factories/IndexRouteFactory.java ================================================ package run.halo.app.theme.router.factories; import static org.springframework.web.reactive.function.server.RequestPredicates.GET; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; import static run.halo.app.theme.router.PageUrlUtils.totalPage; import java.time.Duration; import java.util.Map; import lombok.AllArgsConstructor; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.i18n.LocaleContextResolver; import org.thymeleaf.context.LazyContextVariable; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.ReactiveUtils; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.router.ModelConst; import run.halo.app.theme.router.PageUrlUtils; import run.halo.app.theme.router.TitleVisibilityIdentifyCalculator; import run.halo.app.theme.router.UrlContextListResult; /** * The {@link IndexRouteFactory} for generate {@link RouterFunction} specific to the template * index.html. * * @author guqing * @since 2.0.0 */ @Component @AllArgsConstructor public class IndexRouteFactory implements RouteFactory { private static final Duration BLOCKING_TIMEOUT = ReactiveUtils.DEFAULT_TIMEOUT; private final PostFinder postFinder; private final SystemConfigFetcher environmentFetcher; private final TitleVisibilityIdentifyCalculator titleVisibilityIdentifyCalculator; private final LocaleContextResolver localeContextResolver; @Override public RouterFunction create(String pattern) { return RouterFunctions .route(GET("/").or(GET("/page/{page:\\d+}") .or(GET("/index")).or(GET("/index/page/{page:\\d+}")) .and(accept(MediaType.TEXT_HTML))), handlerFunction()); } HandlerFunction handlerFunction() { return request -> Mono.deferContextual(contextView -> { var posts = new LazyContextVariable>() { @Override protected UrlContextListResult loadValue() { return postList(request).contextWrite(contextView).block(BLOCKING_TIMEOUT); } }; return ServerResponse.ok().render(DefaultTemplateEnum.INDEX.getValue(), Map.of( "posts", posts, ModelConst.TEMPLATE_ID, DefaultTemplateEnum.INDEX.getValue() )); }); } private Mono> postList(ServerRequest request) { String path = request.path(); return configuredPageSize(environmentFetcher, SystemSetting.Post::getPostPageSize) .flatMap(pageSize -> postFinder.list(pageNumInPathVariable(request), pageSize)) .doOnNext(list -> list.getItems() .forEach(listedPostVo -> listedPostVo.getSpec() .setTitle(titleVisibilityIdentifyCalculator.calculateTitle( listedPostVo.getSpec().getTitle(), listedPostVo.getSpec().getVisible(), localeContextResolver.resolveLocaleContext(request.exchange()) .getLocale()) ) ) ) .map(list -> new UrlContextListResult.Builder() .listResult(list) .nextUrl(PageUrlUtils.nextPageUrl(path, totalPage(list))) .prevUrl(PageUrlUtils.prevPageUrl(path)) .build() ); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/factories/PostRouteFactory.java ================================================ package run.halo.app.theme.router.factories; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static org.apache.commons.lang3.StringUtils.isNotBlank; import static org.springframework.web.reactive.function.server.RequestPredicates.GET; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; import static run.halo.app.content.permalinks.PostPermalinkPolicy.DEFAULT_CATEGORY; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.regex.Pattern; import lombok.AllArgsConstructor; import lombok.Data; import lombok.Getter; import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Sort; import org.springframework.http.MediaType; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RequestPredicate; import org.springframework.web.reactive.function.server.RequestPredicates; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.i18n.LocaleContextResolver; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.content.PostService; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.Queries; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.ViewNameResolver; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.vo.PostVo; import run.halo.app.theme.router.ModelMapUtils; import run.halo.app.theme.router.ReactiveQueryPostPredicateResolver; import run.halo.app.theme.router.TitleVisibilityIdentifyCalculator; /** * The {@link PostRouteFactory} for generate {@link RouterFunction} specific to the template * post.html. * * @author guqing * @since 2.0.0 */ @Component @AllArgsConstructor public class PostRouteFactory implements RouteFactory { private final PostFinder postFinder; private final ViewNameResolver viewNameResolver; private final ReactiveExtensionClient client; private final ReactiveQueryPostPredicateResolver queryPostPredicateResolver; private final TitleVisibilityIdentifyCalculator titleVisibilityIdentifyCalculator; private final LocaleContextResolver localeContextResolver; private final PostService postService; @Override public RouterFunction create(String pattern) { var postParamPredicate = new PatternParser(pattern); if (postParamPredicate.isQueryParamPattern()) { RequestPredicate requestPredicate = postParamPredicate.toRequestPredicate(); return RouterFunctions.route(GET("/") .and(requestPredicate), queryParamHandlerFunction(postParamPredicate)); } return RouterFunctions .route(GET(pattern).and(accept(MediaType.TEXT_HTML)), handlerFunction()); } HandlerFunction queryParamHandlerFunction(PatternParser paramPredicate) { return request -> { Map variables = mergedVariables(request); PostPatternVariable patternVariable = new PostPatternVariable(); Optional.ofNullable(variables.get(paramPredicate.getParamName())) .ifPresent(value -> { switch (paramPredicate.getPlaceholderName()) { case "name" -> patternVariable.setName(value); case "slug" -> patternVariable.setSlug(value); default -> throw new IllegalArgumentException("Unsupported query param predicate"); } }); return postResponse(request, patternVariable); }; } HandlerFunction handlerFunction() { return request -> { PostPatternVariable patternVariable = PostPatternVariable.from(request); return postResponse(request, patternVariable) .switchIfEmpty(Mono.error(() -> new NotFoundException("Post not found."))); }; } @NonNull private Mono postResponse(ServerRequest request, PostPatternVariable patternVariable) { Mono postVoMono = bestMatchPost(patternVariable); return postVoMono .doOnNext(postVo -> { postVo.getSpec().setTitle( titleVisibilityIdentifyCalculator.calculateTitle( postVo.getSpec().getTitle(), postVo.getSpec().getVisible(), localeContextResolver.resolveLocaleContext(request.exchange()) .getLocale()) ); }) .flatMap(postVo -> { Map model = ModelMapUtils.postModel(postVo); return determineTemplate(request, postVo) .flatMap(templateName -> ServerResponse.ok().render(templateName, model)); }); } Mono determineTemplate(ServerRequest request, PostVo postVo) { return Flux.fromIterable(defaultIfNull(postVo.getCategories(), List.of())) .filter(category -> isNotBlank(category.getSpec().getPostTemplate())) .concatMap(category -> viewNameResolver.resolveViewNameOrDefault(request, category.getSpec().getPostTemplate(), null) ) .next() .switchIfEmpty(Mono.defer(() -> viewNameResolver.resolveViewNameOrDefault(request, postVo.getSpec().getTemplate(), DefaultTemplateEnum.POST.getValue()) )); } Mono bestMatchPost(PostPatternVariable variable) { return postsByPredicates(variable) .filter(post -> { Map labels = MetadataUtil.nullSafeLabels(post); return matchIfPresent(variable.getName(), post.getMetadata().getName()) && matchIfPresent(variable.getSlug(), post.getSpec().getSlug()) && matchIfPresent(variable.getYear(), labels.get(Post.ARCHIVE_YEAR_LABEL)) && matchIfPresent(variable.getMonth(), labels.get(Post.ARCHIVE_MONTH_LABEL)) && matchIfPresent(variable.getDay(), labels.get(Post.ARCHIVE_DAY_LABEL)); }) .filterWhen(post -> { if (isNotBlank(variable.getCategorySlug())) { var categoryNames = post.getSpec().getCategories(); return postService.listCategories(categoryNames) .next() .filter(category -> category.getSpec().getSlug() .equals(variable.getCategorySlug()) ) .map(category -> category.getSpec().getSlug()) .switchIfEmpty(Mono.defer(() -> { if (DEFAULT_CATEGORY.equals(variable.getCategorySlug())) { return Mono.just(DEFAULT_CATEGORY); } return Mono.empty(); })) .hasElement(); } return Mono.just(true); }) .next() .flatMap(post -> postFinder.getByName(post.getMetadata().getName())); } Flux postsByPredicates(PostPatternVariable patternVariable) { if (isNotBlank(patternVariable.getName())) { return fetchPostsByName(patternVariable.getName()); } if (isNotBlank(patternVariable.getSlug())) { return fetchPostsBySlug(patternVariable.getSlug()); } return Flux.empty(); } private Flux fetchPostsByName(String name) { return queryPostPredicateResolver.getPredicate() .flatMap(predicate -> client.fetch(Post.class, name) .filter(predicate) ) .flux(); } private Flux fetchPostsBySlug(String slug) { return queryPostPredicateResolver.getListOptions() .flatMapMany(listOptions -> { if (isNotBlank(slug)) { var other = Queries.equal("spec.slug", slug); listOptions.setFieldSelector(listOptions.getFieldSelector().andQuery(other)); } return client.listAll(Post.class, listOptions, Sort.unsorted()); }); } private boolean matchIfPresent(String variable, String target) { return StringUtils.isBlank(variable) || StringUtils.equals(target, variable); } @Data static class PostPatternVariable { String name; String slug; String year; String month; String day; String categorySlug; static PostPatternVariable from(ServerRequest request) { Map variables = mergedVariables(request); return JsonUtils.mapToObject(variables, PostPatternVariable.class); } } static Map mergedVariables(ServerRequest request) { Map pathVariables = request.pathVariables(); MultiValueMap queryParams = request.queryParams(); Map mergedVariables = new LinkedHashMap<>(); for (String paramKey : queryParams.keySet()) { mergedVariables.put(paramKey, queryParams.getFirst(paramKey)); } // path variables higher priority will override query params mergedVariables.putAll(pathVariables); return mergedVariables; } @Getter static class PatternParser { private static final Pattern PATTERN_COMPILE = Pattern.compile("([^&?]*)=\\{(.*?)\\}(&|$)"); private final String pattern; private String paramName; private String placeholderName; private final boolean isQueryParamPattern; PatternParser(String pattern) { this.pattern = pattern; var matcher = PATTERN_COMPILE.matcher(pattern); if (matcher.find()) { this.paramName = matcher.group(1); this.placeholderName = matcher.group(2); this.isQueryParamPattern = true; } else { this.isQueryParamPattern = false; } } RequestPredicate toRequestPredicate() { if (!this.isQueryParamPattern) { throw new IllegalStateException("Not a query param pattern: " + pattern); } return RequestPredicates.queryParam(paramName, value -> true); } } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/factories/RouteFactory.java ================================================ package run.halo.app.theme.router.factories; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import java.util.function.Function; import org.apache.commons.lang3.math.NumberUtils; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.theme.router.ModelConst; /** * @author guqing * @since 2.0.0 */ public interface RouteFactory { RouterFunction create(String pattern); default Mono configuredPageSize( SystemConfigFetcher environmentFetcher, Function mapper) { return environmentFetcher.fetchPost() .map(p -> defaultIfNull(mapper.apply(p), ModelConst.DEFAULT_PAGE_SIZE)); } default int pageNumInPathVariable(ServerRequest request) { String page = request.pathVariables().get("page"); return NumberUtils.toInt(page, 1); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/factories/TagPostRouteFactory.java ================================================ package run.halo.app.theme.router.factories; import static org.springframework.web.reactive.function.server.RequestPredicates.GET; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; import static run.halo.app.extension.index.query.Queries.and; import static run.halo.app.extension.index.query.Queries.equal; import static run.halo.app.extension.index.query.Queries.isNull; import static run.halo.app.theme.router.PageUrlUtils.totalPage; import java.util.HashMap; import java.util.Map; import lombok.AllArgsConstructor; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.i18n.LocaleContextResolver; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.infra.utils.PathUtils; import run.halo.app.theme.Constant; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.TagFinder; import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.finders.vo.TagVo; import run.halo.app.theme.router.PageUrlUtils; import run.halo.app.theme.router.TitleVisibilityIdentifyCalculator; import run.halo.app.theme.router.UrlContextListResult; /** * The {@link TagPostRouteFactory} for generate {@link RouterFunction} specific to the template * tag.html. * * @author guqing * @since 2.0.0 */ @Component @AllArgsConstructor public class TagPostRouteFactory implements RouteFactory { private final ReactiveExtensionClient client; private final SystemConfigFetcher environmentFetcher; private final TagFinder tagFinder; private final PostFinder postFinder; private final TitleVisibilityIdentifyCalculator titleVisibilityIdentifyCalculator; private final LocaleContextResolver localeContextResolver; @Override public RouterFunction create(String prefix) { return RouterFunctions .route(GET(PathUtils.combinePath(prefix, "/{slug}")) .or(GET(PathUtils.combinePath(prefix, "/{slug}/page/{page:\\d+}"))) .and(accept(MediaType.TEXT_HTML)), handlerFunction()); } private HandlerFunction handlerFunction() { return request -> tagBySlug(request.pathVariable("slug")) .flatMap(tagVo -> { int pageNum = pageNumInPathVariable(request); String path = request.path(); var postList = postList(tagVo.getMetadata().getName(), pageNum, path) .doOnNext(list -> list.forEach(postVo -> postVo.getSpec().setTitle( titleVisibilityIdentifyCalculator.calculateTitle( postVo.getSpec().getTitle(), postVo.getSpec().getVisible(), localeContextResolver.resolveLocaleContext(request.exchange()) .getLocale() ) ) )); Map model = new HashMap<>(); model.put("name", tagVo.getMetadata().getName()); model.put("posts", postList); model.put("tag", tagVo); model.put( Constant.META_DESCRIPTION_VARIABLE_NAME, tagVo.getSpec().getDescription() ); return ServerResponse.ok() .render(DefaultTemplateEnum.TAG.getValue(), model); }); } private Mono> postList(String name, Integer page, String requestPath) { return configuredPageSize(environmentFetcher, SystemSetting.Post::getTagPageSize) .flatMap(pageSize -> postFinder.listByTag(page, pageSize, name)) .map(list -> new UrlContextListResult.Builder() .listResult(list) .nextUrl(PageUrlUtils.nextPageUrl(requestPath, totalPage(list))) .prevUrl(PageUrlUtils.prevPageUrl(requestPath)) .build() ); } private Mono tagBySlug(String slug) { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of( and( equal("spec.slug", slug), isNull("metadata.deletionTimestamp") ) )); return client.listBy(Tag.class, listOptions, PageRequestImpl.ofSize(1)) .mapNotNull(result -> ListResult.first(result).orElse(null)) .flatMap(tag -> tagFinder.getByName(tag.getMetadata().getName())) .switchIfEmpty( Mono.error(new NotFoundException("Tag not found with slug: " + slug))); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/router/factories/TagsRouteFactory.java ================================================ package run.halo.app.theme.router.factories; import static org.springframework.web.reactive.function.server.RequestPredicates.GET; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; import java.util.Map; import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.finders.TagFinder; import run.halo.app.theme.router.ModelConst; /** * The {@link TagsRouteFactory} for generate {@link RouterFunction} specific to the template * tags.html. * * @author guqing * @since 2.0.0 */ @Component @AllArgsConstructor public class TagsRouteFactory implements RouteFactory { private final TagFinder tagFinder; @Override public RouterFunction create(String prefix) { return RouterFunctions .route(GET(StringUtils.prependIfMissing(prefix, "/")) .and(accept(MediaType.TEXT_HTML)), handlerFunction()); } private HandlerFunction handlerFunction() { return request -> ServerResponse.ok() .render(DefaultTemplateEnum.TAGS.getValue(), Map.of("tags", tagFinder.listAll(), ModelConst.TEMPLATE_ID, DefaultTemplateEnum.TAGS.getValue() ) ); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/service/ThemeService.java ================================================ package run.halo.app.theme.service; import org.reactivestreams.Publisher; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.util.StringUtils; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Theme; import run.halo.app.extension.ConfigMap; import run.halo.app.infra.SystemSetting; public interface ThemeService { Mono installPresetTheme(); Mono install(Publisher content); Mono upgrade(String themeName, Publisher content); Mono reloadTheme(String name); Mono resetSettingConfig(String name); /** * Fetch activated theme. * * @return the activated theme */ Mono fetchActivatedTheme(); /** * Fetch system setting of theme. * * @return the system setting of theme */ Mono fetchSystemSetting(); /** * Fetch activated theme name. * * @return the activated theme name */ default Mono fetchActivatedThemeName() { return fetchSystemSetting() .mapNotNull(SystemSetting.Theme::getActive) .filter(StringUtils::hasText); } // TODO Migrate other useful methods in ThemeEndpoint in the future. } ================================================ FILE: application/src/main/java/run/halo/app/theme/service/ThemeServiceImpl.java ================================================ package run.halo.app.theme.service; import static org.springframework.util.FileSystemUtils.copyRecursively; import static run.halo.app.infra.utils.FileUtils.deleteRecursivelyAndSilently; import static run.halo.app.infra.utils.FileUtils.unzip; import static run.halo.app.theme.service.ThemeUtils.loadThemeManifest; import static run.halo.app.theme.service.ThemeUtils.locateThemeManifest; import static run.halo.app.theme.service.ThemeUtils.unzipThemeTo; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.function.Predicate; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.jspecify.annotations.Nullable; import org.reactivestreams.Publisher; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.UrlResource; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.util.Assert; import org.springframework.util.ResourceUtils; import org.springframework.util.StreamUtils; import org.springframework.web.server.ServerErrorException; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; import run.halo.app.core.extension.AnnotationSetting; import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.notification.NotificationTemplate; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Extension; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Unstructured; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.infra.ThemeRootGetter; import run.halo.app.infra.exception.ThemeAlreadyExistsException; import run.halo.app.infra.exception.ThemeUpgradeException; import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.utils.FileUtils; import run.halo.app.infra.utils.SettingUtils; import run.halo.app.infra.utils.VersionUtils; @Slf4j @Service @AllArgsConstructor public class ThemeServiceImpl implements ThemeService { private final ReactiveExtensionClient client; private final ThemeRootGetter themeRoot; private final HaloProperties haloProperties; private final SystemVersionSupplier systemVersionSupplier; private final SystemConfigFetcher systemConfigFetcher; private final Scheduler scheduler = Schedulers.boundedElastic(); @Override public Mono installPresetTheme() { var themeProps = haloProperties.getTheme(); var location = themeProps.getInitializer().getLocation(); return Mono.using( () -> Files.createTempDirectory("halo-theme-preset"), tempDir -> Mono.fromCallable(() -> { var themeUrl = ResourceUtils.getURL(location); var resource = new UrlResource(themeUrl); var tempThemePath = tempDir.resolve("theme.zip"); FileUtils.copyResource(resource, tempThemePath); return tempThemePath; }).flatMap(themePath -> { var content = DataBufferUtils.read(new FileSystemResource(themePath), DefaultDataBufferFactory.sharedInstance, StreamUtils.BUFFER_SIZE); // We don't want to run on the bounded elastic scheduler again, so pass null // here. return doInstall(content, null); }), FileUtils::deleteRecursivelyAndSilently ) .subscribeOn(scheduler) .onErrorResume(IOException.class, e -> { log.warn("Failed to initialize theme from {}", location, e); return Mono.empty(); }) .onErrorResume(ThemeAlreadyExistsException.class, e -> { log.warn("Failed to initialize theme from {}, because it already exists", location); return Mono.empty(); }) .then(); } @Override public Mono install(Publisher content) { return doInstall(content, this.scheduler); } @Override public Mono upgrade(String themeName, Publisher content) { var checkTheme = client.fetch(Theme.class, themeName) .switchIfEmpty(Mono.error(() -> new ServerWebInputException( "The given theme with name " + themeName + " did not exist") )); var upgradeTheme = Mono.using( () -> Files.createTempDirectory("halo-theme-"), tempDir -> { var locateThemeManifest = Mono.fromCallable( () -> locateThemeManifest(tempDir).orElse(null) ) .switchIfEmpty(Mono.error(() -> new ThemeUpgradeException( "Missing theme manifest file: theme.yaml or theme.yml", "problemDetail.theme.upgrade.missingManifest", null) )); return unzip(content, tempDir) .then(locateThemeManifest) .flatMap(themeManifest -> { if (log.isDebugEnabled()) { log.debug("Found theme manifest file: {}", themeManifest); } var newTheme = loadThemeManifest(themeManifest); if (!Objects.equals(themeName, newTheme.getMetadata().getName())) { if (log.isDebugEnabled()) { log.error("Want theme name: {}, but provided: {}", themeName, newTheme.getMetadata().getName()); } return Mono.error(new ThemeUpgradeException( "Please make sure the theme name is correct", "problemDetail.theme.upgrade.nameMismatch", new Object[] {newTheme.getMetadata().getName(), themeName})); } var copyTheme = Mono.fromCallable(() -> { var themePath = themeRoot.get().resolve(themeName); // TODO Create backup before deleting deleteRecursivelyAndSilently(themePath); copyRecursively(themeManifest.getParent(), themePath); return themePath; }); return copyTheme.then(this.persistent(newTheme, true)); }); }, FileUtils::deleteRecursivelyAndSilently ).subscribeOn(scheduler); return checkTheme.then(upgradeTheme); } private Mono doInstall(Publisher content, @Nullable Scheduler scheduler) { var themeRoot = this.themeRoot.get(); return unzipThemeTo(content, themeRoot, scheduler) .flatMap(theme -> persistent(theme, false)); } /** * Creates theme manifest and related unstructured resources. * TODO: In case of failure in saving midway, the problem of data consistency needs to be * solved. * * @param themeManifest the theme custom model * @return a theme custom model * @see Theme */ private Mono persistent(Unstructured themeManifest, boolean isUpgrade) { Assert.state(StringUtils.equals(Theme.KIND, themeManifest.getKind()), "Theme manifest kind must be Theme."); var newTheme = Unstructured.OBJECT_MAPPER.convertValue(themeManifest, Theme.class); final Mono createOrUpdateTheme; if (isUpgrade) { createOrUpdateTheme = client.get(Theme.class, newTheme.getMetadata().getName()) .doOnNext(theme -> updateTheme(theme, newTheme)) .flatMap(client::update); } else { createOrUpdateTheme = client.create(newTheme); } return createOrUpdateTheme .doOnNext(theme -> { String systemVersion = systemVersionSupplier.get().toStableVersion().toString(); String requires = theme.getSpec().getRequires(); if (!VersionUtils.satisfiesRequires(systemVersion, requires)) { throw new UnsatisfiedAttributeValueException( String.format("The theme requires a minimum system version of %s, " + "but the current version is %s.", requires, systemVersion), "problemDetail.theme.version.unsatisfied.requires", new String[] {requires, systemVersion}); } }) .flatMap(theme -> { var unstructureds = ThemeUtils.loadThemeResources(getThemePath(theme)); if (unstructureds.stream() .filter(hasSettingsYaml(theme)) .count() > 1) { return Mono.error(new IllegalStateException( "Theme must only have one settings.yaml or settings.yml.")); } if (unstructureds.stream() .filter(hasConfigYaml(theme)) .count() > 1) { return Mono.error(new IllegalStateException( "Theme must only have one config.yaml or config.yml.")); } return Flux.fromIterable(unstructureds) .filter(unstructured -> ExtensionWhitelist.of(theme).isAllowed(unstructured)) .doOnNext(unstructured -> populateThemeNameLabel(unstructured, theme.getMetadata().getName())) .flatMap(unstructured -> { if (isUpgrade) { return createOrUpdate(unstructured); } return client.create(unstructured); }) .then() .thenReturn(theme); }); } private Mono createOrUpdate(Unstructured unstructured) { return Mono.defer(() -> client.fetch(unstructured.groupVersionKind(), unstructured.getMetadata().getName()) .flatMap(existUnstructured -> { unstructured.getMetadata() .setVersion(existUnstructured.getMetadata().getVersion()); return client.update(unstructured); }) .switchIfEmpty(Mono.defer(() -> client.create(unstructured))) ) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance)); } @Override public Mono reloadTheme(String name) { return client.fetch(Theme.class, name) .flatMap(oldTheme -> { String settingName = oldTheme.getSpec().getSettingName(); return waitForSettingDeleted(settingName) .then(waitForAnnotationSettingsDeleted(name)); }) .then(Mono.defer(() -> { Path themePath = themeRoot.get().resolve(name); Path themeManifestPath = ThemeUtils.resolveThemeManifest(themePath); if (themeManifestPath == null) { throw new IllegalArgumentException( "The manifest file [theme.yaml] is required."); } Unstructured unstructured = loadThemeManifest(themeManifestPath); Theme newTheme = Unstructured.OBJECT_MAPPER.convertValue(unstructured, Theme.class); return client.fetch(Theme.class, name) .map(oldTheme -> { newTheme.getMetadata().setVersion(oldTheme.getMetadata().getVersion()); return newTheme; }) .flatMap(client::update); })) .flatMap(theme -> Flux.fromIterable(ThemeUtils.loadThemeResources(getThemePath(theme))) .filter(unstructured -> ExtensionWhitelist.of(theme).isAllowed(unstructured)) .doOnNext(unstructured -> populateThemeNameLabel(unstructured, name)) .flatMap(this::createOrUpdate) .then(Mono.just(theme)) ); } private static void populateThemeNameLabel(Unstructured unstructured, String themeName) { Map labels = unstructured.getMetadata().getLabels(); if (labels == null) { labels = new HashMap<>(); unstructured.getMetadata().setLabels(labels); } labels.put(Theme.THEME_NAME_LABEL, themeName); } @Override public Mono resetSettingConfig(String name) { return client.fetch(Theme.class, name) .filter(theme -> StringUtils.isNotBlank(theme.getSpec().getSettingName())) .flatMap(theme -> { String configMapName = theme.getSpec().getConfigMapName(); String settingName = theme.getSpec().getSettingName(); return client.fetch(Setting.class, settingName) .map(SettingUtils::settingDefinedDefaultValueMap) .flatMap(data -> updateConfigMapData(configMapName, data)); }); } @Override public Mono fetchActivatedTheme() { return fetchSystemSetting().mapNotNull(SystemSetting.Theme::getActive) .flatMap(name -> client.fetch(Theme.class, name)); } @Override public Mono fetchSystemSetting() { return systemConfigFetcher.fetch(SystemSetting.Theme.GROUP, SystemSetting.Theme.class); } private Mono updateConfigMapData(String configMapName, Map data) { return client.fetch(ConfigMap.class, configMapName) .flatMap(configMap -> { configMap.setData(data); return client.update(configMap); }) .retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100)) .filter(t -> t instanceof OptimisticLockingFailureException)); } private Mono waitForSettingDeleted(String settingName) { return client.fetch(Setting.class, settingName) .flatMap(setting -> client.delete(setting) .flatMap(deleted -> client.fetch(Setting.class, settingName) .flatMap(s -> Mono.error( () -> new IllegalStateException("Re-check if the setting is deleted.") )) .retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100)) .filter(IllegalStateException.class::isInstance) ) ) ) .then(); } private Mono waitForAnnotationSettingsDeleted(String themeName) { return client.list(AnnotationSetting.class, annotationSetting -> { Map labels = MetadataUtil.nullSafeLabels(annotationSetting); return StringUtils.equals(themeName, labels.get(Theme.THEME_NAME_LABEL)); }, null) .flatMap(annotationSetting -> client.delete(annotationSetting) .flatMap(deleted -> client.fetch(AnnotationSetting.class, annotationSetting.getMetadata().getName()) .flatMap(latest -> Mono.error( new IllegalStateException("AnnotationSetting is not deleted yet.") )) .retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100)) .filter(t -> t instanceof IllegalStateException) ) ) ) .then(); } private Path getThemePath(Theme theme) { return themeRoot.get().resolve(theme.getMetadata().getName()); } private Predicate hasSettingsYaml(Theme theme) { return unstructured -> Setting.KIND.equals(unstructured.getKind()) && theme.getSpec().getSettingName().equals(unstructured.getMetadata().getName()); } private Predicate hasConfigYaml(Theme theme) { return unstructured -> ConfigMap.KIND.equals(unstructured.getKind()) && theme.getSpec().getConfigMapName().equals(unstructured.getMetadata().getName()); } Mono deleteThemeAndWaitForComplete(String themeName) { return client.fetch(Theme.class, themeName) .flatMap(client::delete) .flatMap(deletingTheme -> waitForThemeDeleted(themeName) .thenReturn(deletingTheme)); } Mono waitForThemeDeleted(String themeName) { return client.fetch(Theme.class, themeName) .flatMap(theme -> Mono.error( new IllegalStateException("Re-check if the theme is deleted successfully") )) .retryWhen(Retry.fixedDelay(20, Duration.ofMillis(100)) .filter(IllegalStateException.class::isInstance) .onRetryExhaustedThrow((spec, signal) -> new ServerErrorException("Wait timeout for theme deleted", null))) .then(); } static class ExtensionWhitelist { private final Set rules; private ExtensionWhitelist(Theme theme) { this.rules = getRules(theme); } public static ExtensionWhitelist of(Theme theme) { return new ExtensionWhitelist(theme); } public boolean isAllowed(Unstructured unstructured) { return this.rules.stream() .anyMatch(rule -> rule.matches(unstructured)); } private Set getRules(Theme theme) { var rules = new HashSet(); rules.add(AllowedExtension.of(AnnotationSetting.class)); rules.add(AllowedExtension.of(NotificationTemplate.class)); var configMapName = theme.getSpec().getConfigMapName(); if (StringUtils.isNotBlank(configMapName)) { rules.add(AllowedExtension.of(ConfigMap.class, configMapName)); } var settingName = theme.getSpec().getSettingName(); if (StringUtils.isNotBlank(settingName)) { rules.add(AllowedExtension.of(Setting.class, settingName)); } return rules; } } record AllowedExtension(String apiGroup, String kind, String name) { AllowedExtension { Assert.notNull(apiGroup, "The apiGroup must not be null"); Assert.notNull(kind, "Kind must not be null"); } public static AllowedExtension of(Class clazz) { return of(clazz, null); } public static AllowedExtension of(Class clazz, String name) { var gvk = GroupVersionKind.fromExtension(clazz); return new AllowedExtension(gvk.group(), gvk.kind(), name); } public boolean matches(Unstructured unstructured) { var groupVersionKind = unstructured.groupVersionKind(); return this.apiGroup.equals(groupVersionKind.group()) && this.kind.equals(groupVersionKind.kind()) && (this.name == null || this.name.equals(unstructured.getMetadata().getName())); } } private static void updateTheme(Theme existing, Theme updating) { var existingSpec = existing.getSpec(); var updatingSpec = updating.getSpec(); // merge spec existingSpec.setAuthor(updatingSpec.getAuthor()); existingSpec.setCustomTemplates(updatingSpec.getCustomTemplates()); existingSpec.setDescription(updatingSpec.getDescription()); existingSpec.setDisplayName(updatingSpec.getDisplayName()); existingSpec.setHomepage(updatingSpec.getHomepage()); existingSpec.setIssues(updatingSpec.getIssues()); existingSpec.setLicense(updatingSpec.getLicense()); existingSpec.setLogo(updatingSpec.getLogo()); existingSpec.setRepo(updatingSpec.getRepo()); existingSpec.setSettingName(updatingSpec.getSettingName()); existingSpec.setVersion(updatingSpec.getVersion()); existingSpec.setRequires(updatingSpec.getRequires()); // Do not overwrite configMapName to avoid data loss var existingMeta = existing.getMetadata(); var updatingMeta = updating.getMetadata(); // merge labels if (updatingMeta.getLabels() != null) { if (existingMeta.getLabels() == null) { existingMeta.setLabels(new HashMap<>()); } existingMeta.getLabels().putAll(updatingMeta.getLabels()); } // merge annotations if (updatingMeta.getAnnotations() != null) { if (existingMeta.getAnnotations() == null) { existingMeta.setAnnotations(new HashMap<>()); } existingMeta.getAnnotations().putAll(updatingMeta.getAnnotations()); } } } ================================================ FILE: application/src/main/java/run/halo/app/theme/service/ThemeUtils.java ================================================ package run.halo.app.theme.service; import static org.springframework.util.FileSystemUtils.copyRecursively; import static run.halo.app.infra.utils.FileUtils.createTempDir; import static run.halo.app.infra.utils.FileUtils.deleteRecursivelyAndSilently; import static run.halo.app.infra.utils.FileUtils.unzip; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedList; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.stream.BaseStream; import java.util.stream.Stream; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.Nullable; import org.reactivestreams.Publisher; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.util.CollectionUtils; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ServerWebInputException; import reactor.core.Exceptions; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; import run.halo.app.core.extension.Theme; import run.halo.app.extension.Unstructured; import run.halo.app.infra.exception.ThemeAlreadyExistsException; import run.halo.app.infra.exception.ThemeInstallationException; import run.halo.app.infra.utils.FileUtils; import run.halo.app.infra.utils.YamlUnstructuredLoader; @Slf4j @UtilityClass public class ThemeUtils { private static final String THEME_TMP_PREFIX = "halo-theme-"; private static final String[] THEME_MANIFESTS = {"theme.yaml", "theme.yml"}; public static Flux listAllThemesFromThemeDir(Path themesDir) { return walkThemesFromPath(themesDir) .filter(Files::isDirectory) .map(ThemeUtils::findThemeManifest) .flatMap(Flux::fromIterable) .filter(unstructured -> unstructured.getKind().equals(Theme.KIND)) .map(unstructured -> Unstructured.OBJECT_MAPPER.convertValue(unstructured, Theme.class)) .sort(Comparator.comparing(theme -> theme.getMetadata().getName())); } private static Flux walkThemesFromPath(Path path) { return Flux.using(() -> Files.walk(path, 2), Flux::fromStream, BaseStream::close ) .subscribeOn(Schedulers.boundedElastic()); } private static List findThemeManifest(Path themePath) { List resources = new ArrayList<>(4); for (String themeResource : THEME_MANIFESTS) { Path resourcePath = themePath.resolve(themeResource); if (Files.exists(resourcePath)) { resources.add(new FileSystemResource(resourcePath)); } } if (CollectionUtils.isEmpty(resources)) { return List.of(); } return new YamlUnstructuredLoader(resources.toArray(new Resource[0])) .load(); } static List loadThemeResources(Path themePath) { try (Stream paths = Files.list(themePath)) { List resources = paths .filter(path -> { String pathString = path.toString(); return pathString.endsWith(".yaml") || pathString.endsWith(".yml"); }) .filter(path -> { String pathString = path.toString(); for (String themeManifest : THEME_MANIFESTS) { if (pathString.endsWith(themeManifest)) { return false; } } return true; }) .map(FileSystemResource::new) .toList(); return new YamlUnstructuredLoader(resources.toArray(new Resource[0])) .load(); } catch (IOException e) { throw new RuntimeException(e); } } static Mono unzipThemeTo(Publisher content, Path themeWorkDir, @Nullable Scheduler scheduler) { return unzipThemeTo(content, themeWorkDir, false, scheduler) .onErrorMap(e -> !(e instanceof ResponseStatusException), e -> { log.error("Failed to unzip theme", e); throw new ServerWebInputException("Failed to unzip theme"); }); } static Mono unzipThemeTo(Publisher content, Path themeWorkDir, boolean override, @Nullable Scheduler scheduler) { var unzipThem = Mono.usingWhen( createTempDir(THEME_TMP_PREFIX, null), tempDir -> { var locateThemeManifest = Mono.fromCallable( () -> locateThemeManifest(tempDir).orElse(null) ) .switchIfEmpty( Mono.error(() -> new ThemeInstallationException( "Missing theme manifest", "problemDetail.theme.install.missingManifest", null )) ); return unzip(content, tempDir, null) .then(locateThemeManifest) .handle((themeManifestPath, sink) -> { var theme = loadThemeManifest(themeManifestPath); var themeName = theme.getMetadata().getName(); var themeTargetPath = themeWorkDir.resolve(themeName); try { if (!override && !FileUtils.isEmpty(themeTargetPath)) { sink.error(new ThemeAlreadyExistsException(themeName)); return; } // install theme to theme work dir copyRecursively(themeManifestPath.getParent(), themeTargetPath); sink.next(theme); } catch (IOException e) { deleteRecursivelyAndSilently(themeTargetPath); sink.error(e); } }); }, tempDir -> FileUtils.deleteRecursivelyAndSilently(tempDir, null) ); if (scheduler != null) { return unzipThem.subscribeOn(scheduler); } return unzipThem; } static Unstructured loadThemeManifest(Path themeManifestPath) { var unstructureds = new YamlUnstructuredLoader(new FileSystemResource(themeManifestPath)) .load(); if (CollectionUtils.isEmpty(unstructureds)) { throw new ThemeInstallationException("Missing theme manifest", "problemDetail.theme.install.missingManifest", null); } return unstructureds.get(0); } @Nullable static Path resolveThemeManifest(Path tempDirectory) { for (String themeManifest : THEME_MANIFESTS) { Path path = tempDirectory.resolve(themeManifest); if (Files.exists(path)) { return path; } } return null; } static Optional locateThemeManifest(Path path) { if (!Files.isDirectory(path)) { return Optional.empty(); } var queue = new LinkedList(); queue.add(path); var manifest = Optional.empty(); while (!queue.isEmpty()) { var current = queue.pop(); try (Stream subPaths = Files.list(current)) { manifest = subPaths.filter(Files::isReadable) .filter(subPath -> { if (Files.isDirectory(subPath)) { queue.add(subPath); return false; } return true; }) .filter(Files::isRegularFile) .filter(ThemeUtils::isManifest) .findFirst(); } catch (IOException e) { throw Exceptions.propagate(e); } if (manifest.isPresent()) { break; } } return manifest; } static boolean isManifest(Path file) { if (!Files.isRegularFile(file)) { return false; } return Set.of(THEME_MANIFESTS).contains(file.getFileName().toString()); } } ================================================ FILE: application/src/main/java/run/halo/app/theme/utils/PatternUtils.java ================================================ package run.halo.app.theme.utils; import static org.apache.commons.lang3.StringUtils.prependIfMissing; import static org.apache.commons.lang3.StringUtils.removeEnd; import org.apache.commons.lang3.StringUtils; import org.springframework.util.Assert; import run.halo.app.infra.SystemSetting.ThemeRouteRules; /** * Pattern utility methods. * * @author johnniang * @since 2.22.0 */ public enum PatternUtils { ; /** * Normalize the pattern by ensuring it starts with a "/" and does not end with a "/". * * @param pattern the pattern to normalize, must not be blank or just "/" * @return the normalized pattern */ public static String normalizePattern(String pattern) { Assert.hasText(pattern, "Pattern must not be blank"); Assert.isTrue(!"/".equals(pattern.trim()), "Pattern must not be just '/'"); pattern = prependIfMissing(pattern.trim(), "/"); return removeEnd(pattern, "/"); } /** * Normalize the post pattern, if the post pattern starts with /archives/ or /categories/, * replace it with the corresponding pattern from rules. * * @param rules the theme route rules * @return the normalized post pattern */ public static String normalizePostPattern(ThemeRouteRules rules) { var postPattern = normalizePattern(rules.getPost()); if (StringUtils.startsWith(postPattern, "/archives/")) { var archivesPattern = normalizePattern(rules.getArchives()); postPattern = archivesPattern + StringUtils.removeStart(postPattern, "/archives"); } else if (StringUtils.startsWith(postPattern, "/categories/")) { var categoriesPattern = normalizePattern(rules.getCategories()); postPattern = categoriesPattern + StringUtils.removeStart(postPattern, "/categories"); } return postPattern; } } ================================================ FILE: application/src/main/resources/META-INF/spring-devtools.properties ================================================ # See https://docs.spring.io/spring-boot/docs/current/reference/html/using.html#using.devtools.restart.customizing-the-classload for more restart.include.apimodule=/api-[\\w\\d-\\.]+\\.jar ================================================ FILE: application/src/main/resources/application-dev.yaml ================================================ server: port: 8090 spring: output: ansi: enabled: always thymeleaf: cache: false web: resources: cache: cachecontrol: no-cache: true use-last-modified: false halo: security: basic-auth: disabled: false ui: proxy: endpoint: http://localhost:3000/ enabled: true plugin: runtime-mode: development # development, deployment work-dir: ${user.home}/halo2-dev logging: level: org.springframework.data.r2dbc: DEBUG org.springframework.r2dbc: DEBUG run.halo: DEBUG web: DEBUG org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler: DEBUG springdoc: cache: disabled: true api-docs: enabled: true version: OPENAPI_3_0 swagger-ui: enabled: true show-actuator: true management: endpoints: web: exposure: include: "*" ================================================ FILE: application/src/main/resources/application-doc.yaml ================================================ springdoc: cache: disabled: true api-docs: enabled: true version: OPENAPI_3_0 spring: main: banner-mode: off r2dbc: url: r2dbc:h2:mem:///halo halo: extension: controller: disabled: true ================================================ FILE: application/src/main/resources/application-mariadb.yaml ================================================ spring: r2dbc: url: r2dbc:pool:mariadb://localhost:3306/halo username: root password: mariadb sql: init: mode: always platform: mariadb ================================================ FILE: application/src/main/resources/application-mysql.yaml ================================================ spring: r2dbc: url: r2dbc:pool:mysql://localhost:3306/halo username: root password: openmysql sql: init: mode: always platform: mysql ================================================ FILE: application/src/main/resources/application-postgresql.yaml ================================================ spring: r2dbc: url: r2dbc:pool:postgresql://localhost:5432/halo username: postgres password: openpostgresql sql: init: mode: always platform: postgresql ================================================ FILE: application/src/main/resources/application-win.yaml ================================================ spring: r2dbc: url: r2dbc:h2:file:///~/halo2-dev/db/halo-next?MODE=MySQL&DB_CLOSE_ON_EXIT=FALSE halo: work-dir: ${user.home}/halo2-dev ================================================ FILE: application/src/main/resources/application.yaml ================================================ server: port: 8090 forward-headers-strategy: native compression: enabled: true spring: output: ansi: enabled: detect r2dbc: url: r2dbc:h2:file:///${halo.work-dir}/db/halo-next?MODE=MySQL&DB_CLOSE_ON_EXIT=FALSE username: admin password: 123456 sql: init: mode: always platform: h2 http: codecs: max-in-memory-size: 10MB messages: basename: config.i18n.messages web: error: whitelabel: enabled: false resources: cache: cachecontrol: max-age: 365d thymeleaf: reactive: maxChunkSize: 8KB cache: type: caffeine caffeine: spec: expireAfterAccess=1h, maximumSize=10000 threads: virtual: enabled: true halo: work-dir: ${user.home}/.halo2 attachment: resource-mappings: - pathPattern: /upload/** locations: - migrate-from-1.x security: password-reset-methods: - name: email href: /password-reset/email icon: /images/password-reset-methods/email.svg springdoc: api-docs: enabled: false writer-with-order-by-keys: true logging: level: org.thymeleaf.TemplateEngine: OFF file: name: ${halo.work-dir}/logs/halo.log logback: rollingpolicy: max-file-size: 10MB total-size-cap: 1GB max-history: 0 management: endpoints: web: exposure: include: "*" endpoint: shutdown: access: unrestricted heapdump: access: unrestricted health: show-details: when-authorized show-components: when-authorized roles: super-role probes: enabled: true info: java: enabled: true os: enabled: true resilience4j.ratelimiter: configs: authentication: limitForPeriod: 3 limitRefreshPeriod: 1m timeoutDuration: 0 comment-creation: limitForPeriod: 10 limitRefreshPeriod: 1m timeoutDuration: 0s signup: limitForPeriod: 3 limitRefreshPeriod: 1h timeoutDuration: 0s send-email-verification-code: limitForPeriod: 1 limitRefreshPeriod: 1m timeoutDuration: 0s verify-email: limitForPeriod: 3 limitRefreshPeriod: 1h timeoutDuration: 0s send-password-reset-email: limitForPeriod: 10 limitRefreshPeriod: 1m timeoutDuration: 0s password-reset-verification: limitForPeriod: 10 limitRefreshPeriod: 1m timeoutDuration: 0s r2dbc: migrate: resources-path: classpath:/db/migration/${spring.sql.init.platform}/*.sql dialect: ${spring.sql.init.platform} ================================================ FILE: application/src/main/resources/banner.txt ================================================ ${AnsiColor.BLUE} __ __ __ / / / /___ _/ /___ / /_/ / __ `/ / __ \ / __ / /_/ / / /_/ / /_/ /_/\__,_/_/\____/ ${AnsiColor.BRIGHT_YELLOW} Version: ${application.version}${AnsiColor.DEFAULT} ================================================ FILE: application/src/main/resources/config/i18n/messages.properties ================================================ # Title definitions problemDetail.title.org.springframework.web.server.ServerWebInputException=Bad Request problemDetail.title.run.halo.app.infra.exception.UnsatisfiedAttributeValueException=Unsatisfied Request Attribute value problemDetail.title.org.springframework.web.server.UnsupportedMediaTypeStatusException=Unsupported Media Type problemDetail.title.org.springframework.web.server.MissingRequestValueException=Missing Request Value problemDetail.title.org.springframework.web.server.UnsatisfiedRequestParameterException=Unsatisfied Request Parameter problemDetail.title.org.springframework.web.bind.support.WebExchangeBindException=Data Binding or Validation Failure problemDetail.title.org.springframework.web.server.NotAcceptableStatusException=Not Acceptable problemDetail.title.org.springframework.web.server.ServerErrorException=Server Error problemDetail.title.org.springframework.web.server.MethodNotAllowedException=Method Not Allowed problemDetail.title.org.springframework.security.authentication.BadCredentialsException=Bad Credentials problemDetail.title.run.halo.app.extension.exception.SchemaViolationException=Schema Violation problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=Attachment Already Exists problemDetail.title.run.halo.app.infra.exception.FileTypeNotAllowedException=File Type Not Allowed problemDetail.title.run.halo.app.infra.exception.FileSizeExceededException=File Size Exceeded problemDetail.title.run.halo.app.infra.exception.AccessDeniedException=Access Denied problemDetail.title.run.halo.app.infra.exception.RequestRestrictedException=Request Restricted problemDetail.title.reactor.core.Exceptions.RetryExhaustedException=Retry Exhausted problemDetail.title.run.halo.app.infra.exception.ThemeInstallationException=Theme Install Error problemDetail.title.run.halo.app.infra.exception.ThemeUpgradeException=Theme Upgrade Error problemDetail.title.run.halo.app.infra.exception.ThemeAlreadyExistsException=Theme Already Exists Error problemDetail.title.run.halo.app.infra.exception.PluginInstallationException=Plugin Install Error problemDetail.title.run.halo.app.infra.exception.PluginAlreadyExistsException=Plugin Already Exists Error problemDetail.title.run.halo.app.infra.exception.DuplicateNameException=Duplicate Name Error problemDetail.title.run.halo.app.infra.exception.RateLimitExceededException=Request Not Permitted problemDetail.title.run.halo.app.infra.exception.NotFoundException=Resource Not Found problemDetail.title.run.halo.app.infra.exception.EmailVerificationFailed=Email Verification Failed problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$CyclicException=Cyclic Dependency Detected problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$NotFoundException=Dependencies Not Found problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=Wrong Dependency Version problemDetail.title.run.halo.app.infra.exception.PluginDependentsNotDisabledException=Dependents Not Disabled problemDetail.title.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=Dependencies Not Enabled problemDetail.title.run.halo.app.infra.exception.OAuth2UserAlreadyBoundException=User Already Bound Error problemDetail.title.run.halo.app.infra.exception.PluginRuntimeIncompatibleException=Plugin Runtime Incompatible problemDetail.title.run.halo.app.infra.exception.RestrictedNameException=Restricted Name problemDetail.title.internalServerError=Internal Server Error problemDetail.title.conflict=Conflict # Detail definitions problemDetail.org.springframework.web.server.UnsupportedMediaTypeStatusException=Content type {0} is not supported. Supported media types: {1}. problemDetail.org.springframework.web.server.UnsupportedMediaTypeStatusException.parseError=Could not parse Content-Type. problemDetail.org.springframework.web.server.MissingRequestValueException=Required {0} '{1}' is not present. problemDetail.org.springframework.web.server.UnsatisfiedRequestParameterException=Parameter conditions "{0}" not met for actual request parameters. problemDetail.org.springframework.web.bind.support.WebExchangeBindException=Invalid request content. Global errors: {0}. Field errors: {1}. problemDetail.org.springframework.web.server.NotAcceptableStatusException=Acceptable representations: {0}. problemDetail.org.springframework.web.server.NotAcceptableStatusException.parseError=Could not parse Accept header. problemDetail.org.springframework.web.server.ServerErrorException={0}. problemDetail.org.springframework.security.authentication.BadCredentialsException=The username or password is incorrect. problemDetail.org.springframework.web.server.MethodNotAllowedException=Request method {0} is not supported. Supported methods: {1}. problemDetail.run.halo.app.extension.exception.SchemaViolationException={1} of schema {0}. problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=File {0} already exists, please rename it and try again. problemDetail.run.halo.app.infra.exception.DuplicateNameException=Duplicate name detected, please rename it and retry. problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=Plugin {0} already exists. problemDetail.run.halo.app.infra.exception.RateLimitExceededException=API rate limit exceeded, please try again later. problemDetail.run.halo.app.infra.exception.EmailVerificationFailed=Invalid email verification code. problemDetail.run.halo.app.infra.exception.PluginDependencyException$CyclicException=A cyclic dependency was detected. problemDetail.run.halo.app.infra.exception.PluginDependencyException$NotFoundException=Dependencies "{0}" were not found. problemDetail.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=Dependencies have wrong version: {0}. problemDetail.run.halo.app.infra.exception.PluginDependentsNotDisabledException=Plugin dependents {0} are not fully disabled, please disable them first. problemDetail.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=Plugin dependencies {0} are not fully enabled, please enable them first. problemDetail.run.halo.app.infra.exception.OAuth2UserAlreadyBoundException=The user {0} has already been bound to another OAuth2 user, cannot automatically bind the current OAuth2 user. problemDetail.run.halo.app.infra.exception.PluginRuntimeIncompatibleException=The plugin is incompatible with the current Halo runtime, please upgrade the plugin or downgrade the Halo runtime. problemDetail.index.duplicateKey=The value of {0} already exists for unique index {1}, please rename it and retry. problemDetail.user.email.verify.maxAttempts=Too many verification attempts, please try again later. problemDetail.user.email.verify.emailInUse=The email has been used, please change the email and retry. problemDetail.user.password.unsatisfied=The password does not meet the specifications. problemDetail.user.username.unsatisfied=The username does not meet the specifications. problemDetail.user.oldPassword.notMatch=The old password does not match. problemDetail.user.password.notMatch=The password does not match. problemDetail.user.signUpFailed.disallowed=System does not allow new users to register. problemDetail.user.duplicateName=The username {0} already exists, please rename it and retry. problemDetail.user.displayName.restricted=The display name {0} is a reserved name, please change it and retry. problemDetail.comment.turnedOff=The comment function has been turned off. problemDetail.comment.systemUsersOnly=Allow only system users to comment problemDetail.theme.upgrade.missingManifest=Missing theme manifest file "theme.yaml" or "theme.yml". problemDetail.theme.upgrade.nameMismatch=The current theme name {0} did not match the installed theme name. problemDetail.theme.install.missingManifest=Missing theme manifest file "theme.yaml" or manifest file does not conform to the theme specification. problemDetail.theme.install.alreadyExists=Theme {0} already exists. problemDetail.theme.version.unsatisfied.requires=The theme requires a minimum system version of {0}, but the current version is {1}. problemDetail.directoryTraversal=Directory traversal detected. Base path is {0}, but real path is {1}. problemDetail.plugin.version.unsatisfied.requires=Plugin requires a minimum system version of {0}, but the current version is {1}. problemDetail.plugin.missingManifest=Missing plugin manifest file "plugin.yaml" or manifest file does not conform to the specification. problemDetail.internalServerError=Something went wrong, please try again later. problemDetail.conflict=Conflict detected, please check the data and retry. problemDetail.migration.backup.notFound=The backup file does not exist or has been deleted. problemDetail.attachment.upload.fileSizeExceeded=Make sure the file size is less than {0}. problemDetail.attachment.upload.fileTypeNotSupported=Unsupported upload of {0} type files. problemDetail.attachment.upload.fileTypeNotMatch=The file type {0} does not match the file extension, and the upload is rejected. problemDetail.comment.waitingForApproval=Comment is awaiting approval. title.visibility.identification.private=(Private) signup.error.confirm-password-not-match=The confirmation password does not match the password. signup.error.email-code.invalid=Invalid email code. signup.error.email.already-taken=Email address is already taken. validation.error.email.pattern=The email format is incorrect validation.error.username.pattern=The username can only be lowercase and can only contain letters, numbers, hyphens, and dots, starting and ending with characters. validation.error.password.pattern=The password can only use uppercase and lowercase letters (A-Z, a-z), numbers (0-9), and the following special characters: !@#$%^&* validation.error.password.size=The password length must be between {0} and {1} ================================================ FILE: application/src/main/resources/config/i18n/messages_es.properties ================================================ # Title definitions problemDetail.title.org.springframework.web.server.ServerWebInputException=Solicitud incorrecta problemDetail.title.run.halo.app.infra.exception.UnsatisfiedAttributeValueException=Valor de atributo de solicitud no satisfecho problemDetail.title.org.springframework.web.server.UnsupportedMediaTypeStatusException=Tipo de medio no soportado problemDetail.title.org.springframework.web.server.MissingRequestValueException=Valor de solicitud faltante problemDetail.title.org.springframework.web.server.UnsatisfiedRequestParameterException=Parámetro de solicitud no satisfecho problemDetail.title.org.springframework.web.bind.support.WebExchangeBindException=Fallo en la validación o vinculación de datos problemDetail.title.org.springframework.web.server.NotAcceptableStatusException=No aceptable problemDetail.title.org.springframework.web.server.ServerErrorException=Error del servidor problemDetail.title.org.springframework.web.server.MethodNotAllowedException=Método no permitido problemDetail.title.org.springframework.security.authentication.BadCredentialsException=Credenciales incorrectas problemDetail.title.run.halo.app.extension.exception.SchemaViolationException=Violación de esquema problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=El archivo adjunto ya existe problemDetail.title.run.halo.app.infra.exception.FileTypeNotAllowedException=Tipo de archivo no permitido problemDetail.title.run.halo.app.infra.exception.FileSizeExceededException=Tamaño de archivo excedido problemDetail.title.run.halo.app.infra.exception.AccessDeniedException=Acceso denegado problemDetail.title.run.halo.app.infra.exception.RequestRestrictedException=Solicitud restringida problemDetail.title.reactor.core.Exceptions.RetryExhaustedException=Reintentos agotados problemDetail.title.run.halo.app.infra.exception.ThemeInstallationException=Error de instalación del tema problemDetail.title.run.halo.app.infra.exception.ThemeUpgradeException=Error de actualización del tema problemDetail.title.run.halo.app.infra.exception.ThemeAlreadyExistsException=Error: El tema ya existe problemDetail.title.run.halo.app.infra.exception.PluginInstallationException=Error de instalación del complemento problemDetail.title.run.halo.app.infra.exception.PluginAlreadyExistsException=Error: El complemento ya existe problemDetail.title.run.halo.app.infra.exception.DuplicateNameException=Error de nombre duplicado problemDetail.title.run.halo.app.infra.exception.RateLimitExceededException=Solicitud no permitida (Límite de tasa excedido) problemDetail.title.run.halo.app.infra.exception.NotFoundException=Recurso no encontrado problemDetail.title.run.halo.app.infra.exception.EmailVerificationFailed=Fallo en la verificación del correo electrónico problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$CyclicException=Dependencia cíclica detectada problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$NotFoundException=Dependencias no encontradas problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=Versión de dependencia incorrecta problemDetail.title.run.halo.app.infra.exception.PluginDependentsNotDisabledException=Dependientes no desactivados problemDetail.title.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=Dependencias no activadas problemDetail.title.run.halo.app.infra.exception.OAuth2UserAlreadyBoundException=Error: Usuario ya vinculado problemDetail.title.run.halo.app.infra.exception.PluginRuntimeIncompatibleException=Entorno de ejecución del complemento incompatible problemDetail.title.run.halo.app.infra.exception.RestrictedNameException=Nombre restringido problemDetail.title.internalServerError=Error interno del servidor problemDetail.title.conflict=Conflicto # Detail definitions problemDetail.org.springframework.web.server.UnsupportedMediaTypeStatusException=El tipo de contenido {0} no está soportado. Tipos de medios soportados: {1}. problemDetail.org.springframework.web.server.UnsupportedMediaTypeStatusException.parseError=No se pudo analizar el Content-Type. problemDetail.org.springframework.web.server.MissingRequestValueException=El {0} requerido ''{1}'' no está presente. problemDetail.org.springframework.web.server.UnsatisfiedRequestParameterException=No se cumplen las condiciones del parámetro "{0}" para los parámetros de la solicitud actual. problemDetail.org.springframework.web.bind.support.WebExchangeBindException=Contenido de la solicitud inválido. Errores globales: {0}. Errores de campo: {1}. problemDetail.org.springframework.web.server.NotAcceptableStatusException=Representaciones aceptables: {0}. problemDetail.org.springframework.web.server.NotAcceptableStatusException.parseError=No se pudo analizar el encabezado Accept. problemDetail.org.springframework.web.server.ServerErrorException={0}. problemDetail.org.springframework.security.authentication.BadCredentialsException=El nombre de usuario o la contraseña son incorrectos. problemDetail.org.springframework.web.server.MethodNotAllowedException=El método de solicitud {0} no está soportado. Métodos soportados: {1}. problemDetail.run.halo.app.extension.exception.SchemaViolationException={1} del esquema {0}. problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=El archivo {0} ya existe, por favor cámbiale el nombre e inténtalo de nuevo. problemDetail.run.halo.app.infra.exception.DuplicateNameException=Se ha detectado un nombre duplicado, por favor cámbiale el nombre e inténtalo de nuevo. problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=El complemento {0} ya existe. problemDetail.run.halo.app.infra.exception.RateLimitExceededException=Se ha excedido el límite de tasa de la API, por favor inténtalo de nuevo más tarde. problemDetail.run.halo.app.infra.exception.EmailVerificationFailed=Código de verificación de correo electrónico inválido. problemDetail.run.halo.app.infra.exception.PluginDependencyException$CyclicException=Se ha detectado una dependencia cíclica. problemDetail.run.halo.app.infra.exception.PluginDependencyException$NotFoundException=No se encontraron las dependencias "{0}". problemDetail.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=Las dependencias tienen una versión incorrecta: {0}. problemDetail.run.halo.app.infra.exception.PluginDependentsNotDisabledException=Los dependientes del complemento {0} no están totalmente desactivados, por favor desactívalos primero. problemDetail.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=Las dependencias del complemento {0} no están totalmente activadas, por favor actívalas primero. problemDetail.run.halo.app.infra.exception.OAuth2UserAlreadyBoundException=El usuario {0} ya ha sido vinculado a otro usuario de OAuth2, no se puede vincular automáticamente el usuario de OAuth2 actual. problemDetail.run.halo.app.infra.exception.PluginRuntimeIncompatibleException=El complemento es incompatible con el entorno de ejecución de Halo actual, por favor actualiza el complemento o degrada el entorno de Halo. problemDetail.index.duplicateKey=El valor de {0} ya existe para el índice único {1}, por favor cámbiale el nombre e inténtalo de nuevo. problemDetail.user.email.verify.maxAttempts=Demasiados intentos de verificación, por favor inténtalo de nuevo más tarde. problemDetail.user.email.verify.emailInUse=El correo electrónico ya está en uso, por favor cámbialo e inténtalo de nuevo. problemDetail.user.password.unsatisfied=La contraseña no cumple con las especificaciones. problemDetail.user.username.unsatisfied=El nombre de usuario no cumple con las especificaciones. problemDetail.user.oldPassword.notMatch=La contraseña antigua no coincide. problemDetail.user.password.notMatch=La contraseña no coincide. problemDetail.user.signUpFailed.disallowed=El sistema no permite que nuevos usuarios se registren. problemDetail.user.duplicateName=El nombre de usuario {0} ya existe, por favor cámbiale el nombre e inténtalo de nuevo. problemDetail.user.displayName.restricted=El nombre para mostrar {0} es un nombre reservado, por favor cámbialo e inténtalo de nuevo. problemDetail.comment.turnedOff=La función de comentarios ha sido desactivada. problemDetail.comment.systemUsersOnly=Solo se permite comentar a los usuarios del sistema. problemDetail.theme.upgrade.missingManifest=Falta el archivo de manifiesto del tema "theme.yaml" o "theme.yml". problemDetail.theme.upgrade.nameMismatch=El nombre del tema actual {0} no coincide con el nombre del tema instalado. problemDetail.theme.install.missingManifest=Falta el archivo de manifiesto del tema "theme.yaml" o el archivo no cumple con las especificaciones del tema. problemDetail.theme.install.alreadyExists=El tema {0} ya existe. problemDetail.theme.version.unsatisfied.requires=El tema requiere una versión mínima del sistema de {0}, pero la versión actual es {1}. problemDetail.directoryTraversal=Travesía de directorios detectada. La ruta base es {0}, pero la ruta real es {1}. problemDetail.plugin.version.unsatisfied.requires=El complemento requiere una versión mínima del sistema de {0}, pero la versión actual es {1}. problemDetail.plugin.missingManifest=Falta el archivo de manifiesto del complemento "plugin.yaml" o el archivo no cumple con las especificaciones. problemDetail.internalServerError=Algo salió mal, por favor inténtalo de nuevo más tarde. problemDetail.conflict=Conflicto detectado, por favor revisa los datos e inténtalo de nuevo. problemDetail.migration.backup.notFound=El archivo de respaldo no existe o ha sido eliminado. problemDetail.attachment.upload.fileSizeExceeded=Asegúrate de que el tamaño del archivo sea inferior a {0}. problemDetail.attachment.upload.fileTypeNotSupported=Carga no soportada para archivos de tipo {0}. problemDetail.attachment.upload.fileTypeNotMatch=El tipo de archivo {0} no coincide con la extensión del archivo y la carga ha sido rechazada. problemDetail.comment.waitingForApproval=El comentario está esperando aprobación. title.visibility.identification.private=(Privado) signup.error.confirm-password-not-match=La contraseña de confirmación no coincide con la contraseña. signup.error.email-code.invalid=Código de correo electrónico inválido. signup.error.email.already-taken=La dirección de correo electrónico ya está en uso. validation.error.email.pattern=El formato del correo electrónico es incorrecto validation.error.username.pattern=El nombre de usuario solo puede estar en minúsculas y solo puede contener letras, números, guiones y puntos, comenzando y terminando con caracteres. validation.error.password.pattern=La contraseña solo puede usar letras mayúsculas y minúsculas (A-Z, a-z), números (0-9) y los siguientes caracteres especiales: !@#$%^&* validation.error.password.size=La longitud de la contraseña debe estar entre {0} y {1} ================================================ FILE: application/src/main/resources/config/i18n/messages_zh.properties ================================================ problemDetail.title.org.springframework.web.server.ServerWebInputException=请求参数有误 problemDetail.title.org.springframework.security.authentication.BadCredentialsException=无效凭据 problemDetail.title.run.halo.app.infra.exception.UnsatisfiedAttributeValueException=请求参数属性值不满足要求 problemDetail.title.run.halo.app.infra.exception.PluginInstallationException=插件安装失败 problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=附件已存在 problemDetail.title.run.halo.app.infra.exception.FileTypeNotAllowedException=文件类型不允许 problemDetail.title.run.halo.app.infra.exception.FileSizeExceededException=文件大小超出限制 problemDetail.title.run.halo.app.infra.exception.RequestRestrictedException=请求受限 problemDetail.title.run.halo.app.infra.exception.DuplicateNameException=名称重复 problemDetail.title.run.halo.app.infra.exception.PluginAlreadyExistsException=插件已存在 problemDetail.title.run.halo.app.infra.exception.ThemeInstallationException=主题安装失败 problemDetail.title.run.halo.app.infra.exception.ThemeAlreadyExistsException=主题已存在 problemDetail.title.run.halo.app.infra.exception.RateLimitExceededException=请求限制 problemDetail.title.run.halo.app.infra.exception.NotFoundException=资源不存在 problemDetail.title.run.halo.app.infra.exception.EmailVerificationFailed=邮箱验证失败 problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$CyclicException=循环依赖 problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$NotFoundException=依赖未找到 problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=依赖版本错误 problemDetail.title.run.halo.app.infra.exception.PluginDependentsNotDisabledException=子插件未禁用 problemDetail.title.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=依赖未启用 problemDetail.title.run.halo.app.infra.exception.OAuth2UserAlreadyBoundException=用户已绑定错误 problemDetail.title.run.halo.app.infra.exception.PluginRuntimeIncompatibleException=插件运行时不兼容 problemDetail.title.run.halo.app.infra.exception.RestrictedNameException=名称受限 problemDetail.title.internalServerError=服务器内部错误 problemDetail.title.conflict=冲突 problemDetail.org.springframework.security.authentication.BadCredentialsException=用户名或密码错误。 problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=文件 {0} 已存在,建议更名后重试。 problemDetail.run.halo.app.infra.exception.DuplicateNameException=检测到有重复的名称,请重命名后重试。 problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=插件 {0} 已经存在。 problemDetail.run.halo.app.infra.exception.RateLimitExceededException=请求过于频繁,请稍候再试。 problemDetail.run.halo.app.infra.exception.EmailVerificationFailed=验证码错误或已失效。 problemDetail.run.halo.app.infra.exception.PluginDependencyException$CyclicException=检测到循环依赖。 problemDetail.run.halo.app.infra.exception.PluginDependencyException$NotFoundException=依赖“{0}”未找到。 problemDetail.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=依赖版本有误:{0}。 problemDetail.run.halo.app.infra.exception.PluginDependentsNotDisabledException=子插件 {0} 未完全禁用,请先禁用它们。 problemDetail.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=插件依赖 {0} 未完全启用,请先启用它们。 problemDetail.run.halo.app.infra.exception.OAuth2UserAlreadyBoundException=用户 {0} 已经绑定到另一个 OAuth2 用户,无法自动绑定当前 OAuth2 用户。 problemDetail.run.halo.app.infra.exception.PluginRuntimeIncompatibleException=插件和当前 Halo 运行时不兼容,请升级插件或降级 Halo 运行时。 problemDetail.index.duplicateKey=唯一索引 {1} 中的值 {0} 已存在,请更名后重试。 problemDetail.user.email.verify.maxAttempts=尝试次数过多,请稍候再试。 problemDetail.user.email.verify.emailInUse=邮箱已被使用, 请更换邮箱后重试。 problemDetail.user.password.unsatisfied=密码不符合规范。 problemDetail.user.username.unsatisfied=用户名不符合规范。 problemDetail.user.oldPassword.notMatch=旧密码不匹配。 problemDetail.user.password.notMatch=密码不匹配。 problemDetail.user.signUpFailed.disallowed=系统不允许注册新用户。 problemDetail.user.duplicateName=用户名 {0} 已存在,请更换用户名后重试。 problemDetail.user.displayName.restricted=显示名称 {0} 为保留名称,请更换后重试。 problemDetail.plugin.version.unsatisfied.requires=插件要求一个最小的系统版本为 {0}, 但当前版本为 {1}。 problemDetail.plugin.missingManifest=缺少 plugin.yaml 配置文件或配置文件不符合规范。 problemDetail.theme.version.unsatisfied.requires=主题要求一个最小的系统版本为 {0}, 但当前版本为 {1}。 problemDetail.theme.install.missingManifest=缺少 theme.yaml 配置文件或配置文件不符合规范。 problemDetail.theme.install.alreadyExists=主题 {0} 已存在。 problemDetail.internalServerError=服务器内部发生错误,请稍候再试。 problemDetail.conflict=检测到冲突,请检查数据后重试。 problemDetail.migration.backup.notFound=备份文件不存在或已删除。 problemDetail.attachment.upload.fileSizeExceeded=最大支持上传 {0} 大小的文件。 problemDetail.attachment.upload.fileTypeNotSupported=不支持上传 {0} 类型的文件。 problemDetail.attachment.upload.fileTypeNotMatch=文件类型 {0} 与文件扩展名不匹配,上传被拒绝。 problemDetail.comment.waitingForApproval=评论审核中。 title.visibility.identification.private=(私有) signup.error.confirm-password-not-match=确认密码与密码不匹配。 signup.error.email-code.invalid=邮箱验证码无效。 signup.error.email.already-taken=邮箱地址已被注册。 validation.error.email.pattern=邮箱格式不正确 validation.error.username.pattern=用户名只能小写且只能包含字母、数字、中划线和点,以字符开头和结尾 validation.error.password.pattern=密码只能使用大小写字母 (A-Z, a-z)、数字 (0-9),以及以下特殊字符: !@#$%^&*.? validation.error.password.size=密码长度必须在 {0} 到 {1} 之间 ================================================ FILE: application/src/main/resources/db/migration/h2/.gitkeep ================================================ ================================================ FILE: application/src/main/resources/db/migration/mariadb/.gitkeep ================================================ ================================================ FILE: application/src/main/resources/db/migration/mysql/.gitkeep ================================================ ================================================ FILE: application/src/main/resources/db/migration/postgresql/.gitkeep ================================================ ================================================ FILE: application/src/main/resources/extensions/attachment-local-policy.yaml ================================================ apiVersion: storage.halo.run/v1alpha1 kind: PolicyTemplate metadata: name: local spec: displayName: 本地存储 settingName: local-policy-template-setting --- apiVersion: storage.halo.run/v1alpha1 kind: Policy metadata: name: default-policy finalizers: - system-protection spec: displayName: 本地存储 templateName: local configMapName: default-policy-config --- apiVersion: v1alpha1 kind: ConfigMap metadata: name: default-policy-config labels: storage.halo.run/policy-owner: default-policy halo.run/do-not-overwrite: "true" data: default: "{\"location\":\"\"}" --- apiVersion: v1alpha1 kind: Setting metadata: name: local-policy-template-setting spec: forms: - group: default label: Default formSchema: - $formkit: text name: location label: 存储位置 help: ~/.halo2/attachments/upload 下的子目录 - $formkit: text name: maxFileSize label: 最大单文件大小 validation: [ [ 'matches', '/^(0|[1-9]\d*)(?:[KMG]B)?$/' ] ] validation-visibility: "live" validation-messages: matches: "输入格式错误,遵循:整数 + 大写的单位(KB, MB, GB)" help: "0 表示不限制,示例:5KB、10MB、1GB" - $formkit: checkbox name: allowedFileTypes label: 文件类型限制 help: 限制允许上传的文件类型 options: - label: 无限制 value: ALL - label: 图片 value: IMAGE - label: SVG value: SVG - label: 视频 value: VIDEO - label: 音频 value: AUDIO - label: 文档 value: DOCUMENT - label: 压缩包 value: ARCHIVE - $formkit: checkbox name: alwaysRenameFilename id: alwaysRenameFilename label: 是否总是重命名文件名 help: 勾选后上传后的文件名将被重命名 value: false - $formkit: group if: $get(alwaysRenameFilename).value id: renameStrategy name: renameStrategy label: 重命名策略 value: method: RANDOM randomLength: 32 children: - $formkit: radio name: method label: 重命名方法 options: - label: 随机字符串 value: RANDOM - label: UUID value: UUID - label: 时间戳(毫秒级) value: TIMESTAMP - $formkit: number number: integer if: "$value.method === RANDOM" name: randomLength id: randomLength label: 随机文件名长度 help: 默认值为 32。因为文件名的长度限制,随机文件名的长度范围为 [8, 64]。 validation: "between:8,64" validation-visibility: live min: 8 max: 64 - $formkit: checkbox name: excludeOriginalFilename label: 是否排除原始文件名 help: 勾选后重命名后的文件名将不包含原始文件名 --- apiVersion: storage.halo.run/v1alpha1 kind: Group metadata: name: user-avatar-group labels: halo.run/hidden: "true" finalizers: - system-protection spec: displayName: UserAvatar ================================================ FILE: application/src/main/resources/extensions/authproviders.yaml ================================================ apiVersion: auth.halo.run/v1alpha1 kind: AuthProvider metadata: name: local labels: auth.halo.run/auth-binding: "false" auth.halo.run/privileged: "true" finalizers: - system-protection spec: displayName: Local description: Built-in authentication for Halo. logo: /images/login-methods/login-with-credentials.svg website: https://www.halo.run authenticationUrl: /login method: post rememberMeSupport: true authType: form ================================================ FILE: application/src/main/resources/extensions/extension-definitions.yaml ================================================ ## TODO: Currently, Halo does not support i18n for configuration file descriptions ## So Simplified Chinese is temporarily used as the default description language. apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionDefinition metadata: name: username-password-logout-handler labels: auth.halo.run/extension-point-name: "additional-webfilter" spec: className: run.halo.app.security.authentication.login.UsernamePasswordLogoutHandler extensionPointName: additional-webfilter displayName: "用户名密码注销处理器" description: "用于用户名和密码认证的注销处理器" --- apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionDefinition metadata: name: delegating-logout-page-generating-webfilter labels: auth.halo.run/extension-point-name: "additional-webfilter" spec: className: run.halo.app.security.authentication.login.DelegatingLogoutPageGeneratingWebFilter extensionPointName: additional-webfilter displayName: "注销页面生成过滤器" description: "用于生成默认的注销页面" --- apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionDefinition metadata: name: halo-email-notifier spec: className: run.halo.app.notification.EmailNotifier extensionPointName: reactive-notifier displayName: "邮件通知器" description: "支持通过电子邮件向用户发送通知" --- apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionDefinition metadata: name: search-engine-lucene spec: className: run.halo.app.search.lucene.LuceneSearchEngine extensionPointName: search-engine displayName: "Lucene 搜索引擎" description: "Halo 自带的本地搜索引擎" icon: /images/extension-points/lucene.png --- apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionDefinition metadata: name: post-content-thumbnail-handler spec: className: run.halo.app.content.PostContentThumbnailHandler extensionPointName: reactive-post-content-handler displayName: "文章内容缩略图处理" description: "处理文章的 HTML 内容为 img 标签追加缩略图" --- apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionDefinition metadata: name: page-content-thumbnail-handler spec: className: run.halo.app.content.PageContentThumbnailHandler extensionPointName: reactive-page-content-handler displayName: "自定义页面内容缩略图处理" description: "处理页面的 HTML 内容为 img 标签追加缩略图" ================================================ FILE: application/src/main/resources/extensions/extensionpoint-definitions.yaml ================================================ ## TODO: Currently, Halo does not support i18n for configuration file descriptions ## So Simplified Chinese is temporarily used as the default description language. apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionPointDefinition metadata: name: additional-webfilter spec: className: run.halo.app.security.AdditionalWebFilter displayName: "附加 Web 过滤器" type: MULTI_INSTANCE description: "用于 Web 请求的链式处理,可以用来实现跨领域、与应用无关的需求,如安全性、超时等" --- apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionPointDefinition metadata: name: reactive-post-content-handler spec: className: run.halo.app.theme.ReactivePostContentHandler displayName: "文章内容处理器" type: MULTI_INSTANCE description: "扩展在主题侧显示的文章内容" --- apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionPointDefinition metadata: name: reactive-singlepage-content-handler spec: className: run.halo.app.theme.ReactiveSinglePageContentHandler displayName: "页面内容处理器" type: MULTI_INSTANCE description: "扩展在主题侧显示的页面内容" --- apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionPointDefinition metadata: name: comment-widget spec: className: run.halo.app.theme.dialect.CommentWidget displayName: "评论组件" type: SINGLETON description: "扩展在文章页面中显示的评论组件" --- apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionPointDefinition metadata: name: username-password-authentication-manager spec: className: run.halo.app.security.authentication.login.UsernamePasswordAuthenticationManager displayName: "用户名密码认证管理器" type: SINGLETON description: "扩展用户名密码认证" --- apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionPointDefinition metadata: name: reactive-notifier spec: className: run.halo.app.notification.ReactiveNotifier displayName: "消息通知器" type: MULTI_INSTANCE description: "扩展消息通知器,以向用户发送通知" --- apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionPointDefinition metadata: name: search-engine spec: className: run.halo.app.search.SearchEngine displayName: "搜索引擎" type: SINGLETON description: "扩展内容搜索引擎" --- apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionPointDefinition metadata: name: template-footer-processor spec: className: run.halo.app.theme.dialect.TemplateFooterProcessor displayName: 页脚标签内容处理器 type: MULTI_INSTANCE description: "提供用于扩展 标签内容的扩展方式。" --- apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionPointDefinition metadata: name: excerpt-generator spec: className: run.halo.app.content.ExcerptGenerator displayName: 摘要生成器 type: SINGLETON description: "提供自动生成摘要的方式扩展,如使用算法提取或使用 AI 生成。" --- apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionPointDefinition metadata: name: thumbnail-provider spec: className: run.halo.app.core.attachment.ThumbnailProvider displayName: 图片缩略图生成 type: MULTI_INSTANCE description: "提供生成图片缩略图的扩展方式" ================================================ FILE: application/src/main/resources/extensions/notification-templates.yaml ================================================ apiVersion: notification.halo.run/v1alpha1 kind: NotificationTemplate metadata: name: template-new-comment-on-post spec: reasonSelector: reasonType: new-comment-on-post language: default template: title: "[(${commenter})] 评论了你的文章《[(${postTitle})]》" rawBody: | [(${subscriber.displayName})] 你好: [(${commenter})] 评论了你的文章 《[(${postTitle})]》,以下是评论的具体内容: [(${content})] htmlBody: |

评论了你的文章 ,以下是评论的具体内容:

--- apiVersion: notification.halo.run/v1alpha1 kind: NotificationTemplate metadata: name: template-new-comment-on-single-page spec: reasonSelector: reasonType: new-comment-on-single-page language: default template: title: "[(${commenter})] 评论了你的页面《[(${pageTitle})]》" rawBody: | [(${subscriber.displayName})] 你好: [(${commenter})] 评论了你的页面 《[(${pageTitle})]》,以下是评论的具体内容: [(${content})] htmlBody: |

评论了你的页面 ,以下是评论的具体内容:

--- apiVersion: notification.halo.run/v1alpha1 kind: NotificationTemplate metadata: name: template-someone-replied-to-you spec: reasonSelector: reasonType: someone-replied-to-you language: default template: title: "[(${replier})] 在评论中回复了你" rawBody: | [(${subscriber.displayName})] 你好: [(${replier})] 在评论“[(${isQuoteReply ? quoteContent : commentContent})]”中回复了你,以下是回复的具体内容: [(${content})] htmlBody: |

中回复了你。

你的评论:

回复的内容:

--- apiVersion: notification.halo.run/v1alpha1 kind: NotificationTemplate metadata: name: template-email-verification spec: reasonSelector: reasonType: email-verification language: default template: title: "邮箱验证-[(${site.title})]" rawBody: | 【[(${site.title})]】你的邮箱验证码是:[(${code})],请在 [(${expirationAtMinutes})] 分钟内完成验证。 htmlBody: |

使用下面的动态验证码(OTP)验证您的电子邮件地址。

如果您没有尝试验证您的电子邮件地址,请忽略此电子邮件。

--- apiVersion: notification.halo.run/v1alpha1 kind: NotificationTemplate metadata: name: template-reset-password-by-email spec: reasonSelector: reasonType: reset-password-by-email language: default template: title: "重置密码-[(${site.title})]" rawBody: | 【[(${site.title})]】你已经请求了重置密码,可以链接来重置密码:[(${link})],请在 [(${expirationAtMinutes})] 分钟内完成重置。 htmlBody: |

你已经请求了重置密码,可以点击下面的链接来重置密码:

如果您没有请求重置密码,请忽略此电子邮件。

--- apiVersion: notification.halo.run/v1alpha1 kind: NotificationTemplate metadata: name: template-new-device-login spec: reasonSelector: reasonType: new-device-login language: default template: title: "你的 [(${site.title})] 账号被用于在 [(${os})] 上登录" rawBody: | [(${subscriber.displayName})] 你好: 你的 [(${site.title})] 账号被用于在 [(${os})] 的 [(${browser})] 上登录。 时间:[(${loginTime})] IP 地址:[(${ipAddress})] 如果你知悉上述信息,请忽略此电子邮件。 如果你最近没有使用你的 Halo 账号登录并相信有人可能访问了你的账户,请尽快重设你的密码。 htmlBody: |

如果你知悉上述信息,请忽略此电子邮件。

================================================ FILE: application/src/main/resources/extensions/notification.yaml ================================================ apiVersion: notification.halo.run/v1alpha1 kind: NotifierDescriptor metadata: name: default-email-notifier spec: displayName: '邮件通知' description: '通过邮件将通知发送给用户' notifierExtName: 'halo-email-notifier' senderSettingRef: name: 'notifier-setting-for-email' group: 'sender' --- apiVersion: v1alpha1 kind: Setting metadata: name: notifier-setting-for-email spec: forms: - group: sender label: 发件设置 formSchema: - $formkit: checkbox label: "启用邮件通知器" value: false name: enable - $formkit: verificationForm if: "$enable" action: /apis/console.api.notification.halo.run/v1alpha1/notifiers/default-email-notifier/verify-connection label: 测试邮箱 children: - $formkit: text label: "用户名" name: username validation: required - $formkit: text if: "$enable" label: "发信地址" name: "sender" help: "如果用户名为实际发信地址,可忽略" - $formkit: password label: "密码" name: password validation: required - $formkit: text label: "显示名称" name: displayName - $formkit: text label: "SMTP 服务器地址" name: host validation: required - $formkit: text label: "端口号" name: port validation: required - $formkit: select label: "加密方式" name: encryption value: "SSL" options: - label: "SSL" value: "SSL" - label: "TLS" value: "TLS" - label: "不加密" value: "NONE" --- apiVersion: notification.halo.run/v1alpha1 kind: ReasonType metadata: name: new-comment-on-post annotations: rbac.authorization.halo.run/ui-permissions: | [ "uc:posts:publish" ] spec: displayName: "我的文章收到新评论" description: "如果有读者在你的文章下方留下了新的评论,你将会收到一条通知,告诉你有新的评论。 这个通知事件可以帮助你及时了解读者对你的文章的反馈,以便你更好地与读者互动,提高文章的质量和受欢迎程度。" properties: - name: postName type: string description: "The name of the post." - name: postOwner type: string description: "The user name of the post owner." - name: postTitle type: string - name: postUrl type: string - name: commenter type: string description: "The display name of the commenter." - name: commentName type: string description: "The name of the comment." - name: content type: string description: "The content of the comment." --- apiVersion: notification.halo.run/v1alpha1 kind: ReasonType metadata: name: new-comment-on-single-page annotations: rbac.authorization.halo.run/ui-permissions: | [ "system:singlepages:manage" ] spec: displayName: "我的自定义页面收到新评论" description: "当你创建的自定义页面收到新评论时,你将会收到一条通知,告诉你有新的评论。" properties: - name: pageName type: string description: "The name of the single page." - name: pageOwner type: string description: "The user name of the page owner." - name: pageTitle type: string - name: pageUrl type: string - name: commenter type: string description: "The display name of the commenter." - name: commentName type: string description: "The name of the comment." - name: content type: string description: "The content of the comment." --- apiVersion: notification.halo.run/v1alpha1 kind: ReasonType metadata: name: someone-replied-to-you spec: displayName: "有人回复了我" description: "如果有其他用户回复了你的评论,你将会收到一条通知,告诉你有人回复了你。" properties: - name: commentName type: string description: "The name of the comment." - name: commentSubjectTitle type: string - name: commentSubjectUrl type: string - name: quoteContent type: string optional: true description: "The content of quoted reply." - name: isQuoteReply type: boolean - name: commentContent type: string - name: repliedOwner type: string description: "The owner of the comment or reply that has been replied to." - name: replyOwner type: string description: "The user who created the current reply." - name: replier type: string description: "The display name of the replier." - name: replyName type: string description: "The name of the reply." - name: content type: string description: "The content of the reply." --- apiVersion: notification.halo.run/v1alpha1 kind: ReasonType metadata: name: email-verification labels: halo.run/hidden: "true" spec: displayName: "邮箱验证" description: "当你的邮箱被用于注册账户时,会收到一条带有验证码的邮件,你需要点击邮件中的链接来验证邮箱是否属于你。" properties: - name: username type: string description: "The username of the user." - name: code type: string description: "The verification code." - name: expirationAtMinutes type: string description: "The expiration minutes of the verification code, such as 5 minutes." --- apiVersion: notification.halo.run/v1alpha1 kind: ReasonType metadata: name: reset-password-by-email labels: halo.run/hidden: "true" spec: displayName: "根据邮件地址重置密码" description: "当你通过邮件地址找回密码时,会收到一条带密码重置链接的邮件,你需要点击邮件中的链接来重置密码。" properties: - name: username type: string description: "The username of the user." - name: link type: string description: "The reset link." - name: expirationAtMinutes type: string description: "The expiration minutes of the reset link, such as 30 minutes." --- apiVersion: notification.halo.run/v1alpha1 kind: ReasonType metadata: name: new-device-login spec: displayName: "新设备登录" description: "当你的账户在新设备上登录时,你会收到一条通知,告诉你有新设备登录了你的账户。" properties: - name: os type: string description: "The operating system of the device." - name: browser type: string description: "The browser of the device." - name: ipAddress type: string description: "The IP address of the device." - name: loginTime type: string description: "The login time of the device." - name: principalName type: string description: "The principal name of the device." ================================================ FILE: application/src/main/resources/extensions/role-template-actuator.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-actuator labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Actuator Management" rbac.authorization.halo.run/display-name: "Actuator Manage" rbac.authorization.halo.run/ui-permissions: | ["system:actuator:manage"] rules: - nonResourceURLs: [ "actuator", "/actuator/*" ] verbs: [ "get" ] ================================================ FILE: application/src/main/resources/extensions/role-template-anonymous.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: anonymous labels: halo.run/role-template: "true" halo.run/hidden: "true" annotations: rbac.authorization.halo.run/dependencies: | [ "role-template-own-permissions", "role-template-public-apis" ] rules: - apiGroups: [ "api.halo.run" ] resources: [ "comments", "comments/reply" ] verbs: [ "create", "get", "list" ] - apiGroups: [ "api.halo.run" ] resources: [ "*" ] verbs: [ "*" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "users" ] resourceNames: [ "-" ] verbs: [ "get" ] - nonResourceURLs: [ "/apis/api.halo.run/v1alpha1/trackers/*" ] verbs: [ "create" ] - nonResourceURLs: [ "/actuator/globalinfo", "/actuator/health", "/actuator/health/*", "/login/public-key" ] verbs: [ "get" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-public-apis labels: halo.run/role-template: "true" halo.run/hidden: "true" rules: - apiGroups: [ "api.halo.run" ] resources: [ "*" ] verbs: [ "get", "list" ] - apiGroups: [ "api.content.halo.run" ] resources: [ "*" ] verbs: [ "get", "list" ] - apiGroups: [ "api.plugin.halo.run" ] resources: [ "*" ] verbs: [ "get", "list" ] - apiGroups: [ "api.notification.halo.run" ] resources: [ "subscriptions/unsubscribe" ] verbs: [ "get", "list" ] - apiGroups: [ "api.storage.halo.run" ] resources: [ "thumbnails/via-uri" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-attachment.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-attachments labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/dependencies: "[ \"role-template-view-attachments\" ]" rbac.authorization.halo.run/module: "Attachments Management" rbac.authorization.halo.run/display-name: "Attachment Manage" rbac.authorization.halo.run/ui-permissions: | ["system:attachments:manage"] rules: - apiGroups: [ "storage.halo.run" ] resources: [ "attachments", "policies", "policytemplates", "groups" ] verbs: [ "*" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "attachments" ] verbs: [ "*" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "attachments/upload-from-url" ] verbs: [ "create" ] - apiGroups: [ "" ] resources: [ "settings" ] verbs: [ "get" ] - nonResourceURLs: [ "/apis/api.console.halo.run/v1alpha1/attachments/upload" ] verbs: [ "create" ] - apiGroups: [ "console.api.storage.halo.run" ] resources: [ "attachments/upload" ] verbs: [ "create" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-attachments labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Attachments Management" rbac.authorization.halo.run/display-name: "Attachment View" rbac.authorization.halo.run/ui-permissions: | ["system:attachments:view"] rules: - apiGroups: [ "storage.halo.run" ] resources: [ "attachments", "policies", "policytemplates", "groups" ] verbs: [ "get", "list" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "attachments" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-authenticated.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: authenticated labels: halo.run/role-template: "true" halo.run/hidden: "true" annotations: rbac.authorization.halo.run/dependencies: | [ "role-template-own-user-info", "role-template-own-permissions", "role-template-change-own-password", "role-template-stats", "role-template-annotation-setting", "role-template-manage-own-pat", "role-template-manage-own-authentications", "role-template-user-notification" ] rules: - apiGroups: [ "" ] resources: [ "configmaps" ] resourceNames: [ "system-states" ] verbs: [ "get" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "auth-providers" ] verbs: [ "list" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "plugins/bundle.js", "plugins/bundle.css" ] resourceNames: [ "-" ] verbs: [ "get" ] - apiGroups: [ "uc.api.auth.halo.run" ] resources: [ "user-connections/disconnect" ] verbs: [ "update" ] - apiGroups: [ "uc.api.halo.run" ] resources: [ "user-preferences", "annotationsettings" ] verbs: [ "get", "list", "update" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-own-user-info labels: halo.run/role-template: "true" halo.run/hidden: "true" rules: - apiGroups: [ "api.console.halo.run" ] resources: [ "users" ] resourceNames: [ "-" ] verbs: [ "get", "update" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "users/avatar" ] resourceNames: [ "-" ] verbs: [ "create", "delete" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "users/send-email-verification-code", "users/verify-email" ] resourceNames: [ "-" ] verbs: [ "create" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-own-permissions labels: halo.run/role-template: "true" halo.run/hidden: "true" rules: - apiGroups: [ "api.console.halo.run" ] resources: [ "users/permissions" ] resourceNames: [ "-" ] verbs: [ "list", "get" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-change-own-password labels: halo.run/role-template: "true" halo.run/hidden: "true" rules: - apiGroups: [ "api.console.halo.run" ] resources: [ "users/password" ] resourceNames: [ "-" ] verbs: [ "update" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-stats labels: halo.run/role-template: "true" halo.run/hidden: "true" rules: - apiGroups: [ "api.console.halo.run" ] resources: [ "stats" ] verbs: [ "get", "list" ] --- apiVersion: v1alpha1 kind: Role metadata: name: role-template-annotation-setting labels: halo.run/role-template: "true" halo.run/hidden: "true" rules: - apiGroups: [ "" ] resources: [ "annotationsettings" ] verbs: [ "get", "list" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-own-pat labels: halo.run/role-template: "true" halo.run/hidden: "true" rules: - apiGroups: [ "uc.api.security.halo.run" ] resources: [ "personalaccesstokens" ] verbs: [ "*" ] - apiGroups: [ "uc.api.security.halo.run" ] resources: [ "personalaccesstokens/actions" ] verbs: [ "update" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-own-authentications labels: halo.run/role-template: "true" halo.run/hidden: "true" rules: - apiGroups: [ "uc.api.security.halo.run" ] resources: [ "authentications", "authentications/totp", "authentications/settings" ] verbs: [ "*" ] - apiGroups: [ "uc.api.security.halo.run" ] resources: [ "devices" ] verbs: [ "get", "list", "delete" ] --- apiVersion: v1alpha1 kind: Role metadata: name: role-template-user-notification labels: halo.run/role-template: "true" halo.run/hidden: "true" rules: - apiGroups: [ "api.notification.halo.run" ] resources: [ "notifications" ] verbs: [ "get", "list", "delete" ] - apiGroups: [ "api.notification.halo.run" ] resources: [ "notifications/mark-as-read", "notifications/mark-specified-as-read" ] verbs: [ "update" ] - apiGroups: [ "api.notification.halo.run" ] resources: [ "notifiers/receiver-config" ] verbs: [ "get", "update" ] - apiGroups: [ "api.notification.halo.run" ] resources: [ "notification-preferences" ] verbs: [ "create", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-cache.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-cache deletionTimestamp: 2024-06-01T00:00:00Z labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Cache Management" rbac.authorization.halo.run/display-name: "Cache Manage" rbac.authorization.halo.run/ui-permissions: | ["system:caches:manage"] rules: - apiGroups: ["api.console.halo.run"] resources: ["caches"] verbs: ["delete"] ================================================ FILE: application/src/main/resources/extensions/role-template-category.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-categories labels: halo.run/role-template: "true" halo.run/hidden: "true" annotations: rbac.authorization.halo.run/dependencies: "[ \"role-template-view-categories\" ]" rbac.authorization.halo.run/ui-permissions: | [ "system:categories:manage", "uc:categories:manage" ] rules: - apiGroups: [ "content.halo.run" ] resources: [ "categories" ] verbs: [ "*" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-categories labels: halo.run/role-template: "true" halo.run/hidden: "true" annotations: rbac.authorization.halo.run/ui-permissions: | [ "system:categories:view", "uc:categories:view" ] rules: - apiGroups: [ "content.halo.run" ] resources: [ "categories" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-comment.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-comments labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/dependencies: "[ \"role-template-view-comments\" ]" rbac.authorization.halo.run/module: "Comments Management" rbac.authorization.halo.run/display-name: "Comment Manage" rbac.authorization.halo.run/ui-permissions: | ["system:comments:manage"] rules: - apiGroups: [ "content.halo.run" ] resources: [ "comments", "replies" ] verbs: [ "*" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "comments", "comments/reply", "replies" ] verbs: [ "*" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-comments labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Comments Management" rbac.authorization.halo.run/display-name: "Comment View" rbac.authorization.halo.run/ui-permissions: | ["system:comments:view"] rules: - apiGroups: [ "content.halo.run" ] resources: [ "comments", "replies" ] verbs: [ "get", "list" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "comments", "comments/reply", "replies" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-configmap.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-configmaps labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/dependencies: "[ \"role-template-view-configmaps\" ]" rbac.authorization.halo.run/module: "ConfigMaps Management" rbac.authorization.halo.run/display-name: "ConfigMap Manage" rbac.authorization.halo.run/ui-permissions: | ["system:configmaps:manage"] rules: - apiGroups: [ "" ] resources: [ "configmaps" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-configmaps labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "ConfigMaps Management" rbac.authorization.halo.run/display-name: "ConfigMap View" rbac.authorization.halo.run/ui-permissions: | ["system:configmaps:view"] rules: - apiGroups: [ "" ] resources: [ "configmaps" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-menu.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-menus labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/dependencies: "[ \"role-template-view-menus\" ]" rbac.authorization.halo.run/module: "Menus Management" rbac.authorization.halo.run/display-name: "Menu Manage" rbac.authorization.halo.run/ui-permissions: | ["system:menus:manage"] rules: - apiGroups: [ "" ] resources: [ "menus", "menuitems" ] verbs: [ "*" ] - apiGroups: [ "console.api.halo.run" ] resources: [ "systemconfigs" ] resourceNames: [ "menu" ] verbs: [ "update" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-menus labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Menus Management" rbac.authorization.halo.run/display-name: "Menu View" rbac.authorization.halo.run/ui-permissions: | ["system:menus:view"] rules: - apiGroups: [ "" ] resources: [ "menus", "menuitems" ] verbs: [ "get", "list" ] - apiGroups: [ "console.api.halo.run" ] resources: [ "systemconfigs" ] resourceNames: [ "menu" ] verbs: [ "get" ] ================================================ FILE: application/src/main/resources/extensions/role-template-migration.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-migration labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Migration Management" rbac.authorization.halo.run/display-name: "Migration Manage" rbac.authorization.halo.run/ui-permissions: | ["system:migrations:manage"] rules: - apiGroups: [ "console.api.migration.halo.run" ] resources: [ "restorations" ] verbs: [ "create" ] - apiGroups: [ "console.api.migration.halo.run" ] resources: [ "backup-files" ] verbs: [ "list" ] - apiGroups: [ "console.api.migration.halo.run" ] resources: [ "backups/files" ] verbs: [ "get" ] - apiGroups: [ "migration.halo.run" ] resources: [ "backups" ] verbs: [ "list", "get", "create", "update", "delete", "patch" ] ================================================ FILE: application/src/main/resources/extensions/role-template-notification.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-notifier-config labels: halo.run/role-template: "true" halo.run/hidden: "true" annotations: rbac.authorization.halo.run/module: "Notification Configuration" rbac.authorization.halo.run/display-name: "Configure Notifier" rules: - apiGroups: [ "notification.halo.run" ] resources: [ "notifierDescriptors" ] verbs: [ "*" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "notifiers/sender-config" ] verbs: [ "get", "update" ] - apiGroups: [ "console.api.notification.halo.run" ] resources: [ "notifiers/verify-connection" ] resourceNames: [ "default-email-notifier" ] verbs: [ "create" ] ================================================ FILE: application/src/main/resources/extensions/role-template-permissions.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-permissions labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/dependencies: "[ \"role-template-view-permissions\" ]" rbac.authorization.halo.run/module: "Permissions Management" rbac.authorization.halo.run/display-name: "Permissions Manage" rbac.authorization.halo.run/ui-permissions: | ["system:permissions:manage"] rules: - apiGroups: [ "api.console.halo.run" ] resources: [ "users/permissions" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-permissions labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Permissions Management" rbac.authorization.halo.run/display-name: "Permissions View" rbac.authorization.halo.run/ui-permissions: | ["system:permissions:view"] rules: - apiGroups: [ "api.console.halo.run" ] resources: [ "users/permissions" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-plugin.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-plugins labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/dependencies: | [ "role-template-view-plugins" ] rbac.authorization.halo.run/module: "Plugins Management" rbac.authorization.halo.run/display-name: "Plugin Manage" rbac.authorization.halo.run/ui-permissions: | ["system:plugins:manage"] rules: - apiGroups: [ "plugin.halo.run" ] resources: [ "plugins" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "plugins/upgrade", "plugins/resetconfig", "plugins/config", "plugins/json-config", "plugins/reload", "plugins/install-from-uri", "plugins/upgrade-from-uri", "plugins/plugin-state" ] verbs: [ "*" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "plugin-presets" ] verbs: [ "list" ] - nonResourceURLs: [ "/apis/api.console.halo.run/v1alpha1/plugins/*" ] verbs: [ "create" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-plugins labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Plugins Management" rbac.authorization.halo.run/display-name: "Plugin View" rbac.authorization.halo.run/ui-permissions: | ["system:plugins:view"] rules: - apiGroups: [ "plugin.halo.run" ] resources: [ "plugins" ] verbs: [ "get", "list" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "plugins", "plugins/setting", "plugins/config", "plugins/json-config" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-post.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-posts labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/dependencies: | [ "role-template-view-posts", "role-template-manage-snapshots", "role-template-manage-tags", "role-template-manage-categories", "role-template-post-author" ] rbac.authorization.halo.run/module: "Posts Management" rbac.authorization.halo.run/display-name: "Post Manage" rbac.authorization.halo.run/ui-permissions: | ["system:posts:manage"] rules: - apiGroups: [ "content.halo.run" ] resources: [ "posts" ] verbs: [ "*" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "posts", "posts/publish", "posts/unpublish", "posts/recycle", "posts/content", "indices/post", "posts/revert-content" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-posts labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/dependencies: | [ "role-template-view-snapshots", "role-template-view-tags", "role-template-view-categories" ] rbac.authorization.halo.run/module: "Posts Management" rbac.authorization.halo.run/display-name: "Post View" rbac.authorization.halo.run/ui-permissions: | ["system:posts:view"] rules: - apiGroups: [ "content.halo.run" ] resources: [ "posts" ] verbs: [ "get", "list" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "posts", "posts/head-content", "posts/release-content", "posts/snapshot", "posts/content" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-role.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-roles labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/dependencies: | [ "role-template-view-roles", "role-template-manage-permissions" ] rbac.authorization.halo.run/module: "Roles Management" rbac.authorization.halo.run/display-name: "Role Manage" rbac.authorization.halo.run/ui-permissions: | ["system:roles:manage"] rules: - apiGroups: [ "" ] resources: [ "roles" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-roles labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Roles Management" rbac.authorization.halo.run/display-name: "Role View" rbac.authorization.halo.run/ui-permissions: | ["system:roles:view"] rules: - apiGroups: [ "" ] resources: [ "roles" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-setting.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-settings labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/dependencies: "[ \"role-template-view-settings\", \"role-template-notifier-config\" ]" rbac.authorization.halo.run/module: "Settings Management" rbac.authorization.halo.run/display-name: "Setting Manage" rbac.authorization.halo.run/ui-permissions: | ["system:settings:manage", "system:notifier:configuration"] rules: - apiGroups: [ "" ] resources: [ "settings" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "auth-providers/enable", "auth-providers/disable" ] verbs: [ "update" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-settings labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Settings Management" rbac.authorization.halo.run/display-name: "Setting View" rbac.authorization.halo.run/ui-permissions: | ["system:settings:view"] rules: - apiGroups: [ "" ] resources: [ "settings" ] verbs: [ "get", "list" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "auth-providers" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-singlepage.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-singlepages labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/dependencies: "[ \"role-template-view-singlepages\", \"role-template-manage-snapshots\" ]" rbac.authorization.halo.run/module: "SinglePages Management" rbac.authorization.halo.run/display-name: "SinglePage Manage" rbac.authorization.halo.run/ui-permissions: | ["system:singlepages:manage"] rules: - apiGroups: [ "content.halo.run" ] resources: [ "singlepages" ] verbs: [ "*" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "singlepages", "singlepages/publish", "singlepages/content", "singlepages/revert-content" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-singlepages labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/dependencies: "[ \"role-template-view-snapshots\" ]" rbac.authorization.halo.run/module: "SinglePages Management" rbac.authorization.halo.run/display-name: "SinglePage View" rbac.authorization.halo.run/ui-permissions: | ["system:singlepages:view"] rules: - apiGroups: [ "content.halo.run" ] resources: [ "singlepages" ] verbs: [ "get", "list" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "singlepages", "singlepages/head-content", "singlepages/release-content", "singlepages/snapshot", "singlepages/content" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-snapshot.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-snapshots labels: halo.run/role-template: "true" halo.run/hidden: "true" annotations: rbac.authorization.halo.run/dependencies: "[ \"role-template-view-snapshots\" ]" rules: - apiGroups: [ "content.halo.run" ] resources: [ "snapshots" ] verbs: [ "*" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-snapshots labels: halo.run/role-template: "true" halo.run/hidden: "true" rules: - apiGroups: [ "content.halo.run" ] resources: [ "snapshots" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-tag.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-tags labels: halo.run/role-template: "true" halo.run/hidden: "true" annotations: rbac.authorization.halo.run/dependencies: "[ \"role-template-view-tags\" ]" rules: - apiGroups: [ "content.halo.run" ] resources: [ "tags" ] verbs: [ "*" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-tags labels: halo.run/role-template: "true" halo.run/hidden: "true" rules: - apiGroups: [ "content.halo.run" ] resources: [ "tags" ] verbs: [ "get", "list" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "tags" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-theme.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-themes labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/dependencies: "[ \"role-template-view-themes\" ]" rbac.authorization.halo.run/module: "Themes Management" rbac.authorization.halo.run/display-name: "Theme Manage" rbac.authorization.halo.run/ui-permissions: | ["system:themes:manage"] rules: - apiGroups: [ "theme.halo.run" ] resources: [ "themes" ] verbs: [ "*" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "themes", "themes/reload", "themes/resetconfig", "themes/config", "themes/json-config", "themes/activation", "themes/install-from-uri", "themes/upgrade-from-uri", "themes/invalidate-cache" ] verbs: [ "*" ] - nonResourceURLs: [ "/apis/api.console.halo.run/themes/install" ] verbs: [ "create" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-themes labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Themes Management" rbac.authorization.halo.run/display-name: "Theme View" rbac.authorization.halo.run/ui-permissions: | ["system:themes:view"] rules: - apiGroups: [ "theme.halo.run" ] resources: [ "themes" ] verbs: [ "get", "list" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "themes", "themes/activation", "themes/setting", "themes/config", "themes/json-config" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-uc-attachment.yaml ================================================ apiVersion: v1alpha1 kind: Role metadata: name: role-template-uc-attachment-manager labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Attachments Management" rbac.authorization.halo.run/display-name: "UC Attachment Manage" rbac.authorization.halo.run/ui-permissions: | [ "uc:attachments:manage" ] rules: - apiGroups: [ "uc.api.storage.halo.run" ] resources: [ "attachments", "attachments/upload", "attachments/upload-from-url" ] verbs: [ "create", "list" ] ================================================ FILE: application/src/main/resources/extensions/role-template-uc-content.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: post-editor labels: rbac.authorization.halo.run/system-reserved: "true" annotations: # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 rbac.authorization.halo.run/display-name: "文章管理员" rbac.authorization.halo.run/dependencies: | ["role-template-manage-posts"] rules: [ ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: post-author labels: rbac.authorization.halo.run/system-reserved: "true" annotations: # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 rbac.authorization.halo.run/display-name: "作者" rbac.authorization.halo.run/disallow-access-console: "true" rbac.authorization.halo.run/redirect-on-login: "/uc" rbac.authorization.halo.run/dependencies: | [ "role-template-post-author" ] rules: [ ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-post-author labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Posts Management" # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 rbac.authorization.halo.run/display-name: "Post Author" rbac.authorization.halo.run/dependencies: | [ "role-template-post-contributor", "role-template-post-publisher", "role-template-recycle-my-post", "role-template-uc-attachment-manager" ] rules: [ ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: post-contributor labels: rbac.authorization.halo.run/system-reserved: "true" annotations: # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 rbac.authorization.halo.run/display-name: "投稿者" rbac.authorization.halo.run/disallow-access-console: "true" rbac.authorization.halo.run/redirect-on-login: "/uc" rbac.authorization.halo.run/dependencies: | [ "role-template-post-contributor" ] rules: [ ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-post-contributor labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Posts Management" # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 rbac.authorization.halo.run/display-name: "Post Contributor" rbac.authorization.halo.run/dependencies: | [ "role-template-view-categories", "role-template-view-tags" ] rbac.authorization.halo.run/ui-permissions: | [ "uc:posts:manage" ] rules: - apiGroups: [ "uc.api.content.halo.run" ] resources: [ "posts" ] verbs: [ "get", "list", "create", "update", "delete" ] - apiGroups: [ "uc.api.content.halo.run" ] resources: [ "posts/draft" ] verbs: [ "update", "get" ] --- apiVersion: v1alpha1 kind: Role metadata: name: role-template-post-publisher labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Posts Management" rbac.authorization.halo.run/display-name: "Post Publisher" rbac.authorization.halo.run/ui-permissions: | [ "uc:posts:publish" ] rules: - apiGroups: [ "uc.api.content.halo.run" ] resources: [ "posts/publish", "posts/unpublish" ] verbs: [ "update" ] --- # TODO remove this in next major version apiVersion: v1alpha1 kind: Role metadata: name: role-template-recycle-my-post labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Posts Management" rbac.authorization.halo.run/display-name: "Recycle My Post" rbac.authorization.halo.run/ui-permissions: | [ "uc:posts:recycle" ] rules: - apiGroups: [ "uc.api.content.halo.run" ] resources: [ "posts/recycle" ] verbs: [ "delete" ] --- apiVersion: v1alpha1 kind: Role metadata: name: role-template-post-attachment-manager deletionTimestamp: 2024-09-30T14:00:41.813954138Z labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Posts Management" rbac.authorization.halo.run/display-name: "Post Attachment Manager" rules: [ ] ================================================ FILE: application/src/main/resources/extensions/role-template-user.yaml ================================================ apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-manage-users labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/dependencies: | [ "role-template-view-users", "role-template-change-password" ] rbac.authorization.halo.run/module: "Users Management" rbac.authorization.halo.run/display-name: "User manage" rbac.authorization.halo.run/ui-permissions: | ["system:users:manage"] rules: - apiGroups: [ "" ] resources: [ "users" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "users", "users/permissions", "users/password", "users/avatar" ] verbs: [ "*" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-view-users labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Users Management" rbac.authorization.halo.run/display-name: "User View" rbac.authorization.halo.run/ui-permissions: | ["system:users:view"] rules: - apiGroups: [ "" ] resources: [ "users" ] verbs: [ "get", "list" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "users" ] verbs: [ "get", "list" ] --- apiVersion: v1alpha1 kind: "Role" metadata: name: role-template-change-password labels: halo.run/role-template: "true" halo.run/hidden: "true" annotations: rbac.authorization.halo.run/module: "Users Management" rbac.authorization.halo.run/display-name: "User Password Change" rules: - apiGroups: [ "api.console.halo.run" ] resources: [ "users/password" ] verbs: [ "update" ] ================================================ FILE: application/src/main/resources/extensions/system-configurable-configmap.yaml ================================================ apiVersion: v1alpha1 kind: "ConfigMap" metadata: name: system-default data: user: | { "allowRegistration": false, "mustVerifyEmailOnRegistration": false, "defaultRole": "guest", "avatarPolicy": "default-policy", "ucAttachmentPolicy": "default-policy" } attachment: | { "console": { "policyName": "default-policy" }, "uc": { "policyName": "default-policy" }, "avatar": { "policyName": "default-policy" } } theme: | { "active": "theme-earth" } routeRules: | { "categories": "categories", "archives": "archives", "post": "/archives/{slug}", "tags": "tags" } codeInjection: | { "globalHead": "", "footer": "" } post: | { "review": false, "postPageSize": 10, "archivePageSize": 10, "categoryPageSize": 10, "tagPageSize": 10, "authorPageSize": 10, "slugGenerationStrategy": "generateByTitle", "attachmentPolicyName": "default-policy" } comment: | { "enable": true, "requireReviewForNew": true, "systemUserOnly": true } menu: | { "primary": "primary" } extensionPointEnabled: | { "search-engine": ["search-engine-lucene"] } authProvider: | { "states": [{ "name": "local", "enabled": true, "priority": 0 }] } ================================================ FILE: application/src/main/resources/extensions/system-default-role.yaml ================================================ apiVersion: v1alpha1 kind: Role metadata: name: guest labels: rbac.authorization.halo.run/system-reserved: "true" annotations: rbac.authorization.halo.run/display-name: "访客" rbac.authorization.halo.run/disallow-access-console: "true" rbac.authorization.halo.run/redirect-on-login: "/uc" rules: [] --- apiVersion: v1alpha1 kind: Role metadata: name: super-role labels: rbac.authorization.halo.run/system-reserved: "true" annotations: rbac.authorization.halo.run/display-name: "超级管理员" rbac.authorization.halo.run/ui-permissions: | ["*"] rules: - apiGroups: ["*"] resources: ["*"] nonResourceURLs: ["*"] verbs: ["*"] ================================================ FILE: application/src/main/resources/extensions/system-setting.yaml ================================================ apiVersion: v1alpha1 kind: Setting metadata: name: system spec: forms: - group: basic label: 基本设置 formSchema: - $formkit: text label: "站点标题" name: title validation: required - $formkit: text label: "站点副标题" name: subtitle - $formkit: attachment label: Logo name: logo width: "4rem" accepts: - 'image/*' - $formkit: attachment label: Favicon name: favicon width: "4rem" accepts: - 'image/*' - $formkit: select label: "首选语言" name: language value: 'zh-CN' options: - label: 'English' value: 'en' - label: 'Español' value: 'es' - label: '简体中文' value: 'zh-CN' - label: '繁体中文' value: 'zh-TW' - group: post label: 文章设置 formSchema: - $formkit: number label: "文章列表显示条数" name: postPageSize value: 10 min: 1 max: 100 validation: required | max:100 - $formkit: number label: "归档页文章显示条数" name: archivePageSize value: 10 min: 1 max: 100 validation: required | max:100 - $formkit: number label: "分类页文章显示条数" name: categoryPageSize value: 10 min: 1 max: 100 validation: required | max:100 - $formkit: number label: "标签页文章显示条数" name: tagPageSize value: 10 min: 1 max: 100 validation: required - $formkit: number label: "作者页文章显示条数" name: authorPageSize value: 10 min: 1 max: 100 validation: required - $formkit: select label: "别名生成策略" name: slugGenerationStrategy value: 'generateByTitle' options: - label: '根据标题' value: 'generateByTitle' - label: '时间戳' value: 'timestamp' - label: 'Short UUID' value: 'shortUUID' - label: 'UUID' value: 'UUID' help: 此选项仅在创建文章时生效,修改此选项不会影响已有文章 - $formkit: attachmentPolicySelect name: attachmentPolicyName label: "附件存储策略" value: "default-policy" help: 用于指定在文章编辑器中上传的默认附件存储策略(已过时,请使用附件配置中的管理端附件配置) - $formkit: attachmentGroupSelect name: attachmentGroupName label: "附件存储组" value: "" help: 用于指定在文章编辑器中上传的默认附件存储分组(已过时,请使用附件配置中的管理端附件配置) - group: seo label: SEO 设置 formSchema: - $formkit: checkbox name: blockSpiders label: "屏蔽搜索引擎" value: false help: "为所有页面添加 标签,阻止搜索引擎索引,但不是所有搜索引擎都会遵守" - $formkit: textarea name: keywords label: "站点关键词" help: "目前主流搜索引擎已经不再使用此字段,所以通常不建议设置,此选项可能在未来版本中被移除" auto-height: true - $formkit: textarea name: description label: "站点描述" help: "仅对首页生效,其他页面将根据页面类型自动生成描述" auto-height: true - group: user label: 用户设置 formSchema: - $formkit: checkbox name: allowRegistration id: allowRegistration key: allowRegistration label: "开放注册" value: false - $formkit: checkbox name: mustVerifyEmailOnRegistration label: "注册需验证邮箱" if: "$get(allowRegistration).value === true" help: "需要确保已经正确配置邮件通知器" value: false - $formkit: textarea name: protectedUsernames label: "保留用户名与名称" if: "$get(allowRegistration).value === true" help: 保留用户名与名称,限制用户注册或编辑资料时使用,多个请用英文逗号,分隔,留空则不限制 value: "admin,administrator,root,system,superuser,guest,test,demo,user,backup,ftp,www,manager,support,service,api,超级管理员,管理员" - $formkit: roleSelect name: defaultRole label: "默认角色" validation: 'required' if: "$get(allowRegistration).value === true" help: 用户注册之后默认为用户分配的角色 - $formkit: attachmentPolicySelect name: avatarPolicy label: "头像存储位置" value: "default-policy" help: 指定用户上传头像的存储策略(已过时,请使用附件配置中的头像附件配置) - $formkit: attachmentPolicySelect name: ucAttachmentPolicy label: "个人中心附件存储位置" value: "default-policy" help: 指定用户在个人中心上传的附件的存储位置(已过时,请使用附件配置中的个人中心附件配置) - group: attachment label: 附件配置 formSchema: - $formkit: group name: console label: 管理端附件配置 help: 为管理端直接上传附件的地方设置默认的存储策略和分组 children: - $formkit: attachmentPolicySelect name: policyName label: "存储策略" auto-select: false - $formkit: attachmentGroupSelect name: groupName label: "存储组" auto-select: false clearable: true - $formkit: group name: uc label: 个人中心附件配置 help: 为个人中心直接上传附件的地方设置默认的存储策略和分组,处于安全考虑,建议为所选存储策略限制文件类型和大小 children: - $formkit: attachmentPolicySelect name: policyName label: "存储策略" auto-select: false - $formkit: attachmentGroupSelect name: groupName label: "存储组" auto-select: false clearable: true - $formkit: group name: avatar label: 头像附件配置 help: 为用户头像设置存储策略,处于安全考虑,建议为所选存储策略限制文件类型和大小 children: - $formkit: attachmentPolicySelect name: policyName label: "存储策略" - group: comment label: 评论设置 formSchema: - $formkit: checkbox name: enable value: true label: "启用评论" - $formkit: checkbox name: requireReviewForNew value: true label: "新评论审核" help: 开启之后,新评论需要管理员审核后才会显示 - $formkit: checkbox name: systemUserOnly value: true label: "仅允许注册用户评论" - group: routeRules label: 主题路由设置 formSchema: - $formkit: checkbox label: "关闭主题预览" value: false name: disableThemePreview help: "关闭后,未包含主题管理权限的用户将无法通过参数预览未激活的主题" - $formkit: text label: "分类页路由前缀" value: "categories" name: categories validation: required | alphanumeric - $formkit: text label: "标签页路由前缀" value: "tags" name: tags validation: required | alphanumeric - $formkit: text label: "归档页路由前缀" value: "archives" name: archives validation: required | alphanumeric - $formkit: select label: 文章详情页访问规则 value: '/archives/{slug}' options: - label: '/archives/{slug}' value: '/archives/{slug}' - label: '/archives/{name}' value: '/archives/{name}' - label: '/?p={name}' value: '/?p={name}' - label: '/?p={slug}' value: '/?p={slug}' - label: '/{year}/{slug}' value: '/{year:\d{4}}/{slug}' - label: '/{year}/{month}/{slug}' value: '/{year:\d{4}}/{month:\d{2}}/{slug}' - label: '/{year}/{month}/{day}/{slug}' value: '/{year:\d{4}}/{month:\d{2}}/{day:\d{2}}/{slug}' - label: '/categories/{categorySlug}/{slug}' value: '/categories/{categorySlug}/{slug}' name: post validation: required - group: codeInjection label: 代码注入 formSchema: - $formkit: code language: html height: 200px label: "全局 head 标签" name: globalHead help: "注入代码到所有页面的 head 标签部分" - $formkit: code language: html height: 200px label: "内容页 head 标签" name: contentHead help: "注入代码到文章页面和自定义页面的 head 标签部分" - $formkit: code language: html height: 200px label: "页脚" name: footer help: "注入代码到所有页面的页脚部分" ================================================ FILE: application/src/main/resources/extensions/user.yaml ================================================ apiVersion: v1alpha1 kind: User metadata: name: anonymousUser labels: halo.run/hidden-user: "true" finalizers: - system-protection spec: displayName: Anonymous User email: anonymous@example.com disabled: true --- apiVersion: v1alpha1 kind: User metadata: name: ghost labels: halo.run/hidden-user: "true" finalizers: - system-protection spec: displayName: 已删除用户 email: ghost@example.com disabled: true bio: 该用户已被删除。 ================================================ FILE: application/src/main/resources/initial-data.yaml ================================================ # 提供了 timestamp、username 变量,用于初始化数据时填充时间戳和用户名 # 初始化文章关联的分类、标签数据 apiVersion: content.halo.run/v1alpha1 kind: Category metadata: name: 76514a40-6ef1-4ed9-b58a-e26945bde3ca spec: displayName: 默认分类 slug: default description: 这是你的默认分类,如不需要,删除即可。 cover: "" template: "" priority: 0 children: [ ] status: permalink: "/categories/default" --- apiVersion: content.halo.run/v1alpha1 kind: Tag metadata: name: c33ceabb-d8f1-4711-8991-bb8f5c92ad7c spec: displayName: Halo slug: halo cover: "" status: permalink: "/tags/halo" --- # 文章关联的内容 apiVersion: content.halo.run/v1alpha1 kind: Snapshot metadata: name: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 annotations: content.halo.run/keep-raw: "true" spec: subjectRef: group: content.halo.run version: v1alpha1 kind: Post name: 5152aea5-c2e8-4717-8bba-2263d46e19d5 rawType: HTML rawPatch:

Hello Halo

如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo 进行创作,希望能够使用愉快。

相关链接

在使用过程中,有任何问题都可以通过以上链接找寻答案,或者联系我们。

这是一篇自动生成的文章,请删除这篇文章之后开始你的创作吧!

contentPatch:

Hello Halo

如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo 进行创作,希望能够使用愉快。

相关链接

在使用过程中,有任何问题都可以通过以上链接找寻答案,或者联系我们。

这是一篇自动生成的文章,请删除这篇文章之后开始你的创作吧!

lastModifyTime: "${timestamp}" owner: "${username}" contributors: - "${username}" --- # 初始化文章数据 apiVersion: content.halo.run/v1alpha1 kind: Post metadata: name: 5152aea5-c2e8-4717-8bba-2263d46e19d5 spec: title: Hello Halo slug: hello-halo releaseSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 headSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 baseSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 owner: "${username}" template: "" cover: "" deleted: false publish: true publishTime: "${timestamp}" pinned: false allowComment: true visible: PUBLIC priority: 0 excerpt: autoGenerate: false raw: 如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo 进行创作,希望能够使用愉快。 categories: - 76514a40-6ef1-4ed9-b58a-e26945bde3ca tags: - c33ceabb-d8f1-4711-8991-bb8f5c92ad7c htmlMetas: [ ] status: permalink: /archives/hello-halo --- # 自定义页面关联的内容 apiVersion: content.halo.run/v1alpha1 kind: Snapshot metadata: name: c3f73cc2-194e-4cd8-9092-7386aa50a0e5 annotations: content.halo.run/keep-raw: "true" spec: subjectRef: group: content.halo.run version: v1alpha1 kind: SinglePage name: 373a5f79-f44f-441a-9df1-85a4f553ece8 rawType: HTML rawPatch:

关于页面

这是一个自定义页面,你可以在后台的 页面 -> 自定义页面 找到它,你可以用于新建关于页面、联系我们页面等等。

这是一篇自动生成的页面,你可以在后台删除它。

contentPatch:

关于页面

这是一个自定义页面,你可以在后台的 页面 -> 自定义页面 找到它,你可以用于新建关于页面、联系我们页面等等。

这是一篇自动生成的页面,你可以在后台删除它。

lastModifyTime: "${timestamp}" owner: "${username}" contributors: - "${username}" --- # 初始化自定义页面数据 apiVersion: content.halo.run/v1alpha1 kind: SinglePage metadata: name: 373a5f79-f44f-441a-9df1-85a4f553ece8 spec: title: 关于 slug: about template: "" cover: "" owner: "${username}" deleted: false publish: true baseSnapshot: c3f73cc2-194e-4cd8-9092-7386aa50a0e5 headSnapshot: c3f73cc2-194e-4cd8-9092-7386aa50a0e5 releaseSnapshot: c3f73cc2-194e-4cd8-9092-7386aa50a0e5 pinned: false allowComment: true visible: PUBLIC version: 1 priority: 0 excerpt: autoGenerate: false raw: 这是一个自定义页面,你可以在后台的 页面 -> 自定义页面 找到它,你可以用于新建关于页面、联系我们页面等等。 htmlMetas: [ ] status: permalink: "/about" --- # 首页菜单项 apiVersion: v1alpha1 kind: MenuItem metadata: name: 88c3f10b-321c-4092-86a8-70db00251b74 spec: displayName: 首页 href: / children: [ ] priority: 0 --- # 关联到文章作为菜单 apiVersion: v1alpha1 kind: MenuItem metadata: name: c4c814d1-0c2c-456b-8c96-4864965fee94 spec: displayName: "Hello Halo" href: "/archives/hello-halo" children: [ ] priority: 1 targetRef: group: content.halo.run version: v1alpha1 kind: Post name: 5152aea5-c2e8-4717-8bba-2263d46e19d5 --- # 关联到标签作为菜单 apiVersion: v1alpha1 kind: MenuItem metadata: name: 35869bd3-33b5-448b-91ee-cf6517a59644 spec: displayName: "Halo" href: "/tags/halo" children: [ ] priority: 2 targetRef: group: content.halo.run version: v1alpha1 kind: Tag name: c33ceabb-d8f1-4711-8991-bb8f5c92ad7c --- # 关联到自定义页面作为菜单 apiVersion: v1alpha1 kind: MenuItem metadata: name: b0d041fa-dc99-48f6-a193-8604003379cf spec: displayName: "关于" href: "/about" children: [ ] priority: 3 targetRef: group: content.halo.run version: v1alpha1 kind: SinglePage name: 373a5f79-f44f-441a-9df1-85a4f553ece8 --- apiVersion: v1alpha1 kind: Menu metadata: name: primary spec: displayName: 主菜单 menuItems: - 88c3f10b-321c-4092-86a8-70db00251b74 - c4c814d1-0c2c-456b-8c96-4864965fee94 - 35869bd3-33b5-448b-91ee-cf6517a59644 - b0d041fa-dc99-48f6-a193-8604003379cf ================================================ FILE: application/src/main/resources/schema-h2.sql ================================================ create table if not exists extensions ( name varchar(255) not null, data blob, version bigint, primary key (name) ); ================================================ FILE: application/src/main/resources/schema-mariadb.sql ================================================ create table if not exists extensions ( name varchar(255) not null COLLATE utf8mb4_bin, data longblob, version bigint, primary key (name) ); ================================================ FILE: application/src/main/resources/schema-mysql.sql ================================================ create table if not exists extensions ( name varchar(255) not null COLLATE utf8mb4_bin, data longblob, version bigint, primary key (name) ); ================================================ FILE: application/src/main/resources/schema-postgresql.sql ================================================ create table if not exists extensions ( name varchar(255) not null, data bytea, version bigint, primary key (name) ); ================================================ FILE: application/src/main/resources/static/halo-tracker.js ================================================ !function(){"use strict";!function(t){var e=t.screen,r=e.width,n=e.height,a=t.navigator.language,o=t.location,i=t.localStorage,c=t.document,u=t.history,l=o.hostname,s=o.pathname,p=o.search,f=c.currentScript;if(f){var h=function(t,e,r){var n=t[e];return function(){for(var e=[],a=arguments.length;a--;)e[a]=arguments[a];return r.apply(null,e),n.apply(t,e)}},d=function(){return i&&i.getItem("haloTracker.disabled")||T&&function(){var e=t.doNotTrack,r=t.navigator,n=t.external,a="msTrackingProtectionEnabled",o=e||r.doNotTrack||r.msDoNotTrack||n&&a in n&&n[a]();return"1"==o||"yes"===o}()||j&&!w.includes(l)},g="data-",v=f.getAttribute.bind(f),m=v(g+"group")||"",k=v(g+"plural"),y=v(g+"name"),S=v(g+"host-url"),b="false"!==v(g+"auto-track"),T=v(g+"do-not-track"),j=v(g+"domains")||"",w=j.split(",").map((function(t){return t.trim()})),E=(S?S.replace(/\/$/,""):f.src.split("/").slice(0,-1).join("/"))+"/apis/api.halo.run/v1alpha1/trackers/counter",N=r+"x"+n,O=""+s+p,x=c.referrer,P=function(t,e){return void 0===t&&(t=O),void 0===e&&(e=x),function(t){if(!d())return fetch(E,{method:"POST",body:JSON.stringify(Object.assign({},t)),headers:{"Content-Type":"application/json"}}).then((function(t){return t.text()})).then((function(t){console.debug("Visit count:",t)}))}((r={group:m,plural:k,name:y,hostname:l,screen:N,language:a,url:O},n={url:t,referrer:e},Object.keys(n).forEach((function(t){void 0!==n[t]&&(r[t]=n[t])})),r));var r,n},V=function(t,e,r){if(r){x=O;var n=r.toString();(O="http"===n.substring(0,4)?"/"+n.split("/").splice(3).join("/"):n)!==x&&P()}};if(!t.haloTracker){var A=function(t){return trackEvent(t)};A.trackView=P,t.haloTracker=A}if(b&&!d()){u.pushState=h(u,"pushState",V),u.replaceState=h(u,"replaceState",V);var C=function(){"complete"===c.readyState&&P()};c.addEventListener("readystatechange",C,!0),C()}}}(window)}(); ================================================ FILE: application/src/main/resources/static/js/main.js ================================================ const Toast = (function () { let container; function getContainer() { if (container) return container; container = document.createElement("div"); container.style.cssText = ` position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 9999; `; if (document.body) { document.body.appendChild(container); } else { document.addEventListener("DOMContentLoaded", () => { document.body.appendChild(container); }); } return container; } class ToastMessage { constructor(message, type) { this.message = message; this.type = type; this.element = null; this.create(); } create() { this.element = document.createElement("div"); this.element.textContent = this.message; this.element.style.cssText = ` background-color: ${this.type === "success" ? "#4CAF50" : "#F44336"}; color: white; padding: 12px 24px; border-radius: 4px; margin-bottom: 10px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); opacity: 0; transition: opacity 0.3s ease-in-out; `; getContainer().appendChild(this.element); setTimeout(() => { this.element.style.opacity = "1"; }, 10); setTimeout(() => { this.remove(); }, 3000); } remove() { this.element.style.opacity = "0"; setTimeout(() => { const parent = this.element.parentNode; if (parent) { parent.removeChild(this.element); } }, 300); } } function showToast(message, type) { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { new ToastMessage(message, type); }); } else { new ToastMessage(message, type); } } return { success: function (message) { showToast(message, "success"); }, error: function (message) { showToast(message, "error"); }, }; })(); function sendVerificationCode(button, sendRequest) { let timer; const countdown = 60; const originalButtonText = button.textContent; button.addEventListener("click", () => { button.disabled = true; button.textContent = i18nResources.sendVerificationCodeSending; sendRequest() .then(() => { startCountdown(); Toast.success(i18nResources.sendVerificationCodeSuccess); }) .catch((e) => { button.disabled = false; button.textContent = originalButtonText; if (e instanceof Error) { Toast.error(e.message); } else { Toast.error(i18nResources.sendVerificationCodeFailed); } }); }); function startCountdown() { let remainingTime = countdown; button.disabled = true; button.classList.add("disabled"); timer = setInterval(() => { if (remainingTime > 0) { button.textContent = `${remainingTime}s`; remainingTime--; } else { clearInterval(timer); button.textContent = originalButtonText; button.disabled = false; button.classList.remove("disabled"); } }, 1000); } } document.addEventListener("DOMContentLoaded", () => { const passwordContainers = document.querySelectorAll(".toggle-password-display-flag"); passwordContainers.forEach((container) => { const passwordInput = container.querySelector('input[type="password"]'); const toggleButton = container.querySelector(".toggle-password-button"); const displayIcon = container.querySelector(".password-display-icon"); const hiddenIcon = container.querySelector(".password-hidden-icon"); if (passwordInput && toggleButton && displayIcon && hiddenIcon) { toggleButton.addEventListener("click", () => { if (passwordInput.type === "password") { passwordInput.type = "text"; displayIcon.style.display = "none"; hiddenIcon.style.display = "block"; } else { passwordInput.type = "password"; displayIcon.style.display = "block"; hiddenIcon.style.display = "none"; } }); } }); }); function setupPasswordConfirmation(passwordId, confirmPasswordId) { const password = document.getElementById(passwordId); const confirmPassword = document.getElementById(confirmPasswordId); function validatePasswordMatch() { if (password.value !== confirmPassword.value) { confirmPassword.setCustomValidity(i18nResources.passwordConfirmationFailed); } else { confirmPassword.setCustomValidity(""); } } password.addEventListener("change", validatePasswordMatch); confirmPassword.addEventListener("input", validatePasswordMatch); } ================================================ FILE: application/src/main/resources/static/styles/main.css ================================================ .gateway-page { width: 100%; height: 100vh; overflow: auto; } .gateway-wrapper, .gateway-wrapper:before, .gateway-wrapper:after, .gateway-wrapper *, .gateway-wrapper :before, .gateway-wrapper :after { box-sizing: border-box; border-style: solid; border-width: 0; } .gateway-wrapper { --color-primary: #4ccba0; --color-secondary: #0e1731; --color-link: #1f75cb; --color-text: #374151; --color-border: #d1d5db; --rounded-sm: 0.125em; --rounded-base: 0.25em; --rounded-lg: 0.5em; --spacing-2xl: 1.5em; --spacing-xl: 1.25em; --spacing-lg: 1em; --spacing-md: 0.875em; --spacing-sm: 0.625em; --spacing-xs: 0.5em; --text-xl: 1.25em; --text-2xl: 1.5em; --text-lg: 1.125em; --text-base: 1em; --text-sm: 0.875em; --font-size-base: 16px; --font-size-md: 14px; padding: 5% var(--spacing-lg); font-size: var(--font-size-base); max-width: 28em; margin: 0 auto; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; line-height: 1.5; } .halo-form-wrapper { border-radius: var(--rounded-lg); padding: var(--spacing-2xl); background: #fff; border: 1px solid #dfe6ecb3; } .form-title { all: unset; font-size: var(--text-2xl); margin-bottom: var(--spacing-lg); font-weight: 500; display: block; } .halo-form .form-item { margin-bottom: var(--spacing-2xl); flex-direction: column; width: 100%; display: flex; } .halo-form .form-item:last-of-type { margin-bottom: 0; } .halo-form .form-item-group { gap: var(--spacing-lg); margin-bottom: var(--spacing-2xl); align-items: flex-start; display: flex; } .halo-form .form-item-group .form-item { margin-bottom: 0; } .halo-form .form-input { border-radius: var(--rounded-base); border: 1px solid var(--color-border); background: #fff; height: 2.5em; padding: 0 0.75rem; } .halo-form .form-input:focus-within { border-color: var(--color-primary); outline-offset: "2px"; outline: 2px solid #0000; } .halo-form .form-item input { appearance: none; font-size: var(--text-base); box-shadow: none; background: none; width: 100%; height: 100%; display: block; } .halo-form .form-item select { appearance: none; font-size: var(--text-base); box-shadow: none; width: 100%; height: 100%; display: block; outline: none; border: none; background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m12 13.171l4.95-4.95l1.414 1.415L12 16L5.636 9.636L7.05 8.222z'/%3E%3C/svg%3E") right 0em center no-repeat; } .halo-form .form-item input:focus { outline: none; } .halo-form .form-input-stack { align-items: center; gap: 0.5em; display: flex; } .halo-form .form-input-stack-icon { color: var(--color-text); cursor: pointer; align-items: center; display: inline-flex; } .halo-form .form-input-stack-select { all: unset; color: var(--color-text); font-size: var(--text-sm); background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m12 13.171l4.95-4.95l1.414 1.415L12 16L5.636 9.636L7.05 8.222z'/%3E%3C/svg%3E") right 0.3em center no-repeat; align-items: center; padding-right: 1.85em; display: inline-flex; } .halo-form .form-input-stack-text { color: var(--color-text); font-size: var(--text-sm); } .halo-form .form-item label { color: var(--color-text); font-size: var(--text-base); margin-bottom: 0.75rem; font-weight: 500; } .halo-form .form-item .form-label-group { justify-content: space-between; align-items: center; margin-bottom: 0.75em; display: flex; } .halo-form .form-item .form-label-group label { margin-bottom: 0; } .halo-form .form-item-extra-link { color: var(--color-link); font-size: var(--text-sm); text-decoration: none; } .halo-form .form-item-compact { gap: var(--spacing-sm); margin-bottom: var(--spacing-2xl); align-items: center; display: flex; } .halo-form .form-item-compact label { color: var(--color-text); font-size: var(--text-sm); } .halo-form button[type="submit"] { background: var(--color-secondary); border-radius: var(--rounded-base); color: #fff; cursor: pointer; border: none; height: 2.5em; } .halo-form button[type="submit"]:hover { opacity: 0.8; } .halo-form button[type="submit"]:active { opacity: 0.9; } .halo-form button[disabled] { cursor: not-allowed !important; } .halo-form input[type="checkbox"] { border: 1px solid var(--color-border); border-radius: var(--rounded-sm); appearance: none; print-color-adjust: exact; vertical-align: middle; user-select: none; color: #2563eb; background-color: #fff; background-origin: border-box; flex-shrink: 0; width: 1em; height: 1em; padding: 0; display: inline-block; } .halo-form input[type="checkbox"]:focus { outline-offset: 2px; outline: 2px solid #0000; box-shadow: 0 0 0 2px #fff, 0 0 0 4px #2563eb, 0 0 #0000; } .halo-form input[type="checkbox"]:checked { background-color: currentColor; background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); background-position: center; background-repeat: no-repeat; background-size: 100% 100%; border-color: #0000; } .halo-form .form-input-group { gap: var(--spacing-sm); grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: center; display: grid; height: 2.5em; } .halo-form .form-input { grid-column: span 2 / span 2; } .halo-form .form-input-group button { border-radius: var(--rounded-base); border: 1px solid var(--color-border); color: var(--color-text); font-size: var(--text-sm); cursor: pointer; background: #fff; grid-column: span 1 / span 1; height: 100%; } .halo-form .form-input-group button:hover { color: #333; background: #f3f4f6; } .halo-form .form-input-group button:active { background: #f9fafb; } .pill-items { all: unset; gap: var(--spacing-sm); flex-wrap: wrap; justify-content: center; margin: 0; display: flex; } .pill-items li { all: unset; border-radius: var(--rounded-lg); border: 1px solid #e5e7eb; transition-property: all; transition-duration: 0.15s; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); overflow: hidden; } .pill-items li button { all: unset; cursor: pointer; width: 100%; height: 100%; } .pill-items li a, .pill-items li button { gap: var(--spacing-sm); font-size: var(--text-sm); color: #1f2937; align-items: center; padding: 0.6em 0.9em; text-decoration: none; display: flex; } .pill-items li img { width: 1.5em; height: 1.5em; } .pill-items li:hover { border-color: var(--color-primary); background: #f3f4f6; } .pill-items li:hover { color: #111827; } .pill-items li:focus-within { border-color: var(--color-primary); } .divider-wrapper { color: var(--color-text); font-size: var(--text-sm); gap: var(--spacing-lg); align-items: center; margin: 1.5em 0; display: flex; } .divider-wrapper hr { border: 0; border-top: 1px solid #f3f4f6; flex-grow: 1; overflow: hidden; } .alert { border-radius: var(--rounded-base); margin-bottom: var(--spacing-xl); padding: var(--spacing-md) var(--spacing-xl); font-size: var(--text-sm); color: var(--color-text); border: 1px solid #e5e7eb; position: relative; overflow: hidden; } .alert:before { content: ""; background: #d1d5db; width: 0.25em; height: 100%; position: absolute; top: 0; left: 0; } .alert-warning { border-color: #fde047; } .alert-warning:before { background: #ea580c; } .alert-error { border-color: #fca5a5; } .alert-error:before { background: #dc2626; } .alert-success { border-color: #86efac; } .alert-success:before { background: #16a34a; } .alert-info { border-color: #7dd3fc; } .alert-info:before { background: #0284c7; } @media (forced-colors: active) { .halo-form input[type="checkbox"]:checked { appearance: auto; } } @media only screen and (max-width: 768px) { .halo-form .form-item-group { flex-direction: column; } } @media screen and (min-width: 1201px) and (max-width: 1600px) { .gateway-wrapper { font-size: var(--font-size-md); } } @media screen and (min-width: 1601px) { .gateway-wrapper { font-size: var(--font-size-base); } } ::-ms-reveal { display: none; } ================================================ FILE: application/src/main/resources/templates/challenges/two-factor/totp.html ================================================

================================================ FILE: application/src/main/resources/templates/challenges/two-factor/totp.properties ================================================ title=两步验证 ================================================ FILE: application/src/main/resources/templates/challenges/two-factor/totp_en.properties ================================================ title=Two-Factor Authentication ================================================ FILE: application/src/main/resources/templates/challenges/two-factor/totp_es.properties ================================================ title=Autenticación en Dos Pasos ================================================ FILE: application/src/main/resources/templates/challenges/two-factor/totp_zh_TW.properties ================================================ title=兩步驗證 ================================================ FILE: application/src/main/resources/templates/error/error.html ================================================

================================================ FILE: application/src/main/resources/templates/gateway_fragments/common.html ================================================


================================================ FILE: application/src/main/resources/templates/gateway_fragments/common.properties ================================================ socialLogin.label=社交登录 js.sendVerificationCode.success=发送成功 js.sendVerificationCode.failed=发送失败,请稍后再试 js.sendVerificationCode.sending=发送中... js.passwordConfirmation.failed=确认密码不匹配 signupNotice.description=没有账号? signupNotice.link=立即注册 loginNotice.description=已有账号, loginNotice.link=立即登录 returnToSite=返回网站 passwordResetMethods.label=其他重置方式 passwordResetMethods.email.displayName=通过邮件重置 ================================================ FILE: application/src/main/resources/templates/gateway_fragments/common_en.properties ================================================ socialLogin.label=Social Login js.sendVerificationCode.success=Sent Successfully js.sendVerificationCode.failed=Sending Failed, Please Try Again Later js.sendVerificationCode.sending=Sending... js.passwordConfirmation.failed=Password confirmation does not match signupNotice.description=Don't have an account? signupNotice.link=Sign up loginNotice.description=Already have an account, loginNotice.link=Login now returnToSite=Return to site passwordResetMethods.label=Other Reset Methods passwordResetMethods.email.displayName=Reset via Email ================================================ FILE: application/src/main/resources/templates/gateway_fragments/common_es.properties ================================================ socialLogin.label=Inicio de Sesión Social js.sendVerificationCode.success=Enviado con éxito js.sendVerificationCode.failed=Error al enviar, por favor intente nuevamente más tarde js.sendVerificationCode.sending=Enviando... js.passwordConfirmation.failed=La confirmación de la contraseña no coincide signupNotice.description=¿No tienes una cuenta? signupNotice.link=Regístrate ahora loginNotice.description=Ya tienes una cuenta, loginNotice.link=Inicia sesión ahora returnToSite=Volver al sitio passwordResetMethods.label=Otros Métodos de Restablecimiento passwordResetMethods.email.displayName=Restablecer por Correo Electrónico ================================================ FILE: application/src/main/resources/templates/gateway_fragments/common_zh_TW.properties ================================================ socialLogin.label=社交登入 js.sendVerificationCode.success=發送成功 js.sendVerificationCode.failed=發送失敗,請稍後再試 js.sendVerificationCode.sending=發送中... js.passwordConfirmation.failed=確認密碼不匹配 signupNotice.description=沒有帳號? signupNotice.link=立即註冊 loginNotice.description=已有帳號, loginNotice.link=立即登入 returnToSite=返回網站 passwordResetMethods.label=其他重置方式 passwordResetMethods.email.displayName=通過郵件重置 ================================================ FILE: application/src/main/resources/templates/gateway_fragments/input.html ================================================
================================================ FILE: application/src/main/resources/templates/gateway_fragments/layout.html ================================================ ================================================ FILE: application/src/main/resources/templates/gateway_fragments/login.html ================================================
================================================ FILE: application/src/main/resources/templates/gateway_fragments/login.properties ================================================ form.messages.logoutSuccess=登出成功。 form.messages.signupSuccess=恭喜!注册成功,请立即登录。 form.messages.oauth2Bind=当前登录未绑定账号,请尝试通过其他方式登录,登录成功后会自动绑定账号。 form.messages.passwordReset=密码重置成功,请立即登录。 form.error.invalidCredential=无效的凭证。 form.error.rateLimitExceeded=请求过于频繁,请稍后再试。 form.error.accountDisabled=账号已被禁用。 form.rememberMe.label=保持登录会话 form.submit=登录 otherLogin.label=其他登录方式 # Rule: `formAuthProviders.${provider.metadata.name}.displayName` formAuthProviders.local.displayName=账号密码登录 ================================================ FILE: application/src/main/resources/templates/gateway_fragments/login_en.properties ================================================ form.messages.logoutSuccess=Logout successfully. form.messages.signupSuccess=Congratulations! Sign up successfully, please login now. form.messages.oauth2Bind=The current login is not bound to an account. Please try to log in through other methods. After successful login, the account will be automatically bound. form.messages.passwordReset=Password reset successfully, please login now. form.error.invalidCredential=Invalid credentials. form.error.rateLimitExceeded=Too many requests, please try again later. form.error.accountDisabled=Account has been disabled. form.rememberMe.label=Remember me form.submit=Login otherLogin.label=Other Login # Rule: `formAuthProviders.${provider.metadata.name}.displayName` formAuthProviders.local.displayName=Login with credentials ================================================ FILE: application/src/main/resources/templates/gateway_fragments/login_es.properties ================================================ form.messages.logoutSuccess=Cierre de sesión exitoso. form.messages.signupSuccess=¡Felicidades! Registro exitoso, por favor inicie sesión de inmediato. form.messages.oauth2Bind=El inicio de sesión actual no está vinculado a una cuenta. Intente iniciar sesión a través de otros métodos. Después de un inicio de sesión exitoso, la cuenta se vinculará automáticamente. form.messages.passwordReset=¡Felicidades! La contraseña se ha restablecido con éxito. form.error.invalidCredential=Credenciales inválidas. form.error.rateLimitExceeded=Demasiadas solicitudes, por favor intente nuevamente más tarde. form.error.accountDisabled=La cuenta ha sido deshabilitada. form.rememberMe.label=Mantener sesión iniciada form.submit=Iniciar sesión otherLogin.label=Otras formas de inicio de sesión # Rule: `formAuthProviders.${provider.metadata.name}.displayName` formAuthProviders.local.displayName=Iniciar sesión con credenciales ================================================ FILE: application/src/main/resources/templates/gateway_fragments/login_zh_TW.properties ================================================ form.messages.logoutSuccess=登出成功。 form.messages.signupSuccess=恭喜!註冊成功,請立即登入。 form.messages.oauth2Bind=當前登入未綁定至帳戶。請嘗試通過其他方法登入。成功登入後,帳戶將自動綁定。 form.messages.passwordReset=密碼重置成功。請立即登入。 form.error.invalidCredential=無效的憑證。 form.error.rateLimitExceeded=請求過於頻繁,請稍後再試。 form.error.accountDisabled=帳戶已被停用。 form.form.rememberMe.label=保持登入會話 form.form.submit=登入 otherLogin.label=其他登入方式 # Rule: `formAuthProviders.${provider.metadata.name}.displayName` formAuthProviders.local.displayName=帳號密碼登入 ================================================ FILE: application/src/main/resources/templates/gateway_fragments/logout.html ================================================
================================================ FILE: application/src/main/resources/templates/gateway_fragments/logout.properties ================================================ form.submit=退出登录 form.cancel=取消 form.currentUser.label=当前登录的用户: ================================================ FILE: application/src/main/resources/templates/gateway_fragments/logout_en.properties ================================================ form.submit=Logout form.cancel=Cancel form.currentUser.label=Currently logged in user: ================================================ FILE: application/src/main/resources/templates/gateway_fragments/logout_es.properties ================================================ form.submit=Cerrar Sesión form.cancel=Cancelar form.currentUser.label=Usuario actualmente conectado: ================================================ FILE: application/src/main/resources/templates/gateway_fragments/logout_zh_TW.properties ================================================ form.submit=退出登入 form.cancel=取消 form.currentUser.label=當前登入的使用者: ================================================ FILE: application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.html ================================================

================================================ FILE: application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.properties ================================================ form.password.label=密码 form.confirmPassword.label=确认密码 form.password.tips=密码只能使用大小写字母 (A-Z, a-z)、数字 (0-9),以及以下特殊字符: !@#$%^&*.?。 form.submit=修改密码 error.rate_limit_exceeded=您的请求过于频繁,请稍后再试。 ================================================ FILE: application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_en.properties ================================================ form.password.label=Password form.confirmPassword.label=Confirm Password form.password.tips=The password can only use uppercase and lowercase letters (A-Z, a-z), numbers (0-9), and the following special characters: !@#$%^&*.? form.submit=Change password error.rate_limit_exceeded=Your request is too frequent, please try again later. ================================================ FILE: application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_es.properties ================================================ form.password.label=Contraseña form.confirmPassword.label=Confirmar Contraseña form.password.tips=La contraseña solo puede usar letras mayúsculas y minúsculas (A-Z, a-z), números (0-9) y los siguientes caracteres especiales: !@#$%^&*.? form.submit=Cambiar Contraseña error.rate_limit_exceeded=Su solicitud es demasiado frecuente, por favor intente nuevamente más tarde. ================================================ FILE: application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_zh_TW.properties ================================================ form.password.label=密碼 form.confirmPassword.label=確認密碼 form.password.tips=密碼只能使用大小寫字母 (A-Z, a-z)、數字 (0-9),以及以下特殊字符: !@#$%^&*.? form.submit=修改密碼 error.rate_limit_exceeded=您的請求過於頻繁,請稍後再試。 ================================================ FILE: application/src/main/resources/templates/gateway_fragments/password_reset_email_send.html ================================================
================================================ FILE: application/src/main/resources/templates/gateway_fragments/password_reset_email_send.properties ================================================ form.email.label=电子邮箱 form.submit=提交 form.sent.submit=返回到登录页面 form.message.success=检查您的电子邮件中是否有重置密码的链接。如果几分钟内没有出现,请检查您的垃圾邮件文件夹。 error.rate_limit_exceeded=您的请求速度太快。请稍后再试。 error.invalid_reset_token=重置密码令牌无效。请重试。 ================================================ FILE: application/src/main/resources/templates/gateway_fragments/password_reset_email_send_en.properties ================================================ form.email.label=Email form.submit=Submit form.sent.submit=Return to login form.message.success=Check your email for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder. error.rate_limit_exceeded=You are making requests too quickly. Please try again later. error.invalid_reset_token=The reset password token is invalid. Please try again. ================================================ FILE: application/src/main/resources/templates/gateway_fragments/password_reset_email_send_es.properties ================================================ form.email.label=Correo Electrónico form.submit=Enviar form.sent.submit=Volver a la Página de Inicio de Sesión form.message.success=Revisa tu correo electrónico para ver el enlace de restablecimiento de contraseña. Si no aparece en unos minutos, revisa tu carpeta de spam. error.rate_limit_exceeded=Se ha superado el límite de intentos de restablecimiento de contraseña. Por favor, inténtalo de nuevo más tarde. error.invalid_reset_token=El enlace de restablecimiento de contraseña no es válido o ha expirado. Por favor, solicita un nuevo enlace. ================================================ FILE: application/src/main/resources/templates/gateway_fragments/password_reset_email_send_zh_TW.properties ================================================ form.email.label=電子郵件 form.submit=提交 form.sent.submit=返回到登入頁面 form.message.success=檢查您的電子郵件中是否有重置密碼的連結。如果幾分鐘內沒有出現,請檢查您的垃圾郵件資料夾。 error.rate_limit_exceeded=您的請求過於頻繁。請稍後再試。 error.invalid_reset_token=重置密碼連結無效。請重試。 ================================================ FILE: application/src/main/resources/templates/gateway_fragments/signup.html ================================================

================================================ FILE: application/src/main/resources/templates/gateway_fragments/signup.properties ================================================ form.username.label=用户名 form.displayName.label=名称 form.email.label=电子邮箱 form.emailCode.label=邮箱验证码 form.emailCode.sendButton=发送 form.emailCode.send.emptyValidation=请先输入邮箱地址 form.password.label=密码 form.confirmPassword.label=确认密码 form.submit=注册 form.error.invalidEmailCode=无效的邮箱验证码 form.error.duplicateUsername=用户名已经被注册 form.error.rateLimitExceeded=请求过于频繁,请稍后再试 form.error.protectedUsernames=用户名或名称被禁止注册 ================================================ FILE: application/src/main/resources/templates/gateway_fragments/signup_en.properties ================================================ form.username.label=Username form.displayName.label=Display Name form.email.label=Email form.emailCode.label=Email Verification Code form.emailCode.sendButton=Send form.emailCode.send.emptyValidation=Please enter your email address first form.password.label=Password form.confirmPassword.label=Confirm Password form.submit=Sign Up form.error.invalidEmailCode=Invalid Email Verification Code form.error.duplicateUsername=Username is already taken form.error.rateLimitExceeded=Too many requests, please try again later form.error.protectedUsernames=The username or display name is not allowed to be registered ================================================ FILE: application/src/main/resources/templates/gateway_fragments/signup_es.properties ================================================ form.username.label=Nombre de Usuario form.displayName.label=Nombre form.email.label=Correo Electrónico form.emailCode.label=Código de Verificación form.emailCode.sendButton=Enviar form.emailCode.send.emptyValidation=Por favor, introduce tu dirección de correo electrónico primero form.password.label=Contraseña form.confirmPassword.label=Confirmar Contraseña form.submit=Registrarse form.error.invalidEmailCode=Código de verificación del correo inválido form.error.duplicateUsername=El nombre de usuario ya está registrado form.error.rateLimitExceeded=Demasiadas solicitudes, por favor intente nuevamente más tarde form.error.protectedUsernames=Este nombre de usuario o nombre no están permitidos ================================================ FILE: application/src/main/resources/templates/gateway_fragments/signup_zh_TW.properties ================================================ form.username.label=使用者名稱 form.displayName.label=名稱 form.email.label=電子郵件 form.emailCode.label=郵箱驗證碼 form.emailCode.sendButton=發送 form.emailCode.send.emptyValidation=請先輸入電子郵件地址 form.password.label=密碼 form.confirmPassword.label=確認密碼 form.submit=註冊 form.error.invalidEmailCode=無效的郵箱驗證碼 form.error.duplicateUsername=使用者名稱已經被註冊 form.error.rateLimitExceeded=請求過於頻繁,請稍後再試 form.error.protectedUsernames=使用者名稱或名稱被禁止註冊 ================================================ FILE: application/src/main/resources/templates/gateway_fragments/totp.html ================================================
================================================ FILE: application/src/main/resources/templates/gateway_fragments/totp.properties ================================================ form.messages.invalidError=错误的验证码 form.code.label=验证码 form.submit=验证 form.cancel=取消 ================================================ FILE: application/src/main/resources/templates/gateway_fragments/totp_en.properties ================================================ form.messages.invalidError=Invalid TOTP code form.code.label=TOTP Code form.submit=Verify form.cancel=Cancel ================================================ FILE: application/src/main/resources/templates/gateway_fragments/totp_es.properties ================================================ form.messages.invalidError=Código de verificación incorrecto form.code.label=Código de Verificación form.submit=Verificar form.cancel=Cancelar ================================================ FILE: application/src/main/resources/templates/gateway_fragments/totp_zh_TW.properties ================================================ form.messages.invalidError=錯誤的驗證碼 form.code.label=驗證碼 form.submit=驗證 form.cancel=取消 ================================================ FILE: application/src/main/resources/templates/login.html ================================================
================================================ FILE: application/src/main/resources/templates/login.properties ================================================ title=登录 ================================================ FILE: application/src/main/resources/templates/login_en.properties ================================================ title=Login ================================================ FILE: application/src/main/resources/templates/login_es.properties ================================================ title=Iniciar Sesión ================================================ FILE: application/src/main/resources/templates/login_local.html ================================================ ================================================ FILE: application/src/main/resources/templates/login_local.properties ================================================ form.username.label=用户名或邮箱地址 form.password.label=密码 form.password.forgot=忘记密码? ================================================ FILE: application/src/main/resources/templates/login_local_en.properties ================================================ form.username.label=Username or email address form.password.label=Password form.password.forgot=Forgot your password? ================================================ FILE: application/src/main/resources/templates/login_local_es.properties ================================================ form.username.label=Nombre de usuario o dirección de correo electrónico form.password.label=Contraseña form.password.forgot=¿Olvidaste tu contraseña? ================================================ FILE: application/src/main/resources/templates/login_local_zh_TW.properties ================================================ form.username.label=使用者名稱或電子郵件地址 form.password.label=密碼 form.password.forgot=忘記密碼? ================================================ FILE: application/src/main/resources/templates/login_zh_TW.properties ================================================ title=登入 ================================================ FILE: application/src/main/resources/templates/logout.html ================================================

================================================ FILE: application/src/main/resources/templates/logout.properties ================================================ title=退出登录 form.title=确定要退出登录吗? ================================================ FILE: application/src/main/resources/templates/logout_en.properties ================================================ title=Logout form.title=Are you sure want to log out? ================================================ FILE: application/src/main/resources/templates/logout_es.properties ================================================ title=Cerrar Sesión form.title=¿Estás seguro de que deseas cerrar sesión? ================================================ FILE: application/src/main/resources/templates/logout_zh_TW.properties ================================================ title=退出登入 form.title=確定要退出登入嗎? ================================================ FILE: application/src/main/resources/templates/password-reset/email/reset.html ================================================

================================================ FILE: application/src/main/resources/templates/password-reset/email/reset.properties ================================================ title=为 {0} 修改密码 ================================================ FILE: application/src/main/resources/templates/password-reset/email/reset_en.properties ================================================ title=Change password for @{0} ================================================ FILE: application/src/main/resources/templates/password-reset/email/reset_es.properties ================================================ title=Cambiar Contraseña para {0} ================================================ FILE: application/src/main/resources/templates/password-reset/email/reset_zh_TW.properties ================================================ title=為 {0} 修改密碼 ================================================ FILE: application/src/main/resources/templates/password-reset/email/send.html ================================================

================================================ FILE: application/src/main/resources/templates/password-reset/email/send.properties ================================================ title=重置密码 sent.title=已发送重置密码的邮件 ================================================ FILE: application/src/main/resources/templates/password-reset/email/send_en.properties ================================================ title=Reset password sent.title=Password reset email has been sent ================================================ FILE: application/src/main/resources/templates/password-reset/email/send_es.properties ================================================ title=Restablecer Contraseña sent.title=Correo de Restablecimiento de Contraseña Enviado ================================================ FILE: application/src/main/resources/templates/password-reset/email/send_zh_TW.properties ================================================ title=重置密碼 sent.title=已發送重置密碼的郵件 ================================================ FILE: application/src/main/resources/templates/setup.html ================================================

================================================ FILE: application/src/main/resources/templates/setup.properties ================================================ title=系统初始化 form.language.label=语言 form.siteTitle.label=站点标题 form.username.label=用户名 form.email.label=电子邮箱 form.password.label=密码 form.confirmPassword.label=确认密码 form.externalUrl.label=外部访问地址 form.submit=初始化 form.messages.h2.title=警告:正在使用 H2 数据库 form.messages.h2.content=H2 数据库仅适用于开发环境和测试环境,不推荐在生产环境中使用,H2 非常容易因为操作不当导致数据文件损坏。如果必须要使用,请按时进行数据备份。 ================================================ FILE: application/src/main/resources/templates/setup_en.properties ================================================ title=Setup form.language.label=Language form.siteTitle.label=Site title form.username.label=Username form.email.label=Email form.password.label=Password form.confirmPassword.label=Confirm Password form.externalUrl.label=External URL form.submit=Setup form.messages.h2.title=Warning: Using H2 Database form.messages.h2.content=The H2 database is only suitable for development and testing environments. It is not recommended for production environments, as H2 is very prone to data file corruption due to improper operations. If you must use it, please back up your data regularly. ================================================ FILE: application/src/main/resources/templates/setup_es.properties ================================================ title=Configuración form.language.label=Idioma form.siteTitle.label=Título del Sitio form.username.label=Nombre de Usuario form.email.label=Correo Electrónico form.password.label=Contraseña form.confirmPassword.label=Confirmar Contraseña form.externalUrl.label=URL Externa form.submit=Configurar form.messages.h2.title=Advertencia: Usando la base de datos H2 form.messages.h2.content=La base de datos H2 solo es adecuada para entornos de desarrollo y prueba. No se recomienda su uso en entornos de producción, ya que H2 es muy susceptible a la corrupción de archivos de datos debido a un manejo inadecuado. Si debe usarla, realice copias de seguridad de los datos regularmente. ================================================ FILE: application/src/main/resources/templates/setup_zh_TW.properties ================================================ title=系統初始化 form.language.label=語言 form.siteTitle.label=站點標題 form.username.label=使用者名稱 form.email.label=電子郵件 form.password.label=密碼 form.confirmPassword.label=確認密碼 form.externalUrl.label=外部訪問地址 form.submit=初始化 form.messages.h2.title=警告:正在使用 H2 資料庫 form.messages.h2.content=H2 資料庫僅適用於開發環境和測試環境,不建議在生產環境中使用,H2 非常容易因為操作不當導致資料檔案損壞。如果必須要使用,請按時進行資料備份。 ================================================ FILE: application/src/main/resources/templates/signup.html ================================================ ================================================ FILE: application/src/main/resources/templates/signup.properties ================================================ title=注册 ================================================ FILE: application/src/main/resources/templates/signup_en.properties ================================================ title=Sign Up ================================================ FILE: application/src/main/resources/templates/signup_es.properties ================================================ title=Registrarse ================================================ FILE: application/src/main/resources/templates/signup_zh_TW.properties ================================================ title=註冊 ================================================ FILE: application/src/main/resources/thumbnailator.properties ================================================ # See https://github.com/coobird/thumbnailator/issues/69 for more thumbnailator.conserveMemoryWorkaround=true ================================================ FILE: application/src/test/java/run/halo/app/ApplicationTests.java ================================================ package run.halo.app; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class ApplicationTests { @Test void contextLoads() { } } ================================================ FILE: application/src/test/java/run/halo/app/PathPrefixPredicateTest.java ================================================ package run.halo.app; import static org.assertj.core.api.Assertions.assertThat; import java.net.URI; import org.junit.jupiter.api.Test; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.method.HandlerTypePredicate; /** * Test case for api path prefix predicate. * * @author guqing * @date 2022-04-13 */ public class PathPrefixPredicateTest { @Test public void prefixPredicate() { boolean falseResult = HandlerTypePredicate.forAnnotation(RestController.class) .and(HandlerTypePredicate.forBasePackage(Application.class.getPackageName())) .test(getClass()); assertThat(falseResult).isFalse(); boolean result = HandlerTypePredicate.forAnnotation(RestController.class) .and(HandlerTypePredicate.forBasePackage(Application.class.getPackageName())) .test(TestController.class); assertThat(result).isTrue(); } @RestController("controller-for-test") @RequestMapping("/test-prefix") class TestController { } @Test void urlTest() { URI uri = URI.create("https:///path"); System.out.println(uri); System.out.println(uri.getPath()); System.out.println(URI.create("/")); } } ================================================ FILE: application/src/test/java/run/halo/app/XForwardHeaderTest.java ================================================ package run.halo.app; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; import static org.springframework.web.reactive.function.server.RequestPredicates.GET; import static org.springframework.web.reactive.function.server.RouterFunctions.route; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.context.annotation.Bean; import org.springframework.http.HttpStatus; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.test.StepVerifier; @SpringBootTest(webEnvironment = RANDOM_PORT, properties = "server.forward-headers-strategy=native") class XForwardHeaderTest { @LocalServerPort int port; @Test void shouldGetCorrectProtoFromXForwardHeaders() { var response = WebClient.create("http://localhost:" + port) .get().uri("/print-uri") .header("X-Forwarded-Proto", "https") .header("X-Forwarded-Host", "halo.run") .header("X-Forwarded-Port", "6666") .retrieve() .toEntity(String.class); StepVerifier.create(response) .assertNext(entity -> { assertEquals(HttpStatus.OK, entity.getStatusCode()); assertEquals("\"https://halo.run:6666/print-uri\"", entity.getBody()); }) .verifyComplete(); } @TestConfiguration static class Configuration { @Bean RouterFunction printUri() { return route(GET("/print-uri"), request -> { var uri = request.exchange().getRequest().getURI(); return ServerResponse.ok().bodyValue(uri); }); } } } ================================================ FILE: application/src/test/java/run/halo/app/config/CorsTest.java ================================================ package run.halo.app.config; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.reactive.server.WebTestClient; @SpringBootTest @AutoConfigureWebTestClient class CorsTest { @Autowired WebTestClient webClient; @Nested class RequestCorsEnabledApi { @Test @WithMockUser void shouldNotResponseAllowOriginHeaderWithSameOrigin() { webClient.get().uri("http://localhost:3000/apis/cors-enabled") .header(HttpHeaders.ORIGIN, "http://localhost:3000") .header(HttpHeaders.AUTHORIZATION, "fake-authorization") .header("FakeHeader", "fake-header-value") .accept(MediaType.APPLICATION_JSON) .exchange() .expectHeader() .doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN); } @Test @WithMockUser void shouldResponseAllowOriginHeaderWithDifferentOrigin() { webClient.get().uri("http://localhost:3000/apis/cors-enabled") .header(HttpHeaders.ORIGIN, "https://another.website") .header(HttpHeaders.AUTHORIZATION, "fake-authorization") // .header("ForbiddenHeader", "fake value") .accept(MediaType.APPLICATION_JSON) .exchange() .expectHeader() .exists(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN); } @Test @WithMockUser void shouldResponseAllowOriginHeaderWithForbiddenHeader() { webClient.get().uri("http://localhost:3000/apis/cors-enabled") .header(HttpHeaders.ORIGIN, "https://another.website") .header(HttpHeaders.AUTHORIZATION, "fake-authorization") .header("FakeHeader", "fake-header-value") // .header("ForbiddenHeader", "fake value") .accept(MediaType.APPLICATION_JSON) .exchange() .expectHeader() .exists(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN); } } @Nested class RequestCorsDisabledApi { @Test @WithMockUser void shouldNotResponseAllowOriginHeaderWithDifferentOrigin() { webClient.get().uri("http://localhost:3000/cors-disabled") .header(HttpHeaders.ORIGIN, "https://another.website") .header(HttpHeaders.AUTHORIZATION, "fake-authorization") .accept(MediaType.APPLICATION_JSON) .exchange() .expectHeader() .doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN); } @Test @WithMockUser void shouldNotResponseAllowOriginHeaderWithSameOrigin() { webClient.get().uri("http://localhost:3000/cors-disabled") .header(HttpHeaders.ORIGIN, "http://localhost:3000") .header(HttpHeaders.AUTHORIZATION, "fake-authorization") .header("FakeHeader", "fake-header-value") .accept(MediaType.APPLICATION_JSON) .exchange() .expectHeader() .doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN); } } } ================================================ FILE: application/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java ================================================ package run.halo.app.config; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; import java.time.Instant; import java.util.List; import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Flux; import run.halo.app.core.extension.Role; import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.Metadata; import run.halo.app.extension.Scheme; import run.halo.app.extension.SchemeManager; import run.halo.app.extension.store.ExtensionStoreRepository; @DirtiesContext @SpringBootTest @AutoConfigureWebTestClient class ExtensionConfigurationTest { @Autowired WebTestClient webClient; @Autowired SchemeManager schemeManager; @MockitoBean RoleService roleService; @BeforeEach void setUp() { // disable authorization var rule = new Role.PolicyRule.Builder() .apiGroups("*") .resources("*") .verbs("*") .build(); var role = new Role(); role.setMetadata(new Metadata()); role.getMetadata().setName("supper-role"); role.setRules(List.of(rule)); when(roleService.listDependenciesFlux(anySet())).thenReturn(Flux.just(role)); // register scheme schemeManager.register(FakeExtension.class); webClient = webClient.mutateWith(csrf()); } @AfterEach void cleanUp(@Autowired ExtensionStoreRepository repository) { var gvk = Scheme.buildFromType(FakeExtension.class).groupVersionKind(); repository.deleteAll().block(); schemeManager.fetch(GroupVersionKind.fromExtension(FakeExtension.class)) .ifPresent(scheme -> schemeManager.unregister(scheme)); } @Test @WithMockUser void shouldReturnNotFoundWhenSchemeNotRegistered() { // unregister the Extension if necessary schemeManager.fetch(Scheme.buildFromType(FakeExtension.class).groupVersionKind()) .ifPresent(schemeManager::unregister); webClient.get() .uri("/apis/fake.halo.run/v1alpha1/fakes") .exchange() .expectStatus().isNotFound(); webClient.get() .uri("/apis/fake.halo.run/v1alpha1/fakes/my-fake") .exchange() .expectStatus().isNotFound(); webClient.post() .uri("/apis/fake.halo.run/v1alpha1/fakes") .bodyValue(new FakeExtension()) .exchange() .expectStatus().isNotFound(); webClient.put() .uri("/apis/fake.halo.run/v1alpha1/fakes/my-fake") .bodyValue(new FakeExtension()) .exchange() .expectStatus().isNotFound(); webClient.delete() .uri("/apis/fake.halo.run/v1alpha1/fakes/my-fake") .exchange() .expectStatus().isNotFound(); } @Nested @DisplayName("After creating extension") class AfterCreatingExtension { @Autowired ExtensionClient extClient; FakeExtension createdFake; @BeforeEach void setUp() { var metadata = new Metadata(); metadata.setName("my-fake"); metadata.setLabels(Map.of("label-key", "label-value")); var fake = new FakeExtension(); fake.setMetadata(metadata); webClient.get() .uri("/apis/fake.halo.run/v1alpha1/fakes/{}", metadata.getName()) .exchange() .expectStatus().isNotFound(); createdFake = webClient.post() .uri("/apis/fake.halo.run/v1alpha1/fakes") .contentType(MediaType.APPLICATION_JSON) .bodyValue(fake) .exchange() .expectStatus().isCreated() .expectHeader().location("/apis/fake.halo.run/v1alpha1/fakes/my-fake") .expectBody(FakeExtension.class) .consumeWith(result -> { var gotFake = result.getResponseBody(); assertNotNull(gotFake); assertEquals("my-fake", gotFake.getMetadata().getName()); assertNotNull(gotFake.getMetadata().getVersion()); assertNotNull(gotFake.getMetadata().getCreationTimestamp()); }) .returnResult() .getResponseBody(); } @Test @WithMockUser void shouldDeleteExtensionWhenSchemeRegistered() { webClient.delete() .uri("/apis/fake.halo.run/v1alpha1/fakes/{name}", createdFake.getMetadata().getName()) .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody(FakeExtension.class) .consumeWith(result -> { var deletedFake = result.getResponseBody(); assertNotNull(deletedFake); assertNotNull(deletedFake.getMetadata().getDeletionTimestamp()); assertTrue(deletedFake.getMetadata().getDeletionTimestamp() .isBefore(Instant.now())); }); } @Test @WithMockUser void shouldListExtensionsWhenSchemeRegistered() { webClient.get().uri("/apis/fake.halo.run/v1alpha1/fakes") .exchange() .expectStatus().isOk() .expectBody().jsonPath("$.items.length()").isEqualTo(1); } @Test @WithMockUser void shouldListExtensionsWithMatchedSelectors() { webClient.get().uri(uriBuilder -> uriBuilder .path("/apis/fake.halo.run/v1alpha1/fakes") .queryParam("labelSelector", "label-key=label-value") .queryParam("fieldSelector", "name=my-fake") .build()) .exchange() .expectStatus().isOk() .expectBody().jsonPath("$.items.length()").isEqualTo(1); } @Test @WithMockUser void shouldListExtensionsWithMismatchedSelectors() { webClient.get().uri(uriBuilder -> uriBuilder .path("/apis/fake.halo.run/v1alpha1/fakes") .queryParam("labelSelector", "label-key=invalid-label-value") .queryParam("fieldSelector", "name=invalid-name") .build()) .exchange() .expectStatus().isOk() .expectBody().jsonPath("$.items.length()").isEqualTo(0); } @Test @WithMockUser void shouldUpdateExtensionWhenSchemeRegistered() { var name = createdFake.getMetadata().getName(); FakeExtension fakeToUpdate = getFakeExtension(name); fakeToUpdate.getMetadata().setLabels(Map.of("updated", "true")); webClient.put() .uri("/apis/fake.halo.run/v1alpha1/fakes/{name}", name) .bodyValue(fakeToUpdate) .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody(FakeExtension.class) .consumeWith(result -> { var updatedFake = result.getResponseBody(); assertNotNull(updatedFake); assertNotEquals(fakeToUpdate.getMetadata().getVersion(), updatedFake.getMetadata().getVersion()); assertEquals(Map.of("updated", "true"), updatedFake.getMetadata().getLabels()); }); } @Test @WithMockUser void shouldGetExtensionWhenSchemeRegistered() { var name = createdFake.getMetadata().getName(); webClient.get() .uri("/apis/fake.halo.run/v1alpha1/fakes/{name}", name) .exchange() .expectStatus().isOk() .expectBody(FakeExtension.class) .consumeWith(result -> { var gotFake = result.getResponseBody(); assertNotNull(gotFake); assertEquals(name, gotFake.getMetadata().getName()); assertNotNull(gotFake.getMetadata().getVersion()); assertNotNull(gotFake.getMetadata().getCreationTimestamp()); }); } FakeExtension getFakeExtension(String name) { return webClient.get() .uri("/apis/fake.halo.run/v1alpha1/fakes/{name}", name) .exchange() .expectStatus().isOk() .expectBody(FakeExtension.class) .returnResult() .getResponseBody(); } } } ================================================ FILE: application/src/test/java/run/halo/app/config/HaloConfigurationTest.java ================================================ package run.halo.app.config; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.DirtiesContext; import run.halo.app.search.SearchEngine; import run.halo.app.search.lucene.LuceneSearchEngine; class HaloConfigurationTest { @Nested @SpringBootTest class LuceneSearchEngineDisabled { @Test void shouldNotCreateLuceneSearchEngineBean( @Autowired ObjectProvider searchEngines) { var searchEngine = searchEngines.getIfAvailable(); assertNull(searchEngine); } } @Nested @SpringBootTest(properties = "halo.search-engine.lucene.enabled=true") @DirtiesContext class LuceneSearchEngineEnabled { @Test void shouldCreateLuceneSearchEngineBean( @Autowired ObjectProvider searchEngines) { var searchEngine = searchEngines.getIfAvailable(); assertNotNull(searchEngine); assertInstanceOf(LuceneSearchEngine.class, searchEngine); } } } ================================================ FILE: application/src/test/java/run/halo/app/config/SecurityConfigTest.java ================================================ package run.halo.app.config; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.springframework.security.web.server.header.StrictTransportSecurityServerHttpHeadersWriter.STRICT_TRANSPORT_SECURITY; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.http.MediaType; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.web.reactive.server.WebTestClient; @SpringBootTest @AutoConfigureWebTestClient class SecurityConfigTest { @Autowired WebTestClient webClient; @Test void shouldNotIncludeSubdomainForHstsHeader() { webClient.get() .uri(builder -> builder.scheme("https").path("/fake").build()) .accept(MediaType.TEXT_HTML) .exchange() .expectHeader() .value(STRICT_TRANSPORT_SECURITY, hsts -> assertFalse(hsts.contains("includeSubDomains"))); webClient.get() .uri(builder -> builder.scheme("https").path("/apis/fake").build()) .accept(MediaType.APPLICATION_JSON) .exchange() .expectHeader() .value(STRICT_TRANSPORT_SECURITY, hsts -> assertFalse(hsts.contains("includeSubDomains"))); } @Test void shouldAllowPasswordLengthMoreThan72(@Autowired PasswordEncoder passwordEncoder) { var encoded = passwordEncoder.encode(RandomStringUtils.secure().nextAlphanumeric(73)); assertNotNull(encoded); } } ================================================ FILE: application/src/test/java/run/halo/app/config/ServerCodecTest.java ================================================ package run.halo.app.config; import static org.hamcrest.Matchers.equalTo; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; import static org.springframework.web.reactive.function.server.RouterFunctions.route; import java.time.Instant; import java.time.LocalDateTime; import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; @SpringBootTest @AutoConfigureWebTestClient @Import(ServerCodecTest.TestConfig.class) class ServerCodecTest { static final String INSTANT = "2022-06-09T10:57:30Z"; static final String LOCAL_DATE_TIME = "2022-06-10T10:57:30"; @Autowired WebTestClient webClient; @Test @WithMockUser void timeSerializationTest() { webClient.get().uri("/fake/api/times") .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.instant").value(equalTo(INSTANT)) .jsonPath("$.localDateTime").value(equalTo(LOCAL_DATE_TIME)) ; } @Test @WithMockUser void timeDeserializationTest() { webClient .mutateWith(csrf()) .post().uri("/fake/api/time/report") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .bodyValue(Map.of("now", Instant.parse(INSTANT))) .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody(new ParameterizedTypeReference>() { }).isEqualTo(Map.of("now", Instant.parse(INSTANT))) ; } @TestConfiguration(proxyBeanMethods = false) static class TestConfig { @Bean RouterFunction timesRouter() { return route().GET("/fake/api/times", request -> { var times = Map.of("instant", Instant.parse(INSTANT), "localDateTime", LocalDateTime.parse(LOCAL_DATE_TIME)); return ServerResponse .ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(times); }).build(); } @Bean RouterFunction reportTime() { final var type = new ParameterizedTypeReference>() { }; return route().POST("/fake/api/time/report", contentType(MediaType.APPLICATION_JSON).and(accept(MediaType.APPLICATION_JSON)), request -> ServerResponse.ok() .body(request.bodyToMono(type), type)) .build(); } } } ================================================ FILE: application/src/test/java/run/halo/app/config/WebFluxConfigTest.java ================================================ package run.halo.app.config; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; import java.net.URI; import java.util.List; import java.util.Set; import org.hamcrest.core.StringStartsWith; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.filter.reactive.ServerWebExchangeContextFilter; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.socket.WebSocketHandler; import org.springframework.web.reactive.socket.WebSocketMessage; import org.springframework.web.reactive.socket.client.ReactorNettyWebSocketClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.endpoint.WebSocketEndpoint; import run.halo.app.core.extension.Role; import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.Metadata; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Import({ WebFluxConfigTest.WebSocketSupportTest.TestWebSocketConfiguration.class, WebFluxConfigTest.ServerWebExchangeContextFilterTest.TestConfig.class, WebFluxConfigTest.UrlHandlerFilterTest.TestConfig.class }) @AutoConfigureWebTestClient class WebFluxConfigTest { @Autowired WebTestClient webClient; @MockitoSpyBean RoleService roleService; @LocalServerPort int port; @Nested class WebSocketSupportTest { @Test void shouldInitializeWebSocketEndpoint() { var role = new Role(); var metadata = new Metadata(); metadata.setName("fake-role"); role.setMetadata(metadata); role.setRules(List.of(new Role.PolicyRule.Builder() .apiGroups("fake.halo.run") .verbs("watch") .resources("resources") .build())); when(roleService.listDependenciesFlux(Set.of("anonymous"))).thenReturn(Flux.just(role)); var webSocketClient = new ReactorNettyWebSocketClient(); webSocketClient.execute( URI.create("ws://localhost:" + port + "/apis/fake.halo.run/v1alpha1/resources"), session -> { var send = session.send(Flux.just(session.textMessage("halo"))); var receive = session.receive().map(WebSocketMessage::getPayloadAsText) .next() .doOnNext(message -> assertEquals("HALO", message)); return send.and(receive); }) .as(StepVerifier::create) .verifyComplete(); } @TestConfiguration static class TestWebSocketConfiguration { @Bean WebSocketEndpoint fakeWebSocketEndpoint() { return new FakeWebSocketEndpoint(); } } static class FakeWebSocketEndpoint implements WebSocketEndpoint { @Override public String urlPath() { return "/resources"; } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("fake.halo.run/v1alpha1"); } @Override public WebSocketHandler handler() { return session -> { var messages = session.receive() .map(message -> session.textMessage( message.getPayloadAsText().toUpperCase()) ); return session.send(messages).then(session.close()); }; } } } @Nested class UiPageRequest { @WithMockUser @ParameterizedTest @ValueSource(strings = { "/console", "/console/index", "/console/index.html", "/console/dashboard", "/console/fake" }) void shouldRequestConsoleIndex(String uri) { webClient.get().uri(uri) .exchange() .expectStatus().isOk() .expectBody(String.class).value(StringStartsWith.startsWith("console index")); } @WithMockUser @ParameterizedTest @ValueSource(strings = { "/uc", "/uc/index", "/uc/index.html", "/uc/profile", "/uc/fake" }) void shouldRequestUcIndex(String uri) { webClient.get().uri(uri) .exchange() .expectStatus().isOk() .expectBody(String.class).value(StringStartsWith.startsWith("uc index")); } @Test void shouldRedirectToLoginPageIfUnauthenticated() { webClient.get().uri("/console") .exchange() .expectStatus().isFound() .expectHeader().location("/login?authentication_required"); } @Test @WithMockUser void shouldRequestUiAssetsCorrectly() { webClient.get().uri("/ui-assets/fake.txt") .exchange() .expectStatus().isOk() .expectBody(String.class).value(StringStartsWith.startsWith("fake.")); } @Test @WithMockUser void shouldResponseNotFoundWhenAssetsNotExist() { webClient.get().uri("/ui-assets/not-found.txt") .exchange() .expectStatus().isNotFound(); } } @Nested class StaticResourcesTest { @Test void shouldRespond404WhenThemeResourceNotFound() { webClient.get().uri("/themes/fake-theme/assets/favicon.ico") .exchange() .expectStatus().isNotFound(); } } @Nested class ServerWebExchangeContextFilterTest { @TestConfiguration static class TestConfig { @Bean RouterFunction assertServerWebExchangeRoute() { return RouterFunctions.route() .GET("/assert-server-web-exchange", request -> Mono.deferContextual(contextView -> { var exchange = ServerWebExchangeContextFilter.getExchange(contextView); assertTrue(exchange.isPresent()); return ServerResponse.ok().build(); })) .build(); } } @Test void shouldGetExchangeFromContextView() { webClient.get().uri("/assert-server-web-exchange") .exchange() .expectStatus().isOk(); } } @Nested class UrlHandlerFilterTest { @TestConfiguration static class TestConfig { @Bean RouterFunction urlHandlerFilterTestRoute() { return RouterFunctions.route() .GET("/fake", request -> ServerResponse.ok().bodyValue("ok")) .build(); } } @Test void shouldHandleUrlWithTrailingSlash() { webClient.get().uri("/fake/") .exchange() .expectStatus().isOk() .expectBody(String.class).isEqualTo("ok"); } } } ================================================ FILE: application/src/test/java/run/halo/app/content/CategoryPostCountUpdaterTest.java ================================================ package run.halo.app.content; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; import java.time.Duration; import java.util.List; import java.util.Set; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import reactor.util.retry.Retry; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.Extension; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.SchemeManager; /** * Tests for {@link CategoryPostCountUpdater}. * * @author guqing * @since 2.15.0 */ @SpringBootTest class CategoryPostCountUpdaterTest { private final List storedPosts = posts(); private final List storedCategories = categories(); @Autowired SchemeManager schemeManager; @MockitoSpyBean ExtensionClient client; @Autowired ReactiveExtensionClient reactiveClient; private CategoryPostCountUpdater.CategoryPostCountService categoryPostCountService; Mono deleteImmediately(Extension extension) { var name = extension.getMetadata().getName(); var scheme = schemeManager.get(extension.getClass()); return reactiveClient.fetch(scheme.type(), name) .flatMap(reactiveClient::delete) .flatMap(deleting -> reactiveClient.fetch(scheme.type(), name) .flatMap(e -> Mono.error(new IllegalStateException("Extension still exists"))) .retryWhen(Retry.backoff(10, Duration.ofMillis(100)) .filter(IllegalStateException.class::isInstance) ) .thenReturn(deleting) ); } @BeforeEach void setUp() { categoryPostCountService = new CategoryPostCountUpdater.CategoryPostCountService(client); Flux.fromIterable(storedPosts) .flatMap(post -> reactiveClient.create(post)) .as(StepVerifier::create) .expectNextCount(storedPosts.size()) .verifyComplete(); Flux.fromIterable(storedCategories) .flatMap(category -> reactiveClient.create(category)) .as(StepVerifier::create) .expectNextCount(storedCategories.size()) .verifyComplete(); } @AfterEach void tearDown() { Flux.fromIterable(storedPosts) .flatMap(this::deleteImmediately) .as(StepVerifier::create) .expectNextCount(storedPosts.size()) .verifyComplete(); Flux.fromIterable(storedCategories) .flatMap(this::deleteImmediately) .as(StepVerifier::create) .expectNextCount(storedCategories.size()) .verifyComplete(); } @Test void reconcileStatusPostForCategoryA() { categoryPostCountService.recalculatePostCount(Set.of("category-A")); ArgumentCaptor captor = ArgumentCaptor.forClass(Category.class); verify(client).update(captor.capture()); var value = captor.getValue(); assertThat(value.getStatusOrDefault().getPostCount()).isEqualTo(1); assertThat(value.getStatusOrDefault().getVisiblePostCount()).isEqualTo(0); } @Test void reconcileStatusPostForCategoryB() { categoryPostCountService.recalculatePostCount(Set.of("category-B")); ArgumentCaptor captor = ArgumentCaptor.forClass(Category.class); verify(client).update(captor.capture()); var category = captor.getValue(); assertThat(category.getStatusOrDefault().getPostCount()).isEqualTo(1); assertThat(category.getStatusOrDefault().getVisiblePostCount()).isEqualTo(0); } @Test void reconcileStatusPostForCategoryC() { categoryPostCountService.recalculatePostCount(Set.of("category-C")); ArgumentCaptor captor = ArgumentCaptor.forClass(Category.class); verify(client).update(captor.capture()); var value = captor.getValue(); assertThat(value.getStatusOrDefault().getPostCount()).isEqualTo(2); assertThat(value.getStatusOrDefault().getVisiblePostCount()).isEqualTo(0); } @Test void reconcileStatusPostForCategoryD() { categoryPostCountService.recalculatePostCount(Set.of("category-D")); ArgumentCaptor captor = ArgumentCaptor.forClass(Category.class); verify(client).update(captor.capture()); var value = captor.getValue(); assertThat(value.getStatusOrDefault().postCount).isEqualTo(1); assertThat(value.getStatusOrDefault().visiblePostCount).isEqualTo(0); } private List categories() { /* * |-A(post-4) * |-B(post-3) * |-|-C(post-2,post-1) * |-D(post-1) */ Category categoryA = category("category-A"); categoryA.getSpec().setChildren(List.of("category-B", "category-D")); Category categoryB = category("category-B"); categoryB.getSpec().setChildren(List.of("category-C")); Category categoryC = category("category-C"); Category categoryD = category("category-D"); return List.of(categoryA, categoryB, categoryC, categoryD); } private Category category(String name) { Category category = new Category(); Metadata metadata = new Metadata(); metadata.setName(name); category.setMetadata(metadata); category.setSpec(new Category.CategorySpec()); category.setStatus(new Category.CategoryStatus()); category.getSpec().setDisplayName("display-name"); category.getSpec().setSlug("slug"); category.getSpec().setPriority(0); return category; } private List posts() { /* * |-A(post-4) * |-B(post-3) * |-|-C(post-2,post-1) * |-D(post-1) */ Post post1 = fakePost(); post1.getMetadata().setName("post-1"); post1.getSpec().setCategories(List.of("category-D", "category-C")); post1.getSpec().setVisible(Post.VisibleEnum.PUBLIC); Post post2 = fakePost(); post2.getMetadata().setName("post-2"); post2.getSpec().setCategories(List.of("category-C")); post2.getSpec().setVisible(Post.VisibleEnum.PUBLIC); Post post3 = fakePost(); post3.getMetadata().setName("post-3"); post3.getSpec().setCategories(List.of("category-B")); post3.getSpec().setVisible(Post.VisibleEnum.PUBLIC); Post post4 = fakePost(); post4.getMetadata().setName("post-4"); post4.getSpec().setCategories(List.of("category-A")); post4.getSpec().setVisible(Post.VisibleEnum.PUBLIC); return List.of(post1, post2, post3, post4); } Post fakePost() { var post = TestPost.postV1(); post.getSpec().setAllowComment(true); post.getSpec().setDeleted(false); post.getSpec().setExcerpt(new Post.Excerpt()); post.getSpec().getExcerpt().setAutoGenerate(false); post.getSpec().setPinned(false); post.getSpec().setPriority(0); post.getSpec().setPublish(false); post.getSpec().setSlug("fake-post"); return post; } } ================================================ FILE: application/src/test/java/run/halo/app/content/ContentRequestTest.java ================================================ package run.halo.app.content; import org.json.JSONException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.extension.Ref; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link ContentRequest}. * * @author guqing * @since 2.0.0 */ class ContentRequestTest { private ContentRequest contentRequest; @BeforeEach void setUp() { Ref ref = new Ref(); ref.setKind(Post.KIND); ref.setGroup("content.halo.run"); ref.setName("test-post"); contentRequest = new ContentRequest(ref, "snapshot-1", null, """ Four score and seven years ago our fathers brought forth on this continent """, """

Four score and seven

years ago our fathers


brought forth on this continent

""", "MARKDOWN"); } @Test void toSnapshot() throws JSONException { String expectedContentPath = "

Four score and seven

\\n

years ago our fathers

\\n
\\n

brought forth " + "on this continent

\\n"; String expectedRawPatch = "Four score and seven\\nyears ago our fathers\\n\\nbrought forth on this continent\\n"; Snapshot snapshot = contentRequest.toSnapshot(); snapshot.getMetadata().setName("7b149646-ac60-4a5c-98ee-78b2dd0631b2"); JSONAssert.assertEquals(JsonUtils.objectToJson(snapshot), """ { "spec": { "subjectRef": { "kind": "Post", "group": "content.halo.run", "name": "test-post" }, "rawType": "MARKDOWN", "rawPatch": "%s", "contentPatch": "%s" }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Snapshot", "metadata": { "name": "7b149646-ac60-4a5c-98ee-78b2dd0631b2", "annotations": {} } } """.formatted(expectedRawPatch, expectedContentPath), true); } @Test void rawPatchFrom() throws JSONException { String s = contentRequest.rawPatchFrom(""" Four score and seven years ago our fathers """); JSONAssert.assertEquals(s, """ [ { "source": { "position": 3, "lines": [] }, "target": { "position": 3, "lines": [ "brought forth on this continent", "" ] }, "type": "INSERT" } ] """, true); } @Test void contentPatchFrom() throws JSONException { String s = contentRequest.contentPatchFrom("""

Four score and seven

years ago our fathers

"""); JSONAssert.assertEquals(s, """ [ { "source": { "position": 2, "lines": [] }, "target": { "position": 2, "lines": [ "
", "

brought forth on this continent

" ] }, "type": "INSERT" } ] """, true); } } ================================================ FILE: application/src/test/java/run/halo/app/content/PostIntegrationTests.java ================================================ package run.halo.app.content; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Flux; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.content.Post; import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.Metadata; import run.halo.app.extension.MetadataOperator; import run.halo.app.infra.utils.JsonUtils; /** * Integration tests for {@link PostService}. * * @author guqing * @since 2.0.0 */ @SpringBootTest @AutoConfigureWebTestClient @WithMockUser(username = "fake-user", password = "fake-password", roles = "fake-super-role") public class PostIntegrationTests { @Autowired private WebTestClient webTestClient; @MockitoBean RoleService roleService; @BeforeEach void setUp() { var rule = new Role.PolicyRule.Builder() .apiGroups("*") .resources("*") .verbs("*") .build(); var role = new Role(); role.setMetadata(new Metadata()); role.getMetadata().setName("super-role"); role.setRules(List.of(rule)); when(roleService.listDependenciesFlux(anySet())).thenReturn(Flux.just(role)); webTestClient = webTestClient.mutateWith(csrf()); } @Test void draftPost() { webTestClient.post() .uri("/apis/api.console.halo.run/v1alpha1/posts") .contentType(MediaType.APPLICATION_JSON) .bodyValue(postDraftRequest()) .exchange() .expectBody(Post.class) .value(post -> { MetadataOperator metadata = post.getMetadata(); Post.PostSpec spec = post.getSpec(); assertThat(spec.getTitle()).isEqualTo("无标题文章"); assertThat(metadata.getCreationTimestamp()).isNotNull(); assertThat(metadata.getName()).startsWith("post-"); assertThat(spec.getHeadSnapshot()).isNotNull(); assertThat(spec.getHeadSnapshot()).isEqualTo(spec.getBaseSnapshot()); assertThat(spec.getOwner()).isEqualTo("fake-user"); assertThat(post.getStatus()).isNotNull(); assertThat(post.getStatus().getPhase()).isEqualTo("DRAFT"); assertThat(post.getStatus().getConditions().peek().getType()).isEqualTo("DRAFT"); }); } @Test void draftPostAsPublish() { PostRequest postRequest = postDraftRequest(); postRequest.post().getSpec().setPublish(true); webTestClient.post() .uri("/apis/api.console.halo.run/v1alpha1/posts") .contentType(MediaType.APPLICATION_JSON) .bodyValue(postRequest) .exchange() .expectBody(Post.class) .value(post -> { assertThat(post.getSpec().getReleaseSnapshot()).isNotNull(); assertThat(post.getSpec().getReleaseSnapshot()) .isEqualTo(post.getSpec().getHeadSnapshot()); assertThat(post.getSpec().getHeadSnapshot()) .isEqualTo(post.getSpec().getBaseSnapshot()); }); } PostRequest postDraftRequest() { String s = """ { "post": { "spec": { "title": "无标题文章", "slug": "41c2ad39-21b4-45e4-a36b-5768245a0555", "template": "", "cover": "", "deleted": false, "publish": true, "publishTime": "", "pinned": false, "allowComment": true, "visible": "PUBLIC", "version": 1, "priority": 0, "excerpt": { "autoGenerate": true, "raw": "" }, "categories": [], "tags": [], "htmlMetas": [] }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Post", "metadata": { "name": "", "generateName": "post-" } }, "content": { "raw": "

hello world

", "content": "

hello world

", "rawType": "HTML" } } """; return JsonUtils.jsonToObject(s, PostRequest.class); } } ================================================ FILE: application/src/test/java/run/halo/app/content/TestPost.java ================================================ package run.halo.app.content; import java.time.Instant; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.Metadata; import run.halo.app.extension.MetadataUtil; /** * @author guqing * @since 2.0.0 */ public class TestPost { public static Post postV1() { Post post = new Post(); post.setKind(Post.KIND); post.setApiVersion(getApiVersion(Post.class)); Metadata metadata = new Metadata(); metadata.setName("post-A"); metadata.setVersion(1L); post.setMetadata(metadata); Post.PostSpec postSpec = new Post.PostSpec(); post.setSpec(postSpec); postSpec.setTitle("post-A"); postSpec.setBaseSnapshot(snapshotV1().getMetadata().getName()); postSpec.setHeadSnapshot("base-snapshot"); postSpec.setReleaseSnapshot(null); return post; } public static Snapshot snapshotV1() { Snapshot snapshot = new Snapshot(); snapshot.setKind(Snapshot.KIND); snapshot.setApiVersion(getApiVersion(Snapshot.class)); Metadata metadata = new Metadata(); metadata.setName("snapshot-A"); metadata.setVersion(1L); metadata.setCreationTimestamp(Instant.now()); snapshot.setMetadata(metadata); MetadataUtil.nullSafeAnnotations(snapshot).put(Snapshot.KEEP_RAW_ANNO, "true"); Snapshot.SnapShotSpec spec = new Snapshot.SnapShotSpec(); snapshot.setSpec(spec); Snapshot.addContributor(snapshot, "guqing"); spec.setRawType("MARKDOWN"); spec.setRawPatch("A"); spec.setContentPatch("

A

"); return snapshot; } public static Snapshot snapshotV2() { Snapshot snapshot = new Snapshot(); snapshot.setKind(Snapshot.KIND); snapshot.setApiVersion(getApiVersion(Snapshot.class)); Metadata metadata = new Metadata(); metadata.setCreationTimestamp(Instant.now().plusSeconds(10)); metadata.setName("snapshot-B"); snapshot.setMetadata(metadata); Snapshot.SnapShotSpec spec = new Snapshot.SnapShotSpec(); snapshot.setSpec(spec); Snapshot.addContributor(snapshot, "guqing"); spec.setRawType("MARKDOWN"); spec.setRawPatch(PatchUtils.diffToJsonPatch("A", "B")); spec.setContentPatch(PatchUtils.diffToJsonPatch("

A

", "

B

")); return snapshot; } public static Snapshot snapshotV3() { Snapshot snapshotV3 = snapshotV2(); snapshotV3.getMetadata().setName("snapshot-C"); snapshotV3.getMetadata().setCreationTimestamp(Instant.now().plusSeconds(20)); Snapshot.SnapShotSpec spec = snapshotV3.getSpec(); Snapshot.addContributor(snapshotV3, "guqing"); spec.setRawType("MARKDOWN"); spec.setRawPatch(PatchUtils.diffToJsonPatch("B", "C")); spec.setContentPatch(PatchUtils.diffToJsonPatch("

B

", "

C

")); return snapshotV3; } public static String getApiVersion(Class extension) { GVK annotation = extension.getAnnotation(GVK.class); return annotation.group() + "/" + annotation.version(); } } ================================================ FILE: application/src/test/java/run/halo/app/content/comment/CommentEmailOwnerTest.java ================================================ package run.halo.app.content.comment; import org.json.JSONException; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import run.halo.app.core.extension.content.Comment; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link CommentEmailOwner}. * * @author guqing * @since 2.0.0 */ class CommentEmailOwnerTest { @Test void constructorTest() throws JSONException { CommentEmailOwner commentEmailOwner = new CommentEmailOwner("example@example.com", "avatar", "displayName", "website"); JSONAssert.assertEquals(""" { "email": "example@example.com", "avatar": "avatar", "displayName": "displayName", "website": "website" } """, JsonUtils.objectToJson(commentEmailOwner), true); } @Test void toCommentOwner() throws JSONException { CommentEmailOwner commentEmailOwner = new CommentEmailOwner("example@example.com", "avatar", "displayName", "website"); Comment.CommentOwner commentOwner = commentEmailOwner.toCommentOwner(); JSONAssert.assertEquals(""" { "kind": "Email", "name": "example@example.com", "displayName": "displayName", "annotations": { "website": "website", "avatar": "avatar" } } """, JsonUtils.objectToJson(commentOwner), true); } } ================================================ FILE: application/src/test/java/run/halo/app/content/comment/CommentNotificationReasonPublisherTest.java ================================================ package run.halo.app.content.comment; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.content.NotificationReasonConst; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Reply; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.core.extension.notification.Reason; import run.halo.app.event.post.CommentCreatedEvent; import run.halo.app.event.post.ReplyCreatedEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.Metadata; import run.halo.app.extension.Ref; import run.halo.app.infra.ExternalLinkProcessor; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.notification.NotificationReasonEmitter; import run.halo.app.notification.ReasonPayload; import run.halo.app.notification.UserIdentity; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** * Tests for {@link CommentNotificationReasonPublisher}. * * @author guqing * @since 2.9.0 */ @ExtendWith(MockitoExtension.class) class CommentNotificationReasonPublisherTest { @Mock private ExtensionClient client; @Mock CommentNotificationReasonPublisher.NewCommentOnPostReasonPublisher newCommentOnPostReasonPublisher; @Mock CommentNotificationReasonPublisher.NewCommentOnPageReasonPublisher newCommentOnPageReasonPublisher; @Mock CommentNotificationReasonPublisher.NewReplyReasonPublisher newReplyReasonPublisher; @InjectMocks private CommentNotificationReasonPublisher reasonPublisher; @Test void onNewCommentTest() { var comment = mock(Comment.class); var spyReasonPublisher = spy(reasonPublisher); doReturn(true).when(spyReasonPublisher).isPostComment(eq(comment)); var event = new CommentCreatedEvent(this, comment); spyReasonPublisher.onNewComment(event); verify(newCommentOnPostReasonPublisher).publishReasonBy(eq(comment)); doReturn(false).when(spyReasonPublisher).isPostComment(eq(comment)); doReturn(true).when(spyReasonPublisher).isPageComment(eq(comment)); spyReasonPublisher.onNewComment(event); verify(newCommentOnPageReasonPublisher).publishReasonBy(eq(comment)); } @Test void onNewReplyTest() { var reply = mock(Reply.class); var spec = mock(Reply.ReplySpec.class); when(reply.getSpec()).thenReturn(spec); when(spec.getCommentName()).thenReturn("fake-comment"); var spyReasonPublisher = spy(reasonPublisher); var comment = mock(Comment.class); when(client.fetch(eq(Comment.class), eq("fake-comment"))) .thenReturn(Optional.of(comment)); var event = new ReplyCreatedEvent(this, reply); spyReasonPublisher.onNewReply(event); verify(newReplyReasonPublisher).publishReasonBy(eq(reply), eq(comment)); verify(spec).getCommentName(); verify(client).fetch(eq(Comment.class), eq("fake-comment")); } @Test void isPostCommentTest() { var comment = createComment(); comment.getSpec() .setSubjectRef(Ref.of("fake-post", GroupVersionKind.fromExtension(Post.class))); assertThat(reasonPublisher.isPostComment(comment)).isTrue(); comment.getSpec() .setSubjectRef(Ref.of("fake-post", GroupVersionKind.fromExtension(SinglePage.class))); assertThat(reasonPublisher.isPostComment(comment)).isFalse(); } @Test void isPageComment() { var comment = createComment(); comment.getSpec() .setSubjectRef(Ref.of("fake-post", GroupVersionKind.fromExtension(Post.class))); assertThat(reasonPublisher.isPageComment(comment)).isFalse(); comment.getSpec() .setSubjectRef(Ref.of("fake-post", GroupVersionKind.fromExtension(SinglePage.class))); assertThat(reasonPublisher.isPageComment(comment)).isTrue(); } @Nested @ExtendWith(MockitoExtension.class) class CommentContentConverterTest { @Mock ExternalUrlSupplier externalUrlSupplier; @Mock ExternalLinkProcessor externalLinkProcessor; @InjectMocks CommentNotificationReasonPublisher.CommentContentConverter commentContentConverter; @Test void shouldConvertRelativeImageLinksToAbsolute() { var content = "

Test content \"Test

"; when(externalLinkProcessor.processLink("/upload/image.jpg")) .thenReturn("https://example.com/upload/image.jpg"); var result = commentContentConverter.convertRelativeLinks(content); assertThat(result).contains("https://example.com/upload/image.jpg"); assertThat(result).contains("Test content"); verify(externalLinkProcessor).processLink("/upload/image.jpg"); } @Test void shouldHandleRelativeImageLinksWithoutLeadingSlash() { var content = "

"; when(externalLinkProcessor.processLink("upload/image.jpg")) .thenReturn("https://example.com/upload/image.jpg"); var result = commentContentConverter.convertRelativeLinks(content); assertThat(result).contains("https://example.com/upload/image.jpg"); verify(externalLinkProcessor).processLink("upload/image.jpg"); } @Test void shouldNotConvertAbsoluteImageLinks() { var content = "

"; when(externalLinkProcessor.processLink("https://cdn.example.com/image.jpg")) .thenReturn("https://cdn.example.com/image.jpg"); var result = commentContentConverter.convertRelativeLinks(content); assertThat(result).contains("https://cdn.example.com/image.jpg"); verify(externalLinkProcessor).processLink("https://cdn.example.com/image.jpg"); } @Test void shouldHandleMultipleImages() { var content = "

" + "" + "" + "" + "

"; when(externalLinkProcessor.processLink("/img1.jpg")) .thenReturn("https://example.com/img1.jpg"); when(externalLinkProcessor.processLink("/img2.jpg")) .thenReturn("https://example.com/img2.jpg"); when(externalLinkProcessor.processLink("https://example.com/img3.jpg")) .thenReturn("https://example.com/img3.jpg"); var result = commentContentConverter.convertRelativeLinks(content); assertThat(result).contains("https://example.com/img1.jpg"); assertThat(result).contains("https://example.com/img2.jpg"); assertThat(result).contains("https://example.com/img3.jpg"); verify(externalLinkProcessor).processLink("/img1.jpg"); verify(externalLinkProcessor).processLink("/img2.jpg"); verify(externalLinkProcessor).processLink("https://example.com/img3.jpg"); } @Test void shouldHandleContentWithoutImages() { var content = "

This is a comment content without images

"; var result = commentContentConverter.convertRelativeLinks(content); assertThat(result).contains("This is a comment content without images"); assertThat(result).doesNotContain("img"); } @Test void shouldHandleEmptyContent() { var content = ""; var result = commentContentConverter.convertRelativeLinks(content); assertThat(result).isEmpty(); } @Test void shouldHandleComplexHtmlContent() { var content = """

Title

Paragraph content

Photo 1

More text

"""; when(externalLinkProcessor.processLink("/images/photo1.png")) .thenReturn("https://example.com/images/photo1.png"); when(externalLinkProcessor.processLink("assets/photo2.jpg")) .thenReturn("https://example.com/assets/photo2.jpg"); var result = commentContentConverter.convertRelativeLinks(content); assertThat(result).contains("https://example.com/images/photo1.png"); assertThat(result).contains("https://example.com/assets/photo2.jpg"); assertThat(result).contains("Title"); assertThat(result).contains("Paragraph content"); verify(externalLinkProcessor).processLink("/images/photo1.png"); verify(externalLinkProcessor).processLink("assets/photo2.jpg"); } } @Nested @ExtendWith(MockitoExtension.class) class NewCommentOnPostReasonPublisherTest { @Mock ExtensionClient client; @Mock NotificationReasonEmitter emitter; @Mock ExtensionGetter extensionGetter; @Mock ExternalLinkProcessor externalLinkProcessor; @Mock CommentNotificationReasonPublisher.CommentContentConverter commentContentConverter; @InjectMocks CommentNotificationReasonPublisher.NewCommentOnPostReasonPublisher newCommentOnPostReasonPublisher; @Test void publishReasonByTest() { final var comment = createComment(); comment.getSpec().getOwner().setDisplayName("fake-display-name"); comment.getSpec().setContent("fake-comment-content"); var post = mock(Post.class); final var spec = mock(Post.PostSpec.class); var metadata = new Metadata(); metadata.setName("fake-post"); when(post.getMetadata()).thenReturn(metadata); when(post.getStatusOrDefault()).thenReturn(new Post.PostStatus()); when(post.getSpec()).thenReturn(spec); when(spec.getTitle()).thenReturn("fake-title"); when(client.fetch(eq(Post.class), eq(metadata.getName()))) .thenReturn(Optional.of(post)); when(commentContentConverter.convertRelativeLinks(eq("fake-comment-content"))) .thenReturn("fake-comment-content"); when(emitter.emit(eq("new-comment-on-post"), any())) .thenReturn(Mono.empty()); newCommentOnPostReasonPublisher.publishReasonBy(comment); verify(client).fetch(eq(Post.class), eq(metadata.getName())); verify(emitter).emit(eq("new-comment-on-post"), assertArg(consumer -> { var builder = ReasonPayload.builder(); consumer.accept(builder); var reasonPayload = builder.build(); var reasonSubject = Reason.Subject.builder() .apiVersion(post.getApiVersion()) .kind(post.getKind()) .name(post.getMetadata().getName()) .title(post.getSpec().getTitle()) .build(); assertThat(reasonPayload.getSubject()).isEqualTo(reasonSubject); assertThat(reasonPayload.getAuthor()) .isEqualTo( UserIdentity.anonymousWithEmail(comment.getSpec().getOwner().getName())); assertThat(reasonPayload.getAttributes()).containsAllEntriesOf(Map.of( "postName", post.getMetadata().getName(), "postTitle", post.getSpec().getTitle(), "commenter", comment.getSpec().getOwner().getDisplayName(), "content", comment.getSpec().getContent(), "commentName", comment.getMetadata().getName() )); })); } @Test void doNotEmitReasonTest() { final var comment = createComment(); var commentOwner = new Comment.CommentOwner(); commentOwner.setKind(User.KIND); commentOwner.setName("fake-user"); comment.getSpec().setOwner(commentOwner); var post = new Post(); post.setMetadata(new Metadata()); post.getMetadata().setName("fake-post"); post.setSpec(new Post.PostSpec()); post.getSpec().setOwner("fake-user"); // the username is the same as the comment owner assertThat(newCommentOnPostReasonPublisher.doNotEmitReason(comment, post)).isTrue(); // not the same username commentOwner.setName("other"); assertThat(newCommentOnPostReasonPublisher.doNotEmitReason(comment, post)).isFalse(); // the comment owner is email and the same as the post-owner user email commentOwner.setKind(Comment.CommentOwner.KIND_EMAIL); commentOwner.setName("example@example.com"); var user = new User(); user.setSpec(new User.UserSpec()); user.getSpec().setEmail("example@example.com"); when(client.fetch(eq(User.class), eq("fake-user"))) .thenReturn(Optional.of(user)); assertThat(newCommentOnPostReasonPublisher.doNotEmitReason(comment, post)).isTrue(); // the comment owner is email and not the same as the post-owner user email user.getSpec().setEmail("fake@example.com"); assertThat(newCommentOnPostReasonPublisher.doNotEmitReason(comment, post)).isFalse(); } } @Nested @ExtendWith(MockitoExtension.class) class NewCommentOnPageReasonPublisherTest { @Mock ExtensionClient client; @Mock NotificationReasonEmitter emitter; @Mock ExternalLinkProcessor externalLinkProcessor; @Mock CommentNotificationReasonPublisher.CommentContentConverter commentContentConverter; @InjectMocks CommentNotificationReasonPublisher.NewCommentOnPageReasonPublisher newCommentOnPageReasonPublisher; @Test void publishReasonByTest() { final var comment = createComment(); comment.getSpec().getOwner().setDisplayName("fake-display-name"); comment.getSpec().setContent("fake-comment-content"); comment.getSpec().setSubjectRef( Ref.of("fake-page", GroupVersionKind.fromExtension(SinglePage.class))); var page = mock(SinglePage.class); final var spec = mock(SinglePage.SinglePageSpec.class); var metadata = new Metadata(); metadata.setName("fake-page"); when(page.getMetadata()).thenReturn(metadata); when(page.getStatusOrDefault()).thenReturn(new SinglePage.SinglePageStatus()); when(page.getSpec()).thenReturn(spec); when(spec.getTitle()).thenReturn("fake-title"); when(client.fetch(eq(SinglePage.class), eq(metadata.getName()))) .thenReturn(Optional.of(page)); when(commentContentConverter.convertRelativeLinks(eq("fake-comment-content"))) .thenReturn("fake-comment-content"); when(emitter.emit(eq("new-comment-on-single-page"), any())) .thenReturn(Mono.empty()); newCommentOnPageReasonPublisher.publishReasonBy(comment); verify(client).fetch(eq(SinglePage.class), eq(metadata.getName())); verify(emitter).emit(eq("new-comment-on-single-page"), assertArg(consumer -> { var builder = ReasonPayload.builder(); consumer.accept(builder); var reasonPayload = builder.build(); var reasonSubject = Reason.Subject.builder() .apiVersion(page.getApiVersion()) .kind(page.getKind()) .name(page.getMetadata().getName()) .title(page.getSpec().getTitle()) .build(); assertThat(reasonPayload.getSubject()).isEqualTo(reasonSubject); assertThat(reasonPayload.getAuthor()) .isEqualTo( UserIdentity.anonymousWithEmail(comment.getSpec().getOwner().getName())); assertThat(reasonPayload.getAttributes()).containsAllEntriesOf(Map.of( "pageName", page.getMetadata().getName(), "pageTitle", page.getSpec().getTitle(), "commenter", comment.getSpec().getOwner().getDisplayName(), "content", comment.getSpec().getContent(), "commentName", comment.getMetadata().getName() )); })); } @Test void doNotEmitReasonTest() { final var comment = createComment(); var commentOwner = new Comment.CommentOwner(); commentOwner.setKind(User.KIND); commentOwner.setName("fake-user"); comment.getSpec().setOwner(commentOwner); var page = new SinglePage(); page.setMetadata(new Metadata()); page.getMetadata().setName("fake-page"); page.setSpec(new SinglePage.SinglePageSpec()); page.getSpec().setOwner("fake-user"); // the username is the same as the comment owner assertThat(newCommentOnPageReasonPublisher.doNotEmitReason(comment, page)).isTrue(); // not the same username commentOwner.setName("other"); assertThat(newCommentOnPageReasonPublisher.doNotEmitReason(comment, page)).isFalse(); // the comment owner is email and the same as the page-owner user email commentOwner.setKind(Comment.CommentOwner.KIND_EMAIL); commentOwner.setName("example@example.com"); var user = new User(); user.setSpec(new User.UserSpec()); user.getSpec().setEmail("example@example.com"); when(client.fetch(eq(User.class), eq("fake-user"))) .thenReturn(Optional.of(user)); assertThat(newCommentOnPageReasonPublisher.doNotEmitReason(comment, page)).isTrue(); // the comment owner is email and not the same as the post-owner user email user.getSpec().setEmail("fake@example.com"); assertThat(newCommentOnPageReasonPublisher.doNotEmitReason(comment, page)).isFalse(); } } @Nested @ExtendWith(MockitoExtension.class) class NewReplyReasonPublisherTest { @Mock ExtensionClient client; @Mock NotificationReasonEmitter notificationReasonEmitter; @Mock ExtensionGetter extensionGetter; @Mock CommentNotificationReasonPublisher.CommentContentConverter commentContentConverter; @InjectMocks CommentNotificationReasonPublisher.NewReplyReasonPublisher newReplyReasonPublisher; @Test void publishReasonByTest() { when(extensionGetter.getExtensions(CommentSubject.class)) .thenReturn(Flux.empty()); var reply = createReply("fake-reply"); reply.getSpec().setQuoteReply("fake-quote-reply"); var quoteReply = createReply("fake-quote-reply"); when(client.fetch(eq(Reply.class), eq("fake-quote-reply"))) .thenReturn(Optional.of(quoteReply)); var spyNewReplyReasonPublisher = spy(newReplyReasonPublisher); var comment = createComment(); comment.getSpec().setContent("fake-comment-content"); doReturn(false).when(spyNewReplyReasonPublisher) .doNotEmitReason(any(), any(), any()); // Mock commentContentConverter for all content conversions when(commentContentConverter.convertRelativeLinks(eq("fake-comment-content"))) .thenReturn("fake-comment-content"); when(commentContentConverter.convertRelativeLinks(eq("fake-reply-content"))) .thenReturn("fake-reply-content"); when(notificationReasonEmitter.emit(any(), any())) .thenReturn(Mono.empty()); // execute target method spyNewReplyReasonPublisher.publishReasonBy(reply, comment); verify(notificationReasonEmitter) .emit(eq(NotificationReasonConst.SOMEONE_REPLIED_TO_YOU), assertArg(consumer -> { var builder = ReasonPayload.builder(); consumer.accept(builder); var reasonPayload = builder.build(); var reasonSubject = Reason.Subject.builder() .apiVersion(quoteReply.getApiVersion()) .kind(quoteReply.getKind()) .name(quoteReply.getMetadata().getName()) .title(quoteReply.getSpec().getContent()) .build(); assertThat(reasonPayload.getSubject()).isEqualTo(reasonSubject); assertThat(reasonPayload.getAuthor()) .isEqualTo( UserIdentity.of(reply.getSpec().getOwner().getName())); assertThat(reasonPayload.getAttributes()).containsAllEntriesOf(Map.of( "commentContent", comment.getSpec().getContent(), "isQuoteReply", true, "quoteContent", quoteReply.getSpec().getContent(), "commentName", comment.getMetadata().getName(), "replier", reply.getSpec().getOwner().getDisplayName(), "content", reply.getSpec().getContent(), "replyName", reply.getMetadata().getName() )); })); } @Test void doNotEmitReasonTest() { final var currentReply = createReply("current"); currentReply.getSpec().setQuoteReply("quote"); final var quoteReply = createReply("quote"); final var comment = createComment(); assertThat(newReplyReasonPublisher .doNotEmitReason(currentReply, quoteReply, comment)).isTrue(); currentReply.getSpec().getOwner().setName("other"); assertThat(newReplyReasonPublisher .doNotEmitReason(currentReply, quoteReply, comment)).isFalse(); currentReply.getSpec().setQuoteReply(null); assertThat(newReplyReasonPublisher .doNotEmitReason(currentReply, quoteReply, comment)).isFalse(); currentReply.getSpec().setOwner(comment.getSpec().getOwner()); assertThat(newReplyReasonPublisher .doNotEmitReason(currentReply, quoteReply, comment)).isTrue(); } static Reply createReply(String name) { var reply = new Reply(); reply.setMetadata(new Metadata()); reply.getMetadata().setName(name); reply.setSpec(new Reply.ReplySpec()); reply.getSpec().setCommentName("fake-comment"); var owner = new Comment.CommentOwner(); owner.setKind(User.KIND); owner.setName("fake-user"); owner.setDisplayName("fake-display-name"); reply.getSpec().setOwner(owner); reply.getSpec().setContent("fake-reply-content"); return reply; } } static Comment createComment() { var comment = new Comment(); comment.setMetadata(new Metadata()); comment.getMetadata().setName("fake-comment"); comment.setSpec(new Comment.CommentSpec()); var commentOwner = new Comment.CommentOwner(); commentOwner.setKind(Comment.CommentOwner.KIND_EMAIL); commentOwner.setName("example@example.com"); comment.getSpec().setOwner(commentOwner); comment.getSpec().setSubjectRef( Ref.of("fake-post", GroupVersionKind.fromExtension(Post.class))); return comment; } } ================================================ FILE: application/src/test/java/run/halo/app/content/comment/CommentRequestTest.java ================================================ package run.halo.app.content.comment; import static org.assertj.core.api.Assertions.assertThat; import org.json.JSONException; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import run.halo.app.core.extension.content.Comment; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.Metadata; import run.halo.app.extension.Ref; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link CommentRequest}. * * @author guqing * @since 2.0.0 */ class CommentRequestTest { @Test void constructor() throws JSONException { CommentRequest commentRequest = createCommentRequest(); JSONAssert.assertEquals(""" { "subjectRef": { "group": "fake.halo.run", "version": "v1alpha1", "kind": "Fake", "name": "fake" }, "raw": "raw", "content": "content", "allowNotification": true } """, JsonUtils.objectToJson(commentRequest), true); } @Test void toComment() throws JSONException { CommentRequest commentRequest = createCommentRequest(); Comment comment = commentRequest.toComment(); assertThat(comment.getMetadata().getName()).isNotNull(); comment.getMetadata().setName("fake"); JSONAssert.assertEquals(""" { "spec": { "raw": "raw", "content": "content", "allowNotification": true, "subjectRef": { "group": "fake.halo.run", "version": "v1alpha1", "kind": "Fake", "name": "fake" } }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Comment", "metadata": { "name": "fake" } } """, JsonUtils.objectToJson(comment), true); } private static CommentRequest createCommentRequest() { CommentRequest commentRequest = new CommentRequest(); commentRequest.setRaw("raw"); commentRequest.setContent("content"); commentRequest.setAllowNotification(true); FakeExtension fakeExtension = new FakeExtension(); fakeExtension.setMetadata(new Metadata()); fakeExtension.getMetadata().setName("fake"); commentRequest.setSubjectRef(Ref.of(fakeExtension)); return commentRequest; } } ================================================ FILE: application/src/test/java/run/halo/app/content/comment/CommentServiceImplIntegrationTest.java ================================================ package run.halo.app.content.comment; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.content.Comment; import run.halo.app.extension.Extension; import run.halo.app.extension.ExtensionStoreUtil; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.extension.SchemeManager; import run.halo.app.extension.store.ReactiveExtensionStoreClient; import run.halo.app.infra.utils.JsonUtils; /** * Integration tests for {@link CommentServiceImpl}. * * @author guqing * @since 2.15.0 */ class CommentServiceImplIntegrationTest { @Nested @DirtiesContext @SpringBootTest class CommentRemoveTest { private final List storedComments = createComments(350); @Autowired private SchemeManager schemeManager; @MockitoSpyBean private ReactiveExtensionClient reactiveClient; @Autowired private ReactiveExtensionStoreClient storeClient; @MockitoSpyBean private CommentServiceImpl commentService; Mono deleteImmediately(Extension extension) { var name = extension.getMetadata().getName(); var scheme = schemeManager.get(extension.getClass()); // delete from db var storeName = ExtensionStoreUtil.buildStoreName(scheme, name); return storeClient.delete(storeName, extension.getMetadata().getVersion()) .thenReturn(extension); } @BeforeEach void setUp() { Flux.fromIterable(storedComments) .flatMap(post -> reactiveClient.create(post)) .as(StepVerifier::create) .expectNextCount(storedComments.size()) .verifyComplete(); } @AfterEach void tearDown() { Flux.fromIterable(storedComments) .flatMap(this::deleteImmediately) .as(StepVerifier::create) .expectNextCount(storedComments.size()) .verifyComplete(); } @Test void commentBatchDeletionTest() { Ref ref = Ref.of("67", GroupVersionKind.fromAPIVersionAndKind("content.halo.run/v1alpha1", "SinglePage")); commentService.removeBySubject(ref) .as(StepVerifier::create) .verifyComplete(); verify(reactiveClient, times(storedComments.size())).delete(any(Comment.class)); verify(commentService, times(2)).listCommentsByRef(eq(ref), any()); commentService.listCommentsByRef(ref, PageRequestImpl.ofSize(1)) .as(StepVerifier::create) .consumeNextWith(result -> { assertThat(result.getTotal()).isEqualTo(0); }) .verifyComplete(); } List createComments(int size) { List comments = new ArrayList<>(size); for (int i = 0; i < size; i++) { var comment = createComment(); comment.getMetadata().setName("comment-" + i); comments.add(comment); } return comments; } } Comment createComment() { return JsonUtils.jsonToObject(""" { "spec": { "raw": "fake-raw", "content": "fake-content", "owner": { "kind": "User", "name": "fake-user" }, "userAgent": "", "ipAddress": "", "approvedTime": "2024-02-28T09:15:16.095Z", "creationTime": "2024-02-28T06:23:42.923294424Z", "priority": 0, "top": false, "allowNotification": false, "approved": true, "hidden": false, "subjectRef": { "group": "content.halo.run", "version": "v1alpha1", "kind": "SinglePage", "name": "67" }, "lastReadTime": "2024-02-29T03:39:04.230Z" }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Comment", "metadata": { "generateName": "comment-" } } """, Comment.class); } } ================================================ FILE: application/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java ================================================ package run.halo.app.content.comment; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import java.util.Map; import java.util.Set; import org.json.JSONException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.mock.web.reactive.function.server.MockServerRequest; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.content.TestPost; import run.halo.app.core.counter.CounterService; import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Post; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.security.authorization.AuthorityUtils; /** * Tests for {@link CommentServiceImpl}. * * @author guqing * @since 2.0.0 */ @ExtendWith({SpringExtension.class, MockitoExtension.class}) class CommentServiceImplTest { @Mock SystemConfigFetcher environmentFetcher; @Mock ReactiveExtensionClient client; @Mock UserService userService; @Mock RoleService roleService; @Mock ExtensionGetter extensionGetter; @InjectMocks CommentServiceImpl commentService; @Mock CounterService counterService; private static User createUser(String name) { User user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName(name); user.setSpec(new User.UserSpec()); user.getSpec().setAvatar(name + "-avatar"); user.getSpec().setDisplayName(name + "-displayName"); user.getSpec().setEmail(name + "-email"); return user; } @Test void listComment() { var comments = new ListResult(1, 10, 3, comments()); when(client.listBy(eq(Comment.class), any(ListOptions.class), any(PageRequest.class))) .thenReturn(Mono.just(comments)); PostCommentSubject postCommentSubject = Mockito.mock(PostCommentSubject.class); when(extensionGetter.getExtensions(CommentSubject.class)) .thenReturn(Flux.just(postCommentSubject)); when(postCommentSubject.supports(any())).thenReturn(true); when(postCommentSubject.get(eq("fake-post"))).thenReturn(Mono.just(post())); when(userService.getUserOrGhost(any())) .thenReturn(Mono.just(ghostUser())); // when(userService.getUserOrGhost("A-owner")) // .thenReturn(Mono.just(createUser("A-owner"))); when(userService.getUserOrGhost("B-owner")) .thenReturn(Mono.just(createUser("B-owner"))); ServerWebExchange exchange = mock(ServerWebExchange.class); MultiValueMap queryParams = new LinkedMultiValueMap<>(); MockServerRequest request = MockServerRequest.builder() .queryParams(queryParams) .exchange(exchange) .build(); ServerHttpRequest httpRequest = mock(ServerHttpRequest.class); when(exchange.getRequest()).thenReturn(httpRequest); when(httpRequest.getQueryParams()).thenReturn(queryParams); final var listResultMono = commentService.listComment(new CommentQuery(request)); Counter counterA = new Counter(); counterA.setUpvote(3); String commentACounter = MeterUtils.nameOf(Comment.class, "A"); when(counterService.getByName(eq(commentACounter))).thenReturn(Mono.just(counterA)); Counter counterB = new Counter(); counterB.setUpvote(9); String commentBCounter = MeterUtils.nameOf(Comment.class, "B"); when(counterService.getByName(eq(commentBCounter))).thenReturn(Mono.just(counterB)); Counter counterC = new Counter(); counterC.setUpvote(0); String commentCCounter = MeterUtils.nameOf(Comment.class, "C"); when(counterService.getByName(eq(commentCCounter))).thenReturn(Mono.just(counterC)); StepVerifier.create(listResultMono) .consumeNextWith(result -> { try { JSONAssert.assertEquals(expectListResultJson(), JsonUtils.objectToJson(result), true); } catch (JSONException e) { throw new RuntimeException(e); } }) .verifyComplete(); } @Test @WithMockUser(username = "B-owner") void create() throws JSONException { var commentSetting = getCommentSetting(); when(environmentFetcher.fetchComment()).thenReturn(Mono.just(commentSetting)); when(roleService.contains(Set.of("USER"), Set.of(AuthorityUtils.COMMENT_MANAGEMENT_ROLE_NAME))) .thenReturn(Mono.just(false)); CommentRequest commentRequest = new CommentRequest(); commentRequest.setRaw("fake-raw"); commentRequest.setContent("fake-content"); commentRequest.setAllowNotification(true); commentRequest.setSubjectRef(Ref.of(post())); ArgumentCaptor captor = ArgumentCaptor.forClass(Comment.class); when(client.fetch(eq(User.class), eq("B-owner"))) .thenReturn(Mono.just(createUser("B-owner"))); Comment commentToCreate = commentRequest.toComment(); commentToCreate.getMetadata().setName("fake"); Mono commentMono = commentService.create(commentToCreate); when(client.create(any())).thenReturn(Mono.empty()); StepVerifier.create(commentMono) .verifyComplete(); verify(client, times(1)).create(captor.capture()); Comment comment = captor.getValue(); comment.getSpec().setCreationTime(null); JSONAssert.assertEquals(""" { "spec": { "raw": "fake-raw", "content": "fake-content", "owner": { "kind": "User", "name": "B-owner", "displayName": "B-owner-displayName" }, "priority": 0, "top": false, "allowNotification": true, "approved": false, "hidden": false, "subjectRef": { "group": "content.halo.run", "version": "v1alpha1", "kind": "Post", "name": "fake-post" } }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Comment", "metadata": { "name": "fake" } } """, JsonUtils.objectToJson(comment), true); } private List comments() { Comment a = comment("A"); a.getSpec().getOwner().setKind(Comment.CommentOwner.KIND_EMAIL); a.getSpec().getOwner() .setAnnotations(Map.of(Comment.CommentOwner.AVATAR_ANNO, "avatar", Comment.CommentOwner.WEBSITE_ANNO, "website")); return List.of(a, comment("B"), comment("C")); } private Comment comment(String name) { Comment comment = new Comment(); comment.setMetadata(new Metadata()); comment.getMetadata().setName(name); comment.setSpec(new Comment.CommentSpec()); Comment.CommentOwner commentOwner = new Comment.CommentOwner(); commentOwner.setKind(User.KIND); commentOwner.setDisplayName("displayName"); commentOwner.setName(name + "-owner"); comment.getSpec().setOwner(commentOwner); comment.getSpec().setSubjectRef(Ref.of(post())); comment.setStatus(new Comment.CommentStatus()); return comment; } private Post post() { Post post = TestPost.postV1(); post.getMetadata().setName("fake-post"); return post; } private static SystemSetting.Comment getCommentSetting() { SystemSetting.Comment commentSetting = new SystemSetting.Comment(); commentSetting.setEnable(true); commentSetting.setSystemUserOnly(true); commentSetting.setRequireReviewForNew(true); return commentSetting; } User ghostUser() { User user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName("ghost"); user.setSpec(new User.UserSpec()); user.getSpec().setDisplayName("Ghost"); user.getSpec().setEmail(""); return user; } private String expectListResultJson() { return """ { "page": 1, "size": 10, "total": 3, "totalPages": 1, "items": [ { "comment": { "spec": { "owner": { "kind": "Email", "name": "A-owner", "displayName": "displayName", "annotations": { "website": "website", "avatar": "avatar" } }, "subjectRef": { "group": "content.halo.run", "version": "v1alpha1", "kind": "Post", "name": "fake-post" } }, "status": {}, "apiVersion": "content.halo.run/v1alpha1", "kind": "Comment", "metadata": { "name": "A" } }, "owner": { "kind": "Email", "name": "A-owner", "displayName": "displayName", "avatar": "avatar", "email": "A-owner" }, "subject": { "spec": { "title": "post-A", "headSnapshot": "base-snapshot", "baseSnapshot": "snapshot-A" }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Post", "metadata": { "name": "fake-post", "version": 1 } }, "stats": { "upvote": 3 } }, { "comment": { "spec": { "owner": { "kind": "User", "name": "B-owner", "displayName": "displayName" }, "subjectRef": { "group": "content.halo.run", "version": "v1alpha1", "kind": "Post", "name": "fake-post" } }, "status": {}, "apiVersion": "content.halo.run/v1alpha1", "kind": "Comment", "metadata": { "name": "B" } }, "owner": { "kind": "User", "name": "B-owner", "displayName": "B-owner-displayName", "avatar": "B-owner-avatar", "email": "B-owner-email" }, "subject": { "spec": { "title": "post-A", "headSnapshot": "base-snapshot", "baseSnapshot": "snapshot-A" }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Post", "metadata": { "name": "fake-post", "version": 1 } }, "stats": { "upvote": 9 } }, { "comment": { "spec": { "owner": { "kind": "User", "name": "C-owner", "displayName": "displayName" }, "subjectRef": { "group": "content.halo.run", "version": "v1alpha1", "kind": "Post", "name": "fake-post" } }, "status": {}, "apiVersion": "content.halo.run/v1alpha1", "kind": "Comment", "metadata": { "name": "C" } }, "owner": { "kind": "User", "name": "ghost", "displayName": "Ghost", "email": "" }, "subject": { "spec": { "title": "post-A", "headSnapshot": "base-snapshot", "baseSnapshot": "snapshot-A" }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Post", "metadata": { "name": "fake-post", "version": 1 } }, "stats": { "upvote": 0 } } ], "first": true, "last": true, "hasNext": false, "hasPrevious": false } """; } } ================================================ FILE: application/src/test/java/run/halo/app/content/comment/PostCommentSubjectTest.java ================================================ package run.halo.app.content.comment; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.content.TestPost; import run.halo.app.core.extension.content.Constant; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; /** * Tests for {@link PostCommentSubject}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class PostCommentSubjectTest { @Mock private ReactiveExtensionClient client; @InjectMocks private PostCommentSubject postCommentSubject; @Test void get() { when(client.fetch(eq(Post.class), any())) .thenReturn(Mono.empty()); when(client.fetch(eq(Post.class), eq("fake-post"))) .thenReturn(Mono.just(TestPost.postV1())); postCommentSubject.get("fake-post") .as(StepVerifier::create) .expectNext(TestPost.postV1()) .verifyComplete(); postCommentSubject.get("fake-post2") .as(StepVerifier::create) .verifyComplete(); } @Test void supports() { Post post = new Post(); post.setMetadata(new Metadata()); post.getMetadata().setName("test"); boolean supports = postCommentSubject.supports(Ref.of(post)); assertThat(supports).isTrue(); FakeExtension fakeExtension = new FakeExtension(); fakeExtension.setMetadata(new Metadata()); fakeExtension.getMetadata().setName("test"); supports = postCommentSubject.supports(Ref.of(fakeExtension)); assertThat(supports).isFalse(); } @Test void shouldSupportRefWithoutVersion() { var ref = new Ref(); ref.setName("fake-post"); ref.setGroup(Constant.GROUP); ref.setKind(Post.KIND); assertTrue(postCommentSubject.supports(ref)); } } ================================================ FILE: application/src/test/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelperTest.java ================================================ package run.halo.app.content.comment; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static run.halo.app.content.comment.ReplyNotificationSubscriptionHelper.identityFrom; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; import run.halo.app.content.NotificationReasonConst; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Reply; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.Metadata; import run.halo.app.extension.Ref; import run.halo.app.notification.NotificationCenter; import run.halo.app.notification.UserIdentity; /** * Tests for {@link ReplyNotificationSubscriptionHelper}. * * @author guqing * @since 2.9.0 */ @ExtendWith(MockitoExtension.class) class ReplyNotificationSubscriptionHelperTest { @Mock NotificationCenter notificationCenter; @InjectMocks ReplyNotificationSubscriptionHelper notificationSubscriptionHelper; @Test void subscribeNewReplyReasonForCommentTest() { var comment = createComment(); var spyNotificationSubscriptionHelper = spy(notificationSubscriptionHelper); doNothing().when(spyNotificationSubscriptionHelper).subscribeReply(any(UserIdentity.class)); spyNotificationSubscriptionHelper.subscribeNewReplyReasonForComment(comment); verify(spyNotificationSubscriptionHelper).subscribeReply( eq(ReplyNotificationSubscriptionHelper.identityFrom( comment.getSpec().getOwner())) ); } @Test void subscribeNewReplyReasonForReplyTest() { var reply = new Reply(); reply.setMetadata(new Metadata()); reply.getMetadata().setName("fake-reply"); reply.setSpec(new Reply.ReplySpec()); reply.getSpec().setCommentName("fake-comment"); var owner = new Comment.CommentOwner(); owner.setKind(User.KIND); owner.setName("fake-user"); reply.getSpec().setOwner(owner); var spyNotificationSubscriptionHelper = spy(notificationSubscriptionHelper); doNothing().when(spyNotificationSubscriptionHelper).subscribeReply(any(UserIdentity.class)); spyNotificationSubscriptionHelper.subscribeNewReplyReasonForReply(reply); verify(spyNotificationSubscriptionHelper).subscribeReply( eq(ReplyNotificationSubscriptionHelper.identityFrom( reply.getSpec().getOwner())) ); } @Test void subscribeReplyTest() { var comment = createComment(); var identity = ReplyNotificationSubscriptionHelper.identityFrom( comment.getSpec().getOwner()); when(notificationCenter.subscribe(any(), any())).thenReturn(Mono.empty()); var subscriber = new Subscription.Subscriber(); subscriber.setName(identity.name()); notificationSubscriptionHelper.subscribeReply(identity); var interestReason = new Subscription.InterestReason(); interestReason.setReasonType(NotificationReasonConst.SOMEONE_REPLIED_TO_YOU); interestReason.setExpression("props.repliedOwner == '%s'".formatted(subscriber.getName())); verify(notificationCenter).subscribe(eq(subscriber), eq(interestReason)); } @Nested class IdentityTest { @Test void identityFromTest() { var owner = new Comment.CommentOwner(); owner.setKind(User.KIND); owner.setName("fake-user"); assertThat(identityFrom(owner)) .isEqualTo(UserIdentity.of(owner.getName())); owner.setKind(Comment.CommentOwner.KIND_EMAIL); owner.setName("example@example.com"); assertThat(identityFrom(owner)) .isEqualTo(UserIdentity.anonymousWithEmail(owner.getName())); } } static Comment createComment() { var comment = new Comment(); comment.setMetadata(new Metadata()); comment.getMetadata().setName("fake-comment"); comment.setSpec(new Comment.CommentSpec()); var commentOwner = new Comment.CommentOwner(); commentOwner.setKind(Comment.CommentOwner.KIND_EMAIL); commentOwner.setName("example@example.com"); comment.getSpec().setOwner(commentOwner); comment.getSpec().setSubjectRef( Ref.of("fake-post", GroupVersionKind.fromExtension(Post.class))); return comment; } } ================================================ FILE: application/src/test/java/run/halo/app/content/comment/ReplyServiceImplIntegrationTest.java ================================================ package run.halo.app.content.comment; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.content.Reply; import run.halo.app.extension.Extension; import run.halo.app.extension.ExtensionStoreUtil; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.SchemeManager; import run.halo.app.extension.store.ReactiveExtensionStoreClient; import run.halo.app.infra.utils.JsonUtils; /** * Integration tests for {@link ReplyServiceImpl}. * * @author guqing * @since 2.15.0 */ class ReplyServiceImplIntegrationTest { @Nested @DirtiesContext @SpringBootTest class ReplyRemoveTest { private final List storedReplies = createReplies(320); private List createReplies(int size) { List replies = new ArrayList<>(size); for (int i = 0; i < size; i++) { var reply = JsonUtils.jsonToObject(fakeReplyJson(), Reply.class); reply.getMetadata().setName("reply-" + i); replies.add(reply); } return replies; } @Autowired private SchemeManager schemeManager; @MockitoSpyBean private ReactiveExtensionClient reactiveClient; @Autowired private ReactiveExtensionStoreClient storeClient; @MockitoSpyBean private ReplyServiceImpl replyService; Mono deleteImmediately(Extension extension) { var name = extension.getMetadata().getName(); var scheme = schemeManager.get(extension.getClass()); // delete from db var storeName = ExtensionStoreUtil.buildStoreName(scheme, name); return storeClient.delete(storeName, extension.getMetadata().getVersion()) .thenReturn(extension); } @BeforeEach void setUp() { Flux.fromIterable(storedReplies) .flatMap(post -> reactiveClient.create(post)) .as(StepVerifier::create) .expectNextCount(storedReplies.size()) .verifyComplete(); } @AfterEach void tearDown() { Flux.fromIterable(storedReplies) .flatMap(this::deleteImmediately) .as(StepVerifier::create) .expectNextCount(storedReplies.size()) .verifyComplete(); } @Test void removeAllByComment() { String commentName = "fake-comment"; replyService.removeAllByComment(commentName) .as(StepVerifier::create) .verifyComplete(); verify(reactiveClient, times(storedReplies.size())).delete(any(Reply.class)); verify(replyService, times(2)).listRepliesByComment(eq(commentName), any()); replyService.listRepliesByComment(commentName, PageRequestImpl.ofSize(1)) .as(StepVerifier::create) .consumeNextWith(result -> assertThat(result.getTotal()).isEqualTo(0)) .verifyComplete(); } } String fakeReplyJson() { return """ { "metadata":{ "name":"fake-reply" }, "spec":{ "raw":"fake-raw", "content":"fake-content", "owner":{ "kind":"User", "name":"fake-user", "displayName":"fake-display-name" }, "creationTime": "2024-03-11T06:23:42.923294424Z", "ipAddress":"", "approved": true, "hidden": false, "allowNotification": false, "top": false, "priority": 0, "commentName":"fake-comment" }, "owner":{ "kind":"User", "displayName":"fake-display-name" }, "stats":{ "upvote":0 } } """; } } ================================================ FILE: application/src/test/java/run/halo/app/content/comment/SinglePageCommentSubjectTest.java ================================================ package run.halo.app.content.comment; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.content.Constant; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; /** * Tests for {@link SinglePageCommentSubject}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class SinglePageCommentSubjectTest { @Mock private ReactiveExtensionClient client; @InjectMocks private SinglePageCommentSubject singlePageCommentSubject; @Test void get() { when(client.fetch(eq(SinglePage.class), any())) .thenReturn(Mono.empty()); SinglePage singlePage = new SinglePage(); singlePage.setMetadata(new Metadata()); singlePage.getMetadata().setName("fake-single-page"); when(client.fetch(eq(SinglePage.class), eq("fake-single-page"))) .thenReturn(Mono.just(singlePage)); singlePageCommentSubject.get("fake-single-page") .as(StepVerifier::create) .expectNext(singlePage) .verifyComplete(); singlePageCommentSubject.get("fake-single-page-2") .as(StepVerifier::create) .verifyComplete(); verify(client, times(1)).fetch(eq(SinglePage.class), eq("fake-single-page")); } @Test void supports() { SinglePage singlePage = new SinglePage(); singlePage.setMetadata(new Metadata()); singlePage.getMetadata().setName("test"); boolean supports = singlePageCommentSubject.supports(Ref.of(singlePage)); assertThat(supports).isTrue(); FakeExtension fakeExtension = new FakeExtension(); fakeExtension.setMetadata(new Metadata()); fakeExtension.getMetadata().setName("test"); supports = singlePageCommentSubject.supports(Ref.of(fakeExtension)); assertThat(supports).isFalse(); } @Test void shouldSupportRefWithoutVersion() { var ref = new Ref(); ref.setName("fake-post"); ref.setGroup(Constant.GROUP); ref.setKind(SinglePage.KIND); assertTrue(singlePageCommentSubject.supports(ref)); } } ================================================ FILE: application/src/test/java/run/halo/app/content/permalinks/CategoryPermalinkPolicyTest.java ================================================ package run.halo.app.content.permalinks; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; import java.net.URI; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.core.extension.content.Category; import run.halo.app.extension.Metadata; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemConfigFetcher; /** * Tests for {@link CategoryPermalinkPolicy}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class CategoryPermalinkPolicyTest { @Mock private ExternalUrlSupplier externalUrlSupplier; @Mock private SystemConfigFetcher environmentFetcher; private CategoryPermalinkPolicy categoryPermalinkPolicy; @BeforeEach void setUp() { categoryPermalinkPolicy = new CategoryPermalinkPolicy(externalUrlSupplier, environmentFetcher); } @Test void permalink() { Category category = new Category(); Metadata metadata = new Metadata(); metadata.setName("category-test"); category.setMetadata(metadata); Category.CategorySpec categorySpec = new Category.CategorySpec(); categorySpec.setSlug("slug-test"); category.setSpec(categorySpec); when(externalUrlSupplier.get()).thenReturn(URI.create("")); String permalink = categoryPermalinkPolicy.permalink(category); assertThat(permalink).isEqualTo("/categories/slug-test"); when(externalUrlSupplier.get()).thenReturn(URI.create("http://exmaple.com")); permalink = categoryPermalinkPolicy.permalink(category); assertThat(permalink).isEqualTo("http://exmaple.com/categories/slug-test"); String path = URI.create(permalink).getPath(); assertThat(path).isEqualTo("/categories/slug-test"); category.getSpec().setSlug("中文 slug"); permalink = categoryPermalinkPolicy.permalink(category); assertThat(permalink).isEqualTo("http://exmaple.com/categories/%E4%B8%AD%E6%96%87%20slug"); } } ================================================ FILE: application/src/test/java/run/halo/app/content/permalinks/PostPermalinkPolicyTest.java ================================================ package run.halo.app.content.permalinks; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; import java.net.URI; import java.text.DecimalFormat; import java.text.NumberFormat; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationContext; import reactor.core.publisher.Flux; import run.halo.app.content.PostService; import run.halo.app.content.TestPost; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Constant; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.Metadata; import run.halo.app.extension.MetadataUtil; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.utils.PathUtils; /** * Tests for {@link PostPermalinkPolicy}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class PostPermalinkPolicyTest { private static final NumberFormat NUMBER_FORMAT = new DecimalFormat("00"); @Mock private ApplicationContext applicationContext; @Mock private ExternalUrlSupplier externalUrlSupplier; @Mock private SystemConfigFetcher environmentFetcher; @Mock private PostService postService; private PostPermalinkPolicy postPermalinkPolicy; @BeforeEach void setUp() { lenient().when(externalUrlSupplier.get()).thenReturn(URI.create("")); lenient().when(postService.listCategories(any())).thenReturn(Flux.empty()); postPermalinkPolicy = new PostPermalinkPolicy(environmentFetcher, externalUrlSupplier, postService); } @Test void permalink() { Post post = TestPost.postV1(); Map annotations = MetadataUtil.nullSafeAnnotations(post); annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/{year}/{month}/{day}/{slug}"); post.getMetadata().setName("test-post"); post.getSpec().setSlug("test-post-slug"); Instant now = Instant.now(); post.getSpec().setPublishTime(now); ZonedDateTime zonedDateTime = now.atZone(ZoneId.systemDefault()); String year = String.valueOf(zonedDateTime.getYear()); String month = NUMBER_FORMAT.format(zonedDateTime.getMonthValue()); String day = NUMBER_FORMAT.format(zonedDateTime.getDayOfMonth()); String permalink = postPermalinkPolicy.permalink(post); assertThat(permalink) .isEqualTo(PathUtils.combinePath(year, month, day, post.getSpec().getSlug())); // pattern {month}/{day}/{slug} annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/{month}/{day}/{slug}"); permalink = postPermalinkPolicy.permalink(post); assertThat(permalink) .isEqualTo(PathUtils.combinePath(month, day, post.getSpec().getSlug())); // pattern /?p={name} annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/?p={name}"); permalink = postPermalinkPolicy.permalink(post); assertThat(permalink).isEqualTo("/?p=test-post"); // pattern /posts/{slug} annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/posts/{slug}"); permalink = postPermalinkPolicy.permalink(post); assertThat(permalink).isEqualTo("/posts/test-post-slug"); // pattern /posts/{name} annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/posts/{name}"); permalink = postPermalinkPolicy.permalink(post); assertThat(permalink).isEqualTo("/posts/test-post"); } @Test void permalinkForCategory() { Post post = TestPost.postV1(); post.getSpec().setCategories(List.of("test-category")); Map annotations = MetadataUtil.nullSafeAnnotations(post); annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/{categorySlug}/{slug}"); post.getMetadata().setName("test-post"); post.getSpec().setSlug("test-post-slug"); Instant now = Instant.now(); post.getSpec().setPublishTime(now); var category = createCategory("test-category", "test-category-slug"); when(postService.listCategories(post.getSpec().getCategories())) .thenReturn(Flux.just(category)); var permalink = postPermalinkPolicy.permalink(post); assertThat(permalink).isEqualTo("/test-category-slug/test-post-slug"); } @Test void permalinkWithExternalUrl() { Post post = TestPost.postV1(); Map annotations = MetadataUtil.nullSafeAnnotations(post); annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/{year}/{month}/{day}/{slug}"); post.getMetadata().setName("test-post"); post.getSpec().setSlug("test-post-slug"); Instant now = Instant.parse("2022-11-01T02:40:06.806310Z"); post.getSpec().setPublishTime(now); when(externalUrlSupplier.get()).thenReturn(URI.create("http://example.com")); String permalink = postPermalinkPolicy.permalink(post); assertThat(permalink).isEqualTo("http://example.com/2022/11/01/test-post-slug"); post.getSpec().setSlug("中文 slug"); permalink = postPermalinkPolicy.permalink(post); assertThat(permalink).isEqualTo("http://example.com/2022/11/01/%E4%B8%AD%E6%96%87%20slug"); } private Category createCategory(String name, String slug) { Category category = new Category(); Metadata metadata = new Metadata(); metadata.setName(name); category.setMetadata(metadata); category.setSpec(new Category.CategorySpec()); category.setStatus(new Category.CategoryStatus()); category.getSpec().setDisplayName("display-name"); category.getSpec().setSlug(slug); category.getSpec().setPriority(0); return category; } } ================================================ FILE: application/src/test/java/run/halo/app/content/permalinks/TagPermalinkPolicyTest.java ================================================ package run.halo.app.content.permalinks; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; import java.net.URI; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationContext; import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.Metadata; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemConfigFetcher; /** * Tests for {@link TagPermalinkPolicy}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class TagPermalinkPolicyTest { @Mock private ApplicationContext applicationContext; @Mock private ExternalUrlSupplier externalUrlSupplier; @Mock private SystemConfigFetcher environmentFetcher; private TagPermalinkPolicy tagPermalinkPolicy; @BeforeEach void setUp() { tagPermalinkPolicy = new TagPermalinkPolicy(externalUrlSupplier, environmentFetcher); } @Test void permalink() { Tag tag = new Tag(); Metadata metadata = new Metadata(); metadata.setName("test-tag"); tag.setMetadata(metadata); Tag.TagSpec tagSpec = new Tag.TagSpec(); tagSpec.setSlug("test-slug"); tag.setSpec(tagSpec); when(externalUrlSupplier.get()).thenReturn(URI.create("")); String permalink = tagPermalinkPolicy.permalink(tag); assertThat(permalink).isEqualTo("/tags/test-slug"); when(externalUrlSupplier.get()).thenReturn(URI.create("http://example.com")); permalink = tagPermalinkPolicy.permalink(tag); assertThat(permalink).isEqualTo("http://example.com/tags/test-slug"); tag.getSpec().setSlug("中文slug"); permalink = tagPermalinkPolicy.permalink(tag); assertThat(permalink).isEqualTo("http://example.com/tags/%E4%B8%AD%E6%96%87slug"); } } ================================================ FILE: application/src/test/java/run/halo/app/core/attachment/PolicyConfigChangeDetectorTest.java ================================================ package run.halo.app.core.attachment; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Sort; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler; /** * Tests for {@link PolicyConfigChangeDetector}. * * @author guqing * @since 2.20.0 */ @ExtendWith(MockitoExtension.class) class PolicyConfigChangeDetectorTest { @Mock private PolicyConfigChangeDetector.AttachmentUpdateTrigger updateTrigger; @Mock private ExtensionClient client; @InjectMocks private PolicyConfigChangeDetector policyConfigChangeDetector; @Test void reconcileTest() { final var spyDetector = spy(policyConfigChangeDetector); var configMap = new ConfigMap(); configMap.setMetadata(new Metadata()); configMap.getMetadata().setLabels(Map.of(Policy.POLICY_OWNER_LABEL, "fake-policy")); when(client.fetch(eq(ConfigMap.class), eq("fake-config"))) .thenReturn(Optional.of(configMap)); when(client.listAllNames(same(Attachment.class), any(ListOptions.class), any(Sort.class))) .thenReturn(List.of("fake-attachment")); spyDetector.reconcile(new Reconciler.Request("fake-config")); verify(updateTrigger).addAll(List.of("fake-attachment")); } } ================================================ FILE: application/src/test/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandlerTest.java ================================================ package run.halo.app.core.attachment.endpoint; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.params.provider.Arguments.arguments; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.time.Clock; import java.time.Instant; import java.time.ZoneId; import java.util.Map; import java.util.function.Consumer; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.http.MediaType; import reactor.core.publisher.Flux; import reactor.test.StepVerifier; import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.core.attachment.thumbnail.LocalThumbnailService; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Constant; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler; import run.halo.app.core.extension.attachment.endpoint.UploadOption; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.infra.ExternalUrlSupplier; @ExtendWith(MockitoExtension.class) class LocalAttachmentUploadHandlerTest { @InjectMocks LocalAttachmentUploadHandler uploadHandler; @Mock AttachmentRootGetter attachmentRootGetter; @Mock ExternalUrlSupplier externalUrlSupplier; @Mock LocalThumbnailService localThumbnailService; @TempDir Path attachmentRoot; static Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()); @BeforeEach void setUp() { uploadHandler.setClock(clock); lenient().when(externalUrlSupplier.get()).thenReturn(URI.create("/")); } public static Stream testUploadWithRenameStrategy() { return Stream.of(arguments( "Random file name with length 10", """ { "alwaysRenameFilename": true, "renameStrategy": { "method": "RANDOM", "randomLength": 10 } } """, (Consumer) attachment -> { var displayName = attachment.getSpec().getDisplayName(); assertTrue(displayName.startsWith("halo-")); assertTrue(displayName.endsWith(".png")); // halo-xxxxxx.png assertEquals(4 + 10 + 5, displayName.length()); // fake-content assertEquals(12L, attachment.getSpec().getSize()); }), arguments( "Random file name with length 10 but without original filename", """ { "alwaysRenameFilename": true, "renameStrategy": { "method": "RANDOM", "randomLength": 10, "excludeOriginalFilename": true } } """, (Consumer) attachment -> { var displayName = attachment.getSpec().getDisplayName(); assertFalse(displayName.startsWith("halo-")); assertTrue(displayName.endsWith(".png")); // halo-xxxxxx.png assertEquals(10 + 4, displayName.length()); // fake-content assertEquals(12L, attachment.getSpec().getSize()); }), arguments( "Rename filename with UUID but exclude original filename", """ { "alwaysRenameFilename": true, "renameStrategy": { "method": "UUID", "excludeOriginalFilename": true } } """, (Consumer) attachment -> { var displayName = attachment.getSpec().getDisplayName(); assertFalse(displayName.startsWith("halo-")); assertTrue(displayName.endsWith(".png")); // xxxxxx.png assertEquals(36 + 4, displayName.length()); // fake-content assertEquals(12L, attachment.getSpec().getSize()); } ), arguments( "Rename filename with UUID", """ { "alwaysRenameFilename": true, "renameStrategy": { "method": "UUID", "excludeOriginalFilename": false } } """, (Consumer) attachment -> { var displayName = attachment.getSpec().getDisplayName(); assertTrue(displayName.startsWith("halo-")); assertTrue(displayName.endsWith(".png")); // xxxxxx.png assertEquals(5 + 36 + 4, displayName.length()); // fake-content assertEquals(12L, attachment.getSpec().getSize()); } ), arguments( "Rename filename with timestamp but without original filename", """ { "alwaysRenameFilename": true, "renameStrategy": { "method": "TIMESTAMP", "excludeOriginalFilename": true } } """, (Consumer) attachment -> { var expect = clock.instant().toEpochMilli() + ".png"; assertEquals(expect, attachment.getSpec().getDisplayName()); } ), arguments( "Rename filename with timestamp", """ { "alwaysRenameFilename": true, "renameStrategy": { "method": "TIMESTAMP" } } """, (Consumer) attachment -> { var expect = "halo-" + clock.instant().toEpochMilli() + ".png"; assertEquals(expect, attachment.getSpec().getDisplayName()); } ) ); } @ParameterizedTest(name = "{0}") @MethodSource void testUploadWithRenameStrategy(String name, String config, Consumer assertion) { assertNotNull(uploadHandler); var dataBufferFactory = new DefaultDataBufferFactory(); var dataBuffer = dataBufferFactory.allocateBuffer(1024); dataBuffer.write("fake content".getBytes(StandardCharsets.UTF_8)); var content = Flux.just(dataBuffer); var policy = new Policy(); var policySpec = new Policy.PolicySpec(); policy.setSpec(policySpec); policySpec.setTemplateName("local"); var configMap = new ConfigMap(); configMap.setData(Map.of("default", config)); var uploadOption = UploadOption.from("halo.png", content, MediaType.IMAGE_PNG, policy, configMap); when(attachmentRootGetter.get()).thenReturn(attachmentRoot); uploadHandler.upload(uploadOption) .as(StepVerifier::create) .assertNext(attachment -> { assertion.accept(attachment); assertNotNull(attachment.getStatus().getPermalink()); assertNotNull(attachment.getStatus().getThumbnails()); }) .verifyComplete(); } @Test void shouldGetPermalinkWhenUriContainsIllegalChars() { var attachment = new Attachment(); attachment.setMetadata(new Metadata()); attachment.getMetadata().setAnnotations(Map.of( Constant.URI_ANNO_KEY, "/path/with space.png" )); var permalink = uploadHandler.doGetPermalink(attachment); assertTrue(permalink.isPresent()); assertEquals("/path/with%20space.png", permalink.get().toASCIIString()); } @Test void shouldDeleteWithThumbnails() { var deleteContext = Mockito.mock(AttachmentHandler.DeleteContext.class); when(deleteContext.policy()).thenReturn(createPolicy("local")); var attachment = createAttachment(Map.of(Constant.LOCAL_REL_PATH_ANNO_KEY, "path/to/file.png")); when(deleteContext.attachment()).thenReturn(attachment); when(attachmentRootGetter.get()).thenReturn(attachmentRoot); uploadHandler.delete(deleteContext) .as(StepVerifier::create) .expectNext(attachment) .verifyComplete(); verify(this.localThumbnailService).delete(attachmentRoot .resolve("path") .resolve("to") .resolve("file.png") ); } Attachment createAttachment(Map annotations) { var attachment = new Attachment(); attachment.setMetadata(new Metadata()); attachment.getMetadata().setName("fake-attachment"); attachment.getMetadata().setAnnotations(annotations); return attachment; } Policy createPolicy(String templateName) { var policy = new Policy(); policy.setMetadata(new Metadata()); policy.getMetadata().setName("fake-policy"); policy.setSpec(new Policy.PolicySpec()); policy.getSpec().setTemplateName(templateName); return policy; } } ================================================ FILE: application/src/test/java/run/halo/app/core/attachment/endpoint/PolicyEndpointTest.java ================================================ package run.halo.app.core.attachment.endpoint; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.HashMap; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.transaction.ReactiveTransaction; import org.springframework.transaction.ReactiveTransactionManager; import reactor.core.publisher.Mono; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; import run.halo.app.extension.exception.ExtensionNotFoundException; import tools.jackson.databind.json.JsonMapper; @ExtendWith(MockitoExtension.class) class PolicyEndpointTest { @Mock ReactiveExtensionClient client; @Spy JsonMapper jsonMapper = JsonMapper.shared(); @Mock ReactiveTransactionManager txManager; @InjectMocks PolicyEndpoint endpoint; WebTestClient webClient; @BeforeEach void setUp() { webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) .build(); } @Test void shouldRespondNotFoundIfPolicyNotFound() { // Implement test logic here var policyScheme = Scheme.buildFromType(Policy.class); when(client.get(Policy.class, "fake-policy")) .thenReturn(Mono.error(() -> new ExtensionNotFoundException( policyScheme.groupVersionKind(), "fake-policy") )); webClient.get().uri("/policies/fake-policy/configs/fake-group") .exchange() .expectStatus().isNotFound(); } @Test void shouldRespondNullIfNoConfigFound() { when(client.get(Policy.class, "fake-policy")).thenReturn(Mono.fromSupplier(() -> { var policy = new Policy(); policy.setSpec(new Policy.PolicySpec()); policy.getSpec().setConfigMapName("fake-config-map"); return policy; })); when(client.fetch(ConfigMap.class, "fake-config-map")) .thenReturn(Mono.empty()); webClient.get().uri("/policies/fake-policy/configs/fake-group") .exchange() .expectStatus().isOk() .expectBody(String.class) .isEqualTo("null"); } @Test void shouldRespondNullIfGroupNotFound() { when(client.get(Policy.class, "fake-policy")).thenReturn(Mono.fromSupplier(() -> { var policy = new Policy(); policy.setSpec(new Policy.PolicySpec()); policy.getSpec().setConfigMapName("fake-config-map"); return policy; })); when(client.fetch(ConfigMap.class, "fake-config-map")) .thenReturn(Mono.fromSupplier(() -> { var cm = new ConfigMap(); cm.setData(new HashMap<>()); return cm; })); webClient.get().uri("/policies/fake-policy/configs/fake-group") .exchange() .expectStatus().isOk() .expectBody(String.class) .isEqualTo("null"); } @Test void shouldRespondConfigIfGroupFound() { when(client.get(Policy.class, "fake-policy")).thenReturn(Mono.fromSupplier(() -> { var policy = new Policy(); policy.setSpec(new Policy.PolicySpec()); policy.getSpec().setConfigMapName("fake-config-map"); return policy; })); when(client.fetch(ConfigMap.class, "fake-config-map")) .thenReturn(Mono.fromSupplier(() -> { var cm = new ConfigMap(); cm.setData(new HashMap<>()); cm.getData().put("fake-group", """ { "halo": "awesome" }"""); return cm; })); webClient.get().uri("/policies/fake-policy/configs/fake-group") .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.halo").isEqualTo("awesome"); } @Test void shouldUpdateConfigIfPresent() { when(client.get(Policy.class, "fake-policy")).thenReturn(Mono.fromSupplier(() -> { var policy = new Policy(); policy.setSpec(new Policy.PolicySpec()); policy.getSpec().setConfigMapName("fake-config-map"); return policy; })); var cm = new ConfigMap(); cm.setMetadata(new Metadata()); cm.getMetadata().setName("fake-config-map"); cm.getMetadata().setVersion(1L); cm.setData(new HashMap<>()); cm.getData().put("fake-group", """ { "halo": "awesome" }"""); when(client.fetch(ConfigMap.class, "fake-config-map")) .thenReturn(Mono.just(cm)); var tx = mock(ReactiveTransaction.class); when(txManager.getReactiveTransaction(any())).thenReturn(Mono.just(tx)); when(txManager.commit(tx)).thenReturn(Mono.empty()); when(client.update(cm)).thenReturn(Mono.just(cm)); var body = """ { "halo": "nice", "key": "value" }"""; webClient.put().uri("/policies/fake-policy/configs/fake-group") .contentType(MediaType.APPLICATION_JSON) .bodyValue(body) .exchange() .expectStatus().isNoContent(); verify(client).update(assertArg(gotCm -> { var data = gotCm.getData(); JSONAssert.assertEquals(body, data.get("fake-group"), true); })); } @Test void shouldCreateConfigIfAbsent() { var policy = new Policy(); policy.setSpec(new Policy.PolicySpec()); when(client.get(Policy.class, "fake-policy")).thenReturn(Mono.just(policy)); var tx = mock(ReactiveTransaction.class); when(txManager.getReactiveTransaction(any())).thenReturn(Mono.just(tx)); when(txManager.commit(tx)).thenReturn(Mono.empty()); var cm = new ConfigMap(); cm.setMetadata(new Metadata()); cm.getMetadata().setName("fake-config-map"); cm.getMetadata().setVersion(1L); cm.setData(new HashMap<>()); cm.getData().put("fake-group", """ { "halo": "nice", "key": "value" }\ """); when(client.create(any(ConfigMap.class))).thenReturn(Mono.just(cm)); when(client.update(policy)).thenReturn(Mono.just(policy)); var body = """ { "halo": "nice", "key": "value" }"""; webClient.put().uri("/policies/fake-policy/configs/fake-group") .contentType(MediaType.APPLICATION_JSON) .bodyValue(body) .exchange() .expectStatus().isNoContent(); verify(client).create(assertArg(gotCm -> { var data = gotCm.getData(); JSONAssert.assertEquals(body, data.get("fake-group"), true); })); verify(client).update(assertArg( gotPolicy -> assertEquals("fake-config-map", gotPolicy.getSpec().getConfigMapName()) )); } } ================================================ FILE: application/src/test/java/run/halo/app/core/attachment/impl/AttachmentRootGetterImplTest.java ================================================ package run.halo.app.core.attachment.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; import java.nio.file.Path; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.infra.properties.HaloProperties; /** * Tests for {@link AttachmentRootGetterImpl}. * * @author guqing * @since 2.19.0 */ @ExtendWith(MockitoExtension.class) class AttachmentRootGetterImplTest { @Mock private HaloProperties haloProperties; @InjectMocks private AttachmentRootGetterImpl localAttachmentDirGetter; @Test void get() { var rootPath = Path.of("/tmp"); when(haloProperties.getWorkDir()).thenReturn(rootPath); var dir = localAttachmentDirGetter.get(); assertThat(dir).isEqualTo(rootPath.resolve("attachments")); } } ================================================ FILE: application/src/test/java/run/halo/app/core/attachment/thumbnail/DefaultLocalThumbnailServiceTest.java ================================================ package run.halo.app.core.attachment.thumbnail; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import com.google.common.util.concurrent.MoreExecutors; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.util.ResourceUtils; import reactor.test.StepVerifier; import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.infra.properties.AttachmentProperties; import run.halo.app.infra.properties.HaloProperties; @ExtendWith(MockitoExtension.class) class DefaultLocalThumbnailServiceTest { @Mock AttachmentRootGetter attachmentRootGetter; @Mock HaloProperties haloProperties; @Mock AttachmentProperties.ThumbnailProperties thumbnailProperties; DefaultLocalThumbnailService generator; Path source; @TempDir Path attachmentRoot; @BeforeEach void setUp() throws IOException { var attachmentProperties = mock(AttachmentProperties.class); when(attachmentProperties.getThumbnail()).thenReturn(thumbnailProperties); when(haloProperties.getAttachment()).thenReturn(attachmentProperties); when(thumbnailProperties.isDisabled()).thenReturn(false); when(thumbnailProperties.getConcurrentThreads()).thenReturn(1); var imagePath = ResourceUtils.getFile("classpath:static/images/halo-logo-401x401.png").toPath(); lenient().when(attachmentRootGetter.get()).thenReturn(attachmentRoot); this.source = attachmentRoot.resolve("static").resolve("hal-logo-401x401.png"); Files.createDirectories(this.source.getParent()); Files.copy(imagePath, this.source); this.generator = new DefaultLocalThumbnailService(this.attachmentRootGetter, this.haloProperties); var executorService = MoreExecutors.newDirectExecutorService(); this.generator.setExecutorService(executorService); } @AfterEach void cleanUp() throws Exception { this.generator.destroy(); } @Test void shouldGenerateThumbnail() { this.generator.generate(source, ThumbnailSize.S) .as(StepVerifier::create) .assertNext(resource -> { assertTrue(resource.isReadable()); assertDoesNotThrow(() -> { var thumbnailSize = resource.contentLength(); var sourceSize = Files.size(source); assertTrue(thumbnailSize < sourceSize); }); }) .verifyComplete(); } @Test void shouldReplaceWithSourceIfSizeIsLarger() { this.generator.generate(source, ThumbnailSize.M) .as(StepVerifier::create) .assertNext(resource -> { assertTrue(resource.isReadable()); assertDoesNotThrow(() -> { var thumbnailSize = resource.contentLength(); var sourceSize = Files.size(source); assertEquals(thumbnailSize, sourceSize); }); }) .verifyComplete(); } @Test void shouldDisableThumbnailGeneration() { when(thumbnailProperties.isDisabled()).thenReturn(true); this.generator.generate(source, ThumbnailSize.S) .as(StepVerifier::create) .verifyComplete(); } } ================================================ FILE: application/src/test/java/run/halo/app/core/attachment/thumbnail/DefaultThumbnailServiceTest.java ================================================ package run.halo.app.core.attachment.thumbnail; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.net.MalformedURLException; import java.net.URI; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Sort; import reactor.core.publisher.Flux; import reactor.test.StepVerifier; import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.ExternalUrlSupplier; @ExtendWith(MockitoExtension.class) class DefaultThumbnailServiceTest { @Mock ReactiveExtensionClient client; @Mock ExternalUrlSupplier externalUrlSupplier; @InjectMocks DefaultThumbnailService thumbnailService; @Test void shouldGetThumbnailDirectlyIfPermalinkIsRelative() { thumbnailService.get(URI.create("/images/fake.png"), ThumbnailSize.M) .as(StepVerifier::create) .expectNext(URI.create("/images/fake.png?width=800")) .verifyComplete(); } @Test void shouldGetThumbnailDirectlyIfPermalinkContainsSpecialChars() { thumbnailService.get(URI.create("/images/中文.png"), ThumbnailSize.M) .as(StepVerifier::create) .expectNext(URI.create("/images/%E4%B8%AD%E6%96%87.png?width=800")) .verifyComplete(); thumbnailService.get(URI.create("/images/space%20space.png"), ThumbnailSize.M) .as(StepVerifier::create) .expectNext(URI.create("/images/space%20space.png?width=800")) .verifyComplete(); thumbnailService.get(URI.create("/images/percent%2f.png"), ThumbnailSize.M) .as(StepVerifier::create) .expectNext(URI.create("/images/percent%2f.png?width=800")) .verifyComplete(); } @Test void shouldGetThumbnailDirectlyIfPermalinkIsInSite() throws MalformedURLException { when(externalUrlSupplier.getRaw()).thenReturn(URI.create("https://www.halo.run").toURL()); thumbnailService.get(URI.create("https://www.halo.run/images/fake.png"), ThumbnailSize.M) .as(StepVerifier::create) .expectNext(URI.create("https://www.halo.run/images/fake.png?width=800")) .verifyComplete(); } @Test void shouldGetEmptyThumbnailIfNoAttachmentsFound() throws MalformedURLException { when(externalUrlSupplier.getRaw()).thenReturn(URI.create("https://www.halo.run").toURL()); Mockito.when( client.listAll(same(Attachment.class), isA(ListOptions.class), isA(Sort.class)) ) .thenReturn(Flux.empty()); thumbnailService.get(URI.create("https://fake.halo.run/fake.png")) .as(StepVerifier::create) .expectNext(Map.of()) .verifyComplete(); // Only invoke once due to caching verify(client).listAll(same(Attachment.class), isA(ListOptions.class), isA(Sort.class)); } @Test void shouldGetThumbnailsIfAttachmentsFound() throws MalformedURLException { when(externalUrlSupplier.getRaw()).thenReturn(URI.create("https://www.halo.run").toURL()); Mockito.when( client.listAll(same(Attachment.class), isA(ListOptions.class), isA(Sort.class)) ) .thenReturn(Flux.just( createAttachment("fake-png", "https://fake.halo.run/fake.png", Map.of("s", "/fake.png?width=400")), createAttachment("fake-png", "https://fake.halo.run/fake.png", Map.of("m", "/fake.png?width=800")) )); thumbnailService.get(URI.create("https://fake.halo.run/fake.png")) .as(StepVerifier::create) .expectNext(Map.of(ThumbnailSize.S, URI.create("/fake.png?width=400"))) .verifyComplete(); // Only invoke once due to caching verify(client).listAll(same(Attachment.class), isA(ListOptions.class), isA(Sort.class)); } Attachment createAttachment(String name, String permalink, Map thumbnails) { var attachment = new Attachment(); attachment.setMetadata(new Metadata()); attachment.getMetadata().setName(name); attachment.setSpec(new Attachment.AttachmentSpec()); attachment.setStatus(new Attachment.AttachmentStatus()); attachment.getStatus().setPermalink(permalink); attachment.getStatus().setThumbnails(thumbnails); return attachment; } } ================================================ FILE: application/src/test/java/run/halo/app/core/attachment/thumbnail/ThumbnailImgTagPostProcessorTest.java ================================================ package run.halo.app.core.attachment.thumbnail; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; import static org.thymeleaf.templatemode.TemplateMode.HTML; import java.net.URI; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.engine.StandardModelFactory; import org.thymeleaf.model.AttributeValueQuotes; import org.thymeleaf.model.IModelFactory; import org.thymeleaf.spring6.SpringTemplateEngine; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.attachment.ThumbnailSize; @ExtendWith(MockitoExtension.class) class ThumbnailImgTagPostProcessorTest { @Mock ThumbnailService thumbnailService; @Mock ITemplateContext templateContext; @InjectMocks ThumbnailImgTagPostProcessor processor; IModelFactory modelFactory; @BeforeEach void setUp() { var templateEngine = new SpringTemplateEngine(); this.modelFactory = new StandardModelFactory(templateEngine.getConfiguration(), HTML); } @Test void shouldReturnEmptyIfImgTagWithoutSrc() { var imgTag = modelFactory.createStandaloneElementTag("img"); processor.process(templateContext, imgTag) .as(StepVerifier::create) .verifyComplete(); } @Test void shouldReturnEmptyIfImgTagWithSrcSet() { var imgTag = modelFactory.createStandaloneElementTag( "img", Map.of("src", "/halo.png", "srcset", "fake-srcset"), AttributeValueQuotes.DOUBLE, true, true); processor.process(templateContext, imgTag) .as(StepVerifier::create) .verifyComplete(); } @Test void shouldReturnEmptyIfNotImgTag() { var imgTag = modelFactory.createStandaloneElementTag("not-a-img"); processor.process(templateContext, imgTag) .as(StepVerifier::create) .verifyComplete(); } @Test void shouldReturnEmptyIfNoThumbnailsFound() { var imgTag = modelFactory.createStandaloneElementTag("img", "src", "/halo.png"); when(thumbnailService.get(URI.create("/halo.png"))) .thenReturn(Mono.just(Map.of())); processor.process(templateContext, imgTag) .as(StepVerifier::create) .verifyComplete(); } @Test void shouldReturnTagIfImgTagWithSrc() { var imgTag = modelFactory.createStandaloneElementTag("img", "src", "/halo.png"); when(templateContext.getModelFactory()).thenReturn(modelFactory); when(thumbnailService.get(URI.create("/halo.png"))) .thenReturn(Mono.just(Map.of(ThumbnailSize.S, URI.create("/halo.png?width=400")))); processor.process(templateContext, imgTag) .as(StepVerifier::create) .assertNext(tag -> { var srcset = tag.getAttribute("srcset"); assertEquals("/halo.png?width=400 400w", srcset.getValue()); assertTrue(tag.hasAttribute("sizes")); }) .verifyComplete(); } @Test void shouldReturnTagIfImgTagWithSrcAndSizes() { var imgTag = modelFactory.createStandaloneElementTag( "img", Map.of("src", "/halo.png", "sizes", "fake-sizes"), AttributeValueQuotes.DOUBLE, true, true); when(templateContext.getModelFactory()).thenReturn(modelFactory); when(thumbnailService.get(URI.create("/halo.png"))) .thenReturn(Mono.just(Map.of(ThumbnailSize.S, URI.create("/halo.png?width=400")))); processor.process(templateContext, imgTag) .as(StepVerifier::create) .assertNext(tag -> { assertEquals("/halo.png?width=400 400w", tag.getAttribute("srcset").getValue()); assertEquals("fake-sizes", tag.getAttribute("sizes").getValue()); }) .verifyComplete(); } } ================================================ FILE: application/src/test/java/run/halo/app/core/attachment/thumbnail/ThumbnailResourceTransformerTest.java ================================================ package run.halo.app.core.attachment.thumbnail; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import java.io.IOException; import java.nio.file.Path; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.io.Resource; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.web.reactive.resource.ResourceTransformerChain; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.attachment.ThumbnailSize; @ExtendWith(MockitoExtension.class) class ThumbnailResourceTransformerTest { @Mock LocalThumbnailService localThumbnailService; @Mock ResourceTransformerChain transformerChain; @Mock Resource resource; @InjectMocks ThumbnailResourceTransformer thumbnailResourceTransformer; @Test void shouldNotTransformWithoutWidthQuery() { var exchange = MockServerWebExchange.builder(MockServerHttpRequest.get("/halo.png").build()) .build(); when(this.transformerChain.transform(exchange, this.resource)) .thenReturn(Mono.just(this.resource)); thumbnailResourceTransformer.transform(exchange, this.resource, this.transformerChain) .as(StepVerifier::create) .expectNext(this.resource) .verifyComplete(); } @Test void shouldNotTransformWithNonFileResource() { var exchange = MockServerWebExchange.builder( MockServerHttpRequest.get("/halo.png").queryParam("width", "400").build()) .build(); when(this.resource.isFile()).thenReturn(false); when(this.transformerChain.transform(exchange, this.resource)) .thenReturn(Mono.just(this.resource)); thumbnailResourceTransformer.transform(exchange, this.resource, this.transformerChain) .as(StepVerifier::create) .expectNext(this.resource) .verifyComplete(); } @Test void shouldNotTransformWithUnsupportedImageType() { var exchange = MockServerWebExchange.builder( MockServerHttpRequest.get("/halo.svg").queryParam("width", "400").build()) .build(); when(this.resource.isFile()).thenReturn(true); when(this.resource.getFilename()).thenReturn("halo.svg"); when(this.transformerChain.transform(exchange, this.resource)) .thenReturn(Mono.just(this.resource)); thumbnailResourceTransformer.transform(exchange, this.resource, this.transformerChain) .as(StepVerifier::create) .expectNext(this.resource) .verifyComplete(); } @Test void shouldReturnSourceIfEmptyGeneration() throws IOException { var exchange = MockServerWebExchange.builder( MockServerHttpRequest.get("/halo.png").queryParam("width", "400").build()) .build(); var attachmentRoot = Path.of("attachments").toAbsolutePath(); var sourcePath = attachmentRoot.resolve("upload").resolve("halo.png"); when(this.resource.isFile()).thenReturn(true); when(this.resource.getFilename()).thenReturn(sourcePath.getFileName().toString()); when(this.resource.getFile()).thenReturn(sourcePath.toFile()); thumbnailResourceTransformer = spy(thumbnailResourceTransformer); when(localThumbnailService.generate(sourcePath, ThumbnailSize.S)).thenReturn(Mono.empty()); when(this.transformerChain.transform(eq(exchange), isA(Resource.class))) .thenAnswer(invocation -> Mono.just(invocation.getArgument(1))); thumbnailResourceTransformer.transform(exchange, this.resource, this.transformerChain) .as(StepVerifier::create) .assertNext(resource -> assertDoesNotThrow( () -> assertEquals(sourcePath.toUri(), resource.getURI()) )) .verifyComplete(); } @Test void shouldReturnIfThumbnailExists() throws IOException { var exchange = MockServerWebExchange.builder( MockServerHttpRequest.get("/halo.png").queryParam("width", "400").build()) .build(); var attachmentRoot = Path.of("attachments").toAbsolutePath(); var sourcePath = attachmentRoot.resolve("upload").resolve("halo.png"); when(this.resource.isFile()).thenReturn(true); when(this.resource.getFilename()).thenReturn(sourcePath.getFileName().toString()); when(this.resource.getFile()).thenReturn(sourcePath.toFile()); thumbnailResourceTransformer = spy(thumbnailResourceTransformer); var generatedResource = mock(Resource.class); when(this.localThumbnailService.generate(sourcePath, ThumbnailSize.S)) .thenReturn(Mono.just(generatedResource)); when(this.transformerChain.transform(exchange, generatedResource)) .thenReturn(Mono.just(generatedResource)); thumbnailResourceTransformer.transform(exchange, this.resource, this.transformerChain) .as(StepVerifier::create) .expectNext(generatedResource) .verifyComplete(); } } ================================================ FILE: application/src/test/java/run/halo/app/core/attachment/thumbnail/ThumbnailUtilsTest.java ================================================ package run.halo.app.core.attachment.thumbnail; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.net.URI; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.MediaType; import run.halo.app.core.attachment.ThumbnailSize; class ThumbnailUtilsTest { @ParameterizedTest @ValueSource(strings = { "image/jpg", "image/jpeg", "image/png", "image/bmp", "image/vnd.wap.wbmp", }) void shouldBeSupportedImageType(String mimeType) { Assertions.assertTrue(ThumbnailUtils.isSupportedImage(MediaType.parseMediaType(mimeType))); } @ParameterizedTest @ValueSource(strings = { "image/svg+xml", "image/gif", "image/webp", "image/x-icon", "image/avif", "image/tiff", "application/json", "text/plain", "application/octet-stream" }) void shouldNotBeSupportedImageType(String mimeType) { assertFalse(ThumbnailUtils.isSupportedImage(MediaType.parseMediaType(mimeType))); } @ParameterizedTest @ValueSource(strings = { "jpg", "jpeg", "png", "bmp", "wbmp", "JPG", "JPEG", "PNG", "BMP", "WBMP" }) void shouldBeSupportedImageSuffix(String suffix) { assertTrue(ThumbnailUtils.isSupportedImage(suffix)); } @ParameterizedTest @ValueSource(strings = { "svg", "avif", "gif", "webp", "x-icon", "tiff", "json", "txt", "", " ", " ", ".jpg" }) void shouldNotBeSupportedImageSuffix(String suffix) { assertFalse(ThumbnailUtils.isSupportedImage(suffix)); } @Test void shouldBuildSrcSetWithUriWithSpecialCharacters() { var permalink = URI.create("/中文.png").toASCIIString(); var srcsetMap = ThumbnailUtils.buildSrcsetMap(URI.create(permalink)); assertEquals("/%E4%B8%AD%E6%96%87.png?width=400", srcsetMap.get(ThumbnailSize.S).toString()); assertEquals("/%E4%B8%AD%E6%96%87.png?width=800", srcsetMap.get(ThumbnailSize.M).toString()); assertEquals("/%E4%B8%AD%E6%96%87.png?width=1200", srcsetMap.get(ThumbnailSize.L).toString()); assertEquals("/%E4%B8%AD%E6%96%87.png?width=1600", srcsetMap.get(ThumbnailSize.XL).toString()); } } ================================================ FILE: application/src/test/java/run/halo/app/core/counter/MeterUtilsTest.java ================================================ package run.halo.app.core.counter; import static org.assertj.core.api.Assertions.assertThat; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.Meter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.search.RequiredSearch; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; import run.halo.app.core.extension.content.Post; /** * Tests for {@link MeterUtils}. * * @author guqing * @since 2.0.0 */ class MeterUtilsTest { @Test void nameOf() { String s = MeterUtils.nameOf(Post.class, "fake-post"); assertThat(s).isEqualTo("posts.content.halo.run/fake-post"); } @Test void testNameOf() { String s = MeterUtils.nameOf("content.halo.run", "posts", "fake-post"); assertThat(s).isEqualTo("posts.content.halo.run/fake-post"); } @Test void visitCounter() { MeterRegistry meterRegistry = new SimpleMeterRegistry(); MeterUtils.visitCounter(meterRegistry, "posts.content.halo.run/fake-post") .increment(); RequiredSearch requiredSearch = meterRegistry.get("posts.content.halo.run/fake-post"); assertThat(requiredSearch.counter().count()).isEqualTo(1); Meter.Id id = requiredSearch.counter().getId(); assertThat(id.getTag(MeterUtils.SCENE)).isEqualTo(MeterUtils.VISIT_SCENE); assertThat(id.getTag(MeterUtils.METRICS_COMMON_TAG.getKey())) .isEqualTo(MeterUtils.METRICS_COMMON_TAG.getValue()); } @Test void upvoteCounter() { MeterRegistry meterRegistry = new SimpleMeterRegistry(); MeterUtils.upvoteCounter(meterRegistry, "posts.content.halo.run/fake-post") .increment(2); RequiredSearch requiredSearch = meterRegistry.get("posts.content.halo.run/fake-post"); assertThat(requiredSearch.counter().count()).isEqualTo(2); Meter.Id id = requiredSearch.counter().getId(); assertThat(id.getTag(MeterUtils.SCENE)).isEqualTo(MeterUtils.UPVOTE_SCENE); assertThat(id.getTag(MeterUtils.METRICS_COMMON_TAG.getKey())) .isEqualTo(MeterUtils.METRICS_COMMON_TAG.getValue()); } @Test void totalCommentCounter() { MeterRegistry meterRegistry = new SimpleMeterRegistry(); MeterUtils.totalCommentCounter(meterRegistry, "content.halo.run.posts.fake-post") .increment(3); RequiredSearch requiredSearch = meterRegistry.get("content.halo.run.posts.fake-post"); assertThat(requiredSearch.counter().count()).isEqualTo(3); Meter.Id id = requiredSearch.counter().getId(); assertThat(id.getTag(MeterUtils.SCENE)).isEqualTo(MeterUtils.TOTAL_COMMENT_SCENE); assertThat(id.getTag(MeterUtils.METRICS_COMMON_TAG.getKey())) .isEqualTo(MeterUtils.METRICS_COMMON_TAG.getValue()); } @Test void approvedCommentCounter() { MeterRegistry meterRegistry = new SimpleMeterRegistry(); MeterUtils.approvedCommentCounter(meterRegistry, "posts.content.halo.run/fake-post") .increment(2); RequiredSearch requiredSearch = meterRegistry.get("posts.content.halo.run/fake-post"); assertThat(requiredSearch.counter().count()).isEqualTo(2); Meter.Id id = requiredSearch.counter().getId(); assertThat(id.getTag(MeterUtils.SCENE)).isEqualTo(MeterUtils.APPROVED_COMMENT_SCENE); assertThat(id.getTag(MeterUtils.METRICS_COMMON_TAG.getKey())) .isEqualTo(MeterUtils.METRICS_COMMON_TAG.getValue()); } @Test void isVisitCounter() { MeterRegistry meterRegistry = new SimpleMeterRegistry(); Counter visitCounter = MeterUtils.visitCounter(meterRegistry, "posts.content.halo.run/fake-post"); assertThat(MeterUtils.isVisitCounter(visitCounter)).isTrue(); } @Test void isUpvoteCounter() { MeterRegistry meterRegistry = new SimpleMeterRegistry(); Counter upvoteCounter = MeterUtils.upvoteCounter(meterRegistry, "posts.content.halo.run/fake-post"); assertThat(MeterUtils.isUpvoteCounter(upvoteCounter)).isTrue(); assertThat(MeterUtils.isVisitCounter(upvoteCounter)).isFalse(); } @Test void isDownvoteCounter() { MeterRegistry meterRegistry = new SimpleMeterRegistry(); Counter downvoteCounter = MeterUtils.downvoteCounter(meterRegistry, "posts.content.halo.run/fake-post"); assertThat(MeterUtils.isDownvoteCounter(downvoteCounter)).isTrue(); assertThat(MeterUtils.isVisitCounter(downvoteCounter)).isFalse(); } @Test void isTotalCommentCounter() { MeterRegistry meterRegistry = new SimpleMeterRegistry(); Counter totalCommentCounter = MeterUtils.totalCommentCounter(meterRegistry, "posts.content.halo.run/fake-post"); assertThat(MeterUtils.isTotalCommentCounter(totalCommentCounter)).isTrue(); assertThat(MeterUtils.isVisitCounter(totalCommentCounter)).isFalse(); } @Test void isApprovedCommentCounter() { MeterRegistry meterRegistry = new SimpleMeterRegistry(); Counter approvedCommentCounter = MeterUtils.approvedCommentCounter(meterRegistry, "posts.content.halo.run/fake-post"); assertThat(MeterUtils.isApprovedCommentCounter(approvedCommentCounter)).isTrue(); assertThat(MeterUtils.isVisitCounter(approvedCommentCounter)).isFalse(); } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/WebSocketHandlerMappingTest.java ================================================ package run.halo.app.core.endpoint; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.reactive.socket.WebSocketHandler; import org.springframework.web.reactive.socket.WebSocketSession; import run.halo.app.extension.GroupVersion; @ExtendWith(MockitoExtension.class) class WebSocketHandlerMappingTest { @InjectMocks WebSocketHandlerMapping handlerMapping; @Test void shouldRegisterEndpoint() { var endpoint = new FakeWebSocketEndpoint(); handlerMapping.register(List.of(endpoint)); assertTrue(handlerMapping.getEndpointMap().containsValue(endpoint)); } @Test void shouldUnregisterEndpoint() { var endpoint = new FakeWebSocketEndpoint(); handlerMapping.register(List.of(endpoint)); assertTrue(handlerMapping.getEndpointMap().containsValue(endpoint)); handlerMapping.unregister(List.of(endpoint)); assertFalse(handlerMapping.getEndpointMap().containsValue(endpoint)); } static class FakeWebSocketEndpoint implements WebSocketEndpoint { @Override public String urlPath() { return "/resources"; } @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("fake.halo.run/v1alpha1"); } @Override public WebSocketHandler handler() { return WebSocketSession::close; } } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/console/EmailVerificationCodeTest.java ================================================ package run.halo.app.core.endpoint.console; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; import io.github.resilience4j.ratelimiter.RateLimiterConfig; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import java.time.Duration; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.validation.Validator; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.core.user.service.EmailVerificationService; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; /** * Tests for a part of {@link UserEndpoint} about sending email verification code. * * @author guqing * @see UserEndpoint * @see EmailVerificationService * @since 2.11.0 */ @ExtendWith({SpringExtension.class, MockitoExtension.class}) @WithMockUser(username = "fake-user", password = "fake-password") class EmailVerificationCodeTest { WebTestClient webClient; @Mock ReactiveExtensionClient client; @Mock EmailVerificationService emailVerificationService; @Mock UserService userService; @Mock RateLimiterRegistry rateLimiterRegistry; @Mock Validator validator; @InjectMocks UserEndpoint endpoint; @BeforeEach void setUp() { webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) .apply(springSecurity()) .build(); } @Test void sendEmailVerificationCode() { var config = RateLimiterConfig.custom() .limitRefreshPeriod(Duration.ofSeconds(10)) .limitForPeriod(1) .build(); var sendCodeRateLimiter = RateLimiterRegistry.of(config) .rateLimiter("send-email-verification-code-fake-user:hi@halo.run"); when(rateLimiterRegistry.rateLimiter( "send-email-verification-code-fake-user:hi@halo.run", "send-email-verification-code") ).thenReturn(sendCodeRateLimiter); var user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName("fake-user"); user.setSpec(new User.UserSpec()); user.getSpec().setEmail("hi@halo.run"); when(emailVerificationService.sendVerificationCode(anyString(), anyString())) .thenReturn(Mono.empty()); webClient.post() .uri("/users/-/send-email-verification-code") .bodyValue(Map.of("email", "hi@halo.run")) .exchange() .expectStatus() .isOk(); // request again to trigger rate limit webClient.post() .uri("/users/-/send-email-verification-code") .bodyValue(Map.of("email", "hi@halo.run")) .exchange() .expectStatus() .isEqualTo(HttpStatus.TOO_MANY_REQUESTS); } @Test void verifyEmail() { var config = RateLimiterConfig.custom() .limitRefreshPeriod(Duration.ofSeconds(10)) .limitForPeriod(1) .build(); var verifyEmailRateLimiter = RateLimiterRegistry.of(config) .rateLimiter("verify-email-fake-user"); when(rateLimiterRegistry.rateLimiter("verify-email-fake-user", "verify-email")) .thenReturn(verifyEmailRateLimiter); when(emailVerificationService.verify(anyString(), anyString())) .thenReturn(Mono.empty()); when(userService.confirmPassword(anyString(), anyString())) .thenReturn(Mono.just(true)); webClient.post() .uri("/users/-/verify-email") .bodyValue(Map.of("code", "fake-code-1", "password", "123456")) .exchange() .expectStatus() .isOk(); // request again to trigger rate limit webClient.post() .uri("/users/-/verify-email") .bodyValue(Map.of("code", "fake-code-2", "password", "123456")) .exchange() .expectStatus() .isEqualTo(HttpStatus.TOO_MANY_REQUESTS); } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/console/PluginEndpointTest.java ================================================ package run.halo.app.core.endpoint.console; import static java.util.Objects.requireNonNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction; import static org.springframework.web.reactive.function.BodyInserters.fromMultipartData; import com.github.zafarkhaja.semver.Version; import java.io.IOException; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Instant; import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.boot.autoconfigure.web.WebProperties; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.CacheControl; import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.Setting; import run.halo.app.core.user.service.SettingConfigService; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.infra.utils.FileUtils; import run.halo.app.plugin.PluginService; @Slf4j @ExtendWith(MockitoExtension.class) class PluginEndpointTest { @Mock private ReactiveExtensionClient client; @Mock SystemVersionSupplier systemVersionSupplier; @Mock PluginService pluginService; @Mock SettingConfigService settingConfigService; @Spy WebProperties webProperties = new WebProperties(); @InjectMocks PluginEndpoint endpoint; @Nested class PluginListTest { @Test void shouldListEmptyPluginsWhenNoPlugins() { when(client.listBy(same(Plugin.class), any(ListOptions.class), any(PageRequest.class))) .thenReturn(Mono.just(ListResult.emptyResult())); bindToRouterFunction(endpoint.endpoint()) .build() .get().uri("/plugins") .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.items.length()").isEqualTo(0) .jsonPath("$.total").isEqualTo(0); } @Test void shouldListPluginsWhenPluginPresent() { var plugins = List.of( createPlugin("fake-plugin-1"), createPlugin("fake-plugin-2"), createPlugin("fake-plugin-3") ); var expectResult = new ListResult<>(plugins); when(client.listBy(same(Plugin.class), any(ListOptions.class), any(PageRequest.class))) .thenReturn(Mono.just(expectResult)); bindToRouterFunction(endpoint.endpoint()) .build() .get().uri("/plugins") .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.items.length()").isEqualTo(3) .jsonPath("$.total").isEqualTo(3); } @Test void shouldFilterPluginsWhenKeywordProvided() { var expectPlugin = createPlugin("fake-plugin-2", "expected display name", "", false); var unexpectedPlugin1 = createPlugin("fake-plugin-1", "first fake display name", "", false); var unexpectedPlugin2 = createPlugin("fake-plugin-3", "second fake display name", "", false); var plugins = List.of( expectPlugin ); var expectResult = new ListResult<>(plugins); when(client.listBy(same(Plugin.class), any(ListOptions.class), any(PageRequest.class))) .thenReturn(Mono.just(expectResult)); bindToRouterFunction(endpoint.endpoint()) .build() .get().uri("/plugins?keyword=Expected") .exchange() .expectStatus().isOk(); } @Test void shouldFilterPluginsWhenEnabledProvided() { var expectPlugin = createPlugin("fake-plugin-2", "expected display name", "", true); var plugins = List.of( expectPlugin ); var expectResult = new ListResult<>(plugins); when(client.listBy(same(Plugin.class), any(ListOptions.class), any(PageRequest.class))) .thenReturn(Mono.just(expectResult)); bindToRouterFunction(endpoint.endpoint()) .build() .get().uri("/plugins?enabled=true") .exchange() .expectStatus().isOk(); } @Test void shouldSortPluginsWhenCreationTimestampSet() { var expectPlugin = createPlugin("fake-plugin-2", "expected display name", "", true); var expectResult = new ListResult<>(List.of(expectPlugin)); when(client.listBy(same(Plugin.class), any(ListOptions.class), any(PageRequest.class))) .thenReturn(Mono.just(expectResult)); bindToRouterFunction(endpoint.endpoint()) .build() .get().uri("/plugins?sort=creationTimestamp,desc") .exchange() .expectStatus().isOk(); } } @Nested class PluginUpgradeTest { WebTestClient webClient; Path tempDirectory; Path plugin002; @BeforeEach void setUp() throws URISyntaxException, IOException { webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) .build(); lenient().when(systemVersionSupplier.get()).thenReturn(Version.parse("0.0.0")); tempDirectory = Files.createTempDirectory("halo-test-plugin-upgrade-"); plugin002 = tempDirectory.resolve("plugin-0.0.2.jar"); var plugin002Uri = requireNonNull( getClass().getClassLoader().getResource("plugin/plugin-0.0.2")).toURI(); FileUtils.jar(Paths.get(plugin002Uri), tempDirectory.resolve("plugin-0.0.2.jar")); } @AfterEach void cleanUp() { FileUtils.deleteRecursivelyAndSilently(tempDirectory); } @Test void shouldResponseBadRequestIfNoPluginInstalledBefore() { var bodyBuilder = new MultipartBodyBuilder(); bodyBuilder.part("file", new FileSystemResource(plugin002)) .contentType(MediaType.MULTIPART_FORM_DATA); when(pluginService.upgrade(eq("fake-plugin"), isA(Path.class))) .thenReturn(Mono.error(new ServerWebInputException("plugin not found"))); webClient.post().uri("/plugins/fake-plugin/upgrade") .contentType(MediaType.MULTIPART_FORM_DATA) .body(fromMultipartData(bodyBuilder.build())) .exchange() .expectStatus().isBadRequest(); verify(pluginService).upgrade(eq("fake-plugin"), isA(Path.class)); } } @Nested class UpdatePluginConfigTest { WebTestClient webClient; @BeforeEach void setUp() { webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()).build(); } @Test void updateWhenConfigMapNameIsNull() { Plugin plugin = createPlugin("fake-plugin"); plugin.getSpec().setConfigMapName(null); when(client.fetch(eq(Plugin.class), eq("fake-plugin"))).thenReturn(Mono.just(plugin)); webClient.put() .uri("/plugins/fake-plugin/json-config") .exchange() .expectStatus().isBadRequest(); } @Test void updateJsonConfigTest() { Plugin plugin = createPlugin("fake-plugin"); plugin.getSpec().setConfigMapName("fake-config-map"); when(client.fetch(eq(Plugin.class), eq("fake-plugin"))).thenReturn(Mono.just(plugin)); when(settingConfigService.upsertConfig(eq("fake-config-map"), any())) .thenReturn(Mono.empty()); webClient.put() .uri("/plugins/fake-plugin/json-config") .body(Mono.just(Map.of()), Map.class) .exchange() .expectStatus().is2xxSuccessful(); } } @Nested class PluginConfigAndSettingFetchTest { WebTestClient webClient; @BeforeEach void setUp() { webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) .build(); } @Test void fetchSetting() { Plugin plugin = createPlugin("fake"); plugin.getSpec().setSettingName("fake-setting"); when(client.fetch(eq(Setting.class), eq("fake-setting"))) .thenReturn(Mono.just(new Setting())); when(client.fetch(eq(Plugin.class), eq("fake"))).thenReturn(Mono.just(plugin)); webClient.get() .uri("/plugins/fake/setting") .exchange() .expectStatus().isOk(); verify(client).fetch(eq(Setting.class), eq("fake-setting")); verify(client).fetch(eq(Plugin.class), eq("fake")); } @Test void fetchJsonConfig() { Plugin plugin = createPlugin("fake"); plugin.getSpec().setConfigMapName("fake-config"); when(settingConfigService.fetchConfig(eq("fake-config"))) .thenReturn(Mono.empty()); when(client.fetch(eq(Plugin.class), eq("fake"))).thenReturn(Mono.just(plugin)); webClient.get() .uri("/plugins/fake/json-config") .exchange() .expectStatus().isOk(); verify(settingConfigService).fetchConfig(eq("fake-config")); verify(client).fetch(eq(Plugin.class), eq("fake")); } } Plugin createPlugin(String name) { return createPlugin(name, "fake display name", "fake description", null); } Plugin createPlugin(String name, String displayName, String description, Boolean enabled) { var metadata = new Metadata(); metadata.setName(name); metadata.setCreationTimestamp(Instant.now()); var spec = new Plugin.PluginSpec(); spec.setDisplayName(displayName); spec.setDescription(description); spec.setEnabled(enabled); var plugin = new Plugin(); plugin.setMetadata(metadata); plugin.setSpec(spec); return plugin; } Plugin createPlugin(String name, Instant creationTimestamp) { var metadata = new Metadata(); metadata.setName(name); metadata.setCreationTimestamp(creationTimestamp); var spec = new Plugin.PluginSpec(); var plugin = new Plugin(); plugin.setMetadata(metadata); plugin.setSpec(spec); return plugin; } @Nested class BundleResourceEndpointTest { private long lastModified; WebTestClient webClient; @BeforeEach void setUp() { webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()).build(); long currentTimeMillis = System.currentTimeMillis(); // We should ignore milliseconds here // See https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.1 for more. this.lastModified = currentTimeMillis - currentTimeMillis % 1_000; } @Test void shouldBeRedirectedWhileFetchingBundleJsWithoutVersion() { when(pluginService.generateBundleVersion()).thenReturn(Mono.just("fake-version")); webClient.get().uri("/plugins/-/bundle.js") .exchange() .expectStatus().is3xxRedirection() .expectHeader().cacheControl(CacheControl.noStore()) .expectHeader().location( "/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js?v=fake-version"); } @Test void shouldBeRedirectedWhileFetchingBundleCssWithoutVersion() { when(pluginService.generateBundleVersion()).thenReturn(Mono.just("fake-version")); webClient.get().uri("/plugins/-/bundle.css") .exchange() .expectStatus().is3xxRedirection() .expectHeader().cacheControl(CacheControl.noStore()) .expectHeader().location( "/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.css?v=fake-version"); } @Test void shouldFetchBundleCssWithCacheControl() { var cache = webProperties.getResources().getCache(); cache.setUseLastModified(true); var cachecontrol = cache.getCachecontrol(); cachecontrol.setNoCache(true); endpoint.afterPropertiesSet(); when(pluginService.getCssBundle("fake-version")) .thenReturn(Mono.fromSupplier(() -> mockResource("fake-css"))); webClient.get().uri("/plugins/-/bundle.css?v=fake-version") .exchange() .expectStatus().isOk() .expectHeader().cacheControl(CacheControl.noCache()) .expectHeader().contentType("text/css") .expectHeader().lastModified(lastModified) .expectBody(String.class).isEqualTo("fake-css"); } @Test void shouldFetchBundleJsWithCacheControl() { var cache = webProperties.getResources().getCache(); cache.setUseLastModified(true); var cachecontrol = cache.getCachecontrol(); cachecontrol.setNoStore(true); endpoint.afterPropertiesSet(); when(pluginService.getJsBundle("fake-version")) .thenReturn(Mono.fromSupplier(() -> mockResource("fake-js"))); webClient.get().uri("/plugins/-/bundle.js?v=fake-version") .exchange() .expectStatus().isOk() .expectHeader().cacheControl(CacheControl.noStore()) .expectHeader().contentType("text/javascript") .expectHeader().lastModified(lastModified) .expectBody(String.class).isEqualTo("fake-js"); } @Test void shouldFetchBundleCss() { when(pluginService.getCssBundle("fake-version")) .thenReturn(Mono.fromSupplier(() -> mockResource("fake-css"))); webClient.get().uri("/plugins/-/bundle.css?v=fake-version") .exchange() .expectStatus().isOk() .expectHeader().cacheControl(CacheControl.empty()) .expectHeader().contentType("text/css") .expectHeader().lastModified(-1) .expectBody(String.class).isEqualTo("fake-css"); } @Test void shouldFetchBundleJs() { when(pluginService.getJsBundle("fake-version")) .thenReturn(Mono.fromSupplier(() -> mockResource("fake-js"))); webClient.get().uri("/plugins/-/bundle.js?v=fake-version") .exchange() .expectStatus().isOk() .expectHeader().cacheControl(CacheControl.empty()) .expectHeader().contentType("text/javascript") .expectHeader().lastModified(-1) .expectBody(String.class).isEqualTo("fake-js"); } Resource mockResource(String content) { var resource = new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)); resource = spy(resource); try { doReturn(lastModified).when(resource).lastModified(); } catch (IOException e) { // should never happen throw new RuntimeException(e); } return resource; } } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/console/PostEndpointTest.java ================================================ package run.halo.app.core.endpoint.console; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; import run.halo.app.content.ContentUpdateParam; import run.halo.app.content.PostRequest; import run.halo.app.content.PostService; import run.halo.app.content.TestPost; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Post.PostSpec; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; /** * Tests for @{@link PostEndpoint}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class PostEndpointTest { @Mock PostService postService; @Mock ReactiveExtensionClient client; @Mock ApplicationEventPublisher eventPublisher; @InjectMocks PostEndpoint postEndpoint; WebTestClient webTestClient; @BeforeEach void setUp() { postEndpoint.setMaxAttemptsWaitForPublish(3); webTestClient = WebTestClient .bindToRouterFunction(postEndpoint.endpoint()) .build(); } @Test void draftPost() { when(postService.draftPost(any())).thenReturn(Mono.just(TestPost.postV1())); webTestClient.post() .uri("/posts") .bodyValue(postRequest(TestPost.postV1())) .exchange() .expectStatus() .isOk() .expectBody(Post.class) .value(post -> assertThat(post).isEqualTo(TestPost.postV1())); } @Test void updatePost() { when(postService.updatePost(any())).thenReturn(Mono.just(TestPost.postV1())); webTestClient.put() .uri("/posts/post-A") .bodyValue(postRequest(TestPost.postV1())) .exchange() .expectStatus() .isOk() .expectBody(Post.class) .value(post -> assertThat(post).isEqualTo(TestPost.postV1())); } @Test void publishRetryOnOptimisticLockingFailure() { var post = new Post(); post.setMetadata(new Metadata()); post.getMetadata().setName("post-1"); post.setSpec(new PostSpec()); when(client.get(eq(Post.class), eq("post-1"))).thenReturn(Mono.just(post)); when(client.update(any(Post.class))) .thenReturn(Mono.error(new OptimisticLockingFailureException("fake-error"))); // Send request webTestClient.put() .uri("/posts/{name}/publish?async=false", "post-1") .exchange() .expectStatus() .is5xxServerError(); // Verify WebClient retry behavior verify(client, times(6)).get(eq(Post.class), eq("post-1")); verify(client, times(6)).update(any(Post.class)); } @Test void publishSuccess() { var post = new Post(); post.setMetadata(new Metadata()); post.getMetadata().setName("post-1"); post.setSpec(new PostSpec()); var publishedPost = new Post(); var publishedMetadata = new Metadata(); publishedMetadata.setAnnotations(Map.of(Post.LAST_RELEASED_SNAPSHOT_ANNO, "my-release")); publishedPost.setMetadata(publishedMetadata); var publishedPostSpec = new PostSpec(); publishedPostSpec.setReleaseSnapshot("my-release"); publishedPost.setSpec(publishedPostSpec); when(client.get(eq(Post.class), eq("post-1"))) .thenReturn(Mono.just(post)) .thenReturn(Mono.just(publishedPost)); when(client.update(any(Post.class))) .thenReturn(Mono.just(post)); // Send request webTestClient.put() .uri("/posts/{name}/publish?async=false", "post-1") .exchange() .expectStatus() .is2xxSuccessful(); // Verify WebClient retry behavior verify(client, times(2)).get(eq(Post.class), eq("post-1")); verify(client).update(any(Post.class)); } @Test void shouldFailIfWaitTimeoutForPublishedStatus() { var post = new Post(); post.setMetadata(new Metadata()); post.getMetadata().setName("post-1"); post.setSpec(new PostSpec()); var publishedPost = new Post(); var publishedMetadata = new Metadata(); publishedMetadata.setAnnotations( Map.of(Post.LAST_RELEASED_SNAPSHOT_ANNO, "old-my-release")); publishedPost.setMetadata(publishedMetadata); var publishedPostSpec = new PostSpec(); publishedPostSpec.setReleaseSnapshot("my-release"); publishedPost.setSpec(publishedPostSpec); when(client.get(eq(Post.class), eq("post-1"))) .thenReturn(Mono.just(post)) .thenReturn(Mono.just(publishedPost)); when(client.update(any(Post.class))) .thenReturn(Mono.just(post)); // Send request webTestClient.put() .uri("/posts/{name}/publish?async=false", "post-1") .exchange() .expectStatus() .is5xxServerError(); // Verify WebClient retry behavior verify(client, times(5)).get(eq(Post.class), eq("post-1")); verify(client).update(any(Post.class)); } PostRequest postRequest(Post post) { return new PostRequest(post, new ContentUpdateParam(null, "B", "

B

", "MARKDOWN")); } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/console/SinglePageEndpointTest.java ================================================ package run.halo.app.core.endpoint.console; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; /** * Tests for @{@link SinglePageEndpoint}. * * @author guqing * @since 2.3.0 */ @ExtendWith(MockitoExtension.class) class SinglePageEndpointTest { @Mock private ReactiveExtensionClient client; @InjectMocks SinglePageEndpoint singlePageEndpoint; WebTestClient webTestClient; @BeforeEach void setUp() { webTestClient = WebTestClient .bindToRouterFunction(singlePageEndpoint.endpoint()) .build(); } @Test void publishRetryOnOptimisticLockingFailure() { var page = new SinglePage(); page.setMetadata(new Metadata()); page.getMetadata().setName("page-1"); page.setSpec(new SinglePage.SinglePageSpec()); when(client.get(eq(SinglePage.class), eq("page-1"))).thenReturn(Mono.just(page)); when(client.update(any(SinglePage.class))) .thenReturn(Mono.error(new OptimisticLockingFailureException("fake-error"))); // Send request webTestClient.put() .uri("/singlepages/{name}/publish?async=false", "page-1") .exchange() .expectStatus() .is5xxServerError(); // Verify WebClient retry behavior verify(client, times(6)).get(eq(SinglePage.class), eq("page-1")); verify(client, times(6)).update(any(SinglePage.class)); } @Test void publishSuccess() { var page = new SinglePage(); page.setMetadata(new Metadata()); page.getMetadata().setName("page-1"); page.setSpec(new SinglePage.SinglePageSpec()); when(client.get(eq(SinglePage.class), eq("page-1"))).thenReturn(Mono.just(page)); when(client.fetch(eq(SinglePage.class), eq("page-1"))).thenReturn(Mono.empty()); when(client.update(any(SinglePage.class))).thenReturn(Mono.just(page)); // Send request webTestClient.put() .uri("/singlepages/{name}/publish?async=false", "page-1") .exchange() .expectStatus() .is2xxSuccessful(); // Verify WebClient retry behavior verify(client, times(1)).get(eq(SinglePage.class), eq("page-1")); verify(client, times(1)).update(any(SinglePage.class)); } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/console/TagEndpointTest.java ================================================ package run.halo.app.core.endpoint.console; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction; import java.time.Instant; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; /** * Tag endpoint test. * * @author LIlGG */ @ExtendWith(MockitoExtension.class) class TagEndpointTest { @Mock ReactiveExtensionClient client; @InjectMocks TagEndpoint tagEndpoint; WebTestClient webClient; @BeforeEach void setUp() { webClient = WebTestClient.bindToRouterFunction(tagEndpoint.endpoint()) .apply(springSecurity()) .build(); } @Nested class TagListTest { @Test void shouldListEmptyTagsWhenNoTags() { when(client.listBy(same(Tag.class), any(), any(PageRequest.class))) .thenReturn(Mono.just(ListResult.emptyResult())); bindToRouterFunction(tagEndpoint.endpoint()) .build() .get().uri("/tags") .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.items.length()").isEqualTo(0) .jsonPath("$.total").isEqualTo(0); } @Test void shouldListTagsWhenTagPresent() { var tags = List.of( createTag("fake-tag-1"), createTag("fake-tag-2") ); var expectResult = new ListResult<>(tags); when(client.listBy(same(Tag.class), any(), any(PageRequest.class))) .thenReturn(Mono.just(expectResult)); bindToRouterFunction(tagEndpoint.endpoint()) .build() .get().uri("/tags") .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.items.length()").isEqualTo(2) .jsonPath("$.total").isEqualTo(2); } Tag createTag(String name) { return createTag(name, "fake display name"); } Tag createTag(String name, String displayName) { var metadata = new Metadata(); metadata.setName(name); metadata.setCreationTimestamp(Instant.now()); var spec = new Tag.TagSpec(); spec.setDisplayName(displayName); spec.setSlug(name); var tag = new Tag(); tag.setMetadata(metadata); tag.setSpec(spec); return tag; } } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/console/UserEndpointIntegrationTest.java ================================================ package run.halo.app.core.endpoint.console; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.User; import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; @SpringBootTest @AutoConfigureWebTestClient @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) @WithMockUser(username = "fake-user", password = "fake-password", roles = "fake-super-role") public class UserEndpointIntegrationTest { @Autowired WebTestClient webClient; @Autowired ReactiveExtensionClient client; @MockitoBean RoleService roleService; @BeforeEach void setUp() { var rule = new Role.PolicyRule.Builder() .apiGroups("*") .resources("*") .verbs("*") .build(); var role = new Role(); role.setMetadata(new Metadata()); role.getMetadata().setName("fake-super-role"); role.setRules(List.of(rule)); when(roleService.listDependenciesFlux(anySet())).thenReturn(Flux.just(role)); webClient = webClient.mutateWith(csrf()); } @Nested class UserListTest { @Test void shouldFilterUsersWhenDisplayNameKeywordProvided() { var expectUser = createUser("fake-user-2", "expected display name"); var unexpectedUser1 = createUser("fake-user-1", "first fake display name"); var unexpectedUser2 = createUser("fake-user-3", "second fake display name"); client.create(expectUser).block(); client.create(unexpectedUser1).block(); client.create(unexpectedUser2).block(); when(roleService.list(anySet())).thenReturn(Flux.empty()); when(roleService.getRolesByUsernames( List.of("fake-user-2") )).thenReturn(Mono.just(Map.of("fake-user-2", Set.of("fake-super-role")))); webClient.get().uri("/apis/api.console.halo.run/v1alpha1/users?keyword=Expected") .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.items.length()").isEqualTo(1) .jsonPath("$.items[0].user.metadata.name").isEqualTo("fake-user-2"); } @Test void shouldFilterUsersWhenUserNameKeywordProvided() { var expectUser = createUser("fake-user", "expected display name"); var unexpectedUser1 = createUser("fake-user-1", "first fake display name"); var unexpectedUser2 = createUser("fake-user-3", "second fake display name"); client.create(expectUser).block(); client.create(unexpectedUser1).block(); client.create(unexpectedUser2).block(); when(roleService.list(anySet())).thenReturn(Flux.empty()); when(roleService.getRolesByUsernames(List.of("fake-user"))) .thenReturn(Mono.just(Map.of("fake-user", Set.of("fake-super-role")))); webClient.get().uri("/apis/api.console.halo.run/v1alpha1/users?keyword=fake-user") .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.items.length()").isEqualTo(1) .jsonPath("$.items[0].user.metadata.name").isEqualTo("fake-user"); } } User createUser(String name, String displayName) { var metadata = new Metadata(); metadata.setName(name); metadata.setCreationTimestamp(Instant.now()); var spec = new User.UserSpec(); spec.setEmail("fake-email"); spec.setDisplayName(displayName); var user = new User(); user.setMetadata(metadata); user.setSpec(spec); return user; } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/console/UserEndpointTest.java ================================================ package run.halo.app.core.endpoint.console; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockUser; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.BodyInserters; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.User; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.exception.UserNotFoundException; import run.halo.app.infra.utils.JsonUtils; @ExtendWith(MockitoExtension.class) class UserEndpointTest { WebTestClient webClient; @Mock RoleService roleService; @Mock AttachmentService attachmentService; @Mock SystemConfigFetcher environmentFetcher; @Mock ReactiveExtensionClient client; @Mock UserService userService; @InjectMocks UserEndpoint endpoint; @BeforeEach void setUp() { webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) .apply(springSecurity()) .build() .mutateWith(mockUser("fake-user").password("fake-password").roles("fake-super-role")); } @Nested class UserListTest { @Test void shouldListEmptyUsersWhenNoUsers() { when(roleService.getRolesByUsernames(any())).thenReturn(Mono.just(Map.of())); when(roleService.list(any())).thenReturn(Flux.empty()); when(client.listBy(same(User.class), any(), any(PageRequest.class))) .thenReturn(Mono.just(ListResult.emptyResult())); webClient.get().uri("/users") .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.items.length()").isEqualTo(0) .jsonPath("$.total").isEqualTo(0); } @Test void shouldListUsersWhenUserPresent() { var users = List.of( createUser("fake-user-1"), createUser("fake-user-2"), createUser("fake-user-3") ); var expectResult = new ListResult<>(users); when(roleService.getRolesByUsernames(any())).thenReturn(Mono.just(Map.of())); when(roleService.list(anySet())).thenReturn(Flux.empty()); when(client.listBy(same(User.class), any(), any(PageRequest.class))) .thenReturn(Mono.just(expectResult)); webClient.get().uri("/users") .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.items.length()").isEqualTo(3) .jsonPath("$.total").isEqualTo(3); } @Test void shouldFilterUsersWhenRoleProvided() { var expectUser = JsonUtils.jsonToObject(""" { "apiVersion": "v1alpha1", "kind": "User", "metadata": { "name": "alice", "annotations": { "rbac.authorization.halo.run/role-names": "[\\"guest\\"]" } } } """, User.class); var users = List.of( expectUser ); var expectResult = new ListResult<>(users); when(client.listBy(same(User.class), any(), any(PageRequest.class))) .thenReturn(Mono.just(expectResult)); when(roleService.getRolesByUsernames(any())).thenReturn(Mono.just(Map.of())); when(roleService.list(anySet())).thenReturn(Flux.empty()); webClient.get().uri("/users?role=guest") .exchange() .expectStatus().isOk(); } @Test void shouldSortUsersWhenCreationTimestampSet() { var expectUser = createUser("fake-user-2", "expected display name"); var expectResult = new ListResult<>(List.of(expectUser)); when(client.listBy(same(User.class), any(), any(PageRequest.class))) .thenReturn(Mono.just(expectResult)); when(roleService.getRolesByUsernames(any())).thenReturn(Mono.just(Map.of())); when(roleService.list(anySet())).thenReturn(Flux.empty()); webClient.get().uri("/users?sort=creationTimestamp,desc") .exchange() .expectStatus().isOk(); } User createUser(String name) { return createUser(name, "fake display name"); } User createUser(String name, String displayName) { var metadata = new Metadata(); metadata.setName(name); metadata.setCreationTimestamp(Instant.now()); var spec = new User.UserSpec(); spec.setDisplayName(displayName); var user = new User(); user.setMetadata(metadata); user.setSpec(spec); return user; } } @Nested @DisplayName("GetUserDetail") class GetUserDetailTest { @Test void shouldResponseErrorIfUserNotFound() { when(userService.getUser("fake-user")) .thenReturn(Mono.error(new UserNotFoundException("fake-user"))); webClient.get().uri("/users/-") .exchange() .expectStatus().isNotFound(); verify(userService).getUser(eq("fake-user")); } @Test void shouldGetCurrentUserDetail() { var metadata = new Metadata(); metadata.setName("fake-user"); var user = new User(); user.setMetadata(metadata); when(userService.getUser("fake-user")).thenReturn(Mono.just(user)); Role role = new Role(); role.setMetadata(new Metadata()); role.getMetadata().setName("fake-super-role"); role.setRules(List.of()); when(roleService.list(Set.of("fake-super-role"), true)).thenReturn(Flux.just(role)); webClient.get().uri("/users/-") .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody(UserEndpoint.DetailedUser.class) .isEqualTo(new UserEndpoint.DetailedUser(user, List.of(role))); } } @Nested @DisplayName("UpdateProfile") class UpdateProfileTest { @Test void shouldUpdateProfileCorrectly() { var currentUser = createUser("fake-user"); var updatedUser = createUser("fake-user"); var requestUser = createUser("fake-user"); when(client.get(User.class, "fake-user")).thenReturn(Mono.just(currentUser)); when(client.update(currentUser)).thenReturn(Mono.just(updatedUser)); webClient.put().uri("/users/-") .bodyValue(requestUser) .exchange() .expectStatus().isOk() .expectBody(User.class) .isEqualTo(updatedUser); verify(client).get(User.class, "fake-user"); verify(client).update(currentUser); } @Test void shouldGetErrorIfUsernameMismatch() { var currentUser = createUser("fake-user"); var requestUser = createUser("another-fake-user"); when(client.get(User.class, "fake-user")).thenReturn(Mono.just(currentUser)); webClient.put().uri("/users/-") .bodyValue(requestUser) .exchange() .expectStatus().isBadRequest(); verify(client).get(User.class, "fake-user"); verify(client, never()).update(currentUser); } User createUser(String name) { var spec = new User.UserSpec(); spec.setEmail("hi@halo.run"); spec.setBio("Fake bio"); spec.setDisplayName("Faker"); spec.setPassword("fake-password"); var metadata = new Metadata(); metadata.setName(name); var user = new User(); user.setSpec(spec); user.setMetadata(metadata); return user; } } @Nested @DisplayName("ChangePassword") class ChangePasswordTest { @Test void shouldUpdateMyPasswordCorrectly() { var user = new User(); when(userService.updateWithRawPassword("fake-user", "new-password")) .thenReturn(Mono.just(user)); when(userService.confirmPassword("fake-user", "old-password")) .thenReturn(Mono.just(true)); webClient.put().uri("/users/-/password") .bodyValue( new UserEndpoint.ChangeOwnPasswordRequest("old-password", "new-password")) .exchange() .expectStatus().isOk() .expectBody(User.class) .isEqualTo(user); verify(userService, times(1)).updateWithRawPassword("fake-user", "new-password"); } @Test void shouldUpdateOtherPasswordCorrectly() { var user = new User(); when(userService.updateWithRawPassword("another-fake-user", "new-password")) .thenReturn(Mono.just(user)); webClient.put() .uri("/users/another-fake-user/password") .bodyValue( new UserEndpoint.ChangeOwnPasswordRequest("old-password", "new-password")) .exchange() .expectStatus().isOk() .expectBody(User.class) .isEqualTo(user); verify(userService, times(1)).updateWithRawPassword("another-fake-user", "new-password"); } } @Nested @DisplayName("GrantPermission") class GrantPermissionEndpointTest { @Test void shouldGetBadRequestIfRequestBodyIsEmpty() { webClient.post().uri("/users/fake-user/permissions") .contentType(MediaType.APPLICATION_JSON) .exchange() .expectStatus().isBadRequest(); // Why one more time to verify? Because the SuperAdminInitializer will fetch admin user. verify(client, never()).fetch(same(User.class), eq("fake-user")); verify(client, never()).fetch(same(Role.class), eq("fake-role")); } @Test void shouldGrantPermission() { when(userService.grantRoles("fake-user", Set.of("fake-role"))).thenReturn(Mono.empty()); webClient.post().uri("/users/fake-user/permissions") .contentType(MediaType.APPLICATION_JSON) .bodyValue(new UserEndpoint.GrantRequest(Set.of("fake-role"))) .exchange() .expectStatus().isOk(); } @Test void shouldGetPermission() { Role roleA = JsonUtils.jsonToObject(""" { "apiVersion": "v1alpha1", "kind": "Role", "metadata": { "name": "test-A", "annotations": { "rbac.authorization.halo.run/ui-permissions": \ "[\\"permission-A\\", \\"permission-A\\"]" } }, "rules": [] } """, Role.class); when(roleService.listPermissions(eq(Set.of("test-A")))).thenReturn(Flux.just(roleA)); when(roleService.getRolesByUsername("fake-user")).thenReturn(Flux.just("test-A")); when(roleService.list(Set.of("test-A"), true)).thenReturn(Flux.just(roleA)); webClient.get().uri("/users/fake-user/permissions") .exchange() .expectStatus() .isOk() .expectBody(UserEndpoint.UserPermission.class) .value(userPermission -> { assertEquals(List.of(roleA), userPermission.getRoles()); assertEquals(List.of(roleA), userPermission.getPermissions()); assertEquals(List.of("permission-A"), userPermission.getUiPermissions()); }); } } @Test void createWhenNameDuplicate() { when(userService.createUser(any(User.class), anySet())) .thenReturn(Mono.just(new User())); var userRequest = new UserEndpoint.CreateUserRequest("fake-user", "fake-email", "", "", "", "", "", Map.of(), Set.of()); webClient.post().uri("/users") .bodyValue(userRequest) .exchange() .expectStatus().isOk(); } @Nested class AvatarUploadTest { @Test void respondWithErrorIfTypeNotPNG() { var multipartBodyBuilder = new MultipartBodyBuilder(); multipartBodyBuilder.part("file", "fake-file") .contentType(MediaType.IMAGE_JPEG) .filename("fake-filename.jpg"); when(environmentFetcher.fetch( SystemSetting.Attachment.GROUP, SystemSetting.Attachment.class )).thenReturn(Mono.fromSupplier(() -> SystemSetting.Attachment.builder() .avatar(null) .build()) ); when(environmentFetcher.fetch( SystemSetting.User.GROUP, SystemSetting.User.class) ).thenReturn(Mono.empty()); webClient .post() .uri("/users/-/avatar") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(multipartBodyBuilder.build())) .exchange() .expectStatus() .is4xxClientError(); } @Test void shouldUploadSuccessfully() { var currentUser = createUser("fake-user"); Attachment attachment = new Attachment(); Metadata metadata = new Metadata(); metadata.setName("fake-attachment"); attachment.setMetadata(metadata); var multipartBodyBuilder = new MultipartBodyBuilder(); multipartBodyBuilder.part("file", "fake-file") .contentType(MediaType.IMAGE_PNG) .filename("fake-filename.png"); when(environmentFetcher.fetch( SystemSetting.Attachment.GROUP, SystemSetting.Attachment.class )).thenReturn(Mono.fromSupplier(() -> SystemSetting.Attachment.builder() .avatar(null) .build()) ); when(environmentFetcher.fetch( SystemSetting.User.GROUP, SystemSetting.User.class) ).thenReturn(Mono.empty()); when(client.get(User.class, "fake-user")).thenReturn(Mono.just(currentUser)); when(attachmentService.upload(eq("default-policy"), anyString(), anyString(), any(), any(MediaType.IMAGE_PNG.getClass()))).thenReturn(Mono.just(attachment)); when(client.update(currentUser)).thenReturn(Mono.just(currentUser)); webClient.post() .uri("/users/-/avatar") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(multipartBodyBuilder.build())) .exchange() .expectStatus() .isOk() .expectBody(User.class).isEqualTo(currentUser); verify(client).get(User.class, "fake-user"); verify(client).update(currentUser); } @Test void shouldUseFallbackSetting() { var currentUser = createUser("fake-user"); Attachment attachment = new Attachment(); Metadata metadata = new Metadata(); metadata.setName("fake-attachment"); attachment.setMetadata(metadata); var multipartBodyBuilder = new MultipartBodyBuilder(); multipartBodyBuilder.part("file", "fake-file") .contentType(MediaType.IMAGE_PNG) .filename("fake-filename.png"); when(environmentFetcher.fetch( SystemSetting.Attachment.GROUP, SystemSetting.Attachment.class )).thenReturn(Mono.fromSupplier(() -> SystemSetting.Attachment.builder() .avatar(null) .build()) ); when(environmentFetcher.fetch( SystemSetting.User.GROUP, SystemSetting.User.class) ).thenReturn(Mono.fromSupplier(() -> { var us = new SystemSetting.User(); us.setAvatarPolicy("fake-avatar-policy"); return us; })); when(client.get(User.class, "fake-user")).thenReturn(Mono.just(currentUser)); when(attachmentService.upload(eq("fake-avatar-policy"), anyString(), anyString(), any(), any(MediaType.IMAGE_PNG.getClass()))).thenReturn(Mono.just(attachment)); when(client.update(currentUser)).thenReturn(Mono.just(currentUser)); webClient.post() .uri("/users/-/avatar") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(multipartBodyBuilder.build())) .exchange() .expectStatus() .isOk() .expectBody(User.class).isEqualTo(currentUser); verify(client).get(User.class, "fake-user"); verify(client).update(currentUser); } User createUser(String name) { var spec = new User.UserSpec(); spec.setEmail("hi@halo.run"); spec.setBio("Fake bio"); spec.setDisplayName("Faker"); spec.setAvatar("fake-avatar.png"); spec.setPassword("fake-password"); var metadata = new Metadata(); metadata.setName(name); metadata.setAnnotations(new HashMap<>()); var user = new User(); user.setSpec(spec); user.setMetadata(metadata); return user; } } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/theme/CategoryQueryEndpointTest.java ================================================ package run.halo.app.core.endpoint.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Category; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.theme.finders.PostPublicQueryService; import run.halo.app.theme.finders.vo.ListedPostVo; /** * Tests for {@link CategoryQueryEndpoint}. * * @author guqing * @since 2.5.0 */ @ExtendWith(MockitoExtension.class) class CategoryQueryEndpointTest { @Mock private ReactiveExtensionClient client; @Mock private PostPublicQueryService postPublicQueryService; private CategoryQueryEndpoint endpoint; private WebTestClient webTestClient; @BeforeEach void setUp() { endpoint = new CategoryQueryEndpoint(client, postPublicQueryService); RouterFunction routerFunction = endpoint.endpoint(); webTestClient = WebTestClient.bindToRouterFunction(routerFunction).build(); } @Test void listCategories() { ListResult listResult = new ListResult<>(List.of()); when(client.listBy(eq(Category.class), any(ListOptions.class), any(PageRequest.class))) .thenReturn(Mono.just(listResult)); webTestClient.get() .uri("/categories?page=1&size=10") .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.total").isEqualTo(listResult.getTotal()) .jsonPath("$.items").isArray(); } @Test void getByName() { Category category = new Category(); category.setMetadata(new Metadata()); category.getMetadata().setName("test"); when(client.get(eq(Category.class), eq("test"))).thenReturn(Mono.just(category)); webTestClient.get() .uri("/categories/test") .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.metadata.name").isEqualTo(category.getMetadata().getName()); } @Test void listPostsByCategoryName() { ListResult listResult = new ListResult<>(List.of()); when(postPublicQueryService.list(any(), any(PageRequest.class))) .thenReturn(Mono.just(listResult)); webTestClient.get() .uri("/categories/test/posts?page=1&size=10") .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.total").isEqualTo(listResult.getTotal()) .jsonPath("$.items").isArray(); } @Test void groupVersion() { GroupVersion groupVersion = endpoint.groupVersion(); assertThat(groupVersion.toString()).isEqualTo("api.content.halo.run/v1alpha1"); } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/theme/CommentFinderEndpointTest.java ================================================ package run.halo.app.core.endpoint.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import io.github.resilience4j.ratelimiter.RateLimiter; import io.github.resilience4j.ratelimiter.RateLimiterConfig; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import java.time.Duration; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; import run.halo.app.content.comment.CommentRequest; import run.halo.app.content.comment.CommentService; import run.halo.app.content.comment.ReplyRequest; import run.halo.app.content.comment.ReplyService; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.Ref; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.theme.finders.CommentFinder; import run.halo.app.theme.finders.CommentPublicQueryService; /** * Tests for {@link CommentFinderEndpoint}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class CommentFinderEndpointTest { @Mock private CommentFinder commentFinder; @Mock private CommentPublicQueryService commentPublicQueryService; @Mock private CommentService commentService; @Mock private SystemConfigFetcher environmentFetcher; @Mock private ReplyService replyService; @Mock private RateLimiterRegistry rateLimiterRegistry; @InjectMocks private CommentFinderEndpoint commentFinderEndpoint; private WebTestClient webTestClient; @BeforeEach void setUp() { lenient().when(environmentFetcher.fetchComment()).thenReturn(Mono.empty()); webTestClient = WebTestClient .bindToRouterFunction(commentFinderEndpoint.endpoint()) .build(); } @Test void listComments() { when(commentPublicQueryService.list(any(), any(PageRequest.class))) .thenReturn(Mono.just(new ListResult<>(1, 10, 0, List.of()))); Ref ref = new Ref(); ref.setGroup("content.halo.run"); ref.setVersion("v1alpha1"); ref.setKind("Post"); ref.setName("test"); webTestClient.get() .uri(uriBuilder -> uriBuilder.path("/comments") .queryParam("group", ref.getGroup()) .queryParam("version", ref.getVersion()) .queryParam("kind", ref.getKind()) .queryParam("name", ref.getName()) .queryParam("page", 1) .queryParam("size", 10) .build()) .exchange() .expectStatus() .isOk(); ArgumentCaptor refCaptor = ArgumentCaptor.forClass(Ref.class); verify(commentPublicQueryService, times(1)) .list(refCaptor.capture(), any(PageRequest.class)); Ref value = refCaptor.getValue(); assertThat(value).isEqualTo(ref); } @Test void getComment() { when(commentPublicQueryService.getByName(any())) .thenReturn(null); webTestClient.get() .uri("/comments/test-comment") .exchange() .expectStatus() .isOk(); verify(commentPublicQueryService, times(1)).getByName(eq("test-comment")); } @Test void listCommentReplies() { when(commentPublicQueryService.listReply(any(), anyInt(), anyInt())) .thenReturn(Mono.just(new ListResult<>(2, 20, 0, List.of()))); webTestClient.get() .uri(uriBuilder -> uriBuilder.path("/comments/test-comment/reply") .queryParam("page", 2) .queryParam("size", 20) .build()) .exchange() .expectStatus() .isOk(); verify(commentPublicQueryService, times(1)).listReply(eq("test-comment"), eq(2), eq(20)); } @Test void createComment() { when(commentService.create(any())).thenReturn(Mono.empty()); RateLimiterConfig config = RateLimiterConfig.custom() .limitForPeriod(10) .limitRefreshPeriod(Duration.ofSeconds(1)) .timeoutDuration(Duration.ofSeconds(10)) .build(); RateLimiter rateLimiter = RateLimiter.of("comment-creation-from-ip-" + "0:0:0:0:0:0:0:0", config); when(rateLimiterRegistry.rateLimiter(anyString(), anyString())).thenReturn(rateLimiter); final CommentRequest commentRequest = new CommentRequest(); Ref ref = new Ref(); ref.setGroup("content.halo.run"); ref.setVersion("v1alpha1"); ref.setKind("Post"); ref.setName("test-post"); commentRequest.setSubjectRef(ref); commentRequest.setContent("content"); commentRequest.setRaw("raw"); commentRequest.setAllowNotification(false); webTestClient.post() .uri("/comments") .bodyValue(commentRequest) .exchange() .expectStatus() .isOk(); ArgumentCaptor captor = ArgumentCaptor.forClass(Comment.class); verify(commentService, times(1)).create(captor.capture()); Comment value = captor.getValue(); assertThat(value.getSpec().getIpAddress()).isNotNull(); assertThat(value.getSpec().getUserAgent()).isNotNull(); assertThat(value.getSpec().getSubjectRef()).isEqualTo(ref); } @Test void createReply() { when(replyService.create(any(), any())).thenReturn(Mono.empty()); final ReplyRequest replyRequest = new ReplyRequest(); replyRequest.setRaw("raw"); replyRequest.setContent("content"); replyRequest.setAllowNotification(true); when(rateLimiterRegistry.rateLimiter("comment-creation-from-ip-127.0.0.1", "comment-creation")) .thenReturn(RateLimiter.ofDefaults("comment-creation")); webTestClient.post() .uri("/comments/test-comment/reply") .header("X-Forwarded-For", "127.0.0.1") .bodyValue(replyRequest) .exchange() .expectStatus() .isOk(); ArgumentCaptor captor = ArgumentCaptor.forClass(Reply.class); verify(replyService, times(1)).create(eq("test-comment"), captor.capture()); Reply value = captor.getValue(); assertThat(value.getSpec().getIpAddress()).isNotNull(); assertThat(value.getSpec().getUserAgent()).isNotNull(); assertThat(value.getSpec().getQuoteReply()).isNull(); verify(rateLimiterRegistry).rateLimiter("comment-creation-from-ip-127.0.0.1", "comment-creation"); } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/theme/MenuQueryEndpointTest.java ================================================ package run.halo.app.core.endpoint.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import lombok.NonNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Menu; import run.halo.app.core.extension.MenuItem; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.Metadata; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.theme.finders.MenuFinder; import run.halo.app.theme.finders.vo.MenuItemVo; import run.halo.app.theme.finders.vo.MenuVo; /** * Tests for {@link MenuQueryEndpoint}. * * @author guqing * @since 2.5.0 */ @ExtendWith(MockitoExtension.class) class MenuQueryEndpointTest { @Mock private MenuFinder menuFinder; @Mock private SystemConfigFetcher environmentFetcher; @InjectMocks private MenuQueryEndpoint endpoint; private WebTestClient webClient; @BeforeEach void setUp() { webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()).build(); } @Test void getPrimaryMenu() { Metadata metadata = new Metadata(); metadata.setName("fake-primary"); MenuVo menuVo = MenuVo.builder() .metadata(metadata) .spec(new Menu.Spec()) .menuItems(List.of(MenuItemVo.from(createMenuItem("item1")))) .build(); when(menuFinder.getByName(eq("fake-primary"))) .thenReturn(Mono.just(menuVo)); SystemSetting.Menu menuSetting = new SystemSetting.Menu(); menuSetting.setPrimary("fake-primary"); when(environmentFetcher.fetch(eq(SystemSetting.Menu.GROUP), eq(SystemSetting.Menu.class))) .thenReturn(Mono.just(menuSetting)); webClient.get().uri("/menus/-") .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.metadata.name").isEqualTo("fake-primary") .jsonPath("$.menuItems[0].metadata.name").isEqualTo("item1"); verify(menuFinder).getByName(eq("fake-primary")); verify(environmentFetcher).fetch(eq(SystemSetting.Menu.GROUP), eq(SystemSetting.Menu.class)); } @NonNull private static MenuItem createMenuItem(String name) { MenuItem menuItem = new MenuItem(); menuItem.setMetadata(new Metadata()); menuItem.getMetadata().setName(name); menuItem.setSpec(new MenuItem.MenuItemSpec()); menuItem.getSpec().setDisplayName(name); return menuItem; } @Test void getMenuByName() { Metadata metadata = new Metadata(); metadata.setName("test-menu"); MenuVo menuVo = MenuVo.builder() .metadata(metadata) .spec(new Menu.Spec()) .menuItems(List.of(MenuItemVo.from(createMenuItem("item2")))) .build(); when(menuFinder.getByName(eq("test-menu"))) .thenReturn(Mono.just(menuVo)); webClient.get().uri("/menus/test-menu") .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.metadata.name").isEqualTo("test-menu") .jsonPath("$.menuItems[0].metadata.name").isEqualTo("item2"); verify(menuFinder).getByName(eq("test-menu")); } @Test void groupVersion() { GroupVersion groupVersion = endpoint.groupVersion(); assertThat(groupVersion.toString()).isEqualTo("api.halo.run/v1alpha1"); } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/theme/PluginQueryEndpointTest.java ================================================ package run.halo.app.core.endpoint.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; import run.halo.app.extension.GroupVersion; import run.halo.app.theme.finders.PluginFinder; /** * Tests for {@link PluginQueryEndpoint}. * * @author guqing * @since 2.5.0 */ @ExtendWith(MockitoExtension.class) class PluginQueryEndpointTest { @Mock private PluginFinder pluginFinder; @InjectMocks private PluginQueryEndpoint endpoint; private WebTestClient webClient; @BeforeEach void setUp() { webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()).build(); } @Test void available() { when(pluginFinder.available("fake-plugin")).thenReturn(true); webClient.get().uri("/plugins/fake-plugin/available") .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$").isEqualTo(true); } @Test void groupVersion() { GroupVersion groupVersion = endpoint.groupVersion(); assertThat(groupVersion.toString()).isEqualTo("api.plugin.halo.run/v1alpha1"); } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/theme/PostQueryEndpointTest.java ================================================ package run.halo.app.core.endpoint.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.PostPublicQueryService; import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.finders.vo.NavigationPostVo; import run.halo.app.theme.finders.vo.PostVo; /** * Tests for {@link PostQueryEndpoint}. * * @author guqing * @since 2.5.0 */ @ExtendWith(MockitoExtension.class) class PostQueryEndpointTest { private WebTestClient webClient; @Mock private PostFinder postFinder; @Mock private PostPublicQueryService postPublicQueryService; @InjectMocks private PostQueryEndpoint endpoint; @BeforeEach public void setUp() { webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) .build(); } @Test public void listPosts() { ListResult result = new ListResult<>(List.of()); when(postPublicQueryService.list(any(), any(PageRequest.class))) .thenReturn(Mono.just(result)); webClient.get().uri("/posts") .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.items").isArray(); verify(postPublicQueryService).list(any(), any(PageRequest.class)); } @Test public void getPostByName() { Metadata metadata = new Metadata(); metadata.setName("test"); PostVo post = PostVo.builder() .metadata(metadata) .build(); when(postFinder.getByName(anyString())).thenReturn(Mono.just(post)); webClient.get().uri("/posts/{name}", "test") .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.metadata.name").isEqualTo("test"); verify(postFinder).getByName(anyString()); } @Test public void testGetPostNavigationByName() { Metadata metadata = new Metadata(); metadata.setName("test"); NavigationPostVo navigation = NavigationPostVo.builder() .next(ListedPostVo.builder().metadata(metadata).build()) .build(); when(postFinder.cursor(anyString())) .thenReturn(Mono.just(navigation)); webClient.get().uri("/posts/{name}/navigation", "test") .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.next.metadata.name").isEqualTo("test"); verify(postFinder).cursor(anyString()); } @Test void groupVersion() { GroupVersion groupVersion = endpoint.groupVersion(); assertThat(groupVersion.toString()).isEqualTo("api.content.halo.run/v1alpha1"); } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/theme/PublicApiUtilsTest.java ================================================ package run.halo.app.core.endpoint.theme; import static org.assertj.core.api.Assertions.assertThat; import java.util.List; import org.junit.jupiter.api.Test; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.GroupVersion; /** * Tests for {@link PublicApiUtils}. * * @author guqing * @since 2.5.0 */ class PublicApiUtilsTest { @Test void groupVersion() { GroupVersion groupVersion = PublicApiUtils.groupVersion(new FakExtension()); assertThat(groupVersion.toString()).isEqualTo("api.halo.run/v1alpha1"); groupVersion = PublicApiUtils.groupVersion(new FakeGroupExtension()); assertThat(groupVersion.toString()).isEqualTo("api.fake.halo.run/v1"); } @Test void containsElement() { assertThat(PublicApiUtils.containsElement(null, null)).isFalse(); assertThat(PublicApiUtils.containsElement(null, "test")).isFalse(); assertThat(PublicApiUtils.containsElement(List.of("test"), null)).isFalse(); assertThat(PublicApiUtils.containsElement(List.of("test"), "test")).isTrue(); assertThat(PublicApiUtils.containsElement(List.of("test"), "test1")).isFalse(); } @GVK(group = "fake.halo.run", version = "v1", kind = "FakeGroupExtension", plural = "fakegroupextensions", singular = "fakegroupextension") static class FakeGroupExtension extends AbstractExtension { } @GVK(group = "", version = "v1alpha1", kind = "FakeExtension", plural = "fakeextensions", singular = "fakeextension") static class FakExtension extends AbstractExtension { } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/theme/SinglePageQueryEndpointTest.java ================================================ package run.halo.app.core.endpoint.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.Metadata; import run.halo.app.theme.finders.SinglePageFinder; import run.halo.app.theme.finders.vo.SinglePageVo; /** * Tests for {@link SinglePageQueryEndpoint}. * * @author guqing * @since 2.5.0 */ @ExtendWith(MockitoExtension.class) class SinglePageQueryEndpointTest { @Mock private SinglePageFinder singlePageFinder; @InjectMocks private SinglePageQueryEndpoint endpoint; private WebTestClient webTestClient; @BeforeEach void setUp() { webTestClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()).build(); } @Test void getByName() { SinglePageVo singlePage = SinglePageVo.builder() .metadata(metadata("fake-page")) .spec(new SinglePage.SinglePageSpec()) .build(); when(singlePageFinder.getByName(eq("fake-page"))) .thenReturn(Mono.just(singlePage)); webTestClient.get() .uri("/singlepages/fake-page") .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.metadata.name").isEqualTo("fake-page"); verify(singlePageFinder).getByName("fake-page"); } Metadata metadata(String name) { Metadata metadata = new Metadata(); metadata.setName(name); return metadata; } @Test void groupVersion() { GroupVersion groupVersion = endpoint.groupVersion(); assertThat(groupVersion.toString()).isEqualTo("api.content.halo.run/v1alpha1"); } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/theme/ThumbnailEndpointTest.java ================================================ package run.halo.app.core.endpoint.theme; import static org.mockito.Mockito.when; import java.net.URI; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.core.attachment.thumbnail.ThumbnailService; /** * Tests for {@link ThumbnailEndpoint}. * * @author guqing * @since 2.19.0 */ @ExtendWith(MockitoExtension.class) class ThumbnailEndpointTest { WebTestClient webClient; @Mock private ThumbnailService thumbnailService; @InjectMocks private ThumbnailEndpoint endpoint; @BeforeEach void setUp() { webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) .build(); } @Test void thumbnailUriNotAccessible() { when(thumbnailService.get(URI.create("/myavatar.png"), ThumbnailSize.L)) .thenReturn(Mono.empty()); webClient.get() .uri("/thumbnails/-/via-uri?size=l&uri=/myavatar.png") .exchange() .expectHeader().location("/myavatar.png") .expectStatus().is3xxRedirection(); } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/uc/AnnotationSettingEndpointTest.java ================================================ package run.halo.app.core.endpoint.uc; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Sort; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.AnnotationSetting; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.plugin.PluginService; import run.halo.app.theme.service.ThemeService; @ExtendWith(MockitoExtension.class) class AnnotationSettingEndpointTest { @Mock ReactiveExtensionClient client; @Mock PluginService pluginService; @Mock ThemeService themeService; @InjectMocks AnnotationSettingEndpoint endpoint; WebTestClient webTestClient; @BeforeEach void setUp() { webTestClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) .build(); } @Test void shouldFetchAnnotationSettings() { when(themeService.fetchActivatedThemeName()).thenReturn(Mono.just("fake-theme")); when(pluginService.getStartedPluginNames()).thenReturn(Flux.just("plugin-1", "plugin-2")); var annotationSetting = new AnnotationSetting(); annotationSetting.setMetadata(new Metadata()); annotationSetting.getMetadata().setName("fake-annotation"); when( client.listAll(same(AnnotationSetting.class), any(ListOptions.class), any(Sort.class)) ).thenReturn(Flux.just(annotationSetting)); webTestClient.get() .uri("/annotationsettings?targetRef={targetRef}", "content.halo.run/Post") .exchange() .expectStatus().isOk() .expectBodyList(AnnotationSetting.class) .isEqualTo(List.of(annotationSetting)); verify(client).listAll( same(AnnotationSetting.class), assertArg(listOptions -> { var condition = listOptions.toCondition(); assertEquals(""" (\ (metadata.labels['theme.halo.run/theme-name'] = 'fake-theme' \ OR metadata.labels['plugin.halo.run/plugin-name'] IN ('plugin-1', 'plugin-2')\ ) \ AND spec.targetRef = content.halo.run/Post\ )""", condition.toString()); }), any(Sort.class)); } @Test void shouldFetchAnnotationSettingsWithoutActivatedTheme() { when(themeService.fetchActivatedThemeName()).thenReturn(Mono.empty()); when(pluginService.getStartedPluginNames()).thenReturn(Flux.just("plugin-1", "plugin-2")); var annotationSetting = new AnnotationSetting(); annotationSetting.setMetadata(new Metadata()); annotationSetting.getMetadata().setName("fake-annotation"); when( client.listAll(same(AnnotationSetting.class), any(ListOptions.class), any(Sort.class)) ).thenReturn(Flux.just(annotationSetting)); webTestClient.get() .uri("/annotationsettings?targetRef={targetRef}", "content.halo.run/Post") .exchange() .expectStatus().isOk() .expectBodyList(AnnotationSetting.class) .isEqualTo(List.of(annotationSetting)); verify(client).listAll( same(AnnotationSetting.class), assertArg(listOptions -> { var condition = listOptions.toCondition(); assertEquals(""" (\ metadata.labels['plugin.halo.run/plugin-name'] IN ('plugin-1', 'plugin-2') \ AND spec.targetRef = content.halo.run/Post\ )""", condition.toString()); }), any(Sort.class)); } @Test void shouldFetchAnnotationSettingsWithoutStartedPlugins() { when(themeService.fetchActivatedThemeName()).thenReturn(Mono.just("fake-theme")); when(pluginService.getStartedPluginNames()).thenReturn(Flux.empty()); var annotationSetting = new AnnotationSetting(); annotationSetting.setMetadata(new Metadata()); annotationSetting.getMetadata().setName("fake-annotation"); when( client.listAll(same(AnnotationSetting.class), any(ListOptions.class), any(Sort.class)) ).thenReturn(Flux.just(annotationSetting)); webTestClient.get() .uri("/annotationsettings?targetRef={targetRef}", "content.halo.run/Post") .exchange() .expectStatus().isOk() .expectBodyList(AnnotationSetting.class) .isEqualTo(List.of(annotationSetting)); verify(client).listAll( same(AnnotationSetting.class), assertArg(listOptions -> { var condition = listOptions.toCondition(); assertEquals(""" (\ metadata.labels['theme.halo.run/theme-name'] = 'fake-theme' \ AND spec.targetRef = content.halo.run/Post\ )""", condition.toString()); }), any(Sort.class)); } @Test void shouldFetchAnnotationSettingsWithoutActivatedThemeAndStartedPlugins() { when(themeService.fetchActivatedThemeName()).thenReturn(Mono.empty()); when(pluginService.getStartedPluginNames()).thenReturn(Flux.empty()); var annotationSetting = new AnnotationSetting(); annotationSetting.setMetadata(new Metadata()); annotationSetting.getMetadata().setName("fake-annotation"); when( client.listAll(same(AnnotationSetting.class), any(ListOptions.class), any(Sort.class)) ).thenReturn(Flux.just(annotationSetting)); webTestClient.get() .uri("/annotationsettings?targetRef={targetRef}", "content.halo.run/Post") .exchange() .expectStatus().isOk() .expectBodyList(AnnotationSetting.class) .isEqualTo(List.of(annotationSetting)); verify(client).listAll( same(AnnotationSetting.class), assertArg(listOptions -> { var condition = listOptions.toCondition(); assertEquals(""" (\ EMPTY AND spec.targetRef = content.halo.run/Post\ )""", condition.toString()); }), any(Sort.class)); } } ================================================ FILE: application/src/test/java/run/halo/app/core/endpoint/uc/UcUserPreferenceEndpointTest.java ================================================ package run.halo.app.core.endpoint.uc; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockAuthentication; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockUser; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; import java.util.HashMap; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.http.MediaType; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.node.NullNode; import tools.jackson.databind.node.ObjectNode; @ExtendWith(MockitoExtension.class) class UcUserPreferenceEndpointTest { WebTestClient webClient; @InjectMocks UcUserPreferenceEndpoint endpoint; @Mock ReactiveExtensionClient client; @Spy JsonMapper mapper = JsonMapper.shared(); @BeforeEach void setUp() { webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) .apply(springSecurity()) .build(); } @Test void testGroupVersion() { var gv = endpoint.groupVersion(); assertEquals("uc.api.halo.run", gv.group()); assertEquals("v1alpha1", gv.version()); } @Test void shouldNotGetPreferenceWhenUnauthenticated() { webClient.mutate() .apply(mockAuthentication(new AnonymousAuthenticationToken( "key", "anonymousUser", createAuthorityList("ROLE_ANONYMOUS") ))) .build() .get() .uri("/user-preferences/fake") .exchange() .expectStatus() .isForbidden(); } @Test void shouldGetNullPreferenceWhenAuthenticatedAndConfigMapAbsent() { when(client.fetch(ConfigMap.class, "user-preferences-faker")).thenReturn(Mono.empty()); webClient.mutate() .apply(mockUser("faker")) .build() .get() .uri("/user-preferences/fake") .exchange() .expectStatus() .isOk() .expectBody(JsonNode.class) .value(n -> { assertInstanceOf(NullNode.class, n); }); } @Test void shouldGetPreferenceWhenAuthenticatedAndConfigMapPresent() { var cm = new ConfigMap(); cm.setData(new HashMap<>()); cm.getData().put("fake", """ { "key": "value" }\ """); when(client.fetch(ConfigMap.class, "user-preferences-faker")) .thenReturn(Mono.just(cm)); webClient.mutate() .apply(mockUser("faker")) .build() .get() .uri("/user-preferences/fake") .exchange() .expectStatus() .isOk() .expectBody(ObjectNode.class) .value(node -> assertEquals("value", node.get("key").asString())); } @Test void shouldNotCreatePreferenceWhenUnauthenticated() { webClient.mutate() .apply(mockAuthentication(new AnonymousAuthenticationToken( "key", "anonymousUser", createAuthorityList("ROLE_ANONYMOUS") ))) .build() .put() .uri("/user-preferences/faker") .exchange() .expectStatus() .isForbidden(); } @Test void shouldCreatePreferenceWithoutConfigMap() { when(client.fetch(ConfigMap.class, "user-preferences-faker")).thenReturn(Mono.empty()); when(client.create(any(ConfigMap.class))).thenReturn(Mono.just(new ConfigMap())); webClient.mutate() .apply(mockUser("faker")) .build() .put() .uri("/user-preferences/fake") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" { "key": "value" }\ """) .exchange() .expectStatus() .isNoContent(); verify(client).create(assertArg(cm -> JSONAssert.assertEquals( """ {"key":"value"}\ """, cm.getData().get("fake"), true ))); verify(client, never()).update(any()); } @Test void shouldUpdatePreferenceWhenConfigMapExists() { var cm = new ConfigMap(); cm.setMetadata(new Metadata()); cm.getMetadata().setName("user-preferences-faker"); cm.getMetadata().setVersion(1L); cm.setData(new HashMap<>()); cm.getData().put("fake1", """ { "key1": "value1" }\ """); cm.getData().put("fake2", """ { "key2": "value2" } """); when(client.fetch(ConfigMap.class, "user-preferences-faker")).thenReturn(Mono.just(cm)); when(client.update(any(ConfigMap.class))).thenReturn(Mono.just(cm)); webClient.mutate() .apply(mockUser("faker")) .build() .put() .uri("/user-preferences/fake1") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" { "newKey": "newValue" }\ """) .exchange() .expectStatus() .isNoContent(); verify(client).update(assertArg(cmToUpdate -> { JSONAssert.assertEquals( """ {"newKey":"newValue"}\ """, cmToUpdate.getData().get("fake1"), true ); JSONAssert.assertEquals( """ {"key2":"value2"}\ """, cmToUpdate.getData().get("fake2"), true ); })); verify(client, never()).create(any()); } @Test void shouldNotUpdatePreferenceWhenNotChange() { var cm = new ConfigMap(); cm.setMetadata(new Metadata()); cm.getMetadata().setName("user-preferences-faker"); cm.getMetadata().setVersion(1L); cm.setData(new HashMap<>()); cm.getData().put("fake", """ {"key":"value"}\ """); when(client.fetch(ConfigMap.class, "user-preferences-faker")).thenReturn(Mono.just(cm)); webClient.mutate() .apply(mockUser("faker")) .build() .put() .uri("/user-preferences/fake") .contentType(MediaType.APPLICATION_JSON) .bodyValue(""" {"key":"value"}\ """) .exchange() .expectStatus() .isNoContent(); verify(client, never()).update(any(ConfigMap.class)); verify(client, never()).create(any(ConfigMap.class)); } } ================================================ FILE: application/src/test/java/run/halo/app/core/extension/PostTest.java ================================================ package run.halo.app.core.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.when; import java.util.Map; import java.util.function.Function; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.MetadataOperator; class PostTest { @Test void staticIsPublishedTest() { var test = (Function, Boolean>) (labels) -> { var metadata = Mockito.mock(MetadataOperator.class); when(metadata.getLabels()).thenReturn(labels); return Post.isPublished(metadata); }; assertEquals(false, test.apply(Map.of())); assertEquals(false, test.apply(Map.of("content.halo.run/published", "false"))); assertEquals(false, test.apply(Map.of("content.halo.run/published", "False"))); assertEquals(false, test.apply(Map.of("content.halo.run/published", "0"))); assertEquals(false, test.apply(Map.of("content.halo.run/published", "1"))); assertEquals(false, test.apply(Map.of("content.halo.run/published", "T"))); assertEquals(false, test.apply(Map.of("content.halo.run/published", ""))); assertEquals(true, test.apply(Map.of("content.halo.run/published", "true"))); assertEquals(true, test.apply(Map.of("content.halo.run/published", "True"))); } } ================================================ FILE: application/src/test/java/run/halo/app/core/extension/RoleBindingTest.java ================================================ package run.halo.app.core.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.Instant; import java.util.List; import org.junit.jupiter.api.Test; import run.halo.app.extension.Metadata; class RoleBindingTest { @Test void shouldContainUser() { var subject = new RoleBinding.Subject(); subject.setName("fake-name"); subject.setApiGroup(""); subject.setKind("User"); var binding = new RoleBinding(); binding.setMetadata(new Metadata()); binding.setSubjects(List.of(subject)); assertTrue(RoleBinding.containsUser("fake-name").test(binding)); assertFalse(RoleBinding.containsUser("non-exist-fake-name").test(binding)); } @Test void shouldNotContainUserWhenBindingIsDeleted() { var subject = new RoleBinding.Subject(); subject.setName("fake-name"); subject.setApiGroup(""); subject.setKind("User"); var binding = new RoleBinding(); var metadata = new Metadata(); metadata.setDeletionTimestamp(Instant.now()); binding.setMetadata(metadata); binding.setSubjects(List.of(subject)); assertFalse(RoleBinding.containsUser("fake-name").test(binding)); assertFalse(RoleBinding.containsUser("non-exist-fake-name").test(binding)); } @Test void subjectToStringTest() { assertEquals("User/fake-name", createSubject("fake-name", "", "User").toString()); assertEquals( "fake.group/User/fake-name", createSubject("fake-name", "fake.group", "User").toString() ); } RoleBinding.Subject createSubject(String name, String apiGroup, String kind) { var subject = new RoleBinding.Subject(); subject.setName(name); subject.setApiGroup(apiGroup); subject.setKind(kind); return subject; } } ================================================ FILE: application/src/test/java/run/halo/app/core/extension/SettingTest.java ================================================ package run.halo.app.core.extension; import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import org.json.JSONException; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.security.util.InMemoryResource; import run.halo.app.extension.Unstructured; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.YamlUnstructuredLoader; /** * Tests for {@link Setting}. * * @author guqing * @since 2.0.0 */ class SettingTest { @Test void setting() throws JSONException { String settingYaml = """ apiVersion: v1alpha1 kind: Setting metadata: name: setting-name spec: forms: - group: basic label: 基本设置 formSchema: - $el: h1 children: Register - $formkit: text help: This will be used for your account. label: Email name: email validation: required|email - group: sns label: 社交资料 formSchema: - $formkit: text help: This will be used for your theme. label: color name: color validation: required """; var unstructureds = new YamlUnstructuredLoader( new InMemoryResource(settingYaml.getBytes(UTF_8), "In-memory setting YAML")) .load(); assertThat(unstructureds).hasSize(1); Unstructured unstructured = unstructureds.get(0); Setting setting = Unstructured.OBJECT_MAPPER.convertValue(unstructured, Setting.class); assertThat(setting).isNotNull(); JSONAssert.assertEquals(""" { "spec": { "forms": [ { "group": "basic", "label": "基本设置", "formSchema": [ { "$el": "h1", "children": "Register" }, { "$formkit": "text", "help": "This will be used for your account.", "label": "Email", "name": "email", "validation": "required|email" } ] }, { "group": "sns", "label": "社交资料", "formSchema": [ { "$formkit": "text", "help": "This will be used for your theme.", "label": "color", "name": "color", "validation": "required" } ] } ] }, "apiVersion": "v1alpha1", "kind": "Setting", "metadata": { "name": "setting-name" } } """, JsonUtils.objectToJson(setting), false); } } ================================================ FILE: application/src/test/java/run/halo/app/core/extension/TestRole.java ================================================ package run.halo.app.core.extension; import run.halo.app.infra.utils.JsonUtils; /** * Roles to test. * * @author guqing * @since 2.0.0 */ public class TestRole { public static Role getRoleManage() { return JsonUtils.jsonToObject(""" { "apiVersion": "v1alpha1", "kind": "Role", "metadata": { "name": "role-template-apple-manage" }, "rules": [{ "resources": ["apples"], "verbs": ["create"] }] } """, Role.class); } public static Role getRoleView() { return JsonUtils.jsonToObject(""" { "apiVersion": "v1alpha1", "kind": "Role", "metadata": { "name": "role-template-apple-view" }, "rules": [{ "resources": ["apples"], "verbs": ["list"] }] } """, Role.class); } public static Role getRoleOther() { return JsonUtils.jsonToObject(""" { "apiVersion": "v1alpha1", "kind": "Role", "metadata": { "name": "role-template-apple-other" }, "rules": [{ "resources": ["apples"], "verbs": ["update"] }] } """, Role.class); } } ================================================ FILE: application/src/test/java/run/halo/app/core/extension/ThemeTest.java ================================================ package run.halo.app.core.extension; import static org.assertj.core.api.Assertions.assertThat; import java.util.List; import org.json.JSONException; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.security.util.InMemoryResource; import run.halo.app.extension.Metadata; import run.halo.app.extension.Unstructured; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.YamlUnstructuredLoader; /** * Tests for {@link Theme}. * * @author guqing * @since 2.0.0 */ class ThemeTest { @Test void constructor() throws JSONException { Theme theme = new Theme(); Metadata metadata = new Metadata(); metadata.setName("test-theme"); theme.setMetadata(metadata); Theme.ThemeSpec themeSpec = new Theme.ThemeSpec(); theme.setSpec(themeSpec); themeSpec.setDisplayName("test-theme"); Theme.Author author = new Theme.Author(); author.setName("test-author"); author.setWebsite("https://test.com"); themeSpec.setAuthor(author); themeSpec.setRepo("https://test.com"); themeSpec.setLogo("https://test.com"); themeSpec.setHomepage("https://test.com"); themeSpec.setDescription("test-description"); themeSpec.setConfigMapName("test-config-map"); themeSpec.setSettingName("test-setting"); JSONAssert.assertEquals(""" { "spec": { "displayName": "test-theme", "author": { "name": "test-author", "website": "https://test.com" }, "description": "test-description", "logo": "https://test.com", "homepage": "https://test.com", "repo": "https://test.com", "version": "*", "requires": "*", "settingName": "test-setting", "configMapName": "test-config-map" }, "apiVersion": "theme.halo.run/v1alpha1", "kind": "Theme", "metadata": { "name": "test-theme" } } """, JsonUtils.objectToJson(theme), true); themeSpec.setVersion("1.0.0"); themeSpec.setRequires("2.0.0"); assertThat(themeSpec.getVersion()).isEqualTo("1.0.0"); assertThat(themeSpec.getRequires()).isEqualTo("2.0.0"); } @Test void themeCustomTemplate() throws JSONException { String themeYaml = """ apiVersion: theme.halo.run/v1alpha1 kind: Theme metadata: name: guqing-higan spec: displayName: higan customTemplates: post: - name: post-template-1 description: description for post-template-1 screenshot: foo.png file: post_template_1.html - name: post-template-2 description: description for post-template-2 screenshot: bar.png file: post_template_2.html category: - name: category-template-1 description: description for category-template-1 screenshot: foo.png file: category_template_1.html page: - name: page-template-1 description: description for page-template-1 screenshot: foo.png file: page_template_1.html """; List unstructuredList = new YamlUnstructuredLoader(new InMemoryResource(themeYaml)).load(); assertThat(unstructuredList).hasSize(1); Theme theme = Unstructured.OBJECT_MAPPER.convertValue(unstructuredList.get(0), Theme.class); assertThat(theme).isNotNull(); JSONAssert.assertEquals(""" { "post": [ { "name": "post-template-1", "description": "description for post-template-1", "screenshot": "foo.png", "file": "post_template_1.html" }, { "name": "post-template-2", "description": "description for post-template-2", "screenshot": "bar.png", "file": "post_template_2.html" } ], "category": [ { "name": "category-template-1", "description": "description for category-template-1", "screenshot": "foo.png", "file": "category_template_1.html" }], "page": [ { "name": "page-template-1", "description": "description for page-template-1", "screenshot": "foo.png", "file": "page_template_1.html" }] } """, JsonUtils.objectToJson(theme.getSpec().getCustomTemplates()), true); } } ================================================ FILE: application/src/test/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpointTest.java ================================================ package run.halo.app.core.extension.attachment.endpoint; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockUser; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.BodyInserters; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.AttachmentLister; import run.halo.app.core.attachment.endpoint.AttachmentEndpoint; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Group; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.attachment.Policy.PolicySpec; import run.halo.app.core.user.service.impl.DefaultAttachmentService; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.ReactiveUrlDataBufferFetcher; import run.halo.app.plugin.extensionpoint.ExtensionGetter; @ExtendWith(MockitoExtension.class) class AttachmentEndpointTest { @Mock ReactiveExtensionClient client; @Mock ExtensionGetter extensionGetter; @Mock ReactiveUrlDataBufferFetcher dataBufferFetcher; @Mock AttachmentLister attachmentLister; AttachmentEndpoint endpoint; WebTestClient webClient; @BeforeEach void setUp() { var attachmentService = new DefaultAttachmentService(client, extensionGetter, dataBufferFetcher); endpoint = new AttachmentEndpoint(attachmentService, attachmentLister); webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) .apply(springSecurity()) .build(); } @Nested class UploadTest { @Test void shouldResponseErrorIfNotLogin() { var policySpec = new PolicySpec(); policySpec.setConfigMapName("fake-configmap"); var policyMetadata = new Metadata(); policyMetadata.setName("fake-policy"); var policy = new Policy(); policy.setSpec(policySpec); policy.setMetadata(policyMetadata); var cm = new ConfigMap(); var cmMetadata = new Metadata(); cmMetadata.setName("fake-configmap"); cm.setData(Map.of()); var handler = mock(AttachmentHandler.class); var metadata = new Metadata(); metadata.setName("fake-attachment"); var attachment = new Attachment(); attachment.setMetadata(metadata); var builder = new MultipartBodyBuilder(); builder.part("policyName", "fake-policy"); builder.part("groupName", "fake-group"); builder.part("file", "fake-file") .contentType(MediaType.TEXT_PLAIN) .filename("fake-filename"); webClient .post() .uri("/attachments/upload") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(builder.build())) .exchange() .expectStatus().isUnauthorized(); verify(client, never()).get(Policy.class, "fake-policy"); verify(client, never()).get(ConfigMap.class, "fake-configmap"); verify(client, never()).create(attachment); verify(extensionGetter, never()).getExtensions(AttachmentHandler.class); verify(handler, never()).upload(any()); } @Test void shouldResponseErrorIfNoBodyProvided() { webClient .mutateWith(mockUser("fake-user").password("fake-password")) .post() .uri("/attachments/upload") .contentType(MediaType.MULTIPART_FORM_DATA) .exchange() .expectStatus().is5xxServerError(); } @Test void shouldResponseErrorIfPolicyNameIsMissing() { var builder = new MultipartBodyBuilder(); builder.part("file", "fake-file") .contentType(MediaType.TEXT_PLAIN) .filename("fake-filename"); webClient .mutateWith(mockUser("fake-user").password("fake-password")) .post() .uri("/attachments/upload") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(builder.build())) .exchange() .expectStatus().isBadRequest(); } @Test void shouldUploadSuccessfully() { var policySpec = new PolicySpec(); policySpec.setConfigMapName("fake-configmap"); var policyMetadata = new Metadata(); policyMetadata.setName("fake-policy"); var policy = new Policy(); policy.setSpec(policySpec); policy.setMetadata(policyMetadata); var cm = new ConfigMap(); var cmMetadata = new Metadata(); cmMetadata.setName("fake-configmap"); cm.setData(Map.of()); var group = new Group(); group.setMetadata(new Metadata()); group.getMetadata().setName("fake-group"); when(client.get(Policy.class, "fake-policy")).thenReturn(Mono.just(policy)); when(client.get(ConfigMap.class, "fake-configmap")).thenReturn(Mono.just(cm)); when(client.get(Group.class, "fake-group")).thenReturn(Mono.just(group)); var handler = mock(AttachmentHandler.class); var metadata = new Metadata(); metadata.setName("fake-attachment"); var attachment = new Attachment(); attachment.setMetadata(metadata); when(handler.upload(any())).thenReturn(Mono.just(attachment)); when(extensionGetter.getExtensions(AttachmentHandler.class)) .thenReturn(Flux.just(handler)); when(client.create(attachment)).thenReturn(Mono.just(attachment)); var builder = new MultipartBodyBuilder(); builder.part("policyName", "fake-policy"); builder.part("groupName", "fake-group"); builder.part("file", "fake-file") .contentType(MediaType.TEXT_PLAIN) .filename("fake-filename"); webClient .mutateWith(mockUser("fake-user").password("fake-password")) .post() .uri("/attachments/upload") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(builder.build())) .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.metadata.name").isEqualTo("fake-attachment") .jsonPath("$.spec.ownerName").isEqualTo("fake-user") .jsonPath("$.spec.policyName").isEqualTo("fake-policy") .jsonPath("$.spec.groupName").isEqualTo("fake-group") ; verify(client).get(Policy.class, "fake-policy"); verify(client).get(ConfigMap.class, "fake-configmap"); verify(client).get(Group.class, "fake-group"); verify(client).create(attachment); verify(handler).upload(assertArg(context -> { assertEquals(policy, context.policy()); assertEquals(cm, context.configMap()); assertEquals(group, context.group()); })); } } @Nested class SearchTest { @Test void shouldListUngroupedAttachments() { when(attachmentLister.listBy(any())).thenReturn(Mono.just(ListResult.emptyResult())); webClient .get() .uri("/attachments") .exchange() .expectStatus().isOk() .expectBody() .jsonPath("items.length()").isEqualTo(0); } @Test void searchAttachmentWhenGroupIsEmpty() { when(attachmentLister.listBy(any())).thenReturn(Mono.just(ListResult.emptyResult())); webClient .get() .uri("/attachments") .exchange() .expectStatus().isOk(); verify(attachmentLister).listBy(any()); } } @Nested class ExternalTransferTest { @Test void shouldResponseErrorIfNoPermalinkProvided() { webClient .mutateWith(mockUser("fake-user").password("fake-password")) .post() .uri("/attachments/-/upload-from-url") .contentType(MediaType.APPLICATION_JSON) .bodyValue(Map.of("policyName", "fake-policy")) .exchange() .expectStatus().isBadRequest(); } @Test void shouldTransferSuccessfully() { var policySpec = new PolicySpec(); policySpec.setConfigMapName("fake-configmap"); var policyMetadata = new Metadata(); policyMetadata.setName("fake-policy"); var policy = new Policy(); policy.setSpec(policySpec); policy.setMetadata(policyMetadata); var cm = new ConfigMap(); var cmMetadata = new Metadata(); cmMetadata.setName("fake-configmap"); cm.setData(Map.of()); when(client.get(Policy.class, "fake-policy")).thenReturn(Mono.just(policy)); when(client.get(ConfigMap.class, "fake-configmap")).thenReturn(Mono.just(cm)); var handler = mock(AttachmentHandler.class); var metadata = new Metadata(); metadata.setName("fake-attachment"); var attachment = new Attachment(); attachment.setMetadata(metadata); ResponseEntity response = new ResponseEntity<>(HttpStatusCode.valueOf(200)); HttpHeaders headers = response.getHeaders(); DataBuffer dataBuffer = mock(DataBuffer.class); when(handler.upload(any())).thenReturn(Mono.just(attachment)); when(dataBufferFetcher.head(any())).thenReturn(Mono.just(headers)); when(dataBufferFetcher.fetch(any())).thenReturn(Flux.just(dataBuffer)); when(extensionGetter.getExtensions(AttachmentHandler.class)) .thenReturn(Flux.just(handler)); when(client.create(attachment)).thenReturn(Mono.just(attachment)); var fakeValue = Map.of("policyName", "fake-policy", "url", "http://localhost:8090/fake-url.jpg"); webClient .mutateWith(mockUser("fake-user").password("fake-password")) .post() .uri("/attachments/-/upload-from-url") .contentType(MediaType.APPLICATION_JSON) .bodyValue(fakeValue) .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.metadata.name").isEqualTo("fake-attachment") .jsonPath("$.spec.ownerName").isEqualTo("fake-user") .jsonPath("$.spec.policyName").isEqualTo("fake-policy") ; verify(client).get(Policy.class, "fake-policy"); verify(client).get(ConfigMap.class, "fake-configmap"); verify(client).create(attachment); verify(dataBufferFetcher).head(any()); verify(dataBufferFetcher).fetch(any()); verify(handler).upload(any()); } } } ================================================ FILE: application/src/test/java/run/halo/app/core/reconciler/CommentReconcilerTest.java ================================================ package run.halo.app.core.reconciler; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Instant; import java.util.HashSet; import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; import run.halo.app.content.comment.ReplyService; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.reconciler.CommentReconciler; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; import run.halo.app.extension.Ref; import run.halo.app.extension.SchemeManager; import run.halo.app.extension.controller.Reconciler; /** * Tests for {@link CommentReconciler}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class CommentReconcilerTest { @Mock private ExtensionClient client; @Mock SchemeManager schemeManager; @Mock ReplyService replyService; @InjectMocks private CommentReconciler commentReconciler; private final Instant now = Instant.now(); @Test void reconcileDelete() { Comment comment = new Comment(); comment.setMetadata(new Metadata()); comment.getMetadata().setName("test"); comment.getMetadata().setDeletionTimestamp(Instant.now()); Set finalizers = new HashSet<>(); finalizers.add(CommentReconciler.FINALIZER_NAME); comment.getMetadata().setFinalizers(finalizers); comment.setSpec(new Comment.CommentSpec()); comment.getSpec().setSubjectRef(getRef()); comment.getSpec().setLastReadTime(now.plusSeconds(5)); comment.setStatus(new Comment.CommentStatus()); when(client.fetch(eq(Comment.class), eq("test"))) .thenReturn(Optional.of(comment)); when(replyService.removeAllByComment(eq(comment.getMetadata().getName()))) .thenReturn(Mono.empty()); when(client.listBy(eq(Comment.class), any(ListOptions.class), isA(PageRequest.class))) .thenReturn(ListResult.emptyResult()); Reconciler.Result reconcile = commentReconciler.reconcile(new Reconciler.Request("test")); assertThat(reconcile.reEnqueue()).isFalse(); assertThat(reconcile.retryAfter()).isNull(); verify(replyService).removeAllByComment(eq(comment.getMetadata().getName())); ArgumentCaptor captor = ArgumentCaptor.forClass(Comment.class); verify(client, times(1)).update(captor.capture()); Comment value = captor.getValue(); assertThat(value.getMetadata().getFinalizers() .contains(CommentReconciler.FINALIZER_NAME)).isFalse(); } @Test void compatibleCreationTime() { Comment comment = new Comment(); comment.setMetadata(new Metadata()); comment.getMetadata().setName("fake-comment"); comment.setSpec(new Comment.CommentSpec()); comment.getSpec().setApprovedTime(Instant.now()); comment.getSpec().setCreationTime(null); commentReconciler.compatibleCreationTime(comment); assertThat(comment.getSpec().getCreationTime()) .isEqualTo(comment.getSpec().getApprovedTime()); } private static Ref getRef() { Ref ref = new Ref(); ref.setGroup("content.halo.run"); ref.setVersion("v1alpha1"); ref.setKind("Post"); ref.setName("fake-post"); return ref; } } ================================================ FILE: application/src/test/java/run/halo/app/core/reconciler/MenuItemReconcilerTest.java ================================================ package run.halo.app.core.reconciler; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Duration; import java.util.Optional; import java.util.function.Consumer; import java.util.function.Supplier; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.core.extension.MenuItem; import run.halo.app.core.extension.MenuItem.MenuItemSpec; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.core.reconciler.MenuItemReconciler; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.Ref; import run.halo.app.extension.controller.Reconciler.Request; @ExtendWith(MockitoExtension.class) class MenuItemReconcilerTest { @Mock ExtensionClient client; @InjectMocks MenuItemReconciler reconciler; @Nested class WhenCategoryRefSet { @Test void shouldNotUpdateMenuItemIfCategoryNotFound() { Supplier menuItemSupplier = () -> createMenuItem("fake-name", spec -> { spec.setTargetRef(Ref.of("fake-category", Category.GVK)); }); when(client.fetch(MenuItem.class, "fake-name")) .thenReturn(Optional.of(menuItemSupplier.get())); when(client.fetch(Category.class, "fake-category")).thenReturn(Optional.empty()); var result = reconciler.reconcile(new Request("fake-name")); assertTrue(result.reEnqueue()); assertEquals(Duration.ofMinutes(1), result.retryAfter()); verify(client).fetch(MenuItem.class, "fake-name"); verify(client).fetch(Category.class, "fake-category"); verify(client, never()).update(isA(MenuItem.class)); } @Test void shouldUpdateMenuItemIfCategoryFound() { Supplier menuItemSupplier = () -> createMenuItem("fake-name", spec -> { spec.setTargetRef(Ref.of("fake-category", Category.GVK)); }); when(client.fetch(MenuItem.class, "fake-name")) .thenReturn(Optional.of(menuItemSupplier.get())) .thenReturn(Optional.of(menuItemSupplier.get())); when(client.fetch(Category.class, "fake-category")) .thenReturn(Optional.of(createCategory())); var result = reconciler.reconcile(new Request("fake-name")); assertTrue(result.reEnqueue()); assertEquals(Duration.ofMinutes(1), result.retryAfter()); verify(client, times(2)).fetch(MenuItem.class, "fake-name"); verify(client).fetch(Category.class, "fake-category"); verify(client).update(argThat(menuItem -> { var status = menuItem.getStatus(); return status.getHref().equals("fake://permalink") && status.getDisplayName().equals("Fake Category"); })); } Category createCategory() { var metadata = new Metadata(); metadata.setName("fake-category"); var spec = new Category.CategorySpec(); spec.setDisplayName("Fake Category"); var status = new Category.CategoryStatus(); status.setPermalink("fake://permalink"); var category = new Category(); category.setMetadata(metadata); category.setSpec(spec); category.setStatus(status); return category; } } @Nested class WhenSinglePageRefSet { @Test void shouldUpdateMenuItemIfPageFound() { Supplier menuItemSupplier = () -> createMenuItem("fake-name", spec -> spec.setTargetRef(Ref.of("fake-page", SinglePage.GVK))); when(client.fetch(MenuItem.class, "fake-name")) .thenReturn(Optional.of(menuItemSupplier.get())) .thenReturn(Optional.of(menuItemSupplier.get())); when(client.fetch(SinglePage.class, "fake-page")) .thenReturn(Optional.of(createSinglePage())); var result = reconciler.reconcile(new Request("fake-name")); assertTrue(result.reEnqueue()); assertEquals(Duration.ofMinutes(1), result.retryAfter()); verify(client, times(2)).fetch(MenuItem.class, "fake-name"); verify(client).fetch(SinglePage.class, "fake-page"); verify(client).update(argThat(menuItem -> { var status = menuItem.getStatus(); return status.getHref().equals("fake://permalink") && status.getDisplayName().equals("fake-title"); })); } SinglePage createSinglePage() { var metadata = new Metadata(); metadata.setName("fake-page"); var spec = new SinglePage.SinglePageSpec(); spec.setTitle("fake-title"); var status = new SinglePage.SinglePageStatus(); status.setPermalink("fake://permalink"); var singlePage = new SinglePage(); singlePage.setMetadata(metadata); singlePage.setSpec(spec); singlePage.setStatus(status); return singlePage; } } @Nested class WhenOtherRefsNotSet { @Test void shouldNotRequeueIfHrefNotSet() { var menuItem = createMenuItem("fake-name", spec -> { spec.setHref(null); spec.setDisplayName("Fake display name"); }); when(client.fetch(MenuItem.class, "fake-name")).thenReturn(Optional.of(menuItem)); var result = reconciler.reconcile(new Request("fake-name")); assertFalse(result.reEnqueue()); verify(client).fetch(MenuItem.class, "fake-name"); verify(client, never()).update(menuItem); } @Test void shouldNotRequeueIfDisplayNameNotSet() { var menuItem = createMenuItem("fake-name", spec -> { spec.setHref("/fake"); spec.setDisplayName(null); }); when(client.fetch(MenuItem.class, "fake-name")).thenReturn(Optional.of(menuItem)); var result = reconciler.reconcile(new Request("fake-name")); assertFalse(result.reEnqueue()); verify(client).fetch(MenuItem.class, "fake-name"); verify(client, never()).update(menuItem); } @Test void shouldReconcileIfHrefAndDisplayNameSet() { Supplier menuItemSupplier = () -> createMenuItem("fake-name", spec -> { spec.setHref("/fake"); spec.setDisplayName("Fake display name"); }); when(client.fetch(MenuItem.class, "fake-name")) .thenReturn(Optional.of(menuItemSupplier.get())) .thenReturn(Optional.of(menuItemSupplier.get())); var result = reconciler.reconcile(new Request("fake-name")); assertFalse(result.reEnqueue()); verify(client, times(2)).fetch(MenuItem.class, "fake-name"); verify(client).update(argThat(ext -> { if (!(ext instanceof MenuItem menuItem)) { return false; } return menuItem.getStatus().getHref().equals("/fake") && menuItem.getStatus().getDisplayName().equals("Fake display name"); })); } } MenuItem createMenuItem(String name, Consumer specCustomizer) { var metadata = new Metadata(); metadata.setName(name); var menuItem = new MenuItem(); menuItem.setMetadata(metadata); var spec = new MenuItemSpec(); if (specCustomizer != null) { specCustomizer.accept(spec); } menuItem.setSpec(spec); return menuItem; } } ================================================ FILE: application/src/test/java/run/halo/app/core/reconciler/PluginReconcilerTest.java ================================================ package run.halo.app.core.reconciler; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static run.halo.app.plugin.PluginConst.PLUGIN_PATH; import static run.halo.app.plugin.PluginConst.RELOAD_ANNO; import static run.halo.app.plugin.PluginConst.RUNTIME_MODE_ANNO; import java.io.IOException; import java.net.URL; import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Consumer; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.pf4j.DefaultPluginDescriptor; import org.pf4j.PluginState; import org.pf4j.PluginWrapper; import org.pf4j.RuntimeMode; import org.springframework.core.io.DefaultResourceLoader; import reactor.core.scheduler.Schedulers; import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.ReverseProxy; import run.halo.app.core.extension.Setting; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler.Request; import run.halo.app.extension.controller.RequeueException; import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionStatus; import run.halo.app.plugin.PluginProperties; import run.halo.app.plugin.PluginService; import run.halo.app.plugin.SpringPluginManager; /** * Tests for {@link PluginReconciler}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class PluginReconcilerTest { @Mock SpringPluginManager pluginManager; @Mock ExtensionClient client; @Mock PluginProperties pluginProperties; @Mock PluginService pluginService; @InjectMocks PluginReconciler reconciler; Clock clock = Clock.fixed(Instant.parse("2024-01-09T12:00:00Z"), ZoneOffset.UTC); String finalizer = "plugin-protection"; String name = "fake-plugin"; String reverseProxyName = "fake-plugin-system-generated-reverse-proxy"; String settingName = "fake-setting"; String configMapName = "fake-configmap"; @BeforeEach void setUp() { reconciler.setClock(clock); reconciler.setScheduler(Schedulers.immediate()); } @Test void shouldNotRequeueIfPluginNotFound() { when(client.fetch(Plugin.class, "fake-plugin")).thenReturn(Optional.empty()); var result = reconciler.reconcile(new Request("fake-plugin")); assertFalse(result.reEnqueue()); verify(client).fetch(Plugin.class, "fake-plugin"); } @Nested class WhenNotDeleting { @TempDir Path tempPath; @BeforeEach void setUp() throws IOException { lenient().when(pluginService.getRequiredDependencies(any(), any())) .thenReturn(List.of()); Files.createFile(tempPath.resolve("fake-plugin-1.2.3.jar")); } @Test void shouldNotStartPluginWithDevModeInNonDevEnv() { var fakePlugin = createPlugin(name, plugin -> { var spec = plugin.getSpec(); spec.setVersion("1.2.3"); spec.setLogo("fake-logo.svg"); spec.setEnabled(true); plugin.getMetadata() .setAnnotations(new HashMap<>(Map.of(RUNTIME_MODE_ANNO, "dev", PLUGIN_PATH, "fake-path"))); }); when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); var result = reconciler.reconcile(new Request(name)); assertFalse(result.reEnqueue()); var status = fakePlugin.getStatus(); assertEquals(Plugin.Phase.UNKNOWN, status.getPhase()); var condition = status.getConditions().peekFirst(); assertEquals(Condition.builder() .type(PluginReconciler.ConditionType.INITIALIZED) .status(ConditionStatus.FALSE) .reason(PluginReconciler.ConditionReason.INVALID_RUNTIME_MODE) .message(""" Cannot run the plugin with development mode in non-development environment.\ """) .build(), condition); verify(client).update(fakePlugin); verify(client).fetch(Plugin.class, name); verify(pluginProperties).getRuntimeMode(); verify(pluginManager, never()).loadPlugin(any(Path.class)); verify(pluginManager, never()).startPlugin(name); } @Test void shouldStartInDevMode() { var fakePlugin = createPlugin(name, plugin -> { var spec = plugin.getSpec(); spec.setVersion("1.2.3"); spec.setLogo("fake-logo.svg"); spec.setEnabled(true); plugin.getMetadata() .setAnnotations(new HashMap<>(Map.of(RUNTIME_MODE_ANNO, "dev", PLUGIN_PATH, "fake-path"))); }); when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); when(pluginManager.getPlugin(name)) .thenReturn(null) .thenReturn(mockPluginWrapper(PluginState.RESOLVED)); when(pluginManager.startPlugin(name)).thenReturn(PluginState.STARTED); when(pluginProperties.getRuntimeMode()).thenReturn(RuntimeMode.DEVELOPMENT); var result = reconciler.reconcile(new Request(name)); assertTrue(result.reEnqueue()); assertEquals(Paths.get("fake-path").toUri(), fakePlugin.getStatus().getLoadLocation()); verify(pluginManager).startPlugin(name); } @Test void shouldThrowExceptionIfNoPluginPathProvidedInDevMode() { var fakePlugin = createPlugin(name, plugin -> { var spec = plugin.getSpec(); spec.setVersion("1.2.3"); spec.setLogo("fake-logo.svg"); spec.setEnabled(true); plugin.getMetadata() .setAnnotations(new HashMap<>(Map.of(RUNTIME_MODE_ANNO, "dev"))); }); when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); when(pluginManager.getPlugin(name)) // loading plugin .thenReturn(null); when(pluginProperties.getRuntimeMode()).thenReturn(RuntimeMode.DEVELOPMENT); var result = reconciler.reconcile(new Request(name)); assertFalse(result.reEnqueue()); } @Test void shouldReloadIfReloadAnnotationPresent() { var fakePlugin = createPlugin(name, plugin -> { var spec = plugin.getSpec(); spec.setVersion("1.2.3"); spec.setLogo("fake-logo.svg"); spec.setEnabled(true); plugin.getMetadata().setAnnotations(new HashMap<>(Map.of(RELOAD_ANNO, "true"))); }); when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); when(pluginManager.getPluginsRoots()).thenReturn(List.of(tempPath)); var pluginWrapper = mockPluginWrapper(PluginState.RESOLVED); when(pluginManager.getPlugin(name)).thenReturn(pluginWrapper); when(pluginManager.startPlugin(name)).thenReturn(PluginState.STARTED); when(pluginManager.getUnresolvedPlugins()).thenReturn(List.of(pluginWrapper)); when(pluginManager.getResolvedPlugins()).thenReturn(List.of()); var result = reconciler.reconcile(new Request(name)); assertTrue(result.reEnqueue()); verify(pluginManager).unloadPlugin(name); var loadLocation = Paths.get(fakePlugin.getStatus().getLoadLocation()); verify(pluginManager).loadPlugin(loadLocation); } @Test void shouldReportIfFailedToStartPlugin() throws IOException { var fakePlugin = createPlugin(name, plugin -> { var spec = plugin.getSpec(); spec.setVersion("1.2.3"); spec.setLogo("fake-logo.svg"); spec.setEnabled(true); spec.setSettingName(settingName); spec.setConfigMapName(configMapName); }); when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); when(pluginManager.getPluginsRoots()).thenReturn(List.of(tempPath)); when(pluginManager.getPlugin(name)) // loading plugin .thenReturn(null) // get setting extension .thenReturn(mockPluginWrapperForSetting()) .thenReturn(mockPluginWrapperForStaticResources()) .thenReturn( mockPluginWrapper(PluginState.FAILED, new IllegalStateException("Fake error")) ); var result = reconciler.reconcile(new Request(name)); assertFalse(result.reEnqueue()); verify(client).update(fakePlugin); var status = fakePlugin.getStatus(); assertEquals(Plugin.Phase.FAILED, status.getPhase()); var condition = status.getConditions().peekFirst(); assertEquals(PluginReconciler.ConditionType.READY, condition.getType()); assertEquals(ConditionStatus.FALSE, condition.getStatus()); assertEquals(PluginReconciler.ConditionReason.START_ERROR, condition.getReason()); assertTrue(condition.getMessage().contains("Fake error")); verify(pluginManager, never()).startPlugin(name); } @Test void shouldEnablePluginIfEnabled() throws IOException { var fakePlugin = createPlugin(name, plugin -> { var spec = plugin.getSpec(); spec.setVersion("1.2.3"); spec.setLogo("fake-logo.svg"); spec.setEnabled(true); spec.setSettingName(settingName); spec.setConfigMapName(configMapName); }); when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); when(pluginManager.getPluginsRoots()).thenReturn(List.of(tempPath)); when(pluginManager.getPlugin(name)) // loading plugin .thenReturn(null) // get setting extension .thenReturn(mockPluginWrapperForSetting()) .thenReturn(mockPluginWrapperForStaticResources()) // before starting .thenReturn(mockPluginWrapper(PluginState.STARTED)) // sync plugin state .thenReturn(mockPluginWrapper(PluginState.STARTED)); var result = reconciler.reconcile(new Request(name)); assertFalse(result.reEnqueue()); assertTrue(fakePlugin.getMetadata().getFinalizers().contains(finalizer)); assertEquals("fake-plugin-1.2.3.jar", fakePlugin.getMetadata().getAnnotations().get(PLUGIN_PATH)); var loadLocation = Paths.get(fakePlugin.getStatus().getLoadLocation()); assertEquals(tempPath.resolve("fake-plugin-1.2.3.jar"), loadLocation); assertEquals("/plugins/fake-plugin/assets/fake-logo.svg?version=1.2.3", fakePlugin.getStatus().getLogo()); assertEquals("/plugins/fake-plugin/assets/console/main.js?version=1.2.3", fakePlugin.getStatus().getEntry()); assertEquals("/plugins/fake-plugin/assets/console/style.css?version=1.2.3", fakePlugin.getStatus().getStylesheet()); assertEquals(Plugin.Phase.STARTED, fakePlugin.getStatus().getPhase()); assertEquals(PluginState.STARTED, fakePlugin.getStatus().getLastProbeState()); assertNotNull(fakePlugin.getStatus().getLastStartTime()); var condition = fakePlugin.getStatus().getConditions().peek(); assertEquals(PluginReconciler.ConditionType.READY, condition.getType()); assertEquals(ConditionStatus.TRUE, condition.getStatus()); assertEquals(clock.instant(), condition.getLastTransitionTime()); verify(pluginManager, never()).startPlugin(name); verify(pluginManager).loadPlugin(loadLocation); verify(pluginManager, times(5)).getPlugin(name); verify(client).update(fakePlugin); verify(client).fetch(Setting.class, settingName); verify(client).create(any(Setting.class)); verify(client).fetch(ConfigMap.class, configMapName); verify(client).create(any(ConfigMap.class)); verify(client).fetch(ReverseProxy.class, reverseProxyName); verify(client).create(any(ReverseProxy.class)); } @Test void shouldDisablePluginIfDisabled() throws IOException { var fakePlugin = createPlugin(name, plugin -> { var spec = plugin.getSpec(); spec.setVersion("1.2.3"); spec.setLogo("fake-logo.svg"); spec.setEnabled(false); spec.setSettingName(settingName); spec.setConfigMapName(configMapName); }); when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); when(pluginManager.getPluginsRoots()).thenReturn(List.of(tempPath)); when(pluginManager.getPlugin(name)) // loading plugin .thenReturn(null) // get setting files. .thenReturn(mockPluginWrapperForSetting()) // resolving static resources .thenReturn(mockPluginWrapperForStaticResources()) // before disabling plugin .thenReturn(mock(PluginWrapper.class)) // sync plugin state .thenReturn(mockPluginWrapper(PluginState.DISABLED)); var result = reconciler.reconcile(new Request("fake-plugin")); assertFalse(result.reEnqueue()); assertTrue(fakePlugin.getMetadata().getFinalizers().contains(finalizer)); assertEquals("fake-plugin-1.2.3.jar", fakePlugin.getMetadata().getAnnotations().get(PLUGIN_PATH)); var loadLocation = Paths.get(fakePlugin.getStatus().getLoadLocation()); assertEquals(tempPath.resolve("fake-plugin-1.2.3.jar"), loadLocation); assertEquals("/plugins/fake-plugin/assets/fake-logo.svg?version=1.2.3", fakePlugin.getStatus().getLogo()); assertEquals("/plugins/fake-plugin/assets/console/main.js?version=1.2.3", fakePlugin.getStatus().getEntry()); assertEquals("/plugins/fake-plugin/assets/console/style.css?version=1.2.3", fakePlugin.getStatus().getStylesheet()); assertEquals(Plugin.Phase.DISABLED, fakePlugin.getStatus().getPhase()); assertEquals(PluginState.DISABLED, fakePlugin.getStatus().getLastProbeState()); verify(pluginManager).disablePlugin(name); verify(pluginManager).loadPlugin(loadLocation); verify(pluginManager, times(5)).getPlugin(name); verify(client).update(fakePlugin); verify(client).fetch(Setting.class, settingName); verify(client).create(any(Setting.class)); verify(client).fetch(ConfigMap.class, configMapName); verify(client).create(any(ConfigMap.class)); verify(client).fetch(ReverseProxy.class, reverseProxyName); verify(client).create(any(ReverseProxy.class)); } PluginWrapper mockPluginWrapperForSetting() throws IOException { var pluginWrapper = mock(PluginWrapper.class); var pluginRootResource = new DefaultResourceLoader().getResource("classpath:plugin/plugin-0.0.1/"); var classLoader = new URLClassLoader(new URL[] {pluginRootResource.getURL()}, null); when(pluginWrapper.getPluginClassLoader()).thenReturn(classLoader); lenient().when(pluginWrapper.getDescriptor()).thenReturn(new DefaultPluginDescriptor()); return pluginWrapper; } PluginWrapper mockPluginWrapperForStaticResources() { // check var pluginWrapper = mock(PluginWrapper.class); var pluginClassLoader = mock(ClassLoader.class); when(pluginClassLoader.getResource("console/main.js")).thenReturn( mock(URL.class)); when(pluginClassLoader.getResource("console/style.css")).thenReturn( mock(URL.class)); when(pluginWrapper.getPluginClassLoader()).thenReturn(pluginClassLoader); lenient().when(pluginWrapper.getDescriptor()).thenReturn(new DefaultPluginDescriptor()); return pluginWrapper; } PluginWrapper mockPluginWrapper(PluginState state) { return mockPluginWrapper(state, null); } PluginWrapper mockPluginWrapper(PluginState state, @Nullable Throwable t) { var pluginWrapper = mock(PluginWrapper.class); lenient().when(pluginWrapper.getPluginState()).thenReturn(state); lenient().when(pluginWrapper.getDescriptor()).thenReturn(new DefaultPluginDescriptor()); lenient().when(pluginWrapper.getFailedException()).thenReturn(t); return pluginWrapper; } } @Nested class WhenDeleting { @Test void shouldDoNothingWithoutFinalizer() { var fakePlugin = createPlugin(name, plugin -> { var metadata = plugin.getMetadata(); metadata.setDeletionTimestamp(clock.instant()); }); when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); var result = reconciler.reconcile(new Request(name)); assertFalse(result.reEnqueue()); verify(client).fetch(Plugin.class, name); verify(client, never()).update(fakePlugin); verify(pluginManager, never()).getPlugin(name); verify(pluginManager, never()).deletePlugin(name); } @Test void shouldCleanUpResourceFully() { var fakePlugin = createPlugin(name, plugin -> { var metadata = plugin.getMetadata(); metadata.setDeletionTimestamp(clock.instant()); metadata.setFinalizers(new HashSet<>(Set.of(finalizer))); plugin.getStatus().setLastProbeState(PluginState.STARTED); plugin.getSpec().setConfigMapName("fake-configmap"); plugin.getSpec().setSettingName("fake-setting"); }); when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); when(client.fetch(Setting.class, "fake-setting")) .thenReturn(Optional.empty()); when(client.fetch(ReverseProxy.class, reverseProxyName)) .thenReturn(Optional.empty()); when(pluginManager.getPlugin(name)) .thenReturn(mock(PluginWrapper.class)) .thenReturn(null); var result = reconciler.reconcile(new Request(name)); assertFalse(result.reEnqueue()); // make sure the finalizer is removed. assertFalse(fakePlugin.getMetadata().getFinalizers().contains(finalizer)); assertNull(fakePlugin.getStatus().getLastProbeState()); verify(pluginManager, times(2)).getPlugin(name); verify(pluginManager).deletePlugin(name); verify(client).fetch(Plugin.class, name); verify(client).fetch(Setting.class, "fake-setting"); verify(client).fetch(ReverseProxy.class, reverseProxyName); verify(client).update(fakePlugin); } @Test void shouldDeleteSettingAndRequeueIfExists() { var fakePlugin = createPlugin(name, plugin -> { var metadata = plugin.getMetadata(); metadata.setDeletionTimestamp(clock.instant()); metadata.setFinalizers(new HashSet<>(Set.of(finalizer))); plugin.getStatus().setLastProbeState(PluginState.STARTED); plugin.getSpec().setSettingName(settingName); }); var fakeSetting = createSetting(settingName); when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); when(client.fetch(Setting.class, settingName)) .thenReturn(Optional.of(fakeSetting)); when(client.fetch(ReverseProxy.class, reverseProxyName)) .thenReturn(Optional.empty()); var exception = assertThrows( RequeueException.class, () -> reconciler.reconcile(new Request(name)) ); assertEquals(Reconciler.Result.requeue(null), exception.getResult()); assertEquals("Waiting for setting fake-setting to be deleted.", exception.getMessage()); // make sure the finalizer is removed. assertFalse(fakePlugin.getMetadata().getFinalizers().contains(finalizer)); assertEquals(PluginState.STARTED, fakePlugin.getStatus().getLastProbeState()); verify(pluginManager, never()).getPlugin(name); verify(pluginManager, never()).deletePlugin(name); verify(client).fetch(Plugin.class, name); verify(client).fetch(ReverseProxy.class, reverseProxyName); verify(client).fetch(Setting.class, settingName); verify(client).delete(fakeSetting); verify(client, never()).update(fakePlugin); } @Test void shouldDeleteReverseProxyAndRequeueIfExists() { var fakePlugin = createPlugin(name, plugin -> { var metadata = plugin.getMetadata(); metadata.setDeletionTimestamp(clock.instant()); metadata.setFinalizers(new HashSet<>(Set.of(finalizer))); plugin.getStatus().setLastProbeState(PluginState.STARTED); plugin.getSpec().setSettingName(settingName); }); var reverseProxy = createReverseProxy(reverseProxyName); when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); when(client.fetch(ReverseProxy.class, reverseProxyName)) .thenReturn(Optional.of(reverseProxy)); var exception = assertThrows(RequeueException.class, () -> reconciler.reconcile(new Request(name)), "Waiting for setting fake-setting to be deleted."); assertEquals(Reconciler.Result.requeue(null), exception.getResult()); assertEquals("Waiting for reverse proxy " + reverseProxyName + " to be deleted.", exception.getMessage()); // make sure the finalizer is removed. assertFalse(fakePlugin.getMetadata().getFinalizers().contains(finalizer)); assertEquals(PluginState.STARTED, fakePlugin.getStatus().getLastProbeState()); verify(pluginManager, never()).getPlugin(name); verify(pluginManager, never()).deletePlugin(name); verify(client).fetch(Plugin.class, name); verify(client).fetch(ReverseProxy.class, reverseProxyName); verify(client).delete(reverseProxy); verify(client, never()).fetch(Setting.class, settingName); verify(client, never()).update(fakePlugin); } } Setting createSetting(String name) { var setting = new Setting(); var metadata = new Metadata(); metadata.setName(name); setting.setMetadata(metadata); return setting; } ReverseProxy createReverseProxy(String name) { var reverseProxy = new ReverseProxy(); var metadata = new Metadata(); metadata.setName(name); reverseProxy.setMetadata(metadata); return reverseProxy; } Plugin createPlugin(String name, Consumer pluginConsumer) { var plugin = new Plugin(); var metadata = new Metadata(); plugin.setMetadata(metadata); metadata.setName(name); plugin.setSpec(new Plugin.PluginSpec()); plugin.setStatus(new Plugin.PluginStatus()); pluginConsumer.accept(plugin); return plugin; } } ================================================ FILE: application/src/test/java/run/halo/app/core/reconciler/PostReconcilerTest.java ================================================ package run.halo.app.core.reconciler; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import reactor.core.publisher.Mono; import run.halo.app.content.ContentWrapper; import run.halo.app.content.ExcerptGenerator; import run.halo.app.content.NotificationReasonConst; import run.halo.app.content.PostService; import run.halo.app.content.TestPost; import run.halo.app.content.permalinks.PostPermalinkPolicy; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.core.reconciler.PostReconciler; import run.halo.app.event.post.PostPublishedEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.controller.Reconciler; import run.halo.app.notification.NotificationCenter; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** * Tests for {@link PostReconciler}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class PostReconcilerTest { @Mock private ExtensionClient client; @Mock private PostPermalinkPolicy postPermalinkPolicy; @Mock private PostService postService; @Mock private ApplicationEventPublisher eventPublisher; @Mock private NotificationCenter notificationCenter; @Mock private ExtensionGetter extensionGetter; @InjectMocks private PostReconciler postReconciler; @BeforeEach void setUp() { lenient().when(notificationCenter.subscribe(any(), any())).thenReturn(Mono.empty()); } @Test void reconcile() { String name = "post-A"; Post post = TestPost.postV1(); post.getSpec().setPublish(false); post.getSpec().setHeadSnapshot("post-A-head-snapshot"); when(client.fetch(eq(Post.class), eq(name))) .thenReturn(Optional.of(post)); when(postService.getContent(eq(post.getSpec().getReleaseSnapshot()), eq(post.getSpec().getBaseSnapshot()))) .thenReturn(Mono.empty()); Snapshot snapshotV1 = TestPost.snapshotV1(); Snapshot snapshotV2 = TestPost.snapshotV2(); snapshotV1.getSpec().setContributors(Set.of("guqing")); snapshotV2.getSpec().setContributors(Set.of("guqing", "zhangsan")); when(client.listAll(eq(Snapshot.class), any(), any())) .thenReturn(List.of(snapshotV1, snapshotV2)); ArgumentCaptor captor = ArgumentCaptor.forClass(Post.class); postReconciler.reconcile(new Reconciler.Request(name)); verify(client, times(1)).update(captor.capture()); verify(postPermalinkPolicy, times(1)).permalink(any()); Post value = captor.getValue(); assertThat(value.getStatus().getExcerpt()).isEmpty(); assertThat(value.getStatus().getContributors()).isEqualTo(List.of("guqing", "zhangsan")); } @Test void shouldGenerateBlankExcerptWhenContentIsNull() { var name = "post-A"; Post post = TestPost.postV1(); post.getSpec().setPublish(true); post.getSpec().setHeadSnapshot("post-A-head-snapshot"); post.getSpec().setReleaseSnapshot("post-fake-released-snapshot"); when(client.fetch(eq(Post.class), eq(name))) .thenReturn(Optional.of(post)); when(postService.getContent(eq(post.getSpec().getReleaseSnapshot()), eq(post.getSpec().getBaseSnapshot()))) .thenReturn(Mono.just(ContentWrapper.builder() .snapshotName(post.getSpec().getHeadSnapshot()) .raw(null) .content(null) .rawType("markdown") .build())); Snapshot snapshotV2 = TestPost.snapshotV2(); snapshotV2.getMetadata().setLabels(new HashMap<>()); snapshotV2.getSpec().setContributors(Set.of("guqing", "zhangsan")); Snapshot snapshotV1 = TestPost.snapshotV1(); snapshotV1.getSpec().setContributors(Set.of("guqing")); when(client.listAll(eq(Snapshot.class), any(), any())) .thenReturn(List.of(snapshotV1, snapshotV2)); ArgumentCaptor captor = ArgumentCaptor.forClass(Post.class); postReconciler.reconcile(new Reconciler.Request(name)); verify(client, times(1)).update(captor.capture()); Post value = captor.getValue(); assertThat(value.getStatus().getExcerpt()).isEqualTo(""); } @Test void reconcileExcerpt() { // https://github.com/halo-dev/halo/issues/2452 String name = "post-A"; Post post = TestPost.postV1(); post.getSpec().setPublish(true); post.getSpec().setHeadSnapshot("post-A-head-snapshot"); post.getSpec().setReleaseSnapshot("post-fake-released-snapshot"); when(client.fetch(eq(Post.class), eq(name))) .thenReturn(Optional.of(post)); when(postService.getContent(eq(post.getSpec().getReleaseSnapshot()), eq(post.getSpec().getBaseSnapshot()))) .thenReturn(Mono.just(ContentWrapper.builder() .snapshotName(post.getSpec().getHeadSnapshot()) .raw("hello world") .content("

hello world

") .rawType("markdown") .build())); Snapshot snapshotV2 = TestPost.snapshotV2(); snapshotV2.getMetadata().setLabels(new HashMap<>()); snapshotV2.getSpec().setContributors(Set.of("guqing", "zhangsan")); Snapshot snapshotV1 = TestPost.snapshotV1(); snapshotV1.getSpec().setContributors(Set.of("guqing")); when(extensionGetter.getEnabledExtension(eq(ExcerptGenerator.class))) .thenReturn(Mono.empty()); when(client.listAll(eq(Snapshot.class), any(), any())) .thenReturn(List.of(snapshotV1, snapshotV2)); ArgumentCaptor captor = ArgumentCaptor.forClass(Post.class); postReconciler.reconcile(new Reconciler.Request(name)); verify(client, times(1)).update(captor.capture()); Post value = captor.getValue(); assertThat(value.getStatus().getExcerpt()).isEqualTo("hello world"); } @Nested class LastModifyTimeTest { @Test void reconcileLastModifyTimeWhenPostIsPublished() { String name = "post-A"; Post post = TestPost.postV1(); post.getSpec().setPublish(true); post.getSpec().setHeadSnapshot("post-A-head-snapshot"); post.getSpec().setReleaseSnapshot("post-fake-released-snapshot"); when(client.fetch(eq(Post.class), eq(name))) .thenReturn(Optional.of(post)); when(postService.getContent(eq(post.getSpec().getReleaseSnapshot()), eq(post.getSpec().getBaseSnapshot()))) .thenReturn(Mono.just(ContentWrapper.builder() .snapshotName(post.getSpec().getHeadSnapshot()) .raw("hello world") .content("

hello world

") .rawType("markdown") .build())); Instant lastModifyTime = Instant.now(); Snapshot snapshotV2 = TestPost.snapshotV2(); snapshotV2.getSpec().setLastModifyTime(lastModifyTime); when(client.fetch(eq(Snapshot.class), eq(post.getSpec().getReleaseSnapshot()))) .thenReturn(Optional.of(snapshotV2)); when(extensionGetter.getEnabledExtension(eq(ExcerptGenerator.class))) .thenReturn(Mono.empty()); when(client.listAll(eq(Snapshot.class), any(), any())) .thenReturn(List.of()); ArgumentCaptor captor = ArgumentCaptor.forClass(Post.class); postReconciler.reconcile(new Reconciler.Request(name)); verify(client, times(1)).update(captor.capture()); Post value = captor.getValue(); assertThat(value.getStatus().getLastModifyTime()).isEqualTo(lastModifyTime); verify(eventPublisher).publishEvent(any(PostPublishedEvent.class)); } @Test void reconcileLastModifyTimeWhenPostIsNotPublished() { String name = "post-A"; Post post = TestPost.postV1(); post.getSpec().setPublish(false); post.getSpec().setHeadSnapshot("post-A-head-snapshot"); when(client.fetch(eq(Post.class), eq(name))) .thenReturn(Optional.of(post)); when(postService.getContent(eq(post.getSpec().getReleaseSnapshot()), eq(post.getSpec().getBaseSnapshot()))) .thenReturn(Mono.just(ContentWrapper.builder() .snapshotName(post.getSpec().getHeadSnapshot()) .raw("hello world") .content("

hello world

") .rawType("markdown") .build())); when(extensionGetter.getEnabledExtension(eq(ExcerptGenerator.class))) .thenReturn(Mono.empty()); when(client.listAll(eq(Snapshot.class), any(), any())) .thenReturn(List.of()); ArgumentCaptor captor = ArgumentCaptor.forClass(Post.class); postReconciler.reconcile(new Reconciler.Request(name)); verify(client, times(1)).update(captor.capture()); Post value = captor.getValue(); assertThat(value.getStatus().getLastModifyTime()).isNull(); } } @Test void subscribeNewCommentNotificationTest() { Post post = TestPost.postV1(); postReconciler.subscribeNewCommentNotification(post); verify(notificationCenter).subscribe( assertArg(subscriber -> assertThat(subscriber.getName()) .isEqualTo(post.getSpec().getOwner())), assertArg(argReason -> { var interestReason = new Subscription.InterestReason(); interestReason.setReasonType(NotificationReasonConst.NEW_COMMENT_ON_POST); interestReason.setExpression("props.postOwner == 'null'"); assertThat(argReason).isEqualTo(interestReason); })); } } ================================================ FILE: application/src/test/java/run/halo/app/core/reconciler/ReverseProxyReconcilerTest.java ================================================ package run.halo.app.core.reconciler; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.core.extension.ReverseProxy; import run.halo.app.core.reconciler.ReverseProxyReconciler; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler; import run.halo.app.plugin.PluginConst; import run.halo.app.plugin.resources.ReverseProxyRouterFunctionRegistry; /** * Tests for {@link ReverseProxyReconciler}. * * @author guqing * @since 2.0.1 */ @ExtendWith(MockitoExtension.class) class ReverseProxyReconcilerTest { @Mock private ExtensionClient client; @Mock private ReverseProxyRouterFunctionRegistry routerFunctionRegistry; @InjectMocks private ReverseProxyReconciler reverseProxyReconciler; @Test void reconcileRemoval() { // fix gh-2937 ReverseProxy reverseProxy = new ReverseProxy(); reverseProxy.setMetadata(new Metadata()); reverseProxy.getMetadata().setName("fake-reverse-proxy"); reverseProxy.getMetadata().setDeletionTimestamp(Instant.now()); reverseProxy.getMetadata() .setLabels(Map.of(PluginConst.PLUGIN_NAME_LABEL_NAME, "fake-plugin")); reverseProxy.setRules(List.of()); doNothing().when(routerFunctionRegistry).remove(anyString(), anyString()); when(client.fetch(ReverseProxy.class, "fake-reverse-proxy")) .thenReturn(Optional.of(reverseProxy)); reverseProxyReconciler.reconcile(new Reconciler.Request("fake-reverse-proxy")); verify(routerFunctionRegistry, never()).register(anyString(), any(ReverseProxy.class)); verify(routerFunctionRegistry, times(1)) .remove(eq("fake-plugin"), eq("fake-reverse-proxy")); } } ================================================ FILE: application/src/test/java/run/halo/app/core/reconciler/SinglePageReconcilerTest.java ================================================ package run.halo.app.core.reconciler; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static run.halo.app.content.TestPost.snapshotV1; import java.net.URI; import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationContext; import reactor.core.publisher.Mono; import run.halo.app.content.ContentWrapper; import run.halo.app.content.ExcerptGenerator; import run.halo.app.content.NotificationReasonConst; import run.halo.app.content.SinglePageService; import run.halo.app.content.TestPost; import run.halo.app.core.counter.CounterService; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.notification.NotificationCenter; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** * Tests for {@link SinglePageReconciler}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class SinglePageReconcilerTest { @Mock private ExtensionClient client; @Mock private ApplicationContext applicationContext; @Mock private CounterService counterService; @Mock private SinglePageService singlePageService; @Mock private ExternalUrlSupplier externalUrlSupplier; @Mock NotificationCenter notificationCenter; @Mock ExtensionGetter extensionGetter; @InjectMocks private SinglePageReconciler singlePageReconciler; @BeforeEach void setUp() { lenient().when(notificationCenter.subscribe(any(), any())).thenReturn(Mono.empty()); } @Test void reconcile() { String name = "page-A"; SinglePage page = pageV1(); page.getSpec().setHeadSnapshot("page-A-head-snapshot"); page.getSpec().setReleaseSnapshot(page.getSpec().getHeadSnapshot()); when(client.fetch(eq(SinglePage.class), eq(name))) .thenReturn(Optional.of(page)); when(singlePageService.getContent(eq(page.getSpec().getReleaseSnapshot()), eq(page.getSpec().getBaseSnapshot()))) .thenReturn(Mono.just(ContentWrapper.builder() .snapshotName(page.getSpec().getHeadSnapshot()) .raw("hello world") .content("

hello world

") .rawType("markdown") .build()) ); Snapshot snapshotV1 = snapshotV1(); Snapshot snapshotV2 = TestPost.snapshotV2(); snapshotV1.getSpec().setContributors(Set.of("guqing")); snapshotV2.getSpec().setContributors(Set.of("guqing", "zhangsan")); when(client.listAll(eq(Snapshot.class), any(), any())) .thenReturn(List.of(snapshotV1, snapshotV2)); when(externalUrlSupplier.get()).thenReturn(URI.create("")); when(extensionGetter.getEnabledExtension(eq(ExcerptGenerator.class))) .thenReturn(Mono.empty()); ArgumentCaptor captor = ArgumentCaptor.forClass(SinglePage.class); singlePageReconciler.reconcile(new Reconciler.Request(name)); verify(client, times(3)).update(captor.capture()); SinglePage value = captor.getValue(); assertThat(value.getStatus().getExcerpt()).isEqualTo("hello world"); assertThat(value.getStatus().getContributors()).isEqualTo(List.of("guqing", "zhangsan")); } @Test void createPermalink() { SinglePage page = pageV1(); page.getSpec().setSlug("page-slug"); when(externalUrlSupplier.get()).thenReturn(URI.create("")); String permalink = singlePageReconciler.createPermalink(page); assertThat(permalink).isEqualTo("/page-slug"); when(externalUrlSupplier.get()).thenReturn(URI.create("http://example.com")); permalink = singlePageReconciler.createPermalink(page); assertThat(permalink).isEqualTo("http://example.com/page-slug"); page.getSpec().setSlug("中文 slug"); permalink = singlePageReconciler.createPermalink(page); assertThat(permalink).isEqualTo("http://example.com/%E4%B8%AD%E6%96%87%20slug"); } @Nested class LastModifyTimeTest { @Test void reconcileLastModifyTimeWhenPageIsPublished() { String name = "page-A"; when(externalUrlSupplier.get()).thenReturn(URI.create("")); SinglePage page = pageV1(); page.getSpec().setPublish(true); page.getSpec().setHeadSnapshot("page-A-head-snapshot"); page.getSpec().setReleaseSnapshot("page-fake-released-snapshot"); when(client.fetch(eq(SinglePage.class), eq(name))) .thenReturn(Optional.of(page)); when(singlePageService.getContent(eq(page.getSpec().getReleaseSnapshot()), eq(page.getSpec().getBaseSnapshot()))) .thenReturn(Mono.just(ContentWrapper.builder() .snapshotName(page.getSpec().getHeadSnapshot()) .raw("hello world") .content("

hello world

") .rawType("markdown") .build()) ); Instant lastModifyTime = Instant.now(); Snapshot snapshotV2 = TestPost.snapshotV2(); snapshotV2.getSpec().setLastModifyTime(lastModifyTime); when(client.fetch(eq(Snapshot.class), eq(page.getSpec().getReleaseSnapshot()))) .thenReturn(Optional.of(snapshotV2)); when(extensionGetter.getEnabledExtension(eq(ExcerptGenerator.class))) .thenReturn(Mono.empty()); when(client.listAll(eq(Snapshot.class), any(), any())) .thenReturn(List.of()); ArgumentCaptor captor = ArgumentCaptor.forClass(SinglePage.class); singlePageReconciler.reconcile(new Reconciler.Request(name)); verify(client, times(4)).update(captor.capture()); SinglePage value = captor.getValue(); assertThat(value.getStatus().getLastModifyTime()).isEqualTo(lastModifyTime); } @Test void reconcileLastModifyTimeWhenPageIsNotPublished() { String name = "page-A"; when(externalUrlSupplier.get()).thenReturn(URI.create("")); SinglePage page = pageV1(); page.getSpec().setPublish(false); when(client.fetch(eq(SinglePage.class), eq(name))) .thenReturn(Optional.of(page)); when(singlePageService.getContent(eq(page.getSpec().getReleaseSnapshot()), eq(page.getSpec().getBaseSnapshot()))) .thenReturn(Mono.just(ContentWrapper.builder() .snapshotName(page.getSpec().getHeadSnapshot()) .raw("hello world") .content("

hello world

") .rawType("markdown") .build()) ); when(extensionGetter.getEnabledExtension(eq(ExcerptGenerator.class))) .thenReturn(Mono.empty()); when(client.listAll(eq(Snapshot.class), any(), any())) .thenReturn(List.of()); ArgumentCaptor captor = ArgumentCaptor.forClass(SinglePage.class); singlePageReconciler.reconcile(new Reconciler.Request(name)); verify(client, times(3)).update(captor.capture()); SinglePage value = captor.getValue(); assertThat(value.getStatus().getLastModifyTime()).isNull(); } } public static SinglePage pageV1() { SinglePage page = new SinglePage(); page.setKind(Post.KIND); page.setApiVersion("content.halo.run/v1alpha1"); Metadata metadata = new Metadata(); metadata.setName("page-A"); page.setMetadata(metadata); SinglePage.SinglePageSpec spec = new SinglePage.SinglePageSpec(); page.setSpec(spec); spec.setTitle("page-A"); spec.setSlug("page-slug"); spec.setBaseSnapshot(snapshotV1().getMetadata().getName()); spec.setHeadSnapshot("base-snapshot"); spec.setReleaseSnapshot(null); return page; } @Test void subscribeNewCommentNotificationTest() { var page = pageV1(); singlePageReconciler.subscribeNewCommentNotification(page); verify(notificationCenter).subscribe( assertArg(subscriber -> assertThat(subscriber.getName()) .isEqualTo(page.getSpec().getOwner())), assertArg(argReason -> { var interestReason = new Subscription.InterestReason(); interestReason.setReasonType(NotificationReasonConst.NEW_COMMENT_ON_PAGE); interestReason.setExpression("props.pageOwner == 'null'"); assertThat(argReason).isEqualTo(interestReason); })); } } ================================================ FILE: application/src/test/java/run/halo/app/core/reconciler/SystemConfigReconcilerTest.java ================================================ package run.halo.app.core.reconciler; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Instant; import java.util.HashMap; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import run.halo.app.core.extension.content.Constant; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.SystemConfigChangedEvent; import run.halo.app.infra.SystemSetting; @ExtendWith(MockitoExtension.class) class SystemConfigReconcilerTest { @Mock ExtensionClient client; @Mock ApplicationEventPublisher eventPublisher; @InjectMocks SystemConfigReconciler reconciler; ConfigMap systemConfigMap; ConfigMap defaultConfigMap; @BeforeEach void setUp() { systemConfigMap = createConfigMap(SystemSetting.SYSTEM_CONFIG); defaultConfigMap = createConfigMap(SystemSetting.SYSTEM_CONFIG_DEFAULT); } @Test void reconcileShouldDoNothingWhenConfigMapNotFound() { var request = new Reconciler.Request(SystemSetting.SYSTEM_CONFIG); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)) .thenReturn(Optional.empty()); reconciler.reconcile(request); verify(client, never()).update(any(ConfigMap.class)); verify(eventPublisher, never()).publishEvent(any()); } @Test void reconcileShouldDoNothingWhenConfigMapIsDeleted() { var request = new Reconciler.Request(SystemSetting.SYSTEM_CONFIG); systemConfigMap.getMetadata().setDeletionTimestamp(Instant.now()); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)) .thenReturn(Optional.of(systemConfigMap)); reconciler.reconcile(request); verify(client, never()).update(any(ConfigMap.class)); verify(eventPublisher, never()).publishEvent(any()); } @Test void reconcileShouldThrowExceptionForNonSystemConfig() { var request = new Reconciler.Request("other-config"); assertThrows(IllegalStateException.class, () -> reconciler.reconcile(request)); } @Test void reconcileShouldUpdateChecksumAndPublishEventWhenDataChanges() { var data = Map.of("key1", "value1", "key2", "value2"); systemConfigMap.setData(data); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)) .thenReturn(Optional.of(systemConfigMap)); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG_DEFAULT)) .thenReturn(Optional.of(defaultConfigMap)); var request = new Reconciler.Request(SystemSetting.SYSTEM_CONFIG); reconciler.reconcile(request); // Verify checksum annotation was added verify(client, times(1)).update(argThat(configMap -> { var annotations = configMap.getMetadata().getAnnotations(); return annotations != null && annotations.containsKey(Constant.CHECKSUM_CONFIG_ANNO) && annotations.containsKey("halo.run/data-snapshot"); })); // Verify event was published ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(SystemConfigChangedEvent.class); verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture()); var event = eventCaptor.getValue(); assertThat(event.getNewData()).isEqualTo(data); } @Test void reconcileShouldNotUpdateWhenChecksumUnchanged() { var data = Map.of("key1", "value1"); systemConfigMap.setData(data); // Pre-set checksum to match current data - sha256 of data.toString() var existingChecksum = com.google.common.hash.Hashing.sha256() .hashString(systemConfigMap.getData().toString(), java.nio.charset.StandardCharsets.UTF_8) .toString(); systemConfigMap.getMetadata().getAnnotations() .put(Constant.CHECKSUM_CONFIG_ANNO, existingChecksum); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)) .thenReturn(Optional.of(systemConfigMap)); var request = new Reconciler.Request(SystemSetting.SYSTEM_CONFIG); reconciler.reconcile(request); verify(client, never()).update(any(ConfigMap.class)); verify(eventPublisher, never()).publishEvent(any()); } @Test void reconcileShouldHandleNullData() { systemConfigMap.setData(null); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)) .thenReturn(Optional.of(systemConfigMap)); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG_DEFAULT)) .thenReturn(Optional.of(defaultConfigMap)); var request = new Reconciler.Request(SystemSetting.SYSTEM_CONFIG); reconciler.reconcile(request); // Should still update with checksum verify(client, times(1)).update(any(ConfigMap.class)); verify(eventPublisher, times(1)).publishEvent(any(SystemConfigChangedEvent.class)); } @Test void reconcileShouldMergeWithDefaultConfig() { var userData = Map.of("user.key", "user-value"); var defaultData = Map.of("default.key", "default-value"); systemConfigMap.setData(userData); defaultConfigMap.setData(defaultData); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)) .thenReturn(Optional.of(systemConfigMap)); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG_DEFAULT)) .thenReturn(Optional.of(defaultConfigMap)); var request = new Reconciler.Request(SystemSetting.SYSTEM_CONFIG); reconciler.reconcile(request); ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(SystemConfigChangedEvent.class); verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture()); var event = eventCaptor.getValue(); // New data should contain user data assertThat(event.getNewData()).containsEntry("user.key", "user-value"); } @Test void reconcileShouldHandleEmptyData() { systemConfigMap.setData(Map.of()); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)) .thenReturn(Optional.of(systemConfigMap)); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG_DEFAULT)) .thenReturn(Optional.of(defaultConfigMap)); var request = new Reconciler.Request(SystemSetting.SYSTEM_CONFIG); reconciler.reconcile(request); verify(client, times(1)).update(any(ConfigMap.class)); verify(eventPublisher, times(1)).publishEvent(any(SystemConfigChangedEvent.class)); } @Test void reconcileShouldPreserveDataSnapshotForComparison() { var oldData = Map.of("key1", "old-value"); var newData = Map.of("key1", "new-value"); // Set initial data and snapshot systemConfigMap.setData(oldData); systemConfigMap.getMetadata().getAnnotations() .put("halo.run/data-snapshot", "{\"key1\":\"old-value\"}"); // Update to new data systemConfigMap.setData(newData); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)) .thenReturn(Optional.of(systemConfigMap)); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG_DEFAULT)) .thenReturn(Optional.of(defaultConfigMap)); var request = new Reconciler.Request(SystemSetting.SYSTEM_CONFIG); reconciler.reconcile(request); ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(SystemConfigChangedEvent.class); verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture()); var event = eventCaptor.getValue(); assertThat(event.getOldData()).containsEntry("key1", "old-value"); assertThat(event.getNewData()).containsEntry("key1", "new-value"); } @Test void reconcileShouldHandleNoDefaultConfig() { var data = Map.of("key1", "value1"); systemConfigMap.setData(data); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)) .thenReturn(Optional.of(systemConfigMap)); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG_DEFAULT)) .thenReturn(Optional.empty()); var request = new Reconciler.Request(SystemSetting.SYSTEM_CONFIG); reconciler.reconcile(request); verify(client, times(1)).update(any(ConfigMap.class)); ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(SystemConfigChangedEvent.class); verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture()); var event = eventCaptor.getValue(); assertThat(event.getNewData()).isEqualTo(data); } private ConfigMap createConfigMap(String name) { var configMap = new ConfigMap(); var metadata = new Metadata(); metadata.setName(name); metadata.setAnnotations(new HashMap<>()); configMap.setMetadata(metadata); configMap.setData(new HashMap<>()); return configMap; } } ================================================ FILE: application/src/test/java/run/halo/app/core/reconciler/TagReconcilerTest.java ================================================ package run.halo.app.core.reconciler; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Instant; import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.content.permalinks.TagPermalinkPolicy; import run.halo.app.core.extension.content.Tag; import run.halo.app.core.reconciler.TagReconciler; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; /** * Tests for {@link TagReconciler}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class TagReconcilerTest { @Mock private ExtensionClient client; @Mock private TagPermalinkPolicy tagPermalinkPolicy; @InjectMocks private TagReconciler tagReconciler; @Test void reconcile() { Tag tag = tag(); when(client.fetch(eq(Tag.class), eq("fake-tag"))) .thenReturn(Optional.of(tag)); when(tagPermalinkPolicy.permalink(any())) .thenAnswer(arg -> "/tags/" + tag.getSpec().getSlug()); ArgumentCaptor captor = ArgumentCaptor.forClass(Tag.class); tagReconciler.reconcile(new TagReconciler.Request("fake-tag")); verify(client).update(captor.capture()); Tag capture = captor.getValue(); assertThat(capture.getStatus().getPermalink()).isEqualTo("/tags/fake-slug"); // change slug tag.getSpec().setSlug("new-slug"); tagReconciler.reconcile(new TagReconciler.Request("fake-tag")); verify(client, times(2)).update(captor.capture()); assertThat(capture.getStatus().getPermalink()).isEqualTo("/tags/new-slug"); } @Test void reconcileDelete() { Tag tag = tag(); tag.getMetadata().setDeletionTimestamp(Instant.now()); tag.getMetadata().setFinalizers(Set.of(TagReconciler.FINALIZER_NAME)); when(client.fetch(eq(Tag.class), eq("fake-tag"))) .thenReturn(Optional.of(tag)); ArgumentCaptor captor = ArgumentCaptor.forClass(Tag.class); tagReconciler.reconcile(new TagReconciler.Request("fake-tag")); verify(client, times(1)).update(captor.capture()); verify(tagPermalinkPolicy, times(0)).permalink(any()); } Tag tag() { Tag tag = new Tag(); tag.setMetadata(new Metadata()); tag.getMetadata().setVersion(0L); tag.getMetadata().setName("fake-tag"); tag.setSpec(new Tag.TagSpec()); tag.getSpec().setSlug("fake-slug"); tag.setStatus(new Tag.TagStatus()); return tag; } } ================================================ FILE: application/src/test/java/run/halo/app/core/reconciler/ThemeReconcilerTest.java ================================================ package run.halo.app.core.reconciler; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.github.zafarkhaja.semver.Version; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.time.Instant; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import org.json.JSONException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.stubbing.Answer; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.core.retry.RetryException; import org.springframework.core.retry.RetryPolicy; import org.springframework.core.retry.RetryTemplate; import org.springframework.util.FileSystemUtils; import org.springframework.util.ResourceUtils; import reactor.core.Exceptions; import reactor.core.publisher.Mono; import run.halo.app.core.extension.AnnotationSetting; import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Theme; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.RequeueException; import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.infra.ThemeRootGetter; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.theme.TemplateEngineManager; /** * Tests for {@link ThemeReconciler}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class ThemeReconcilerTest { @Mock private ExtensionClient extensionClient; @Mock private SystemVersionSupplier systemVersionSupplier; @Mock ThemeRootGetter themeRoot; @Mock private File defaultTheme; @Mock private TemplateEngineManager templateEngineManager; @Spy RetryTemplate retryTemplate = new RetryTemplate(RetryPolicy.builder() .maxRetries(1) .delay(Duration.ZERO) .predicate(IllegalStateException.class::isInstance) .build()); @InjectMocks ThemeReconciler themeReconciler; @TempDir private Path tempDirectory; @BeforeEach void setUp() throws IOException { themeReconciler.setRetryTemplate(retryTemplate); defaultTheme = ResourceUtils.getFile("classpath:themes/default"); lenient().when(systemVersionSupplier.get()).thenReturn(Version.parse("0.0.0")); lenient().when(templateEngineManager.clearCache(any())).thenReturn(Mono.empty()); } @Test void reconcileDelete() throws IOException, RetryException { Path testWorkDir = tempDirectory.resolve("reconcile-delete"); Files.createDirectory(testWorkDir); when(themeRoot.get()).thenReturn(testWorkDir); Theme theme = new Theme(); Metadata metadata = new Metadata(); metadata.setName("theme-test"); metadata.setFinalizers(new HashSet<>()); metadata.getFinalizers().add("theme-protection"); metadata.setDeletionTimestamp(Instant.now()); theme.setMetadata(metadata); theme.setKind(Theme.KIND); theme.setApiVersion("theme.halo.run/v1alpha1"); Theme.ThemeSpec themeSpec = new Theme.ThemeSpec(); themeSpec.setSettingName("theme-test-setting"); theme.setSpec(themeSpec); Path defaultThemePath = testWorkDir.resolve("theme-test"); // copy to temp directory FileSystemUtils.copyRecursively(defaultTheme.toPath(), defaultThemePath); assertThat(testWorkDir).isNotEmptyDirectory(); assertThat(defaultThemePath).exists(); when(extensionClient.fetch(eq(Theme.class), eq(metadata.getName()))) .thenReturn(Optional.of(theme)); when(extensionClient.fetch(Setting.class, themeSpec.getSettingName())) .thenReturn(Optional.empty()); themeReconciler.reconcile(new Reconciler.Request(metadata.getName())); verify(extensionClient, times(1)).fetch(eq(Theme.class), eq(metadata.getName())); verify(extensionClient, times(2)).fetch(eq(Setting.class), eq(themeSpec.getSettingName())); verify(extensionClient, times(2)).list(eq(AnnotationSetting.class), any(), any()); assertThat(Files.exists(testWorkDir)).isTrue(); assertThat(Files.exists(defaultThemePath)).isFalse(); } @Test void reconcileDeleteRetry() { var theme = fakeTheme(); var metadata = theme.getMetadata(); metadata.setDeletionTimestamp(Instant.now()); metadata.setFinalizers(new HashSet<>()); metadata.getFinalizers().add("theme-protection"); when(extensionClient.fetch(Theme.class, "theme-test")).thenReturn(Optional.of(theme)); Path testWorkDir = tempDirectory.resolve("reconcile-delete"); when(themeRoot.get()).thenReturn(testWorkDir); final ThemeReconciler themeReconciler = new ThemeReconciler(extensionClient, themeRoot, systemVersionSupplier, templateEngineManager); final int[] retryFlags = {0, 0}; when(extensionClient.fetch(eq(Setting.class), eq("theme-test-setting"))) .thenAnswer((Answer>) invocation -> { retryFlags[0]++; // retry 2 times if (retryFlags[0] < 3) { return Optional.of(new Setting()); } return Optional.empty(); }); when(extensionClient.list(eq(AnnotationSetting.class), any(), eq(null))) .thenAnswer((Answer>) invocation -> { retryFlags[1]++; // retry 2 times if (retryFlags[1] < 3) { return List.of(new AnnotationSetting()); } return List.of(); }); themeReconciler.reconcile(new Reconciler.Request(metadata.getName())); String settingName = theme.getSpec().getSettingName(); verify(extensionClient, times(1)).fetch(eq(Theme.class), eq(metadata.getName())); verify(extensionClient, times(3)).fetch(eq(Setting.class), eq(settingName)); verify(extensionClient, times(3)).list(eq(AnnotationSetting.class), any(), eq(null)); verify(templateEngineManager).clearCache(eq(metadata.getName())); } @Test void reconcileDeleteRetryWhenThrowException() { var theme = fakeTheme(); theme.getMetadata().setDeletionTimestamp(Instant.now()); theme.getMetadata().setFinalizers(new HashSet<>()); theme.getMetadata().getFinalizers().add("theme-protection"); when(extensionClient.fetch(Theme.class, "theme-test")).thenReturn(Optional.of(theme)); when(extensionClient.fetch(Setting.class, "theme-test-setting")) .thenReturn(Optional.of(new Setting())); String settingName = theme.getSpec().getSettingName(); assertThatThrownBy( () -> themeReconciler.reconcile(new Reconciler.Request(theme.getMetadata().getName()))) .satisfies(t -> { var e = Exceptions.unwrap(t); assertThat(e).isInstanceOf(RetryException.class); }); verify(extensionClient, times(3)).fetch(eq(Setting.class), eq(settingName)); } @Test void shouldBeFailedIfVersionNotSatisfied() { when(systemVersionSupplier.get()).thenReturn(Version.parse("2.3.0")); var testWorkDir = tempDirectory.resolve("reconcile-delete"); when(themeRoot.get()).thenReturn(testWorkDir); var theme = fakeTheme(); theme.setStatus(null); theme.getSpec().setRequires(">2.3.0"); theme.getSpec().setSettingName(null); when(extensionClient.fetch(Theme.class, "theme-test")) .thenReturn(Optional.of(theme)); var themeReconciler = new ThemeReconciler( extensionClient, themeRoot, systemVersionSupplier, templateEngineManager ); themeReconciler.reconcile(new Reconciler.Request(theme.getMetadata().getName())); var themeUpdateCaptor = ArgumentCaptor.forClass(Theme.class); verify(extensionClient).update(themeUpdateCaptor.capture()); Theme value = themeUpdateCaptor.getValue(); assertThat(value.getStatus()).isNotNull(); assertThat(value.getStatus().getConditions().peekFirst().getType()) .isEqualTo(Theme.ThemePhase.FAILED.name()); assertThat(value.getStatus().getPhase()) .isEqualTo(Theme.ThemePhase.FAILED); } @Test void shouldBeReadyIfVersionSatisfied() { when(systemVersionSupplier.get()).thenReturn(Version.parse("2.3.0")); var testWorkDir = tempDirectory.resolve("reconcile-delete"); when(themeRoot.get()).thenReturn(testWorkDir); var theme = fakeTheme(); theme.setStatus(null); theme.getSpec().setRequires(">=2.3.0"); theme.getSpec().setSettingName(null); when(extensionClient.fetch(Theme.class, "theme-test")) .thenReturn(Optional.of(theme)); var themeReconciler = new ThemeReconciler( extensionClient, themeRoot, systemVersionSupplier, templateEngineManager ); var themeUpdateCaptor = ArgumentCaptor.forClass(Theme.class); themeReconciler.reconcile(new Reconciler.Request(theme.getMetadata().getName())); verify(extensionClient).update(themeUpdateCaptor.capture()); assertThat(themeUpdateCaptor.getValue().getStatus().getPhase()) .isEqualTo(Theme.ThemePhase.READY); } private Theme fakeTheme() { Theme theme = new Theme(); Metadata metadata = new Metadata(); metadata.setName("theme-test"); theme.setMetadata(metadata); theme.setKind(Theme.KIND); theme.setApiVersion("theme.halo.run/v1alpha1"); Theme.ThemeSpec themeSpec = new Theme.ThemeSpec(); themeSpec.setSettingName("theme-test-setting"); theme.setSpec(themeSpec); return theme; } @Test void themeSettingDefaultValue() throws IOException, JSONException { Path testWorkDir = tempDirectory.resolve("reconcile-setting-value"); Files.createDirectory(testWorkDir); when(themeRoot.get()).thenReturn(testWorkDir); Theme theme = new Theme(); Metadata metadata = new Metadata(); metadata.setName("theme-test"); theme.setMetadata(metadata); theme.setKind(Theme.KIND); theme.setApiVersion("theme.halo.run/v1alpha1"); Theme.ThemeSpec themeSpec = new Theme.ThemeSpec(); themeSpec.setSettingName(null); theme.setSpec(themeSpec); when(extensionClient.fetch(eq(Theme.class), eq(metadata.getName()))) .thenReturn(Optional.of(theme)); var reconcile = themeReconciler.reconcile(new Reconciler.Request(metadata.getName())); assertThat(reconcile.reEnqueue()).isFalse(); verify(extensionClient, times(1)).fetch(eq(Theme.class), eq(metadata.getName())); // setting exists themeSpec.setSettingName("theme-test-setting"); assertThat(theme.getSpec().getConfigMapName()).isNull(); ArgumentCaptor captor = ArgumentCaptor.forClass(Theme.class); Assertions.assertThrows(RequeueException.class, () -> themeReconciler.reconcile(new Reconciler.Request(metadata.getName())) ); verify(extensionClient, times(2)).fetch(eq(Theme.class), eq(metadata.getName())); verify(extensionClient).update(captor.capture()); Theme value = captor.getValue(); assertThat(value.getSpec().getConfigMapName()).isNotNull(); // populate setting name and configMap name and configMap not exists themeSpec.setSettingName("theme-test-setting"); themeSpec.setConfigMapName("theme-test-configmap"); when(extensionClient.fetch(eq(ConfigMap.class), any())) .thenReturn(Optional.empty()); when(extensionClient.fetch(eq(Setting.class), eq(themeSpec.getSettingName()))) .thenReturn(Optional.of(getFakeSetting())); themeReconciler.reconcile(new Reconciler.Request(metadata.getName())); verify(extensionClient, times(2)) .fetch(eq(Setting.class), eq(themeSpec.getSettingName())); ArgumentCaptor configMapCaptor = ArgumentCaptor.forClass(ConfigMap.class); verify(extensionClient, times(1)).create(any(ConfigMap.class)); verify(extensionClient, times(1)).create(configMapCaptor.capture()); ConfigMap defaultValueConfigMap = configMapCaptor.getValue(); Map data = defaultValueConfigMap.getData(); JSONAssert.assertEquals(""" { "sns": "{\\"email\\":\\"example@exmple.com\\"}" } """, JsonUtils.objectToJson(data), true); } private static Setting getFakeSetting() { String settingJson = """ { "apiVersion": "v1alpha1", "kind": "Setting", "metadata": { "name": "theme-default-setting" }, "spec": { "forms": [{ "formSchema": [ { "$el": "h1", "children": "Register" }, { "$formkit": "text", "label": "Email", "name": "email", "value": "example@exmple.com" }, { "$formkit": "password", "label": "Password", "name": "password", "validation": "required|length:5,16", "value": null } ], "group": "sns", "label": "社交资料" }] } } """; return JsonUtils.jsonToObject(settingJson, Setting.class); } } ================================================ FILE: application/src/test/java/run/halo/app/core/reconciler/UserReconcilerTest.java ================================================ package run.halo.app.core.reconciler; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.net.URI; import java.net.URISyntaxException; import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.core.reconciler.UserReconciler; import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.notification.NotificationCenter; /** * Tests for {@link UserReconciler}. * * @author guqing * @since 2.0.1 */ @ExtendWith(MockitoExtension.class) class UserReconcilerTest { @Mock private ExternalUrlSupplier externalUrlSupplier; @Mock private ExtensionClient client; @Mock private NotificationCenter notificationCenter; @Mock private RoleService roleService; @InjectMocks private UserReconciler userReconciler; @BeforeEach void setUp() { lenient().when(notificationCenter.unsubscribe(any(), any())).thenReturn(Mono.empty()); } @Test void permalinkForFakeUser() throws URISyntaxException { when(externalUrlSupplier.get()).thenReturn(new URI("http://localhost:8090")); when(roleService.getRolesByUsername("fake-user")) .thenReturn(Flux.empty()); when(client.fetch(eq(User.class), eq("fake-user"))) .thenReturn(Optional.of(user("fake-user"))); userReconciler.reconcile(new Reconciler.Request("fake-user")); verify(client).update(assertArg(user -> assertEquals( "http://localhost:8090/authors/fake-user", user.getStatus().getPermalink() ) )); } @Test void permalinkForAnonymousUser() { when(client.fetch(eq(User.class), eq(AnonymousUserConst.PRINCIPAL))) .thenReturn(Optional.of(user(AnonymousUserConst.PRINCIPAL))); when(roleService.getRolesByUsername(AnonymousUserConst.PRINCIPAL)).thenReturn(Flux.empty()); userReconciler.reconcile(new Reconciler.Request(AnonymousUserConst.PRINCIPAL)); verify(client).update(any(User.class)); } @Test void ensureRoleNamesAnno() { when(roleService.getRolesByUsername("fake-user")).thenReturn(Flux.just("fake-role")); when(client.fetch(eq(User.class), eq("fake-user"))) .thenReturn(Optional.of(user("fake-user"))); when(externalUrlSupplier.get()).thenReturn(URI.create("/")); userReconciler.reconcile(new Reconciler.Request("fake-user")); verify(client).update(assertArg(user -> { assertEquals(""" ["fake-role"]\ """, user.getMetadata().getAnnotations().get(User.ROLE_NAMES_ANNO)); })); } User user(String name) { User user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName(name); user.getMetadata().setFinalizers(Set.of("user-protection")); user.setSpec(new User.UserSpec()); return user; } } ================================================ FILE: application/src/test/java/run/halo/app/core/user/service/DefaultRoleServiceTest.java ================================================ package run.halo.app.core.user.service; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Predicate; import java.util.stream.Stream; import org.assertj.core.api.AssertionsForInterfaceTypes; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Sort; import reactor.core.publisher.Flux; import reactor.test.StepVerifier; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.user.service.DefaultRoleService; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link DefaultRoleService}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class DefaultRoleServiceTest { @Mock private ReactiveExtensionClient client; @InjectMocks private DefaultRoleService roleService; @ParameterizedTest @MethodSource("usernamesProvider") void shouldReturnEmptyMapIfNoUsernamesProvided(Collection usernames) { roleService.getRolesByUsernames(usernames) .as(StepVerifier::create) .expectNext(Map.of()) .verifyComplete(); } static Stream> usernamesProvider() { return Stream.of(null, List.of(), Set.of()); } @Nested class ListDependenciesTest { @Test void listDependencies() { // prepare test data var role1 = createRole("role1", "role2"); var role2 = createRole("role2", "role3"); var role3 = createRole("role3"); var roleNames = Set.of("role1"); when(client.listAll(same(Role.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.just(role1)) .thenReturn(Flux.just(role2)) .thenReturn(Flux.just(role3)) .thenReturn(Flux.empty()); // call the method under test var result = roleService.listDependenciesFlux(roleNames); // verify the result StepVerifier.create(result) .expectNext(role1) .expectNext(role2) .expectNext(role3) .verifyComplete(); // verify the mock invocations verify(client, times(4)).listAll( same(Role.class), any(ListOptions.class), any(Sort.class) ); } @Test void listDependenciesWithCycle() { // prepare test data var role1 = createRole("role1", "role2"); var role2 = createRole("role2", "role3"); var role3 = createRole("role3", "role1"); var roleNames = Set.of("role1"); // setup mocks when(client.listAll(same(Role.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.just(role1)) .thenReturn(Flux.just(role2)) .thenReturn(Flux.just(role3)) .thenReturn(Flux.empty()); // call the method under test var result = roleService.listDependenciesFlux(roleNames); // verify the result StepVerifier.create(result) .expectNext(role1) .expectNext(role2) .expectNext(role3) .verifyComplete(); // verify the mock invocations verify(client, times(4)).listAll( same(Role.class), any(ListOptions.class), any(Sort.class) ); } @Test void listDependenciesWithMiddleCycle() { // prepare test data // role1 -> role2 -> role3 -> role4 // \<-----| var role1 = createRole("role1", "role2"); var role2 = createRole("role2", "role3"); var role3 = createRole("role3", "role2", "role4"); var role4 = createRole("role4"); var roleNames = Set.of("role1"); when(client.listAll(same(Role.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.just(role1)) .thenReturn(Flux.just(role2)) .thenReturn(Flux.just(role3)) .thenReturn(Flux.just(role4)) .thenReturn(Flux.empty()); // call the method under test var result = roleService.listDependenciesFlux(roleNames); // verify the result StepVerifier.create(result) .expectNext(role1) .expectNext(role2) .expectNext(role3) .expectNext(role4) .verifyComplete(); // verify the mock invocations verify(client, times(5)).listAll( same(Role.class), any(ListOptions.class), any(Sort.class) ); } @Test void listDependenciesWithCycleAndSequence() { // prepare test data // role1 -> role2 -> role3 // \->role4 \<-----| Role role1 = createRole("role1", "role4", "role2"); Role role2 = createRole("role2", "role3"); Role role3 = createRole("role3", "role2"); Role role4 = createRole("role4"); Set roleNames = Set.of("role1"); when(client.listAll(same(Role.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.just(role1)) .thenReturn(Flux.just(role4, role2)) .thenReturn(Flux.just(role3)) .thenReturn(Flux.empty()); // call the method under test var result = roleService.listDependenciesFlux(roleNames); // verify the result StepVerifier.create(result) .expectNext(role1) .expectNext(role4) .expectNext(role2) .expectNext(role3) .verifyComplete(); // verify the mock invocations verify(client, times(4)).listAll(same(Role.class), any(), any()); } @Test void listDependenciesAfterCycle() { // prepare test data // role1 -> role2 -> role3 // \->role4 \<-----| Role role1 = createRole("role1", "role4", "role2"); Role role2 = createRole("role2", "role3"); Role role3 = createRole("role3", "role2"); Role role4 = createRole("role4"); Set roleNames = Set.of("role2"); when(client.listAll(same(Role.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.just(role2)) .thenReturn(Flux.just(role3)) .thenReturn(Flux.empty()); // call the method under test var result = roleService.listDependenciesFlux(roleNames); // verify the result StepVerifier.create(result) .expectNext(role2) .expectNext(role3) .verifyComplete(); // verify the mock invocations verify(client, times(3)).listAll( same(Role.class), any(ListOptions.class), any(Sort.class) ); } @Test void listDependenciesWithNullParam() { var result = roleService.listDependenciesFlux(null); // verify the result StepVerifier.create(result) .verifyComplete(); result = roleService.listDependenciesFlux(Set.of()); StepVerifier.create(result) .verifyComplete(); // verify the mock invocations verify(client, never()).listAll( eq(Role.class), any(ListOptions.class), any(Sort.class) ); } @Test void listDependenciesAndSomeOneNotFound() { var role1 = createRole("role1", "role2"); var role2 = createRole("role2", "role3", "role4"); var role4 = createRole("role4"); var roleNames = Set.of("role1"); when(client.listAll(same(Role.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.just(role1)) .thenReturn(Flux.just(role2)) .thenReturn(Flux.just(role4)) .thenReturn(Flux.empty()) ; var result = roleService.listDependenciesFlux(roleNames); // verify the result StepVerifier.create(result) .expectNext(role1) .expectNext(role2) .expectNext(role4) .verifyComplete(); // verify the mock invocations verify(client, times(4)).listAll( same(Role.class), any(ListOptions.class), any(Sort.class) ); } @Test void testSubjectMatch() { RoleBinding fakeAuthenticatedBinding = createRoleBinding("authenticated-fake-binding", "fake", "authenticated"); RoleBinding fakeEditorBinding = createRoleBinding("editor-fake-binding", "fake", "editor"); RoleBinding fakeAnonymousBinding = createRoleBinding("test-anonymous-binding", "test", "anonymous"); RoleBinding.Subject subject = new RoleBinding.Subject(); subject.setName("authenticated"); subject.setKind(Role.KIND); subject.setApiGroup(Role.GROUP); Predicate predicate = roleService.getRoleBindingPredicate(subject); List result = Stream.of(fakeAuthenticatedBinding, fakeEditorBinding, fakeAnonymousBinding) .filter(predicate) .toList(); AssertionsForInterfaceTypes.assertThat(result) .containsExactly(fakeAuthenticatedBinding); subject.setName("editor"); predicate = roleService.getRoleBindingPredicate(subject); result = Stream.of(fakeAuthenticatedBinding, fakeEditorBinding, fakeAnonymousBinding) .filter(predicate) .toList(); AssertionsForInterfaceTypes.assertThat(result).containsExactly(fakeEditorBinding); } RoleBinding createRoleBinding(String name, String refName, String subjectName) { RoleBinding roleBinding = new RoleBinding(); roleBinding.setMetadata(new Metadata()); roleBinding.getMetadata().setName(name); roleBinding.setRoleRef(new RoleBinding.RoleRef()); roleBinding.getRoleRef().setKind(Role.KIND); roleBinding.getRoleRef().setApiGroup(Role.GROUP); roleBinding.getRoleRef().setName(refName); roleBinding.setSubjects(List.of(new RoleBinding.Subject())); roleBinding.getSubjects().get(0).setKind(Role.KIND); roleBinding.getSubjects().get(0).setName(subjectName); roleBinding.getSubjects().get(0).setApiGroup(Role.GROUP); return roleBinding; } private Role createRole(String name, String... dependencies) { Role role = new Role(); role.setMetadata(new Metadata()); role.getMetadata().setName(name); Map annotations = new HashMap<>(); annotations.put(Role.ROLE_DEPENDENCIES_ANNO, JsonUtils.objectToJson(dependencies)); role.getMetadata().setAnnotations(annotations); return role; } } } ================================================ FILE: application/src/test/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImplTest.java ================================================ package run.halo.app.core.user.service.impl; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; /** * Tests for {@link EmailPasswordRecoveryServiceImpl}. * * @author guqing * @since 2.11.0 */ @ExtendWith(MockitoExtension.class) class EmailPasswordRecoveryServiceImplTest { } ================================================ FILE: application/src/test/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImplTest.java ================================================ package run.halo.app.core.user.service.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static run.halo.app.core.user.service.impl.EmailVerificationServiceImpl.MAX_ATTEMPTS; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.core.user.service.impl.EmailVerificationServiceImpl.EmailVerificationManager.UsernameEmail; import run.halo.app.infra.exception.EmailVerificationFailed; /** * Tests for {@link EmailVerificationServiceImpl}. * * @author guqing * @since 2.11.0 */ @ExtendWith(MockitoExtension.class) class EmailVerificationServiceImplTest { @Nested class EmailVerificationManagerTest { @Test public void generateCodeTest() { var emailVerificationManager = new EmailVerificationServiceImpl.EmailVerificationManager(); emailVerificationManager.generateCode("fake-user", "fake-email"); var result = emailVerificationManager.contains("fake-user", "fake-email"); assertThat(result).isTrue(); emailVerificationManager.generateCode("guqing", "hi@halo.run"); result = emailVerificationManager.contains("guqing", "hi@halo.run"); assertThat(result).isTrue(); result = emailVerificationManager.contains("123", "123"); assertThat(result).isFalse(); } @Test public void removeTest() { var emailVerificationManager = new EmailVerificationServiceImpl.EmailVerificationManager(); emailVerificationManager.generateCode("fake-user", "fake-email"); var result = emailVerificationManager.contains("fake-user", "fake-email"); emailVerificationManager.removeCode("fake-user", "fake-email"); result = emailVerificationManager.contains("fake-user", "fake-email"); assertThat(result).isFalse(); } @Test void verifyCodeTestNormal() { String username = "guqing"; String email = "hi@halo.run"; var emailVerificationManager = new EmailVerificationServiceImpl.EmailVerificationManager(); var result = emailVerificationManager.verifyCode(username, email, "fake-code"); assertThat(result).isFalse(); var code = emailVerificationManager.generateCode(username, email); result = emailVerificationManager.verifyCode(username, email, "fake-code"); assertThat(result).isFalse(); result = emailVerificationManager.verifyCode(username, email, code); assertThat(result).isTrue(); } @Test void verifyCodeFailedAfterMaxAttempts() { String username = "guqing"; String email = "example@example.com"; var emailVerificationManager = new EmailVerificationServiceImpl.EmailVerificationManager(); var code = emailVerificationManager.generateCode(username, email); for (int i = 0; i <= MAX_ATTEMPTS; i++) { var result = emailVerificationManager.verifyCode(username, email, "fake-code"); assertThat(result).isFalse(); } assertThatThrownBy(() -> emailVerificationManager.verifyCode(username, email, code)) .isInstanceOf(EmailVerificationFailed.class) .hasMessage("400 BAD_REQUEST \"Too many attempts. Please try again later.\""); } } @Test void shouldBeEqualUsernameEmailWithDifferentCase() { var expected = new UsernameEmail("faker", "a@b.com"); var got = new UsernameEmail("faker", "A@B.com"); assertEquals(expected, got); } } ================================================ FILE: application/src/test/java/run/halo/app/core/user/service/impl/UserServiceImplTest.java ================================================ package run.halo.app.core.user.service.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static run.halo.app.extension.GroupVersionKind.fromExtension; import java.util.HashMap; import java.util.List; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Sort; import org.springframework.security.core.session.ReactiveSessionInformation; import org.springframework.security.core.session.ReactiveSessionRegistry; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.transaction.ReactiveTransaction; import org.springframework.transaction.ReactiveTransactionManager; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.RoleBinding.Subject; import run.halo.app.core.extension.User; import run.halo.app.core.user.service.EmailVerificationService; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.SignUpData; import run.halo.app.core.user.service.UserPostCreatingHandler; import run.halo.app.core.user.service.UserPreCreatingHandler; import run.halo.app.core.user.service.UserService; import run.halo.app.event.user.PasswordChangedEvent; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.exception.ExtensionNotFoundException; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.exception.DuplicateNameException; import run.halo.app.infra.exception.EmailAlreadyTakenException; import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.exception.UserNotFoundException; import run.halo.app.plugin.extensionpoint.ExtensionGetter; @ExtendWith(MockitoExtension.class) class UserServiceImplTest { @Mock ReactiveExtensionClient client; @Mock SystemConfigFetcher environmentFetcher; @Mock PasswordEncoder passwordEncoder; @Mock ApplicationEventPublisher eventPublisher; @Mock RoleService roleService; @Mock ExtensionGetter extensionGetter; @Mock EmailVerificationService emailVerificationService; @Mock ReactiveTransactionManager txManager; @Mock ReactiveSessionRegistry sessionRegistry; @InjectMocks UserServiceImpl userService; @Test void shouldThrowExceptionIfUserNotFoundInExtension() { when(client.get(eq(User.class), eq("faker"))).thenReturn( Mono.error(new ExtensionNotFoundException(fromExtension(User.class), "faker"))); StepVerifier.create(userService.getUser("faker")) .verifyError(UserNotFoundException.class); verify(client, times(1)).get(eq(User.class), eq("faker")); } @Test void shouldGetUserIfUserFoundInExtension() { User fakeUser = new User(); when(client.get(User.class, "faker")).thenReturn(Mono.just(fakeUser)); StepVerifier.create(userService.getUser("faker")) .assertNext(user -> assertEquals(fakeUser, user)) .verifyComplete(); verify(client, times(1)).get(eq(User.class), eq("faker")); } @Test void shouldFindUserByVerifiedEmail() { var fakeUser = createUser("fake-user", "fake-password"); when(client.listAll(eq(User.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.just(fakeUser)); userService.findUserByVerifiedEmail("faker@halo.run") .as(StepVerifier::create) .expectNext(fakeUser) .verifyComplete(); } @Test void shouldReturnEmptyIfNoUserWithVerifiedEmail() { when(client.listAll(eq(User.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.empty()); userService.findUserByVerifiedEmail("faker@halo.run") .as(StepVerifier::create) .verifyComplete(); } @Test void shouldGetGhostsIfUsersContainDeleted() { var fakeUser1 = createUser("fake-user1", "fake-password"); var fakeUser2 = createUser("fake-user2", "fake-password"); var ghost = createUser(UserService.GHOST_USER_NAME, "fake-password"); when(client.listAll(eq(User.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.just(fakeUser1, fakeUser2, ghost)); userService.getUsersOrGhosts(List.of("fake-user1", "deleted-user", "fake-user2")) .as(StepVerifier::create) .expectNext(fakeUser1, ghost, fakeUser2) .verifyComplete(); } @Test void shouldUpdatePasswordIfUserFoundInExtension() { var fakeUser = new User(); fakeUser.setSpec(new User.UserSpec()); when(client.get(User.class, "faker")).thenReturn(Mono.just(fakeUser)); when(client.update(eq(fakeUser))).thenReturn(Mono.just(fakeUser)); StepVerifier.create(userService.updatePassword("faker", "new-fake-password")) .expectNext(fakeUser) .verifyComplete(); verify(client, times(1)).get(eq(User.class), eq("faker")); verify(client, times(1)).update(argThat(extension -> { var user = (User) extension; return "new-fake-password".equals(user.getSpec().getPassword()); })); verify(eventPublisher).publishEvent(any(PasswordChangedEvent.class)); } @Nested @DisplayName("UpdateWithRawPassword") class UpdateWithRawPasswordTest { @Test void shouldUpdatePasswordWithDifferentPassword() { var oldUser = createUser("fake@password"); var newUser = createUser("new@password"); when(client.get(User.class, "fake-user")).thenReturn( Mono.just(oldUser)); when(client.update(eq(oldUser))).thenReturn(Mono.just(newUser)); when(passwordEncoder.matches("new@password", "fake@password")).thenReturn(false); when(passwordEncoder.encode("new@password")).thenReturn("encoded@new@password"); StepVerifier.create(userService.updateWithRawPassword("fake-user", "new@password")) .expectNext(newUser) .verifyComplete(); verify(passwordEncoder).matches("new@password", "fake@password"); verify(passwordEncoder).encode("new@password"); verify(client).get(User.class, "fake-user"); verify(client).update(argThat(extension -> { var user = (User) extension; return "encoded@new@password".equals(user.getSpec().getPassword()); })); verify(eventPublisher).publishEvent(any(PasswordChangedEvent.class)); } @Test void shouldUpdatePasswordIfNoPasswordBefore() { var oldUser = createUser(null); var newUser = createUser("new@password"); when(client.get(User.class, "fake-user")).thenReturn(Mono.just(oldUser)); when(client.update(oldUser)).thenReturn(Mono.just(newUser)); when(passwordEncoder.encode("new@password")).thenReturn("encoded@new@password"); StepVerifier.create(userService.updateWithRawPassword("fake-user", "new@password")) .expectNext(newUser) .verifyComplete(); verify(passwordEncoder, never()).matches("new@password", null); verify(passwordEncoder).encode("new@password"); verify(client).update(argThat(extension -> { var user = (User) extension; return "encoded@new@password".equals(user.getSpec().getPassword()); })); verify(client).get(User.class, "fake-user"); verify(eventPublisher).publishEvent(any(PasswordChangedEvent.class)); } @Test void shouldDoNothingIfPasswordNotChanged() { userService = spy(userService); var oldUser = createUser("fake@password"); var newUser = createUser("new@password"); when(client.get(User.class, "fake-user")).thenReturn(Mono.just(oldUser)); when(passwordEncoder.matches("fake@password", "fake@password")).thenReturn(true); StepVerifier.create(userService.updateWithRawPassword("fake-user", "fake@password")) .expectNextCount(0) .verifyComplete(); verify(passwordEncoder, times(1)).matches("fake@password", "fake@password"); verify(passwordEncoder, never()).encode(any()); verify(client, never()).update(any()); verify(client).get(User.class, "fake-user"); verify(eventPublisher, times(0)).publishEvent(any(PasswordChangedEvent.class)); } @Test void shouldThrowExceptionIfUserNotFound() { when(client.get(eq(User.class), eq("fake-user"))) .thenReturn(Mono.error( new ExtensionNotFoundException(fromExtension(User.class), "fake-user"))); StepVerifier.create(userService.updateWithRawPassword("fake-user", "new@password")) .verifyError(UserNotFoundException.class); verify(passwordEncoder, never()).matches(anyString(), anyString()); verify(passwordEncoder, never()).encode(anyString()); verify(client, never()).update(any()); verify(client).get(User.class, "fake-user"); } @Test void shouldThrowWhenPwdContainsInvalidChars() { StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password")) .expectError(UnsatisfiedAttributeValueException.class) .verify(); verify(passwordEncoder, never()).encode(anyString()); verify(client, never()).update(any()); } } User createUser(String username, String password) { var user = new User(); Metadata metadata = new Metadata(); metadata.setName(username); user.setMetadata(metadata); user.setSpec(new User.UserSpec()); user.getSpec().setPassword(password); return user; } User createUser(String password) { return createUser("fake-user", password); } @Nested class GrantRolesTest { @BeforeEach void setUp() { var tx = mock(ReactiveTransaction.class); when(txManager.getReactiveTransaction(any())) .thenReturn(Mono.just(tx)); when(txManager.commit(tx)).thenReturn(Mono.empty()); } @Test void shouldGetNotFoundIfUserNotFound() { when(client.get(User.class, "invalid-user")) .thenReturn(Mono.error( new ExtensionNotFoundException(fromExtension(User.class), "invalid-user")) ); when(roleService.listRoleBindings(any())).thenReturn(Flux.empty()); when(client.create(isA(RoleBinding.class))) .thenReturn(Mono.just(mock(RoleBinding.class))); when(sessionRegistry.getAllSessions("invalid-user")).thenReturn(Flux.empty()); var grantRolesMono = userService.grantRoles("invalid-user", Set.of("fake-role")); StepVerifier.create(grantRolesMono) .expectError(ExtensionNotFoundException.class) .verify(); verify(client).get(User.class, "invalid-user"); } @Test void shouldCreateRoleBindingIfNotExist() { var user = createUser("fake-password"); when(client.get(User.class, "fake-user")) .thenReturn(Mono.just(user)); when(roleService.listRoleBindings(any(Subject.class))).thenReturn(Flux.empty()); when(client.create(isA(RoleBinding.class))).thenReturn( Mono.just(mock(RoleBinding.class))); when(client.update(user)).thenReturn(Mono.just(user)); var session = mock(ReactiveSessionInformation.class); when(session.invalidate()).thenReturn(Mono.empty()); when(sessionRegistry.getAllSessions("fake-user")).thenReturn(Flux.just(session)); var grantRolesMono = userService.grantRoles("fake-user", Set.of("fake-role")); StepVerifier.create(grantRolesMono) .expectNextCount(1) .verifyComplete(); verify(client).create(isA(RoleBinding.class)); } @Test void shouldDeleteRoleBindingIfNotProvided() { var notProvidedRoleBinding = RoleBinding.create("fake-user", "non-provided-fake-role"); var existingRoleBinding = RoleBinding.create("fake-user", "fake-role"); when(roleService.listRoleBindings(any(Subject.class))) .thenReturn(Flux.just(notProvidedRoleBinding, existingRoleBinding)); when(client.delete(isA(RoleBinding.class))) .thenReturn(Mono.just(mock(RoleBinding.class))); when(sessionRegistry.getAllSessions("fake-user")).thenReturn(Flux.empty()); var user = createUser("fake-password"); when(client.get(User.class, "fake-user")).thenReturn(Mono.just(user)); when(client.update(user)).thenReturn(Mono.just(user)); StepVerifier.create(userService.grantRoles("fake-user", Set.of("fake-role"))) .expectNext(user) .verifyComplete(); } @Test void shouldUpdateRoleBindingIfExists() { // add another subject var anotherSubject = new Subject(); anotherSubject.setName("another-fake-user"); anotherSubject.setKind(User.KIND); anotherSubject.setApiGroup(User.GROUP); var notProvidedRoleBinding = RoleBinding.create("fake-user", "non-provided-fake-role"); notProvidedRoleBinding.getSubjects().add(anotherSubject); var existingRoleBinding = RoleBinding.create("fake-user", "fake-role"); when(roleService.listRoleBindings(any(Subject.class))) .thenReturn(Flux.just(notProvidedRoleBinding, existingRoleBinding)); when(client.update(isA(RoleBinding.class))) .thenReturn(Mono.just(mock(RoleBinding.class))); when(sessionRegistry.getAllSessions("fake-user")).thenReturn(Flux.empty()); var user = createUser("fake-password"); when(client.get(User.class, "fake-user")).thenReturn(Mono.just(user)); when(client.update(user)).thenReturn(Mono.just(user)); StepVerifier.create(userService.grantRoles("fake-user", Set.of("fake-role"))) // Because the roles are the same, so no need to update the existingRoleBinding .expectNext(user) .verifyComplete(); verify(client).update(notProvidedRoleBinding); } } @Nested class SignUpTest { @Test void signUpWhenRegistrationNotAllowed() { SystemSetting.User userSetting = new SystemSetting.User(); userSetting.setAllowRegistration(false); when(environmentFetcher.fetch(eq(SystemSetting.User.GROUP), eq(SystemSetting.User.class))) .thenReturn(Mono.just(userSetting)); var signUpData = createSignUpData("fake-user", "fake-password"); userService.signUp(signUpData) .as(StepVerifier::create) .consumeErrorWith(e -> { assertInstanceOf(ServerWebInputException.class, e); assertTrue(e.getMessage().contains("registration is not allowed")); }) .verify(); } @Test void signUpWhenRegistrationDefaultRoleNotConfigured() { SystemSetting.User userSetting = new SystemSetting.User(); userSetting.setAllowRegistration(true); when(environmentFetcher.fetch(eq(SystemSetting.User.GROUP), eq(SystemSetting.User.class))) .thenReturn(Mono.just(userSetting)); var signUpData = createSignUpData("fake-user", "fake-password"); userService.signUp(signUpData) .as(StepVerifier::create) .consumeErrorWith(e -> { assertInstanceOf(ServerWebInputException.class, e); assertTrue(e.getMessage().contains("default role is not configured")); }) .verify(); } @Test void signUpWhenRegistrationUsernameExists() { SystemSetting.User userSetting = new SystemSetting.User(); userSetting.setAllowRegistration(true); userSetting.setDefaultRole("fake-role"); when(environmentFetcher.fetch(eq(SystemSetting.User.GROUP), eq(SystemSetting.User.class))) .thenReturn(Mono.just(userSetting)); when(passwordEncoder.encode(eq("fake-password"))).thenReturn("fake-password"); when(client.fetch(eq(User.class), eq("fake-user"))) .thenReturn(Mono.just(createFakeUser("test", "test"))); when(extensionGetter.getExtensions(UserPreCreatingHandler.class)) .thenReturn(Flux.empty()); var signUpData = createSignUpData("fake-user", "fake-password"); userService.signUp(signUpData) .as(StepVerifier::create) .expectError(DuplicateNameException.class) .verify(); } @Test void signUpWhenEmailAlreadyTaken() { SystemSetting.User userSetting = new SystemSetting.User(); userSetting.setAllowRegistration(true); userSetting.setMustVerifyEmailOnRegistration(true); userSetting.setDefaultRole("fake-role"); when(environmentFetcher.fetch(eq(SystemSetting.User.GROUP), eq(SystemSetting.User.class))) .thenReturn(Mono.just(userSetting)); when(passwordEncoder.encode(eq("fake-password"))).thenReturn("fake-password"); when(emailVerificationService.verifyRegisterVerificationCode("fake@example.com", "fakeCode")) .thenReturn(Mono.just(true)); when(client.listAll(same(User.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.from(Mono.fromSupplier(() -> { var user = new User(); user.setSpec(new User.UserSpec()); user.getSpec().setEmailVerified(true); return user; }))); var signUpData = createSignUpData("fake-user", "fake-password"); signUpData.setEmail("fake@example.com"); signUpData.setEmailCode("fakeCode"); userService.signUp(signUpData) .as(StepVerifier::create) .expectError(EmailAlreadyTakenException.class) .verify(); } @Test void signUpWhenRegistrationSuccessfully() { SystemSetting.User userSetting = new SystemSetting.User(); userSetting.setAllowRegistration(true); userSetting.setDefaultRole("fake-role"); when(environmentFetcher.fetch(eq(SystemSetting.User.GROUP), eq(SystemSetting.User.class))) .thenReturn(Mono.just(userSetting)); when(passwordEncoder.encode(eq("fake-password"))).thenReturn("fake-password"); when(client.fetch(eq(User.class), eq("fake-user"))) .thenReturn(Mono.empty()); User fakeUser = createFakeUser("fake-user", "fake-password"); var signUpData = createSignUpData("fake-user", "fake-password"); when(client.fetch(eq(Role.class), anyString())).thenReturn(Mono.just(new Role())); when(client.create(any(User.class))).thenReturn(Mono.just(fakeUser)); UserServiceImpl spyUserService = spy(userService); doReturn(Mono.just(fakeUser)).when(spyUserService).grantRoles(eq("fake-user"), anySet()); when(extensionGetter.getExtensions(UserPreCreatingHandler.class)) .thenReturn(Flux.just(user -> { if (user.getMetadata().getAnnotations() == null) { user.getMetadata().setAnnotations(new HashMap<>()); } user.getMetadata().getAnnotations() .put("pre.creating.handler.handled", "true"); return Mono.empty(); })); when(extensionGetter.getExtensions(UserPostCreatingHandler.class)) .thenReturn(Flux.just(user -> { assertEquals(fakeUser, user); return Mono.empty(); })); spyUserService.signUp(signUpData) .as(StepVerifier::create) .consumeNextWith(user -> { assertThat(user.getMetadata().getName()).isEqualTo("fake-user"); assertThat(user.getSpec().getPassword()).isEqualTo("fake-password"); }) .verifyComplete(); verify(client).create(assertArg(u -> { var handled = u.getMetadata().getAnnotations().get("pre.creating.handler.handled"); assertEquals("true", handled); })); verify(spyUserService).grantRoles(eq("fake-user"), anySet()); } User createFakeUser(String name, String password) { User user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName(name); user.setSpec(new User.UserSpec()); user.getSpec().setPassword(password); return user; } SignUpData createSignUpData(String name, String password) { SignUpData signUpData = new SignUpData(); signUpData.setUsername(name); signUpData.setPassword(password); signUpData.setDisplayName(name); return signUpData; } } @Test void confirmPasswordWhenPasswordNotSet() { var user = new User(); user.setSpec(new User.UserSpec()); when(client.get(User.class, "fake-user")).thenReturn(Mono.just(user)); userService.confirmPassword("fake-user", "fake-password") .as(StepVerifier::create) .expectNext(true) .verifyComplete(); user.getSpec().setPassword(""); userService.confirmPassword("fake-user", "fake-password") .as(StepVerifier::create) .expectNext(true) .verifyComplete(); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/AbstractExtensionTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; class AbstractExtensionTest { @Test void groupVersionKind() { var extension = new AbstractExtension() { }; extension.setApiVersion("fake.halo.run/v1alpha1"); extension.setKind("Fake"); var gvk = extension.groupVersionKind(); assertEquals(new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"), gvk); } @Test void testGroupVersionKind() { var extension = new AbstractExtension() { }; extension.groupVersionKind(new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake")); assertEquals("fake.halo.run/v1alpha1", extension.getApiVersion()); assertEquals("Fake", extension.getKind()); } @Test void metadata() { var extension = new AbstractExtension() { }; Metadata metadata = new Metadata(); metadata.setName("fake"); extension.setMetadata(metadata); assertEquals(metadata, extension.getMetadata()); } @Test void testMetadata() { var extension = new AbstractExtension() { }; Metadata metadata = new Metadata(); metadata.setName("fake"); extension.setMetadata(metadata); assertEquals(metadata, extension.getMetadata()); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/ComparatorsTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import java.time.Instant; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class ComparatorsTest { @Nested class CompareCreationTimestamp { FakeExtension createFake(String name, Instant creationTimestamp) { var metadata = new Metadata(); metadata.setName(name); metadata.setCreationTimestamp(creationTimestamp); var fake = new FakeExtension(); fake.setMetadata(metadata); return fake; } @Test void desc() { var comparator = Comparators.compareCreationTimestamp(false); var now = Instant.now(); var before = now.minusMillis(1); var after = now.plusMillis(1); var fakeNow = createFake("now", now); var fakeBefore = createFake("before", before); var fakeAfter = createFake("after", after); var sortedFakes = new ArrayList<>(List.of(fakeNow, fakeAfter, fakeBefore)); sortedFakes.sort(comparator); assertEquals(List.of(fakeAfter, fakeNow, fakeBefore), sortedFakes); } @Test void asc() { var comparator = Comparators.compareCreationTimestamp(true); var now = Instant.now(); var before = now.minusMillis(1); var after = now.plusMillis(1); var fakeNow = createFake("now", now); var fakeBefore = createFake("before", before); var fakeAfter = createFake("after", after); var sortedFakes = new ArrayList<>(List.of(fakeNow, fakeAfter, fakeBefore)); sortedFakes.sort(comparator); assertEquals(List.of(fakeBefore, fakeNow, fakeAfter), sortedFakes); } } @Nested class CompareName { FakeExtension createFake(String name) { var metadata = new Metadata(); metadata.setName(name); var fake = new FakeExtension(); fake.setMetadata(metadata); return fake; } @Test void desc() { var comparator = Comparators.compareName(false); var fake01 = createFake("fake01"); var fake02 = createFake("fake02"); var fake03 = createFake("fake03"); var sortedFakes = new ArrayList<>(List.of(fake02, fake01, fake03)); sortedFakes.sort(comparator); assertEquals(List.of(fake03, fake02, fake01), sortedFakes); } @Test void asc() { var comparator = Comparators.compareName(true); var fake01 = createFake("fake01"); var fake02 = createFake("fake02"); var fake03 = createFake("fake03"); var sortedFakes = new ArrayList<>(List.of(fake02, fake03, fake01)); sortedFakes.sort(comparator); assertEquals(List.of(fake01, fake02, fake03), sortedFakes); } } } ================================================ FILE: application/src/test/java/run/halo/app/extension/ConfigMapTest.java ================================================ package run.halo.app.extension; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.doNothing; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.util.InMemoryResource; import run.halo.app.infra.utils.YamlUnstructuredLoader; /** * Tests for {@link ConfigMap}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class ConfigMapTest { @Mock ExtensionClient extensionClient; @Test void configMapTest() { ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(ConfigMap.class); doNothing().when(extensionClient).create(argumentCaptor.capture()); ConfigMap configMap = new ConfigMap(); Metadata metadata = new Metadata(); metadata.setName("test-configmap"); configMap.setMetadata(metadata); Map data = Map.of("k1", "v1", "k2", "v2", "k3", "v3"); configMap.setData(data); extensionClient.create(configMap); ConfigMap value = argumentCaptor.getValue(); assertThat(value).isNotNull(); assertThat(value.getData()).isEqualTo(data); } @Test void putDataItem() { ConfigMap configMap = new ConfigMap(); configMap.putDataItem("k1", "v1") .putDataItem("k2", "v2") .putDataItem("k3", "v3"); assertThat(configMap.getData()).isNotNull(); assertThat(configMap.getData()).hasSize(3); assertThat(configMap.getData()).isEqualTo( Map.of("k1", "v1", "k2", "v2", "k3", "v3")); } @Test void equalsTest() { ConfigMap configMapA = new ConfigMap(); Metadata metadataA = new Metadata(); metadataA.setName("test-configmap"); configMapA.setMetadata(metadataA); configMapA.putDataItem("k1", "v1"); ConfigMap configMapB = new ConfigMap(); Metadata metadataB = new Metadata(); metadataB.setName("test-configmap"); configMapB.setMetadata(metadataB); configMapB.putDataItem("k1", "v1"); assertThat(configMapA).isEqualTo(configMapB); configMapB.getMetadata().setName("test-configmap-2"); assertThat(configMapA).isNotEqualTo(configMapB); } @Test void yamlTest() { String configMapYaml = """ apiVersion: v1alpha1 kind: ConfigMap metadata: name: test-configmap data: k1: v1 k2: v2 k3: v3 """; List unstructureds = new YamlUnstructuredLoader(new InMemoryResource(configMapYaml)).load(); assertThat(unstructureds).hasSize(1); Unstructured unstructured = unstructureds.get(0); ConfigMap configMap = Unstructured.OBJECT_MAPPER.convertValue(unstructured, ConfigMap.class); assertThat(configMap.getData()).isEqualTo(Map.of("k1", "v1", "k2", "v2", "k3", "v3")); assertThat(configMap.getMetadata().getName()).isEqualTo("test-configmap"); assertThat(configMap.getApiVersion()).isEqualTo("v1alpha1"); assertThat(configMap.getKind()).isEqualTo("ConfigMap"); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/DefaultSchemeManagerTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import run.halo.app.extension.event.SchemeAddedEvent; import run.halo.app.extension.event.SchemeRemovedEvent; import run.halo.app.extension.exception.SchemeNotFoundException; import run.halo.app.extension.index.IndexEngine; import run.halo.app.extension.index.IndicesManager; @ExtendWith(MockitoExtension.class) class DefaultSchemeManagerTest { @Mock IndicesManager indicesManager; @Mock IndexEngine indexEngine; @Mock ApplicationEventPublisher eventPublisher; @InjectMocks DefaultSchemeManager schemeManager; @BeforeEach void setUp() { lenient().when(indexEngine.getIndicesManager()).thenReturn(indicesManager); } @Test void shouldThrowExceptionWhenNoGvkAnnotation() { class WithoutGvkExtension extends AbstractExtension { } assertThrows(IllegalArgumentException.class, () -> schemeManager.register(WithoutGvkExtension.class)); } @Test void shouldGetNothingWhenUnregistered() { final var gvk = new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"); var scheme = schemeManager.fetch(gvk); assertFalse(scheme.isPresent()); assertThrows(SchemeNotFoundException.class, () -> schemeManager.get(gvk)); assertThrows(SchemeNotFoundException.class, () -> schemeManager.get(FakeExtension.class)); assertThrows(SchemeNotFoundException.class, () -> schemeManager.get(new FakeExtension())); } @Test void shouldGetSchemeWhenRegistered() { schemeManager.register(FakeExtension.class); final var gvk = new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"); var scheme = schemeManager.fetch(gvk); assertTrue(scheme.isPresent()); assertEquals(gvk, schemeManager.get(gvk).groupVersionKind()); assertEquals(gvk, schemeManager.get(FakeExtension.class).groupVersionKind()); assertEquals(gvk, schemeManager.get(new FakeExtension()).groupVersionKind()); } @Test void shouldUnregisterSuccessfully() { schemeManager.register(FakeExtension.class); Scheme scheme = schemeManager.get(FakeExtension.class); assertNotNull(scheme); schemeManager.unregister(scheme); assertThrows(SchemeNotFoundException.class, () -> schemeManager.get(FakeExtension.class)); } @Test void shouldTriggerOnChangeOnlyOnceWhenRegisterTwice() { schemeManager.register(FakeExtension.class); schemeManager.register(FakeExtension.class); verify(eventPublisher).publishEvent(isA(SchemeAddedEvent.class)); verify(indicesManager).add(same(FakeExtension.class), any()); } @Test void shouldTriggerOnChangeOnlyOnceWhenUnregisterTwice() { schemeManager.register(FakeExtension.class); var scheme = schemeManager.get(FakeExtension.class); schemeManager.unregister(scheme); schemeManager.unregister(scheme); verify(eventPublisher).publishEvent(isA(SchemeAddedEvent.class)); verify(eventPublisher).publishEvent(isA(SchemeRemovedEvent.class)); verify(indicesManager).add(same(FakeExtension.class), any()); } @Test void getSizeOfSchemes() { assertEquals(0, schemeManager.size()); schemeManager.register(FakeExtension.class); assertEquals(1, schemeManager.size()); schemeManager.unregister(schemeManager.get(FakeExtension.class)); assertEquals(0, schemeManager.size()); } @Test void shouldReturnCopyOnWriteList() { schemeManager.register(FakeExtension.class); var schemes = schemeManager.schemes(); schemes.forEach(scheme -> { // make sure concurrent modification won't happen schemeManager.register(FooExtension.class); }); } @GVK(group = "fake.halo.run", version = "v1alpha1", kind = "Foo", plural = "foos", singular = "foo") static class FooExtension extends AbstractExtension { } } ================================================ FILE: application/src/test/java/run/halo/app/extension/ExtensionOperatorTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.time.Instant; import org.junit.jupiter.api.Test; class ExtensionOperatorTest { @Test void testIsNotDeleted() { var ext = mock(ExtensionOperator.class); var metadata = mock(Metadata.class); when(metadata.getDeletionTimestamp()).thenReturn(null); when(ext.getMetadata()).thenReturn(metadata); assertTrue(ExtensionOperator.isNotDeleted().test(ext)); when(metadata.getDeletionTimestamp()).thenReturn(Instant.now()); assertFalse(ExtensionOperator.isNotDeleted().test(ext)); } @Test void testIsDeleted() { var ext = mock(ExtensionOperator.class); when(ext.getMetadata()).thenReturn(null); assertFalse(ExtensionOperator.isDeleted(ext)); var metadata = mock(Metadata.class); when(ext.getMetadata()).thenReturn(metadata); when(metadata.getDeletionTimestamp()).thenReturn(null); assertFalse(ExtensionOperator.isDeleted(ext)); when(metadata.getDeletionTimestamp()).thenReturn(Instant.now()); assertTrue(ExtensionOperator.isDeleted(ext)); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/ExtensionStoreUtilTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class ExtensionStoreUtilTest { Scheme scheme; Scheme grouplessScheme; @BeforeEach void setUp() { scheme = new Scheme(FakeExtension.class, new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"), "fakes", "fake", new ObjectNode(null)); grouplessScheme = new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", "Fake"), "fakes", "fake", new ObjectNode(null)); } @Test void buildStoreNamePrefix() { var prefix = ExtensionStoreUtil.buildStoreNamePrefix(scheme); assertEquals("/registry/fake.halo.run/fakes", prefix); prefix = ExtensionStoreUtil.buildStoreNamePrefix(grouplessScheme); assertEquals("/registry/fakes", prefix); } @Test void buildStoreName() { var storeName = ExtensionStoreUtil.buildStoreName(scheme, "fake-name"); assertEquals("/registry/fake.halo.run/fakes/fake-name", storeName); storeName = ExtensionStoreUtil.buildStoreName(grouplessScheme, "fake-name"); assertEquals("/registry/fakes/fake-name", storeName); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/FakeExtension.java ================================================ package run.halo.app.extension; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; @GVK(group = "fake.halo.run", version = "v1alpha1", kind = "Fake", plural = "fakes", singular = "fake") @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) public class FakeExtension extends AbstractExtension { private FakeStatus status = new FakeStatus(); public static FakeExtension createFake(String name) { var metadata = new Metadata(); metadata.setName(name); var fake = new FakeExtension(); fake.setMetadata(metadata); return fake; } @Data public static class FakeStatus { private String state; } } ================================================ FILE: application/src/test/java/run/halo/app/extension/GroupVersionKindTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static run.halo.app.extension.GroupVersionKind.fromAPIVersionAndKind; import java.util.List; import org.junit.jupiter.api.Test; class GroupVersionKindTest { @Test void testFromApiVersionAndKind() { record TestCase(String apiVersion, String kind, GroupVersionKind expected, Class exception) { } List.of( new TestCase("v1alpha1", "Fake", new GroupVersionKind("", "v1alpha1", "Fake"), null), new TestCase("fake.halo.run/v1alpha1", "Fake", new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"), null), new TestCase("", "", null, IllegalArgumentException.class), new TestCase("", "Fake", null, IllegalArgumentException.class), new TestCase("v1alpha1", "", null, IllegalArgumentException.class), new TestCase("fake.halo.run/v1alpha1/v1alpha2", "Fake", null, IllegalArgumentException.class) ).forEach(testCase -> { if (testCase.exception != null) { assertThrows(testCase.exception, () -> { fromAPIVersionAndKind(testCase.apiVersion, testCase.kind); }); } else { var got = fromAPIVersionAndKind(testCase.apiVersion, testCase.kind); assertEquals(testCase.expected, got); } }); } @Test void testHasGroup() { record TestCase(GroupVersionKind gvk, boolean hasGroup) { } List.of( new TestCase(new GroupVersionKind("", "v1alpha1", "Fake"), false), new TestCase(new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"), true) ).forEach(testCase -> assertEquals(testCase.hasGroup, testCase.gvk.hasGroup())); } @Test void testGroupKind() { record TestCase(GroupVersionKind gvk, GroupKind gk) { } List.of( new TestCase(new GroupVersionKind("", "v1alpha1", "Fake"), new GroupKind("", "Fake")), new TestCase(new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"), new GroupKind("fake.halo.run", "Fake")) ).forEach(testCase -> { assertEquals(testCase.gk, testCase.gvk.groupKind()); }); } @Test void testGroupVersion() { record TestCase(GroupVersionKind gvk, GroupVersion gv) { } List.of( new TestCase(new GroupVersionKind("", "v1alpha1", "Fake"), new GroupVersion("", "v1alpha1")), new TestCase(new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"), new GroupVersion("fake.halo.run", "v1alpha1")) ).forEach(testCase -> { assertEquals(testCase.gv, testCase.gvk.groupVersion()); }); } @Test void fromExtension() { GroupVersionKind groupVersionKind = GroupVersionKind.fromExtension(FakeExtension.class); assertEquals("fake.halo.run", groupVersionKind.group()); assertEquals("v1alpha1", groupVersionKind.version()); assertEquals("Fake", groupVersionKind.kind()); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/GroupVersionTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; class GroupVersionTest { @Test void shouldThrowIllegalArgumentExceptionWhenAPIVersionIsIllegal() { assertThrows(IllegalArgumentException.class, () -> GroupVersion.parseAPIVersion(null), "apiVersion is null"); assertThrows(IllegalArgumentException.class, () -> GroupVersion.parseAPIVersion(""), "apiVersion is empty"); assertThrows(IllegalArgumentException.class, () -> GroupVersion.parseAPIVersion(" "), "apiVersion is blank"); assertThrows(IllegalArgumentException.class, () -> GroupVersion.parseAPIVersion("a/b/c"), "apiVersion contains more than 1 '/'"); } @Test void shouldReturnGroupVersionCorrectly() { assertEquals(new GroupVersion("", "v1"), GroupVersion.parseAPIVersion("v1"), "only contains version"); assertEquals(new GroupVersion("core.halo.run", "v1"), GroupVersion.parseAPIVersion("core.halo.run/v1"), "only contains version"); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/JsonExtensionConverterTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.lenient; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.extension.exception.ExtensionConvertException; import run.halo.app.extension.exception.SchemaViolationException; import run.halo.app.extension.store.ExtensionStore; @ExtendWith(MockitoExtension.class) class JsonExtensionConverterTest { @InjectMocks JSONExtensionConverter converter; @Mock SchemeManager schemeManager; ObjectMapper objectMapper = Unstructured.OBJECT_MAPPER; @BeforeEach void setUp() { converter.setObjectMapper(objectMapper); var scheme = Scheme.buildFromType(FakeExtension.class); lenient().when(schemeManager.get(scheme.groupVersionKind())).thenReturn(scheme); } @Test void convertTo() throws IOException { var fake = createFakeExtension("fake", 10L); var extensionStore = converter.convertTo(fake); assertEquals("/registry/fake.halo.run/fakes/fake", extensionStore.getName()); assertEquals(10L, extensionStore.getVersion()); assertEquals(fake, objectMapper.readValue(extensionStore.getData(), FakeExtension.class)); } @Test void convertFrom() throws JsonProcessingException { var fake = createFakeExtension("fake", 20L); var store = new ExtensionStore(); store.setName("/registry/fake.halo.run/fakes/fake"); store.setVersion(20L); store.setData(objectMapper.writeValueAsBytes(fake)); FakeExtension gotFake = converter.convertFrom(FakeExtension.class, store); assertEquals(fake, gotFake); } @Test void shouldThrowConvertExceptionWhenDataIsInvalid() { var store = new ExtensionStore(); store.setName("/registry/fake.halo.run/fakes/fake"); store.setVersion(20L); store.setData("{".getBytes()); assertThrows(ExtensionConvertException.class, () -> converter.convertFrom(FakeExtension.class, store)); } @Test void shouldThrowSchemaViolationExceptionWhenNameNotSet() { var fake = new FakeExtension(); Metadata metadata = new Metadata(); fake.setMetadata(metadata); fake.setApiVersion("fake.halo.run/v1alpha1"); fake.setKind("Fake"); var error = assertThrows(SchemaViolationException.class, () -> converter.convertTo(fake)); assertEquals(1, error.getErrors().size()); var result = error.getErrors().items().get(0); assertEquals(1026, result.code()); assertEquals("Field 'name' is required.", result.message()); } FakeExtension createFakeExtension(String name, Long version) { var fake = new FakeExtension(); fake.groupVersionKind(new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake")); Metadata metadata = new Metadata(); metadata.setName(name); metadata.setVersion(version); fake.setMetadata(metadata); return fake; } } ================================================ FILE: application/src/test/java/run/halo/app/extension/JsonExtensionTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.node.TextNode; import org.json.JSONException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; class JsonExtensionTest { ObjectMapper objectMapper; @BeforeEach void setUp() { objectMapper = JsonMapper.builder().build(); } @Test void serializeEmptyExt() throws JsonProcessingException, JSONException { var ext = new JsonExtension(objectMapper); var json = objectMapper.writeValueAsString(ext); JSONAssert.assertEquals("{}", json, true); } @Test void serializeExt() throws JsonProcessingException, JSONException { var ext = new JsonExtension(objectMapper); ext.setApiVersion("fake.halo.run/v1alpha"); ext.setKind("Fake"); var metadata = ext.getMetadataOrCreate(); metadata.setName("fake-name"); ext.getInternal().set("data", TextNode.valueOf("halo")); JSONAssert.assertEquals(""" { "apiVersion": "fake.halo.run/v1alpha", "kind": "Fake", "metadata": { "name": "fake-name" }, "data": "halo" }""", objectMapper.writeValueAsString(ext), true); } @Test void deserialize() throws JsonProcessingException { var json = """ { "apiVersion": "fake.halo.run/v1alpha1", "kind": "Fake", "metadata": { "name": "faker" }, "otherProperty": "otherPropertyValue" }"""; var ext = objectMapper.readValue(json, JsonExtension.class); assertEquals("fake.halo.run/v1alpha1", ext.getApiVersion()); assertEquals("Fake", ext.getKind()); assertNotNull(ext.getMetadata()); assertEquals("faker", ext.getMetadata().getName()); assertNull(ext.getMetadata().getVersion()); assertNull(ext.getMetadata().getFinalizers()); assertNull(ext.getMetadata().getAnnotations()); assertNull(ext.getMetadata().getLabels()); assertNull(ext.getMetadata().getGenerateName()); assertNull(ext.getMetadata().getCreationTimestamp()); assertNull(ext.getMetadata().getDeletionTimestamp()); assertEquals("otherPropertyValue", ext.getInternal().get("otherProperty").asText()); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/ListResultTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.json.JsonMapper; import java.lang.reflect.ParameterizedType; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; class ListResultTest { @Test void generateGenericClass() { var fakeListClass = ListResult.generateGenericClass(Scheme.buildFromType(FakeExtension.class)); assertTrue(ListResult.class.isAssignableFrom(fakeListClass)); assertSame(FakeExtension.class, ((ParameterizedType) fakeListClass.getGenericSuperclass()) .getActualTypeArguments()[0]); assertEquals("FakeList", fakeListClass.getSimpleName()); } @Test void generateGenericClassForClassParam() { var fakeListClass = ListResult.generateGenericClass(FakeExtension.class); assertTrue(ListResult.class.isAssignableFrom(fakeListClass)); assertSame(FakeExtension.class, ((ParameterizedType) fakeListClass.getGenericSuperclass()) .getActualTypeArguments()[0]); assertEquals("FakeExtensionList", fakeListClass.getSimpleName()); } @Test void totalPages() { var listResult = new ListResult<>(1, 10, 100, List.of()); assertEquals(10, listResult.getTotalPages()); listResult = new ListResult<>(1, 10, 1, List.of()); assertEquals(1, listResult.getTotalPages()); listResult = new ListResult<>(1, 10, 9, List.of()); assertEquals(1, listResult.getTotalPages()); listResult = new ListResult<>(1, 0, 100, List.of()); assertEquals(1, listResult.getTotalPages()); } @Test void subListWhenSizeIsZero() { var list = List.of(1, 2, 3, 4, 5); assertSubList(list); list = List.of(1); assertSubList(list); } @Test void firstTest() { var listResult = new ListResult<>(List.of()); assertEquals(Optional.empty(), ListResult.first(listResult)); listResult = new ListResult<>(1, 10, 1, List.of("A")); assertEquals(Optional.of("A"), ListResult.first(listResult)); } @Test void serializationTest() throws JsonProcessingException { var result = new ListResult<>(1, 10, 100, List.of("a", "b", "c")); var json = JsonMapper.builder() .build() .writeValueAsString(result); JSONAssert.assertEquals(""" { "page": 1, "size": 10, "total": 100, "items": [ "a", "b", "c" ], "first": true, "last": false, "hasNext": true, "hasPrevious": false, "totalPages": 10 } """, json, true); } @Test void deserializationTest() throws JsonProcessingException { var json = """ { "page": 2, "size": 10, "total": 100, "items": [ "a", "b", "c" ], "first": false, "last": false, "hasNext": true, "hasPrevious": true, "totalPages": 10 } """; var result = JsonMapper.builder() .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build() .readValue(json, new TypeReference>() { }); assertEquals(2, result.getPage()); assertEquals(100, result.getTotal()); assertEquals(10, result.getTotalPages()); assertEquals(10, result.getSize()); assertFalse(result.isFirst()); assertFalse(result.isLast()); assertTrue(result.hasNext()); assertTrue(result.hasPrevious()); assertEquals(List.of("a", "b", "c"), result.getItems()); } private void assertSubList(List list) { var result = ListResult.subList(list, 0, 0); assertEquals(list, result); result = ListResult.subList(list, 0, 1); assertEquals(list.subList(0, 1), result); result = ListResult.subList(list, 1, 0); assertEquals(list, result); assertEquals(list.subList(0, 1), ListResult.subList(list, -1, 1)); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/MetadataOperatorTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.time.Instant; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; class MetadataOperatorTest { Instant now = Instant.now(); @Test void testEqualsWithSameType() { assertTrue(MetadataOperator.equals(null, null)); var left = createFullMetadata(); var right = createFullMetadata(); assertFalse(MetadataOperator.equals(left, null)); assertFalse(MetadataOperator.equals(null, right)); assertTrue(MetadataOperator.equals(left, right)); left.setDeletionTimestamp(null); assertFalse(MetadataOperator.equals(left, right)); right.setDeletionTimestamp(null); assertTrue(MetadataOperator.equals(left, right)); left.setCreationTimestamp(null); assertFalse(MetadataOperator.equals(left, right)); right.setCreationTimestamp(null); assertTrue(MetadataOperator.equals(left, right)); left.setVersion(null); assertFalse(MetadataOperator.equals(left, right)); right.setVersion(null); assertTrue(MetadataOperator.equals(left, right)); left.setAnnotations(null); assertFalse(MetadataOperator.equals(left, right)); right.setAnnotations(null); assertTrue(MetadataOperator.equals(left, right)); left.setLabels(null); assertFalse(MetadataOperator.equals(left, right)); right.setLabels(null); assertTrue(MetadataOperator.equals(left, right)); left.setName(null); assertFalse(MetadataOperator.equals(left, right)); right.setName(null); assertTrue(MetadataOperator.equals(left, right)); left.setFinalizers(null); assertFalse(MetadataOperator.equals(left, right)); right.setFinalizers(null); assertTrue(MetadataOperator.equals(left, right)); } @Test void testEqualsWithDifferentType() { var mockMetadata = mock(MetadataOperator.class); when(mockMetadata.getName()).thenReturn("fake-name"); when(mockMetadata.getLabels()).thenReturn(Map.of("fake-label-key", "fake-label-value")); when(mockMetadata.getAnnotations()).thenReturn(Map.of("fake-anno-key", "fake-anno-value")); when(mockMetadata.getVersion()).thenReturn(123L); when(mockMetadata.getCreationTimestamp()).thenReturn(now); when(mockMetadata.getDeletionTimestamp()).thenReturn(now); when(mockMetadata.getFinalizers()) .thenReturn(Set.of("fake-finalizer-1", "fake-finalizer-2")); var metadata = createFullMetadata(); assertTrue(MetadataOperator.equals(metadata, mockMetadata)); } Metadata createFullMetadata() { var metadata = new Metadata(); metadata.setName("fake-name"); metadata.setLabels(Map.of("fake-label-key", "fake-label-value")); metadata.setAnnotations(Map.of("fake-anno-key", "fake-anno-value")); metadata.setVersion(123L); metadata.setCreationTimestamp(now); metadata.setDeletionTimestamp(now); metadata.setFinalizers(Set.of("fake-finalizer-2", "fake-finalizer-1")); return metadata; } } ================================================ FILE: application/src/test/java/run/halo/app/extension/ReactiveExtensionClientTest.java ================================================ package run.halo.app.extension; import static java.util.Collections.emptyList; import static java.util.Collections.reverseOrder; import static java.util.Comparator.comparing; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static run.halo.app.extension.GroupVersionKind.fromAPIVersionAndKind; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.transaction.ReactiveTransactionManager; import org.springframework.transaction.reactive.TransactionalOperator; import reactor.core.Exceptions; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.extension.exception.SchemeNotFoundException; import run.halo.app.extension.index.IndexEngine; import run.halo.app.extension.store.ExtensionStore; import run.halo.app.extension.store.ReactiveExtensionStoreClient; @ExtendWith(MockitoExtension.class) class ReactiveExtensionClientTest { static final Scheme fakeScheme = Scheme.buildFromType(FakeExtension.class); @Mock ReactiveExtensionStoreClient storeClient; @Mock ExtensionConverter converter; @Mock SchemeManager schemeManager; @Mock ReactiveTransactionManager reactiveTransactionManager; @Spy ObjectMapper objectMapper = JsonMapper.builder() .addModule(new JavaTimeModule()) .build(); @Mock IndexEngine indexEngine; @InjectMocks ReactiveExtensionClientImpl client; @BeforeEach void setUp() { lenient().when(schemeManager.get(eq(FakeExtension.class))) .thenReturn(fakeScheme); lenient().when(schemeManager.get(eq(fakeScheme.groupVersionKind()))).thenReturn(fakeScheme); var transactionalOperator = mock(TransactionalOperator.class); client.setTransactionalOperator(transactionalOperator); lenient().when(transactionalOperator.transactional(any(Mono.class))) .thenAnswer(invocation -> invocation.getArgument(0)); } FakeExtension createFakeExtension(String name, Long version) { var fake = new FakeExtension(); var metadata = new Metadata(); metadata.setName(name); metadata.setVersion(version); fake.setMetadata(metadata); fake.setApiVersion("fake.halo.run/v1alpha1"); fake.setKind("Fake"); return fake; } ExtensionStore createExtensionStore(String name) { return createExtensionStore(name, null); } ExtensionStore createExtensionStore(String name, Long version) { var extensionStore = new ExtensionStore(); extensionStore.setName(name); extensionStore.setVersion(version); extensionStore.setData("fake data".getBytes()); return extensionStore; } Unstructured createUnstructured() throws JsonProcessingException { String extensionJson = """ { "apiVersion": "fake.halo.run/v1alpha1", "kind": "Fake", "metadata": { "labels": { "category": "fake", "default": "true" }, "name": "fake", "creationTimestamp": "2011-12-03T10:15:30Z", "version": 12345 } } """; return Unstructured.OBJECT_MAPPER.readValue(extensionJson, Unstructured.class); } @Test void shouldThrowSchemeNotFoundExceptionWhenSchemeNotRegistered() { class UnRegisteredExtension extends AbstractExtension { } when(schemeManager.get(eq(UnRegisteredExtension.class))) .thenThrow(SchemeNotFoundException.class); when(schemeManager.get(isA(GroupVersionKind.class))) .thenThrow(SchemeNotFoundException.class); assertThrows(SchemeNotFoundException.class, () -> client.list(UnRegisteredExtension.class, null, null)); assertThrows(SchemeNotFoundException.class, () -> client.list(UnRegisteredExtension.class, null, null, 0, 10)); assertThrows(SchemeNotFoundException.class, () -> client.fetch(UnRegisteredExtension.class, "fake")); assertThrows(SchemeNotFoundException.class, () -> client.fetch(fromAPIVersionAndKind("fake.halo.run/v1alpha1", "UnRegistered"), "fake")); when(converter.convertTo(any())).thenThrow(SchemeNotFoundException.class); StepVerifier.create(client.create(createFakeExtension("fake", null))) .verifyError(SchemeNotFoundException.class); assertThrows(SchemeNotFoundException.class, () -> { when(converter.convertTo(any())).thenThrow(SchemeNotFoundException.class); client.update(createFakeExtension("fake", 1L)); }); assertThrows(SchemeNotFoundException.class, () -> { when(converter.convertTo(any())).thenThrow(SchemeNotFoundException.class); client.delete(createFakeExtension("fake", 1L)); }); } @Test void shouldReturnEmptyExtensions() { when(storeClient.listByNamePrefix(anyString())).thenReturn(Flux.empty()); var fakes = client.list(FakeExtension.class, null, null); StepVerifier.create(fakes) .verifyComplete(); } @Test void shouldReturnExtensionsWithFilterAndSorter() { var fake1 = createFakeExtension("fake-01", 1L); var fake2 = createFakeExtension("fake-02", 1L); when( converter.convertFrom(FakeExtension.class, createExtensionStore("fake-01"))).thenReturn( fake1); when( converter.convertFrom(FakeExtension.class, createExtensionStore("fake-02"))).thenReturn( fake2); when(storeClient.listByNamePrefix(anyString())).thenReturn( Flux.fromIterable( List.of(createExtensionStore("fake-01"), createExtensionStore("fake-02")))); // without filter and sorter Flux fakes = client.list(FakeExtension.class, null, null); StepVerifier.create(fakes) .expectNext(fake1) .expectNext(fake2) .verifyComplete(); // with filter fakes = client.list(FakeExtension.class, fake -> { String name = fake.getMetadata().getName(); return "fake-01".equals(name); }, null); StepVerifier.create(fakes) .expectNext(fake1) .verifyComplete(); // with sorter fakes = client.list(FakeExtension.class, null, reverseOrder(comparing(fake -> fake.getMetadata().getName()))); StepVerifier.create(fakes) .expectNext(fake2) .expectNext(fake1) .verifyComplete(); } @Test void shouldQueryPageableAndCorrectly() { var fake1 = createFakeExtension("fake-01", 1L); var fake2 = createFakeExtension("fake-02", 1L); var fake3 = createFakeExtension("fake-03", 1L); when( converter.convertFrom(FakeExtension.class, createExtensionStore("fake-01"))).thenReturn( fake1); when( converter.convertFrom(FakeExtension.class, createExtensionStore("fake-02"))).thenReturn( fake2); when( converter.convertFrom(FakeExtension.class, createExtensionStore("fake-03"))).thenReturn( fake3); when(storeClient.listByNamePrefix(anyString())).thenReturn(Flux.fromIterable( List.of(createExtensionStore("fake-01"), createExtensionStore("fake-02"), createExtensionStore("fake-03")))); // without filter and sorter. var fakes = client.list(FakeExtension.class, null, null, 1, 10); StepVerifier.create(fakes) .expectNext(new ListResult<>(1, 10, 3, List.of(fake1, fake2, fake3))) .verifyComplete(); // out of page range fakes = client.list(FakeExtension.class, null, null, 100, 10); StepVerifier.create(fakes) .expectNext(new ListResult<>(100, 10, 3, emptyList())) .verifyComplete(); // with filter only fakes = client.list(FakeExtension.class, fake -> "fake-03".equals(fake.getMetadata().getName()), null, 1, 10); StepVerifier.create(fakes) .expectNext(new ListResult<>(1, 10, 1, List.of(fake3))) .verifyComplete(); // with sorter only fakes = client.list(FakeExtension.class, null, reverseOrder(comparing(fake -> fake.getMetadata().getName())), 1, 10); StepVerifier.create(fakes) .expectNext(new ListResult<>(1, 10, 3, List.of(fake3, fake2, fake1))) .verifyComplete(); // without page fakes = client.list(FakeExtension.class, null, null, 0, 0); StepVerifier.create(fakes) .expectNext(new ListResult<>(0, 0, 3, List.of(fake1, fake2, fake3))) .verifyComplete(); } @Test void shouldFetchNothing() { when(storeClient.fetchByName(any())).thenReturn(Mono.empty()); var fake = client.fetch(FakeExtension.class, "fake"); StepVerifier.create(fake) .verifyComplete(); verify(converter, times(0)).convertFrom(any(), any()); verify(storeClient, times(1)).fetchByName(any()); } @Test void shouldNotFetchUnstructured() { when(schemeManager.get(isA(GroupVersionKind.class))) .thenReturn(fakeScheme); when(storeClient.fetchByName(any())).thenReturn(Mono.empty()); var unstructuredFake = client.fetch(fakeScheme.groupVersionKind(), "fake"); StepVerifier.create(unstructuredFake) .verifyComplete(); verify(converter, times(0)).convertFrom(any(), any()); verify(schemeManager, times(1)).get(isA(GroupVersionKind.class)); verify(storeClient, times(1)).fetchByName(any()); } @Test void shouldFetchAnExtension() { var storeName = "/registry/fake.halo.run/fakes/fake"; when(storeClient.fetchByName(storeName)).thenReturn( Mono.just(createExtensionStore(storeName))); when( converter.convertFrom(FakeExtension.class, createExtensionStore(storeName))).thenReturn( createFakeExtension("fake", 1L)); var fake = client.fetch(FakeExtension.class, "fake"); StepVerifier.create(fake) .expectNext(createFakeExtension("fake", 1L)) .verifyComplete(); verify(storeClient, times(1)).fetchByName(eq(storeName)); verify(converter, times(1)).convertFrom(eq(FakeExtension.class), eq(createExtensionStore(storeName))); } @Test void shouldFetchUnstructuredExtension() throws JsonProcessingException { var storeName = "/registry/fake.halo.run/fakes/fake"; when(storeClient.fetchByName(storeName)).thenReturn( Mono.just(createExtensionStore(storeName))); when(schemeManager.get(isA(GroupVersionKind.class))) .thenReturn(fakeScheme); when(converter.convertFrom(Unstructured.class, createExtensionStore(storeName))) .thenReturn(createUnstructured()); var fake = client.fetch(fakeScheme.groupVersionKind(), "fake"); StepVerifier.create(fake) .expectNext(createUnstructured()) .verifyComplete(); verify(storeClient, times(1)).fetchByName(eq(storeName)); verify(schemeManager, times(1)).get(isA(GroupVersionKind.class)); verify(converter, times(1)).convertFrom(eq(Unstructured.class), eq(createExtensionStore(storeName))); } @Test void shouldCreateSuccessfully() { var fake = createFakeExtension("fake", null); when(converter.convertTo(any())).thenReturn( createExtensionStore("/registry/fake.halo.run/fakes/fake")); when(storeClient.create(any(), any())).thenReturn( Mono.just(createExtensionStore("/registry/fake.halo.run/fakes/fake"))); when(converter.convertFrom(same(FakeExtension.class), any())).thenReturn(fake); doNothing().when(indexEngine).insert(any()); StepVerifier.create(client.create(fake)) .expectNext(fake) .verifyComplete(); verify(converter, times(1)).convertTo(eq(fake)); verify(storeClient, times(1)).create(eq("/registry/fake.halo.run/fakes/fake"), any()); assertNotNull(fake.getMetadata().getCreationTimestamp()); } @Test void shouldCreateWithGenerateNameSuccessfully() { var fake = createFakeExtension("fake", null); fake.getMetadata().setName(""); fake.getMetadata().setGenerateName("fake-"); when(converter.convertTo(any())).thenReturn( createExtensionStore("/registry/fake.halo.run/fakes/fake")); when(storeClient.create(any(), any())).thenReturn( Mono.just(createExtensionStore("/registry/fake.halo.run/fakes/fake"))); when(converter.convertFrom(same(FakeExtension.class), any())).thenReturn(fake); doNothing().when(indexEngine).insert(any()); StepVerifier.create(client.create(fake)) .expectNext(fake) .verifyComplete(); verify(converter, times(1)).convertTo(argThat(ext -> { var name = ext.getMetadata().getName(); return name.startsWith(ext.getMetadata().getGenerateName()); })); verify(storeClient, times(1)).create(eq("/registry/fake.halo.run/fakes/fake"), any()); assertNotNull(fake.getMetadata().getCreationTimestamp()); } @Test void shouldThrowExceptionIfCreatingWithoutGenerateName() { var fake = createFakeExtension("fake", null); fake.getMetadata().setName(""); fake.getMetadata().setGenerateName(""); StepVerifier.create(client.create(fake)) .verifyError(IllegalArgumentException.class); } @Test void shouldThrowExceptionIfPrimaryKeyDuplicated() { var fake = createFakeExtension("fake", null); fake.getMetadata().setName(""); fake.getMetadata().setGenerateName("fake-"); when(converter.convertTo(any())).thenReturn( createExtensionStore("/registry/fake.halo.run/fakes/fake")); when(storeClient.create(any(), any())).thenThrow(DataIntegrityViolationException.class); StepVerifier.create(client.create(fake)) .expectErrorMatches(Exceptions::isRetryExhausted) .verify(); } @Test void shouldCreateUsingUnstructuredSuccessfully() throws JsonProcessingException { var fake = createUnstructured(); when(converter.convertTo(any())).thenReturn( createExtensionStore("/registry/fake.halo.run/fakes/fake")); when(storeClient.create(any(), any())).thenReturn( Mono.just(createExtensionStore("/registry/fake.halo.run/fakes/fake"))); when(converter.convertFrom(same(Unstructured.class), any())).thenReturn(fake); doNothing().when(indexEngine).insert(any()); StepVerifier.create(client.create(fake)) .expectNext(fake) .verifyComplete(); verify(converter, times(1)).convertTo(eq(fake)); verify(storeClient, times(1)).create(eq("/registry/fake.halo.run/fakes/fake"), any()); assertNotNull(fake.getMetadata().getCreationTimestamp()); } @Test void shouldUpdateSuccessfully() { var fake = createFakeExtension("fake", 2L); fake.getMetadata().setLabels(Map.of("new", "true")); var storeName = "/registry/fake.halo.run/fakes/fake"; when(converter.convertTo(any())).thenReturn( createExtensionStore(storeName, 2L)); when(storeClient.update(any(), any(), any())).thenReturn( Mono.just(createExtensionStore(storeName, 2L))); when(storeClient.fetchByName(storeName)).thenReturn( Mono.just(createExtensionStore(storeName, 1L))); doNothing().when(indexEngine).update(any()); var oldFake = createFakeExtension("fake", 2L); oldFake.getMetadata().setLabels(Map.of("old", "true")); var updatedFake = createFakeExtension("fake", 3L); updatedFake.getMetadata().setLabels(Map.of("updated", "true")); when(converter.convertFrom(same(FakeExtension.class), any())) .thenReturn(oldFake) .thenReturn(updatedFake); StepVerifier.create(client.update(fake)) .expectNext(updatedFake) .verifyComplete(); verify(storeClient).fetchByName(storeName); verify(converter).convertTo(isA(JsonExtension.class)); verify(converter, times(2)).convertFrom(same(FakeExtension.class), any()); verify(storeClient) .update(eq("/registry/fake.halo.run/fakes/fake"), eq(2L), any()); } @Test void shouldNotUpdateIfExtensionNotChange() { var fake = createFakeExtension("fake", 2L); var storeName = "/registry/fake.halo.run/fakes/fake"; when(storeClient.fetchByName(storeName)).thenReturn( Mono.just(createExtensionStore(storeName, 1L))); var oldFake = createFakeExtension("fake", 2L); when(converter.convertFrom(same(FakeExtension.class), any())).thenReturn(oldFake); StepVerifier.create(client.update(fake)) .expectNext(fake) .verifyComplete(); verify(storeClient).fetchByName(storeName); verify(converter).convertFrom(same(FakeExtension.class), any()); verify(converter, never()).convertTo(any()); verify(storeClient, never()).update(any(), any(), any()); } @Test void shouldNotUpdateIfUnstructuredNotChange() throws JsonProcessingException { var storeName = "/registry/fake.halo.run/fakes/fake"; var extensionStore = createExtensionStore(storeName, 2L); when(storeClient.fetchByName(storeName)).thenReturn( Mono.just(extensionStore)); var fakeJson = objectMapper.writeValueAsString(createFakeExtension("fake", 2L)); var oldFakeJson = objectMapper.writeValueAsString(createFakeExtension("fake", 2L)); var fake = objectMapper.readValue(fakeJson, Unstructured.class); var oldFake = objectMapper.readValue(oldFakeJson, Unstructured.class); oldFake.getMetadata().setVersion(2L); when(converter.convertFrom(Unstructured.class, extensionStore)).thenReturn(oldFake); StepVerifier.create(client.update(fake)) .expectNext(fake) .verifyComplete(); verify(storeClient).fetchByName(storeName); verify(converter).convertFrom(Unstructured.class, extensionStore); verify(converter, never()).convertTo(any()); verify(storeClient, never()).update(any(), any(), any()); } @Test void shouldUpdateIfExtensionStatusChangedOnly() { var fake = createFakeExtension("fake", 2L); fake.getStatus().setState("new-state"); var storeName = "/registry/fake.halo.run/fakes/fake"; when(converter.convertTo(any())).thenReturn( createExtensionStore(storeName, 2L)); when(storeClient.update(any(), any(), any())).thenReturn( Mono.just(createExtensionStore(storeName, 2L))); when(storeClient.fetchByName(storeName)).thenReturn( Mono.just(createExtensionStore(storeName, 1L))); doNothing().when(indexEngine).update(any()); var oldFake = createFakeExtension("fake", 2L); oldFake.getStatus().setState("old-state"); var updatedFake = createFakeExtension("fake", 3L); when(converter.convertFrom(same(FakeExtension.class), any())) .thenReturn(oldFake) .thenReturn(updatedFake); StepVerifier.create(client.update(fake)) .expectNext(updatedFake) .verifyComplete(); verify(storeClient).fetchByName(storeName); verify(converter).convertTo(isA(JsonExtension.class)); verify(converter, times(2)).convertFrom(same(FakeExtension.class), any()); verify(storeClient) .update(eq("/registry/fake.halo.run/fakes/fake"), eq(2L), any()); } @Test void shouldUpdateUnstructuredSuccessfully() throws JsonProcessingException { var fake = createUnstructured(); var name = "/registry/fake.halo.run/fakes/fake"; when(converter.convertTo(any())) .thenReturn(createExtensionStore(name, 12345L)); when(storeClient.update(any(), any(), any())) .thenReturn(Mono.just(createExtensionStore(name, 12345L))); when(storeClient.fetchByName(name)) .thenReturn(Mono.just(createExtensionStore(name, 12346L))); doNothing().when(indexEngine).update(any()); var oldFake = createUnstructured(); oldFake.getMetadata().setLabels(Map.of("old", "true")); var updatedFake = createUnstructured(); updatedFake.getMetadata().setLabels(Map.of("updated", "true")); when(converter.convertFrom(same(Unstructured.class), any())) .thenReturn(oldFake) .thenReturn(updatedFake); StepVerifier.create(client.update(fake)) .expectNext(updatedFake) .verifyComplete(); verify(storeClient).fetchByName(name); verify(converter).convertTo(isA(JsonExtension.class)); verify(converter, times(2)).convertFrom(same(Unstructured.class), any()); verify(storeClient) .update(eq("/registry/fake.halo.run/fakes/fake"), eq(12345L), any()); } @Test void shouldDeleteSuccessfully() { var fake = createFakeExtension("fake", 2L); when(converter.convertTo(any())).thenReturn( createExtensionStore("/registry/fake.halo.run/fakes/fake")); when(storeClient.update(any(), any(), any())).thenReturn( Mono.just(createExtensionStore("/registry/fake.halo.run/fakes/fake"))); when(converter.convertFrom(same(FakeExtension.class), any())).thenReturn(fake); doNothing().when(indexEngine).update(any()); StepVerifier.create(client.delete(fake)) .expectNext(fake) .verifyComplete(); verify(converter, times(1)).convertTo(any()); verify(storeClient, times(1)).update(any(), any(), any()); verify(storeClient, never()).delete(any(), any()); } @Test void shouldGetJsonExtension() { var storeName = "/registry/fake.halo.run/fakes/fake"; when(storeClient.fetchByName(storeName)).thenReturn( Mono.just(createExtensionStore(storeName))); var fake = createFakeExtension("fake", 1L); var expectedJsonExt = objectMapper.convertValue(fake, JsonExtension.class); when(converter.convertFrom(JsonExtension.class, createExtensionStore(storeName))) .thenReturn(expectedJsonExt); var gvk = Scheme.buildFromType(FakeExtension.class).groupVersionKind(); StepVerifier.create(client.getJsonExtension(gvk, "fake")) .expectNext(expectedJsonExt) .verifyComplete(); verify(storeClient, times(1)).fetchByName(eq(storeName)); verify(converter, times(1)).convertFrom(eq(JsonExtension.class), eq(createExtensionStore(storeName))); } @Nested @DisplayName("Extension watcher test") class WatcherTest { @Mock Watcher watcher; @BeforeEach void setUp() { client.watch(watcher); } @Test void shouldWatchOnAddSuccessfully() { doNothing().when(watcher).onAdd(isA(Extension.class)); shouldCreateSuccessfully(); verify(watcher, times(1)).onAdd(isA(Extension.class)); } @Test void shouldWatchOnUpdateSuccessfully() { doNothing().when(watcher).onUpdate(any(), any()); shouldUpdateSuccessfully(); verify(watcher, times(1)).onUpdate(any(), any()); } @Test void shouldNotWatchOnUpdateIfExtensionNotChange() { shouldNotUpdateIfExtensionNotChange(); verify(watcher, never()).onUpdate(any(), any()); } @Test void shouldNotWatchOnUpdateIfExtensionStatusChangeOnly() { shouldUpdateIfExtensionStatusChangedOnly(); verify(watcher, never()).onUpdate(any(), any()); } @Test void shouldWatchOnDeleteSuccessfully() { doNothing().when(watcher).onDelete(any()); shouldDeleteSuccessfully(); verify(watcher, times(1)).onDelete(any()); } @Test void shouldWatchRealTypeOnAdd() { var name = "/registry/fake.halo.run/fakes/fake"; var extensionStore = createExtensionStore(name); var fake = createFakeExtension("fake", 1L); var unstructured = Unstructured.OBJECT_MAPPER.convertValue(fake, Unstructured.class); when(converter.convertTo(unstructured)).thenReturn(extensionStore); when(converter.convertFrom(Unstructured.class, extensionStore)) .thenReturn(unstructured); when(storeClient.create(eq(name), any(byte[].class))) .thenReturn(Mono.just(extensionStore)); doNothing().when(watcher).onAdd(isA(FakeExtension.class)); client.create(unstructured) .as(StepVerifier::create) .expectNext(unstructured) .verifyComplete(); } @Test void shouldWatchRealTypeOnUpdate() { var name = "/registry/fake.halo.run/fakes/fake"; var oldExtensionStore = createExtensionStore(name, 1L); var extensionStore = createExtensionStore(name, 2L); var oldFake = createFakeExtension("fake", 1L); var fake = createFakeExtension("fake", 2L); var oldUnstructured = Unstructured.OBJECT_MAPPER.convertValue(oldFake, Unstructured.class); var unstructured = Unstructured.OBJECT_MAPPER.convertValue(fake, Unstructured.class); when(storeClient.fetchByName(name)) .thenReturn(Mono.just(oldExtensionStore)); when(converter.convertFrom(Unstructured.class, oldExtensionStore)) .thenReturn(oldUnstructured); when(converter.convertFrom(Unstructured.class, extensionStore)) .thenReturn(unstructured); when(converter.convertTo(isA(JsonExtension.class))).thenReturn(extensionStore); when(storeClient.update(eq(name), eq(2L), any(byte[].class))) .thenReturn(Mono.just(extensionStore)); doNothing().when(watcher).onUpdate(isA(FakeExtension.class), isA(FakeExtension.class)); client.update(unstructured) .as(StepVerifier::create) .expectNext(unstructured) .verifyComplete(); } @Test void shouldWatchRealTypeOnDelete() { var name = "/registry/fake.halo.run/fakes/fake"; var extensionStore = createExtensionStore(name, 1L); var fake = createFakeExtension("fake", 1L); var unstructured = Unstructured.OBJECT_MAPPER.convertValue(fake, Unstructured.class); when(converter.convertFrom(Unstructured.class, extensionStore)) .thenReturn(unstructured); when(converter.convertTo(unstructured)).thenReturn(extensionStore); when(storeClient.update(eq(name), eq(1L), any(byte[].class))) .thenReturn(Mono.just(extensionStore)); doNothing().when(watcher).onDelete(isA(FakeExtension.class)); client.delete(unstructured) .as(StepVerifier::create) .expectNext(unstructured) .verifyComplete(); } } } ================================================ FILE: application/src/test/java/run/halo/app/extension/RefTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static run.halo.app.extension.GroupVersionKind.fromAPIVersionAndKind; import static run.halo.app.extension.GroupVersionKind.fromExtension; import org.junit.jupiter.api.Test; class RefTest { @Test void shouldHasSameGroupAndKind() { FakeExtension fake = new FakeExtension(); Metadata metadata = new Metadata(); metadata.setName("fake"); fake.setMetadata(metadata); assertTrue(Ref.groupKindEquals(Ref.of(fake), fromExtension(fake.getClass()))); // has different version assertTrue(Ref.groupKindEquals(Ref.of(fake), fromAPIVersionAndKind("fake.halo.run/v11111111111", "Fake"))); assertFalse(Ref.groupKindEquals(Ref.of(fake), fromAPIVersionAndKind("fake.halo.run/v1alpha1", "NotFake"))); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/SchemeTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.jupiter.api.Test; class SchemeTest { @Test void requiredFieldTest() { assertThrows(IllegalArgumentException.class, () -> new Scheme(null, new GroupVersionKind("", "v1alpha1", ""), "", "", null)); assertThrows(IllegalArgumentException.class, () -> new Scheme(FakeExtension.class, new GroupVersionKind("", "", ""), "", "", null)); assertThrows(IllegalArgumentException.class, () -> new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", ""), "", "", null)); assertThrows(IllegalArgumentException.class, () -> new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", "Fake"), "", "", null)); assertThrows(IllegalArgumentException.class, () -> new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", "Fake"), "fakes", "", null)); assertThrows(IllegalArgumentException.class, () -> { new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", "Fake"), "fakes", "fake", null); }); new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", "Fake"), "fakes", "fake", new ObjectNode(null)); } @Test void shouldThrowExceptionWhenTypeHasNoGvkAnno() { class NoGvkExtension extends AbstractExtension { } assertThrows(IllegalArgumentException.class, () -> Scheme.getGvkFromType(NoGvkExtension.class)); assertThrows(IllegalArgumentException.class, () -> Scheme.buildFromType(NoGvkExtension.class)); } @Test void shouldGetGvkFromTypeWithGvkAnno() { var gvk = Scheme.getGvkFromType(FakeExtension.class); assertEquals("fake.halo.run", gvk.group()); assertEquals("v1alpha1", gvk.version()); assertEquals("Fake", gvk.kind()); assertEquals("fake", gvk.singular()); assertEquals("fakes", gvk.plural()); } @Test void shouldCreateSchemeSuccessfully() { var scheme = Scheme.buildFromType(FakeExtension.class); assertEquals(new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"), scheme.groupVersionKind()); assertEquals("fake", scheme.singular()); assertEquals("fakes", scheme.plural()); assertNotNull(scheme.openApiSchema()); assertEquals(FakeExtension.class, scheme.type()); } @Test void equalsAndHashCodeTest() { var scheme1 = Scheme.buildFromType(FakeExtension.class); var scheme2 = Scheme.buildFromType(FakeExtension.class); assertEquals(scheme1, scheme2); assertEquals(scheme1.hashCode(), scheme2.hashCode()); // openApiSchema is not included in equals and hashCode. var scheme3 = new Scheme(FakeExtension.class, scheme1.groupVersionKind(), scheme1.plural(), scheme1.singular(), JsonNodeFactory.instance.objectNode()); assertEquals(scheme1, scheme3); // singular and plural are not included in equals and hashCode. var scheme4 = new Scheme(FakeExtension.class, scheme1.groupVersionKind(), scheme1.plural(), "other", scheme1.openApiSchema()); assertEquals(scheme1, scheme4); // plural is not included in equals and hashCode. var scheme5 = new Scheme(FakeExtension.class, scheme1.groupVersionKind(), "other", scheme1.singular(), scheme1.openApiSchema()); assertEquals(scheme1, scheme5); // type is not included in equals and hashCode. var scheme6 = new Scheme(FakeExtension.class, scheme1.groupVersionKind(), scheme1.plural(), scheme1.singular(), scheme1.openApiSchema()); assertEquals(scheme1, scheme6); // groupVersionKind is included in equals and hashCode. var scheme7 = new Scheme(FakeExtension.class, new GroupVersionKind("other.halo.run", "v1alpha1", "Fake"), scheme1.plural(), scheme1.singular(), scheme1.openApiSchema()); assertNotEquals(scheme1, scheme7); scheme7 = new Scheme(FakeExtension.class, new GroupVersionKind("fake.halo.run", "v1alpha2", "Fake"), scheme1.plural(), scheme1.singular(), scheme1.openApiSchema()); assertNotEquals(scheme1, scheme7); scheme7 = new Scheme(FakeExtension.class, new GroupVersionKind("fake.halo.run", "v1alpha1", "Other"), scheme1.plural(), scheme1.singular(), scheme1.openApiSchema()); assertNotEquals(scheme1, scheme7); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/UnstructuredTest.java ================================================ package run.halo.app.extension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.time.Instant; import java.util.HashMap; import java.util.Map; import java.util.Set; import org.json.JSONException; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.test.json.JsonAssert; import org.springframework.test.json.JsonCompareMode; import tools.jackson.databind.json.JsonMapper; class UnstructuredTest { ObjectMapper objectMapper = Unstructured.OBJECT_MAPPER; String extensionJson = """ { "apiVersion": "fake.halo.run/v1alpha1", "kind": "Fake", "metadata": { "labels": { "category": "fake", "default": "true" }, "name": "fake-extension", "creationTimestamp": "2011-12-03T10:15:30Z", "version": 12345, "finalizers": ["finalizer.1", "finalizer.2"] } } """; @Test void shouldSerializeCorrectly() throws JsonProcessingException { Map extensionMap = objectMapper.readValue(extensionJson, Map.class); var extension = new Unstructured(extensionMap); var gotNode = objectMapper.valueToTree(extension); assertEquals(objectMapper.readTree(extensionJson), gotNode); } @Test void shouldSetCreationTimestamp() throws JsonProcessingException, JSONException { Map extensionMap = objectMapper.readValue(extensionJson, Map.class); var extension = new Unstructured(extensionMap); var beforeChange = objectMapper.writeValueAsString(extension); var metadata = extension.getMetadata(); metadata.setCreationTimestamp(metadata.getCreationTimestamp()); var afterChange = objectMapper.writeValueAsString(extension); JSONAssert.assertEquals(beforeChange, afterChange, true); } @Test void shouldDeserializeCorrectly() throws JsonProcessingException, JSONException { var extension = objectMapper.readValue(extensionJson, Unstructured.class); var gotJson = objectMapper.writeValueAsString(extension); JSONAssert.assertEquals(extensionJson, gotJson, true); } @Test void shouldGetExtensionCorrectly() throws JsonProcessingException { var extension = objectMapper.readValue(extensionJson, Unstructured.class); assertEquals("fake.halo.run/v1alpha1", extension.getApiVersion()); assertEquals("Fake", extension.getKind()); MetadataOperator.equals(createMetadata(), extension.getMetadata()); } @Test void shouldSetExtensionCorrectly() { var extension = createUnstructured(); assertEquals("fake.halo.run/v1alpha1", extension.getApiVersion()); assertEquals("Fake", extension.getKind()); assertTrue(MetadataOperator.equals(createMetadata(), extension.getMetadata())); } @Test void shouldBeEqual() { assertEquals(new Unstructured(), new Unstructured()); assertEquals(createUnstructured(), createUnstructured()); } @Test void shouldNotBeEqual() { var another = createUnstructured(); another.getMetadata().setName("fake-extension-2"); assertNotEquals(createUnstructured(), another); } @Test void shouldGetFinalizersCorrectly() throws JsonProcessingException { var extension = objectMapper.readValue(extensionJson, Unstructured.class); assertEquals(Set.of("finalizer.1", "finalizer.2"), extension.getMetadata().getFinalizers()); extension.getMetadata().setFinalizers(Set.of("finalizer.3", "finalizer.4")); assertEquals(Set.of("finalizer.3", "finalizer.4"), extension.getMetadata().getFinalizers()); } @Test void shouldSetLabelsCorrectly() throws JsonProcessingException { var extension = objectMapper.readValue(extensionJson, Unstructured.class); assertEquals(Map.of("category", "fake", "default", "true"), extension.getMetadata().getLabels()); extension.getMetadata().setLabels(Map.of("category", "fake", "default", "false")); assertEquals(Map.of("category", "fake", "default", "false"), extension.getMetadata().getLabels()); } @Test void shouldSetAnnotationsCorrectly() throws JsonProcessingException { var extension = objectMapper.readValue(extensionJson, Unstructured.class); assertNull(extension.getMetadata().getAnnotations()); extension.getMetadata() .setAnnotations(Map.of("annotation1", "value1", "annotation2", "value2")); assertEquals(Map.of("annotation1", "value1", "annotation2", "value2"), extension.getMetadata().getAnnotations()); } @Nested class JacksonJson3Test { JsonMapper jsonMapper = JsonMapper.shared(); @Test void shouldSerializeCorrectly() { var json = """ { "apiVersion": "fake.halo.run/v1alpha1", "kind": "Fake", "metadata": { "labels": { "category": "fake", "default": "true" }, "name": "fake-extension", "creationTimestamp": "2011-12-03T10:15:30Z", "version": 12345 }, "spec": { "field1": "value1", "field2": 2 } } """; var unstructured = jsonMapper.readValue(json, Unstructured.class); assertEquals("fake-extension", unstructured.getMetadata().getName()); assertEquals("fake.halo.run/v1alpha1", unstructured.getApiVersion()); assertEquals("Fake", unstructured.getKind()); assertEquals("fake", unstructured.getMetadata().getLabels().get("category")); assertEquals("true", unstructured.getMetadata().getLabels().get("default")); assertEquals(Instant.parse("2011-12-03T10:15:30Z"), unstructured.getMetadata().getCreationTimestamp()); assertEquals(12345L, unstructured.getMetadata().getVersion()); var field1 = Unstructured.getNestedValue(unstructured.getData(), "spec", "field1").orElse(null); var field2 = Unstructured.getNestedValue(unstructured.getData(), "spec", "field2").orElse(null); assertEquals("value1", field1); assertEquals(2, field2); } @Test void shouldDeserializeCorrectly() { var u = new Unstructured(); u.setApiVersion("fake.halo.run/v1alpha1"); u.setKind("Fake"); var metadata = new Metadata(); metadata.setName("fake-extension"); u.setMetadata(metadata); Unstructured.setNestedValue(u.getData(), new HashMap<>(), "spec"); Unstructured.setNestedValue(u.getData(), "value1", "spec", "field1"); var json = jsonMapper.writeValueAsString(u); JsonAssert.comparator(JsonCompareMode.STRICT).assertIsMatch(""" { "apiVersion": "fake.halo.run/v1alpha1", "kind": "Fake", "metadata": { "name": "fake-extension" }, "spec": { "field1": "value1" } } """, json); } } Unstructured createUnstructured() { var unstructured = new Unstructured(); unstructured.setApiVersion("fake.halo.run/v1alpha1"); unstructured.setKind("Fake"); unstructured.setMetadata(createMetadata()); return unstructured; } private Metadata createMetadata() { var metadata = new Metadata(); metadata.setName("fake-extension"); metadata.setLabels(Map.of("category", "fake", "default", "true")); metadata.setCreationTimestamp(Instant.parse("2011-12-03T10:15:30Z")); metadata.setVersion(12345L); return metadata; } } ================================================ FILE: application/src/test/java/run/halo/app/extension/gc/GcReconcilerTest.java ================================================ package run.halo.app.extension.gc; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Instant; import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.transaction.ReactiveTransaction; import org.springframework.transaction.ReactiveTransactionManager; import reactor.core.publisher.Mono; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionConverter; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.Metadata; import run.halo.app.extension.Scheme; import run.halo.app.extension.SchemeManager; import run.halo.app.extension.index.IndexEngine; import run.halo.app.extension.store.ExtensionStore; import run.halo.app.extension.store.ReactiveExtensionStoreClient; @ExtendWith(MockitoExtension.class) class GcReconcilerTest { @Mock ExtensionClient client; @Mock ReactiveExtensionStoreClient storeClient; @Mock ExtensionConverter converter; @Mock SchemeManager schemeManager; @Mock IndexEngine indexEngine; @Mock ReactiveTransactionManager txManager; @InjectMocks GcReconciler reconciler; @BeforeEach void setUp() { var scheme = Scheme.buildFromType(FakeExtension.class); when(schemeManager.get(scheme.groupVersionKind())).thenReturn(scheme); } @Test void shouldDoNothingIfExtensionNotFound() { var fake = createExtension(); when(client.fetch(FakeExtension.class, fake.getMetadata().getName())) .thenReturn(Optional.empty()); var result = reconciler.reconcile(createGcRequest()); assertNull(result); verify(converter, never()).convertTo(any()); verify(storeClient, never()).delete(any(), any()); } @Test void shouldDoNothingIfFinalizersPresent() { var fake = createExtension(); fake.getMetadata().setFinalizers(Set.of("fake-finalizer")); fake.getMetadata().setDeletionTimestamp(null); when(client.fetch(FakeExtension.class, fake.getMetadata().getName())) .thenReturn(Optional.of(fake)); var result = reconciler.reconcile(createGcRequest()); assertNull(result); verify(converter, never()).convertTo(any()); verify(storeClient, never()).delete(any(), any()); } @Test void shouldDoNothingIfDeletionTimestampIsNull() { var fake = createExtension(); fake.getMetadata().setDeletionTimestamp(null); fake.getMetadata().setFinalizers(null); when(client.fetch(FakeExtension.class, fake.getMetadata().getName())) .thenReturn(Optional.of(fake)); var result = reconciler.reconcile(createGcRequest()); assertNull(result); verify(converter, never()).convertTo(any()); verify(storeClient, never()).delete(any(), any()); } @Test void shouldDeleteCorrectly() { var fake = createExtension(); fake.getMetadata().setDeletionTimestamp(Instant.now()); fake.getMetadata().setFinalizers(null); when(client.fetch(FakeExtension.class, fake.getMetadata().getName())) .thenReturn(Optional.of(fake)); ExtensionStore store = new ExtensionStore(); store.setName("fake-store-name"); store.setVersion(1L); when(converter.convertTo(any())).thenReturn(store); doNothing().when(indexEngine).delete(any()); var tx = mock(ReactiveTransaction.class); when(txManager.getReactiveTransaction(any())).thenReturn(Mono.just(tx)); when(txManager.commit(tx)).thenReturn(Mono.empty()); when(storeClient.delete("fake-store-name", 1L)).thenReturn(Mono.just(store)); var result = reconciler.reconcile(createGcRequest()); assertNull(result); verify(converter).convertTo(any()); verify(storeClient).delete("fake-store-name", 1L); } GcRequest createGcRequest() { var fake = createExtension(); return new GcRequest(fake.groupVersionKind(), fake.getMetadata().getName()); } FakeExtension createExtension() { var fake = new FakeExtension(); var metadata = new Metadata(); metadata.setName("fake"); fake.setMetadata(metadata); return fake; } } ================================================ FILE: application/src/test/java/run/halo/app/extension/gc/GcSynchronizerTest.java ================================================ package run.halo.app.extension.gc; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.verify; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.SchemeManager; @ExtendWith(MockitoExtension.class) class GcSynchronizerTest { @Mock ExtensionClient client; @Mock SchemeManager schemeManager; @InjectMocks GcSynchronizer synchronizer; @Test void shouldStartNormally() { synchronizer.start(); assertFalse(synchronizer.isDisposed()); verify(client).watch(isA(GcWatcher.class)); verify(schemeManager).schemes(); } @Test void shouldDisposeSuccessfully() { assertFalse(synchronizer.isDisposed()); synchronizer.dispose(); assertTrue(synchronizer.isDisposed()); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/gc/GcWatcherTest.java ================================================ package run.halo.app.extension.gc; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import java.time.Instant; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.RequestQueue; @ExtendWith(MockitoExtension.class) class GcWatcherTest { @Mock RequestQueue queue; @InjectMocks GcWatcher watcher; @Test void shouldAddIntoQueueWhenDeletionTimestampSet() { var fake = createExtension(); fake.getMetadata().setDeletionTimestamp(Instant.now()); watcher.onAdd(fake); verify(queue).addImmediately(any(GcRequest.class)); watcher.onUpdate(fake, fake); verify(queue, times(2)).addImmediately(any(GcRequest.class)); watcher.onDelete(fake); verify(queue, times(3)).addImmediately(any(GcRequest.class)); } @Test void shouldNotAddIntoQueueWhenDeletionTimestampNotSet() { var fake = createExtension(); watcher.onAdd(fake); verify(queue, never()).addImmediately(any(GcRequest.class)); watcher.onUpdate(fake, fake); verify(queue, never()).addImmediately(any(GcRequest.class)); watcher.onDelete(fake); verify(queue, never()).addImmediately(any(GcRequest.class)); } @Test void shouldNotAddIntoQueueWhenDisposed() { var fake = createExtension(); fake.getMetadata().setDeletionTimestamp(Instant.now()); watcher.dispose(); watcher.onAdd(fake); verify(queue, never()).addImmediately(any(GcRequest.class)); watcher.onUpdate(fake, fake); verify(queue, never()).addImmediately(any(GcRequest.class)); watcher.onDelete(fake); verify(queue, never()).addImmediately(any(GcRequest.class)); } @Test void shouldDisposeHookCorrectly() { var run = mock(Runnable.class); watcher.registerDisposeHook(run); assertFalse(watcher.isDisposed()); watcher.dispose(); assertTrue(watcher.isDisposed()); verify(run).run(); } FakeExtension createExtension() { var fake = new FakeExtension(); Metadata metadata = new Metadata(); metadata.setName("fake"); fake.setMetadata(metadata); return fake; } } ================================================ FILE: application/src/test/java/run/halo/app/extension/index/DefaultIndexEngineTest.java ================================================ package run.halo.app.extension.index; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.data.domain.Sort.Direction.ASC; import static org.springframework.data.domain.Sort.Direction.DESC; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.StreamSupport; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.convert.ConversionService; import org.springframework.data.domain.Sort; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.index.query.Queries; @ExtendWith(MockitoExtension.class) class DefaultIndexEngineTest { @Mock ConversionService conversionService; @Mock IndicesManager indicesManager; @Mock Indices indices; @Mock SingleValueIndex singleValueIndex; @InjectMocks DefaultIndexEngine engine; @BeforeEach void setUp() { lenient().when(indicesManager.get(Fake.class)).thenReturn(indices); engine.setIndicesManager(indicesManager); assertEquals(indicesManager, engine.getIndicesManager()); } @Test void shouldDestroyIndicesOnClose() throws Exception { engine.destroy(); verify(indicesManager).close(); } @Test void shouldInsertExtensions() { var fake = createFake("fake"); engine.insert(List.of(fake)); verify(indices).insert(fake); } @Test void shouldUpdateExtensions() { var fake = createFake("fake"); engine.update(List.of(fake)); verify(indices).update(fake); } @Test void shouldDeleteExtensions() { var fake = createFake("fake"); engine.delete(List.of(fake)); verify(indices).delete(fake); } @Test void shouldRetrieveWithConditionsAndPage() { var options = ListOptions.builder() .andQuery(Queries.all("metadata.name")) .build(); var page = PageRequestImpl.of(2, 2); when(indices.getIndex("metadata.name")).thenReturn(singleValueIndex); when(singleValueIndex.all()).thenReturn(Set.of("1", "2", "3", "4", "5", "6")); var result = engine.retrieve(Fake.class, options, page); assertEquals(6, result.getTotal()); assertEquals(List.of("3", "4"), result.getItems()); } @Test void shouldRetrieveWithConditionsAndFirstPage() { when(indices.getIndex("metadata.name")).thenReturn(singleValueIndex); when(singleValueIndex.all()).thenReturn(Set.of("1", "2", "3", "4", "5", "6")); var options = ListOptions.builder() .andQuery(Queries.all("metadata.name")) .build(); var page = PageRequestImpl.of(1, 4); var result = engine.retrieve(Fake.class, options, page); assertEquals(6, result.getTotal()); assertEquals(List.of("1", "2", "3", "4"), result.getItems()); } @Test void shouldRetrieveWithConditionsAndLastPage() { when(indices.getIndex("metadata.name")).thenReturn(singleValueIndex); when(singleValueIndex.all()).thenReturn(Set.of("1", "2", "3", "4", "5", "6")); var options = ListOptions.builder() .andQuery(Queries.all("metadata.name")) .build(); var page = PageRequestImpl.of(2, 4); var result = engine.retrieve(Fake.class, options, page); assertEquals(6, result.getTotal()); assertEquals(List.of("5", "6"), result.getItems()); } @Test void shouldRetrieveWithConditionsAndExceededPage() { when(indices.getIndex("metadata.name")).thenReturn(singleValueIndex); when(singleValueIndex.all()).thenReturn(Set.of("1", "2", "3", "4", "5", "6")); var options = ListOptions.builder() .andQuery(Queries.all("metadata.name")) .build(); var page = PageRequestImpl.of(4, 2); var result = engine.retrieve(Fake.class, options, page); assertEquals(6, result.getTotal()); assertEquals(List.of(), result.getItems()); } @Test void shouldRetrieveAllWithConditionsAndNonPositiveSize() { var allResult = IntStream.rangeClosed(1, 1001) .boxed() .map(o -> String.format("%04d", o)) .collect(Collectors.toSet()); when(indices.getIndex("metadata.name")).thenReturn(singleValueIndex); when(singleValueIndex.all()).thenReturn(allResult); var options = ListOptions.builder() .andQuery(Queries.all("metadata.name")) .build(); var page = PageRequestImpl.of(1, 0); var result = engine.retrieve(Fake.class, options, page); assertEquals(1001, result.getTotal()); assertEquals(1000, result.getItems().size()); assertEquals("0001", result.getItems().getFirst()); assertEquals("1000", result.getItems().getLast()); } @Test void shouldRetrieveAllWithConditions() { var options = ListOptions.builder() .andQuery(Queries.all("metadata.name")) .build(); when(indices.getIndex("metadata.name")).thenReturn(singleValueIndex); when(singleValueIndex.all()).thenReturn(Set.of("1", "2", "3")); when(singleValueIndex.getKey("1")).thenReturn("1"); when(singleValueIndex.getKey("2")).thenReturn("2"); when(singleValueIndex.getKey("3")).thenReturn("3"); var result = engine.retrieveAll(Fake.class, options, Sort.by(DESC, "metadata.name")); assertEquals(List.of("3", "2", "1"), StreamSupport.stream(result.spliterator(), false).toList() ); } @Test void shouldRetrieveTopNWithConditions() { var options = ListOptions.builder() .andQuery(Queries.all("metadata.name")) .build(); when(indices.getIndex("metadata.name")).thenReturn(singleValueIndex); when(singleValueIndex.all()).thenReturn(Set.of("1", "2", "3", "4", "5")); when(singleValueIndex.getKey("1")).thenReturn("1"); when(singleValueIndex.getKey("2")).thenReturn("2"); when(singleValueIndex.getKey("3")).thenReturn("3"); when(singleValueIndex.getKey("4")).thenReturn("4"); when(singleValueIndex.getKey("5")).thenReturn("5"); var result = engine.retrieveTopN(Fake.class, options, Sort.by(DESC, "metadata.name"), 3); assertEquals(List.of("5", "4", "3"), StreamSupport.stream(result.spliterator(), false).toList() ); result = engine.retrieveTopN(Fake.class, options, Sort.by(ASC, "metadata.name"), 2); assertEquals(List.of("1", "2"), StreamSupport.stream(result.spliterator(), false).toList()); } @Test void shouldCountWithConditions() { var options = ListOptions.builder() .andQuery(Queries.all("metadata.name")) .build(); when(indices.getIndex("metadata.name")).thenReturn(singleValueIndex); when(singleValueIndex.all()).thenReturn(Set.of("1", "2", "3", "4")); var count = engine.count(Fake.class, options); assertEquals(4L, count); } Fake createFake(String name) { var fake = new Fake(); fake.setMetadata(new Metadata()); fake.getMetadata().setName(name); return fake; } } ================================================ FILE: application/src/test/java/run/halo/app/extension/index/DefaultIndicesManagerTest.java ================================================ package run.halo.app.extension.index; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class DefaultIndicesManagerTest { @Mock SingleValueIndexSpec singleValueIndexSpec; @Mock MultiValueIndexSpec multiValueIndexSpec; @Mock SingleValueIndexSpec duplicateNameIndexSpec; @InjectMocks DefaultIndicesManager indicesManager; @BeforeEach void setUp() { lenient().when(singleValueIndexSpec.getName()).thenReturn("singleValueIndex"); lenient().when(multiValueIndexSpec.getName()).thenReturn("multiValueIndex"); } @Test void shouldAddDefaultIndexSpecs() { indicesManager.add(Fake.class, List.of(singleValueIndexSpec, multiValueIndexSpec)); var indices = indicesManager.get(Fake.class); assertNotNull(indices.getIndex("metadata.name")); assertNotNull(indices.getIndex("metadata.creationTimestamp")); assertNotNull(indices.getIndex("metadata.deletionTimestamp")); assertNotNull(indices.getIndex("singleValueIndex")); assertNotNull(indices.getIndex("multiValueIndex")); } @Test void shouldThrowExceptionForUnknownType() { assertThrows(IllegalArgumentException.class, () -> indicesManager.get(Fake.class)); } @Test void shouldCloseIndicesManager() throws Exception { indicesManager.add(Fake.class, List.of(singleValueIndexSpec, multiValueIndexSpec)); indicesManager.close(); assertThrows(IllegalArgumentException.class, () -> indicesManager.get(Fake.class)); } @Test void shouldNotOverwriteDefaultIndices() { when(duplicateNameIndexSpec.getName()).thenReturn("metadata.name"); indicesManager.add(Fake.class, List.of(duplicateNameIndexSpec)); var indices = indicesManager.get(Fake.class); var nameIndex = indices.getIndex("metadata.name"); assertSame(String.class, nameIndex.getKeyType()); } @Test void shouldRemoveIndices() { indicesManager.add(Fake.class, List.of(singleValueIndexSpec, multiValueIndexSpec)); indicesManager.remove(Fake.class); assertThrows(IllegalArgumentException.class, () -> indicesManager.get(Fake.class)); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/index/DefaultIndicesTest.java ================================================ package run.halo.app.extension.index; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.extension.Metadata; @ExtendWith(MockitoExtension.class) class DefaultIndicesTest { @Mock Index index1; @Mock Index index2; @Mock Index index3; @Mock Index longIndex; DefaultIndices indices; @BeforeEach void setUp() { when(index1.getName()).thenReturn("index1"); when(index2.getName()).thenReturn("index2"); when(index3.getName()).thenReturn("index3"); indices = new DefaultIndices<>(List.of(index1, index2, index3)); } @Test void shouldNotReplaceWhenDuplicateIndexNames() { when(longIndex.getName()).thenReturn("index1"); indices = new DefaultIndices<>(List.of(index1, longIndex)); // The first index should be retained assertEquals(index1, indices.getIndex("index1")); } @Test void shouldInsertCorrectly() { var fake = createFake("fake"); var to = mock(TransactionalOperation.class); doNothing().when(to).prepare(); doNothing().when(to).commit(); when(index1.prepareInsert(fake)).thenReturn(to); when(index2.prepareInsert(fake)).thenReturn(to); when(index3.prepareInsert(fake)).thenReturn(to); indices.insert(fake); verify(index1).prepareInsert(fake); verify(index2).prepareInsert(fake); verify(index3).prepareInsert(fake); verify(to, times(3)).prepare(); verify(to, times(3)).commit(); verify(to, never()).rollback(); } @Test void shouldUpdateCorrectly() { var fake = createFake("fake"); var to = mock(TransactionalOperation.class); doNothing().when(to).prepare(); doNothing().when(to).commit(); when(index1.prepareUpdate(fake)).thenReturn(to); when(index2.prepareUpdate(fake)).thenReturn(to); when(index3.prepareUpdate(fake)).thenReturn(to); indices.update(fake); verify(index1).prepareUpdate(fake); verify(index2).prepareUpdate(fake); verify(index3).prepareUpdate(fake); verify(to, times(3)).prepare(); verify(to, times(3)).commit(); verify(to, never()).rollback(); } @Test void shouldDeleteCorrectly() { var to = mock(TransactionalOperation.class); doNothing().when(to).prepare(); doNothing().when(to).commit(); var name = "fake"; when(index1.prepareDelete(name)).thenReturn(to); when(index2.prepareDelete(name)).thenReturn(to); when(index3.prepareDelete(name)).thenReturn(to); var fake = createFake(name); indices.delete(fake); verify(index1).prepareDelete(name); verify(index2).prepareDelete(name); verify(index3).prepareDelete(name); verify(to, times(3)).prepare(); verify(to, times(3)).commit(); verify(to, never()).rollback(); } @Test void shouldRollbackOnInsertFailure() { var fake = createFake("fake"); var to1 = mock(TransactionalOperation.class); var to2 = mock(TransactionalOperation.class); var to3 = mock(TransactionalOperation.class); doNothing().when(to1).prepare(); doNothing().when(to1).commit(); doNothing().when(to2).prepare(); // Simulate failure on second index doThrow(new RuntimeException("Insert failed")).when(to2).commit(); doNothing().when(to3).prepare(); when(index1.prepareInsert(fake)).thenReturn(to1); when(index2.prepareInsert(fake)).thenReturn(to2); when(index3.prepareInsert(fake)).thenReturn(to3); assertThrows(RuntimeException.class, () -> indices.insert(fake)); verify(to1).prepare(); verify(to1).commit(); verify(to1).rollback(); verify(to2).prepare(); verify(to2).commit(); verify(to2).rollback(); verify(to3).prepare(); // to3 not committed, so no rollback verify(to3, never()).commit(); verify(to3).rollback(); } @Test void shouldRollbackOnUpdateFailure() { var fake = createFake("fake"); var to1 = mock(TransactionalOperation.class); var to2 = mock(TransactionalOperation.class); var to3 = mock(TransactionalOperation.class); doNothing().when(to1).prepare(); doNothing().when(to1).commit(); doNothing().when(to2).prepare(); // Simulate failure on second index doThrow(new RuntimeException("Update failed")).when(to2).commit(); doNothing().when(to3).prepare(); when(index1.prepareUpdate(fake)).thenReturn(to1); when(index2.prepareUpdate(fake)).thenReturn(to2); when(index3.prepareUpdate(fake)).thenReturn(to3); assertThrows(RuntimeException.class, () -> indices.update(fake)); verify(to1).prepare(); verify(to1).commit(); verify(to1).rollback(); verify(to2).prepare(); verify(to2).commit(); verify(to2).rollback(); verify(to3).prepare(); // to3 not committed, so no rollback verify(to3, never()).commit(); verify(to3).rollback(); } @Test void shouldRollbackOnDeleteFailure() { var to1 = mock(TransactionalOperation.class); var to2 = mock(TransactionalOperation.class); var to3 = mock(TransactionalOperation.class); doNothing().when(to1).prepare(); doNothing().when(to1).commit(); doNothing().when(to2).prepare(); // Simulate failure on second index doThrow(new RuntimeException("Delete failed")).when(to2).commit(); doNothing().when(to3).prepare(); var name = "fake"; when(index1.prepareDelete(name)).thenReturn(to1); when(index2.prepareDelete(name)).thenReturn(to2); when(index3.prepareDelete(name)).thenReturn(to3); var fake = createFake(name); assertThrows(RuntimeException.class, () -> indices.delete(fake)); verify(to1).prepare(); verify(to1).commit(); verify(to1).rollback(); verify(to2).prepare(); verify(to2).commit(); verify(to2).rollback(); verify(to3).prepare(); // to3 not committed, so no rollback verify(to3, never()).commit(); verify(to3).rollback(); } @Test void shouldGetIndexCorrectly() { assertEquals(index1, indices.getIndex("index1")); assertEquals(index2, indices.getIndex("index2")); assertEquals(index3, indices.getIndex("index3")); assertThrows(IllegalArgumentException.class, () -> indices.getIndex("non-existent")); } @Test void shouldCloseIndicesCorrectly() throws Exception { doNothing().when(index1).close(); doNothing().when(index2).close(); doNothing().when(index3).close(); indices.close(); verify(index1).close(); verify(index2).close(); verify(index3).close(); var fake = createFake("fake"); assertThrows(IllegalStateException.class, () -> indices.insert(fake)); assertThrows(IllegalStateException.class, () -> indices.update(fake)); assertThrows(IllegalStateException.class, () -> indices.delete(fake)); assertThrows(IllegalStateException.class, () -> indices.getIndex("index1")); } Fake createFake(String name) { var fake = new Fake(); fake.setMetadata(new Metadata()); fake.getMetadata().setName(name); return fake; } } ================================================ FILE: application/src/test/java/run/halo/app/extension/index/Fake.java ================================================ package run.halo.app.extension.index; import java.util.HashSet; import java.util.Set; import lombok.Data; import lombok.EqualsAndHashCode; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @GVK( group = "fake.halo.app", version = "v1", kind = "Fake", singular = "fake", plural = "fakes" ) @Data @EqualsAndHashCode(callSuper = true) class Fake extends AbstractExtension { private Set stringValues = new HashSet<>(); private String stringValue; } ================================================ FILE: application/src/test/java/run/halo/app/extension/index/LabelIndexTest.java ================================================ package run.halo.app.extension.index; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Set; import lombok.Data; import lombok.EqualsAndHashCode; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.Metadata; @ExtendWith(MockitoExtension.class) class LabelIndexTest { @InjectMocks LabelIndex index; @Nested class IndexOperationTests { @Test void shouldInsertCorrectly() { var fake = createFake("fake"); fake.getMetadata().setLabels(Map.of("k1", "v1", "k2", "v2")); var op = index.prepareInsert(fake); op.prepare(); op.commit(); assertEquals(Set.of("fake"), index.equal("k1", "v1")); assertEquals(Set.of("fake"), index.exists("k1")); assertTrue(index.equal("k1", "non-existent").isEmpty()); } @Test void shouldUpdateAndRollbackCorrectly() throws Exception { var fake = createFake("fake"); fake.getMetadata().setLabels(Map.of("k1", "v1")); // insert var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); // update to v2 fake.getMetadata().setLabels(Map.of("k1", "v2")); var update = index.prepareUpdate(fake); update.prepare(); update.commit(); assertTrue(index.equal("k1", "v1").isEmpty()); assertEquals(Set.of("fake"), index.equal("k1", "v2")); // rollback should restore previous state (v1) update.rollback(); assertEquals(Set.of("fake"), index.equal("k1", "v1")); assertTrue(index.equal("k1", "v2").isEmpty()); } @Test void shouldDeleteAndRollbackCorrectly() throws Exception { var fake = createFake("fake"); fake.getMetadata().setLabels(Map.of("k1", "v1")); insert(fake); var delete = index.prepareDelete("fake"); delete.prepare(); delete.commit(); assertTrue(index.equal("k1", "v1").isEmpty()); // rollback restores it delete.rollback(); assertEquals(Set.of("fake"), index.equal("k1", "v1")); } @Test void shouldTreatEmptyLabelsAsEmptySet() throws Exception { var fake = createFake("empty"); fake.getMetadata().setLabels(Map.of()); var op = index.prepareInsert(fake); op.prepare(); op.commit(); // no entries for any label key assertTrue(index.exists("any").isEmpty()); assertTrue(index.equal("any", "any").isEmpty()); } @Test void shouldCloseIndexCorrectly() throws IOException { var fake = createFake("f"); fake.getMetadata().setLabels(Map.of("k", "v")); insert(fake); index.close(); assertTrue(index.exists("k").isEmpty()); assertTrue(index.equal("k", "v").isEmpty()); } } @Nested class QueryTests { @BeforeEach void setUp() { index = new LabelIndex<>(); // fresh var f1 = createFake("f1"); f1.getMetadata().setLabels(Map.of("k1", "v1")); var f2 = createFake("f2"); f2.getMetadata().setLabels(Map.of("k1", "v2")); var f3 = createFake("f3"); f3.getMetadata().setLabels(Map.of("k1", "v3")); insert(f1); insert(f2); insert(f3); } @Test void existsQuery() { assertEquals(Set.of("f1", "f2", "f3"), index.exists("k1")); assertTrue(index.exists("non-existent").isEmpty()); } @Test void equalAndNotEqualQuery() { assertEquals(Set.of("f1"), index.equal("k1", "v1")); assertEquals(Set.of("f2", "f3"), index.notEqual("k1", "v1")); } @Test void inAndNotInQuery() { var inResult = index.in("k1", List.of("v1", "v3", "non-existent")); assertEquals(Set.of("f1", "f3"), inResult); var notInResult = index.notIn("k1", List.of("v1", "non-existent")); assertEquals(Set.of("f2", "f3"), notInResult); } } void insert(Fake fake) { var op = index.prepareInsert(fake); op.prepare(); op.commit(); } Fake createFake(String name) { var fake = new Fake(); fake.setMetadata(new Metadata()); fake.getMetadata().setName(name); return fake; } @GVK( group = "fake.halo.app", version = "v1", kind = "Fake", singular = "fake", plural = "fakes" ) @Data @EqualsAndHashCode(callSuper = true) static class Fake extends AbstractExtension { // ...existing code... } } ================================================ FILE: application/src/test/java/run/halo/app/extension/index/MultiValueIndexTest.java ================================================ package run.halo.app.extension.index; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; import java.io.IOException; import java.util.Collections; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DuplicateKeyException; import run.halo.app.extension.Metadata; @ExtendWith(MockitoExtension.class) class MultiValueIndexTest { @Mock MultiValueIndexSpec spec; @InjectMocks MultiValueIndex index; @Nested class UniqueMultiValueTests { @BeforeEach void setUp() { lenient().when(spec.getName()).thenReturn("metadata.name"); lenient().when(spec.getKeyType()).thenReturn(String.class); lenient().when(spec.isUnique()).thenReturn(true); lenient().when(spec.getValues(any(Fake.class))).thenAnswer(invocation -> { Fake fake = invocation.getArgument(0); return fake.getStringValues(); }); } @Test void shouldCloseIndexCorrectly() throws IOException { var fake = createFake("fake"); fake.setStringValues(Set.of("a", "b")); insert(fake); index.close(); assertTrue(index.all().isEmpty()); } @Nested class IndexOperationTest { @Test void shouldGetKeyType() { assertEquals(String.class, index.getKeyType()); } @Test void shouldInsertCorrectly() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1", "s2")); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); assertEquals(Set.of("fake"), index.equal("s1")); assertEquals(Set.of("fake"), index.equal("s2")); assertEquals(Set.of("s1", "s2"), index.getKeys("fake")); assertTrue(index.isNull().isEmpty()); } @Test void shouldRollbackInsertCorrectly() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1", "s2")); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); insert.rollback(); assertTrue(index.all().isEmpty()); } @Test void shouldUpdateCorrectly() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1")); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); fake.setStringValues(Set.of("s2", "s3")); var update = index.prepareUpdate(fake); update.prepare(); update.commit(); assertTrue(index.equal("s1").isEmpty()); assertEquals(Set.of("fake"), index.equal("s2")); assertEquals(Set.of("fake"), index.equal("s3")); assertEquals(Set.of("s2", "s3"), index.getKeys("fake")); } @Test void shouldRollbackUpdateCorrectly() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1")); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); fake.setStringValues(Set.of("s2")); var update = index.prepareUpdate(fake); update.prepare(); update.commit(); update.rollback(); assertEquals(Set.of("fake"), index.equal("s1")); assertTrue(index.equal("s2").isEmpty()); assertEquals(Set.of("s1"), index.getKeys("fake")); } @Test void shouldDeleteCorrectly() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1", "s2")); insert(fake); var delete = index.prepareDelete("fake"); delete.prepare(); delete.commit(); assertTrue(index.all().isEmpty()); } @Test void shouldHandleDeleteIfPrimaryKeyNotExist() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1")); insert(fake); var delete = index.prepareDelete("non-existent-fake"); delete.prepare(); delete.commit(); assertEquals(Set.of("fake"), index.equal("s1")); } @Test void shouldRollbackDeleteCorrectly() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1", "s2")); insert(fake); var delete = index.prepareDelete("fake"); delete.prepare(); delete.commit(); delete.rollback(); // rollback restores previous keys assertEquals(Set.of("fake"), index.equal("s1")); assertEquals(Set.of("fake"), index.equal("s2")); assertEquals(Set.of("s1", "s2"), index.getKeys("fake")); } @Test void shouldHandleDuplicateKeysOnInsert() { var fake1 = createFake("fake1"); fake1.setStringValues(Set.of("common")); // first insert var insert1 = index.prepareInsert(fake1); insert1.prepare(); insert1.commit(); // second insert with the same key -> duplicate occurs on commit var fake2 = createFake("fake2"); fake2.setStringValues(Set.of("common")); var insert2 = index.prepareInsert(fake2); insert2.prepare(); assertThrows(DuplicateKeyException.class, insert2::commit); } @Test void shouldHandleDuplicateKeysOnUpdate() { var fake1 = createFake("fake1"); fake1.setStringValues(Set.of("s1")); var insert1 = index.prepareInsert(fake1); insert1.prepare(); insert1.commit(); var fake2 = createFake("fake2"); fake2.setStringValues(Set.of("s2")); var insert2 = index.prepareInsert(fake2); insert2.prepare(); insert2.commit(); // Attempt to update fake2's values to contain "s1", which should cause a duplicate fake2.setStringValues(Set.of("s1")); var update = index.prepareUpdate(fake2); update.prepare(); assertThrows(DuplicateKeyException.class, update::commit); } @Test void shouldHandleEmptyValuesAsNullOnInsert() { var fake = createFake("fake"); fake.setStringValues(Collections.emptySet()); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); assertEquals(Set.of("fake"), index.isNull()); assertTrue(index.isNotNull().isEmpty()); assertEquals(Collections.emptySet(), index.getKeys("fake")); } @Test void shouldHandleEmptyValuesAsNullOnUpdate() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1")); insert(fake); // update to empty values fake.setStringValues(Collections.emptySet()); var update = index.prepareUpdate(fake); update.prepare(); update.commit(); assertEquals(Set.of("fake"), index.isNull()); assertTrue(index.equal("s1").isEmpty()); assertEquals(Collections.emptySet(), index.getKeys("fake")); } } @Nested class IndexQueryTest { @BeforeEach void setUp() { // insert some data for query tests (single-value sets) var fake1 = createFake("fake1"); fake1.setStringValues(Set.of("string1")); var fake2 = createFake("fake2"); fake2.setStringValues(Set.of("string2")); var fake3 = createFake("fake3"); fake3.setStringValues(Set.of("string3")); var fakenull = createFake("fakenull"); fakenull.setStringValues(Set.of()); insert(fake1); insert(fake2); insert(fake3); insert(fakenull); } @Test void equalQuery() { var result1 = index.equal("string1"); assertEquals(Set.of("fake1"), result1); var result2 = index.equal("string2"); assertEquals(Set.of("fake2"), result2); var result3 = index.equal("non-existent-string"); assertTrue(result3.isEmpty()); } @Test void notEqualQuery() { var result1 = index.notEqual("string1"); assertEquals(Set.of("fake2", "fake3"), result1); var result2 = index.notEqual("string2"); assertEquals(Set.of("fake1", "fake3"), result2); var result3 = index.notEqual("non-existent-string"); assertEquals(Set.of("fake1", "fake2", "fake3"), result3); } @Test void allQuery() { var result = index.all(); assertEquals(Set.of("fake1", "fake2", "fake3", "fakenull"), result); } @Test void inQuery() { var result = index.in(Set.of("string1", "string3")); assertEquals(Set.of("fake1", "fake3"), result); } @Test void notInQuery() { var result = index.notIn(Set.of("string1", "string3")); assertEquals(Set.of("fake2"), result); } @Test void isNullQuery() { assertEquals(Set.of("fakenull"), index.isNull()); } @Test void isNotNullQuery() { assertEquals(Set.of("fake1", "fake2", "fake3"), index.isNotNull()); } @Test void betweenQuery() { assertThrows(UnsupportedOperationException.class, () -> index.between("string1", true, "string3", false) ); } @Test void notBetweenQuery() { assertThrows(UnsupportedOperationException.class, () -> index.notBetween("string1", true, "string2", false) ); } @Test void lessThanQuery() { assertThrows(UnsupportedOperationException.class, () -> index.lessThan("string3", false) ); } @Test void greaterThanQuery() { assertThrows(UnsupportedOperationException.class, () -> index.greaterThan("string1", false) ); } @Test void stringContainsQuery() { assertThrows(UnsupportedOperationException.class, () -> index.stringContains("ing") ); } @Test void stringNotContainsQuery() { assertThrows(UnsupportedOperationException.class, () -> index.stringNotContains("ing") ); } @Test void stringStartsWithQuery() { assertThrows(UnsupportedOperationException.class, () -> index.stringStartsWith("string") ); } @Test void stringNotStartsWithQuery() { assertThrows(UnsupportedOperationException.class, () -> index.stringNotStartsWith("string") ); } @Test void stringEndsWithQuery() { assertThrows(UnsupportedOperationException.class, () -> index.stringEndsWith("ing") ); } @Test void stringNotEndsWithQuery() { assertThrows(UnsupportedOperationException.class, () -> index.stringNotEndsWith("ing") ); } } } @Nested class NonUniqueMultiValueTests { @BeforeEach void setUp() { lenient().when(spec.getName()).thenReturn("metadata.name"); lenient().when(spec.getKeyType()).thenReturn(String.class); lenient().when(spec.isUnique()).thenReturn(false); lenient().when(spec.getValues(any(Fake.class))).thenAnswer(invocation -> { Fake fake = invocation.getArgument(0); return fake.getStringValues(); }); } @Test void shouldInsertCorrectly() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1", "s2")); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); assertEquals(Set.of("fake"), index.equal("s1")); assertEquals(Set.of("fake"), index.equal("s2")); assertEquals(Set.of("s1", "s2"), index.getKeys("fake")); assertTrue(index.isNull().isEmpty()); } @Test void shouldRollbackInsertCorrectly() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1", "s2")); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); insert.rollback(); assertTrue(index.all().isEmpty()); } @Test void shouldUpdateCorrectly() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1")); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); fake.setStringValues(Set.of("s2", "s3")); var update = index.prepareUpdate(fake); update.prepare(); update.commit(); assertTrue(index.equal("s1").isEmpty()); assertEquals(Set.of("fake"), index.equal("s2")); assertEquals(Set.of("fake"), index.equal("s3")); assertEquals(Set.of("s2", "s3"), index.getKeys("fake")); } @Test void shouldRollbackUpdateCorrectly() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1")); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); fake.setStringValues(Set.of("s2")); var update = index.prepareUpdate(fake); update.prepare(); update.commit(); update.rollback(); assertEquals(Set.of("fake"), index.equal("s1")); assertTrue(index.equal("s2").isEmpty()); assertEquals(Set.of("s1"), index.getKeys("fake")); } @Test void shouldDeleteCorrectly() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1", "s2")); insert(fake); var delete = index.prepareDelete("fake"); delete.prepare(); delete.commit(); assertTrue(index.all().isEmpty()); } @Test void shouldHandleDeleteIfPrimaryKeyNotExist() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1")); insert(fake); var delete = index.prepareDelete("non-existent-fake"); delete.prepare(); delete.commit(); assertEquals(Set.of("fake"), index.equal("s1")); } @Test void shouldRollbackDeleteCorrectly() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1", "s2")); insert(fake); var delete = index.prepareDelete("fake"); delete.prepare(); delete.commit(); delete.rollback(); // rollback restores previous keys assertEquals(Set.of("fake"), index.equal("s1")); assertEquals(Set.of("fake"), index.equal("s2")); assertEquals(Set.of("s1", "s2"), index.getKeys("fake")); } @Test void shouldAllowDuplicateKeysOnInsert() { var fake1 = createFake("fake1"); fake1.setStringValues(Set.of("common")); // first insert var insert1 = index.prepareInsert(fake1); insert1.prepare(); insert1.commit(); // second insert with the same key -> should succeed for non-unique index var fake2 = createFake("fake2"); fake2.setStringValues(Set.of("common")); var insert2 = index.prepareInsert(fake2); insert2.prepare(); insert2.commit(); assertEquals(Set.of("fake1", "fake2"), index.equal("common")); } @Test void shouldAllowDuplicateKeysOnUpdate() { var fake1 = createFake("fake1"); fake1.setStringValues(Set.of("s1")); var insert1 = index.prepareInsert(fake1); insert1.prepare(); insert1.commit(); var fake2 = createFake("fake2"); fake2.setStringValues(Set.of("s2")); var insert2 = index.prepareInsert(fake2); insert2.prepare(); insert2.commit(); // Update fake2's values to contain "s1" — should be allowed for non-unique index fake2.setStringValues(Set.of("s1")); var update = index.prepareUpdate(fake2); update.prepare(); update.commit(); assertEquals(Set.of("fake1", "fake2"), index.equal("s1")); } @Test void shouldHandleEmptyValuesAsNullOnInsert() { var fake = createFake("fake"); fake.setStringValues(Collections.emptySet()); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); assertEquals(Set.of("fake"), index.isNull()); assertTrue(index.isNotNull().isEmpty()); assertEquals(Collections.emptySet(), index.getKeys("fake")); } @Test void shouldHandleEmptyValuesAsNullOnUpdate() { var fake = createFake("fake"); fake.setStringValues(Set.of("s1")); insert(fake); // update to empty values fake.setStringValues(Collections.emptySet()); var update = index.prepareUpdate(fake); update.prepare(); update.commit(); assertEquals(Set.of("fake"), index.isNull()); assertTrue(index.equal("s1").isEmpty()); assertEquals(Collections.emptySet(), index.getKeys("fake")); } @Nested class IndexQueryTest { @BeforeEach void setUp() { // insert some data for query tests (single-value sets) var fake1 = createFake("fake1"); fake1.setStringValues(Set.of("string1", "string2")); var fake2 = createFake("fake2"); fake2.setStringValues(Set.of("string2")); var fake3 = createFake("fake3"); fake3.setStringValues(Set.of("string3")); var fakenull = createFake("fakenull"); fakenull.setStringValues(Set.of()); insert(fake1); insert(fake2); insert(fake3); insert(fakenull); } @Test void equalQuery() { var result1 = index.equal("string1"); assertEquals(Set.of("fake1"), result1); var result2 = index.equal("string2"); assertEquals(Set.of("fake1", "fake2"), result2); var result3 = index.equal("non-existent-string"); assertTrue(result3.isEmpty()); } @Test void notEqualQuery() { var result1 = index.notEqual("string1"); assertEquals(Set.of("fake2", "fake3"), result1); var result2 = index.notEqual("string2"); assertEquals(Set.of("fake3"), result2); var result3 = index.notEqual("non-existent-string"); assertEquals(Set.of("fake1", "fake2", "fake3"), result3); } @Test void inQuery() { var result = index.in(Set.of("string1", "string2")); assertEquals(Set.of("fake1", "fake2"), result); } @Test void notInQuery() { var result = index.notIn(Set.of("string1", "string3")); assertEquals(Set.of("fake2"), result); result = index.notIn(Set.of("string2")); assertEquals(Set.of("fake3"), result); result = index.notIn(Set.of("non-existent-string")); assertEquals(Set.of("fake1", "fake2", "fake3"), result); } @Test void allQuery() { var result = index.all(); assertEquals(Set.of("fake1", "fake2", "fake3", "fakenull"), result); } @Test void isNullQuery() { assertEquals(Set.of("fakenull"), index.isNull()); } @Test void isNotNullQuery() { assertEquals(Set.of("fake1", "fake2", "fake3"), index.isNotNull()); } @Test void betweenQuery() { assertThrows(UnsupportedOperationException.class, () -> index.between("string1", true, "string3", false) ); } @Test void notBetweenQuery() { assertThrows(UnsupportedOperationException.class, () -> index.notBetween("string1", true, "string2", false) ); } @Test void lessThanQuery() { assertThrows(UnsupportedOperationException.class, () -> index.lessThan("string3", false) ); } @Test void greaterThanQuery() { assertThrows(UnsupportedOperationException.class, () -> index.greaterThan("string1", false) ); } @Test void stringContainsQuery() { assertThrows(UnsupportedOperationException.class, () -> index.stringContains("ing") ); } @Test void stringNotContainsQuery() { assertThrows(UnsupportedOperationException.class, () -> index.stringNotContains("ing") ); } @Test void stringStartsWithQuery() { assertThrows(UnsupportedOperationException.class, () -> index.stringStartsWith("string") ); } @Test void stringNotStartsWithQuery() { assertThrows(UnsupportedOperationException.class, () -> index.stringNotStartsWith("string") ); } @Test void stringEndsWithQuery() { assertThrows(UnsupportedOperationException.class, () -> index.stringEndsWith("ing") ); } @Test void stringNotEndsWithQuery() { assertThrows(UnsupportedOperationException.class, () -> index.stringNotEndsWith("ing") ); } } } void insert(Fake fake) { var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); } Fake createFake(String name) { var fake = new Fake(); fake.setMetadata(new Metadata()); fake.getMetadata().setName(name); return fake; } } ================================================ FILE: application/src/test/java/run/halo/app/extension/index/SingleValueIndexTest.java ================================================ package run.halo.app.extension.index; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; import java.io.IOException; import java.util.List; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DuplicateKeyException; import run.halo.app.extension.Metadata; @ExtendWith(MockitoExtension.class) class SingleValueIndexTest { @Mock SingleValueIndexSpec spec; @InjectMocks SingleValueIndex index; @Nested class NonNullAndUniqueTest { @BeforeEach void setUp() { lenient().when(spec.getName()).thenReturn("metadata.name"); lenient().when(spec.getKeyType()).thenReturn(String.class); lenient().when(spec.isNullable()).thenReturn(false); lenient().when(spec.isUnique()).thenReturn(true); lenient().when(spec.getValue(any(Fake.class))).thenAnswer(invocation -> { Fake fake = invocation.getArgument(0); return fake.getStringValue(); }); } @Test void shouldCloseIndexCorrectly() throws IOException { var fake = createFake("fake"); fake.setStringValue("string"); insert(fake); index.close(); assertEquals(Set.of(), index.equal("string")); } @Nested class IndexOperationTest { @Test void shouldGetKeyType() { assertEquals(String.class, index.getKeyType()); } @Test void shouldInsertCorrectly() { var fake = createFake("fake"); fake.setStringValue("string"); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); assertEquals(Set.of("fake"), index.equal("string")); } @Test void shouldRollbackInsertCorrectly() { var fake = createFake("fake"); fake.setStringValue("string"); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); insert.rollback(); assertEquals(Set.of(), index.equal("string")); } @Test void shouldUpdateCorrectly() { var fake = createFake("fake"); fake.setStringValue("string"); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); fake.setStringValue("new-string"); var update = index.prepareUpdate(fake); update.prepare(); update.commit(); assertEquals(Set.of("fake"), index.equal("new-string")); } @Test void shouldRollbackUpdateCorrectly() { var fake = createFake("fake"); fake.setStringValue("string"); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); fake.setStringValue("new-string"); var update = index.prepareUpdate(fake); update.prepare(); update.commit(); update.rollback(); assertEquals(Set.of("fake"), index.equal("string")); } @Test void shouldDeleteCorrectly() { var fake = createFake("fake"); fake.setStringValue("string"); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); var delete = index.prepareDelete("fake"); delete.prepare(); delete.commit(); assertEquals(Set.of(), index.equal("string")); } @Test void shouldHandleDeleteIfPrimaryKeyNotExist() { var fake = createFake("fake"); fake.setStringValue("string"); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); var delete = index.prepareDelete("non-existent-fake"); delete.prepare(); delete.commit(); assertEquals(Set.of("fake"), index.equal("string")); } @Test void shouldRollbackDeleteCorrectly() { var fake = createFake("fake"); fake.setStringValue("string"); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); var delete = index.prepareDelete("fake"); delete.prepare(); delete.commit(); delete.rollback(); assertEquals(Set.of("fake"), index.equal("string")); } @Test void shouldHandleDuplicateKeysOnInsert() { var fake1 = createFake("fake1"); fake1.setStringValue("string"); // first insert var insert = index.prepareInsert(fake1); insert.prepare(); insert.commit(); // second insert with the same string value var fake2 = createFake("fake2"); fake2.setStringValue("string"); insert = index.prepareInsert(fake2); assertThrows(DuplicateKeyException.class, insert::prepare); } @Test void shouldHandleDuplicateKeysOnUpdate() { var fake1 = createFake("fake1"); fake1.setStringValue("string1"); var insert1 = index.prepareInsert(fake1); insert1.prepare(); insert1.commit(); var fake2 = createFake("fake2"); fake2.setStringValue("string2"); var insert2 = index.prepareInsert(fake2); insert2.prepare(); insert2.commit(); // Attempt to update fake2's stringValue to "string1", which should cause a // duplicate // key fake2.setStringValue("string1"); var update = index.prepareUpdate(fake2); assertThrows(DuplicateKeyException.class, update::prepare); } @Test void shouldHandleNullValueOnInsert() { var fake = createFake("fake"); fake.setStringValue(null); var insert = index.prepareInsert(fake); assertThrows(IllegalArgumentException.class, insert::prepare); } @Test void shouldHandleNullValueOnUpdate() { var fake = createFake("fake"); fake.setStringValue("string"); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); // Now update to null value fake.setStringValue(null); var update = index.prepareUpdate(fake); assertThrows(IllegalArgumentException.class, update::prepare); } } @Nested class IndexQueryTest { @BeforeEach void setUp() { // insert some data for query tests var fake1 = createFake("fake1"); fake1.setStringValue("string1"); var fake2 = createFake("fake2"); fake2.setStringValue("string2"); var fake3 = createFake("fake3"); fake3.setStringValue("string3"); insert(fake1); insert(fake2); insert(fake3); } @Test void equalQuery() { var result1 = index.equal("string1"); assertEquals(Set.of("fake1"), result1); var result2 = index.equal("string2"); assertEquals(Set.of("fake2"), result2); var result3 = index.equal("non-existent-string"); assertTrue(result3.isEmpty()); } @Test void notEqualQuery() { var result1 = index.notEqual("string1"); assertEquals(Set.of("fake2", "fake3"), result1); var result2 = index.notEqual("string2"); assertEquals(Set.of("fake1", "fake3"), result2); var result3 = index.notEqual("non-existent-string"); assertEquals(Set.of("fake1", "fake2", "fake3"), result3); } @Test void allQuery() { var result = index.all(); assertEquals(Set.of("fake1", "fake2", "fake3"), result); } @Test void betweenQuery() { var result = index.between("string1", true, "string3", false); assertEquals(Set.of("fake1", "fake2"), result); } @Test void notBetweenQuery() { var result = index.notBetween("string1", true, "string2", false); assertEquals(Set.of("fake2", "fake3"), result); } @Test void inQuery() { var result = index.in(Set.of("string1", "string3")); assertEquals(Set.of("fake1", "fake3"), result); } @Test void notInQueryForSet() { var result = index.notIn(Set.of("string1", "string3")); assertEquals(Set.of("fake2"), result); } @Test void notInQueryForList() { var result = index.notIn(List.of("string2")); assertEquals(Set.of("fake1", "fake3"), result); } @Test void lessThanQuery() { var result = index.lessThan("string3", false); assertEquals(Set.of("fake1", "fake2"), result); } @Test void greaterThanQuery() { var result = index.greaterThan("string1", false); assertEquals(Set.of("fake2", "fake3"), result); } @Test void isNullQuery() { assertThrows(IllegalArgumentException.class, () -> index.isNull()); } @Test void isNotNullQuery() { assertThrows(IllegalArgumentException.class, () -> index.isNotNull()); } @Test void stringContainsQuery() { assertEquals(Set.of("fake1", "fake2", "fake3"), index.stringContains("ing")); assertEquals(Set.of("fake2"), index.stringContains("ing2")); } @Test void stringNotContainsQuery() { assertEquals(Set.of(), index.stringNotContains("ing")); assertEquals(Set.of("fake1", "fake3"), index.stringNotContains("ing2")); } @Test void stringStartsWithQuery() { assertEquals(Set.of("fake1", "fake2", "fake3"), index.stringStartsWith("string")); assertEquals(Set.of("fake2"), index.stringStartsWith("string2")); } @Test void stringNotStartsWithQuery() { assertEquals(Set.of(), index.stringNotStartsWith("string")); assertEquals(Set.of("fake1", "fake3"), index.stringNotStartsWith("string2")); } @Test void stringEndsWithQuery() { assertEquals(Set.of("fake1"), index.stringEndsWith("ing1")); assertEquals(Set.of("fake3"), index.stringEndsWith("ing3")); } @Test void stringNotEndsWithQuery() { assertEquals(Set.of("fake2", "fake3"), index.stringNotEndsWith("ing1")); assertEquals(Set.of("fake1", "fake2"), index.stringNotEndsWith("ing3")); } } } @Nested class NullableAndNonUniqueTest { @BeforeEach void setUp() { lenient().when(spec.getName()).thenReturn("metadata.name"); lenient().when(spec.getKeyType()).thenReturn(String.class); lenient().when(spec.isNullable()).thenReturn(true); lenient().when(spec.isUnique()).thenReturn(false); lenient().when(spec.getValue(any(Fake.class))).thenAnswer(invocation -> { Fake fake = invocation.getArgument(0); return fake.getStringValue(); }); } @Test void shouldGetKeyType() { assertEquals(String.class, index.getKeyType()); } @Test void shouldInsertAllowDuplicate() { var fake1 = createFake("fake1"); fake1.setStringValue("string"); var fake2 = createFake("fake2"); fake2.setStringValue("string"); var insert1 = index.prepareInsert(fake1); insert1.prepare(); insert1.commit(); var insert2 = index.prepareInsert(fake2); insert2.prepare(); insert2.commit(); assertEquals(Set.of("fake1", "fake2"), index.equal("string")); } @Test void shouldUpdateAllowDuplicate() { var fake1 = createFake("fake1"); fake1.setStringValue("string1"); var insert1 = index.prepareInsert(fake1); insert1.prepare(); insert1.commit(); var fake2 = createFake("fake2"); fake2.setStringValue("string2"); var insert2 = index.prepareInsert(fake2); insert2.prepare(); insert2.commit(); // update fake2 to string1 should be allowed when not unique fake2.setStringValue("string1"); var update = index.prepareUpdate(fake2); update.prepare(); update.commit(); assertEquals(Set.of("fake1", "fake2"), index.equal("string1")); } @Test void shouldHandleNullValueOnInsert() { var fake = createFake("fake"); fake.setStringValue(null); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); assertEquals(Set.of("fake"), index.isNull()); // no non-null entries exist assertTrue(index.isNotNull().isEmpty()); } @Test void shouldHandleNullValueOnUpdate() { var fake = createFake("fake"); fake.setStringValue("string"); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); // update to null should be allowed fake.setStringValue(null); var update = index.prepareUpdate(fake); update.prepare(); update.commit(); assertEquals(Set.of("fake"), index.isNull()); assertTrue(index.equal("string").isEmpty()); } @Test void shouldRollbackInsertCorrectly() { var fake = createFake("fake"); fake.setStringValue("string"); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); insert.rollback(); assertEquals(Set.of(), index.equal("string")); } @Test void shouldRollbackUpdateCorrectly() { var fake = createFake("fake"); fake.setStringValue("string"); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); fake.setStringValue("new-string"); var update = index.prepareUpdate(fake); update.prepare(); update.commit(); update.rollback(); assertEquals(Set.of("fake"), index.equal("string")); } @Test void shouldDeleteCorrectly() { var fake = createFake("fake"); fake.setStringValue("string"); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); var delete = index.prepareDelete("fake"); delete.prepare(); delete.commit(); assertEquals(Set.of(), index.equal("string")); } @Test void shouldHandleDeleteIfPrimaryKeyNotExist() { var fake = createFake("fake"); fake.setStringValue("string"); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); var delete = index.prepareDelete("non-existent-fake"); delete.prepare(); delete.commit(); assertEquals(Set.of("fake"), index.equal("string")); } @Test void shouldRollbackDeleteCorrectly() { var fake = createFake("fake"); fake.setStringValue("string"); var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); var delete = index.prepareDelete("fake"); delete.prepare(); delete.commit(); delete.rollback(); assertEquals(Set.of("fake"), index.equal("string")); } @Nested class IndexQueryTest { @BeforeEach void setUp() { // insert some data for query tests var fake1 = createFake("fake1"); fake1.setStringValue("string1"); var fake2 = createFake("fake2"); fake2.setStringValue("string2"); var fake3 = createFake("fake3"); fake3.setStringValue("string3"); var fakenull = createFake("fakenull"); fakenull.setStringValue(null); insert(fake1); insert(fake2); insert(fake3); insert(fakenull); } @Test void equalQuery() { var result1 = index.equal("string1"); assertEquals(Set.of("fake1"), result1); var result2 = index.equal("string2"); assertEquals(Set.of("fake2"), result2); var result3 = index.equal("non-existent-string"); assertTrue(result3.isEmpty()); } @Test void notEqualQuery() { var result1 = index.notEqual("string1"); assertEquals(Set.of("fake2", "fake3"), result1); var result2 = index.notEqual("string2"); assertEquals(Set.of("fake1", "fake3"), result2); var result3 = index.notEqual("non-existent-string"); assertEquals(Set.of("fake1", "fake2", "fake3"), result3); } @Test void allQuery() { var result = index.all(); assertEquals(Set.of("fake1", "fake2", "fake3"), result); } @Test void betweenQuery() { var result = index.between("string1", true, "string3", false); assertEquals(Set.of("fake1", "fake2"), result); } @Test void notBetweenQuery() { var result = index.notBetween("string1", true, "string2", false); assertEquals(Set.of("fake2", "fake3"), result); } @Test void inQuery() { var result = index.in(Set.of("string1", "string3")); assertEquals(Set.of("fake1", "fake3"), result); } @Test void notInQueryForSet() { var result = index.notIn(Set.of("string1", "string3")); assertEquals(Set.of("fake2"), result); } @Test void notInQueryForList() { var result = index.notIn(List.of("string2")); assertEquals(Set.of("fake1", "fake3"), result); } @Test void lessThanQuery() { var result = index.lessThan("string3", false); assertEquals(Set.of("fake1", "fake2"), result); } @Test void greaterThanQuery() { var result = index.greaterThan("string1", false); assertEquals(Set.of("fake2", "fake3"), result); } @Test void isNullQuery() { assertEquals(Set.of("fakenull"), index.isNull()); } @Test void isNotNullQuery() { assertEquals(Set.of("fake1", "fake2", "fake3"), index.isNotNull()); } @Test void stringContainsQuery() { assertEquals(Set.of("fake1", "fake2", "fake3"), index.stringContains("ing")); assertEquals(Set.of("fake2"), index.stringContains("ing2")); } @Test void stringNotContainsQuery() { assertEquals(Set.of(), index.stringNotContains("ing")); assertEquals(Set.of("fake1", "fake3"), index.stringNotContains("ing2")); } @Test void stringStartsWithQuery() { assertEquals(Set.of("fake1", "fake2", "fake3"), index.stringStartsWith("string")); assertEquals(Set.of("fake2"), index.stringStartsWith("string2")); } @Test void stringNotStartsWithQuery() { assertEquals(Set.of(), index.stringNotStartsWith("string")); assertEquals(Set.of("fake1", "fake3"), index.stringNotStartsWith("string2")); } @Test void stringEndsWithQuery() { assertEquals(Set.of("fake1"), index.stringEndsWith("ing1")); assertEquals(Set.of("fake3"), index.stringEndsWith("ing3")); } @Test void stringNotEndsWithQuery() { assertEquals(Set.of("fake2", "fake3"), index.stringNotEndsWith("ing1")); assertEquals(Set.of("fake1", "fake2"), index.stringNotEndsWith("ing3")); } } } void insert(Fake fake) { var insert = index.prepareInsert(fake); insert.prepare(); insert.commit(); } Fake createFake(String name) { var fake = new Fake(); fake.setMetadata(new Metadata()); fake.getMetadata().setName(name); return fake; } } ================================================ FILE: application/src/test/java/run/halo/app/extension/index/query/QueryVisitorTest.java ================================================ package run.halo.app.extension.index.query; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.withSettings; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.boot.convert.ApplicationConversionService; import org.springframework.core.convert.ConversionFailedException; import org.springframework.core.convert.ConversionService; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.index.Index; import run.halo.app.extension.index.Indices; import run.halo.app.extension.index.LabelIndexQuery; import run.halo.app.extension.index.ValueIndexQuery; @SuppressWarnings({"unchecked", "rawtypes"}) @ExtendWith(MockitoExtension.class) class QueryVisitorTest { @Mock Indices indices; @Spy ConversionService conversionService = ApplicationConversionService.getSharedInstance(); @InjectMocks QueryVisitor visitor; @Test void shouldVisitAllCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.all()).thenReturn(Set.of("all", "data")); var condition = Queries.all("metadata.name"); condition.visit(visitor); assertEquals(Set.of("all", "data"), visitor.getResult()); } @Test void shouldVisitNoneCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); when(indices.getIndex("metadata.name")).thenReturn(index); var condition = Queries.all("metadata.name").not(); condition.visit(visitor); assertEquals(Set.of(), visitor.getResult()); } @Test void shouldVisitEmptyCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.all()).thenReturn(Set.of("all", "data")); var condition = Condition.empty(); condition.visit(visitor); assertEquals(Set.of("all", "data"), visitor.getResult()); } @Test void shouldVisitEqualsCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.equal("test-name")).thenReturn(Set.of("equal", "data")); when(index.getKeyType()).thenReturn(String.class); var condition = Queries.equal("metadata.name", "test-name"); condition.visit(visitor); assertEquals(Set.of("equal", "data"), visitor.getResult()); verify(conversionService).canConvert(String.class, String.class); verify(conversionService).convert("test-name", String.class); } @Test void shouldVisitNotEqualsCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.notEqual("test-name")).thenReturn(Set.of("not-equal", "data")); when(index.getKeyType()).thenReturn(String.class); var condition = Queries.notEqual("metadata.name", "test-name"); condition.visit(visitor); assertEquals(Set.of("not-equal", "data"), visitor.getResult()); verify(conversionService).canConvert(String.class, String.class); verify(conversionService).convert("test-name", String.class); } @Test void shouldVisitInCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.in(argThat(c -> c.containsAll(List.of("name1", "name1"))))) .thenReturn(Set.of("in", "data")); when(index.getKeyType()).thenReturn(String.class); var condition = Queries.in("metadata.name", Set.of("name1", "name2")); condition.visit(visitor); assertEquals(Set.of("in", "data"), visitor.getResult()); verify(conversionService, times(2)).canConvert(String.class, String.class); verify(conversionService).convert("name1", String.class); verify(conversionService).convert("name2", String.class); } @Test void shouldVisitNotInCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.notIn(argThat(c -> c.containsAll(List.of("name1", "name2"))))) .thenReturn(Set.of("not-in", "data")); when(index.getKeyType()).thenReturn(String.class); var condition = Queries.in("metadata.name", Set.of("name1", "name2")).not(); condition.visit(visitor); assertEquals(Set.of("not-in", "data"), visitor.getResult()); verify(conversionService, times(2)).canConvert(String.class, String.class); verify(conversionService).convert("name1", String.class); verify(conversionService).convert("name2", String.class); } @Test void shouldVisitGreaterThanCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.age")).thenReturn(index); when(query.greaterThan(18, false)).thenReturn(Set.of("gt", "data")); when(index.getKeyType()).thenReturn(Integer.class); var condition = Queries.greaterThan("metadata.age", 18); condition.visit(visitor); assertEquals(Set.of("gt", "data"), visitor.getResult()); verify(conversionService).canConvert(Integer.class, Integer.class); verify(conversionService).convert(18, Integer.class); } @Test void shouldVisitLessThanCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.age")).thenReturn(index); when(query.lessThan(65, false)).thenReturn(Set.of("lt", "data")); when(index.getKeyType()).thenReturn(Integer.class); var condition = Queries.lessThan("metadata.age", 65); condition.visit(visitor); assertEquals(Set.of("lt", "data"), visitor.getResult()); verify(conversionService).canConvert(Integer.class, Integer.class); verify(conversionService).convert(65, Integer.class); } @Test void shouldVisitBetweenCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.age")).thenReturn(index); when(query.between(18, true, 65, false)).thenReturn(Set.of("between", "data")); when(index.getKeyType()).thenReturn(Integer.class); var condition = Queries.between("metadata.age", 18, true, 65, false); condition.visit(visitor); assertEquals(Set.of("between", "data"), visitor.getResult()); verify(conversionService, times(2)).canConvert(Integer.class, Integer.class); verify(conversionService).convert(18, Integer.class); verify(conversionService).convert(65, Integer.class); } @Test void shouldVisitNotBetweenCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.age")).thenReturn(index); when(query.notBetween(18, false, 65, true)).thenReturn(Set.of("not-between", "data")); when(index.getKeyType()).thenReturn(Integer.class); var condition = Queries.between("metadata.age", 18, true, 65, false).not(); condition.visit(visitor); assertEquals(Set.of("not-between", "data"), visitor.getResult()); verify(conversionService, times(2)).canConvert(Integer.class, Integer.class); verify(conversionService).convert(18, Integer.class); verify(conversionService).convert(65, Integer.class); } @Test void shouldVisitStringContainsCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.description")).thenReturn(index); when(query.stringContains("keyword")).thenReturn(Set.of("contains", "data")); var condition = Queries.contains("metadata.description", "keyword"); condition.visit(visitor); assertEquals(Set.of("contains", "data"), visitor.getResult()); verify(conversionService, never()).canConvert(String.class, String.class); verify(conversionService, never()).convert("keyword", String.class); } @Test void shouldVisitStringNotContainsCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.description")).thenReturn(index); when(query.stringNotContains("keyword")).thenReturn(Set.of("not-contains", "data")); var condition = Queries.contains("metadata.description", "keyword").not(); condition.visit(visitor); assertEquals(Set.of("not-contains", "data"), visitor.getResult()); verify(conversionService, never()).canConvert(String.class, String.class); verify(conversionService, never()).convert("keyword", String.class); } @Test void shouldVisitStringStartsWithCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.title")).thenReturn(index); when(query.stringStartsWith("prefix")).thenReturn(Set.of("starts-with", "data")); var condition = Queries.startsWith("metadata.title", "prefix"); condition.visit(visitor); assertEquals(Set.of("starts-with", "data"), visitor.getResult()); verify(conversionService, never()).canConvert(String.class, String.class); verify(conversionService, never()).convert("prefix", String.class); } @Test void shouldVisitStringNotStartsWithCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.title")).thenReturn(index); when(query.stringNotStartsWith("prefix")).thenReturn(Set.of("not-starts-with", "data")); var condition = Queries.startsWith("metadata.title", "prefix").not(); condition.visit(visitor); assertEquals(Set.of("not-starts-with", "data"), visitor.getResult()); verify(conversionService, never()).canConvert(String.class, String.class); verify(conversionService, never()).convert("prefix", String.class); } @Test void shouldVisitStringEndsWithCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.title")).thenReturn(index); when(query.stringEndsWith("suffix")).thenReturn(Set.of("ends-with", "data")); var condition = Queries.endsWith("metadata.title", "suffix"); condition.visit(visitor); assertEquals(Set.of("ends-with", "data"), visitor.getResult()); verify(conversionService, never()).canConvert(String.class, String.class); verify(conversionService, never()).convert("suffix", String.class); } @Test void shouldVisitStringNotEndsWithCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.title")).thenReturn(index); when(query.stringNotEndsWith("suffix")).thenReturn(Set.of("not-ends-with", "data")); var condition = Queries.endsWith("metadata.title", "suffix").not(); condition.visit(visitor); assertEquals(Set.of("not-ends-with", "data"), visitor.getResult()); verify(conversionService, never()).canConvert(String.class, String.class); verify(conversionService, never()).convert("suffix", String.class); } @Test void shouldVisitIsNullCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.optionalField")).thenReturn(index); when(query.isNull()).thenReturn(Set.of("is-null", "data")); var condition = Queries.isNull("metadata.optionalField"); condition.visit(visitor); assertEquals(Set.of("is-null", "data"), visitor.getResult()); } @Test void shouldVisitIsNotNullCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.optionalField")).thenReturn(index); when(query.isNotNull()).thenReturn(Set.of("is-not-null", "data")); var condition = Queries.isNull("metadata.optionalField").not(); condition.visit(visitor); assertEquals(Set.of("is-not-null", "data"), visitor.getResult()); } @Test void shouldVisitLabelEqualsCondition() { var index = mock(Index.class, withSettings().extraInterfaces(LabelIndexQuery.class)); var query = (LabelIndexQuery) index; when(indices.getIndex("metadata.labels")).thenReturn(index); when(query.equal("env", "production")).thenReturn(Set.of("label-equal", "data")); var condition = Queries.labelEqual("env", "production"); condition.visit(visitor); assertEquals(Set.of("label-equal", "data"), visitor.getResult()); verify(conversionService, never()).canConvert(String.class, String.class); verify(conversionService, never()).convert("production", String.class); } @Test void shouldVisitLabelNotEqualsCondition() { var index = mock(Index.class, withSettings().extraInterfaces(LabelIndexQuery.class)); var primaryIndex = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (LabelIndexQuery) index; var primaryQuery = (ValueIndexQuery) primaryIndex; when(indices.getIndex("metadata.labels")).thenReturn(index); when(indices.getIndex("metadata.name")).thenReturn(primaryIndex); when(query.equal("env", "production")).thenReturn(Set.of("equal", "data")); when(primaryQuery.all()).thenReturn(Set.of("all", "data")); var condition = Queries.labelEqual("env", "production").not(); condition.visit(visitor); assertEquals(Set.of("all"), visitor.getResult()); } @Test void shouldVisitLabelInCondition() { var index = mock(Index.class, withSettings().extraInterfaces(LabelIndexQuery.class)); var query = (LabelIndexQuery) index; when(indices.getIndex("metadata.labels")).thenReturn(index); when(query.in(eq("env"), eq(Set.of("production", "staging")))) .thenReturn(Set.of("label-in", "data")); var condition = Queries.labelIn("env", Set.of("production", "staging")); condition.visit(visitor); assertEquals(Set.of("label-in", "data"), visitor.getResult()); verify(conversionService, never()).canConvert(String.class, String.class); verify(conversionService, never()).convert("production", String.class); verify(conversionService, never()).convert("staging", String.class); } @Test void shouldVisitLabelNotInCondition() { var index = mock(Index.class, withSettings().extraInterfaces(LabelIndexQuery.class)); var query = (LabelIndexQuery) index; when(indices.getIndex("metadata.labels")).thenReturn(index); when(query.notIn(eq("env"), eq(Set.of("production", "staging")))) .thenReturn(Set.of("label-not-in", "data")); var condition = Queries.labelIn("env", Set.of("production", "staging")).not(); condition.visit(visitor); assertEquals(Set.of("label-not-in", "data"), visitor.getResult()); verify(conversionService, never()).canConvert(String.class, String.class); verify(conversionService, never()).convert("production", String.class); verify(conversionService, never()).convert("staging", String.class); } @Test void shouldVisitLabelExistsCondition() { var index = mock(Index.class, withSettings().extraInterfaces(LabelIndexQuery.class)); var query = (LabelIndexQuery) index; when(indices.getIndex("metadata.labels")).thenReturn(index); when(query.exists("env")).thenReturn(Set.of("label-exists", "data")); var condition = Queries.labelExists("env"); condition.visit(visitor); assertEquals(Set.of("label-exists", "data"), visitor.getResult()); } @Test void shouldVisitLabelNotExistsCondition() { var index = mock(Index.class, withSettings().extraInterfaces(LabelIndexQuery.class)); var primaryIndex = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (LabelIndexQuery) index; var primaryQuery = (ValueIndexQuery) primaryIndex; when(indices.getIndex("metadata.name")).thenReturn(primaryIndex); when(indices.getIndex("metadata.labels")).thenReturn(index); when(query.exists("env")).thenReturn(Set.of("label-exists", "data")); when(primaryQuery.all()).thenReturn(Set.of("all", "data")); var condition = Queries.labelExists("env").not(); condition.visit(visitor); assertEquals(Set.of("all"), visitor.getResult()); } @Test void shouldVisiteAndCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.equal("name1")).thenReturn(Set.of("name1", "data")); when(query.equal("name2")).thenReturn(Set.of("name2", "data")); when(index.getKeyType()).thenReturn(String.class); var condition = Queries.equal("metadata.name", "name1") .and(Queries.equal("metadata.name", "name2")); condition.visit(visitor); assertEquals(Set.of("data"), visitor.getResult()); } @Test void shouldVisiteOrCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.equal("name1")).thenReturn(Set.of("name1", "data")); when(query.equal("name2")).thenReturn(Set.of("name2", "data")); when(index.getKeyType()).thenReturn(String.class); var condition = Queries.equal("metadata.name", "name1") .or(Queries.equal("metadata.name", "name2")); condition.visit(visitor); assertEquals(Set.of("name1", "name2", "data"), visitor.getResult()); } @Test void shouldVisiteNotCondition() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.notEqual("name1")).thenReturn(Set.of("not-equal", "data")); when(index.getKeyType()).thenReturn(String.class); var condition = new NotCondition(Queries.equal("metadata.name", "name1")); condition.visit(visitor); assertEquals(Set.of("not-equal", "data"), visitor.getResult()); } @Test void shouldRefineAndConditionWithLeftEmpty() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.equal("name1")).thenReturn(Set.of("name1", "data")); when(index.getKeyType()).thenReturn(String.class); var condition = Condition.empty() .and(Queries.equal("metadata.name", "name1")); condition.visit(visitor); assertEquals(Set.of("name1", "data"), visitor.getResult()); } @Test void shouldRefineAndConditionWithRightEmpty() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.equal("name1")).thenReturn(Set.of("name1", "data")); when(index.getKeyType()).thenReturn(String.class); var condition = Queries.equal("metadata.name", "name1") .and(Condition.empty()); condition.visit(visitor); assertEquals(Set.of("name1", "data"), visitor.getResult()); } @Test void shouldRefineOrConditionWithLeftEmpty() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.all()).thenReturn(Set.of("all")); var condition = Condition.empty() .or(Queries.equal("metadata.name", "name1")); condition.visit(visitor); assertEquals(Set.of("all"), visitor.getResult()); verify(query, never()).equal("name1"); } @Test void shouldRefineOrConditionWithRightEmpty() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.all()).thenReturn(Set.of("all")); var condition = Queries.equal("metadata.name", "name1") .or(Condition.empty()); condition.visit(visitor); assertEquals(Set.of("all"), visitor.getResult()); verify(query, never()).equal("name1"); } @Test void shouldRefineAndConditionWithBothEmpty() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.all()).thenReturn(Set.of("all")); var condition = Condition.empty().and(Condition.empty()); condition.visit(visitor); assertEquals(Set.of("all"), visitor.getResult()); verify(query).all(); } @Test void shouldRefineOrConditionWithBothEmpty() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); var query = (ValueIndexQuery) index; when(indices.getIndex("metadata.name")).thenReturn(index); when(query.all()).thenReturn(Set.of("all")); var condition = Condition.empty().or(Condition.empty()); condition.visit(visitor); assertEquals(Set.of("all"), visitor.getResult()); verify(query).all(); } @Test void throwErrorIfIndexNotFound() { var condition = Queries.equal("metadata.unknownField", "value"); when(indices.getIndex("metadata.unknownField")).thenThrow(IllegalArgumentException.class); assertThrows(IllegalArgumentException.class, (() -> { condition.visit(visitor); })); } @Test void throwErrorIfIndexTypeMismatchForValueIndexQuery() { var labelIndex = mock(Index.class, withSettings().extraInterfaces(LabelIndexQuery.class)); when(indices.getIndex("metadata.name")).thenReturn(labelIndex); var condition = Queries.equal("metadata.name", "name1"); assertThrows(IllegalArgumentException.class, (() -> { condition.visit(visitor); })); } @Test void throwErrorIfIndexTypeMismatchForLabelIndexQuery() { var valueIndex = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); when(indices.getIndex("metadata.labels")).thenReturn(valueIndex); var condition = Queries.labelEqual("env", "production"); assertThrows(IllegalArgumentException.class, (() -> { condition.visit(visitor); })); } @Test void throwErrorIfValueConversionNotSupported() { var index = mock(Index.class, withSettings().extraInterfaces(ValueIndexQuery.class)); when(indices.getIndex("metadata.age")).thenReturn(index); when(index.getKeyType()).thenReturn(Integer.class); var condition = Queries.equal("metadata.age", "not-an-integer"); assertThrows(ConversionFailedException.class, (() -> { condition.visit(visitor); })); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/router/ExtensionCompositeRouterFunctionTest.java ================================================ package run.halo.app.extension.router; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.web.reactive.function.server.HandlerStrategies; import org.springframework.web.reactive.function.server.ServerRequest; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; import run.halo.app.extension.event.SchemeAddedEvent; import run.halo.app.extension.event.SchemeRemovedEvent; @ExtendWith(MockitoExtension.class) class ExtensionCompositeRouterFunctionTest { @Mock ReactiveExtensionClient client; @InjectMocks ExtensionCompositeRouterFunction extensionRouterFunc; @Test void shouldRouteWhenSchemeRegistered() { var exchange = MockServerWebExchange.from( MockServerHttpRequest.get("/apis/fake.halo.run/v1alpha1/fakes").build()); var messageReaders = HandlerStrategies.withDefaults().messageReaders(); ServerRequest request = ServerRequest.create(exchange, messageReaders); var handlerFunc = extensionRouterFunc.route(request).block(); assertNull(handlerFunc); // trigger registering scheme extensionRouterFunc.onSchemeAddedEvent( new SchemeAddedEvent(this, Scheme.buildFromType(FakeExtension.class)) ); handlerFunc = extensionRouterFunc.route(request).block(); assertNotNull(handlerFunc); } @Test void shouldNotRouteWhenSchemeUnregistered() { var exchange = MockServerWebExchange.from( MockServerHttpRequest.get("/apis/fake.halo.run/v1alpha1/fakes").build()); var messageReaders = HandlerStrategies.withDefaults().messageReaders(); // trigger registering scheme extensionRouterFunc.onSchemeAddedEvent( new SchemeAddedEvent(this, Scheme.buildFromType(FakeExtension.class)) ); ServerRequest request = ServerRequest.create(exchange, messageReaders); var handlerFunc = extensionRouterFunc.route(request).block(); assertNotNull(handlerFunc); // trigger registering scheme extensionRouterFunc.onSchemeRemovedEvent( new SchemeRemovedEvent(this, Scheme.buildFromType(FakeExtension.class)) ); handlerFunc = extensionRouterFunc.route(request).block(); assertNull(handlerFunc); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/router/ExtensionCreateHandlerTest.java ================================================ package run.halo.app.extension.router; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Objects; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.mock.web.reactive.function.server.MockServerRequest; import org.springframework.web.reactive.function.server.EntityResponse; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; import run.halo.app.extension.Unstructured; import run.halo.app.extension.exception.ExtensionConvertException; import run.halo.app.extension.exception.ExtensionNotFoundException; @ExtendWith(MockitoExtension.class) class ExtensionCreateHandlerTest { @Mock ReactiveExtensionClient client; @Test void shouldBuildPathPatternCorrectly() { var scheme = Scheme.buildFromType(FakeExtension.class); var createHandler = new ExtensionCreateHandler(scheme, client); var pathPattern = createHandler.pathPattern(); assertEquals("/apis/fake.halo.run/v1alpha1/fakes", pathPattern); } @Test void shouldHandleCorrectly() { final var fake = new FakeExtension(); var metadata = new Metadata(); metadata.setName("my-fake"); fake.setMetadata(metadata); var unstructured = new Unstructured(); unstructured.setMetadata(metadata); unstructured.setApiVersion("fake.halo.run/v1alpha1"); unstructured.setKind("Fake"); var serverRequest = MockServerRequest.builder() .body(Mono.just(unstructured)); when(client.create(any(Unstructured.class))).thenReturn(Mono.just(unstructured)); var scheme = Scheme.buildFromType(FakeExtension.class); var getHandler = new ExtensionCreateHandler(scheme, client); var responseMono = getHandler.handle(serverRequest); StepVerifier.create(responseMono) .consumeNextWith(response -> { assertEquals(HttpStatus.CREATED, response.statusCode()); assertEquals("/apis/fake.halo.run/v1alpha1/fakes/my-fake", Objects.requireNonNull(response.headers().getLocation()).toString()); assertEquals(MediaType.APPLICATION_JSON, response.headers().getContentType()); assertTrue(response instanceof EntityResponse); assertEquals(unstructured, ((EntityResponse) response).entity()); }) .verifyComplete(); verify(client, times(1)).create(eq(unstructured)); } @Test void shouldReturnErrorWhenNoBodyProvided() { var serverRequest = MockServerRequest.builder() .body(Mono.empty()); var scheme = Scheme.buildFromType(FakeExtension.class); var getHandler = new ExtensionCreateHandler(scheme, client); var responseMono = getHandler.handle(serverRequest); StepVerifier.create(responseMono) .verifyError(ExtensionConvertException.class); } @Test void shouldReturnErrorWhenExtensionNotFound() { final var unstructured = new Unstructured(); var metadata = new Metadata(); metadata.setName("my-fake"); unstructured.setMetadata(metadata); unstructured.setApiVersion("fake.halo.run/v1alpha1"); unstructured.setKind("Fake"); var serverRequest = MockServerRequest.builder() .body(Mono.just(unstructured)); doThrow(ExtensionNotFoundException.class).when(client).create(any()); var scheme = Scheme.buildFromType(FakeExtension.class); var createHandler = new ExtensionCreateHandler(scheme, client); var responseMono = createHandler.handle(serverRequest); StepVerifier.create(responseMono) .verifyError(ExtensionNotFoundException.class); verify(client, times(1)).create( argThat(extension -> Objects.equals("my-fake", extension.getMetadata().getName()))); verify(client, times(0)).fetch(same(FakeExtension.class), anyString()); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/router/ExtensionDeleteHandlerTest.java ================================================ package run.halo.app.extension.router; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static run.halo.app.extension.GroupVersionKind.fromExtension; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.mock.web.reactive.function.server.MockServerRequest; import org.springframework.web.reactive.function.server.EntityResponse; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; import run.halo.app.extension.Unstructured; import run.halo.app.extension.exception.ExtensionNotFoundException; @ExtendWith(MockitoExtension.class) class ExtensionDeleteHandlerTest { @Mock ReactiveExtensionClient client; @Test void shouldBuildPathPatternCorrectly() { var scheme = Scheme.buildFromType(FakeExtension.class); var deleteHandler = new ExtensionDeleteHandler(scheme, client); var pathPattern = deleteHandler.pathPattern(); assertEquals("/apis/fake.halo.run/v1alpha1/fakes/{name}", pathPattern); } @Test void shouldHandleCorrectly() { final var fake = new FakeExtension(); var metadata = new Metadata(); metadata.setName("my-fake"); fake.setMetadata(metadata); var unstructured = new Unstructured(); unstructured.setMetadata(metadata); unstructured.setApiVersion("fake.halo.run/v1alpha1"); unstructured.setKind("Fake"); var serverRequest = MockServerRequest.builder() .pathVariable("name", "my-fake") .body(Mono.just(unstructured)); when(client.get(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Mono.just(fake)); when(client.delete(eq(fake))).thenReturn(Mono.just(fake)); var scheme = Scheme.buildFromType(FakeExtension.class); var deleteHandler = new ExtensionDeleteHandler(scheme, client); var responseMono = deleteHandler.handle(serverRequest); StepVerifier.create(responseMono) .assertNext(response -> { assertEquals(HttpStatus.OK, response.statusCode()); assertEquals(MediaType.APPLICATION_JSON, response.headers().getContentType()); assertTrue(response instanceof EntityResponse); assertEquals(fake, ((EntityResponse) response).entity()); }) .verifyComplete(); verify(client, times(1)).get(eq(FakeExtension.class), eq("my-fake")); verify(client, times(1)).delete(any()); verify(client, times(0)).update(any()); } @Test void shouldReturnErrorWhenNoNameProvided() { var serverRequest = MockServerRequest.builder() .body(Mono.empty()); var scheme = Scheme.buildFromType(FakeExtension.class); var deleteHandler = new ExtensionDeleteHandler(scheme, client); assertThrows(IllegalArgumentException.class, () -> deleteHandler.handle(serverRequest)); } @Test void shouldReturnErrorWhenExtensionNotFound() { var serverRequest = MockServerRequest.builder() .pathVariable("name", "my-fake") .build(); when(client.get(FakeExtension.class, "my-fake")).thenReturn( Mono.error( new ExtensionNotFoundException(fromExtension(FakeExtension.class), "my-fake"))); var scheme = Scheme.buildFromType(FakeExtension.class); var deleteHandler = new ExtensionDeleteHandler(scheme, client); var responseMono = deleteHandler.handle(serverRequest); StepVerifier.create(responseMono) .verifyError(ExtensionNotFoundException.class); verify(client, times(1)).get(same(FakeExtension.class), anyString()); verify(client, times(0)).update(any()); verify(client, times(0)).delete(any()); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/router/ExtensionGetHandlerTest.java ================================================ package run.halo.app.extension.router; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import static run.halo.app.extension.GroupVersionKind.fromExtension; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.mock.web.reactive.function.server.MockServerRequest; import org.springframework.web.reactive.function.server.EntityResponse; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; import run.halo.app.extension.exception.ExtensionNotFoundException; @ExtendWith(MockitoExtension.class) class ExtensionGetHandlerTest { @Mock ReactiveExtensionClient client; @Test void shouldBuildPathPatternCorrectly() { var scheme = Scheme.buildFromType(FakeExtension.class); var getHandler = new ExtensionGetHandler(scheme, client); var pathPattern = getHandler.pathPattern(); assertEquals("/apis/fake.halo.run/v1alpha1/fakes/{name}", pathPattern); } @Test void shouldHandleCorrectly() { var scheme = Scheme.buildFromType(FakeExtension.class); var getHandler = new ExtensionGetHandler(scheme, client); var serverRequest = MockServerRequest.builder() .pathVariable("name", "my-fake") .build(); final var fake = new FakeExtension(); when(client.get(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Mono.just(fake)); var responseMono = getHandler.handle(serverRequest); StepVerifier.create(responseMono) .consumeNextWith(response -> { assertEquals(HttpStatus.OK, response.statusCode()); assertEquals(MediaType.APPLICATION_JSON, response.headers().getContentType()); assertTrue(response instanceof EntityResponse); assertEquals(fake, ((EntityResponse) response).entity()); }) .verifyComplete(); } @Test void shouldThrowExceptionWhenExtensionNotFound() { var scheme = Scheme.buildFromType(FakeExtension.class); var getHandler = new ExtensionGetHandler(scheme, client); var serverRequest = MockServerRequest.builder() .pathVariable("name", "my-fake") .build(); when(client.get(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Mono.error( new ExtensionNotFoundException(fromExtension(FakeExtension.class), "my-fake"))); Mono responseMono = getHandler.handle(serverRequest); StepVerifier.create(responseMono) .expectError(ExtensionNotFoundException.class) .verify(); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/router/ExtensionListHandlerTest.java ================================================ package run.halo.app.extension.router; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.reactive.function.server.MockServerRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.web.reactive.function.server.EntityResponse; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; @ExtendWith(MockitoExtension.class) class ExtensionListHandlerTest { @Mock ReactiveExtensionClient client; @Test void shouldBuildPathPatternCorrectly() { var scheme = Scheme.buildFromType(FakeExtension.class); var listHandler = new ExtensionListHandler(scheme, client); var pathPattern = listHandler.pathPattern(); assertEquals("/apis/fake.halo.run/v1alpha1/fakes", pathPattern); } @Test void shouldHandleCorrectly() { var scheme = Scheme.buildFromType(FakeExtension.class); var listHandler = new ExtensionListHandler(scheme, client); var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/fake") .queryParam("sort", "metadata.name,desc")); var serverRequest = MockServerRequest.builder().exchange(exchange).build(); final var fake01 = FakeExtension.createFake("fake01"); final var fake02 = FakeExtension.createFake("fake02"); var fakeListResult = new ListResult<>(0, 0, 2, List.of(fake01, fake02)); when(client.listBy(same(FakeExtension.class), any(ListOptions.class), any())) .thenReturn(Mono.just(fakeListResult)); var responseMono = listHandler.handle(serverRequest); StepVerifier.create(responseMono) .consumeNextWith(response -> { assertEquals(HttpStatus.OK, response.statusCode()); assertEquals(MediaType.APPLICATION_JSON, response.headers().getContentType()); assertTrue(response instanceof EntityResponse); assertEquals(fakeListResult, ((EntityResponse) response).entity()); }) .verifyComplete(); verify(client).listBy(same(FakeExtension.class), any(ListOptions.class), any()); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/router/ExtensionRouterFunctionFactoryTest.java ================================================ package run.halo.app.extension.router; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.github.fge.jackson.jsonpointer.JsonPointer; import com.github.fge.jsonpatch.AddOperation; import com.github.fge.jsonpatch.JsonPatch; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.codec.HttpMessageReader; import org.springframework.http.codec.json.JacksonJsonDecoder; import org.springframework.http.codec.json.JacksonJsonEncoder; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.HandlerStrategies; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.JsonExtension; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; import run.halo.app.extension.router.ExtensionRouterFunctionFactory.CreateHandler; import run.halo.app.extension.router.ExtensionRouterFunctionFactory.GetHandler; import run.halo.app.extension.router.ExtensionRouterFunctionFactory.ListHandler; import run.halo.app.extension.router.ExtensionRouterFunctionFactory.UpdateHandler; import run.halo.app.infra.config.JacksonAdapterModule; import tools.jackson.databind.json.JsonMapper; @ExtendWith(MockitoExtension.class) class ExtensionRouterFunctionFactoryTest { @Mock ReactiveExtensionClient client; @Spy Scheme scheme = Scheme.buildFromType(FakeExtension.class); @InjectMocks ExtensionRouterFunctionFactory factory; WebTestClient webClient; @BeforeEach void setUp() { var mapper = JsonMapper.builder() .addModule(new JacksonAdapterModule( () -> com.fasterxml.jackson.databind.json.JsonMapper.builder().build()) ) .build(); webClient = WebTestClient.bindToRouterFunction(factory.create()) .handlerStrategies(HandlerStrategies.builder() .codecs(c -> { c.defaultCodecs().jacksonJsonEncoder(new JacksonJsonEncoder(mapper)); c.defaultCodecs().jacksonJsonDecoder(new JacksonJsonDecoder(mapper)); }) .build() ) .configureClient() .codecs(c -> { c.defaultCodecs().jacksonJsonEncoder(new JacksonJsonEncoder(mapper)); c.defaultCodecs().jacksonJsonDecoder(new JacksonJsonDecoder(mapper)); }) .build(); } @Nested class PatchTest { @Test void shouldResponse404IfMethodNotPatch() { webClient.method(HttpMethod.POST) .uri("/apis/fake.halo.run/v1alpha1/fakes/my-fake") .exchange() .expectStatus().isNotFound(); } @Test void shouldResponse415IfMediaTypeIsInsufficient() { webClient.method(HttpMethod.PATCH) .uri("/apis/fake.halo.run/v1alpha1/fakes/my-fake") .exchange() .expectStatus().isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE); webClient.method(HttpMethod.PATCH) .uri("/apis/fake.halo.run/v1alpha1/fakes/my-fake") .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON.toString()) .exchange() .expectStatus().isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE); } @Test void shouldResponseBadRequestIfNoPatchBody() { webClient.method(HttpMethod.PATCH) .uri("/apis/fake.halo.run/v1alpha1/fakes/my-fake") .header(HttpHeaders.CONTENT_TYPE, "application/json-patch+json") .exchange() .expectStatus().isBadRequest(); } @Test void shouldPatchCorrectly() { var fake = new FakeExtension(); var metadata = new Metadata(); metadata.setName("my-fake"); fake.setMetadata(metadata); var mapper = com.fasterxml.jackson.databind.json.JsonMapper.builder().build(); var jsonExt = mapper.convertValue(fake, JsonExtension.class); when(client.getJsonExtension(scheme.groupVersionKind(), "my-fake")) .thenReturn(Mono.just(jsonExt)); var status = new FakeExtension.FakeStatus(); status.setState("running"); fake.setStatus(status); var updatedExt = mapper.convertValue(fake, JsonExtension.class); when(client.update(any(JsonExtension.class))).thenReturn(Mono.just(updatedExt)); var stateNode = JsonNodeFactory.instance.textNode("running"); var jsonPatch = new JsonPatch(List.of( new AddOperation(JsonPointer.of("status", "state"), stateNode) )); webClient.method(HttpMethod.PATCH) .uri("/apis/fake.halo.run/v1alpha1/fakes/my-fake") .header(HttpHeaders.CONTENT_TYPE, "application/json-patch+json") .bodyValue(jsonPatch) .exchange() .expectStatus().isOk() .expectBody(JsonExtension.class).isEqualTo(updatedExt); verify(client).update(assertArg(ext -> { var state = ext.getInternal().get("status").get("state") .asText(); assertEquals("running", state); })); } } @Test void shouldCreateSuccessfully() { var routerFunction = factory.create(); testCases().forEach(testCase -> { List> messageReaders = HandlerStrategies.withDefaults().messageReaders(); var request = ServerRequest.create(testCase.webExchange, messageReaders); var handlerFunc = routerFunction.route(request).block(); assertInstanceOf(testCase.expectHandlerType, handlerFunc); }); } List testCases() { var listWebExchange = MockServerWebExchange.from( MockServerHttpRequest.get("/apis/fake.halo.run/v1alpha1/fakes").build()); var getWebExchange = MockServerWebExchange.from( MockServerHttpRequest.get("/apis/fake.halo.run/v1alpha1/fakes/my-fake").build() ); var createWebExchange = MockServerWebExchange.from( MockServerHttpRequest.post("/apis/fake.halo.run/v1alpha1/fakes").body("{}") ); var updateWebExchange = MockServerWebExchange.from( MockServerHttpRequest.put("/apis/fake.halo.run/v1alpha1/fakes/my-fake").body("{}") ); return List.of( new TestCase(listWebExchange, ListHandler.class), new TestCase(getWebExchange, GetHandler.class), new TestCase(createWebExchange, CreateHandler.class), new TestCase(updateWebExchange, UpdateHandler.class) ); } record TestCase(ServerWebExchange webExchange, Class> expectHandlerType) { } } ================================================ FILE: application/src/test/java/run/halo/app/extension/router/ExtensionUpdateHandlerTest.java ================================================ package run.halo.app.extension.router; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Objects; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.mock.web.reactive.function.server.MockServerRequest; import org.springframework.web.reactive.function.server.EntityResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; import run.halo.app.extension.Unstructured; import run.halo.app.extension.exception.ExtensionNotFoundException; @ExtendWith(MockitoExtension.class) class ExtensionUpdateHandlerTest { @Mock ReactiveExtensionClient client; @Test void shouldBuildPathPatternCorrectly() { var scheme = Scheme.buildFromType(FakeExtension.class); var updateHandler = new ExtensionUpdateHandler(scheme, client); var pathPattern = updateHandler.pathPattern(); assertEquals("/apis/fake.halo.run/v1alpha1/fakes/{name}", pathPattern); } @Test void shouldHandleCorrectly() { final var fake = new FakeExtension(); var metadata = new Metadata(); metadata.setName("my-fake"); fake.setMetadata(metadata); var unstructured = new Unstructured(); unstructured.setMetadata(metadata); unstructured.setApiVersion("fake.halo.run/v1alpha1"); unstructured.setKind("Fake"); var serverRequest = MockServerRequest.builder() .pathVariable("name", "my-fake") .body(Mono.just(unstructured)); // when(client.fetch(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Mono.just(fake)); when(client.update(eq(unstructured))).thenReturn(Mono.just(unstructured)); var scheme = Scheme.buildFromType(FakeExtension.class); var updateHandler = new ExtensionUpdateHandler(scheme, client); var responseMono = updateHandler.handle(serverRequest); StepVerifier.create(responseMono) .assertNext(response -> { assertEquals(HttpStatus.OK, response.statusCode()); assertEquals(MediaType.APPLICATION_JSON, response.headers().getContentType()); assertTrue(response instanceof EntityResponse); assertEquals(unstructured, ((EntityResponse) response).entity()); }) .verifyComplete(); // verify(client, times(1)).fetch(eq(FakeExtension.class), eq("my-fake")); verify(client, times(1)).update(eq(unstructured)); } @Test void shouldReturnErrorWhenNoBodyProvided() { var serverRequest = MockServerRequest.builder() .pathVariable("name", "my-fake") .body(Mono.empty()); var scheme = Scheme.buildFromType(FakeExtension.class); var updateHandler = new ExtensionUpdateHandler(scheme, client); var responseMono = updateHandler.handle(serverRequest); StepVerifier.create(responseMono) .verifyError(ServerWebInputException.class); } @Test void shouldReturnErrorWhenNoNameProvided() { var serverRequest = MockServerRequest.builder() .body(Mono.empty()); var scheme = Scheme.buildFromType(FakeExtension.class); var updateHandler = new ExtensionUpdateHandler(scheme, client); assertThrows(IllegalArgumentException.class, () -> updateHandler.handle(serverRequest)); } @Test void shouldReturnErrorWhenExtensionNotFound() { final var unstructured = new Unstructured(); var metadata = new Metadata(); metadata.setName("my-fake"); unstructured.setMetadata(metadata); unstructured.setApiVersion("fake.halo.run/v1alpha1"); unstructured.setKind("Fake"); var serverRequest = MockServerRequest.builder() .pathVariable("name", "my-fake") .body(Mono.just(unstructured)); doThrow(ExtensionNotFoundException.class).when(client).update(any()); var scheme = Scheme.buildFromType(FakeExtension.class); var updateHandler = new ExtensionUpdateHandler(scheme, client); var responseMono = updateHandler.handle(serverRequest); StepVerifier.create(responseMono) .verifyError(ExtensionNotFoundException.class); verify(client, times(1)).update( argThat(extension -> Objects.equals("my-fake", extension.getMetadata().getName()))); verify(client, times(0)).fetch(same(FakeExtension.class), anyString()); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/router/PathPatternGeneratorTest.java ================================================ package run.halo.app.extension.router; import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; import run.halo.app.extension.Scheme; import run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator; class PathPatternGeneratorTest { @GVK(group = "fake.halo.run", version = "v1alpha1", kind = "Fake", singular = "fake", plural = "fakes") private static class GroupExtension extends AbstractExtension { } @GVK(group = "", version = "v1alpha1", kind = "Fake", singular = "fake", plural = "fakes") private static class GrouplessExtension extends AbstractExtension { } @Test void buildGroupedExtensionPathPattern() { var scheme = Scheme.buildFromType(GroupExtension.class); var pathPattern = PathPatternGenerator.buildExtensionPathPattern(scheme); assertEquals("/apis/fake.halo.run/v1alpha1/fakes", pathPattern); } @Test void buildGrouplessExtensionPathPattern() { var scheme = Scheme.buildFromType(GrouplessExtension.class); var pathPattern = PathPatternGenerator.buildExtensionPathPattern(scheme); assertEquals("/api/v1alpha1/fakes", pathPattern); } } ================================================ FILE: application/src/test/java/run/halo/app/extension/store/ReactiveExtensionStoreClientImplTest.java ================================================ package run.halo.app.extension.store; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.r2dbc.core.R2dbcEntityOperations; import org.springframework.data.r2dbc.core.ReactiveSelectOperation; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @ExtendWith(MockitoExtension.class) class ReactiveExtensionStoreClientImplTest { @Mock ExtensionStoreRepository repository; @Mock R2dbcEntityOperations entityOperations; @InjectMocks ReactiveExtensionStoreClientImpl client; @Test void listByNamePrefix() { var expectedExtensions = List.of( new ExtensionStore("/registry/posts/hello-world", "this is post".getBytes(), 1L), new ExtensionStore("/registry/posts/hello-halo", "this is post".getBytes(), 1L) ); var select = mock(ReactiveSelectOperation.ReactiveSelect.class); var selectWithQuery = mock(ReactiveSelectOperation.SelectWithQuery.class); var terminatingSelect = mock(ReactiveSelectOperation.TerminatingSelect.class); when(terminatingSelect.all()).thenReturn(Flux.fromIterable(expectedExtensions)); when(selectWithQuery.matching(any())).thenReturn(terminatingSelect); when(select.withFetchSize(100)).thenReturn(selectWithQuery); when(entityOperations.select(ExtensionStore.class)).thenReturn(select); client.listByNamePrefix("/registry/posts").collectList() .as(StepVerifier::create) .expectNext(expectedExtensions) .verifyComplete(); } @Test void fetchByName() { var expectedExtension = new ExtensionStore("/registry/posts/hello-world", "this is post".getBytes(), 1L); when(repository.findById("/registry/posts/hello-halo")) .thenReturn(Mono.just(expectedExtension)); var gotExtension = client.fetchByName("/registry/posts/hello-halo").blockOptional(); assertTrue(gotExtension.isPresent()); assertEquals(expectedExtension, gotExtension.get()); } @Test void create() { var expectedExtension = new ExtensionStore("/registry/posts/hello-halo", "hello halo".getBytes(), 2L); when(repository.save(any())) .thenReturn(Mono.just(expectedExtension)); var createdExtension = client.create("/registry/posts/hello-halo", "hello halo".getBytes()) .block(); assertEquals(expectedExtension, createdExtension); } @Test void update() { var expectedExtension = new ExtensionStore("/registry/posts/hello-halo", "hello halo".getBytes(), 2L); when(repository.save(any())).thenReturn(Mono.just(expectedExtension)); var updatedExtension = client.update("/registry/posts/hello-halo", 1L, "hello halo".getBytes()) .block(); assertEquals(expectedExtension, updatedExtension); } @Test void shouldDoNotThrowExceptionWhenDeletingNonExistExt() { when(repository.findById(anyString())).thenReturn(Mono.empty()); client.delete("/registry/posts/hello-halo", 1L).block(); } @Test void shouldDeleteSuccessfully() { var expectedExtension = new ExtensionStore("/registry/posts/hello-halo", "hello halo".getBytes(), 2L); when(repository.findById(anyString())).thenReturn(Mono.just(expectedExtension)); when(repository.delete(any())).thenReturn(Mono.empty()); var deletedExtension = client.delete("/registry/posts/hello-halo", 2L).block(); assertEquals(expectedExtension, deletedExtension); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/ConditionListTest.java ================================================ package run.halo.app.infra; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import java.time.Duration; import java.time.Instant; import java.util.Iterator; import org.json.JSONException; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link ConditionList}. * * @author guqing * @since 2.0.0 */ class ConditionListTest { @Test void add() { ConditionList conditionList = new ConditionList(); conditionList.add(condition("type", "message", "reason", ConditionStatus.FALSE)); conditionList.add(condition("type", "message", "reason", ConditionStatus.FALSE)); assertThat(conditionList.size()).isEqualTo(1); conditionList.add(condition("type", "message", "reason", ConditionStatus.TRUE)); assertThat(conditionList.size()).isEqualTo(2); } @Test void addAndEvictFIFO() throws JSONException { ConditionList conditionList = new ConditionList(); conditionList.addFirst(condition("type", "message", "reason", ConditionStatus.FALSE)); conditionList.addFirst(condition("type2", "message2", "reason2", ConditionStatus.FALSE)); conditionList.addFirst(condition("type3", "message3", "reason3", ConditionStatus.FALSE)); JSONAssert.assertEquals(""" [ { "type": "type3", "status": "FALSE", "message": "message3", "reason": "reason3" }, { "type": "type2", "status": "FALSE", "message": "message2", "reason": "reason2" }, { "type": "type", "status": "FALSE", "message": "message", "reason": "reason" } ] """, JsonUtils.objectToJson(conditionList), true); assertThat(conditionList.size()).isEqualTo(3); conditionList.addAndEvictFIFO( condition("type4", "message4", "reason4", ConditionStatus.FALSE), 1); assertThat(conditionList.size()).isEqualTo(1); // json serialize test. JSONAssert.assertEquals(""" [ { "type": "type4", "status": "FALSE", "message": "message4", "reason": "reason4" } ] """, JsonUtils.objectToJson(conditionList), true); } @Test void peek() { ConditionList conditionList = new ConditionList(); conditionList.addFirst(condition("type", "message", "reason", ConditionStatus.FALSE)); Condition condition = condition("type2", "message2", "reason2", ConditionStatus.FALSE); conditionList.addFirst(condition); Condition peek = conditionList.peek(); assertThat(peek).isEqualTo(condition); } @Test void removeLast() { ConditionList conditionList = new ConditionList(); Condition condition = condition("type", "message", "reason", ConditionStatus.FALSE); conditionList.addFirst(condition); conditionList.addFirst(condition("type2", "message2", "reason2", ConditionStatus.FALSE)); assertThat(conditionList.size()).isEqualTo(2); assertThat(conditionList.removeLast()).isEqualTo(condition); assertThat(conditionList.size()).isEqualTo(1); } @Test void test() { ConditionList conditionList = new ConditionList(); conditionList.addAndEvictFIFO( condition("type", "message", "reason", ConditionStatus.FALSE)); conditionList.addAndEvictFIFO( condition("type2", "message2", "reason2", ConditionStatus.FALSE)); Iterator iterator = conditionList.iterator(); assertThat(iterator.next().getType()).isEqualTo("type2"); assertThat(iterator.next().getType()).isEqualTo("type"); } @Test void deserialization() { String s = """ [{ "type": "type3", "status": "FALSE", "message": "message3", "reason": "reason3" }, { "type": "type2", "status": "FALSE", "message": "message2", "reason": "reason2" }, { "type": "type", "status": "FALSE", "message": "message", "reason": "reason" }] """; ConditionList conditions = JsonUtils.jsonToObject(s, ConditionList.class); assertThat(conditions.peek().getType()).isEqualTo("type3"); } @Test void shouldNotAddIfTypeIsSame() { var conditions = new ConditionList(); var condition = Condition.builder() .type("type") .status(ConditionStatus.TRUE) .reason("reason") .message("message") .build(); var anotherCondition = Condition.builder() .type("type") .status(ConditionStatus.FALSE) .reason("another reason") .message("another message") .build(); conditions.addAndEvictFIFO(condition); conditions.addAndEvictFIFO(anotherCondition); assertEquals(1, conditions.size()); } @Test void shouldNotUpdateLastTransitionTimeIfStatusNotChanged() { var now = Instant.now(); var conditions = new ConditionList(); conditions.addAndEvictFIFO( Condition.builder() .type("type") .status(ConditionStatus.TRUE) .reason("reason") .message("message") .lastTransitionTime(now) .build() ); conditions.addAndEvictFIFO( Condition.builder() .type("type") .status(ConditionStatus.TRUE) .reason("reason") .message("message") .lastTransitionTime(now.plus(Duration.ofSeconds(1))) .build() ); assertEquals(1, conditions.size()); // make sure the last transition time was not modified. assertEquals(now, conditions.peek().getLastTransitionTime()); } @Test void shouldUpdateLastTransitionTimeIfStatusChanged() { var now = Instant.now(); var conditions = new ConditionList(); conditions.addAndEvictFIFO( Condition.builder() .type("type") .status(ConditionStatus.TRUE) .reason("reason") .message("message") .lastTransitionTime(now) .build() ); conditions.addAndEvictFIFO( Condition.builder() .type("type") .status(ConditionStatus.FALSE) .reason("reason") .message("message") .lastTransitionTime(now.plus(Duration.ofSeconds(1))) .build() ); assertEquals(1, conditions.size()); assertEquals(now.plus(Duration.ofSeconds(1)), conditions.peek().getLastTransitionTime()); } private Condition condition(String type, String message, String reason, ConditionStatus status) { Condition condition = new Condition(); condition.setType(type); condition.setMessage(message); condition.setReason(reason); condition.setStatus(status); return condition; } } ================================================ FILE: application/src/test/java/run/halo/app/infra/DefaultBackupRootGetterTest.java ================================================ package run.halo.app.infra; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.nio.file.Path; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.infra.properties.HaloProperties; @ExtendWith(MockitoExtension.class) class DefaultBackupRootGetterTest { @Mock HaloProperties haloProperties; @InjectMocks DefaultBackupRootGetter backupRootGetter; @Test void shouldGetBackupRootFromWorkDir() { when(haloProperties.getWorkDir()).thenReturn(Path.of("workdir")); var backupRoot = this.backupRootGetter.get(); assertEquals(Path.of("workdir", "backups"), backupRoot); verify(haloProperties).getWorkDir(); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java ================================================ package run.halo.app.infra; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.web.filter.reactive.ServerWebExchangeContextFilter.EXCHANGE_CONTEXT_ATTRIBUTE; import java.net.MalformedURLException; import java.net.URI; import java.net.URL; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.web.server.ServerWebExchange; import reactor.test.StepVerifier; /** * Tests for {@link DefaultExternalLinkProcessor}. * * @author guqing * @since 2.9.0 */ @ExtendWith(MockitoExtension.class) class DefaultExternalLinkProcessorTest { @Mock private ExternalUrlSupplier externalUrlSupplier; @InjectMocks DefaultExternalLinkProcessor externalLinkProcessor; @Test void processWhenLinkIsEmpty() { assertThat(externalLinkProcessor.processLink((String) null)).isNull(); assertThat(externalLinkProcessor.processLink("")).isEmpty(); } @Test void process() throws MalformedURLException { when(externalUrlSupplier.getRaw()).thenReturn(null); assertThat(externalLinkProcessor.processLink("/test")).isEqualTo("/test"); when(externalUrlSupplier.getRaw()).thenReturn(URI.create("https://halo.run").toURL()); assertThat(externalLinkProcessor.processLink("/test")).isEqualTo("https://halo.run/test"); assertThat(externalLinkProcessor.processLink("https://guqing.xyz/test")) .isEqualTo("https://guqing.xyz/test"); when(externalUrlSupplier.getRaw()).thenReturn(URI.create("https://halo.run/").toURL()); assertThat(externalLinkProcessor.processLink("/test")).isEqualTo("https://halo.run/test"); assertThat(externalLinkProcessor.processLink("https://halo.run/test")) .isEqualTo("https://halo.run/test"); } @ParameterizedTest @MethodSource("processUriTestWithoutServerWebExchangeArguments") void processUriWithoutServerWebExchange(String link, String expectedLink) throws MalformedURLException { lenient().when(externalUrlSupplier.getRaw()) .thenReturn(new URL("https://www.halo.run/context-path")); externalLinkProcessor.processLink(URI.create(link)) .as(StepVerifier::create) .expectNext(URI.create(expectedLink)) .verifyComplete(); } static Stream processUriTestWithoutServerWebExchangeArguments() { return Stream.of( Arguments.of("http://localhost:8090/halo", "http://localhost:8090/halo"), Arguments.of("/halo", "https://www.halo.run/context-path/halo"), Arguments.of("halo", "https://www.halo.run/context-path/halo"), Arguments.of("/halo?query", "https://www.halo.run/context-path/halo?query"), Arguments.of( "/halo?query#fragment", "https://www.halo.run/context-path/halo?query#fragment" ), Arguments.of("/halo/subpath", "https://www.halo.run/context-path/halo/subpath"), Arguments.of("/halo/中文", "https://www.halo.run/context-path/halo/%E4%B8%AD%E6%96%87"), Arguments.of("/halo/ooo%2Fooo", "https://www.halo.run/context-path/halo/ooo%2Fooo") ); } @ParameterizedTest @MethodSource("processUriTestWithServerWebExchangeArguments") void processUriWithServerWebExchange(String link, String expectLink) throws MalformedURLException { lenient().when(externalUrlSupplier.getRaw()) .thenReturn(URI.create("https://www.halo.run").toURL()); var request = mock(ServerHttpRequest.class); var exchange = mock(ServerWebExchange.class); lenient().when(exchange.getRequest()).thenReturn(request); lenient().when(externalUrlSupplier.getURL(request)).thenReturn( new URL("https://antoher.halo.run/context-path")); externalLinkProcessor.processLink(URI.create(link)) .contextWrite(context -> context.put(EXCHANGE_CONTEXT_ATTRIBUTE, exchange)) .as(StepVerifier::create) .expectNext(URI.create(expectLink)) .verifyComplete(); } static Stream processUriTestWithServerWebExchangeArguments() { return Stream.of( Arguments.of("http://localhost:8090/halo?query#fragment", "http://localhost:8090/halo?query#fragment"), Arguments.of("/halo", "https://antoher.halo.run/context-path/halo"), Arguments.of("halo", "https://antoher.halo.run/context-path/halo"), Arguments.of("/halo?query", "https://antoher.halo.run/context-path/halo?query"), Arguments.of("/halo?query#fragment", "https://antoher.halo.run/context-path/halo?query#fragment"), Arguments.of("/halo/subpath", "https://antoher.halo.run/context-path/halo/subpath"), Arguments.of("/halo/中文", "https://antoher.halo.run/context-path/halo/%E4%B8%AD%E6%96%87"), Arguments.of("/halo/ooo%2Fooo", "https://antoher.halo.run/context-path/halo/ooo%2Fooo") ); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/DefaultSystemConfigFetcherTest.java ================================================ package run.halo.app.infra; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.HashMap; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.convert.ConversionService; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ReactiveExtensionClient; import tools.jackson.databind.json.JsonMapper; /** * Tests for {@link DefaultSystemConfigFetcher}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class DefaultSystemConfigFetcherTest { @Mock ReactiveExtensionClient client; @Mock ConversionService conversionService; @Spy JsonMapper mapper = JsonMapper.shared(); @InjectMocks DefaultSystemConfigFetcher systemConfigFetcher; private ConfigMap mockConfigMap; @BeforeEach void setUp() { mockConfigMap = new ConfigMap(); mockConfigMap.setData(Map.of( "basic", """ { "title": "Test Blog", "subtitle": "Test" }""", "post", """ { "postPageSize": 10 }""" )); } @Test void testFetchWithConvertibleType() { // Arrange when(conversionService.canConvert(String.class, String.class)) .thenReturn(true); when(conversionService.convert("testValue", String.class)) .thenReturn("testValue"); var configMapWithString = new ConfigMap(); var data = new HashMap(); data.put("testKey", "testValue"); configMapWithString.setData(data); systemConfigFetcher.getConfigMapCache().set(configMapWithString.getData()); // Act & Assert systemConfigFetcher.fetch("testKey", String.class) .as(StepVerifier::create) .expectNext("testValue") .verifyComplete(); } @Test void testFetchWithJsonConversion() { // Arrange var configMap = new ConfigMap(); configMap.setData(Map.of( "basic", """ { "title": "Test Blog", "subtitle": "Test Subtitle" }""" )); systemConfigFetcher.getConfigMapCache().set(configMap.getData()); when(conversionService.canConvert(String.class, SystemSetting.Basic.class)) .thenReturn(false); // Act & Assert systemConfigFetcher.fetch("basic", SystemSetting.Basic.class) .as(StepVerifier::create) .assertNext(basic -> { assertThat(basic.getTitle()).isEqualTo("Test Blog"); assertThat(basic.getSubtitle()).isEqualTo("Test Subtitle"); }) .verifyComplete(); } @Test void testFetchWhenKeyDoesNotExist() { // Arrange systemConfigFetcher.getConfigMapCache().set(mockConfigMap.getData()); // Act & Assert systemConfigFetcher.fetch("nonExistentKey", String.class) .as(StepVerifier::create) .verifyComplete(); } @Test void testGetBasicWithValidData() { // Arrange var configMap = new ConfigMap(); configMap.setData(Map.of( "basic", """ { "title": "My Blog", "subtitle": "My Subtitle", "logo": "logo.png" }""" )); systemConfigFetcher.getConfigMapCache().set(configMap.getData()); when(conversionService.canConvert(String.class, SystemSetting.Basic.class)) .thenReturn(false); // Act & Assert systemConfigFetcher.getBasic() .as(StepVerifier::create) .assertNext(basic -> { assertThat(basic.getTitle()).isEqualTo("My Blog"); assertThat(basic.getSubtitle()).isEqualTo("My Subtitle"); assertThat(basic.getLogo()).isEqualTo("logo.png"); }) .verifyComplete(); } @Test void testGetBasicWhenKeyDoesNotExist() { // Arrange systemConfigFetcher.getConfigMapCache().set(Map.of()); // Act & Assert - should return a new instance systemConfigFetcher.getBasic() .as(StepVerifier::create) .assertNext(basic -> assertThat(basic).isNotNull()) .verifyComplete(); } @Test void testFetchComment() { // Arrange var configMap = new ConfigMap(); configMap.setData(Map.of( "comment", """ { "enable": true }""" )); systemConfigFetcher.getConfigMapCache().set(configMap.getData()); when(conversionService.canConvert(String.class, SystemSetting.Comment.class)) .thenReturn(false); // Act & Assert systemConfigFetcher.fetchComment() .as(StepVerifier::create) .expectNextMatches(java.util.Objects::nonNull) .verifyComplete(); } @Test void testFetchPost() { // Arrange var configMap = new ConfigMap(); configMap.setData(Map.of( "post", """ { "postPageSize": 10, "archivePageSize": 20 }""" )); systemConfigFetcher.getConfigMapCache().set(configMap.getData()); when(conversionService.canConvert(String.class, SystemSetting.Post.class)) .thenReturn(false); // Act & Assert systemConfigFetcher.fetchPost() .as(StepVerifier::create) .assertNext(post -> { assertThat(post.getPostPageSize()).isEqualTo(10); assertThat(post.getArchivePageSize()).isEqualTo(20); }) .verifyComplete(); } @Test void testFetchRouteRules() { // Arrange var configMap = new ConfigMap(); configMap.setData(Map.of( "routeRules", """ { "post": "/articles/{slug}", "tags": "/labels" }""" )); systemConfigFetcher.getConfigMapCache().set(configMap.getData()); when(conversionService.canConvert(String.class, SystemSetting.ThemeRouteRules.class)) .thenReturn(false); // Act & Assert systemConfigFetcher.fetchRouteRules() .as(StepVerifier::create) .assertNext(rules -> { assertThat(rules.getPost()).isEqualTo("/articles/{slug}"); assertThat(rules.getTags()).isEqualTo("/labels"); }) .verifyComplete(); } @Test void testGetConfig() { // Arrange var configData = mockConfigMap.getData(); systemConfigFetcher.getConfigMapCache().set(configData); // Act & Assert systemConfigFetcher.getConfig() .as(StepVerifier::create) .expectNext(java.util.Objects.requireNonNull(configData)) .verifyComplete(); } @Test void testGetConfigMap() { // Arrange when(client.fetch(ConfigMap.class, "system")) .thenReturn(Mono.just(mockConfigMap)); // Act & Assert systemConfigFetcher.getConfigMap() .as(StepVerifier::create) .expectNext(mockConfigMap) .verifyComplete(); } @Test void testGetConfigMapBlocking() { // Arrange when(client.fetch(ConfigMap.class, "system")) .thenReturn(Mono.just(mockConfigMap)); // Act Optional result = systemConfigFetcher.getConfigMapBlocking(); // Assert assertThat(result).isPresent(); assertThat(result.get()).isEqualTo(mockConfigMap); } @Test void testGetConfigMapBlockingWhenNotFound() { // Arrange when(client.fetch(ConfigMap.class, "system")) .thenReturn(Mono.empty()); // Act Optional result = systemConfigFetcher.getConfigMapBlocking(); // Assert assertThat(result).isEmpty(); } @Test void testOnApplicationEvent() { // Arrange var oldData = new HashMap(); var newDataMap = new HashMap(); newDataMap.put("key1", "value1"); newDataMap.put("key2", "value2"); var event = new SystemConfigChangedEvent(this, oldData, newDataMap); // Act systemConfigFetcher.onApplicationEvent(event); // Assert assertThat(systemConfigFetcher.getConfigMapCache().get()).isEqualTo(newDataMap); } @Test void testCacheInvalidation() { // Arrange var initialDataMap = new HashMap(); initialDataMap.put("key1", "value1"); systemConfigFetcher.getConfigMapCache().set(initialDataMap); // Act & Assert - Verify cache is updated when new data differs var newDataMap = new HashMap(); newDataMap.put("key1", "newValue"); var event = new SystemConfigChangedEvent(this, initialDataMap, newDataMap); systemConfigFetcher.onApplicationEvent(event); assertThat(systemConfigFetcher.getConfigMapCache().get()).isEqualTo(newDataMap); } @Test void shouldGetConfigMapFromDatabaseIfNoCache() { // Arrange when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)) .thenReturn(Mono.just(mockConfigMap)); when(client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG_DEFAULT)) .thenReturn(Mono.empty()); assertNotNull(mockConfigMap.getData()); systemConfigFetcher.getConfigMapMono() .as(StepVerifier::create) .expectNext(mockConfigMap.getData()) .verifyComplete(); } @Test void shouldGetConfigMapFromCacheIfPresent() { // Arrange var cachedData = Map.of( "cachedKey", """ { "key1": "value1" }""" ); systemConfigFetcher.getConfigMapCache().set(cachedData); // Act & Assert systemConfigFetcher.getConfigMapMono() .as(StepVerifier::create) .expectNext(cachedData) .verifyComplete(); verify(client, never()).fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/DefaultSystemVersionSupplierTest.java ================================================ package run.halo.app.infra; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; import com.github.zafarkhaja.semver.Version; import java.util.Properties; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.info.BuildProperties; /** * Tests for {@link DefaultSystemVersionSupplier}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class DefaultSystemVersionSupplierTest { @InjectMocks private DefaultSystemVersionSupplier systemVersionSupplier; @Mock ObjectProvider buildPropertiesProvider; @Test void getWhenBuildPropertiesNotSet() { Version version = systemVersionSupplier.get(); assertThat(version.toString()).isEqualTo("0.0.0"); } @Test void getWhenBuildPropertiesButVersionIsNull() { Properties properties = new Properties(); BuildProperties buildProperties = new BuildProperties(properties); when(buildPropertiesProvider.getIfUnique()).thenReturn(buildProperties); Version version = systemVersionSupplier.get(); assertThat(version.toString()).isEqualTo("0.0.0"); } @Test void getWhenBuildPropertiesAndVersionNotEmpty() { Properties properties = new Properties(); properties.put("version", "2.0.0"); BuildProperties buildProperties = new BuildProperties(properties); when(buildPropertiesProvider.getIfUnique()).thenReturn(buildProperties); Version version = systemVersionSupplier.get(); assertThat(version.toString()).isEqualTo("2.0.0"); properties.put("version", "2.0.0-SNAPSHOT"); buildProperties = new BuildProperties(properties); when(buildPropertiesProvider.getIfUnique()).thenReturn(buildProperties); version = systemVersionSupplier.get(); assertThat(version.toString()).isEqualTo("2.0.0-SNAPSHOT"); assertThat(version.preReleaseVersion().orElseThrow()).isEqualTo("SNAPSHOT"); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/ExtensionResourceInitializerTest.java ================================================ package run.halo.app.infra; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Set; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.util.FileSystemUtils; import reactor.core.publisher.Mono; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Unstructured; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link ExtensionResourceInitializer}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class ExtensionResourceInitializerTest { @Mock ReactiveExtensionClient extensionClient; @Mock HaloProperties haloProperties; @Mock ApplicationStartedEvent applicationStartedEvent; @Mock ApplicationEventPublisher eventPublisher; @InjectMocks ExtensionResourceInitializer extensionResourceInitializer; List dirsToClean; @BeforeEach void setUp() throws IOException { dirsToClean = new ArrayList<>(2); Path tempDirectory = Files.createTempDirectory("extension-resource-initializer-test"); dirsToClean.add(tempDirectory); Path multiDirectory = Files.createDirectories(tempDirectory.resolve("a").resolve("b").resolve("c")); Files.writeString(tempDirectory.resolve("hello.yml"), """ kind: FakeExtension apiVersion: v1 metadata: name: fake-extension spec: hello: world """, StandardCharsets.UTF_8); Files.writeString(multiDirectory.getParent().resolve("fake-1.txt"), """ kind: FakeExtension name: fake-extension """, StandardCharsets.UTF_8); Files.writeString(multiDirectory.resolve("fake.yaml"), """ kind: FakeExtension apiVersion: v1 metadata: name: fake-extension spec: hello: world """, StandardCharsets.UTF_8); // test file in directory Path secondTempDir = Files.createTempDirectory("extension-resource-file-test"); dirsToClean.add(secondTempDir); Path filePath = secondTempDir.resolve("good.yml"); Files.writeString(filePath, """ kind: FakeExtension apiVersion: v1 metadata: name: config-file-is-ok spec: key: value """, StandardCharsets.UTF_8); when(haloProperties.getInitialExtensionLocations()) .thenReturn(Set.of("file:" + tempDirectory + "/**/*.yaml", "file:" + tempDirectory + "/**/*.yml", "file:" + filePath)); } @AfterEach void cleanUp() throws IOException { if (dirsToClean != null) { for (var dir : dirsToClean) { FileSystemUtils.deleteRecursively(dir); } } } @Test void shouldStartCorrectly() throws Exception { when(haloProperties.isRequiredExtensionDisabled()).thenReturn(true); var argumentCaptor = ArgumentCaptor.forClass(Unstructured.class); when(extensionClient.fetch(any(GroupVersionKind.class), any())) .thenReturn(Mono.empty()); when(extensionClient.create(any())).thenReturn(Mono.empty()); extensionResourceInitializer.start(); verify(extensionClient, times(3)).create(argumentCaptor.capture()); List values = argumentCaptor.getAllValues(); assertThat(values).isNotNull(); assertThat(values).hasSize(3); JSONAssert.assertEquals(""" [ { "kind": "FakeExtension", "apiVersion": "v1", "metadata": { "name": "config-file-is-ok" }, "spec": { "key": "value" } }, { "kind": "FakeExtension", "apiVersion": "v1", "metadata": { "name": "fake-extension" }, "spec": { "hello": "world" } }, { "kind": "FakeExtension", "apiVersion": "v1", "metadata": { "name": "fake-extension" }, "spec": { "hello": "world" } } ] """, JsonUtils.objectToJson(values), false); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/InitializationStateGetterTest.java ================================================ package run.halo.app.infra; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.User; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; /** * Tests for {@link InitializationStateGetter}. * * @author guqing * @since 2.9.0 */ @ExtendWith(MockitoExtension.class) class InitializationStateGetterTest { @Mock private ReactiveExtensionClient client; @InjectMocks private DefaultInitializationStateGetter initializationStateGetter; @Test void userInitialized() { when(client.listBy(eq(User.class), any(), any(PageRequest.class))) .thenReturn(Mono.empty()); initializationStateGetter.userInitialized() .as(StepVerifier::create) .expectNext(false) .verifyComplete(); User user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName("fake-hidden-user"); user.getMetadata().setLabels(Map.of("halo.run/hidden-user", "true")); user.setSpec(new User.UserSpec()); user.getSpec().setDisplayName("fake-hidden-user"); ListResult listResult = new ListResult<>(List.of(user)); when(client.listBy(eq(User.class), any(), any(PageRequest.class))) .thenReturn(Mono.just(listResult)); initializationStateGetter.userInitialized() .as(StepVerifier::create) .expectNext(true) .verifyComplete(); } @Test void dataInitialized() { ConfigMap configMap = new ConfigMap(); configMap.setMetadata(new Metadata()); configMap.getMetadata().setName(SystemState.SYSTEM_STATES_CONFIGMAP); configMap.setData(Map.of("states", "{\"isSetup\":true}")); when(client.fetch(eq(ConfigMap.class), eq(SystemState.SYSTEM_STATES_CONFIGMAP))) .thenReturn(Mono.just(configMap)); initializationStateGetter.dataInitialized() .as(StepVerifier::create) .expectNext(true) .verifyComplete(); // call again initializationStateGetter.dataInitialized() .as(StepVerifier::create) .expectNext(true) .verifyComplete(); // execute only once verify(client).fetch(eq(ConfigMap.class), eq(SystemState.SYSTEM_STATES_CONFIGMAP)); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/ReactiveExtensionPaginatedOperatorImplTest.java ================================================ package run.halo.app.infra; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import java.time.Instant; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Sort; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; @ExtendWith(MockitoExtension.class) class ReactiveExtensionPaginatedOperatorImplTest { @Mock private ReactiveExtensionClient client; @InjectMocks private ReactiveExtensionPaginatedOperatorImpl service; @Nested class ListTest { @BeforeEach void setUp() { Instant now = Instant.now(); var items = new ArrayList<>(); // Generate 900 items for (int j = 0; j < 9; j++) { items.addAll(generateItems(100, now)); } // mock new items during the process Instant otherNow = now.plusSeconds(1000); items.addAll(generateItems(90, otherNow)); when(client.listBy(any(), any(), any())).thenAnswer(invocation -> { PageRequest pageRequest = invocation.getArgument(2); int pageNumber = pageRequest.getPageNumber(); var list = ListResult.subList(items, pageNumber, pageRequest.getPageSize()); var result = new ListResult<>(pageNumber, pageRequest.getPageSize(), items.size(), list); return Mono.just(result); }); } @Test public void listTest() { StepVerifier.create(service.list(FakeExtension.class, new ListOptions())) .expectNextCount(900) .verifyComplete(); } } @Test void nextPageTest() { var result = new ListResult(1, 10, 30, List.of()); var sort = Sort.by("metadata.creationTimestamp"); var nextPage = ReactiveExtensionPaginatedOperatorImpl.nextPage(result, sort); assertThat(nextPage.getPageNumber()).isEqualTo(2); assertThat(nextPage.getPageSize()).isEqualTo(10); assertThat(nextPage.getSort()).isEqualTo(sort); } @Test void shouldTakeNextTest() { var now = Instant.now(); var item = new FakeExtension(); item.setMetadata(new Metadata()); item.getMetadata().setCreationTimestamp(now); var result = ReactiveExtensionPaginatedOperatorImpl.shouldTakeNext(item, now); assertThat(result).isTrue(); item.getMetadata().setCreationTimestamp(now.minusSeconds(1)); result = ReactiveExtensionPaginatedOperatorImpl.shouldTakeNext(item, now); assertThat(result).isTrue(); item.getMetadata().setCreationTimestamp(now.plusSeconds(1)); result = ReactiveExtensionPaginatedOperatorImpl.shouldTakeNext(item, now); assertThat(result).isFalse(); } private List generateItems(int count, Instant creationTimestamp) { List items = new ArrayList<>(); for (int i = 0; i < count; i++) { var item = new FakeExtension(); item.setMetadata(new Metadata()); item.getMetadata().setCreationTimestamp(creationTimestamp); items.add(item); } return items; } } ================================================ FILE: application/src/test/java/run/halo/app/infra/SystemConfigFirstExternalUrlSupplierTest.java ================================================ package run.halo.app.infra; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.net.MalformedURLException; import java.net.URI; import java.net.URL; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.boot.webflux.autoconfigure.WebFluxProperties; import org.springframework.http.HttpRequest; import reactor.core.publisher.Mono; import run.halo.app.infra.properties.HaloProperties; @ExtendWith(MockitoExtension.class) class SystemConfigFirstExternalUrlSupplierTest { @Mock HaloProperties haloProperties; @Mock WebFluxProperties webFluxProperties; @Mock SystemConfigFetcher systemConfigFetcher; @InjectMocks SystemConfigFirstExternalUrlSupplier externalUrl; @Nested class HaloPropertiesSupplier { @BeforeEach void setUp() { when(systemConfigFetcher.getBasic()).thenReturn(Mono.empty()); externalUrl.onExtensionInitialized(null); } @Test void getURIWhenUsingAbsolutePermalink() throws MalformedURLException { var fakeUri = URI.create("https://halo.run/fake"); when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL()); when(haloProperties.isUseAbsolutePermalink()).thenReturn(true); assertEquals(fakeUri, externalUrl.get()); } @Test void getURIWhenBasePathSetAndNotUsingAbsolutePermalink() { when(webFluxProperties.getBasePath()).thenReturn("/blog"); when(haloProperties.isUseAbsolutePermalink()).thenReturn(false); assertEquals(URI.create("/blog"), externalUrl.get()); } @Test void getURIWhenBasePathSetAndUsingAbsolutePermalink() throws MalformedURLException { var fakeUri = URI.create("https://halo.run/fake"); when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL()); lenient().when(webFluxProperties.getBasePath()).thenReturn("/blog"); when(haloProperties.isUseAbsolutePermalink()).thenReturn(true); assertEquals(URI.create("https://halo.run/fake"), externalUrl.get()); } @Test void getURIWhenUsingRelativePermalink() throws MalformedURLException { when(haloProperties.isUseAbsolutePermalink()).thenReturn(false); assertEquals(URI.create("/"), externalUrl.get()); } @Test void getURLWhenExternalURLProvided() throws MalformedURLException { var fakeUri = URI.create("https://halo.run/fake"); when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL()); var mockRequest = mock(HttpRequest.class); var url = externalUrl.getURL(mockRequest); assertEquals(fakeUri.toURL(), url); } @Test void getURLWhenExternalURLAbsent() throws MalformedURLException { var fakeUri = URI.create("https://localhost/fake"); when(haloProperties.getExternalUrl()).thenReturn(null); var mockRequest = mock(HttpRequest.class); when(mockRequest.getURI()).thenReturn(fakeUri); var url = externalUrl.getURL(mockRequest); assertEquals(new URL("https://localhost/"), url); } @Test void getURLWhenBasePathSetAndExternalURLProvided() throws MalformedURLException { var fakeUri = URI.create("https://localhost/fake"); when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL()); lenient().when(webFluxProperties.getBasePath()).thenReturn("/blog"); var mockRequest = mock(HttpRequest.class); lenient().when(mockRequest.getURI()).thenReturn(fakeUri); var url = externalUrl.getURL(mockRequest); assertEquals(new URL("https://localhost/fake"), url); } @Test void getURLWhenBasePathSetAndExternalURLAbsent() throws MalformedURLException { var fakeUri = URI.create("https://localhost/fake"); when(haloProperties.getExternalUrl()).thenReturn(null); when(webFluxProperties.getBasePath()).thenReturn("/blog"); var mockRequest = mock(HttpRequest.class); when(mockRequest.getURI()).thenReturn(fakeUri); var url = externalUrl.getURL(mockRequest); assertEquals(new URL("https://localhost/blog"), url); } @Test void getRaw() throws MalformedURLException { var fakeUri = URI.create("http://localhost/fake"); when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL()); assertEquals(fakeUri.toURL(), externalUrl.getRaw()); when(haloProperties.getExternalUrl()).thenReturn(null); assertNull(externalUrl.getRaw()); } } @Nested class SystemConfigSupplier { @Test void shouldGetUrlWhenUseAbsolutePermalink() throws Exception { var basic = new SystemSetting.Basic(); basic.setExternalUrl("https://www.halo.run"); when(systemConfigFetcher.getBasic()).thenReturn(Mono.just(basic)); when(haloProperties.isUseAbsolutePermalink()).thenReturn(true); externalUrl.onExtensionInitialized(null); assertEquals(URI.create("https://www.halo.run").toURL(), externalUrl.getRaw()); assertEquals(URI.create("https://www.halo.run"), externalUrl.get()); var mockRequest = mock(HttpRequest.class); assertEquals(URI.create("https://www.halo.run").toURL(), externalUrl.getURL(mockRequest)); } @Test void shouldGetUrlWhenNotUsingAbsolutePermalink() throws MalformedURLException { var basic = new SystemSetting.Basic(); basic.setExternalUrl("https://www.halo.run"); when(systemConfigFetcher.getBasic()).thenReturn(Mono.just(basic)); when(haloProperties.isUseAbsolutePermalink()).thenReturn(false); when(webFluxProperties.getBasePath()).thenReturn("/fake"); externalUrl.onExtensionInitialized(null); assertEquals(URI.create("https://www.halo.run").toURL(), externalUrl.getRaw()); assertEquals(URI.create("/fake"), externalUrl.get()); var mockRequest = mock(HttpRequest.class); assertEquals(URI.create("https://www.halo.run").toURL(), externalUrl.getURL(mockRequest)); } } } ================================================ FILE: application/src/test/java/run/halo/app/infra/SystemSettingTest.java ================================================ package run.halo.app.infra; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.HashMap; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import run.halo.app.extension.ConfigMap; import run.halo.app.infra.SystemSetting.Comment; import run.halo.app.infra.SystemSetting.ExtensionPointEnabled; import run.halo.app.infra.utils.JsonUtils; class SystemSettingTest { @Nested class ExtensionPointEnabledTest { @Test void deserializeTest() { var json = """ { "run.halo.app.search.post.PostSearchService": [ "run.halo.app.search.post.LucenePostSearchService" ] } """; var enabled = JsonUtils.jsonToObject(json, ExtensionPointEnabled.class); assertTrue(enabled.containsKey("run.halo.app.search.post.PostSearchService")); } } @Test void shouldGetConfigFromJson() { var configMap = new ConfigMap(); configMap.putDataItem("comment", """ {"enable": true} """); var comment = SystemSetting.get(configMap.getData(), Comment.GROUP, Comment.class); assertTrue(comment.getEnable()); } @Test void shouldGetNullIfKeyNotExist() { var configMap = new ConfigMap(); configMap.setData(new HashMap<>()); String fake = SystemSetting.get(configMap.getData(), "fake-key", String.class); assertNull(fake); } @Test void shouldGetConfigViaConversionService() { var configMap = new ConfigMap(); configMap.putDataItem("int", "100"); var integer = SystemSetting.get(configMap.getData(), "int", Integer.class); assertEquals(100, integer); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/SystemStateTest.java ================================================ package run.halo.app.infra; import static org.assertj.core.api.Assertions.assertThat; import java.util.LinkedHashMap; import java.util.Map; import org.junit.jupiter.api.Test; import run.halo.app.extension.ConfigMap; /** * Tests for {@link SystemState}. * * @author guqing * @since 2.8.0 */ class SystemStateTest { @Test void deserialize() { ConfigMap configMap = new ConfigMap(); SystemState systemState = SystemState.deserialize(configMap); assertThat(systemState).isNotNull(); configMap.setData(Map.of(SystemState.GROUP, "{\"isSetup\":true}")); systemState = SystemState.deserialize(configMap); assertThat(systemState.getIsSetup()).isTrue(); } @Test void update() { SystemState newSystemState = new SystemState(); newSystemState.setIsSetup(true); ConfigMap configMap = new ConfigMap(); SystemState.update(newSystemState, configMap); assertThat(configMap.getData().get(SystemState.GROUP)).isEqualTo("{\"isSetup\":true}"); var data = new LinkedHashMap(); configMap.setData(data); data.put(SystemState.GROUP, "{\"isSetup\":false}"); SystemState.update(newSystemState, configMap); assertThat(configMap.getData().get(SystemState.GROUP)).isEqualTo("{\"isSetup\":true}"); data.clear(); data.put(SystemState.GROUP, "{\"isSetup\":true, \"foo\":\"bar\"}"); newSystemState.setIsSetup(false); SystemState.update(newSystemState, configMap); assertThat(configMap.getData().get(SystemState.GROUP)) .isEqualTo("{\"isSetup\":false,\"foo\":\"bar\"}"); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/ValidationUtilsTest.java ================================================ package run.halo.app.infra; import static org.assertj.core.api.Assertions.assertThat; import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; /** * Tests for {@link ValidationUtils}. * * @author guqing * @since 2.5.0 */ class ValidationUtilsTest { @Nested class NameValidationTest { @Test void nullName() { assertThat(validateName(null)).isFalse(); } @Test void emptyUsername() { assertThat(validateName("")).isFalse(); } @Test void startWithIllegalCharacter() { assertThat(validateName("-abc")).isFalse(); } @Test void endWithIllegalCharacter() { assertThat(validateName("abc-")).isFalse(); assertThat(validateName("abcD")).isFalse(); } @Test void middleWithIllegalCharacter() { assertThat(validateName("ab?c")).isFalse(); } @Test void moreThan63Characters() { assertThat(validateName(StringUtils.repeat('a', 64))).isFalse(); } @Test void correctUsername() { assertThat(validateName("abc")).isTrue(); assertThat(validateName("ab-c")).isTrue(); assertThat(validateName("1st")).isTrue(); assertThat(validateName("ast1")).isTrue(); assertThat(validateName("ast-1")).isTrue(); } static boolean validateName(String name) { if (StringUtils.isBlank(name)) { return false; } boolean matches = ValidationUtils.NAME_PATTERN.matcher(name).matches(); return matches && name.length() <= 63; } } } ================================================ FILE: application/src/test/java/run/halo/app/infra/config/SessionConfigurationTest.java ================================================ package run.halo.app.infra.config; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.UnsatisfiedDependencyException; import org.springframework.boot.session.autoconfigure.SessionProperties; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; import org.springframework.boot.web.server.autoconfigure.ServerProperties; import org.springframework.session.ReactiveFindByIndexNameSessionRepository; import run.halo.app.security.session.ReactiveIndexedSessionRepository; class SessionConfigurationTest { @Test void shouldLoadContextIfNoStoreTypeProvided() { var contextRunner = new ReactiveWebApplicationContextRunner() .withUserConfiguration(SessionConfiguration.class) .withBean(SessionProperties.class) .withBean(ServerProperties.class); contextRunner.run(context -> { assertNull(context.getStartupFailure()); assertTrue(context.isActive()); assertInstanceOf( ReactiveIndexedSessionRepository.class, context.getBean(ReactiveIndexedSessionRepository.class) ); assertInstanceOf( ReactiveIndexedSessionRepository.class, context.getBean(ReactiveFindByIndexNameSessionRepository.class) ); }); } @Test void shouldLoadContextIfStoreTypeIsInMemory() { var contextRunner = new ReactiveWebApplicationContextRunner() .withUserConfiguration(SessionConfiguration.class) .withBean(SessionProperties.class) .withBean(ServerProperties.class) .withPropertyValues("halo.session.store-type=in-memory"); contextRunner.run(context -> { assertNull(context.getStartupFailure()); assertTrue(context.isActive()); assertInstanceOf( ReactiveIndexedSessionRepository.class, context.getBean(ReactiveIndexedSessionRepository.class) ); assertInstanceOf( ReactiveIndexedSessionRepository.class, context.getBean(ReactiveFindByIndexNameSessionRepository.class) ); }); } @Test void shouldFailToLoadContextIfStoreTypeIsInvalid() { var contextRunner = new ReactiveWebApplicationContextRunner() .withUserConfiguration(SessionConfiguration.class) .withBean(SessionProperties.class) .withBean(ServerProperties.class) .withPropertyValues("halo.session.store-type=invalid-type"); contextRunner.run(context -> assertInstanceOf(UnsatisfiedDependencyException.class, context.getStartupFailure()) ); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/exception/handlers/I18nExceptionTest.java ================================================ package run.halo.app.infra.exception.handlers; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; import java.util.Locale; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.dao.ConcurrencyFailureException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @SpringBootTest @AutoConfigureWebTestClient class I18nExceptionTest { @Autowired WebTestClient webClient; Locale currentLocale; @BeforeEach void setUp() { currentLocale = Locale.getDefault(); Locale.setDefault(Locale.ENGLISH); } @AfterEach void tearDown() { Locale.setDefault(currentLocale); } @Test void shouldBeOkForGreetingEndpoint() { webClient.get().uri("/response-entity/greet") .exchange() .expectStatus().isOk() .expectBody(String.class).isEqualTo("Hello Halo"); } @Test void shouldGetErrorIfErrorResponseThrow() { webClient.get().uri("/response-entity/error-response") .exchange() .expectStatus().isBadRequest() .expectBody(ProblemDetail.class) .value(problemDetail -> { assertEquals("Error Response", problemDetail.getTitle()); assertEquals("Message argument is {0}.", problemDetail.getDetail()); }); } @Test void shouldGetErrorIfErrorResponseThrowWithMessageCode() { webClient.get().uri("/response-entity/error-response/with-message-code") .exchange() .expectStatus().isBadRequest() .expectBody(ProblemDetail.class) .value(problemDetail -> { assertEquals("Error Response", problemDetail.getTitle()); assertEquals("Something went wrong, argument is fake-arg.", problemDetail.getDetail()); }); } @Test void shouldGetErrorIfErrorResponseThrowWithMessageCodeAndLocaleIsChinese() { webClient.get().uri("/response-entity/error-response/with-message-code") .header(HttpHeaders.ACCEPT_LANGUAGE, "zh-CN,zh") .exchange() .expectStatus().isBadRequest() .expectBody(ProblemDetail.class) .value(problemDetail -> { assertEquals("发生错误", problemDetail.getTitle()); assertEquals("发生了一些错误,参数:fake-arg。", problemDetail.getDetail()); }); } @Test void shouldGetErrorIfThrowingResponseStatusException() { webClient.get().uri("/response-entity/with-response-status-error") .exchange() .expectStatus().isEqualTo(HttpStatus.GONE) .expectBody(ProblemDetail.class) .value(problemDetail -> { assertEquals("Gone", problemDetail.getTitle()); assertEquals("Something went wrong", problemDetail.getDetail()); }); } @Test void shouldGetErrorIfThrowingGeneralException() { // problem reason will be a fixed prompt when internal server error occurred. webClient.get().uri("/response-entity/general-error") .exchange() .expectStatus().isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR) .expectBody(ProblemDetail.class) .value(problemDetail -> { assertEquals("Internal Server Error", problemDetail.getTitle()); assertEquals("Something went wrong, please try again later.", problemDetail.getDetail()); }); } @Test void shouldGetConflictError() { webClient.mutate().apply(csrf()).build() .put().uri("/response-entity/conflict-error") .exchange() .expectStatus().isEqualTo(HttpStatus.CONFLICT) .expectBody(ProblemDetail.class) .value(problemDetail -> { assertEquals("Conflict", problemDetail.getTitle()); assertEquals("Conflict detected.", problemDetail.getDetail()); }); } @TestConfiguration static class TestConfig { @RestController @RequestMapping("/response-entity") static class ResponseEntityController { @GetMapping("/greet") ResponseEntity greet() { return ResponseEntity.ok("Hello Halo"); } @GetMapping("/error-response") ResponseEntity throwErrorResponseException() { throw new ErrorResponseException(); } @GetMapping("/error-response/with-message-args") ResponseEntity throwErrorResponseExceptionWithMessageArgs() { throw new ErrorResponseException("Something went wrong.", null, new Object[] {"fake-arg"}); } @GetMapping("/error-response/with-message-code") ResponseEntity throwErrorResponseExceptionWithMessageCode() { throw new ErrorResponseException("Something went wrong.", "error.somethingWentWrong", new Object[] {"fake-arg"}); } @GetMapping("/with-response-status-error") ResponseEntity throwWithResponseStatusException() { throw new WithResponseStatusException(); } @GetMapping("/general-error") ResponseEntity throwGeneralException() { throw new GeneralException("Something went wrong"); } @PutMapping("/conflict-error") ResponseEntity throwConflictException() { throw new ConcurrencyFailureException("Conflict detected"); } } } static class ErrorResponseException extends ResponseStatusException { public ErrorResponseException() { this("Something went wrong."); } public ErrorResponseException(String reason) { this(reason, null, null); } public ErrorResponseException(String reason, String detailCode, Object[] detailArgs) { super(HttpStatus.BAD_REQUEST, reason, null, detailCode, detailArgs); } } @ResponseStatus(value = HttpStatus.GONE, reason = "Something went wrong") static class WithResponseStatusException extends RuntimeException { } static class GeneralException extends RuntimeException { public GeneralException(String message) { super(message); } } } ================================================ FILE: application/src/test/java/run/halo/app/infra/properties/HaloPropertiesTest.java ================================================ package run.halo.app.infra.properties; import java.net.MalformedURLException; import java.net.URL; import java.util.stream.Stream; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.validation.SimpleErrors; class HaloPropertiesTest { static Stream validateTest() throws MalformedURLException { return Stream.of( Arguments.of(true, new URL("http://localhost:8080"), true), Arguments.of(false, new URL("http://localhost:8080"), true), Arguments.of(true, new URL("https://localhost:8080"), true), Arguments.of(false, new URL("https://localhost:8080"), true), Arguments.of(true, new URL("ftp://localhost:8080"), false), Arguments.of(false, new URL("ftp://localhost:8080"), false), Arguments.of(true, new URL("http:www/halo/run"), false), Arguments.of(false, new URL("http:www/halo.run"), false), Arguments.of(true, new URL("https:www/halo/run"), false), Arguments.of(false, new URL("https:www/halo/run"), false), Arguments.of(true, new URL("https:///path"), false), Arguments.of(false, new URL("https:///path"), false), Arguments.of(true, new URL("http:///path"), false), Arguments.of(false, new URL("http:///path"), false), Arguments.of(true, null, false), Arguments.of(false, null, true) ); } @ParameterizedTest @MethodSource void validateTest(boolean useAbsolutePermalink, URL externalUrl, boolean valid) { var properties = new HaloProperties(); properties.setUseAbsolutePermalink(useAbsolutePermalink); properties.setExternalUrl(externalUrl); var errors = new SimpleErrors(properties); properties.validate(properties, errors); Assertions.assertEquals(valid, !errors.hasErrors()); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/utils/Base62UtilsTest.java ================================================ package run.halo.app.infra.utils; import static org.assertj.core.api.Assertions.assertThat; import io.seruco.encoding.base62.Base62; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Test; /** * Tests for {@link Base62}. * * @author guqing * @since 2.0.0 */ class Base62UtilsTest { @Test void encode() { getNaiveTestSet().forEach( (str, encoded) -> assertThat(Base62Utils.encode(str)).isEqualTo(encoded)); } @Test void decodeToString() { getNaiveTestSet().forEach( (str, encoded) -> assertThat(Base62Utils.decodeToString(encoded)).isEqualTo(str)); } public static Map getNaiveTestSet() { Map testSet = new HashMap<>(); testSet.put("", ""); testSet.put("a", "1Z"); testSet.put("Hello", "5TP3P3v"); testSet.put("Hello world!", "T8dgcjRGuYUueWht"); testSet.put("Just a test", "7G0iTmJjQFG2t6K"); testSet.put("!!!!!!!!!!!!!!!!!", "4A7f43EVXQoS6Am897ZKbAn"); testSet.put("0123456789", "18XU2xYejWO9d3"); testSet.put("The quick brown fox jumps over the lazy dog", "83UM8dOjD4xrzASgmqLOXTgTagvV1jPegUJ39mcYnwHwTlzpdfKXvpp4RL"); testSet.put("Sphinx of black quartz, judge my vow", "1Ul5yQGNM8YFBp3sz19dYj1kTp95OW7jI8pTcTP5JhYjIaFmx"); return testSet; } } ================================================ FILE: application/src/test/java/run/halo/app/infra/utils/FileNameUtilsTest.java ================================================ package run.halo.app.infra.utils; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static run.halo.app.infra.utils.FileNameUtils.randomFileName; import static run.halo.app.infra.utils.FileNameUtils.removeFileExtension; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class FileNameUtilsTest { @Nested class RemoveFileExtensionTest { @Test public void shouldNotRemoveExtIfNoExt() { assertEquals("halo", removeFileExtension("halo", true)); assertEquals("halo", removeFileExtension("halo", false)); } @Test public void shouldRemoveExtIfHasOnlyOneExt() { assertEquals("halo", removeFileExtension("halo.run", true)); assertEquals("halo", removeFileExtension("halo.run", false)); } @Test public void shouldNotRemoveExtIfDotfile() { assertEquals(".halo", removeFileExtension(".halo", true)); assertEquals(".halo", removeFileExtension(".halo", false)); } @Test public void shouldRemoveExtIfDotfileHasOneExt() { assertEquals(".halo", removeFileExtension(".halo.run", true)); assertEquals(".halo", removeFileExtension(".halo.run", false)); } @Test public void shouldRemoveExtIfHasTwoExt() { assertEquals("halo", removeFileExtension("halo.tar.gz", true)); assertEquals("halo.tar", removeFileExtension("halo.tar.gz", false)); } @Test public void shouldRemoveExtIfDotfileHasTwoExt() { assertEquals(".halo", removeFileExtension(".halo.tar.gz", true)); assertEquals(".halo.tar", removeFileExtension(".halo.tar.gz", false)); } @Test void shouldReturnNullIfFilenameIsNull() { assertNull(removeFileExtension(null, true)); assertNull(removeFileExtension(null, false)); } } @Nested class AppendRandomFileNameTest { @Test void normalFileName() { String randomFileName = randomFileName("halo.run", 3); assertEquals(12, randomFileName.length()); assertTrue(randomFileName.startsWith("halo-")); assertTrue(randomFileName.endsWith(".run")); randomFileName = randomFileName(".run", 3); assertEquals(7, randomFileName.length()); assertTrue(randomFileName.endsWith(".run")); randomFileName = randomFileName("halo", 3); assertEquals(8, randomFileName.length()); assertTrue(randomFileName.startsWith("halo-")); } } } ================================================ FILE: application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java ================================================ package run.halo.app.infra.utils; import static org.assertj.core.api.Assertions.assertThat; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import org.apache.tika.mime.MimeTypeException; import org.junit.jupiter.api.Test; import org.springframework.util.ResourceUtils; /** * Test for {@link FileTypeDetectUtils}. * * @author guqing * @since 2.18.0 */ class FileTypeDetectUtilsTest { @Test void detectMimeTypeTest() throws IOException { var file = ResourceUtils.getFile("classpath:app.key"); String mimeType = FileTypeDetectUtils.detectMimeType(Files.newInputStream(file.toPath())); assertThat(mimeType).isEqualTo("application/x-x509-key; format=pem"); file = ResourceUtils.getFile("classpath:console/index.html"); mimeType = FileTypeDetectUtils.detectMimeType(Files.newInputStream(file.toPath())); assertThat(mimeType).isEqualTo("text/plain"); file = ResourceUtils.getFile("classpath:themes/test-theme.zip"); mimeType = FileTypeDetectUtils.detectMimeType(Files.newInputStream(file.toPath())); assertThat(mimeType).isEqualTo("application/zip"); } @Test void detectMimeTypeWithNameTest() throws IOException { var stream = getFileInputStream("classpath:file-type-detect/index.js"); String mimeType = FileTypeDetectUtils.detectMimeType(stream, "index.js"); assertThat(mimeType).isEqualTo("text/javascript"); stream = getFileInputStream("classpath:file-type-detect/index.html"); mimeType = FileTypeDetectUtils.detectMimeType(stream, "index.html"); assertThat(mimeType).isEqualTo("text/html"); stream = getFileInputStream("classpath:file-type-detect/test.json"); mimeType = FileTypeDetectUtils.detectMimeType(stream, "test.json"); assertThat(mimeType).isEqualTo("application/json"); stream = getFileInputStream("classpath:file-type-detect/other.xlsx"); mimeType = FileTypeDetectUtils.detectMimeType(stream, "other.xlsx"); assertThat(mimeType).isEqualTo( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); // other.xlsx detect without name stream = getFileInputStream("classpath:file-type-detect/other.xlsx"); mimeType = FileTypeDetectUtils.detectMimeType(stream); assertThat(mimeType).isEqualTo("application/zip"); // other.xlsx detect with wrong name stream = getFileInputStream("classpath:file-type-detect/other.xlsx"); mimeType = FileTypeDetectUtils.detectMimeType(stream, "other.txt"); assertThat(mimeType).isEqualTo("application/zip"); stream = getFileInputStream("classpath:file-type-detect/test.docx"); mimeType = FileTypeDetectUtils.detectMimeType(stream, "test.docx"); assertThat(mimeType).isEqualTo( "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); // docx detect without file name stream = getFileInputStream("classpath:file-type-detect/test.docx"); mimeType = FileTypeDetectUtils.detectMimeType(stream); assertThat(mimeType).isEqualTo("application/zip"); stream = getFileInputStream("classpath:file-type-detect/test.svg"); mimeType = FileTypeDetectUtils.detectMimeType(stream, "test.svg"); assertThat(mimeType).isEqualTo("image/svg+xml"); stream = getFileInputStream("classpath:file-type-detect/test.png"); mimeType = FileTypeDetectUtils.detectMimeType(stream, "test.png"); assertThat(mimeType).isEqualTo("image/png"); } private static InputStream getFileInputStream(String location) throws IOException { var file = ResourceUtils.getFile(location); return Files.newInputStream(file.toPath()); } @Test void detectFileExtensionTest() throws MimeTypeException { var ext = FileTypeDetectUtils.detectFileExtension("application/x-x509-key; format=pem"); assertThat(ext).isEqualTo(""); ext = FileTypeDetectUtils.detectFileExtension("text/plain"); assertThat(ext).isEqualTo(".txt"); ext = FileTypeDetectUtils.detectFileExtension("application/zip"); assertThat(ext).isEqualTo(".zip"); ext = FileTypeDetectUtils.detectFileExtension("image/bmp"); assertThat(ext).isEqualTo(".bmp"); } @Test void detectFileExtensionsTest() throws MimeTypeException { var extensions = FileTypeDetectUtils.detectFileExtensions( "application/x-x509-key; format=pem" ); assertThat(extensions).isEmpty(); extensions = FileTypeDetectUtils.detectFileExtensions("text/plain"); assertThat(extensions).contains(".text"); extensions = FileTypeDetectUtils.detectFileExtensions("application/zip"); assertThat(extensions).contains(".zipx"); extensions = FileTypeDetectUtils.detectFileExtensions("image/bmp"); assertThat(extensions).contains(".dib"); extensions = FileTypeDetectUtils.detectFileExtensions("image/jpeg"); assertThat(extensions).contains(".jpeg"); } @Test void getFileExtensionTest() { var ext = FileTypeDetectUtils.getFileExtension("BMP+HTML+JAR.html"); assertThat(ext).isEqualTo(".html"); ext = FileTypeDetectUtils.getFileExtension("test.jpg"); assertThat(ext).isEqualTo(".jpg"); ext = FileTypeDetectUtils.getFileExtension("hello"); assertThat(ext).isEqualTo(""); } @Test void isValidExtensionForMimeTest() { assertThat(FileTypeDetectUtils.isValidExtensionForMime("image/bmp", "hello.html")) .isFalse(); assertThat(FileTypeDetectUtils.isValidExtensionForMime("image/bmp", "hello.bmp")) .isTrue(); assertThat(FileTypeDetectUtils.isValidExtensionForMime("image/jpeg", "hello.html")) .isFalse(); assertThat(FileTypeDetectUtils.isValidExtensionForMime("image/jpeg", "hello.jpeg")) .isTrue(); assertThat(FileTypeDetectUtils.isValidExtensionForMime("image/jpeg", "hello.JPEG")) .isTrue(); assertThat(FileTypeDetectUtils.isValidExtensionForMime("image/jpeg", "hello.jpg")) .isTrue(); assertThat(FileTypeDetectUtils.isValidExtensionForMime("image/jpeg", "hello.jpe")) .isTrue(); assertThat(FileTypeDetectUtils.isValidExtensionForMime("audio/mpeg", "hello.html")) .isFalse(); assertThat(FileTypeDetectUtils.isValidExtensionForMime("audio/mpeg", "hello.mp3")) .isTrue(); assertThat(FileTypeDetectUtils.isValidExtensionForMime("audio/mpeg", "hello.MPGA")) .isTrue(); assertThat(FileTypeDetectUtils.isValidExtensionForMime("audio/mpeg", "hello.m3a")) .isTrue(); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/utils/FileUtilsTest.java ================================================ package run.halo.app.infra.utils; import static java.util.Objects.requireNonNull; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal; import static run.halo.app.infra.utils.FileUtils.deleteFileSilently; import static run.halo.app.infra.utils.FileUtils.jar; import static run.halo.app.infra.utils.FileUtils.unzip; import static run.halo.app.infra.utils.FileUtils.zip; import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.zip.ZipInputStream; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import reactor.test.StepVerifier; import run.halo.app.infra.exception.AccessDeniedException; class FileUtilsTest { @TempDir Path tempDirectory; @Nested class DirectoryTraversalTest { @Test void traversalTestWhenSuccess() { checkDirectoryTraversal("/etc/", "/etc/halo/halo/../test"); checkDirectoryTraversal("/etc/", "/etc/halo/../test"); checkDirectoryTraversal("/etc/", "/etc/test"); } @Test void traversalTestWhenFailure() { assertThrows(AccessDeniedException.class, () -> checkDirectoryTraversal("/etc/", "/etc/../tmp")); assertThrows(AccessDeniedException.class, () -> checkDirectoryTraversal("/etc/", "/../tmp")); assertThrows(AccessDeniedException.class, () -> checkDirectoryTraversal("/etc/", "/tmp")); } } @Nested class ZipTest { @Test void zipFolderAndUnzip() throws IOException, URISyntaxException { var uri = requireNonNull(getClass().getClassLoader().getResource("folder-to-zip")) .toURI(); var zipPath = tempDirectory.resolve("example.zip"); zip(Paths.get(uri), zipPath); var unzipTarget = tempDirectory.resolve("example-folder"); try (var zis = new ZipInputStream(Files.newInputStream(zipPath))) { unzip(zis, unzipTarget); } var lines = Files.readAllLines(unzipTarget.resolve("examplefile")); assertEquals(1, lines.size()); assertEquals("Here is an example file.", lines.get(0)); } @Test void jarFolderAndUnzip() throws IOException, URISyntaxException { var uri = requireNonNull(getClass().getClassLoader().getResource("folder-to-zip")) .toURI(); var zipPath = tempDirectory.resolve("example.zip"); jar(Paths.get(uri), zipPath); var unzipTarget = tempDirectory.resolve("example-folder"); try (var zis = new ZipInputStream(Files.newInputStream(zipPath))) { unzip(zis, unzipTarget); } var lines = Files.readAllLines(unzipTarget.resolve("examplefile")); assertEquals(1, lines.size()); assertEquals("Here is an example file.", lines.get(0)); } @Test void zipFolderIfNoSuchFolder() { assertThrows(NoSuchFileException.class, () -> zip(Paths.get("no-such-folder"), tempDirectory.resolve("example.zip"))); } @Test void jarFolderIfNoSuchFolder() { assertThrows(NoSuchFileException.class, () -> jar(Paths.get("no-such-folder"), tempDirectory.resolve("example.zip"))); } } @Test void deleteFileSilentlyTest() throws IOException { StepVerifier.create(deleteFileSilently(null)) .expectNext(false) .verifyComplete(); StepVerifier.create(deleteFileSilently(tempDirectory)) .expectNext(false) .verifyComplete(); StepVerifier.create( deleteFileSilently(Files.createFile(tempDirectory.resolve("for-deleting")))) .expectNext(true) .verifyComplete(); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/utils/HaloUtilsTest.java ================================================ package run.halo.app.infra.utils; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.reactive.function.server.MockServerRequest; import org.springframework.mock.web.server.MockServerWebExchange; import run.halo.app.theme.router.ModelConst; class HaloUtilsTest { @Test void checkNoCache() { var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/").build()); var request = MockServerRequest.builder() .exchange(exchange) .build(); var applied = HaloUtils.noCache().apply(request); assertEquals(applied, request); assertTrue(() -> exchange.getRequiredAttribute(ModelConst.NO_CACHE)); } @ParameterizedTest @CsvSource({ "http://example.com/path with spaces, http://example.com/path%20with%20spaces", "https://example.com/üñîçødé, https://example.com/%C3%BC%C3%B1%C3%AE%C3%A7%C3%B8d%C3%A9", "ftp://example.com/special?param=äöü, ftp://example.com/special?param=%C3%A4%C3%B6%C3%BC", "http://example.com/normal-path, http://example.com/normal-path", "http://example.com/路径, http://example.com/%E8%B7%AF%E5%BE%84", "http://example.com/space%20space, http://example.com/space%20space", "http://example.com/100%100, http://example.com/100%100", "http://example.com/mixed chars%20and 中文, http://example.com/mixed%20chars%20and%20%E4%B8%AD%E6%96%87", "http://中文.com/path/to/中文, http://xn--fiq228c.com/path/to/%E4%B8%AD%E6%96%87" }) void shouldConvertUriSafely(String uri, String expected) { assertEquals(expected, HaloUtils.safeToUri(uri).toString()); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/utils/IpAddressUtilsTest.java ================================================ package run.halo.app.infra.utils; import static org.junit.jupiter.api.Assertions.assertEquals; import java.net.InetSocketAddress; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; class IpAddressUtilsTest { @Test void testGetIPAddressFromCloudflareProxy() { var request = MockServerHttpRequest.get("/") .header("CF-Connecting-IP", "127.0.0.1") .build(); var expected = "127.0.0.1"; var actual = IpAddressUtils.getClientIp(request); assertEquals(expected, actual); } @Test void testGetIPAddressFromXRealIpHeader() { var request = MockServerHttpRequest.get("/") .header("X-Real-IP", "127.0.0.1") .build(); var expected = "127.0.0.1"; var actual = IpAddressUtils.getClientIp(request); assertEquals(expected, actual); } @Test void testGetUnknownIPAddressWhenRemoteAddressIsNull() { var request = MockServerHttpRequest.get("/").build(); var actual = IpAddressUtils.getClientIp(request); assertEquals(IpAddressUtils.UNKNOWN, actual); } @Test void testGetUnknownIPAddressWhenRemoteAddressIsUnresolved() { var request = MockServerHttpRequest.get("/") .remoteAddress(InetSocketAddress.createUnresolved("localhost", 8090)) .build(); var actual = IpAddressUtils.getClientIp(request); assertEquals(IpAddressUtils.UNKNOWN, actual); } @Test void testGetIPAddressWithMultipleHeaders() { var headers = new HttpHeaders(); headers.add("X-Forwarded-For", "127.0.0.1, 127.0.1.1"); headers.add("Proxy-Client-IP", "127.0.0.2"); headers.add("CF-Connecting-IP", "127.0.0.2"); headers.add("WL-Proxy-Client-IP", "127.0.0.3"); headers.add("HTTP_CLIENT_IP", "127.0.0.4"); headers.add("HTTP_X_FORWARDED_FOR", "127.0.0.5"); var request = MockServerHttpRequest.get("/") .headers(headers) .build(); var expected = "127.0.0.1"; var actual = IpAddressUtils.getClientIp(request); assertEquals(expected, actual); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/utils/SettingUtilsTest.java ================================================ package run.halo.app.infra.utils; import java.util.Map; import org.json.JSONException; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import run.halo.app.core.extension.Setting; /** * Tests for {@link SettingUtils}. * * @author guqing * @since 2.0.1 */ class SettingUtilsTest { @Test void settingDefinedDefaultValueMap() throws JSONException { Setting setting = getFakeSetting(); var map = SettingUtils.settingDefinedDefaultValueMap(setting); JSONAssert.assertEquals(""" { "sns": "{\\"email\\":\\"example@exmple.com\\"}" } """, JsonUtils.objectToJson(map), true); } @Test void mergePatch() throws JSONException { Map defaultValue = Map.of("comment", "{\"enable\":true,\"requireReviewForNew\":true}", "basic", "{\"title\":\"guqing's blog\"}", "authProvider", "{\"github\":{\"clientId\":\"fake-client-id\"}}"); Map modified = Map.of("comment", "{\"enable\":true,\"requireReviewForNew\":true,\"systemUserOnly\":false}", "basic", "{\"title\":\"guqing's blog\", \"subtitle\": \"fake-sub-title\"}"); Map result = SettingUtils.mergePatch(modified, defaultValue); Map excepted = Map.of("comment", "{\"enable\":true,\"requireReviewForNew\":true,\"systemUserOnly\":false}", "basic", "{\"title\":\"guqing's blog\",\"subtitle\":\"fake-sub-title\"}", "authProvider", "{\"github\":{\"clientId\":\"fake-client-id\"}}"); JSONAssert.assertEquals(JsonUtils.objectToJson(excepted), JsonUtils.objectToJson(result), true); } @Test void mergePatchWithMoreType() throws JSONException { Map defaultValue = Map.of( "array", "[1,2,3]", "number", "1", "boolean", "false", "string", "new-default-string-value", "object", "{\"name\":\"guqing\"}" ); Map modified = Map.of( "stringArray", "[\"hello\", \"world\"]", "boolean", "true", "string", "hello", "object", "{\"name\":\"guqing\", \"age\": 18}" ); Map result = SettingUtils.mergePatch(modified, defaultValue); Map excepted = Map.of( "array", "[1,2,3]", "number", "1", "boolean", "true", "string", "hello", "object", "{\"name\":\"guqing\",\"age\":18}", "stringArray", "[\"hello\",\"world\"]" ); JSONAssert.assertEquals(JsonUtils.objectToJson(excepted), JsonUtils.objectToJson(result), true); } private static Setting getFakeSetting() { String settingJson = """ { "apiVersion": "v1alpha1", "kind": "Setting", "metadata": { "name": "theme-default-setting" }, "spec": { "forms": [{ "formSchema": [ { "$el": "h1", "children": "Register" }, { "$formkit": "text", "label": "Email", "name": "email", "value": "example@exmple.com" }, { "$formkit": "password", "label": "Password", "name": "password", "validation": "required|length:5,16", "value": null } ], "group": "sns", "label": "社交资料" }] } } """; return JsonUtils.jsonToObject(settingJson, Setting.class); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/utils/SortUtilsTest.java ================================================ package run.halo.app.infra.utils; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.List; import org.junit.jupiter.api.Test; /** * Tests for {@link SortUtils}. * * @author guqing * @since 2.19.0 */ class SortUtilsTest { @Test void resolve() { // null case assertThat(SortUtils.resolve(null).isUnsorted()).isTrue(); // multiple sort and directions var str = List.of("name,asc", "age,desc"); var sort = SortUtils.resolve(str); assertThat(sort.toString()).isEqualTo("name: ASC,age: DESC"); // missing direction str = List.of("name"); sort = SortUtils.resolve(str); assertThat(sort.toString()).isEqualTo("name: ASC"); // whitespace in direction assertThatThrownBy(() -> SortUtils.resolve(List.of("name, desc"))) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Direction must not contain whitespace"); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/utils/SystemConfigUtilsTest.java ================================================ package run.halo.app.infra.utils; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Test; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; class SystemConfigUtilsTest { private final ObjectMapper mapper = JsonUtils.mapper(); @Test void mergeMapShouldMergeEmptyMaps() throws JsonProcessingException { Map defaultMap = new HashMap<>(); Map overrideMap = new HashMap<>(); Map result = SystemConfigUtils.mergeMap(defaultMap, overrideMap); assertNotNull(result); assertTrue(result.isEmpty()); } @Test void mergeMapShouldReturnDefaultMapWhenOverrideIsEmpty() throws JsonProcessingException { Map defaultMap = new HashMap<>(); defaultMap.put("group1", "{\"key1\":\"value1\"}"); Map overrideMap = new HashMap<>(); Map result = SystemConfigUtils.mergeMap(defaultMap, overrideMap); assertEquals(1, result.size()); assertEquals("{\"key1\":\"value1\"}", result.get("group1")); } @Test void mergeMapShouldAddNewKeysFromOverrideMap() throws JsonProcessingException { Map defaultMap = new HashMap<>(); defaultMap.put("group1", "{\"key1\":\"value1\"}"); Map overrideMap = new HashMap<>(); overrideMap.put("group2", "{\"key2\":\"value2\"}"); Map result = SystemConfigUtils.mergeMap(defaultMap, overrideMap); assertEquals(2, result.size()); assertEquals("{\"key1\":\"value1\"}", result.get("group1")); assertEquals("{\"key2\":\"value2\"}", result.get("group2")); } @Test void mergeMapShouldDeepMergeJsonObjects() throws JsonProcessingException { Map defaultMap = new HashMap<>(); defaultMap.put("group1", "{\"key1\":\"value1\",\"nested\":{\"a\":\"1\"}}"); Map overrideMap = new HashMap<>(); overrideMap.put("group1", "{\"key2\":\"value2\",\"nested\":{\"b\":\"2\"}}"); Map result = SystemConfigUtils.mergeMap(defaultMap, overrideMap); assertEquals(1, result.size()); JsonNode resultNode = mapper.readTree(result.get("group1")); assertEquals("value1", resultNode.get("key1").asText()); assertEquals("value2", resultNode.get("key2").asText()); assertEquals("1", resultNode.get("nested").get("a").asText()); assertEquals("2", resultNode.get("nested").get("b").asText()); } @Test void mergeMapShouldOverrideExistingValues() throws JsonProcessingException { Map defaultMap = new HashMap<>(); defaultMap.put("group1", "{\"key1\":\"oldValue\"}"); Map overrideMap = new HashMap<>(); overrideMap.put("group1", "{\"key1\":\"newValue\"}"); Map result = SystemConfigUtils.mergeMap(defaultMap, overrideMap); assertEquals(1, result.size()); JsonNode resultNode = mapper.readTree(result.get("group1")); assertEquals("newValue", resultNode.get("key1").asText()); } @Test void mergeMapShouldHandleComplexNestedStructures() throws JsonProcessingException { Map defaultMap = new HashMap<>(); defaultMap.put("config", "{\"server\":{\"port\":8080,\"host\":\"localhost\"},\"features\":{\"auth\":true}}"); Map overrideMap = new HashMap<>(); overrideMap.put("config", "{\"server\":{\"port\":9090},\"features\":{\"logging\":true}}"); Map result = SystemConfigUtils.mergeMap(defaultMap, overrideMap); JsonNode resultNode = mapper.readTree(result.get("config")); assertEquals(9090, resultNode.get("server").get("port").asInt()); assertEquals("localhost", resultNode.get("server").get("host").asText()); assertTrue(resultNode.get("features").get("auth").asBoolean()); assertTrue(resultNode.get("features").get("logging").asBoolean()); } @Test void mergeMapShouldHandleArrayReplacement() throws JsonProcessingException { Map defaultMap = new HashMap<>(); defaultMap.put("group1", "{\"items\":[1,2,3]}"); Map overrideMap = new HashMap<>(); overrideMap.put("group1", "{\"items\":[4,5,6]}"); Map result = SystemConfigUtils.mergeMap(defaultMap, overrideMap); JsonNode resultNode = mapper.readTree(result.get("group1")); JsonNode items = resultNode.get("items"); assertEquals(3, items.size()); assertEquals(4, items.get(0).asInt()); assertEquals(5, items.get(1).asInt()); assertEquals(6, items.get(2).asInt()); } @Test void mergeMapShouldHandleNullValues() throws JsonProcessingException { Map defaultMap = new HashMap<>(); defaultMap.put("group1", "{\"key1\":\"value1\"}"); Map overrideMap = new HashMap<>(); overrideMap.put("group1", "{\"key1\":null}"); Map result = SystemConfigUtils.mergeMap(defaultMap, overrideMap); JsonNode resultNode = mapper.readTree(result.get("group1")); assertTrue(resultNode.get("key1").isNull()); } @Test void mergeMapShouldThrowExceptionForInvalidJson() { Map defaultMap = new HashMap<>(); defaultMap.put("group1", "invalid json"); Map overrideMap = new HashMap<>(); overrideMap.put("group1", "{\"key1\":\"value1\"}"); assertThrows(JsonProcessingException.class, () -> SystemConfigUtils.mergeMap(defaultMap, overrideMap)); } @Test void mergeConfigMapShouldMergeConfigMaps() throws JsonProcessingException { ConfigMap defaultConfigMap = new ConfigMap(); Metadata defaultMetadata = new Metadata(); defaultMetadata.setName("default-config"); defaultConfigMap.setMetadata(defaultMetadata); Map defaultData = new HashMap<>(); defaultData.put("group1", "{\"key1\":\"value1\"}"); defaultConfigMap.setData(defaultData); ConfigMap overrideConfigMap = new ConfigMap(); Metadata overrideMetadata = new Metadata(); overrideMetadata.setName("override-config"); overrideConfigMap.setMetadata(overrideMetadata); Map overrideData = new HashMap<>(); overrideData.put("group1", "{\"key2\":\"value2\"}"); overrideConfigMap.setData(overrideData); ConfigMap result = SystemConfigUtils.mergeConfigMap(defaultConfigMap, overrideConfigMap); assertNotNull(result); assertEquals("override-config", result.getMetadata().getName()); assertNotNull(result.getData()); assertEquals(1, result.getData().size()); JsonNode resultNode = mapper.readTree(result.getData().get("group1")); assertEquals("value1", resultNode.get("key1").asText()); assertEquals("value2", resultNode.get("key2").asText()); } @Test void mergeConfigMapShouldHandleNullData() throws JsonProcessingException { ConfigMap defaultConfigMap = new ConfigMap(); Metadata defaultMetadata = new Metadata(); defaultMetadata.setName("default-config"); defaultConfigMap.setMetadata(defaultMetadata); defaultConfigMap.setData(null); ConfigMap overrideConfigMap = new ConfigMap(); Metadata overrideMetadata = new Metadata(); overrideMetadata.setName("override-config"); overrideConfigMap.setMetadata(overrideMetadata); Map overrideData = new HashMap<>(); overrideData.put("group1", "{\"key1\":\"value1\"}"); overrideConfigMap.setData(overrideData); ConfigMap result = SystemConfigUtils.mergeConfigMap(defaultConfigMap, overrideConfigMap); assertNotNull(result); assertNotNull(result.getData()); assertEquals(1, result.getData().size()); assertEquals("{\"key1\":\"value1\"}", result.getData().get("group1")); } @Test void mergeConfigMapShouldHandleBothNullData() throws JsonProcessingException { ConfigMap defaultConfigMap = new ConfigMap(); Metadata defaultMetadata = new Metadata(); defaultMetadata.setName("default-config"); defaultConfigMap.setMetadata(defaultMetadata); defaultConfigMap.setData(null); ConfigMap overrideConfigMap = new ConfigMap(); Metadata overrideMetadata = new Metadata(); overrideMetadata.setName("override-config"); overrideConfigMap.setMetadata(overrideMetadata); overrideConfigMap.setData(null); ConfigMap result = SystemConfigUtils.mergeConfigMap(defaultConfigMap, overrideConfigMap); assertNotNull(result); assertNotNull(result.getData()); assertTrue(result.getData().isEmpty()); } @Test void mergeConfigMapShouldUseOverrideMetadata() throws JsonProcessingException { ConfigMap defaultConfigMap = new ConfigMap(); Metadata defaultMetadata = new Metadata(); defaultMetadata.setName("default-config"); defaultConfigMap.setMetadata(defaultMetadata); defaultConfigMap.setData(new HashMap<>()); ConfigMap overrideConfigMap = new ConfigMap(); Metadata overrideMetadata = new Metadata(); overrideMetadata.setName("override-config"); overrideConfigMap.setMetadata(overrideMetadata); overrideConfigMap.setData(new HashMap<>()); ConfigMap result = SystemConfigUtils.mergeConfigMap(defaultConfigMap, overrideConfigMap); assertEquals("override-config", result.getMetadata().getName()); } @Test void mergeMapShouldHandlePrimitiveValueReplacement() throws JsonProcessingException { Map defaultMap = new HashMap<>(); defaultMap.put("group1", "{\"number\":42,\"boolean\":true,\"string\":\"old\"}"); Map overrideMap = new HashMap<>(); overrideMap.put("group1", "{\"number\":99,\"boolean\":false,\"string\":\"new\"}"); Map result = SystemConfigUtils.mergeMap(defaultMap, overrideMap); JsonNode resultNode = mapper.readTree(result.get("group1")); assertEquals(99, resultNode.get("number").asInt()); assertFalse(resultNode.get("boolean").asBoolean()); assertEquals("new", resultNode.get("string").asText()); } @Test void mergeMapShouldHandleEmptyJsonObjects() throws JsonProcessingException { Map defaultMap = new HashMap<>(); defaultMap.put("group1", "{}"); Map overrideMap = new HashMap<>(); overrideMap.put("group1", "{\"key1\":\"value1\"}"); Map result = SystemConfigUtils.mergeMap(defaultMap, overrideMap); JsonNode resultNode = mapper.readTree(result.get("group1")); assertEquals("value1", resultNode.get("key1").asText()); } @Test void mergeMapShouldHandleDeepNesting() throws JsonProcessingException { Map defaultMap = new HashMap<>(); defaultMap.put("group1", "{\"level1\":{\"level2\":{\"level3\":{\"key\":\"value1\"}}}}"); Map overrideMap = new HashMap<>(); overrideMap.put("group1", "{\"level1\":{\"level2\":{\"level3\":{\"newKey\":\"value2\"}}}}"); Map result = SystemConfigUtils.mergeMap(defaultMap, overrideMap); JsonNode resultNode = mapper.readTree(result.get("group1")); JsonNode level3 = resultNode.get("level1").get("level2").get("level3"); assertEquals("value1", level3.get("key").asText()); assertEquals("value2", level3.get("newKey").asText()); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/utils/VersionUtilsTest.java ================================================ package run.halo.app.infra.utils; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; /** * Tests for {@link VersionUtils}. * * @author guqing * @since 2.2.0 */ class VersionUtilsTest { @Test void satisfiesRequires() { // match all requires String systemVersion = "0.0.0"; String requires = ">=2.2.0"; boolean result = VersionUtils.satisfiesRequires(systemVersion, requires); assertThat(result).isTrue(); systemVersion = "2.0.0"; requires = "*"; result = VersionUtils.satisfiesRequires(systemVersion, requires); assertThat(result).isTrue(); systemVersion = "2.0.0"; requires = ""; result = VersionUtils.satisfiesRequires(systemVersion, requires); assertThat(result).isTrue(); // match exact version systemVersion = "2.0.0"; requires = ">=2.0.0"; result = VersionUtils.satisfiesRequires(systemVersion, requires); assertThat(result).isTrue(); systemVersion = "2.0.0"; requires = ">2.0.0"; result = VersionUtils.satisfiesRequires(systemVersion, requires); assertThat(result).isFalse(); //an exact version x.y.z will implicitly mean the same as >=x.y.z systemVersion = "2.1.0"; // means >=2.0.0 requires = "2.0.0"; result = VersionUtils.satisfiesRequires(systemVersion, requires); assertThat(result).isTrue(); } } ================================================ FILE: application/src/test/java/run/halo/app/infra/utils/YamlUnstructuredLoaderTest.java ================================================ package run.halo.app.infra.utils; import static org.assertj.core.api.Assertions.assertThat; import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.core.io.Resource; import org.springframework.security.util.InMemoryResource; import run.halo.app.extension.Unstructured; /** * Tests for {@link YamlUnstructuredLoader}. * * @author guqing * @since 2.0.0 */ class YamlUnstructuredLoaderTest { private List yamlResources; private String notSpecYaml; @BeforeEach void setUp() { String viewCategoriesRoleYaml = """ apiVersion: v1alpha1 kind: Fake metadata: name: test1 hello: world: halo """; String multipleRoleYaml = """ apiVersion: v1alpha1 kind: Fake metadata: name: test2 hello: world: haha --- apiVersion: v1alpha1 kind: Fake metadata: name: test2 hello: world: bang """; notSpecYaml = """ server: port: 8090 spring: jackson: date-format: yyyy-MM-dd HH:mm:ss """; yamlResources = Stream.of(viewCategoriesRoleYaml, multipleRoleYaml, notSpecYaml) .map(InMemoryResource::new) .toList(); } @Test void loadTest() { Resource[] resources = yamlResources.toArray(Resource[]::new); YamlUnstructuredLoader yamlUnstructuredLoader = new YamlUnstructuredLoader(resources); List unstructuredList = yamlUnstructuredLoader.load(); assertThat(unstructuredList).isNotNull(); assertThat(unstructuredList).hasSize(3); assertThat(JsonUtils.objectToJson(unstructuredList)).isEqualToIgnoringWhitespace(""" [ { "apiVersion": "v1alpha1", "kind": "Fake", "metadata": { "name": "test1" }, "hello": { "world": "halo" } }, { "apiVersion": "v1alpha1", "kind": "Fake", "metadata": { "name": "test2" }, "hello": { "world": "haha" } }, { "apiVersion": "v1alpha1", "kind": "Fake", "metadata": { "name": "test2" }, "hello": { "world": "bang" } } ] """); } @Test void loadIgnore() { InMemoryResource resource = new InMemoryResource(notSpecYaml); YamlUnstructuredLoader yamlUnstructuredLoader = new YamlUnstructuredLoader(resource); List unstructuredList = yamlUnstructuredLoader.load(); assertThat(unstructuredList).isEmpty(); } } ================================================ FILE: application/src/test/java/run/halo/app/migration/BackupReconcilerTest.java ================================================ package run.halo.app.migration; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import java.io.IOException; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.Exceptions; import reactor.core.publisher.Mono; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler; @ExtendWith(MockitoExtension.class) class BackupReconcilerTest { @Mock MigrationService migrationService; @Mock ExtensionClient client; @InjectMocks BackupReconciler reconciler; @Test void whenFreshBackupIsComing() { var name = "fake-backup"; var backup = createPureBackup(name); backup.getSpec().setFormat("zip"); when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup)); doNothing().when(client).update(backup); when(migrationService.backup(backup)).thenReturn(Mono.fromRunnable(() -> { var status = backup.getStatus(); status.setFilename("fake-backup-filename"); status.setSize(1024L); })); var result = reconciler.reconcile(new Reconciler.Request(name)); assertNotNull(result); assertFalse(result.reEnqueue()); var status = backup.getStatus(); assertEquals(Backup.Phase.SUCCEEDED, status.getPhase()); assertNotNull(status.getStartTimestamp()); assertNotNull(status.getCompletionTimestamp()); assertEquals("fake-backup-filename", status.getFilename()); assertEquals(1024L, status.getSize()); // 1. query // 2. pending -> running // 3. running -> succeeded verify(client, times(3)).fetch(Backup.class, name); verify(client, times(3)).update(backup); verify(migrationService).backup(backup); } @Test void whenBackupDeleted() { var name = "fake-deleted-backup"; var backup = createPureBackup(name); backup.getMetadata().setDeletionTimestamp(Instant.now()); addFinalizers(backup.getMetadata(), Set.of(Constant.HOUSE_KEEPER_FINALIZER)); when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup)); when(migrationService.cleanup(backup)).thenReturn(Mono.empty()); doNothing().when(client).update(backup); var result = reconciler.reconcile(new Reconciler.Request(name)); assertNotNull(result); assertFalse(result.reEnqueue()); assertFalse(backup.getMetadata().getFinalizers().contains(Constant.HOUSE_KEEPER_FINALIZER)); verify(client).fetch(Backup.class, name); verify(migrationService).cleanup(backup); verify(client).update(backup); } @Test void setPhaseToFailedIfPhaseIsRunning() { var name = "fake-backup"; var backup = createPureBackup(name); var status = backup.getStatus(); status.setPhase(Backup.Phase.RUNNING); when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup)); doNothing().when(client).update(backup); var result = reconciler.reconcile(new Reconciler.Request(name)); assertNotNull(result); assertFalse(result.reEnqueue()); assertEquals(Backup.Phase.FAILED, status.getPhase()); assertEquals("UnexpectedExit", status.getFailureReason()); // 1. add finalizer // 2. update status verify(client, times(2)).fetch(Backup.class, name); verify(client, times(2)).update(backup); } @Test void shouldReQueueIfExpiresAtSetAndNotExpired() { var now = Instant.now(); reconciler.setClock(Clock.fixed(now, ZoneId.systemDefault())); var name = "fake-backup"; var backup = createPureBackup(name); addFinalizers(backup.getMetadata(), Set.of(Constant.HOUSE_KEEPER_FINALIZER)); backup.getSpec().setExpiresAt(now.plus(Duration.ofSeconds(3))); var status = backup.getStatus(); status.setPhase(Backup.Phase.SUCCEEDED); when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup)); var result = reconciler.reconcile(new Reconciler.Request(name)); assertNotNull(result); assertTrue(result.reEnqueue()); assertEquals(Duration.ofSeconds(3), result.retryAfter()); verify(client).fetch(Backup.class, name); verify(client, never()).update(backup); verify(client, never()).delete(backup); } @Test void shouldDeleteIfExpiresAtSetAndExpired() { var now = Instant.now(); reconciler.setClock(Clock.fixed(now, ZoneId.systemDefault())); var name = "fake-backup"; var backup = createPureBackup(name); addFinalizers(backup.getMetadata(), Set.of(Constant.HOUSE_KEEPER_FINALIZER)); backup.getSpec().setExpiresAt(now.minus(Duration.ofSeconds(3))); var status = backup.getStatus(); status.setPhase(Backup.Phase.SUCCEEDED); when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup)); doNothing().when(client).delete(backup); var result = reconciler.reconcile(new Reconciler.Request(name)); assertNotNull(result); assertFalse(result.reEnqueue()); verify(client).fetch(Backup.class, name); verify(client, never()).update(backup); verify(client).delete(backup); } @Test void whenBackupInterrupted() { var name = "fake-backup"; var backup = createPureBackup(name); backup.getSpec().setFormat("zip"); when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup)); doNothing().when(client).update(backup); when(migrationService.backup(backup)).thenReturn( Mono.error(Exceptions.propagate(new InterruptedException()))); var result = reconciler.reconcile(new Reconciler.Request(name)); assertNotNull(result); assertFalse(result.reEnqueue()); var status = backup.getStatus(); assertEquals(Backup.Phase.FAILED, status.getPhase()); assertNotNull(status.getStartTimestamp()); assertNull(status.getCompletionTimestamp()); assertEquals("Interrupted", status.getFailureReason()); // 1. query // 2. pending -> running // 3. running -> failed verify(client, times(3)).fetch(Backup.class, name); verify(client, times(3)).update(backup); verify(migrationService).backup(backup); } @Test void somethingWentWrongWhenBackup() { var name = "fake-backup"; var backup = createPureBackup(name); backup.getSpec().setFormat("zip"); when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup)); doNothing().when(client).update(backup); when(migrationService.backup(backup)) .thenReturn(Mono.error(Exceptions.propagate(new IOException("File not found")))); var result = reconciler.reconcile(new Reconciler.Request(name)); assertNotNull(result); assertFalse(result.reEnqueue()); var status = backup.getStatus(); assertEquals(Backup.Phase.FAILED, status.getPhase()); assertNotNull(status.getStartTimestamp()); assertNull(status.getCompletionTimestamp()); assertEquals("SystemError", status.getFailureReason()); // 1. query // 2. pending -> running // 3. running -> failed verify(client, times(3)).fetch(Backup.class, name); verify(client, times(3)).update(backup); verify(migrationService).backup(backup); } @Test void whenBackupWasFailed() { var name = "fake-backup"; var backup = createPureBackup(name); backup.getStatus().setPhase(Backup.Phase.FAILED); when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup)); var result = reconciler.reconcile(new Reconciler.Request(name)); assertNotNull(result); assertFalse(result.reEnqueue()); Mockito.verify(migrationService, never()).backup(any(Backup.class)); } Backup createPureBackup(String name) { var metadata = new Metadata(); metadata.setName(name); var backup = new Backup(); backup.setMetadata(metadata); return backup; } } ================================================ FILE: application/src/test/java/run/halo/app/migration/impl/MigrationServiceImplTest.java ================================================ package run.halo.app.migration.impl; import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.fasterxml.jackson.core.type.TypeReference; import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.FileTime; import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.zip.ZipInputStream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; import org.springframework.data.r2dbc.core.ReactiveSelectOperation.ReactiveSelect; import org.springframework.data.r2dbc.core.ReactiveSelectOperation.SelectWithQuery; import org.springframework.r2dbc.connection.R2dbcTransactionManager; import org.springframework.transaction.ReactiveTransaction; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.extension.Metadata; import run.halo.app.extension.store.ExtensionStore; import run.halo.app.extension.store.ExtensionStoreRepository; import run.halo.app.infra.BackupRootGetter; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.utils.FileUtils; import run.halo.app.migration.Backup; @ExtendWith(MockitoExtension.class) class MigrationServiceImplTest { @Mock ExtensionStoreRepository repository; @Mock HaloProperties haloProperties; @Mock BackupRootGetter backupRoot; @Mock R2dbcEntityTemplate entityTemplate; @Mock R2dbcTransactionManager txManager; @InjectMocks @Spy MigrationServiceImpl migrationService; @Mock ReactiveSelect reactiveSelect; @Mock SelectWithQuery selectWithQuery; @TempDir Path tempDir; @Test void backupTest() throws IOException { Files.writeString(tempDir.resolve("fake-file"), "halo", StandardOpenOption.CREATE_NEW); var extensionStores = List.of( createExtensionStore("fake-extension-store", "fake-data") ); var tx = mock(ReactiveTransaction.class); when(txManager.getReactiveTransaction(any())).thenReturn(Mono.just(tx)); when(txManager.commit(tx)).thenReturn(Mono.empty()); when(entityTemplate.select(ExtensionStore.class)).thenReturn(reactiveSelect); when(reactiveSelect.withFetchSize(100)).thenReturn(selectWithQuery); when(selectWithQuery.all()) .thenReturn(Flux.fromIterable(extensionStores)) .thenReturn(Flux.empty()); when(haloProperties.getWorkDir()).thenReturn(tempDir); when(backupRoot.get()).thenReturn(tempDir.resolve("backups")); var startTimestamp = Instant.now(); var backup = createRunningBackup("fake-backup", startTimestamp); StepVerifier.create(migrationService.backup(backup)) .verifyComplete(); // 1. backup workdir // 2. package backup verify(haloProperties).getWorkDir(); verify(backupRoot).get(); var status = backup.getStatus(); var datetimePart = migrationService.getDateTimeFormatter().format(startTimestamp); assertEquals(datetimePart + "-fake-backup.zip", status.getFilename()); var backupFile = migrationService.getBackupsRoot() .resolve(status.getFilename()); assertTrue(Files.exists(backupFile)); assertEquals(Files.size(backupFile), status.getSize()); var target = tempDir.resolve("target"); try (var zis = new ZipInputStream( Files.newInputStream(backupFile, StandardOpenOption.READ))) { FileUtils.unzip(zis, tempDir.resolve("target")); } var extensionsFile = target.resolve("extensions.data"); var workdir = target.resolve("workdir"); assertTrue(Files.exists(extensionsFile)); assertTrue(Files.exists(workdir)); var objectMapper = migrationService.getObjectMapper(); var gotExtensionStores = objectMapper.readValue(extensionsFile.toFile(), new TypeReference>() { }); assertEquals(gotExtensionStores, extensionStores); assertEquals("halo", Files.readString(workdir.resolve("fake-file"))); } @Test void restoreTest() throws IOException, URISyntaxException { var unpackedBackup = getClass().getClassLoader().getResource("backups/backup-for-restoration"); assertNotNull(unpackedBackup); var backupFile = tempDir.resolve("backups").resolve("fake-backup.zip"); Files.createDirectories(backupFile.getParent()); FileUtils.zip(Path.of(unpackedBackup.toURI()), backupFile); var workdir = tempDir.resolve("workdir-for-restoration"); Files.createDirectory(workdir); var expectStore = createExtensionStore("fake-extension-store", "fake-data"); expectStore.setVersion(null); when(haloProperties.getWorkDir()).thenReturn(workdir); when(repository.deleteAll()).thenReturn(Mono.empty()); when(repository.saveAll(List.of(expectStore))).thenReturn(Flux.empty()); var tx = mock(ReactiveTransaction.class); when(txManager.getReactiveTransaction(any())).thenReturn(Mono.just(tx)); when(txManager.commit(tx)).thenReturn(Mono.empty()); var content = DataBufferUtils.read(backupFile, DefaultDataBufferFactory.sharedInstance, 2048, StandardOpenOption.READ); StepVerifier.create(migrationService.restore(content)) .verifyComplete(); verify(haloProperties).getWorkDir(); verify(repository).deleteAll(); verify(repository).saveAll(List.of(expectStore)); // make sure the workdir is recovered. var fakeFile = workdir.resolve("fake-file"); assertEquals("halo", Files.readString(fakeFile)); } @Test void cleanupBackupTest() throws IOException { var backupFile = tempDir.resolve("workdir").resolve("backups").resolve("backup.zip"); Files.createDirectories(backupFile.getParent()); Files.createFile(backupFile); when(backupRoot.get()).thenReturn(tempDir.resolve("workdir").resolve("backups")); var backup = createSucceededBackup("fake-backup", "backup.zip"); StepVerifier.create(migrationService.cleanup(backup)) .verifyComplete(); verify(haloProperties, never()).getWorkDir(); verify(backupRoot).get(); assertTrue(Files.notExists(backupFile)); } @Test void cleanupBackupWithNoFilename() { var backup = createSucceededBackup("fake-backup", null); StepVerifier.create(migrationService.cleanup(backup)) .verifyComplete(); verify(haloProperties, never()).getWorkDir(); verify(backupRoot, never()).get(); } @Test void downloadBackupTest() throws IOException { var backupFile = tempDir.resolve("workdir").resolve("backups").resolve("backup.zip"); Files.createDirectories(backupFile.getParent()); Files.writeString(backupFile, "this is a backup file.", StandardOpenOption.CREATE_NEW); when(backupRoot.get()).thenReturn(tempDir.resolve("workdir").resolve("backups")); var backup = createSucceededBackup("fake-backup", "backup.zip"); StepVerifier.create(migrationService.download(backup)) .assertNext(resource -> { assertEquals("backup.zip", resource.getFilename()); try { var content = resource.getContentAsString(UTF_8); assertEquals("this is a backup file.", content); } catch (IOException e) { throw new RuntimeException(e); } }) .verifyComplete(); verify(haloProperties, never()).getWorkDir(); verify(backupRoot).get(); } @Test void downloadBackupWhichDoesNotExist() { var backup = createSucceededBackup("fake-backup", "backup.zip"); when(backupRoot.get()).thenReturn(tempDir.resolve("workdir").resolve("backups")); StepVerifier.create(migrationService.download(backup)) .expectError(NotFoundException.class) .verify(); verify(haloProperties, never()).getWorkDir(); verify(backupRoot).get(); } @Test void getBackupFilesTest() throws Exception { var now = Instant.now(); var backup1 = tempDir.resolve("backup1.zip"); Files.writeString(backup1, "fake-content"); Files.setLastModifiedTime(backup1, FileTime.from(now)); var backup2 = tempDir.resolve("backup2.zip"); Files.writeString(backup2, "fake--content"); Files.setLastModifiedTime( backup2, FileTime.from(now.plus(Duration.ofSeconds(1))) ); var backup3 = tempDir.resolve("backup3.not-a-zip"); Files.writeString(backup3, "fake-content"); Files.setLastModifiedTime( backup3, FileTime.from(now.plus(Duration.ofSeconds(2))) ); when(backupRoot.get()).thenReturn(tempDir); migrationService.afterPropertiesSet(); migrationService.getBackupFiles() .as(StepVerifier::create) .assertNext(backupFile -> { assertEquals("backup2.zip", backupFile.getFilename()); assertEquals(13, backupFile.getSize()); assertEquals(now.plus(Duration.ofSeconds(1)), backupFile.getLastModifiedTime()); }) .assertNext(backupFile -> { assertEquals("backup1.zip", backupFile.getFilename()); assertEquals(12, backupFile.getSize()); assertEquals(now, backupFile.getLastModifiedTime()); }) .verifyComplete(); } @Test void getBackupFileTest() throws Exception { var now = Instant.now(); Files.writeString(tempDir.resolve("backup.zip"), "fake-content"); Files.setLastModifiedTime(tempDir.resolve("backup.zip"), FileTime.from(now)); when(backupRoot.get()).thenReturn(tempDir); migrationService.afterPropertiesSet(); migrationService.getBackupFile("backup.zip") .as(StepVerifier::create) .assertNext(backupFile -> { assertEquals("backup.zip", backupFile.getFilename()); assertEquals(12, backupFile.getSize()); assertEquals(now, backupFile.getLastModifiedTime()); }) .verifyComplete(); migrationService.getBackupFile("backup-not-exist.zip") .as(StepVerifier::create) .verifyComplete(); } Backup createSucceededBackup(String name, String filename) { var metadata = new Metadata(); metadata.setName(name); var backup = new Backup(); backup.setMetadata(metadata); var status = backup.getStatus(); status.setPhase(Backup.Phase.SUCCEEDED); status.setCompletionTimestamp(Instant.now()); status.setFilename(filename); status.setSize(1024L); return backup; } Backup createRunningBackup(String name, Instant startTimestamp) { var metadata = new Metadata(); metadata.setName(name); var backup = new Backup(); backup.setMetadata(metadata); var status = backup.getStatus(); status.setPhase(Backup.Phase.RUNNING); status.setStartTimestamp(startTimestamp); return backup; } ExtensionStore createExtensionStore(String name, String data) { var store = new ExtensionStore(); store.setName(name); store.setData(data.getBytes(UTF_8)); store.setVersion(1024L); return store; } } ================================================ FILE: application/src/test/java/run/halo/app/notification/DefaultNotificationCenterTest.java ================================================ package run.halo.app.notification; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import java.util.Locale; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.User; import run.halo.app.core.extension.notification.Notification; import run.halo.app.core.extension.notification.NotificationTemplate; import run.halo.app.core.extension.notification.NotifierDescriptor; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.ReasonType; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; /** * Tests for {@link DefaultNotificationCenter}. * * @author guqing * @since 2.9.0 */ @ExtendWith(MockitoExtension.class) class DefaultNotificationCenterTest { @Mock private ReactiveExtensionClient client; @Mock private ReasonNotificationTemplateSelector notificationTemplateSelector; @Mock private UserNotificationPreferenceService userNotificationPreferenceService; @Mock private NotificationTemplateRender notificationTemplateRender; @Mock private NotificationSender notificationSender; @Mock private RecipientResolver recipientResolver; @Mock private SubscriptionService subscriptionService; @Mock private SystemConfigFetcher environmentFetcher; @InjectMocks private DefaultNotificationCenter notificationCenter; @Test public void testNotify() { final Reason reason = new Reason(); final Reason.Spec spec = new Reason.Spec(); Reason.Subject subject = new Reason.Subject(); subject.setApiVersion("content.halo.run/v1alpha1"); subject.setKind("Comment"); subject.setName("comment-a"); spec.setSubject(subject); spec.setReasonType("new-reply-on-comment"); spec.setAttributes(null); reason.setSpec(spec); reason.setMetadata(new Metadata()); reason.getMetadata().setName("reason-a"); var spyNotificationCenter = spy(notificationCenter); var subscriber = new Subscriber(UserIdentity.anonymousWithEmail("A"), "fake-name"); when(recipientResolver.resolve(reason)).thenReturn(Flux.just(subscriber)); doReturn(Mono.empty()).when(spyNotificationCenter) .dispatchNotification(eq(reason), any()); spyNotificationCenter.notify(reason).block(); verify(spyNotificationCenter).dispatchNotification(eq(reason), any()); verify(recipientResolver).resolve(eq(reason)); } List createSubscriptions() { Subscription subscription = new Subscription(); subscription.setMetadata(new Metadata()); subscription.getMetadata().setName("subscription-a"); subscription.setSpec(new Subscription.Spec()); subscription.getSpec().setSubscriber(new Subscription.Subscriber()); subscription.getSpec().getSubscriber().setName("anonymousUser#A"); var interestReason = new Subscription.InterestReason(); interestReason.setReasonType("new-reply-on-comment"); interestReason.setSubject(createNewReplyOnCommentSubject()); subscription.getSpec().setReason(interestReason); return List.of(subscription); } Subscription.ReasonSubject createNewReplyOnCommentSubject() { var reasonSubject = new Subscription.ReasonSubject(); reasonSubject.setApiVersion("content.halo.run/v1alpha1"); reasonSubject.setKind("Comment"); reasonSubject.setName("comment-a"); return reasonSubject; } @Test public void testSubscribe() { var spyNotificationCenter = spy(notificationCenter); Subscription subscription = createSubscriptions().get(0); var subscriber = subscription.getSpec().getSubscriber(); var reason = subscription.getSpec().getReason(); doReturn(Mono.empty()) .when(spyNotificationCenter).unsubscribe(eq(subscriber), eq(reason)); when(client.create(any(Subscription.class))).thenReturn(Mono.empty()); spyNotificationCenter.subscribe(subscriber, reason).block(); verify(client).create(any(Subscription.class)); } @Test public void testGetNotifiersBySubscriber() { UserNotificationPreference preference = new UserNotificationPreference(); when(userNotificationPreferenceService.getByUser(any())) .thenReturn(Mono.just(preference)); var reason = new Reason(); reason.setMetadata(new Metadata()); reason.getMetadata().setName("reason-a"); reason.setSpec(new Reason.Spec()); reason.getSpec().setReasonType("new-reply-on-comment"); var subscriber = new Subscriber(UserIdentity.anonymousWithEmail("A"), "fake-name"); notificationCenter.getNotifiersBySubscriber(subscriber, reason) .collectList() .as(StepVerifier::create) .consumeNextWith(notifiers -> { assertThat(notifiers).hasSize(1); assertThat(notifiers.get(0)).isEqualTo("default-email-notifier"); }) .verifyComplete(); verify(userNotificationPreferenceService).getByUser(eq(subscriber.name())); } @Test public void testDispatchNotification() { var spyNotificationCenter = spy(notificationCenter); doReturn(Flux.just("email-notifier")) .when(spyNotificationCenter).getNotifiersBySubscriber(any(), any()); NotifierDescriptor notifierDescriptor = mock(NotifierDescriptor.class); when(client.fetch(eq(NotifierDescriptor.class), eq("email-notifier"))) .thenReturn(Mono.just(notifierDescriptor)); var notificationElement = mock(DefaultNotificationCenter.NotificationElement.class); doReturn(Mono.just(notificationElement)) .when(spyNotificationCenter).prepareNotificationElement(any(), any(), any()); doReturn(Mono.empty()).when(spyNotificationCenter).sendNotification(any()); var reason = new Reason(); reason.setMetadata(new Metadata()); reason.getMetadata().setName("reason-a"); reason.setSpec(new Reason.Spec()); reason.getSpec().setReasonType("new-reply-on-comment"); var subscription = createSubscriptions().get(0); var subscriptionName = subscription.getMetadata().getName(); var subscriber = new Subscriber(UserIdentity.of(subscription.getSpec().getSubscriber().getName()), subscriptionName); spyNotificationCenter.dispatchNotification(reason, subscriber).block(); verify(client).fetch(eq(NotifierDescriptor.class), eq("email-notifier")); verify(spyNotificationCenter).sendNotification(any()); verify(spyNotificationCenter, times(0)).createNotification(any()); } @Test public void testPrepareNotificationElement() { var spyNotificationCenter = spy(notificationCenter); doReturn(Mono.just(Locale.getDefault())) .when(spyNotificationCenter).getLocaleFromSubscriber(any()); var notificationContent = mock(DefaultNotificationCenter.NotificationContent.class); doReturn(Mono.just(notificationContent)) .when(spyNotificationCenter).inferenceTemplate(any(), any(), any()); spyNotificationCenter.prepareNotificationElement(any(), any(), any()) .block(); verify(spyNotificationCenter).getLocaleFromSubscriber(any()); verify(spyNotificationCenter).inferenceTemplate(any(), any(), any()); } @Test public void testSendNotification() { var spyNotificationCenter = spy(notificationCenter); var context = mock(NotificationContext.class); doReturn(Mono.just(context)) .when(spyNotificationCenter).notificationContextFrom(any()); when(notificationSender.sendNotification(eq("fake-notifier-ext"), any())) .thenReturn(Mono.empty()); var element = mock(DefaultNotificationCenter.NotificationElement.class); var mockDescriptor = mock(NotifierDescriptor.class); when(element.descriptor()).thenReturn(mockDescriptor); when(element.subscriber()).thenReturn(mock(Subscriber.class)); var notifierDescriptorSpec = mock(NotifierDescriptor.Spec.class); when(mockDescriptor.getSpec()).thenReturn(notifierDescriptorSpec); when(notifierDescriptorSpec.getNotifierExtName()).thenReturn("fake-notifier-ext"); spyNotificationCenter.sendNotification(element).block(); verify(spyNotificationCenter).notificationContextFrom(any()); verify(notificationSender).sendNotification(any(), any()); } @Test public void testCreateNotification() { var element = mock(DefaultNotificationCenter.NotificationElement.class); var subscription = createSubscriptions().get(0); var user = mock(User.class); var subscriptionName = subscription.getMetadata().getName(); var subscriber = new Subscriber(UserIdentity.of(subscription.getSpec().getSubscriber().getName()), subscriptionName); when(client.fetch(eq(User.class), eq(subscriber.name()))).thenReturn(Mono.just(user)); when(element.subscriber()).thenReturn(subscriber); when(client.create(any(Notification.class))).thenReturn(Mono.empty()); var reason = new Reason(); reason.setMetadata(new Metadata()); reason.getMetadata().setName("reason-a"); reason.setSpec(new Reason.Spec()); reason.getSpec().setReasonType("new-reply-on-comment"); when(element.reason()).thenReturn(reason); notificationCenter.createNotification(element).block(); verify(client).fetch(eq(User.class), eq(subscriber.name())); verify(client).create(any(Notification.class)); } @Test public void testInferenceTemplate() { final var spyNotificationCenter = spy(notificationCenter); final var reasonType = mock(ReasonType.class); var reason = new Reason(); reason.setMetadata(new Metadata()); reason.getMetadata().setName("reason-a"); reason.setSpec(new Reason.Spec()); reason.getSpec().setReasonType("new-reply-on-comment"); var reasonTypeName = reason.getSpec().getReasonType(); doReturn(Mono.just(reasonType)) .when(spyNotificationCenter).getReasonType(eq(reasonTypeName)); doReturn(Mono.just("fake-unsubscribe-url")) .when(spyNotificationCenter).getUnsubscribeUrl(anyString()); final var locale = Locale.CHINESE; var template = new NotificationTemplate(); template.setMetadata(new Metadata()); template.getMetadata().setName("notification-template-a"); template.setSpec(new NotificationTemplate.Spec()); template.getSpec().setTemplate(new NotificationTemplate.Template()); template.getSpec().getTemplate().setRawBody("body"); template.getSpec().getTemplate().setHtmlBody("html-body"); template.getSpec().getTemplate().setTitle("title"); template.getSpec().setReasonSelector(new NotificationTemplate.ReasonSelector()); template.getSpec().getReasonSelector().setReasonType(reasonTypeName); template.getSpec().getReasonSelector().setLanguage(locale.getLanguage()); when(notificationTemplateRender.render(anyString(), any())) .thenReturn(Mono.empty()); when(notificationTemplateSelector.select(eq(reasonTypeName), any())) .thenReturn(Mono.just(template)); var subscriber = new Subscriber(UserIdentity.anonymousWithEmail("A"), "fake-name"); spyNotificationCenter.inferenceTemplate(reason, subscriber, locale).block(); verify(spyNotificationCenter).getReasonType(eq(reasonTypeName)); verify(notificationTemplateSelector).select(eq(reasonTypeName), any()); } @Test void getLocaleFromSubscriberTest() { var subscription = mock(Subscriber.class); when(environmentFetcher.getBasic()).thenReturn(Mono.just(new SystemSetting.Basic())); notificationCenter.getLocaleFromSubscriber(subscription) .as(StepVerifier::create) .expectNext(Locale.getDefault()) .verifyComplete(); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/DefaultNotificationReasonEmitterTest.java ================================================ package run.halo.app.notification; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import java.util.LinkedHashMap; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.skyscreamer.jsonassert.JSONAssert; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.ReasonType; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link DefaultNotificationReasonEmitter}. * * @author guqing * @since 2.9.0 */ @ExtendWith(MockitoExtension.class) class DefaultNotificationReasonEmitterTest { @Mock private ReactiveExtensionClient client; @InjectMocks private DefaultNotificationReasonEmitter emitter; @Test void testEmitWhenReasonTypeNotFound() { var reasonType = createReasonType(); when(client.fetch(eq(ReasonType.class), eq(reasonType.getMetadata().getName()))) .thenReturn(Mono.empty()); doEmmit(reasonType, reasonAttributes()) .as(StepVerifier::create) .verifyErrorMessage("404 NOT_FOUND \"ReasonType [" + reasonType.getMetadata().getName() + "] not found, do you forget to register it?\""); } @Test void testEmitWhenMissingAttributeValue() { var reasonType = createReasonType(); when(client.fetch(eq(ReasonType.class), eq(reasonType.getMetadata().getName()))) .thenReturn(Mono.just(reasonType)); var map = reasonAttributes(); map.put("commenter", null); doEmmit(reasonType, map) .as(StepVerifier::create) .verifyErrorMessage("Reason property [commenter] is required."); } @Test void testEmitWhenMissingOptionalAttribute() { var reasonType = createReasonType(); when(client.fetch(eq(ReasonType.class), eq(reasonType.getMetadata().getName()))) .thenReturn(Mono.just(reasonType)); when(client.create(any(Reason.class))).thenReturn(Mono.empty()); var map = reasonAttributes(); map.put("postTitle", null); doEmmit(reasonType, map) .as(StepVerifier::create) .verifyComplete(); } @Test void testCreateReasonOnEmit() { var reasonType = createReasonType(); when(client.fetch(eq(ReasonType.class), eq(reasonType.getMetadata().getName()))) .thenReturn(Mono.just(reasonType)); when(client.create(any(Reason.class))).thenReturn(Mono.empty()); var spyEmitter = spy(emitter); doAnswer(as -> { var returnedValue = as.callRealMethod(); JSONAssert.assertEquals(createReasonJson(), JsonUtils.objectToJson(returnedValue), true); return returnedValue; }).when(spyEmitter).createReason(any(), any()); spyEmitter.emit(reasonType.getMetadata().getName(), builder -> builder.attributes(reasonAttributes()) .subject(Reason.Subject.builder() .apiVersion("content.halo.run/v1alpha1") .kind("Post") .name("5152aea5-c2e8-4717-8bba-2263d46e19d5") .title("Hello Halo") .url("/archives/hello-halo") .build() ) ) .as(StepVerifier::create) .verifyComplete(); } Map reasonAttributes() { var map = new LinkedHashMap(); map.put("postName", "5152aea5-c2e8-4717-8bba-2263d46e19d5"); map.put("postTitle", "Hello Halo"); map.put("commenter", "guqing"); map.put("commentName", "53a76c38-5df2-469d-ae1b-68f5ae21a398"); map.put("content", "测试评论"); return map; } private Mono doEmmit(ReasonType reasonType, Map map) { return emitter.emit(reasonType.getMetadata().getName(), builder -> { builder.attributes(map) .subject(Reason.Subject.builder() .apiVersion("content.halo.run/v1alpha1") .kind("Post") .name("5152aea5-c2e8-4717-8bba-2263d46e19d5") .title("Hello Halo") .url("/archives/hello-halo") .build() ); }); } String createReasonJson() { return """ { "spec": { "reasonType": "new-comment-on-post", "subject": { "apiVersion": "content.halo.run/v1alpha1", "kind": "Post", "name": "5152aea5-c2e8-4717-8bba-2263d46e19d5", "title": "Hello Halo", "url": "/archives/hello-halo" }, "attributes": { "postName": "5152aea5-c2e8-4717-8bba-2263d46e19d5", "postTitle": "Hello Halo", "commentName": "53a76c38-5df2-469d-ae1b-68f5ae21a398", "content": "测试评论", "commenter": "guqing" } }, "apiVersion": "notification.halo.run/v1alpha1", "kind": "Reason", "metadata": { "generateName": "reason-" } } """; } ReasonType createReasonType() { return JsonUtils.jsonToObject(""" { "apiVersion": "notification.halo.run/v1alpha1", "kind": "ReasonType", "metadata": { "name": "new-comment-on-post" }, "spec": { "description": "当你的文章收到新评论时,触发事件", "displayName": "文章收到新评论", "properties": [ { "name": "postName", "type": "string" }, { "name": "postTitle", "type": "string", "optional": true }, { "name": "commenter", "type": "string" }, { "name": "commentName", "type": "string" }, { "name": "content", "type": "string" } ] } } """, ReasonType.class); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/DefaultNotificationSenderTest.java ================================================ package run.halo.app.notification; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; /** * Tests for {@link DefaultNotificationSender}. * * @author guqing * @since 2.9.0 */ class DefaultNotificationSenderTest { @Nested class QueueItemTest { @Test void equalsTest() { var item1 = new DefaultNotificationSender.QueueItem("1", mock(Runnable.class), 0); var item2 = new DefaultNotificationSender.QueueItem("1", mock(Runnable.class), 1); assertThat(item1).isEqualTo(item2); } } } ================================================ FILE: application/src/test/java/run/halo/app/notification/DefaultNotificationTemplateRenderTest.java ================================================ package run.halo.app.notification; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.net.MalformedURLException; import java.net.URI; import java.net.URL; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; /** * Tests for {@link DefaultNotificationTemplateRender}. * * @author guqing * @since 2.9.0 */ @ExtendWith(MockitoExtension.class) class DefaultNotificationTemplateRenderTest { @Mock private SystemConfigFetcher environmentFetcher; @Mock private ExternalUrlSupplier externalUrlSupplier; @InjectMocks DefaultNotificationTemplateRender templateRender; @BeforeEach void setUp() throws MalformedURLException { var uri = URI.create("http://localhost:8090"); lenient().when(externalUrlSupplier.get()).thenReturn(uri); lenient().when(externalUrlSupplier.getRaw()).thenReturn(uri.toURL()); } @Test void render() { final String template = """ 亲爱的博主 [(${replier})] 在评论“[(${isQuoteReply ? quoteContent : commentContent})]”中回复了您, 以下是回复的具体内容: [(${content})] [(${site.title})] [(${site.url})] 祝好! 查看原文:[(${commentSubjectUrl})] """; final var model = Map.of( "replier", "guqing", "isQuoteReply", true, "quoteContent", "这是引用的内容", "commentContent", "这是评论的内容", "commentSubjectUrl", "/archives/1", "content", "这是回复的内容" ); var basic = new SystemSetting.Basic(); basic.setTitle("Halo"); basic.setLogo("https://halo.run/logo"); basic.setSubtitle("Halo"); when(environmentFetcher.fetch(eq(SystemSetting.Basic.GROUP), eq(SystemSetting.Basic.class))) .thenReturn(Mono.just(basic)); templateRender.render(template, model) .as(StepVerifier::create) .consumeNextWith(render -> { assertThat(render).isEqualTo(""" 亲爱的博主 guqing 在评论“这是引用的内容”中回复了您, 以下是回复的具体内容: 这是回复的内容 Halo http://localhost:8090 祝好! 查看原文:/archives/1 """); }) .verifyComplete(); verify(environmentFetcher).fetch(eq(SystemSetting.Basic.GROUP), eq(SystemSetting.Basic.class)); verify(externalUrlSupplier).getRaw(); } @Test void siteUrlTest() throws MalformedURLException { when(environmentFetcher.fetch(eq(SystemSetting.Basic.GROUP), eq(SystemSetting.Basic.class))) .thenReturn(Mono.just(new SystemSetting.Basic())); var template = "查看通知"; var expected = "查看通知"; when(externalUrlSupplier.getRaw()).thenReturn(new URL("http://localhost:8090/")); templateRender.render(template, Map.of()) .as(StepVerifier::create) .expectNext(expected) .verifyComplete(); when(externalUrlSupplier.getRaw()).thenReturn(new URL("http://localhost:8090")); templateRender.render(template, Map.of()) .as(StepVerifier::create) .expectNext(expected) .verifyComplete(); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/DefaultNotifierConfigStoreTest.java ================================================ package run.halo.app.notification; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static run.halo.app.notification.DefaultNotifierConfigStore.RECEIVER_KEY; import static run.halo.app.notification.DefaultNotifierConfigStore.SECRET_NAME; import static run.halo.app.notification.DefaultNotifierConfigStore.SENDER_KEY; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Secret; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link DefaultNotifierConfigStore}. * * @author guqing * @since 2.9.0 */ @ExtendWith(MockitoExtension.class) class DefaultNotifierConfigStoreTest { @Mock private ReactiveExtensionClient client; @InjectMocks DefaultNotifierConfigStore notifierConfigStore; @Test void fetchReceiverConfigTest() { var objectNode = mock(ObjectNode.class); var spyNotifierConfigStore = spy(notifierConfigStore); doReturn(Mono.just(objectNode)).when(spyNotifierConfigStore) .fetchConfig(eq("fake-notifier")); var receiverConfig = mock(ObjectNode.class); when(objectNode.get(eq(RECEIVER_KEY))).thenReturn(receiverConfig); spyNotifierConfigStore.fetchReceiverConfig("fake-notifier") .as(StepVerifier::create) .consumeNextWith(actual -> assertThat(actual).isEqualTo(receiverConfig)) .verifyComplete(); verify(objectNode).get(eq(RECEIVER_KEY)); } @Test void fetchSenderConfigTest() { var objectNode = mock(ObjectNode.class); var spyNotifierConfigStore = spy(notifierConfigStore); doReturn(Mono.just(objectNode)).when(spyNotifierConfigStore) .fetchConfig(eq("fake-notifier")); var senderConfig = mock(ObjectNode.class); when(objectNode.get(eq(DefaultNotifierConfigStore.SENDER_KEY))).thenReturn(senderConfig); spyNotifierConfigStore.fetchSenderConfig("fake-notifier") .as(StepVerifier::create) .consumeNextWith(actual -> assertThat(actual).isEqualTo(senderConfig)) .verifyComplete(); verify(objectNode).get(eq(DefaultNotifierConfigStore.SENDER_KEY)); } @Test void fetchConfigWhenSecretNotFound() { var spyNotifierConfigStore = spy(notifierConfigStore); var objectNode = JsonNodeFactory.instance.objectNode(); doReturn(Mono.just(objectNode)).when(spyNotifierConfigStore) .fetchConfig(eq("fake-notifier")); spyNotifierConfigStore.fetchSenderConfig("fake-notifier") .as(StepVerifier::create) .consumeNextWith(actual -> assertThat(actual).isNotNull()) .verifyComplete(); spyNotifierConfigStore.fetchReceiverConfig("fake-notifier") .as(StepVerifier::create) .consumeNextWith(actual -> assertThat(actual).isNotNull()) .verifyComplete(); } @Test void saveReceiverConfigTest() { var receiverConfig = mock(ObjectNode.class); var spyNotifierConfigStore = spy(notifierConfigStore); doReturn(Mono.empty()).when(spyNotifierConfigStore) .saveConfig(eq("fake-notifier"), eq(RECEIVER_KEY), eq(receiverConfig)); spyNotifierConfigStore.saveReceiverConfig("fake-notifier", receiverConfig) .as(StepVerifier::create) .verifyComplete(); verify(spyNotifierConfigStore) .saveConfig(eq("fake-notifier"), eq(RECEIVER_KEY), eq(receiverConfig)); } @Test void saveSenderConfigTest() { var senderConfig = mock(ObjectNode.class); var spyNotifierConfigStore = spy(notifierConfigStore); doReturn(Mono.empty()).when(spyNotifierConfigStore) .saveConfig(eq("fake-notifier"), eq(SENDER_KEY), eq(senderConfig)); spyNotifierConfigStore.saveSenderConfig("fake-notifier", senderConfig) .as(StepVerifier::create) .verifyComplete(); verify(spyNotifierConfigStore) .saveConfig(eq("fake-notifier"), eq(SENDER_KEY), eq(senderConfig)); } @Test void saveConfigTest() { when(client.fetch(eq(Secret.class), eq(SECRET_NAME))).thenReturn(Mono.empty()); when(client.create(any(Secret.class))) .thenAnswer(answer -> Mono.just(answer.getArgument(0, Secret.class))); when(client.update(any(Secret.class))) .thenAnswer(answer -> Mono.just(answer.getArgument(0, Secret.class))); var objectNode = JsonNodeFactory.instance.objectNode(); objectNode.put("k1", "v1"); notifierConfigStore.saveConfig("fake-notifier", "fake-key", objectNode) .as(StepVerifier::create) .verifyComplete(); verify(client).fetch(eq(Secret.class), eq(SECRET_NAME)); verify(client).create(assertArg(arg -> { assertThat(arg).isInstanceOf(Secret.class); var secret = (Secret) arg; assertThat(secret.getMetadata().getName()).isEqualTo(SECRET_NAME); assertThat(secret.getMetadata().getFinalizers()) .contains(MetadataUtil.SYSTEM_FINALIZER); assertThat(secret.getStringData()).isNotNull(); })); verify(client).update(assertArg(arg -> { assertThat(arg).isInstanceOf(Secret.class); var secret = (Secret) arg; assertThat(secret.getStringData().get("fake-notifier.json")) .isEqualTo("{\"fake-key\":{\"k1\":\"v1\"}}"); })); } @Test void fetchConfigTest() { String s = "{\"fake-key\":{\"k1\":\"v1\"}}"; var objectNode = JsonUtils.jsonToObject(s, ObjectNode.class); var secret = new Secret(); secret.setStringData(Map.of("fake-notifier.json", s)); when(client.fetch(eq(Secret.class), eq(SECRET_NAME))) .thenReturn(Mono.just(secret)); notifierConfigStore.fetchConfig("fake-notifier") .as(StepVerifier::create) .consumeNextWith(actual -> assertThat(actual).isEqualTo(objectNode)) .verifyComplete(); } @Test void resolveKeyTest() { assertThat(notifierConfigStore.resolveKey("fake-notifier")) .isEqualTo("fake-notifier.json"); assertThat(notifierConfigStore.resolveKey("other-notifier")) .isEqualTo("other-notifier.json"); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/DefaultSubscriberEmailResolverTest.java ================================================ package run.halo.app.notification; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.User; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.AnonymousUserConst; /** * Tests for {@link DefaultSubscriberEmailResolver}. * * @author guqing * @since 2.9.0 */ @ExtendWith(MockitoExtension.class) class DefaultSubscriberEmailResolverTest { @Mock private ReactiveExtensionClient client; @InjectMocks DefaultSubscriberEmailResolver subscriberEmailResolver; @Test void testResolve() { var subscriber = new Subscription.Subscriber(); subscriber.setName(AnonymousUserConst.PRINCIPAL + "#test@example.com"); subscriberEmailResolver.resolve(subscriber) .as(StepVerifier::create) .expectNext("test@example.com") .verifyComplete(); subscriber.setName(AnonymousUserConst.PRINCIPAL + "#"); subscriberEmailResolver.resolve(subscriber) .as(StepVerifier::create) .verifyErrorMessage("The subscriber does not have an email"); var user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName("fake-user"); user.setSpec(new User.UserSpec()); user.getSpec().setEmail("test@halo.run"); user.getSpec().setEmailVerified(false); when(client.fetch(eq(User.class), eq("fake-user"))).thenReturn(Mono.just(user)); subscriber.setName("fake-user"); subscriberEmailResolver.resolve(subscriber) .as(StepVerifier::create) .verifyComplete(); user.getSpec().setEmailVerified(true); when(client.fetch(eq(User.class), eq("fake-user"))).thenReturn(Mono.just(user)); subscriber.setName("fake-user"); subscriberEmailResolver.resolve(subscriber) .as(StepVerifier::create) .expectNext("test@halo.run") .verifyComplete(); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/LanguageUtilsTest.java ================================================ package run.halo.app.notification; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.List; import java.util.Locale; import org.junit.jupiter.api.Test; /** * Tests for {@link LanguageUtils}. * * @author guqing * @since 2.9.0 */ class LanguageUtilsTest { @Test void computeLangFromLocale() { List languages = LanguageUtils.computeLangFromLocale(Locale.CHINA); assertThat(languages).isEqualTo(List.of("default", "zh", "zh_CN")); languages = LanguageUtils.computeLangFromLocale(Locale.CHINESE); assertThat(languages).isEqualTo(List.of("default", "zh")); languages = LanguageUtils.computeLangFromLocale(Locale.TAIWAN); assertThat(languages).isEqualTo(List.of("default", "zh", "zh_TW")); languages = LanguageUtils.computeLangFromLocale(Locale.ENGLISH); assertThat(languages).isEqualTo(List.of("default", "en")); languages = LanguageUtils.computeLangFromLocale(Locale.US); assertThat(languages).isEqualTo(List.of("default", "en", "en_US")); languages = LanguageUtils.computeLangFromLocale(Locale.forLanguageTag("en-US-x-lvariant-POSIX")); assertThat(languages).isEqualTo(List.of("default", "en", "en_US", "en_US-POSIX")); } @Test void computeLangFromLocaleWhenLanguageIsEmpty() { assertThatThrownBy(() -> { LanguageUtils.computeLangFromLocale(Locale.forLanguageTag("")); }).isInstanceOf(IllegalArgumentException.class) .hasMessage("Locale \"\" cannot be used as it does not specify a language."); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/NotificationContextTest.java ================================================ package run.halo.app.notification; import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.time.Instant; import org.junit.jupiter.api.Test; /** * Tests for {@link NotificationContext}. * * @author guqing * @since 2.9.0 */ class NotificationContextTest { @Test void constructTest() { // Create a test message payload NotificationContext.MessagePayload payload = new NotificationContext.MessagePayload(); payload.setTitle("Test Title"); payload.setRawBody("Test Body"); payload.setHtmlBody("Html body"); // Create a test subject NotificationContext.Subject subject = NotificationContext.Subject.builder() .apiVersion("v1") .kind("test") .name("test-name") .title("Test Subject") .url("https://example.com") .build(); // Create a test message NotificationContext.Message message = new NotificationContext.Message(); message.setPayload(payload); message.setSubject(subject); message.setRecipient("test-recipient"); message.setTimestamp(Instant.now()); // Create a test receiver config ObjectMapper mapper = new ObjectMapper(); ObjectNode receiverConfig = mapper.createObjectNode(); receiverConfig.put("key", "value"); // Create a test sender config ObjectNode senderConfig = mapper.createObjectNode(); senderConfig.put("key", "value"); // Create a test notification context NotificationContext notificationContext = new NotificationContext(); notificationContext.setMessage(message); notificationContext.setReceiverConfig(receiverConfig); notificationContext.setSenderConfig(senderConfig); // Test getter methods assertThat(notificationContext.getMessage()).isNotNull(); assertThat(notificationContext.getMessage().getPayload()).isEqualTo(payload); assertThat(notificationContext.getMessage().getSubject()).isEqualTo(subject); assertThat("test-recipient").isEqualTo(notificationContext.getMessage().getRecipient()); assertThat(notificationContext.getMessage().getTimestamp()).isNotNull(); assertThat(notificationContext.getReceiverConfig()).isEqualTo(receiverConfig); assertThat(notificationContext.getSenderConfig()).isEqualTo(senderConfig); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/NotificationTriggerTest.java ================================================ package run.halo.app.notification; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Instant; import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; import run.halo.app.core.extension.notification.Reason; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler; /** * Test for {@link NotificationTrigger}. * * @author guqing * @since 2.9.0 */ @ExtendWith(MockitoExtension.class) class NotificationTriggerTest { @Mock ExtensionClient client; @Mock NotificationCenter notificationCenter; @InjectMocks NotificationTrigger notificationTrigger; @Test void shouldCleanUpAfterNotified() { var reason = mock(Reason.class); var metadata = mock(Metadata.class); when(reason.getMetadata()).thenReturn(metadata); when(metadata.getDeletionTimestamp()).thenReturn(null); when(metadata.getFinalizers()).thenReturn(Set.of()); when(client.fetch(eq(Reason.class), eq("fake-reason"))) .thenReturn(Optional.of(reason)); when(notificationCenter.notify(eq(reason))).thenReturn(Mono.empty()); notificationTrigger.reconcile(new Reconciler.Request("fake-reason")); verify(notificationCenter).notify(eq(reason)); verify(metadata).setFinalizers(eq(Set.of(NotificationTrigger.TRIGGERED_FINALIZER))); verify(client).delete(any(Reason.class)); } @Test void shouldRemoveFinalizerAfterDeleted() { var reason = mock(Reason.class); var metadata = mock(Metadata.class); when(reason.getMetadata()).thenReturn(metadata); when(metadata.getDeletionTimestamp()).thenReturn(Instant.now()); when(metadata.getFinalizers()) .thenReturn(Set.of(NotificationTrigger.TRIGGERED_FINALIZER)); when(client.fetch(eq(Reason.class), eq("fake-reason"))) .thenReturn(Optional.of(reason)); notificationTrigger.reconcile(new Reconciler.Request("fake-reason")); verify(metadata).setFinalizers(eq(Set.of())); verify(client).update(eq(reason)); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/ReasonNotificationTemplateSelectorImplTest.java ================================================ package run.halo.app.notification; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.stream.Stream; import lombok.NonNull; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Sort; import reactor.core.publisher.Flux; import reactor.test.StepVerifier; import run.halo.app.core.extension.notification.NotificationTemplate; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link ReasonNotificationTemplateSelectorImpl}. * * @author guqing * @since 2.9.0 */ @ExtendWith(MockitoExtension.class) class ReasonNotificationTemplateSelectorImplTest { @Mock private ReactiveExtensionClient client; @InjectMocks ReasonNotificationTemplateSelectorImpl templateSelector; @Test void select() { when(client.listAll(eq(NotificationTemplate.class), any(), any(Sort.class))) .thenReturn(Flux.fromIterable(templates())); // language priority: zh_CN -> zh -> default // if language is same, then compare creationTimestamp to get the latest one templateSelector.select("new-comment-on-post", Locale.SIMPLIFIED_CHINESE) .as(StepVerifier::create) .consumeNextWith(template -> { assertThat(template.getMetadata().getName()).isEqualTo("template-2"); assertThat(template.getSpec().getTemplate().getTitle()).isEqualTo("B"); }) .verifyComplete(); } @Test void lookupTemplateByLocaleTest() { Map> map = new HashMap<>(); map.put("zh_CN", Optional.of(createNotificationTemplate("zh_CN-template"))); map.put("zh", Optional.of(createNotificationTemplate("zh-template"))); map.put("default", Optional.of(createNotificationTemplate("default-template"))); var sc = ReasonNotificationTemplateSelectorImpl .lookupTemplateByLocale(Locale.SIMPLIFIED_CHINESE, map); assertThat(sc).isNotNull(); assertThat(sc.getMetadata().getName()).isEqualTo("zh_CN-template"); var c = ReasonNotificationTemplateSelectorImpl .lookupTemplateByLocale(Locale.CHINESE, map); assertThat(c).isNotNull(); assertThat(c.getMetadata().getName()).isEqualTo("zh-template"); var e = ReasonNotificationTemplateSelectorImpl .lookupTemplateByLocale(Locale.ENGLISH, map); assertThat(e).isNotNull(); assertThat(e.getMetadata().getName()).isEqualTo("default-template"); } @Test void matchReasonTypeTest() { var template = createNotificationTemplate("fake-template"); assertThat(ReasonNotificationTemplateSelectorImpl.matchReasonType("new-comment-on-post") .test(template)).isTrue(); assertThat(ReasonNotificationTemplateSelectorImpl.matchReasonType("fake-reason-type") .test(template)).isFalse(); } @Test void getLanguageKeyTest() { final var languageKeyFunc = ReasonNotificationTemplateSelectorImpl.getLanguageKey(); var template = createNotificationTemplate("fake-template"); assertThat(languageKeyFunc.apply(template)).isEqualTo("zh_CN"); template.getSpec().getReasonSelector().setLanguage(""); template.getSpec().getReasonSelector().setReasonType("new-comment-on-post"); assertThat(languageKeyFunc.apply(template)).isEqualTo("default"); } @NonNull private static NotificationTemplate createNotificationTemplate(String name) { var template = new NotificationTemplate(); template.setMetadata(new Metadata()); template.getMetadata().setName(name); template.setSpec(new NotificationTemplate.Spec()); template.getSpec().setReasonSelector(new NotificationTemplate.ReasonSelector()); template.getSpec().getReasonSelector().setLanguage("zh_CN"); template.getSpec().getReasonSelector().setReasonType("new-comment-on-post"); return template; } List templates() { return Stream.of(""" { "apiVersion": "notification.halo.run/v1alpha1", "kind": "NotificationTemplate", "metadata": { "name": "template-1", "creationTimestamp": "2023-01-01T00:00:00Z" }, "spec": { "reasonSelector": { "language": "zh", "reasonType": "new-comment-on-post" }, "template": { "body": "", "title": "A" } } } """, """ { "apiVersion": "notification.halo.run/v1alpha1", "kind": "NotificationTemplate", "metadata": { "name": "template-2", "creationTimestamp": "2023-01-01T00:00:03Z" }, "spec": { "reasonSelector": { "language": "zh_CN", "reasonType": "new-comment-on-post" }, "template": { "body": "", "title": "B" } } } """, """ { "apiVersion": "notification.halo.run/v1alpha1", "kind": "NotificationTemplate", "metadata": { "name": "template-3", "creationTimestamp": "2023-01-01T00:00:00Z" }, "spec": { "reasonSelector": { "language": "zh_CN", "reasonType": "new-comment-on-post" }, "template": { "body": "", "title": "C" } } } """) .map(json -> JsonUtils.jsonToObject(json, NotificationTemplate.class)) .toList(); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/ReasonPayloadTest.java ================================================ package run.halo.app.notification; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertNotNull; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Test; import run.halo.app.core.extension.notification.Reason; /** * Tests for {@link ReasonPayload}. * * @author guqing * @since 2.9.0 */ class ReasonPayloadTest { @Test public void testReasonPayloadBuilder() { Reason.Subject subject = Reason.Subject.builder() .kind("Post") .apiVersion("content.halo.run/v1alpha1") .name("fake-post") .title("Fake post title") .url("https://halo.run/fake-post") .build(); Map attributes = new HashMap<>(); attributes.put("key1", "value1"); attributes.put("key2", 2); attributes.put("key3", "value3"); ReasonPayload reasonPayload = ReasonPayload.builder() .subject(subject) .attribute("key1", "value1") .attribute("key2", 2) .attributes(Map.of("key3", "value3")) .build(); assertNotNull(reasonPayload); assertThat(reasonPayload).isNotNull(); assertThat(reasonPayload.getSubject()).isEqualTo(subject); assertThat(reasonPayload.getAttributes()).isEqualTo(attributes); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/RecipientResolverImplTest.java ================================================ package run.halo.app.notification; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Flux; import reactor.test.StepVerifier; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.Metadata; /** * Tests for {@link RecipientResolverImpl}. * * @author guqing * @since 2.15.0 */ @ExtendWith(MockitoExtension.class) class RecipientResolverImplTest { @Mock private SubscriptionService subscriptionService; @InjectMocks private RecipientResolverImpl recipientResolver; @Test void testExpressionMatch() { var subscriber1 = new Subscription.Subscriber(); subscriber1.setName("test"); final var subscription1 = createSubscription(subscriber1); subscription1.getMetadata().setName("test-subscription"); subscription1.getSpec().getReason().setSubject(null); subscription1.getSpec().getReason().setExpression("props.owner == 'test'"); var subscriber2 = new Subscription.Subscriber(); subscriber2.setName("guqing"); final var subscription2 = createSubscription(subscriber2); subscription2.getMetadata().setName("guqing-subscription"); subscription2.getSpec().getReason().setSubject(null); subscription2.getSpec().getReason().setExpression("props.owner == 'guqing'"); var reason = new Reason(); reason.setSpec(new Reason.Spec()); reason.getSpec().setReasonType("new-comment-on-post"); reason.getSpec().setSubject(new Reason.Subject()); reason.getSpec().getSubject().setApiVersion("content.halo.run/v1alpha1"); reason.getSpec().getSubject().setKind("Post"); reason.getSpec().getSubject().setName("fake-post"); var reasonAttributes = new ReasonAttributes(); reasonAttributes.put("owner", "guqing"); reason.getSpec().setAttributes(reasonAttributes); when(subscriptionService.listByPerPage(anyString())) .thenReturn(Flux.just(subscription1, subscription2)); recipientResolver.resolve(reason) .as(StepVerifier::create) .expectNext(new Subscriber(UserIdentity.of("guqing"), "guqing-subscription")) .verifyComplete(); verify(subscriptionService).listByPerPage(anyString()); } @Test void testSubjectMatch() { var subscriber = new Subscription.Subscriber(); subscriber.setName("test"); Subscription subscription = createSubscription(subscriber); when(subscriptionService.listByPerPage(anyString())) .thenReturn(Flux.just(subscription)); var reason = new Reason(); reason.setSpec(new Reason.Spec()); reason.getSpec().setReasonType("new-comment-on-post"); reason.getSpec().setSubject(new Reason.Subject()); reason.getSpec().getSubject().setApiVersion("content.halo.run/v1alpha1"); reason.getSpec().getSubject().setKind("Post"); reason.getSpec().getSubject().setName("fake-post"); recipientResolver.resolve(reason) .as(StepVerifier::create) .expectNext(new Subscriber(UserIdentity.of("test"), "fake-subscription")) .verifyComplete(); verify(subscriptionService).listByPerPage(anyString()); } @Test void distinct() { // same subscriber to different subscriptions var subscriber = new Subscription.Subscriber(); subscriber.setName("test"); final var subscription1 = createSubscription(subscriber); subscription1.getMetadata().setName("sub-1"); final var subscription2 = createSubscription(subscriber); subscription2.getMetadata().setName("sub-2"); subscription2.getSpec().getReason().setSubject(null); subscription2.getSpec().getReason().setExpression("props.owner == 'guqing'"); when(subscriptionService.listByPerPage(anyString())) .thenReturn(Flux.just(subscription1, subscription2)); var reason = new Reason(); reason.setSpec(new Reason.Spec()); reason.getSpec().setReasonType("new-comment-on-post"); reason.getSpec().setSubject(new Reason.Subject()); reason.getSpec().getSubject().setApiVersion("content.halo.run/v1alpha1"); reason.getSpec().getSubject().setKind("Post"); reason.getSpec().getSubject().setName("fake-post"); var reasonAttributes = new ReasonAttributes(); reasonAttributes.put("owner", "guqing"); reason.getSpec().setAttributes(reasonAttributes); recipientResolver.resolve(reason) .as(StepVerifier::create) .expectNextCount(1) .verifyComplete(); verify(subscriptionService).listByPerPage(anyString()); } @Test void subjectMatchTest() { var subscriber = new Subscription.Subscriber(); subscriber.setName("test"); final var subscription = createSubscription(subscriber); // match all name subscription var subject = new Reason.Subject(); subject.setApiVersion("content.halo.run/v1alpha1"); subject.setKind("Post"); subject.setName("fake-post"); assertThat(RecipientResolverImpl.subjectMatch(subscription, subject)).isTrue(); // different kind subject = new Reason.Subject(); subject.setApiVersion("content.halo.run/v1alpha1"); subject.setKind("SinglePage"); subject.setName("fake-post"); assertThat(RecipientResolverImpl.subjectMatch(subscription, subject)).isFalse(); // special case subscription.getSpec().getReason().getSubject().setName("other-post"); subject = new Reason.Subject(); subject.setApiVersion("content.halo.run/v1alpha1"); subject.setKind("Post"); subject.setName("fake-post"); assertThat(RecipientResolverImpl.subjectMatch(subscription, subject)).isFalse(); subject.setName("other-post"); assertThat(RecipientResolverImpl.subjectMatch(subscription, subject)).isTrue(); } private static Subscription createSubscription(Subscription.Subscriber subscriber) { Subscription subscription = new Subscription(); subscription.setMetadata(new Metadata()); subscription.getMetadata().setName("fake-subscription"); subscription.setSpec(new Subscription.Spec()); subscription.getSpec().setSubscriber(subscriber); var interestReason = new Subscription.InterestReason(); interestReason.setReasonType("new-comment-on-post"); interestReason.setSubject(new Subscription.ReasonSubject()); interestReason.getSubject().setApiVersion("content.halo.run/v1alpha1"); interestReason.getSubject().setKind("Post"); subscription.getSpec().setReason(interestReason); return subscription; } } ================================================ FILE: application/src/test/java/run/halo/app/notification/SubscriptionServiceImplTest.java ================================================ package run.halo.app.notification; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.concurrent.atomic.AtomicLong; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.OptimisticLockingFailureException; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; /** * Tests for {@link SubscriptionServiceImpl}. * * @author guqing * @since 2.15.0 */ @ExtendWith(MockitoExtension.class) class SubscriptionServiceImplTest { @Mock private ReactiveExtensionClient client; @InjectMocks private SubscriptionServiceImpl subscriptionService; @Test void remove() { var i = new AtomicLong(1L); when(client.delete(any(Subscription.class))).thenAnswer(invocation -> { var subscription = (Subscription) invocation.getArgument(0); if (i.get() != subscription.getMetadata().getVersion()) { return Mono.error(new OptimisticLockingFailureException("fake-exception")); } return Mono.just(subscription); }); var subscription = new Subscription(); subscription.setMetadata(new Metadata()); subscription.getMetadata().setName("fake-subscription"); subscription.getMetadata().setVersion(0L); when(client.fetch(eq(Subscription.class), eq("fake-subscription"))) .thenAnswer(invocation -> { if (i.incrementAndGet() > 3) { subscription.getMetadata().setVersion(i.get()); } else { subscription.getMetadata().setVersion(i.get() - 1); } return Mono.just(subscription); }); subscriptionService.remove(subscription) .as(StepVerifier::create) .expectNextCount(1) .verifyComplete(); // give version=0, but the real version is 1 // give version=1, but the real version is 2 // give version=2, but the real version is 3 // give version=3, but the real version is 3 (delete success) verify(client, times(3)).fetch(eq(Subscription.class), eq("fake-subscription")); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/UserIdentityTest.java ================================================ package run.halo.app.notification; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; /** * Tests for {@link UserIdentity}. * * @author guqing * @since 2.9.0 */ class UserIdentityTest { @Test void getEmailTest() { var identity = UserIdentity.anonymousWithEmail("test@example.com"); assertThat(identity.getEmail().orElse(null)).isEqualTo("test@example.com"); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/UserNotificationPreferenceServiceImplTest.java ================================================ package run.halo.app.notification; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Map; import java.util.Set; import org.json.JSONException; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.skyscreamer.jsonassert.JSONAssert; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link UserNotificationPreferenceServiceImpl}. * * @author guqing * @since 2.9.0 */ @ExtendWith(MockitoExtension.class) class UserNotificationPreferenceServiceImplTest { @Mock private ReactiveExtensionClient client; @InjectMocks private UserNotificationPreferenceServiceImpl userNotificationPreferenceService; @Test void getByUser() { var configMap = new ConfigMap(); configMap.setData(Map.of("notification", "{\"reasonTypeNotifier\":{\"comment\":{\"notifiers\":[\"test-notifier\"]}}}")); when(client.fetch(ConfigMap.class, "user-preferences-guqing")) .thenReturn(Mono.just(configMap)); userNotificationPreferenceService.getByUser("guqing") .as(StepVerifier::create) .consumeNextWith(preference -> { assertThat(preference.getReasonTypeNotifier()).isNotNull(); assertThat(preference.getReasonTypeNotifier().get("comment")).isNotNull(); assertThat(preference.getReasonTypeNotifier().get("comment").getNotifiers()) .containsExactly("test-notifier"); }) .verifyComplete(); verify(client).fetch(ConfigMap.class, "user-preferences-guqing"); } @Test void getByUserWhenNotFound() { when(client.fetch(ConfigMap.class, "user-preferences-guqing")) .thenReturn(Mono.empty()); userNotificationPreferenceService.getByUser("guqing") .as(StepVerifier::create) .consumeNextWith(preference -> assertThat(preference.getReasonTypeNotifier()).isNotNull() ) .verifyComplete(); verify(client).fetch(ConfigMap.class, "user-preferences-guqing"); } @Test void getByUserWhenConfigDataNotFound() { when(client.fetch(ConfigMap.class, "user-preferences-guqing")) .thenReturn(Mono.just(new ConfigMap())); userNotificationPreferenceService.getByUser("guqing") .as(StepVerifier::create) .consumeNextWith(preference -> assertThat(preference.getReasonTypeNotifier()).isNotNull() ) .verifyComplete(); verify(client).fetch(ConfigMap.class, "user-preferences-guqing"); } @Nested class UserNotificationPreferenceTest { @Test void getNotifiers() { var preference = new UserNotificationPreference(); preference.getReasonTypeNotifier().put("comment", null); // key doesn't exist assertThat(preference.getReasonTypeNotifier().getNotifiers("comment")) .containsExactly("default-email-notifier"); // key exists but the value is null preference.getReasonTypeNotifier() .put("comment", new UserNotificationPreference.NotifierSetting()); assertThat(preference.getReasonTypeNotifier().getNotifiers("comment")).isEmpty(); // key exists and the value is not null preference.getReasonTypeNotifier().get("comment").setNotifiers(Set.of("test-notifier")); assertThat(preference.getReasonTypeNotifier().getNotifiers("comment")) .containsExactly("test-notifier"); } @Test void toJson() throws JSONException { var preference = new UserNotificationPreference(); preference.getReasonTypeNotifier().put("comment", new UserNotificationPreference.NotifierSetting()); preference.getReasonTypeNotifier().get("comment").setNotifiers(Set.of("test-notifier")); JSONAssert.assertEquals(""" { "reasonTypeNotifier": { "comment": { "notifiers": [ "test-notifier" ] } } } """, JsonUtils.objectToJson(preference), true); } } @Test void buildUserPreferenceConfigMapName() { var preferenceConfigMapName = UserNotificationPreferenceServiceImpl .buildUserPreferenceConfigMapName("guqing"); assertEquals("user-preferences-guqing", preferenceConfigMapName); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/UserNotificationPreferenceTest.java ================================================ package run.halo.app.notification; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link UserNotificationPreference}. * * @author guqing * @since 2.9.0 */ class UserNotificationPreferenceTest { @Test void preferenceCreation() { String s = """ { "reasonTypeNotifier": { "comment": { "notifiers": [ "email-notifier", "sms-notifier" ] }, "new-post": { "notifiers": [ "email-notifier", "webhook-router-notifier" ] } } } """; var preference = JsonUtils.jsonToObject(s, UserNotificationPreference.class); assertThat(preference.getReasonTypeNotifier()).isNotNull(); assertThat(preference.getReasonTypeNotifier().get("comment").getNotifiers()) .containsExactlyInAnyOrder("email-notifier", "sms-notifier"); assertThat(preference.getReasonTypeNotifier().get("new-post").getNotifiers()) .containsExactlyInAnyOrder("email-notifier", "webhook-router-notifier"); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/endpoint/SubscriptionRouterTest.java ================================================ package run.halo.app.notification.endpoint; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; import java.net.MalformedURLException; import java.net.URI; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.Metadata; import run.halo.app.infra.ExternalUrlSupplier; /** * Tests for {@link SubscriptionRouter}. * * @author guqing * @since 2.9.0 */ @ExtendWith(MockitoExtension.class) class SubscriptionRouterTest { @Mock private ExternalUrlSupplier externalUrlSupplier; @InjectMocks SubscriptionRouter subscriptionRouter; @Test void getUnsubscribeUrlTest() throws MalformedURLException { when(externalUrlSupplier.getRaw()).thenReturn(URI.create("https://halo.run").toURL()); var subscription = new Subscription(); subscription.setMetadata(new Metadata()); subscription.getMetadata().setName("fake-subscription"); subscription.setSpec(new Subscription.Spec()); subscription.getSpec().setUnsubscribeToken("fake-unsubscribe-token"); var url = subscriptionRouter.getUnsubscribeUrl(subscription); assertThat(url).isEqualTo("https://halo.run/apis/api.notification.halo.run/v1alpha1" + "/subscriptions/fake-subscription/unsubscribe" + "?token=fake-unsubscribe-token"); } } ================================================ FILE: application/src/test/java/run/halo/app/notification/endpoint/UserNotificationPreferencesEndpointTest.java ================================================ package run.halo.app.notification.endpoint; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.notification.NotifierDescriptor; import run.halo.app.core.extension.notification.ReasonType; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.notification.UserNotificationPreferenceService; /** * Tests for {@link UserNotificationPreferencesEndpoint}. * * @author guqing * @since 2.10.0 */ @ExtendWith(MockitoExtension.class) class UserNotificationPreferencesEndpointTest { @Mock private ReactiveExtensionClient client; @Mock private UserNotificationPreferenceService userNotificationPreferenceService; @InjectMocks private UserNotificationPreferencesEndpoint userNotificationPreferencesEndpoint; private WebTestClient webTestClient; @BeforeEach void setUp() { webTestClient = WebTestClient .bindToRouterFunction(userNotificationPreferencesEndpoint.endpoint()) .build(); when(client.listAll(eq(ReasonType.class), assertArg(option -> assertThat(option.toString()) .isEqualTo("NOT EXISTS metadata.labels['halo.run/hidden']")), eq(ExtensionUtil.defaultSort())) ).thenReturn(Flux.empty()); } @Test void listNotificationPreferences() { when(client.list(eq(NotifierDescriptor.class), eq(null), any())).thenReturn(Flux.empty()); when(userNotificationPreferenceService.getByUser(any())).thenReturn(Mono.empty()); webTestClient.post() .uri("/userspaces/{username}/notification-preferences", "guqing") .exchange() .expectStatus() .isOk(); } @Test void saveNotificationPreferences() { when(client.list(eq(NotifierDescriptor.class), eq(null), any())).thenReturn(Flux.empty()); when(userNotificationPreferenceService.getByUser(any())).thenReturn(Mono.empty()); webTestClient.post() .uri("/userspaces/{username}/notification-preferences", "guqing") .exchange() .expectStatus() .isOk(); } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/BuiltInPluginsInitializerTest.java ================================================ package run.halo.app.plugin; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static run.halo.app.core.extension.Plugin.BUILT_IN_KEEPER_FINALIZER; import static run.halo.app.core.extension.Plugin.SYSTEM_RESERVED_LABEL_KEY; import java.io.IOException; import java.net.URI; import java.nio.file.Path; import java.time.Instant; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.io.Resource; import org.springframework.core.io.support.ResourcePatternResolver; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Plugin; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.infra.ExtensionInitializedEvent; import run.halo.app.infra.exception.PluginAlreadyExistsException; @ExtendWith(MockitoExtension.class) class BuiltInPluginsInitializerTest { @Mock ExtensionClient client; @Mock PluginService pluginService; @Mock ResourcePatternResolver resourcePatternResolver; @Mock PluginFinder pluginFinder; @InjectMocks BuiltInPluginsInitializer initializer; @BeforeEach void setUp() { initializer.setResourcePatternResolver(resourcePatternResolver); initializer.setPluginFinder(pluginFinder); } @Test void shouldCreateBuiltInPlugins() throws IOException { var resource = mock(Resource.class); when(resource.getFilename()).thenReturn("fake-plugin.jar"); var pluginUri = URI.create("file:///fake-plugin.jar"); var pluginPath = Path.of(pluginUri); when(resource.getURI()).thenReturn(pluginUri); when(resourcePatternResolver.getResources(isA(String.class))) .thenReturn(new Resource[] {resource}); var fakePlugin = createPlugin(); when(pluginFinder.find(pluginPath)).thenReturn(fakePlugin); when(pluginService.install(pluginPath)).thenReturn(Mono.just(fakePlugin)); doNothing().when(client).update(fakePlugin); var event = mock(ExtensionInitializedEvent.class); initializer.onApplicationEvent(event); assertEquals("true", fakePlugin.getMetadata().getLabels().get(SYSTEM_RESERVED_LABEL_KEY)); assertTrue(fakePlugin.getMetadata().getFinalizers().contains(BUILT_IN_KEEPER_FINALIZER)); } @Test void shouldUpgradeBuiltInPlugins() throws IOException { var resource = mock(Resource.class); when(resource.getFilename()).thenReturn("fake-plugin.jar"); var pluginUri = URI.create("file:///fake-plugin.jar"); var pluginPath = Path.of(pluginUri); when(resource.getURI()).thenReturn(pluginUri); when(resourcePatternResolver.getResources(isA(String.class))) .thenReturn(new Resource[] {resource}); var fakePlugin = createPlugin(); when(pluginFinder.find(pluginPath)).thenReturn(fakePlugin); when(pluginService.install(pluginPath)) .thenReturn(Mono.error(new PluginAlreadyExistsException("fake-plugin"))); when(pluginService.upgrade("fake-plugin", pluginPath)) .thenReturn(Mono.just(fakePlugin)); doNothing().when(client).update(fakePlugin); var event = mock(ExtensionInitializedEvent.class); initializer.onApplicationEvent(event); assertEquals("true", fakePlugin.getMetadata().getLabels().get(SYSTEM_RESERVED_LABEL_KEY)); assertTrue(fakePlugin.getMetadata().getFinalizers().contains(BUILT_IN_KEEPER_FINALIZER)); } @Test void shouldResetDeletionTimestamp() throws IOException { var resource = mock(Resource.class); when(resource.getFilename()).thenReturn("fake-plugin.jar"); var pluginUri = URI.create("file:///fake-plugin.jar"); var pluginPath = Path.of(pluginUri); when(resource.getURI()).thenReturn(pluginUri); when(resourcePatternResolver.getResources(isA(String.class))) .thenReturn(new Resource[] {resource}); var fakePlugin = createPlugin(); fakePlugin.getMetadata().setDeletionTimestamp(Instant.now()); when(pluginFinder.find(pluginPath)).thenReturn(fakePlugin); when(pluginService.install(pluginPath)).thenReturn(Mono.just(fakePlugin)); doNothing().when(client).update(fakePlugin); var event = mock(ExtensionInitializedEvent.class); initializer.onApplicationEvent(event); assertNull(fakePlugin.getMetadata().getDeletionTimestamp()); } Plugin createPlugin() { var plugin = new Plugin(); plugin.setMetadata(new Metadata()); plugin.getMetadata().setName("fake-plugin"); return plugin; } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/DefaultDevelopmentPluginRepositoryTest.java ================================================ package run.halo.app.plugin; import static org.assertj.core.api.Assertions.assertThat; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.pf4j.PluginRepository; /** * Tests for {@link DefaultDevelopmentPluginRepository}. * * @author guqing * @since 2.8.0 */ class DefaultDevelopmentPluginRepositoryTest { private PluginRepository developmentPluginRepository; @TempDir private Path tempDir; @BeforeEach void setUp() { var repository = new DefaultDevelopmentPluginRepository(); repository.setFixedPaths(List.of(tempDir)); this.developmentPluginRepository = repository; } @Test void deletePluginPath() { boolean deleted = developmentPluginRepository.deletePluginPath(null); assertThat(deleted).isFalse(); // deletePluginPath is a no-op deleted = developmentPluginRepository.deletePluginPath(tempDir); assertThat(deleted).isTrue(); assertThat(Files.exists(tempDir)).isTrue(); } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/DefaultPluginApplicationContextFactoryTest.java ================================================ package run.halo.app.plugin; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.mockito.Mockito.withSettings; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.pf4j.Plugin; import org.pf4j.PluginWrapper; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import run.halo.app.search.SearchService; @SpringBootTest class DefaultPluginApplicationContextFactoryTest { @MockitoSpyBean SpringPluginManager pluginManager; DefaultPluginApplicationContextFactory factory; @BeforeEach void setUp() { factory = new DefaultPluginApplicationContextFactory(pluginManager); } @Test void shouldCreateCorrectly() { var pw = mock(PluginWrapper.class); when(pw.getPluginClassLoader()).thenReturn(this.getClass().getClassLoader()); var plugin = mock(Plugin.class, withSettings().extraInterfaces(SpringPlugin.class)); var sp = (SpringPlugin) plugin; var pluginContext = new PluginContext.PluginContextBuilder() .name("fake-plugin") .version("1.0.0") .build(); when(sp.getPluginContext()).thenReturn(pluginContext); when(pw.getPlugin()).thenReturn(plugin); when(pluginManager.getPlugin("fake-plugin")).thenReturn(pw); var context = factory.create("fake-plugin"); assertEquals(pw.getPluginClassLoader(), Thread.currentThread().getContextClassLoader()); assertInstanceOf(PluginApplicationContext.class, context); assertNotNull(context.getBeanProvider(SearchService.class).getIfUnique()); assertNotNull(context.getBeanProvider(PluginsRootGetter.class).getIfUnique()); // TODO Add more assertions here. } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/DefaultPluginRouterFunctionRegistryTest.java ================================================ package run.halo.app.plugin; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; /** * Tests for {@link DefaultPluginRouterFunctionRegistry}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class DefaultPluginRouterFunctionRegistryTest { @InjectMocks DefaultPluginRouterFunctionRegistry routerFunctionRegistry; @Test void shouldRegisterRouterFunction() { RouterFunction routerFunction = mock(InvocationOnMock::getMock); routerFunctionRegistry.register(Set.of(routerFunction)); assertEquals(Set.of(routerFunction), routerFunctionRegistry.getRouterFunctions()); } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/DefaultSettingFetcherTest.java ================================================ package run.halo.app.plugin; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.JsonNode; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import org.json.JSONException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.context.ApplicationContext; import org.springframework.test.context.bean.override.mockito.MockitoBean; import reactor.core.publisher.Mono; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link DefaultSettingFetcher}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class DefaultSettingFetcherTest { @Mock private ReactiveExtensionClient client; @MockitoBean private final PluginContext pluginContext = PluginContext.builder() .name("fake") .configMapName("fake-config") .build(); @Mock private ApplicationContext applicationContext; private DefaultReactiveSettingFetcher reactiveSettingFetcher; private DefaultSettingFetcher settingFetcher; @BeforeEach void setUp() { this.reactiveSettingFetcher = new DefaultReactiveSettingFetcher(pluginContext, client); reactiveSettingFetcher.setApplicationContext(applicationContext); settingFetcher = new DefaultSettingFetcher(reactiveSettingFetcher); ConfigMap configMap = buildConfigMap(); when(client.fetch(eq(ConfigMap.class), eq(pluginContext.getConfigMapName()))) .thenReturn(Mono.just(configMap)); } @Test void getValues() throws JSONException { Map values = settingFetcher.getValues(); verify(client, times(1)).fetch(eq(ConfigMap.class), any()); assertThat(values).hasSize(2); JSONAssert.assertEquals(getSns(), JsonUtils.objectToJson(values.get("sns")), true); // The extensionClient will only be called once Map callAgain = settingFetcher.getValues(); assertThat(callAgain).isNotNull(); verify(client, times(1)).fetch(eq(ConfigMap.class), any()); } @Test void getValuesWithUpdateCache() throws JSONException { Map values = settingFetcher.getValues(); verify(client, times(1)).fetch(eq(ConfigMap.class), any()); JSONAssert.assertEquals(getSns(), JsonUtils.objectToJson(values.get("sns")), true); ConfigMap configMap = buildConfigMap(); configMap.getData().put("sns", """ { "email": "abc@example.com", "github": "abc" } """); when(client.fetch(eq(ConfigMap.class), eq(pluginContext.getConfigMapName()))) .thenReturn(Mono.just(configMap)); when(client.update(configMap)).thenReturn(Mono.just(configMap)); reactiveSettingFetcher.reconcile(new Reconciler.Request(pluginContext.getConfigMapName())); // Make sure the method cache#put is called before the event is published // to avoid the event listener to fetch the old value from the cache verify(applicationContext).publishEvent(isA(PluginConfigUpdatedEvent.class)); Map updatedValues = settingFetcher.getValues(); verify(client, times(3)).fetch(eq(ConfigMap.class), any()); assertThat(updatedValues).hasSize(2); JSONAssert.assertEquals(configMap.getData().get("sns"), JsonUtils.objectToJson(updatedValues.get("sns")), true); updatedValues = settingFetcher.getValues(); assertThat(updatedValues).hasSize(2); verify(client, times(3)).fetch(eq(ConfigMap.class), any()); } @Test void getGroupForObject() throws JSONException { Optional sns = settingFetcher.fetch("sns", Sns.class); assertThat(sns.isEmpty()).isFalse(); JSONAssert.assertEquals(getSns(), JsonUtils.objectToJson(sns.get()), true); } @Test void getGroup() { JsonNode jsonNode = settingFetcher.get("basic"); assertThat(jsonNode).isNotNull(); assertThat(jsonNode.isObject()).isTrue(); assertThat(jsonNode.get("color").asText()).isEqualTo("red"); assertThat(jsonNode.get("width").asInt()).isEqualTo(100); // missing key will return empty json node JsonNode emptyNode = settingFetcher.get("basic1"); assertThat(emptyNode.isEmpty()).isTrue(); } private ConfigMap buildConfigMap() { ConfigMap configMap = new ConfigMap(); Metadata metadata = new Metadata(); metadata.setName("fake"); metadata.setLabels(Map.of("plugin.halo.run/plugin-name", "fake")); configMap.setMetadata(metadata); configMap.setKind("ConfigMap"); configMap.setApiVersion("v1alpha1"); var map = new HashMap(); map.put("sns", getSns()); map.put("basic", """ { "color": "red", "width": "100" } """); configMap.setData(map); return configMap; } String getSns() { return """ { "email": "example@example.com", "github": "example", "instagram": "123", "twitter": "halo-dev", "user": { "name": "guqing", "age": "18" }, "nums": [1, 2, 3] } """; } record Sns(String email, String github, String instagram, String twitter, Map user, List nums) { } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/HaloPluginManagerTest.java ================================================ package run.halo.app.plugin; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; import com.github.zafarkhaja.semver.Version; import java.nio.file.Path; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.pf4j.RuntimeMode; import org.springframework.context.ApplicationContext; import run.halo.app.infra.SystemVersionSupplier; @ExtendWith(MockitoExtension.class) class HaloPluginManagerTest { @Mock PluginProperties pluginProperties; @Mock SystemVersionSupplier systemVersionSupplier; @Mock PluginsRootGetter pluginsRootGetter; @Mock ApplicationContext rootContext; @InjectMocks HaloPluginManager pluginManager; @TempDir Path tempDir; @Test void shouldGetDependentsWhilePluginsNotResolved() throws Exception { when(pluginProperties.getRuntimeMode()).thenReturn(RuntimeMode.DEPLOYMENT); when(systemVersionSupplier.get()).thenReturn(Version.of(1, 2, 3)); when(pluginsRootGetter.get()).thenReturn(tempDir); pluginManager.afterPropertiesSet(); // if we don't invoke resolves var dependents = pluginManager.getDependents("fake-plugin"); assertTrue(dependents.isEmpty()); } @Test void shouldGetDependentsWhilePluginsResolved() throws Exception { when(pluginProperties.getRuntimeMode()).thenReturn(RuntimeMode.DEPLOYMENT); when(systemVersionSupplier.get()).thenReturn(Version.of(1, 2, 3)); when(pluginsRootGetter.get()).thenReturn(tempDir); pluginManager.afterPropertiesSet(); pluginManager.loadPlugins(); // if we don't invoke resolves var dependents = pluginManager.getDependents("fake-plugin"); assertTrue(dependents.isEmpty()); } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/OptionalDependentResolverTest.java ================================================ package run.halo.app.plugin; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import java.util.List; import org.junit.jupiter.api.Test; import org.pf4j.PluginDependency; import org.pf4j.PluginDescriptor; /** * Tests for {@link OptionalDependentResolver}. * * @author guqing * @since 2.20.11 */ class OptionalDependentResolverTest { @Test void testNoPlugins() { OptionalDependentResolver resolver = new OptionalDependentResolver(List.of()); assertTrue(resolver.getOptionalDependents("nonexistent").isEmpty(), "No dependents expected for non-existent plugin"); } @Test void testSinglePluginNoDependencies() { var pluginA = createpluginDescriptor("A", List.of()); OptionalDependentResolver resolver = new OptionalDependentResolver(List.of(pluginA)); assertTrue(resolver.getOptionalDependents("A").isEmpty(), "A has no dependents"); } @Test void testSingleOptionalDependency() { var pluginA = createpluginDescriptor("A", List.of(new PluginDependency("B?"))); var pluginB = createpluginDescriptor("B", List.of()); OptionalDependentResolver resolver = new OptionalDependentResolver(List.of(pluginA, pluginB)); assertEquals(List.of("A"), resolver.getOptionalDependents("B"), "B should have A as dependent"); assertTrue(resolver.getOptionalDependents("A").isEmpty(), "A has no dependents"); } @Test void testMultipleOptionalDependencies() { var pluginA = createpluginDescriptor("A", List.of( new PluginDependency("B?"), new PluginDependency("C?")) ); var pluginB = createpluginDescriptor("B", List.of()); var pluginC = createpluginDescriptor("C", List.of()); OptionalDependentResolver resolver = new OptionalDependentResolver(List.of(pluginA, pluginB, pluginC)); assertEquals(List.of("A"), resolver.getOptionalDependents("B"), "B should have A as dependent"); assertEquals(List.of("A"), resolver.getOptionalDependents("C"), "C should have A as dependent"); assertTrue(resolver.getOptionalDependents("A").isEmpty(), "A has no dependents"); } @Test void testNestedDependencies() { var pluginA = createpluginDescriptor("A", List.of( new PluginDependency("B?") )); var pluginB = createpluginDescriptor("B", List.of( new PluginDependency("C?") )); var pluginC = createpluginDescriptor("C", List.of()); OptionalDependentResolver resolver = new OptionalDependentResolver(List.of(pluginA, pluginB, pluginC)); assertEquals(List.of("B"), resolver.getOptionalDependents("C"), "C should have B as dependent"); assertEquals(List.of("A"), resolver.getOptionalDependents("B"), "B should have A as dependent"); assertTrue(resolver.getOptionalDependents("A").isEmpty(), "A has no dependents"); } @Test void testCircularDependencies() { var pluginA = createpluginDescriptor("A", List.of( new PluginDependency("B?") )); var pluginB = createpluginDescriptor("B", List.of( new PluginDependency("A?") )); OptionalDependentResolver resolver = new OptionalDependentResolver(List.of(pluginA, pluginB)); assertEquals(List.of("B"), resolver.getOptionalDependents("A"), "A should have B as dependent"); assertEquals(List.of("A"), resolver.getOptionalDependents("B"), "B should have A as dependent"); } @Test void testNonOptionalDependencies() { var pluginA = createpluginDescriptor("A", List.of( new PluginDependency("B") )); var pluginB = createpluginDescriptor("B", List.of()); OptionalDependentResolver resolver = new OptionalDependentResolver(List.of(pluginA, pluginB)); assertTrue(resolver.getOptionalDependents("B").isEmpty(), "B should have no dependents"); assertTrue(resolver.getOptionalDependents("A").isEmpty(), "A should have no dependents"); } PluginDescriptor createpluginDescriptor(String pluginName, List dependencies) { var descriptor = mock(PluginDescriptor.class); lenient().when(descriptor.getPluginId()).thenReturn(pluginName); lenient().when(descriptor.getDependencies()).thenReturn(dependencies); return descriptor; } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/PluginExtensionLoaderUtilsTest.java ================================================ package run.halo.app.plugin; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static run.halo.app.plugin.PluginExtensionLoaderUtils.isSetting; import static run.halo.app.plugin.PluginExtensionLoaderUtils.lookupExtensions; import java.io.IOException; import java.net.URL; import java.net.URLClassLoader; import java.util.Arrays; import java.util.Objects; import org.junit.jupiter.api.Test; import org.springframework.core.io.DefaultResourceLoader; import run.halo.app.infra.utils.YamlUnstructuredLoader; class PluginExtensionLoaderUtilsTest { @Test void lookupExtensionsAndIsSettingTest() throws IOException { var resourceLoader = new DefaultResourceLoader(); var rootResource = resourceLoader.getResource("classpath:plugin/plugin-0.0.1/"); var classLoader = new URLClassLoader(new URL[] {rootResource.getURL()}, null); var resources = lookupExtensions(classLoader); assertTrue(resources.length >= 1); var settingResource = Arrays.stream(resources) .filter(r -> Objects.equals("setting.yaml", r.getFilename())) .findFirst() .orElseThrow(); var loader = new YamlUnstructuredLoader(settingResource); var unstructuredList = loader.load(); assertEquals(1, unstructuredList.size()); assertTrue(isSetting("fake-setting").test(unstructuredList.get(0))); assertFalse(isSetting("non-fake-setting").test(unstructuredList.get(0))); assertFalse(isSetting("").test(unstructuredList.get(0))); assertFalse(isSetting(null).test(unstructuredList.get(0))); } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/PluginRequestMappingHandlerMappingTest.java ================================================ package run.halo.app.plugin; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.get; import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.post; import static org.springframework.web.bind.annotation.RequestMethod.GET; import static org.springframework.web.bind.annotation.RequestMethod.HEAD; import java.lang.reflect.Method; import java.security.Principal; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Set; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.stream.Collectors; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.core.MethodIntrospector; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.http.HttpMethod; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.stereotype.Controller; import org.springframework.util.Assert; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.support.StaticWebApplicationContext; import org.springframework.web.method.HandlerMethod; import org.springframework.web.reactive.result.method.RequestMappingInfo; import org.springframework.web.server.MethodNotAllowedException; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.util.pattern.PathPatternParser; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; /** * Tests for {@link PluginRequestMappingHandlerMapping}. * * @author guqing * @since 2.0.0 */ class PluginRequestMappingHandlerMappingTest { private final StaticWebApplicationContext wac = new StaticWebApplicationContext(); private PluginRequestMappingHandlerMapping handlerMapping; @BeforeEach public void setup() { handlerMapping = new PluginRequestMappingHandlerMapping(); this.handlerMapping.setApplicationContext(wac); } @Test public void shouldAddPathPrefixWhenExistingApiVersion() throws Exception { Method method = UserController.class.getMethod("getUser"); RequestMappingInfo info = this.handlerMapping.getPluginMappingForMethod("fakePlugin", method, UserController.class); assertThat(info).isNotNull(); assertThat(info.getPatternsCondition().getPatterns()).isEqualTo( Collections.singleton( new PathPatternParser().parse( "/apis/api.plugin.halo.run/v1alpha1/plugins/fakePlugin/user/{id}"))); } @Test public void shouldKeepRawWhenMissingApiVersion() throws Exception { Method method = AppleMissingApiVersionController.class.getMethod("getName"); RequestMappingInfo info = this.handlerMapping.getPluginMappingForMethod("fakePlugin", method, AppleMissingApiVersionController.class); assertThat(info.getPatternsCondition().getPatterns()) .isEqualTo(Collections.singleton(new PathPatternParser().parse("/apples"))); } @Test void registerHandlerMethods() { assertThat(handlerMapping.getMappings("fakePlugin")).isEmpty(); UserController userController = mock(UserController.class); handlerMapping.registerHandlerMethods("fakePlugin", userController); List mappings = handlerMapping.getMappings("fakePlugin"); assertThat(mappings).hasSize(1); assertThat(mappings.get(0).toString()).isEqualTo( "{GET /apis/api.plugin.halo.run/v1alpha1/plugins/fakePlugin/user/{id}}"); } @Test void unregister() { UserController userController = mock(UserController.class); // register handler methods first handlerMapping.registerHandlerMethods("fakePlugin", userController); assertThat(handlerMapping.getMappings("fakePlugin")).hasSize(1); // unregister handlerMapping.unregister("fakePlugin"); assertThat(handlerMapping.getMappings("fakePlugin")).isEmpty(); } @Test public void getHandlerDirectMatch() { // register handler methods first handlerMapping.registerHandlerMethods("fakePlugin", new TestController()); // resolve an expected method from TestController Method expected = ResolvableMethod.on(TestController.class).annot(getMapping("/foo")).build(); // get handler by mock exchange ServerWebExchange exchange = MockServerWebExchange.from( get("/apis/api.plugin.halo.run/v1alpha1/plugins/fakePlugin/foo")); HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); assertThat(hm).isNotNull(); assertThat(hm.getMethod()).isEqualTo(expected); } @Test public void getHandlerBestMatch() { // register handler methods first handlerMapping.registerHandlerMethods("fakePlugin", new TestController()); Method expected = ResolvableMethod.on(TestController.class).annot(getMapping("/foo").params("p")).build(); String requestPath = "/apis/api.plugin.halo.run/v1alpha1/plugins/fakePlugin/foo?p=anything"; ServerWebExchange exchange = MockServerWebExchange.from(get(requestPath)); HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); assertThat(hm).isNotNull(); assertThat(hm.getMethod()).isEqualTo(expected); } @Test public void getHandlerRootPathMatch() { // register handler methods first handlerMapping.registerHandlerMethods("fakePlugin", new TestController()); Method expected = ResolvableMethod.on(TestController.class).annot(getMapping("")).build(); String requestPath = "/apis/api.plugin.halo.run/v1alpha1/plugins/fakePlugin"; ServerWebExchange exchange = MockServerWebExchange.from(get(requestPath)); HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); assertThat(hm).isNotNull(); assertThat(hm.getMethod()).isEqualTo(expected); } @Test public void getHandlerRequestMethodNotAllowed() { // register handler methods first handlerMapping.registerHandlerMethods("fakePlugin", new TestController()); String requestPath = "/apis/api.plugin.halo.run/v1alpha1/plugins/fakePlugin/bar"; ServerWebExchange exchange = MockServerWebExchange.from(post(requestPath)); Mono mono = this.handlerMapping.getHandler(exchange); assertError(mono, MethodNotAllowedException.class, ex -> assertThat(ex.getSupportedMethods()).isEqualTo( Set.of(HttpMethod.GET, HttpMethod.HEAD))); } @Test void buildPrefix() { String s = handlerMapping.buildPrefix("fakePlugin", "v1"); assertThat(s).isEqualTo("/apis/api.plugin.halo.run/v1/plugins/fakePlugin"); s = handlerMapping.buildPrefix("fakePlugin", "fake.halo.run/v1alpha1"); assertThat(s).isEqualTo("/apis/fake.halo.run/v1alpha1"); } @SuppressWarnings("unchecked") private void assertError(Mono mono, final Class exceptionClass, final Consumer consumer) { StepVerifier.create(mono) .consumeErrorWith(error -> { assertThat(error.getClass()).isEqualTo(exceptionClass); consumer.accept((T) error); }) .verify(); } private RequestMappingPredicate getMapping(String... path) { return new RequestMappingPredicate(path).method(GET).params(); } public static class ResolvableMethod { private final Class objectClass; private final List> filters = new ArrayList<>(4); public ResolvableMethod(Class objectClass) { this.objectClass = objectClass; } public static ResolvableMethod on(Class objectClass) { return new ResolvableMethod(objectClass); } public ResolvableMethod annot(Predicate predicate) { filters.add(predicate); return this; } public Method build() { Set methods = MethodIntrospector.selectMethods(this.objectClass, this::isMatch); Assert.state(!methods.isEmpty(), () -> "No matching method: " + this); Assert.state(methods.size() == 1, () -> "Multiple matching methods: " + this + formatMethods(methods)); return methods.iterator().next(); } private String formatMethods(Set methods) { return "\nMatched:\n" + methods.stream() .map(Method::toGenericString).collect(Collectors.joining(",\n\t", "[\n\t", "\n]")); } private boolean isMatch(Method method) { return this.filters.stream().allMatch(p -> p.test(method)); } } public static class RequestMappingPredicate implements Predicate { private final String[] path; private RequestMethod[] method = {}; private String[] params; private RequestMappingPredicate(String... path) { this.path = path; } public RequestMappingPredicate method(RequestMethod... methods) { this.method = methods; return this; } public RequestMappingPredicate params(String... params) { this.params = params; return this; } @Override public boolean test(Method method) { RequestMapping annot = AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class); return annot != null && Arrays.equals(this.path, annot.path()) && Arrays.equals(this.method, annot.method()) && (this.params == null || Arrays.equals(this.params, annot.params())); } } @ApiVersion("v1alpha1") @RestController @RequestMapping("/user") static class UserController { @GetMapping("/{id}") public Principal getUser() { return mock(Principal.class); } } @RestController @RequestMapping("/apples") static class AppleMissingApiVersionController { @GetMapping public String getName() { return mock(String.class); } } @ApiVersion("v1alpha1") @Controller @RequestMapping static class TestController { @GetMapping("/foo") public void foo() { } @GetMapping(path = "/foo", params = "p") public void fooParam() { } @RequestMapping(path = "/ba*", method = {GET, HEAD}) public void bar() { } @GetMapping("") public void empty() { } } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/PluginServiceImplTest.java ================================================ package run.halo.app.plugin; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Objects.requireNonNull; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.core.io.buffer.DefaultDataBufferFactory.sharedInstance; import com.github.zafarkhaja.semver.Version; import com.google.common.hash.Hashing; import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Clock; import java.time.Instant; import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.function.Consumer; import java.util.stream.IntStream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.pf4j.PluginDescriptor; import org.pf4j.PluginWrapper; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.util.FileSystemUtils; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import reactor.test.publisher.PublisherProbe; import run.halo.app.core.extension.Plugin; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.infra.exception.PluginAlreadyExistsException; import run.halo.app.infra.utils.FileUtils; @ExtendWith(MockitoExtension.class) class PluginServiceImplTest { @Mock SystemVersionSupplier systemVersionSupplier; @Mock ReactiveExtensionClient client; @Mock PluginsRootGetter pluginsRootGetter; @Mock SpringPluginManager pluginManager; @Spy @InjectMocks PluginServiceImpl pluginService; @Nested class InstallUpdateReloadTest { Path fakePluginPath; @TempDir Path tempDirectory; @BeforeEach void setUp() throws URISyntaxException, IOException { fakePluginPath = tempDirectory.resolve("plugin-0.0.2.jar"); var fakePluingUri = requireNonNull( getClass().getClassLoader().getResource("plugin/plugin-0.0.2")).toURI(); FileUtils.jar(Paths.get(fakePluingUri), tempDirectory.resolve("plugin-0.0.2.jar")); lenient().when(systemVersionSupplier.get()).thenReturn(Version.parse("0.0.0")); } @Test void installWhenPluginExists() { var existingPlugin = new YamlPluginFinder().find(fakePluginPath); when(client.fetch(Plugin.class, "fake-plugin")).thenReturn(Mono.just(existingPlugin)); var plugin = pluginService.install(fakePluginPath); StepVerifier.create(plugin) .expectError(PluginAlreadyExistsException.class) .verify(); verify(client).fetch(Plugin.class, "fake-plugin"); verify(systemVersionSupplier).get(); } @Test void installWhenPluginNotExist() { when(pluginsRootGetter.get()).thenReturn(tempDirectory.resolve("plugins")); when(client.fetch(Plugin.class, "fake-plugin")).thenReturn(Mono.empty()); var createdPlugin = mock(Plugin.class); when(client.create(isA(Plugin.class))).thenReturn(Mono.just(createdPlugin)); var plugin = pluginService.install(fakePluginPath); StepVerifier.create(plugin) .expectNext(createdPlugin) .verifyComplete(); verify(client).fetch(Plugin.class, "fake-plugin"); verify(systemVersionSupplier).get(); verify(client).create(isA(Plugin.class)); } @Test void upgradeWhenPluginNameMismatch() { var plugin = pluginService.upgrade("non-fake-plugin", fakePluginPath); StepVerifier.create(plugin) .expectError(ServerWebInputException.class) .verify(); verify(client, never()).fetch(Plugin.class, "fake-plugin"); } @Test void upgradeWhenPluginNotFound() { when(client.fetch(Plugin.class, "fake-plugin")).thenReturn(Mono.empty()); var plugin = pluginService.upgrade("fake-plugin", fakePluginPath); StepVerifier.create(plugin) .expectError(ServerWebInputException.class) .verify(); verify(client).fetch(Plugin.class, "fake-plugin"); } @Test void upgradeNormally() { when(pluginsRootGetter.get()).thenReturn(tempDirectory.resolve("plugins")); var oldFakePlugin = createPlugin("fake-plugin", plugin -> { plugin.getSpec().setEnabled(true); plugin.getSpec().setVersion("0.0.1"); }); when(client.fetch(Plugin.class, "fake-plugin")) .thenReturn(Mono.just(oldFakePlugin)) .thenReturn(Mono.just(oldFakePlugin)) .thenReturn(Mono.empty()); when(client.update(oldFakePlugin)).thenReturn(Mono.just(oldFakePlugin)); var plugin = pluginService.upgrade("fake-plugin", fakePluginPath); StepVerifier.create(plugin) .expectNext(oldFakePlugin) .verifyComplete(); verify(client).fetch(Plugin.class, "fake-plugin"); verify(client).update(oldFakePlugin); assertTrue(oldFakePlugin.getSpec().getEnabled()); assertEquals("0.0.2", oldFakePlugin.getSpec().getVersion()); assertEquals( tempDirectory.resolve("plugins").resolve("fake-plugin-0.0.2.jar").toString(), oldFakePlugin.getMetadata().getAnnotations().get(PluginConst.PLUGIN_PATH)); } @Test void shouldNotReloadIfLoadLocationIsNotReady() { var pluginName = "test-plugin"; var testPlugin = createPlugin(pluginName, plugin -> { }); when(client.get(Plugin.class, pluginName)).thenReturn(Mono.just(testPlugin)); pluginService.reload(pluginName) .as(StepVerifier::create) .consumeErrorWith(t -> { assertInstanceOf(IllegalStateException.class, t); assertEquals("Load location of plugin has not been populated.", t.getMessage()); }) .verify(); verify(client).get(Plugin.class, pluginName); } @Test void shouldReloadIfLoadLocationReady() { var pluginName = "test-plugin"; var testPlugin = createPlugin(pluginName, plugin -> { plugin.getStatus().setLoadLocation(fakePluginPath.toUri()); }); when(client.get(Plugin.class, pluginName)).thenReturn(Mono.just(testPlugin)); when(client.update(testPlugin)).thenReturn(Mono.just(testPlugin)); pluginService.reload(pluginName) .as(StepVerifier::create) .expectNext(testPlugin) .verifyComplete(); assertEquals(fakePluginPath.toString(), testPlugin.getMetadata().getAnnotations().get(PluginConst.PLUGIN_PATH)); verify(client).get(Plugin.class, pluginName); verify(client).update(testPlugin); } } @Test void generateBundleVersionTest() { var plugin1 = mock(PluginWrapper.class); var plugin2 = mock(PluginWrapper.class); var plugin3 = mock(PluginWrapper.class); when(pluginManager.startedPlugins()).thenReturn(List.of(plugin1, plugin2, plugin3)); var descriptor1 = mock(PluginDescriptor.class); var descriptor2 = mock(PluginDescriptor.class); var descriptor3 = mock(PluginDescriptor.class); when(plugin1.getDescriptor()).thenReturn(descriptor1); when(plugin2.getDescriptor()).thenReturn(descriptor2); when(plugin3.getDescriptor()).thenReturn(descriptor3); when(plugin1.getPluginId()).thenReturn("fake-1"); when(plugin2.getPluginId()).thenReturn("fake-2"); when(plugin3.getPluginId()).thenReturn("fake-3"); when(descriptor1.getVersion()).thenReturn("1.0.0"); when(descriptor2.getVersion()).thenReturn("2.0.0"); when(descriptor3.getVersion()).thenReturn("3.0.0"); var str = "fake-1:1.0.0fake-2:2.0.0fake-3:3.0.0"; var result = Hashing.sha256().hashUnencodedChars(str).toString(); assertThat(result.length()).isEqualTo(64); pluginService.generateBundleVersion() .as(StepVerifier::create) .consumeNextWith(version -> assertThat(version).isEqualTo(result)) .verifyComplete(); var plugin4 = mock(PluginWrapper.class); var descriptor4 = mock(PluginDescriptor.class); when(plugin4.getDescriptor()).thenReturn(descriptor4); when(plugin4.getPluginId()).thenReturn("fake-4"); when(descriptor4.getVersion()).thenReturn("3.0.0"); var str2 = "fake-1:1.0.0fake-2:2.0.0fake-4:3.0.0"; var result2 = Hashing.sha256().hashUnencodedChars(str2).toString(); when(pluginManager.startedPlugins()).thenReturn(List.of(plugin1, plugin2, plugin4)); pluginService.generateBundleVersion() .as(StepVerifier::create) .consumeNextWith(version -> assertThat(version).isEqualTo(result2)) .verifyComplete(); assertThat(result).isNotEqualTo(result2); } @Test void shouldGenerateRandomBundleVersionInDevelopment() { var clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()); pluginService.setClock(clock); when(pluginManager.isDevelopment()).thenReturn(true); pluginService.generateBundleVersion() .as(StepVerifier::create) .expectNext(String.valueOf(clock.instant().toEpochMilli())) .verifyComplete(); verify(pluginManager, never()).startedPlugins(); } @Test void shouldGetStartedPluginNames() { var plugin1 = mock(PluginWrapper.class); when(plugin1.getPluginId()).thenReturn("plugin-1"); var plugin2 = mock(PluginWrapper.class); when(plugin2.getPluginId()).thenReturn("plugin-2"); when(pluginManager.startedPlugins()).thenReturn(List.of(plugin1, plugin2)); pluginService.getStartedPluginNames() .as(StepVerifier::create) .expectNext("plugin-1", "plugin-2") .verifyComplete(); } @Nested class PluginStateChangeTest { @Test void shouldEnablePluginIfPluginWasNotStarted() { var plugin = createPlugin("fake-plugin", p -> { p.getSpec().setEnabled(false); p.statusNonNull().setPhase(Plugin.Phase.RESOLVED); }); when(client.get(Plugin.class, "fake-plugin")).thenReturn(Mono.just(plugin)) .thenReturn(Mono.fromSupplier(() -> { plugin.statusNonNull().setPhase(Plugin.Phase.STARTED); return plugin; })); when(client.update(plugin)).thenReturn(Mono.just(plugin)); pluginService.changeState("fake-plugin", true, false) .as(StepVerifier::create) .expectNext(plugin) .verifyComplete(); assertTrue(plugin.getSpec().getEnabled()); } @Test void shouldDisablePluginIfAlreadyStarted() { var plugin = createPlugin("fake-plugin", p -> { p.getSpec().setEnabled(true); p.statusNonNull().setPhase(Plugin.Phase.STARTED); }); when(client.get(Plugin.class, "fake-plugin")).thenReturn(Mono.just(plugin)) .thenReturn(Mono.fromSupplier(() -> { plugin.getStatus().setPhase(Plugin.Phase.STOPPED); return plugin; })); when(client.update(plugin)).thenReturn(Mono.just(plugin)); pluginService.changeState("fake-plugin", false, false) .as(StepVerifier::create) .expectNext(plugin) .verifyComplete(); assertFalse(plugin.getSpec().getEnabled()); } } @Nested class BundleCacheTest { PluginServiceImpl.BundleCache cache; @TempDir Path tempDir; @BeforeEach void setUp() { pluginService.setTempDir(tempDir); cache = pluginService.new BundleCache(".js"); } @Test void shouldComputeBundleFileIfAbsent() { doReturn(Mono.just("different-version")).when(pluginService).generateBundleVersion(); var fakeContent = Mono.just(sharedInstance.wrap("fake-content".getBytes( UTF_8))); cache.computeIfAbsent("fake-version", fakeContent) .as(StepVerifier::create) .assertNext(resource -> { try { assertEquals(tempDir.resolve("different-version.js"), resource.getFile().toPath()); assertEquals("different-version.js", resource.getFilename()); assertEquals("fake-content", resource.getContentAsString(UTF_8)); } catch (IOException e) { throw new RuntimeException(e); } }) .verifyComplete(); try { FileSystemUtils.deleteRecursively(tempDir); } catch (IOException e) { throw new RuntimeException(e); } cache.computeIfAbsent("fake-version", fakeContent) .as(StepVerifier::create) .assertNext(resource -> { try { assertThat(Files.exists(tempDir)).isTrue(); assertEquals(tempDir.resolve("different-version.js"), resource.getFile().toPath()); } catch (IOException e) { throw new RuntimeException(e); } }) .verifyComplete(); } @Test void shouldNotComputeBundleFileIfPresentAndVersionIsMatch() { shouldComputeBundleFileIfAbsent(); var fakeContent = Mono.just( sharedInstance.wrap("another-fake-content".getBytes(UTF_8))); cache.computeIfAbsent("different-version", fakeContent) .as(StepVerifier::create) .assertNext(resource -> { try { assertEquals("different-version.js", resource.getFilename()); // The content won't be changed if the version is matched. assertEquals("fake-content", resource.getContentAsString(UTF_8)); } catch (IOException e) { throw new RuntimeException(e); } }) .verifyComplete(); } @Test void shouldComputeBundleFileIfPresentButVersionMismatch() { shouldComputeBundleFileIfAbsent(); var fakeContent = Mono.just( sharedInstance.wrap("another-fake-content".getBytes(UTF_8))); doReturn(Mono.just("updated-version")).when(pluginService).generateBundleVersion(); cache.computeIfAbsent("mismatch-version", fakeContent) .as(StepVerifier::create) .assertNext(resource -> { try { assertTrue(Files.notExists(tempDir.resolve("different-version.js"))); assertEquals("updated-version.js", resource.getFilename()); assertEquals("another-fake-content", resource.getContentAsString(UTF_8)); } catch (IOException e) { throw new RuntimeException(e); } }) .verifyComplete(); } @RepeatedTest(10) void concurrentComputeBundleFileIfAbsent() { lenient().doReturn(Mono.just("different-version")) .when(pluginService) .generateBundleVersion(); var executorService = Executors.newCachedThreadPool(); var probes = new ArrayList>(); List> futures = IntStream.range(0, 10) .mapToObj(i -> { var fakeContent = Mono.just(sharedInstance.wrap( ("fake-content-" + i).getBytes(UTF_8) )); var probe = PublisherProbe.of(fakeContent); probes.add(probe); return executorService.submit( () -> { cache.computeIfAbsent("fake-version", probe.mono()) .as(StepVerifier::create) .expectNextCount(1) .verifyComplete(); }); }) .toList(); executorService.shutdown(); futures.forEach(future -> { try { future.get(); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException(e); } }); // ensure only one probe was subscribed var subscribedCount = probes.stream() .filter(PublisherProbe::wasSubscribed) .count(); assertEquals(1, subscribedCount); } } Plugin createPlugin(String name, Consumer pluginConsumer) { var plugin = new Plugin(); plugin.setMetadata(new Metadata()); plugin.getMetadata().setName(name); plugin.setSpec(new Plugin.PluginSpec()); plugin.setStatus(new Plugin.PluginStatus()); pluginConsumer.accept(plugin); return plugin; } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/PluginsRootGetterImplTest.java ================================================ package run.halo.app.plugin; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.when; import java.nio.file.Paths; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.infra.properties.HaloProperties; @ExtendWith(MockitoExtension.class) class PluginsRootGetterImplTest { @Mock HaloProperties haloProperties; @InjectMocks PluginsRootGetterImpl pluginsRootGetter; @Test void shouldGetterPluginsRootCorrectly() { var haloWorkDir = Paths.get("halo-work-dir"); when(haloProperties.getWorkDir()).thenReturn(haloWorkDir); assertEquals(haloWorkDir.resolve("plugins"), pluginsRootGetter.get()); } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/SharedApplicationContextFactoryTest.java ================================================ package run.halo.app.plugin; import static org.junit.jupiter.api.Assertions.assertNotNull; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.ApplicationContext; /** * Tests for {@link SharedApplicationContextFactory}. * * @author guqing * @since 2.0.0 */ @SpringBootTest class SharedApplicationContextFactoryTest { @Autowired ApplicationContext applicationContext; @Test void createSharedApplicationContext() { var sharedContext = SharedApplicationContextFactory.create(applicationContext); assertNotNull(sharedContext); } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/SharedEventDispatcherTest.java ================================================ package run.halo.app.plugin; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.withSettings; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.pf4j.Plugin; import org.pf4j.PluginWrapper; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.Lifecycle; @ExtendWith(MockitoExtension.class) class SharedEventDispatcherTest { @Mock SpringPluginManager pluginManager; @Mock ApplicationEventPublisher publisher; @InjectMocks SharedEventDispatcher dispatcher; @Test void shouldNotDispatchEventIfNotSharedEvent() { dispatcher.onApplicationEvent(new FakeEvent(this)); verify(pluginManager, never()).getStartedPlugins(); } @Test void shouldDispatchEventToAllStartedPlugins() { var pw = mock(PluginWrapper.class); var plugin = mock(Plugin.class, withSettings().extraInterfaces(SpringPlugin.class)); var context = mock(ApplicationContext.class, withSettings().extraInterfaces(Lifecycle.class)); when(((Lifecycle) context).isRunning()).thenReturn(true); when(((SpringPlugin) plugin).getApplicationContext()).thenReturn(context); when(pw.getPlugin()).thenReturn(plugin); when(pluginManager.startedPlugins()).thenReturn(List.of(pw)); var event = new FakeSharedEvent(this); dispatcher.onApplicationEvent(event); verify(context).publishEvent(new HaloSharedEventDelegator(dispatcher, event)); } @Test void shouldNotDispatchEventToAllStartedPluginsWhilePluginContextIsNotRunning() { var pw = mock(PluginWrapper.class); var plugin = mock(Plugin.class, withSettings().extraInterfaces(SpringPlugin.class)); var context = mock(ApplicationContext.class, withSettings().extraInterfaces(Lifecycle.class)); when(((Lifecycle) context).isRunning()).thenReturn(false); when(((SpringPlugin) plugin).getApplicationContext()).thenReturn(context); when(pw.getPlugin()).thenReturn(plugin); when(pluginManager.startedPlugins()).thenReturn(List.of(pw)); var event = new FakeSharedEvent(this); dispatcher.onApplicationEvent(event); verify(context, never()).publishEvent(event); } @Test void shouldNotDispatchEventToAllStartedPluginsWhilePluginContextIsNotLifecycle() { var pw = mock(PluginWrapper.class); var plugin = mock(Plugin.class, withSettings().extraInterfaces(SpringPlugin.class)); var context = mock(ApplicationContext.class); when(((SpringPlugin) plugin).getApplicationContext()).thenReturn(context); when(pw.getPlugin()).thenReturn(plugin); when(pluginManager.startedPlugins()).thenReturn(List.of(pw)); var event = new FakeSharedEvent(this); dispatcher.onApplicationEvent(event); verify(context, never()).publishEvent(event); } @Test void shouldUnwrapPluginSharedEventAndRepublish() { var event = new PluginSharedEventDelegator(this, new FakeSharedEvent(this)); dispatcher.onApplicationEvent(event); verify(publisher).publishEvent(event.getDelegate()); } class FakeEvent extends ApplicationEvent { public FakeEvent(Object source) { super(source); } } @SharedEvent class FakeSharedEvent extends ApplicationEvent { public FakeSharedEvent(Object source) { super(source); } } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/SpringComponentsFinderTest.java ================================================ package run.halo.app.plugin; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.io.FileNotFoundException; import java.net.URL; import java.net.URLClassLoader; import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.pf4j.PluginManager; import org.pf4j.PluginState; import org.pf4j.PluginStateEvent; import org.pf4j.PluginWrapper; import org.springframework.util.ResourceUtils; /** * Tests for {@link SpringComponentsFinder}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class SpringComponentsFinderTest { @Mock private PluginManager pluginManager; @InjectMocks private SpringComponentsFinder finder; @Test void shouldNotInvokeReadClasspathStorages() { assertThrows(UnsupportedOperationException.class, () -> finder.readClasspathStorages() ); } @Test void shouldNotInvokeReadPluginsStorages() { assertThrows(UnsupportedOperationException.class, () -> finder.readPluginsStorages() ); } @Test void shouldPutEntryIfPluginCreated() throws FileNotFoundException { var pluginWrapper = mockPluginWrapper(); when(pluginWrapper.getPluginState()).thenReturn(PluginState.CREATED); var event = new PluginStateEvent(pluginManager, pluginWrapper, null); finder.pluginStateChanged(event); var classNames = finder.findClassNames("fake-plugin"); assertEquals(Set.of("run.halo.fake.FakePlugin"), classNames); } @Test void shouldRemoveEntryIfPluginUnloaded() throws FileNotFoundException { var pluginWrapper = mockPluginWrapper(); when(pluginWrapper.getPluginState()).thenReturn(PluginState.CREATED); var event = new PluginStateEvent(pluginManager, pluginWrapper, null); finder.pluginStateChanged(event); var classNames = finder.findClassNames("fake-plugin"); assertFalse(classNames.isEmpty()); when(pluginWrapper.getPluginState()).thenReturn(PluginState.UNLOADED); event = new PluginStateEvent(pluginManager, pluginWrapper, null); finder.pluginStateChanged(event); classNames = finder.findClassNames("fake-plugin"); assertTrue(classNames.isEmpty()); } private PluginWrapper mockPluginWrapper() throws FileNotFoundException { var pluginWrapper = mock(PluginWrapper.class); when(pluginWrapper.getPluginId()).thenReturn("fake-plugin"); var pluginRootUrl = ResourceUtils.getURL("classpath:plugin/plugin-for-finder/"); var classLoader = new URLClassLoader(new URL[] {pluginRootUrl}); when(pluginWrapper.getPluginClassLoader()).thenReturn(classLoader); return pluginWrapper; } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/YamlPluginDescriptorFinderTest.java ================================================ package run.halo.app.plugin; import static java.util.Objects.requireNonNull; import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import org.json.JSONException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.pf4j.PluginDescriptor; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.util.FileSystemUtils; import org.springframework.util.ResourceUtils; import run.halo.app.infra.utils.FileUtils; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link YamlPluginDescriptorFinder}. * * @author guqing * @since 2.0.0 */ class YamlPluginDescriptorFinderTest { private YamlPluginDescriptorFinder yamlPluginDescriptorFinder; private File testFile; private Path tempDirectory; @BeforeEach void setUp() throws IOException { yamlPluginDescriptorFinder = new YamlPluginDescriptorFinder(); tempDirectory = Files.createTempDirectory("halo-plugin"); var plugin002Uri = requireNonNull( ResourceUtils.getFile("classpath:plugin/plugin-0.0.2")).toURI(); Path targetJarPath = tempDirectory.resolve("plugin-0.0.2.jar"); FileUtils.jar(Paths.get(plugin002Uri), targetJarPath); testFile = targetJarPath.toFile(); } @AfterEach void tearDown() throws IOException { FileSystemUtils.deleteRecursively(tempDirectory); } @Test void isApplicable() throws IOException { // File not exists boolean applicable = yamlPluginDescriptorFinder.isApplicable(Path.of("/some/path/test.jar")); assertThat(applicable).isFalse(); // jar file is applicable Path tempJarFile = Files.createTempFile("test", ".jar"); Path tempZipFile = Files.createTempFile("test", ".zip"); try { applicable = yamlPluginDescriptorFinder.isApplicable(tempJarFile); assertThat(applicable).isTrue(); // zip file is not applicable applicable = yamlPluginDescriptorFinder.isApplicable(tempZipFile); assertThat(applicable).isFalse(); // directory is applicable applicable = yamlPluginDescriptorFinder.isApplicable(tempJarFile.getParent()); assertThat(applicable).isTrue(); } finally { FileUtils.deleteRecursivelyAndSilently(tempJarFile); FileUtils.deleteRecursivelyAndSilently(tempZipFile); } } @Test void find() throws JSONException { PluginDescriptor pluginDescriptor = yamlPluginDescriptorFinder.find(testFile.toPath()); String actual = JsonUtils.objectToJson(pluginDescriptor); JSONAssert.assertEquals(""" { "pluginId": "fake-plugin", "pluginDescription": "Fake description", "pluginClass": "run.halo.app.plugin.BasePlugin", "version": "0.0.2", "requires": ">=2.0.0", "provider": "johnniang", "dependencies": [], "license": "GPLv3" } """, actual, false); } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java ================================================ package run.halo.app.plugin; import static java.util.Objects.requireNonNull; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import com.fasterxml.jackson.core.JsonProcessingException; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import org.json.JSONException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.pf4j.PluginRuntimeException; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.security.util.InMemoryResource; import org.springframework.util.FileCopyUtils; import org.springframework.util.FileSystemUtils; import org.springframework.util.ResourceUtils; import run.halo.app.core.extension.Plugin; import run.halo.app.extension.Unstructured; import run.halo.app.infra.utils.FileUtils; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link YamlPluginDescriptorFinder}. * * @author guqing * @since 2.0.0 */ class YamlPluginFinderTest { private YamlPluginFinder pluginFinder; private File testFile; @BeforeEach void setUp() throws FileNotFoundException { pluginFinder = new YamlPluginFinder(); testFile = ResourceUtils.getFile("classpath:plugin/plugin.yaml"); } @Test void find() throws IOException { var tempDirectory = Files.createTempDirectory("halo-test-plugin"); try { var directories = Files.createDirectories(tempDirectory.resolve("build/resources/main")); FileCopyUtils.copy(testFile, directories.resolve("plugin.yaml").toFile()); var plugin = pluginFinder.find(tempDirectory); assertThat(plugin).isNotNull(); var status = plugin.getStatus(); assertEquals(Plugin.Phase.PENDING, status.getPhase()); assertEquals(tempDirectory.toUri(), status.getLoadLocation()); } finally { FileUtils.deleteRecursivelyAndSilently(tempDirectory); } } @Test void findFromJar() throws IOException, URISyntaxException { Path tempDirectory = Files.createTempDirectory("halo-plugin"); try { var plugin002Uri = requireNonNull( getClass().getClassLoader().getResource("plugin/plugin-0.0.2")).toURI(); Path targetJarPath = tempDirectory.resolve("plugin-0.0.2.jar"); FileUtils.jar(Paths.get(plugin002Uri), targetJarPath); Plugin plugin = pluginFinder.find(targetJarPath); assertThat(plugin).isNotNull(); assertThat(plugin.getMetadata().getName()).isEqualTo("fake-plugin"); } finally { FileSystemUtils.deleteRecursively(tempDirectory); } } @Test void unstructuredToPluginTest() throws JSONException { Plugin plugin = pluginFinder.unstructuredToPlugin(new FileSystemResource(testFile)); assertThat(plugin).isNotNull(); JSONAssert.assertEquals(""" { "spec": { "displayName": "a name to show", "version": "0.0.1", "author": { "name": "guqing" }, "logo": "https://guqing.xyz/avatar", "pluginDependencies": { "banana": "0.0.1" }, "homepage": "https://github.com/guqing/halo-plugin-1", "description": "Tell me more about this plugin.", "license": [ { "name": "MIT" } ], "requires": ">=2.0.0", "enabled": false }, "apiVersion": "plugin.halo.run/v1alpha1", "kind": "Plugin", "metadata": { "name": "plugin-1" } } """, JsonUtils.objectToJson(plugin), true); } @Test void findFailedWhenFileNotFound() { var test = Paths.get(""); assertThatThrownBy(() -> pluginFinder.find(test)) .isInstanceOf(PluginRuntimeException.class) .hasMessage("Unable to find plugin descriptor file: plugin.yaml"); } @Test void acceptArrayObjectLicense() throws JSONException { Resource pluginResource = new InMemoryResource(""" apiVersion: v1 kind: Plugin metadata: name: plugin-1 spec: license: - name: MIT url: https://exmple.com """); Plugin plugin = pluginFinder.unstructuredToPlugin(pluginResource); assertThat(plugin.getSpec()).isNotNull(); JSONAssert.assertEquals(""" [{ "name": "MIT", "url": "https://exmple.com" }] """, JsonUtils.objectToJson(plugin.getSpec().getLicense()), false); } @Test void deserializeLicense() throws JSONException, JsonProcessingException { String pluginJson = """ { "apiVersion": "plugin.halo.run/v1alpha1", "kind": "Plugin", "metadata": { "name": "plugin-1" }, "spec": { "license": [ { "name": "MIT", "url": "https://exmple.com" } ] } } """; Plugin plugin = Unstructured.OBJECT_MAPPER.readValue(pluginJson, Plugin.class); assertThat(plugin.getSpec()).isNotNull(); JSONAssert.assertEquals(pluginJson, JsonUtils.objectToJson(plugin), false); } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetterTest.java ================================================ package run.halo.app.plugin.extensionpoint; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import static run.halo.app.infra.SystemSetting.ExtensionPointEnabled.GROUP; import java.util.LinkedHashSet; import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.pf4j.ExtensionPoint; import org.pf4j.PluginManager; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.ObjectProvider; import org.springframework.core.annotation.Order; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.extension.Metadata; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting.ExtensionPointEnabled; import run.halo.app.plugin.extensionpoint.ExtensionPointDefinition.ExtensionPointType; @ExtendWith(MockitoExtension.class) class DefaultExtensionGetterTest { @Mock ExtensionPointDefinitionGetter extensionPointDefinitionGetter; @Mock ExtensionDefinitionGetter extensionDefinitionGetter; @Mock PluginManager pluginManager; @Mock SystemConfigFetcher configFetcher; @Mock BeanFactory beanFactory; @Mock ObjectProvider extensionPointObjectProvider; @InjectMocks DefaultExtensionGetter getter; @Test void shouldGetExtensionBySingletonDefinitionWhenExtensionPointEnabledSet() { // prepare extension point definition when(extensionPointDefinitionGetter.getByClassName(any())) .thenReturn(Mono.fromSupplier( () -> createExtensionPointDefinition("fake-extension-point", FakeExtensionPoint.class, ExtensionPointType.SINGLETON)) ); when(extensionDefinitionGetter.get(eq("fake-extension"))) .thenReturn(Mono.fromSupplier(() -> createExtensionDefinition( "fake-extension", FakeExtensionPointImpl.class, "fake-extension-point"))); when(configFetcher.fetch(GROUP, ExtensionPointEnabled.class)) .thenReturn(Mono.fromSupplier(() -> { var extensionPointEnabled = new ExtensionPointEnabled(); extensionPointEnabled.put("fake-extension-point", new LinkedHashSet<>(List.of("fake-extension"))); return extensionPointEnabled; })); @SuppressWarnings("unchecked") ObjectProvider objectProvider = mock(ObjectProvider.class); when(objectProvider.orderedStream()) .thenReturn(Stream.of(new FakeExtensionPointDefaultImpl())); when(beanFactory.getBeanProvider(FakeExtensionPoint.class)).thenReturn(objectProvider); var extensionImpl = new FakeExtensionPointImpl(); var spyGetter = spy(getter); doReturn(List.of(extensionImpl)).when(spyGetter) .lookExtensions(eq(FakeExtensionPoint.class)); spyGetter.getEnabledExtensions(FakeExtensionPoint.class) .as(StepVerifier::create) .expectNext(extensionImpl) .verifyComplete(); } @Test void shouldGetDefaultSingletonDefinitionWhileExtensionPointEnabledNotSet() { when(extensionPointDefinitionGetter.getByClassName(any())) .thenReturn(Mono.fromSupplier( () -> createExtensionPointDefinition("fake-extension-point", FakeExtensionPoint.class, ExtensionPointType.SINGLETON)) ); when(configFetcher.fetch(GROUP, ExtensionPointEnabled.class)) .thenReturn(Mono.empty()); @SuppressWarnings("unchecked") ObjectProvider objectProvider = mock(ObjectProvider.class); var extensionDefaultImpl = new FakeExtensionPointDefaultImpl(); when(objectProvider.orderedStream()) .thenReturn(Stream.of(extensionDefaultImpl)); when(beanFactory.getBeanProvider(FakeExtensionPoint.class)).thenReturn(objectProvider); var spyGetter = spy(getter); doReturn(List.of()).when(spyGetter) .lookExtensions(eq(FakeExtensionPoint.class)); spyGetter.getEnabledExtensions(FakeExtensionPoint.class) .as(StepVerifier::create) .expectNext(extensionDefaultImpl) .verifyComplete(); } @Test void shouldGetMultiInstanceExtensionWhileExtensionPointEnabledSet() { // prepare extension point definition when(extensionPointDefinitionGetter.getByClassName(any())) .thenReturn(Mono.fromSupplier( () -> createExtensionPointDefinition("fake-extension-point", FakeExtensionPoint.class, ExtensionPointType.MULTI_INSTANCE)) ); when(extensionDefinitionGetter.get(eq("fake-extension"))) .thenReturn(Mono.fromSupplier(() -> createExtensionDefinition( "fake-extension", FakeExtensionPointImpl.class, "fake-extension-point"))); when(extensionDefinitionGetter.get(eq("default-fake-extension"))) .thenReturn(Mono.fromSupplier(() -> createExtensionDefinition( "default-fake-extension", FakeExtensionPointDefaultImpl.class, "fake-extension-point"))); when(configFetcher.fetch(GROUP, ExtensionPointEnabled.class)) .thenReturn(Mono.fromSupplier(() -> { var extensionPointEnabled = new ExtensionPointEnabled(); extensionPointEnabled.put("fake-extension-point", new LinkedHashSet<>(List.of("default-fake-extension", "fake-extension"))); return extensionPointEnabled; })); @SuppressWarnings("unchecked") ObjectProvider objectProvider = mock(ObjectProvider.class); var extensionDefaultImpl = new FakeExtensionPointDefaultImpl(); when(objectProvider.orderedStream()) .thenReturn(Stream.of(extensionDefaultImpl)); when(beanFactory.getBeanProvider(FakeExtensionPoint.class)).thenReturn(objectProvider); var extensionImpl = new FakeExtensionPointImpl(); var anotherExtensionImpl = new FakeExtensionPoint() { }; var spyGetter = spy(getter); doReturn(List.of(extensionImpl, anotherExtensionImpl)).when(spyGetter) .lookExtensions(eq(FakeExtensionPoint.class)); spyGetter.getEnabledExtensions(FakeExtensionPoint.class) .as(StepVerifier::create) // should keep the order of enabled extensions .expectNext(extensionDefaultImpl) .expectNext(extensionImpl) .verifyComplete(); } @Test void shouldGetMultiInstanceExtensionWhileExtensionPointEnabledNotSet() { // prepare extension point definition when(extensionPointDefinitionGetter.getByClassName(any())) .thenReturn(Mono.fromSupplier( () -> createExtensionPointDefinition("fake-extension-point", FakeExtensionPoint.class, ExtensionPointType.MULTI_INSTANCE)) ); when(configFetcher.fetch(GROUP, ExtensionPointEnabled.class)) .thenReturn(Mono.empty()); @SuppressWarnings("unchecked") ObjectProvider objectProvider = mock(ObjectProvider.class); var extensionDefaultImpl = new FakeExtensionPointDefaultImpl(); when(objectProvider.orderedStream()) .thenReturn(Stream.of(extensionDefaultImpl)); when(beanFactory.getBeanProvider(FakeExtensionPoint.class)).thenReturn(objectProvider); var extensionImpl = new FakeExtensionPointImpl(); var anotherExtensionImpl = new FakeExtensionPoint() { }; var spyGetter = spy(getter); doReturn(List.of(extensionImpl, anotherExtensionImpl)).when(spyGetter) .lookExtensions(eq(FakeExtensionPoint.class)); spyGetter.getEnabledExtensions(FakeExtensionPoint.class) .as(StepVerifier::create) // should keep the order according to @Order annotation // order is 1 .expectNext(extensionImpl) // order is 2 .expectNext(extensionDefaultImpl) // order is not set .expectNext(anotherExtensionImpl) .verifyComplete(); } @Test void shouldGetExtensionsFromPluginManagerAndApplicationContext() { var extensionFromPlugin = new FakeExtensionPointDefaultImpl(); var extensionFromAppContext = new FakeExtensionPointImpl(); var spyGetter = spy(getter); doReturn(List.of(extensionFromPlugin)).when(spyGetter) .lookExtensions(eq(FakeExtensionPoint.class)); when(beanFactory.getBeanProvider(FakeExtensionPoint.class)) .thenReturn(extensionPointObjectProvider); when(extensionPointObjectProvider.orderedStream()) .thenReturn(Stream.of(extensionFromAppContext)); var extensions = spyGetter.getExtensionList(FakeExtensionPoint.class); assertEquals(List.of(extensionFromAppContext, extensionFromPlugin), extensions); } interface FakeExtensionPoint extends ExtensionPoint { } @Order(1) static class FakeExtensionPointImpl implements FakeExtensionPoint { } @Order(2) static class FakeExtensionPointDefaultImpl implements FakeExtensionPoint { } ExtensionDefinition createExtensionDefinition(String name, Class clazz, String epdName) { var ed = new ExtensionDefinition(); var metadata = new Metadata(); metadata.setName(name); ed.setMetadata(metadata); var spec = new ExtensionDefinition.ExtensionSpec(); spec.setClassName(clazz.getName()); spec.setExtensionPointName(epdName); ed.setSpec(spec); return ed; } ExtensionPointDefinition createExtensionPointDefinition(String name, Class clazz, ExtensionPointType type) { var epd = new ExtensionPointDefinition(); var metadata = new Metadata(); metadata.setName(name); epd.setMetadata(metadata); var spec = new ExtensionPointDefinition.ExtensionPointSpec(); spec.setClassName(clazz.getName()); spec.setType(type); epd.setSpec(spec); return epd; } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/resources/BundleResourceUtilsTest.java ================================================ package run.halo.app.plugin.resources; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import java.net.MalformedURLException; import java.net.URL; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.pf4j.PluginClassLoader; import org.pf4j.PluginManager; import org.pf4j.PluginWrapper; import org.springframework.core.io.Resource; import run.halo.app.infra.exception.AccessDeniedException; /** * Tests for {@link BundleResourceUtils}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class BundleResourceUtilsTest { @Mock private PluginManager pluginManager; @BeforeEach void setUp() throws MalformedURLException { PluginWrapper pluginWrapper = Mockito.mock(PluginWrapper.class); PluginClassLoader pluginClassLoader = Mockito.mock(PluginClassLoader.class); lenient().when(pluginWrapper.getPluginClassLoader()).thenReturn(pluginClassLoader); lenient().when(pluginManager.getPlugin(eq("fake-plugin"))).thenReturn(pluginWrapper); lenient().when(pluginClassLoader.getResource(eq("console/main.js"))).thenReturn( new URL("file://console/main.js")); lenient().when(pluginClassLoader.getResource(eq("console/style.css"))).thenReturn( new URL("file://console/style.css")); } @Test void getJsBundleResource() { Resource jsBundleResource = BundleResourceUtils.getJsBundleResource(pluginManager, "fake-plugin", "main.js"); assertThat(jsBundleResource).isNotNull(); assertThat(jsBundleResource.exists()).isTrue(); jsBundleResource = BundleResourceUtils.getJsBundleResource(pluginManager, "fake-plugin", "test.js"); assertThat(jsBundleResource).isNull(); jsBundleResource = BundleResourceUtils.getJsBundleResource(pluginManager, "nothing-plugin", "main.js"); assertThat(jsBundleResource).isNull(); assertThatThrownBy(() -> { BundleResourceUtils.getJsBundleResource(pluginManager, "fake-plugin", "../test/main.js"); }).isInstanceOf(AccessDeniedException.class); } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionFactoryTest.java ================================================ package run.halo.app.plugin.resources; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.when; import java.io.FileNotFoundException; import java.net.URL; import java.net.URLClassLoader; import java.time.Duration; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.pf4j.PluginManager; import org.pf4j.PluginWrapper; import org.springframework.boot.autoconfigure.web.WebProperties; import org.springframework.context.ApplicationContext; import org.springframework.http.CacheControl; import org.springframework.http.HttpHeaders; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.util.ResourceUtils; import run.halo.app.core.extension.ReverseProxy; import run.halo.app.core.extension.ReverseProxy.FileReverseProxyProvider; import run.halo.app.core.extension.ReverseProxy.ReverseProxyRule; import run.halo.app.extension.Metadata; import run.halo.app.plugin.PluginConst; /** * Tests for {@link ReverseProxyRouterFunctionFactory}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class ReverseProxyRouterFunctionFactoryTest { @Mock private PluginManager pluginManager; @Mock private ApplicationContext applicationContext; @Spy WebProperties webProperties = new WebProperties(); @InjectMocks private ReverseProxyRouterFunctionFactory factory; @Test void shouldProxyStaticResourceWithCacheControl() throws FileNotFoundException { var cache = webProperties.getResources().getCache(); cache.setUseLastModified(true); cache.getCachecontrol().setMaxAge(Duration.ofDays(7)); var routerFunction = factory.create(mockReverseProxy(), "fakeA"); assertNotNull(routerFunction); var webClient = WebTestClient.bindToRouterFunction(routerFunction).build(); var pluginWrapper = Mockito.mock(PluginWrapper.class); var pluginRoot = ResourceUtils.getURL("classpath:plugin/plugin-for-reverseproxy/"); var classLoader = new URLClassLoader(new URL[] {pluginRoot}); when(pluginWrapper.getPluginClassLoader()).thenReturn(classLoader); when(pluginManager.getPlugin("fakeA")).thenReturn(pluginWrapper); webClient.get().uri("/plugins/fakeA/assets/static/test.txt") .exchange() .expectStatus().isOk() .expectHeader().cacheControl(CacheControl.maxAge(Duration.ofDays(7))) .expectHeader().value(HttpHeaders.LAST_MODIFIED, Assertions::assertNotNull) .expectBody(String.class).isEqualTo("Fake content."); } @Test void shouldProxyStaticResourceWithoutLastModified() throws FileNotFoundException { var cache = webProperties.getResources().getCache(); cache.setUseLastModified(false); cache.getCachecontrol().setMaxAge(Duration.ofDays(7)); var routerFunction = factory.create(mockReverseProxy(), "fakeA"); assertNotNull(routerFunction); var webClient = WebTestClient.bindToRouterFunction(routerFunction).build(); var pluginWrapper = Mockito.mock(PluginWrapper.class); var pluginRoot = ResourceUtils.getURL("classpath:plugin/plugin-for-reverseproxy/"); var classLoader = new URLClassLoader(new URL[] {pluginRoot}); when(pluginWrapper.getPluginClassLoader()).thenReturn(classLoader); when(pluginManager.getPlugin("fakeA")).thenReturn(pluginWrapper); webClient.get().uri("/plugins/fakeA/assets/static/test.txt") .exchange() .expectStatus().isOk() .expectHeader().cacheControl(CacheControl.maxAge(Duration.ofDays(7))) .expectHeader().lastModified(-1) .expectBody(String.class).isEqualTo("Fake content."); } @Test void shouldReturnNotFoundIfResourceNotFound() throws FileNotFoundException { var routerFunction = factory.create(mockReverseProxy(), "fakeA"); assertNotNull(routerFunction); var webClient = WebTestClient.bindToRouterFunction(routerFunction).build(); var pluginWrapper = Mockito.mock(PluginWrapper.class); var pluginRoot = ResourceUtils.getURL("classpath:plugin/plugin-for-reverseproxy/"); var classLoader = new URLClassLoader(new URL[] {pluginRoot}); when(pluginWrapper.getPluginClassLoader()).thenReturn(classLoader); when(pluginManager.getPlugin("fakeA")).thenReturn(pluginWrapper); webClient.get().uri("/plugins/fakeA/assets/static/non-existing-file.txt") .exchange() .expectHeader().cacheControl(CacheControl.empty()) .expectStatus().isNotFound(); } private ReverseProxy mockReverseProxy() { var reverseProxyRule = new ReverseProxyRule("/static/**", new FileReverseProxyProvider("static", "")); var reverseProxy = new ReverseProxy(); var metadata = new Metadata(); metadata.setLabels( Map.of(PluginConst.PLUGIN_NAME_LABEL_NAME, "fakeA")); reverseProxy.setMetadata(metadata); reverseProxy.setRules(List.of(reverseProxyRule)); return reverseProxy; } } ================================================ FILE: application/src/test/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionRegistryTest.java ================================================ package run.halo.app.plugin.resources; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.ReverseProxy; import run.halo.app.extension.Metadata; import run.halo.app.plugin.PluginRouterFunctionRegistry; /** * Tests for {@link ReverseProxyRouterFunctionRegistry}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class ReverseProxyRouterFunctionRegistryTest { @InjectMocks ReverseProxyRouterFunctionRegistry registry; @Mock ReverseProxyRouterFunctionFactory reverseProxyRouterFunctionFactory; @Mock PluginRouterFunctionRegistry pluginRouterFunctionRegistry; @Test void register() { ReverseProxy mock = getMockReverseProxy(); registry.register("fake-plugin", mock); assertThat(registry.reverseProxySize("fake-plugin")).isEqualTo(1); // repeat register a same reverse proxy registry.register("fake-plugin", mock); assertThat(registry.reverseProxySize("fake-plugin")).isEqualTo(1); verify(reverseProxyRouterFunctionFactory, times(2)).create(any(), any()); } @Test void removeByKeyValue() { ReverseProxy mock = getMockReverseProxy(); registry.register("fake-plugin", mock); registry.remove("fake-plugin", "test-reverse-proxy"); assertThat(registry.reverseProxySize("fake-plugin")).isEqualTo(0); } private ReverseProxy getMockReverseProxy() { ReverseProxy mock = Mockito.mock(ReverseProxy.class); Metadata metadata = new Metadata(); metadata.setName("test-reverse-proxy"); when(mock.getMetadata()).thenReturn(metadata); RouterFunction routerFunction = request -> Mono.empty(); when(reverseProxyRouterFunctionFactory.create(any(), any())) .thenReturn(routerFunction); return mock; } } ================================================ FILE: application/src/test/java/run/halo/app/search/HaloDocumentEventsListenerTest.java ================================================ package run.halo.app.search; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.search.event.HaloDocumentAddRequestEvent; import run.halo.app.search.event.HaloDocumentDeleteRequestEvent; import run.halo.app.search.event.HaloDocumentRebuildRequestEvent; @ExtendWith(MockitoExtension.class) class HaloDocumentEventsListenerTest { @Mock ExtensionGetter extensionGetter; @InjectMocks HaloDocumentEventsListener listener; @Test void shouldRebuildIndicesWhenReceivingRebuildRequestEvent() { listener.setBufferSize(1); var searchEngine = mock(SearchEngine.class); when(searchEngine.available()).thenReturn(true); when(extensionGetter.getEnabledExtension(SearchEngine.class)) .thenReturn(Mono.just(searchEngine)); var docsProvider = mock(HaloDocumentsProvider.class); var docs = List.of(new HaloDocument(), new HaloDocument(), new HaloDocument()); when(docsProvider.fetchAll()).thenReturn(Flux.fromIterable(docs)); when(extensionGetter.getExtensions(HaloDocumentsProvider.class)) .thenReturn(Flux.just(docsProvider)); listener.onApplicationEvent(new HaloDocumentRebuildRequestEvent(this)); verify(searchEngine, times(3)).addOrUpdate(any()); } @Test void shouldAddDocsWhenReceivingAddRequestEvent() { var searchEngine = mock(SearchEngine.class); when(searchEngine.available()).thenReturn(true); when(extensionGetter.getEnabledExtension(SearchEngine.class)) .thenReturn(Mono.just(searchEngine)); var docs = List.of(new HaloDocument()); listener.onApplicationEvent(new HaloDocumentAddRequestEvent(this, docs)); verify(searchEngine).addOrUpdate(docs); } @Test void shouldDeleteDocsWhenReceivingDeleteRequestEvent() { var searchEngine = mock(SearchEngine.class); when(searchEngine.available()).thenReturn(true); when(extensionGetter.getEnabledExtension(SearchEngine.class)) .thenReturn(Mono.just(searchEngine)); var docIds = List.of("1", "2", "3"); listener.onApplicationEvent(new HaloDocumentDeleteRequestEvent(this, docIds)); verify(searchEngine).deleteDocument(docIds); } @Test void shouldFailWhenSearchEngineIsUnavailable() { var searchEngine = mock(SearchEngine.class); when(searchEngine.available()).thenReturn(false); when(extensionGetter.getEnabledExtension(SearchEngine.class)) .thenReturn(Mono.just(searchEngine)); assertThrows( SearchEngineUnavailableException.class, () -> listener.onApplicationEvent(new HaloDocumentRebuildRequestEvent(this)) ); } } ================================================ FILE: application/src/test/java/run/halo/app/search/IndexEndpointTest.java ================================================ package run.halo.app.search; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.validation.Errors; import org.springframework.web.reactive.function.server.HandlerStrategies; import org.springframework.web.server.handler.ResponseStatusExceptionHandler; import reactor.core.publisher.Mono; import run.halo.app.infra.exception.RequestBodyValidationException; @ExtendWith(MockitoExtension.class) class IndexEndpointTest { @Mock SearchService searchService; @InjectMocks IndexEndpoint endpoint; WebTestClient client; @BeforeEach void setUp() { client = WebTestClient.bindToRouterFunction(endpoint.endpoint()) .handlerStrategies(HandlerStrategies.builder() .exceptionHandler(new ResponseStatusExceptionHandler()) .build()) .build(); } @Test void shouldResponseBadRequestIfNotRequestBody() { client.post().uri("/indices/-/search") .exchange() .expectStatus().isBadRequest(); } @Test void shouldResponseBadRequestIfRequestBodyValidationFailed() { var option = new SearchOption(); var errors = mock(Errors.class); when(searchService.search(any(SearchOption.class))) .thenReturn(Mono.error(new RequestBodyValidationException(errors))); client.post().uri("/indices/-/search") .bodyValue(option) .exchange() .expectStatus().isBadRequest(); } @Test void shouldSearchCorrectly() { var option = new SearchOption(); option.setKeyword("halo"); var searchResult = new SearchResult(); when(searchService.search(any(SearchOption.class))).thenReturn(Mono.just(searchResult)); client.post().uri("/indices/-/search") .bodyValue(option) .exchange() .expectStatus().isOk() .expectBody(SearchResult.class) .isEqualTo(searchResult); verify(searchService).search(assertArg(o -> { assertEquals("halo", o.getKeyword()); // make sure the filters are overwritten assertTrue(o.getFilterExposed()); assertTrue(o.getFilterPublished()); assertFalse(o.getFilterRecycled()); })); } @Test void shouldFailWhenSearchEngineIsUnavailable() { when(searchService.search(any(SearchOption.class))) .thenReturn(Mono.error(new SearchEngineUnavailableException())); client.post().uri("/indices/-/search") .bodyValue(new SearchOption()) .exchange() .expectStatus().is4xxClientError(); } @Test void ensureGroupVersionNotModified() { assertEquals("api.halo.run/v1alpha1", endpoint.groupVersion().toString()); } } ================================================ FILE: application/src/test/java/run/halo/app/search/IndicesEndpointTest.java ================================================ package run.halo.app.search; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.Mockito.verify; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.web.reactive.server.WebTestClient; import run.halo.app.search.event.HaloDocumentRebuildRequestEvent; @ExtendWith(MockitoExtension.class) class IndicesEndpointTest { @Mock ApplicationEventPublisher publisher; @InjectMocks IndicesEndpoint endpoint; WebTestClient client; @BeforeEach void setUp() { client = WebTestClient.bindToRouterFunction(endpoint.endpoint()).build(); } @ParameterizedTest @ValueSource(strings = {"/indices/-/rebuild"}) void shouldRebuildIndices(String uri) { client.post().uri(uri) .exchange() .expectStatus().isAccepted(); verify(publisher).publishEvent(assertArg(event -> { assertInstanceOf(HaloDocumentRebuildRequestEvent.class, event); assertEquals(endpoint, event.getSource()); })); } @Test void ensureGroupVersionNotChanged() { assertEquals("api.console.halo.run/v1alpha1", endpoint.groupVersion().toString()); } } ================================================ FILE: application/src/test/java/run/halo/app/search/SearchServiceImplTest.java ================================================ package run.halo.app.search; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.validation.Errors; import org.springframework.validation.Validator; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.infra.exception.RequestBodyValidationException; import run.halo.app.plugin.extensionpoint.ExtensionGetter; @ExtendWith(MockitoExtension.class) class SearchServiceImplTest { @Mock Validator validator; @Mock ExtensionGetter extensionGetter; @InjectMocks SearchServiceImpl searchService; @Test void shouldThrowValidationErrorIfOptionIsInvalid() { var option = new SearchOption(); option.setKeyword("halo"); var errors = mock(Errors.class); when(errors.hasErrors()).thenReturn(true); when(validator.validateObject(option)).thenReturn(errors); searchService.search(option) .as(StepVerifier::create) .expectError(RequestBodyValidationException.class) .verify(); } @Test void shouldThrowSearchEngineUnavailableExceptionIfNoSearchEngineFound() { var option = new SearchOption(); option.setKeyword("halo"); var errors = mock(Errors.class); when(errors.hasErrors()).thenReturn(false); when(validator.validateObject(option)).thenReturn(errors); when(extensionGetter.getEnabledExtension(SearchEngine.class)).thenReturn(Mono.empty()); searchService.search(option) .as(StepVerifier::create) .expectError(SearchEngineUnavailableException.class) .verify(); } @Test void shouldThrowSearchEngineUnavailableExceptionIfNoSearchEngineAvailable() { var option = new SearchOption(); option.setKeyword("halo"); var errors = mock(Errors.class); when(errors.hasErrors()).thenReturn(false); when(validator.validateObject(option)).thenReturn(errors); when(extensionGetter.getEnabledExtension(SearchEngine.class)) .thenAnswer(invocation -> Mono.fromSupplier(() -> { var searchEngine = mock(SearchEngine.class); when(searchEngine.available()).thenReturn(false); return searchEngine; })); searchService.search(option) .as(StepVerifier::create) .expectError(SearchEngineUnavailableException.class); } @Test void shouldSearch() { var option = new SearchOption(); option.setKeyword("halo"); var errors = mock(Errors.class); when(errors.hasErrors()).thenReturn(false); when(validator.validateObject(option)).thenReturn(errors); var searchResult = mock(SearchResult.class); when(extensionGetter.getEnabledExtension(SearchEngine.class)) .thenAnswer(invocation -> Mono.fromSupplier(() -> { var searchEngine = mock(SearchEngine.class); when(searchEngine.available()).thenReturn(true); when(searchEngine.search(option)).thenReturn(searchResult); return searchEngine; })); searchService.search(option) .as(StepVerifier::create) .expectNext(searchResult) .verifyComplete(); } } ================================================ FILE: application/src/test/java/run/halo/app/search/lucene/LuceneSearchEngineIntegrationTest.java ================================================ package run.halo.app.search.lucene; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static run.halo.app.core.extension.content.Post.VisibleEnum.PRIVATE; import static run.halo.app.core.extension.content.Post.VisibleEnum.PUBLIC; import java.time.Duration; import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.opentest4j.AssertionFailedError; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.core.retry.RetryException; import org.springframework.core.retry.RetryPolicy; import org.springframework.core.retry.RetryTemplate; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.test.StepVerifier; import reactor.util.retry.Retry; import run.halo.app.content.Content; import run.halo.app.content.ContentUpdateParam; import run.halo.app.content.PostRequest; import run.halo.app.content.PostService; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.search.SearchEngine; import run.halo.app.search.SearchOption; import run.halo.app.search.SearchResult; @DirtiesContext @SpringBootTest(properties = { "halo.search-engine.lucene.enabled=true", "halo.extension.controller.disabled=false"}) @AutoConfigureWebTestClient public class LuceneSearchEngineIntegrationTest { @Autowired WebTestClient webClient; @Autowired PostService postService; @Autowired ReactiveExtensionClient client; @Autowired SearchEngine searchEngine; @BeforeEach @AfterEach void cleanUp() { searchEngine.deleteAll(); } @Test @WithMockUser(username = "admin", roles = AnonymousUserConst.Role) void shouldSearchPostAfterPostPublished() throws RetryException { var postName = "first-post"; assertNoResult(1); createPost(postName); assertHasResult(5); unpublishPost(postName); assertNoResult(5); publishPost(postName); assertHasResult(5); privatePost(postName); assertNoResult(5); publicPost(postName); assertHasResult(5); recyclePost(postName); assertNoResult(5); recoverPost(postName); assertHasResult(5); deletePostPermanently(postName); assertNoResult(5); } void assertHasResult(int maxAttempts) throws RetryException { var retryTemplate = new RetryTemplate(RetryPolicy.builder() .delay(Duration.ofSeconds(1)) .maxRetries(maxAttempts) .predicate(AssertionFailedError.class::isInstance) .build()); var option = new SearchOption(); option.setKeyword("halo"); option.setHighlightPreTag(""); option.setHighlightPostTag(""); retryTemplate.execute(() -> { webClient.post().uri("/apis/api.halo.run/v1alpha1/indices/-/search") .bodyValue(option) .exchange() .expectStatus().isOk() .expectBody(SearchResult.class).value(result -> { assertEquals(1, result.getTotal()); assertEquals("halo", result.getKeyword()); var hits = result.getHits(); assertEquals(1, hits.size()); var doc = hits.get(0); assertEquals("post.content.halo.run-first-post", doc.getId()); assertEquals("post.content.halo.run", doc.getType()); assertEquals("first halo post", doc.getTitle()); assertNull(doc.getDescription()); assertEquals("halo", doc.getContent()); }); return null; }); } void assertNoResult(int maxAttempts) throws RetryException { var retryTemplate = new RetryTemplate(RetryPolicy.builder() .delay(Duration.ofSeconds(1)) .maxRetries(maxAttempts) .predicate(AssertionFailedError.class::isInstance) .build()); var option = new SearchOption(); option.setKeyword("halo"); option.setHighlightPreTag(""); option.setHighlightPostTag(""); option.setIncludeTagNames(List.of("search")); option.setIncludeCategoryNames(List.of("halo")); option.setIncludeOwnerNames(List.of("admin")); retryTemplate.execute(() -> { webClient.post().uri("/apis/api.halo.run/v1alpha1/indices/-/search") .bodyValue(option) .exchange() .expectStatus().isOk() .expectBody(SearchResult.class).value(result -> { assertEquals(0, result.getTotal()); assertEquals("halo", result.getKeyword()); }); return null; }); } void deletePostPermanently(String postName) { client.get(Post.class, postName) .flatMap(client::delete) .retryWhen(optimisticLockRetry()) .as(StepVerifier::create) .expectNextCount(1) .verifyComplete(); } void recoverPost(String postName) { client.get(Post.class, postName) .doOnNext(post -> post.getSpec().setDeleted(false)) .flatMap(client::update) .retryWhen(optimisticLockRetry()) .as(StepVerifier::create) .expectNextCount(1) .verifyComplete(); } void recyclePost(String postName) { client.get(Post.class, postName) .doOnNext(post -> post.getSpec().setDeleted(true)) .flatMap(client::update) .retryWhen(optimisticLockRetry()) .as(StepVerifier::create) .expectNextCount(1) .verifyComplete(); } void publicPost(String postName) { client.get(Post.class, postName) .doOnNext(post -> post.getSpec().setVisible(PUBLIC)) .flatMap(client::update) .retryWhen(optimisticLockRetry()) .as(StepVerifier::create) .expectNextCount(1) .verifyComplete(); } void privatePost(String postName) { client.get(Post.class, postName) .doOnNext(post -> post.getSpec().setVisible(PRIVATE)) .flatMap(client::update) .retryWhen(optimisticLockRetry()) .as(StepVerifier::create) .expectNextCount(1) .verifyComplete(); } void publishPost(String postName) { client.get(Post.class, postName) .flatMap(postService::publish) .retryWhen(optimisticLockRetry()) .as(StepVerifier::create) .expectNextCount(1) .verifyComplete(); } void unpublishPost(String postName) { client.get(Post.class, postName) .flatMap(postService::unpublish) .retryWhen(optimisticLockRetry()) .as(StepVerifier::create) .expectNextCount(1) .verifyComplete(); } void createPost(String postName) { var post = new Post(); var metadata = new Metadata(); post.setMetadata(metadata); metadata.setName(postName); var spec = new Post.PostSpec(); post.setSpec(spec); spec.setPublish(true); spec.setOwner("admin"); spec.setTitle("first halo post"); spec.setVisible(PUBLIC); spec.setAllowComment(true); spec.setPinned(false); spec.setPriority(0); spec.setSlug("/first-post"); spec.setDeleted(false); spec.setTags(List.of("search")); spec.setCategories(List.of("halo")); var excerpt = new Post.Excerpt(); excerpt.setRaw("first post description"); excerpt.setAutoGenerate(false); spec.setExcerpt(excerpt); var content = new Content("halo", "halo", "Markdown"); var contentParam = ContentUpdateParam.from(content); var postRequest = new PostRequest(post, contentParam); postService.draftPost(postRequest) .as(StepVerifier::create) .expectNextCount(1) .verifyComplete(); } Retry optimisticLockRetry() { return Retry.backoff(5, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance); } } ================================================ FILE: application/src/test/java/run/halo/app/search/lucene/LuceneSearchEngineTest.java ================================================ package run.halo.app.search.lucene; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.nio.file.Path; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.stream.IntStream; import org.apache.lucene.index.DirectoryReader; import org.apache.lucene.store.AlreadyClosedException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.search.HaloDocument; import run.halo.app.search.SearchOption; @ExtendWith(MockitoExtension.class) class LuceneSearchEngineTest { LuceneSearchEngine searchEngine; @TempDir Path tempDir; @BeforeEach void setUp() throws Exception { this.searchEngine = new LuceneSearchEngine(tempDir); this.searchEngine.afterPropertiesSet(); } @AfterEach void cleanUp() throws Exception { this.searchEngine.destroy(); } @Test void shouldAddOrUpdateDocument() throws IOException { var haloDoc = createFakeHaloDoc(); searchEngine.addOrUpdate(List.of(haloDoc)); // validate the index var reader = DirectoryReader.open(searchEngine.getDirectory()); assertEquals(1, reader.getDocCount("id")); } @Test void shouldDeleteDocument() throws IOException { var haloDoc = createFakeHaloDoc(); searchEngine.addOrUpdate(List.of(haloDoc)); var reader = DirectoryReader.open(searchEngine.getDirectory()); assertEquals(1, reader.getDocCount("id")); this.searchEngine.deleteDocument(List.of("fake-id")); reader = DirectoryReader.open(searchEngine.getDirectory()); assertEquals(0, reader.getDocCount("id")); } @Test void shouldDeleteAll() throws IOException { var haloDoc = createFakeHaloDoc(); searchEngine.addOrUpdate(List.of(haloDoc)); var reader = DirectoryReader.open(searchEngine.getDirectory()); assertEquals(1, reader.getDocCount("id")); this.searchEngine.deleteAll(); reader = DirectoryReader.open(searchEngine.getDirectory()); assertEquals(0, reader.getDocCount("id")); } @Test void shouldAddOrUpdateDocumentConcurrently() throws ExecutionException, InterruptedException, TimeoutException { runConcurrently(() -> { var haloDoc = createFakeHaloDoc(); searchEngine.addOrUpdate(List.of(haloDoc)); }); } @Test void shouldDeleteDocumentConcurrently() throws ExecutionException, InterruptedException, TimeoutException { runConcurrently(() -> { var haloDoc = createFakeHaloDoc(); searchEngine.addOrUpdate(List.of(haloDoc)); searchEngine.deleteDocument(List.of(haloDoc.getId())); }); } @Test void shouldDeleteAllConcurrently() throws ExecutionException, InterruptedException, TimeoutException { runConcurrently(() -> { var haloDoc = createFakeHaloDoc(); searchEngine.addOrUpdate(List.of(haloDoc)); searchEngine.deleteAll(); }); } @Test void shouldDestroy() throws Exception { var directory = this.searchEngine.getDirectory(); this.searchEngine.destroy(); assertThrows(AlreadyClosedException.class, () -> DirectoryReader.open(directory)); } @Test void shouldSearchNothingIfIndexNotFound() { var option = new SearchOption(); option.setKeyword("fake"); option.setLimit(123); option.setHighlightPreTag(""); option.setHighlightPostTag(""); var result = this.searchEngine.search(option); assertEquals(0, result.getTotal()); assertEquals("fake", result.getKeyword()); assertEquals(123, result.getLimit()); assertEquals(0, result.getHits().size()); } @Test void shouldSearch() { this.searchEngine.addOrUpdate(List.of(createFakeHaloDoc())); var option = new SearchOption(); option.setKeyword("fake"); option.setLimit(123); option.setHighlightPreTag(""); option.setHighlightPostTag(""); var result = this.searchEngine.search(option); assertEquals(1, result.getTotal()); assertEquals("fake", result.getKeyword()); assertEquals(123, result.getLimit()); assertEquals(1, result.getHits().size()); var gotHaloDoc = result.getHits().get(0); assertEquals("fake-id", gotHaloDoc.getId()); assertEquals("fake-title", gotHaloDoc.getTitle()); assertNull(gotHaloDoc.getDescription()); assertEquals("fake-content", gotHaloDoc.getContent()); } void runConcurrently(Runnable runnable) throws ExecutionException, InterruptedException, TimeoutException { var executorService = Executors.newFixedThreadPool(10); var futures = IntStream.of(0, 10) .mapToObj(i -> CompletableFuture.runAsync(runnable, executorService)) .toArray(CompletableFuture[]::new); CompletableFuture.allOf(futures).get(10, TimeUnit.SECONDS); executorService.shutdownNow(); assertTrue(executorService.awaitTermination(10, TimeUnit.SECONDS)); } HaloDocument createFakeHaloDoc() { var haloDoc = new HaloDocument(); haloDoc.setId("fake-id"); haloDoc.setMetadataName("fake-name"); haloDoc.setTitle("fake-title"); haloDoc.setDescription(null); haloDoc.setContent("fake-content"); haloDoc.setType("fake-type"); haloDoc.setOwnerName("fake-owner"); var now = Instant.now(); haloDoc.setCreationTimestamp(now); haloDoc.setUpdateTimestamp(null); haloDoc.setPermalink("/fake-permalink"); haloDoc.setAnnotations(Map.of("fake-anno-key", "fake-anno-value")); return haloDoc; } } ================================================ FILE: application/src/test/java/run/halo/app/search/post/PostEventsListenerTest.java ================================================ package run.halo.app.search.post; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Instant; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.content.ContentWrapper; import run.halo.app.content.PostService; import run.halo.app.core.extension.content.Post; import run.halo.app.event.post.PostDeletedEvent; import run.halo.app.event.post.PostUpdatedEvent; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.search.event.HaloDocumentAddRequestEvent; import run.halo.app.search.event.HaloDocumentDeleteRequestEvent; @ExtendWith(MockitoExtension.class) class PostEventsListenerTest { @Mock ApplicationEventPublisher publisher; @Mock PostService postService; @Mock ReactiveExtensionClient client; @InjectMocks PostEventsListener listener; @Nested class PostUpdatedEventTest { @Test void shouldDoNothingIfPostIsDeleted() { when(client.fetch(Post.class, "fake-post")) .thenReturn(Mono.empty()); var event = new PostUpdatedEvent(this, "fake-post"); listener.onApplicationEvent(event) .as(StepVerifier::create) .verifyComplete(); verify(publisher, never()).publishEvent(any()); } @Test void shouldRequestDeleteWhilePostIsDeleting() { var post = new Post(); var metadata = new Metadata(); metadata.setName("fake-post"); metadata.setDeletionTimestamp(Instant.now()); post.setMetadata(metadata); when(client.fetch(Post.class, "fake-post")) .thenReturn(Mono.just(post)); var event = new PostUpdatedEvent(this, "fake-post"); listener.onApplicationEvent(event) .as(StepVerifier::create) .verifyComplete(); verify(publisher).publishEvent( assertArg(e -> assertInstanceOf(HaloDocumentDeleteRequestEvent.class, e)) ); } @Test void shouldRequestAddWhilePostIsNotDeleted() { var post = new Post(); var metadata = new Metadata(); metadata.setName("fake-post"); post.setMetadata(metadata); var spec = new Post.PostSpec(); post.setSpec(spec); var status = new Post.PostStatus(); post.setStatus(status); when(client.fetch(Post.class, "fake-post")) .thenReturn(Mono.just(post)); var content = ContentWrapper.builder() .content("fake-content") .raw("fake-content") .build(); when(postService.getReleaseContent(post)).thenReturn(Mono.just(content)); var event = new PostUpdatedEvent(this, "fake-post"); listener.onApplicationEvent(event) .as(StepVerifier::create) .verifyComplete(); verify(publisher).publishEvent( assertArg(e -> assertInstanceOf(HaloDocumentAddRequestEvent.class, e)) ); } } @Nested class PostDeleteEventTest { @Test void shouldRequestDelete() { var post = new Post(); var metadata = new Metadata(); metadata.setName("fake-post"); post.setMetadata(metadata); var event = new PostDeletedEvent(this, post); listener.onApplicationEvent(event); verify(publisher).publishEvent( assertArg(e -> assertInstanceOf(HaloDocumentDeleteRequestEvent.class, e)) ); } } } ================================================ FILE: application/src/test/java/run/halo/app/search/post/PostHaloDocumentsProviderTest.java ================================================ package run.halo.app.search.post; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.content.ContentWrapper; import run.halo.app.content.PostService; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.infra.ReactiveExtensionPaginatedOperator; @ExtendWith(MockitoExtension.class) class PostHaloDocumentsProviderTest { @Mock PostService postService; @Mock ReactiveExtensionPaginatedOperator paginatedOperator; @InjectMocks PostHaloDocumentsProvider provider; @Test void ensureTypeNotModified() { assertEquals("post.content.halo.run", provider.getType()); } @Test void shouldFetchAll() { var post = createFakePost(); when(paginatedOperator.list(same(Post.class), any(ListOptions.class))) .thenReturn(Flux.just(post)); var content = ContentWrapper.builder() .content("fake-content") .raw("fake-content") .build(); when(postService.getReleaseContent(post)).thenReturn(Mono.just(content)); provider.fetchAll() .as(StepVerifier::create) .assertNext(doc -> { assertEquals("post.content.halo.run", doc.getType()); assertEquals("fake-post", doc.getMetadataName()); assertEquals("post.content.halo.run-fake-post", doc.getId()); assertEquals("fake-content", doc.getContent()); }) .verifyComplete(); } @Test void shouldFetchAllIfNoContent() { var post = createFakePost(); when(paginatedOperator.list(same(Post.class), any(ListOptions.class))) .thenReturn(Flux.just(post)); when(postService.getReleaseContent(post)).thenReturn(Mono.empty()); provider.fetchAll() .as(StepVerifier::create) .assertNext(doc -> { assertEquals("post.content.halo.run", doc.getType()); assertEquals("fake-post", doc.getMetadataName()); assertEquals("post.content.halo.run-fake-post", doc.getId()); assertEquals("", doc.getContent()); }) .verifyComplete(); } Post createFakePost() { var post = new Post(); var metadata = new Metadata(); metadata.setName("fake-post"); post.setMetadata(metadata); var spec = new Post.PostSpec(); var status = new Post.PostStatus(); post.setSpec(spec); post.setStatus(status); return post; } } ================================================ FILE: application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java ================================================ package run.halo.app.security; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.HashMap; import org.json.JSONException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.beans.factory.ObjectProvider; import org.springframework.data.domain.Sort; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.junit.jupiter.SpringExtension; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.AuthProvider; import run.halo.app.core.extension.UserConnection; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link AuthProviderServiceImpl}. * * @author guqing * @since 2.4.0 */ @ExtendWith({SpringExtension.class, MockitoExtension.class}) class AuthProviderServiceImplTest { @Mock ReactiveExtensionClient client; @Mock ObjectProvider systemFetchProvider; @Mock SystemConfigFetcher systemConfigFetcher; @InjectMocks AuthProviderServiceImpl authProviderService; @BeforeEach void setUp() { when(systemFetchProvider.getIfUnique()).thenReturn(systemConfigFetcher); } @Test void testEnable() throws JSONException { // Create a test auth provider AuthProvider authProvider = createAuthProvider("github"); when(client.get(eq(AuthProvider.class), eq("github"))).thenReturn(Mono.just(authProvider)); ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigMap.class); when(client.update(captor.capture())).thenReturn(Mono.empty()); pileSystemConfigMap(); // Call the method being tested authProviderService.enable("github") .as(StepVerifier::create) .expectNext(authProvider) .verifyComplete(); ConfigMap value = captor.getValue(); JSONAssert.assertEquals(""" { "states": [ { "name": "github", "enabled": true, "priority": 0 } ] } """, value.getData().get(SystemSetting.AuthProvider.GROUP), true); // Verify the result verify(client).get(AuthProvider.class, "github"); } @Test void testDisable() throws JSONException { // Create a test auth provider AuthProvider authProvider = createAuthProvider("github"); when(client.get(eq(AuthProvider.class), eq("github"))).thenReturn(Mono.just(authProvider)); AuthProvider local = createAuthProvider("local"); local.getMetadata().getLabels().put(AuthProvider.PRIVILEGED_LABEL, "true"); ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigMap.class); when(client.update(captor.capture())).thenReturn(Mono.empty()); pileSystemConfigMap(); // Call the method being tested Mono result = authProviderService.disable("github"); assertEquals(authProvider, result.block()); ConfigMap value = captor.getValue(); JSONAssert.assertEquals(""" { "states": [ { "name": "github", "enabled": false, "priority": 0 } ] } """, value.getData().get(SystemSetting.AuthProvider.GROUP), true); // Verify the result verify(client).get(AuthProvider.class, "github"); } @Test @WithMockUser(username = "admin") void listAll() { AuthProvider github = createAuthProvider("github"); github.getSpec().setBindingUrl("fake-binding-url"); AuthProvider gitlab = createAuthProvider("gitlab"); gitlab.getSpec().setBindingUrl("fake-binding-url"); AuthProvider gitee = createAuthProvider("gitee"); when(client.listAll(same(AuthProvider.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.just(github, gitlab, gitee)); when(client.listAll(same(UserConnection.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.empty()); pileSystemConfigMap(); authProviderService.listAll() .as(StepVerifier::create) .consumeNextWith(result -> { assertThat(result).hasSize(3); try { JSONAssert.assertEquals(""" [{ "name": "gitee", "displayName": "gitee", "authType": "OAUTH2", "isBound": false, "enabled": false, "priority": 0, "supportsBinding": false, "privileged": false }, { "name": "github", "displayName": "github", "bindingUrl": "fake-binding-url", "authType": "OAUTH2", "isBound": false, "enabled": false, "priority": 0, "supportsBinding": false, "privileged": false }, { "name": "gitlab", "displayName": "gitlab", "bindingUrl": "fake-binding-url", "authType": "OAUTH2", "isBound": false, "enabled": false, "priority": 0, "supportsBinding": false, "privileged": false }] """, JsonUtils.objectToJson(result), true); } catch (JSONException e) { throw new RuntimeException(e); } }) .verifyComplete(); } AuthProvider createAuthProvider(String name) { AuthProvider authProvider = new AuthProvider(); authProvider.setMetadata(new Metadata()); authProvider.getMetadata().setName(name); authProvider.getMetadata().setLabels(new HashMap<>()); authProvider.setSpec(new AuthProvider.AuthProviderSpec()); authProvider.getSpec().setDisplayName(name); authProvider.getSpec().setAuthType(AuthProvider.AuthType.OAUTH2); return authProvider; } void pileSystemConfigMap() { ConfigMap configMap = new ConfigMap(); configMap.setData(new HashMap<>()); when(systemConfigFetcher.getConfigMap()) .thenReturn(Mono.just(configMap)); } } ================================================ FILE: application/src/test/java/run/halo/app/security/CsrfSecurityTest.java ================================================ package run.halo.app.security; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.http.HttpHeaders; import org.springframework.test.web.reactive.server.WebTestClient; @SpringBootTest @AutoConfigureWebTestClient class CsrfSecurityTest { @Autowired WebTestClient webClient; @Test void shouldNotCheckCsrfForPatAuthentication() { webClient.post() .uri("/fake") .headers(headers -> headers.setBearerAuth("pat_invalid")) .exchange() .expectStatus() .isUnauthorized() .expectHeader() .exists(HttpHeaders.WWW_AUTHENTICATE); } } ================================================ FILE: application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java ================================================ package run.halo.app.security; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.http.HttpHeaders.WWW_AUTHENTICATE; import java.net.URI; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; import org.springframework.security.web.server.savedrequest.ServerRequestCache; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @ExtendWith(MockitoExtension.class) class DefaultServerAuthenticationEntryPointTest { @Mock ServerRequestCache requestCache; @InjectMocks DefaultServerAuthenticationEntryPoint entryPoint; @Test void commenceForXhrRequest() { var mockReq = MockServerHttpRequest.get("/protected") .header("X-Requested-With", "XMLHttpRequest") .build(); var mockExchange = MockServerWebExchange.builder(mockReq) .build(); var commenceMono = entryPoint.commence(mockExchange, new AuthenticationCredentialsNotFoundException("Not Found")); StepVerifier.create(commenceMono) .verifyComplete(); var headers = mockExchange.getResponse().getHeaders(); assertEquals("FormLogin realm=\"console\"", headers.getFirst(WWW_AUTHENTICATE)); } @Test void commenceForNormalRequest() { var mockReq = MockServerHttpRequest.get("/protected") .build(); var mockExchange = MockServerWebExchange.builder(mockReq) .build(); Mockito.when(requestCache.saveRequest(mockExchange)).thenReturn(Mono.empty()); var commenceMono = entryPoint.commence(mockExchange, new AuthenticationCredentialsNotFoundException("Not Found")); StepVerifier.create(commenceMono) .verifyComplete(); assertEquals(URI.create("/login?authentication_required"), mockExchange.getResponse().getHeaders().getLocation()); } } ================================================ FILE: application/src/test/java/run/halo/app/security/DefaultSuperAdminInitializerTest.java ================================================ package run.halo.app.security; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import org.junit.jupiter.api.Test; import org.springframework.security.crypto.password.PasswordEncoder; import run.halo.app.extension.MetadataUtil; /** * Tests for {@link DefaultSuperAdminInitializer}. */ class DefaultSuperAdminInitializerTest { @Test void createAdminShouldSetSystemProtectionFinalizer() { var passwordEncoder = mock(PasswordEncoder.class); var initializer = new DefaultSuperAdminInitializer(null, passwordEncoder); var admin = initializer.createAdmin("admin", "password", "admin@example.com"); assertThat(admin.getMetadata()).isNotNull(); assertThat(admin.getMetadata().getFinalizers()).isNotNull(); assertThat(admin.getMetadata().getFinalizers()) .contains(MetadataUtil.SYSTEM_FINALIZER); } } ================================================ FILE: application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java ================================================ package run.halo.app.security; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.core.authority.AuthorityUtils.authorityListToSet; import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.Metadata; import run.halo.app.infra.exception.UserNotFoundException; @ExtendWith(MockitoExtension.class) class DefaultUserDetailServiceTest { @Mock UserService userService; @Mock RoleService roleService; @InjectMocks DefaultUserDetailService userDetailService; @Test void shouldUpdatePasswordSuccessfully() { var fakeUser = createFakeUserDetails(); var user = new run.halo.app.core.extension.User(); when(userService.updatePassword("faker", "new-fake-password")).thenReturn( Mono.just(user) ); var userDetailsMono = userDetailService.updatePassword(fakeUser, "new-fake-password"); StepVerifier.create(userDetailsMono) .expectSubscription() .assertNext(userDetails -> assertEquals("new-fake-password", userDetails.getPassword())) .verifyComplete(); verify(userService, times(1)).updatePassword(eq("faker"), eq("new-fake-password")); } @Test void shouldReturnErrorWhenFailedToUpdatePassword() { var fakeUser = createFakeUserDetails(); var exception = new RuntimeException("failed to update password"); when(userService.updatePassword("faker", "new-fake-password")).thenReturn( Mono.error(exception) ); var userDetailsMono = userDetailService.updatePassword(fakeUser, "new-fake-password"); StepVerifier.create(userDetailsMono) .expectSubscription() .expectErrorMatches(throwable -> throwable == exception) .verify(); verify(userService, times(1)).updatePassword(eq("faker"), eq("new-fake-password")); } @Test void shouldFindUserDetailsByExistingUsername() { var foundUser = createFakeUser(); when(userService.getUser("faker")).thenReturn(Mono.just(foundUser)); when(roleService.getRolesByUsername("faker")).thenReturn(Flux.just("fake-role")); var userDetailsMono = userDetailService.findByUsername("faker"); StepVerifier.create(userDetailsMono) .expectSubscription() .assertNext(gotUser -> { assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername()); assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword()); assertEquals( Set.of("ROLE_fake-role", "ROLE_authenticated", "ROLE_anonymous"), authorityListToSet(gotUser.getAuthorities())); }) .verifyComplete(); } @Test void shouldFindHaloUserDetailsWith2faDisabledWhen2faNotEnabled() { var fakeUser = createFakeUser(); when(userService.getUser("faker")).thenReturn(Mono.just(fakeUser)); when(roleService.getRolesByUsername("faker")).thenReturn(Flux.empty()); userDetailService.findByUsername("faker") .as(StepVerifier::create) .assertNext(userDetails -> { assertInstanceOf(HaloUserDetails.class, userDetails); assertFalse(((HaloUserDetails) userDetails).isTwoFactorAuthEnabled()); }) .verifyComplete(); } @Test void shouldFindHaloUserDetailsWith2faDisabledWhen2faEnabledButNoTotpConfigured() { var fakeUser = createFakeUser(); fakeUser.getSpec().setTwoFactorAuthEnabled(true); when(userService.getUser("faker")).thenReturn(Mono.just(fakeUser)); when(roleService.getRolesByUsername("faker")).thenReturn(Flux.empty()); userDetailService.findByUsername("faker") .as(StepVerifier::create) .assertNext(userDetails -> { assertInstanceOf(HaloUserDetails.class, userDetails); assertFalse(((HaloUserDetails) userDetails).isTwoFactorAuthEnabled()); }) .verifyComplete(); } @Test void shouldFindHaloUserDetailsWith2faEnabledWhen2faEnabledAndTotpConfigured() { var fakeUser = createFakeUser(); fakeUser.getSpec().setTwoFactorAuthEnabled(true); fakeUser.getSpec().setTotpEncryptedSecret("fake-totp-encrypted-secret"); when(userService.getUser("faker")).thenReturn(Mono.just(fakeUser)); when(roleService.getRolesByUsername("faker")).thenReturn(Flux.empty()); userDetailService.findByUsername("faker") .as(StepVerifier::create) .assertNext(userDetails -> { assertInstanceOf(HaloUserDetails.class, userDetails); assertTrue(((HaloUserDetails) userDetails).isTwoFactorAuthEnabled()); }) .verifyComplete(); } @Test void shouldFindHaloUserDetailsWith2faDisabledWhen2faDisabledGlobally() { userDetailService.setTwoFactorAuthDisabled(true); var fakeUser = createFakeUser(); fakeUser.getSpec().setTwoFactorAuthEnabled(true); fakeUser.getSpec().setTotpEncryptedSecret("fake-totp-encrypted-secret"); when(userService.getUser("faker")).thenReturn(Mono.just(fakeUser)); when(roleService.getRolesByUsername("faker")).thenReturn(Flux.empty()); userDetailService.findByUsername("faker") .as(StepVerifier::create) .assertNext(userDetails -> { assertInstanceOf(HaloUserDetails.class, userDetails); assertFalse(((HaloUserDetails) userDetails).isTwoFactorAuthEnabled()); }) .verifyComplete(); } @Test void shouldFindUserDetailsByExistingUsernameButWithoutAnyRoles() { var foundUser = createFakeUser(); when(userService.getUser("faker")).thenReturn(Mono.just(foundUser)); when(roleService.getRolesByUsername("faker")).thenReturn(Flux.empty()); StepVerifier.create(userDetailService.findByUsername("faker")) .expectSubscription() .assertNext(gotUser -> { assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername()); assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword()); assertEquals( Set.of("ROLE_anonymous", "ROLE_authenticated"), authorityListToSet(gotUser.getAuthorities())); }) .verifyComplete(); } @Test void shouldNotFindUserDetailsByNonExistingUsername() { when(userService.getUser("non-existing-user")).thenReturn( Mono.error(() -> new UserNotFoundException("non-existing-user"))); var userDetailsMono = userDetailService.findByUsername("non-existing-user"); StepVerifier.create(userDetailsMono) .expectError(AuthenticationException.class) .verify(); } @Test void shouldFindUserDetailsByEmail() { var foundUser = createFakeUser(); when(userService.findUserByVerifiedEmail("faker@halo.run")) .thenReturn(Mono.just(foundUser)); when(roleService.getRolesByUsername("faker")).thenReturn(Flux.just("fake-role")); var userDetailsMono = userDetailService.findByUsername("faker@halo.run"); StepVerifier.create(userDetailsMono) .expectSubscription() .assertNext(gotUser -> { assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername()); assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword()); assertEquals( Set.of("ROLE_fake-role", "ROLE_authenticated", "ROLE_anonymous"), authorityListToSet(gotUser.getAuthorities())); }) .verifyComplete(); verify(userService, never()).getUser(any()); } @Test void shouldReturnNotFoundWhenEmailNotExists() { when(userService.findUserByVerifiedEmail("non-existing-email@halo.run")) .thenReturn(Mono.error(new UserNotFoundException("non-existing-email@halo.run"))); var userDetailsMono = userDetailService.findByUsername("non-existing-email@halo.run"); StepVerifier.create(userDetailsMono) .expectError(BadCredentialsException.class) .verify(); } UserDetails createFakeUserDetails() { return User.builder() .username("faker") .password("fake-password") .roles("fake-role") .build(); } run.halo.app.core.extension.User createFakeUser() { var metadata = new Metadata(); metadata.setName("faker"); var userSpec = new run.halo.app.core.extension.User.UserSpec(); userSpec.setPassword("fake-password"); var user = new run.halo.app.core.extension.User(); user.setMetadata(metadata); user.setSpec(userSpec); return user; } } ================================================ FILE: application/src/test/java/run/halo/app/security/HaloServerRequestCacheTest.java ================================================ package run.halo.app.security; import java.net.URI; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.web.server.session.DefaultWebSessionManager; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; class HaloServerRequestCacheTest { HaloServerRequestCache requestCache; @BeforeEach void setUp() { requestCache = new HaloServerRequestCache(); } @Test void shouldNotSaveIfPageNotCacheable() { var mockExchange = MockServerWebExchange.from(MockServerHttpRequest.get("/login")); requestCache.saveRequest(mockExchange) .then(requestCache.getRedirectUri(mockExchange)) .as(StepVerifier::create) .verifyComplete(); } @Test void shouldSaveIfPageCacheable() { var mockExchange = MockServerWebExchange.from( MockServerHttpRequest.get("/archives") .queryParam("q", "v") .accept(MediaType.TEXT_HTML) ); requestCache.saveRequest(mockExchange) .then(requestCache.getRedirectUri(mockExchange)) .as(StepVerifier::create) .expectNext(URI.create("/archives?q=v")) .verifyComplete(); } @Test void shouldSaveIfRedirectUriPresent() { var mockExchange = MockServerWebExchange.from( MockServerHttpRequest.get("/login") .queryParam("redirect_uri", "/halo?q=v#fragment") ); requestCache.saveRequest(mockExchange) .then(requestCache.getRedirectUri(mockExchange)) .as(StepVerifier::create) .expectNext(URI.create("/halo?q=v#fragment")) .verifyComplete(); } @Test void shouldRemoveIfRedirectUriFound() { var sessionManager = new DefaultWebSessionManager(); var mockExchange = MockServerWebExchange.builder(MockServerHttpRequest.get("/login") .queryParam("redirect_uri", "/halo") ) .sessionManager(sessionManager) .build(); var removeExchange = mockExchange.mutate() .request(builder -> builder.uri(URI.create("/halo"))) .build(); requestCache.saveRequest(mockExchange) .then(Mono.defer(() -> requestCache.removeMatchingRequest(removeExchange))) .as(StepVerifier::create) .assertNext(request -> { Assertions.assertEquals(URI.create("/halo"), request.getURI()); }) .verifyComplete(); } @Test void shouldRemoveIfRedirectUriFoundAndContainsFragment() { var sessionManager = new DefaultWebSessionManager(); var mockExchange = MockServerWebExchange.builder(MockServerHttpRequest.get("/login") .queryParam("redirect_uri", "/halo#fragment") ) .sessionManager(sessionManager) .build(); var removeExchange = mockExchange.mutate() .request(builder -> builder.uri(URI.create("/halo"))) .build(); requestCache.saveRequest(mockExchange) .then(Mono.defer(() -> requestCache.removeMatchingRequest(removeExchange))) .as(StepVerifier::create) .assertNext(request -> { Assertions.assertEquals(URI.create("/halo"), request.getURI()); }) .verifyComplete(); } } ================================================ FILE: application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java ================================================ package run.halo.app.security; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.net.URI; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.security.web.server.ServerRedirectStrategy; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.infra.InitializationStateGetter; /** * Tests for {@link InitializeRedirectionWebFilter}. * * @author guqing * @since 2.5.2 */ @ExtendWith(MockitoExtension.class) class InitializeRedirectionWebFilterTest { @Mock private InitializationStateGetter initializationStateGetter; @Mock private ServerRedirectStrategy serverRedirectStrategy; @InjectMocks private InitializeRedirectionWebFilter filter; @BeforeEach void setUp() { filter.setRedirectStrategy(serverRedirectStrategy); } @Test void shouldRedirectWhenSystemNotInitialized() { when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(false)); WebFilterChain chain = mock(WebFilterChain.class); var paths = new String[] {"/", "/console/test", "/uc/test", "/login", "/signup"}; for (String path : paths) { MockServerHttpRequest request = MockServerHttpRequest.get(path) .accept(MediaType.TEXT_HTML).build(); MockServerWebExchange exchange = MockServerWebExchange.from(request); when(serverRedirectStrategy.sendRedirect(any(), any())).thenReturn(Mono.empty().then()); Mono result = filter.filter(exchange, chain); StepVerifier.create(result) .expectNextCount(0) .expectComplete() .verify(); verify(serverRedirectStrategy).sendRedirect(eq(exchange), eq(URI.create("/system/setup"))); verify(chain, never()).filter(eq(exchange)); } } @Test void shouldNotRedirectWhenSystemInitialized() { lenient().when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true)); WebFilterChain chain = mock(WebFilterChain.class); var paths = new String[] {"/test", "/apis/test", "system/setup", "/logout"}; for (String path : paths) { MockServerHttpRequest request = MockServerHttpRequest.get(path) .accept(MediaType.TEXT_HTML).build(); MockServerWebExchange exchange = MockServerWebExchange.from(request); when(chain.filter(any())).thenReturn(Mono.empty().then()); Mono result = filter.filter(exchange, chain); StepVerifier.create(result) .expectNextCount(0) .expectComplete() .verify(); verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange), any()); verify(chain).filter(eq(exchange)); } } @Test void shouldNotRedirectTest() { WebFilterChain chain = mock(WebFilterChain.class); MockServerHttpRequest request = MockServerHttpRequest.get("/test") .accept(MediaType.TEXT_HTML).build(); MockServerWebExchange exchange = MockServerWebExchange.from(request); when(chain.filter(any())).thenReturn(Mono.empty().then()); Mono result = filter.filter(exchange, chain); StepVerifier.create(result) .expectNextCount(0) .expectComplete() .verify(); verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange), any()); verify(chain).filter(eq(exchange)); } } ================================================ FILE: application/src/test/java/run/halo/app/security/ResponseMap.java ================================================ package run.halo.app.security; import java.util.HashMap; public class ResponseMap extends HashMap { } ================================================ FILE: application/src/test/java/run/halo/app/security/authentication/WebExchangeMatchersTest.java ================================================ package run.halo.app.security.authentication; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.http.MediaType.ALL; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.http.MediaType.TEXT_HTML; import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll; import java.util.Set; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import reactor.test.StepVerifier; class WebExchangeMatchersTest { @Test void shouldNotMatchMediaTypeAll() { assertion(Set.of(APPLICATION_JSON), Set.of(APPLICATION_JSON, ALL), true); assertion(Set.of(APPLICATION_JSON), Set.of(ALL), false); assertion(Set.of(APPLICATION_JSON), Set.of(APPLICATION_JSON), true); assertion(Set.of(APPLICATION_JSON), Set.of(APPLICATION_JSON, TEXT_HTML), true); } void assertion(Set matchingMediaTypes, Set acceptMediaTypes, boolean expectMatch) { var matcher = ignoringMediaTypeAll(matchingMediaTypes.toArray(new MediaType[0])); MockServerHttpRequest request = MockServerHttpRequest.get("/fake") .accept(acceptMediaTypes.toArray(new MediaType[0])) .build(); var webExchange = MockServerWebExchange.from(request); StepVerifier.create(matcher.matches(webExchange)) .consumeNextWith(matchResult -> assertEquals(expectMatch, matchResult.isMatch())) .verifyComplete(); } } ================================================ FILE: application/src/test/java/run/halo/app/security/authentication/impl/RsaKeyServiceTest.java ================================================ package run.halo.app.security.authentication.impl; import static com.nimbusds.jose.jwk.KeyOperation.SIGN; import static com.nimbusds.jose.jwk.KeyOperation.VERIFY; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.KeyUse; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.security.InvalidKeyException; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.interfaces.RSAPrivateCrtKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.Set; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.util.StringUtils; import reactor.core.Exceptions; import reactor.test.StepVerifier; import run.halo.app.security.authentication.login.InvalidEncryptedMessageException; @ExtendWith(MockitoExtension.class) class RsaKeyServiceTest { RsaKeyService service; @TempDir Path tempDir; @BeforeEach void setUp() throws JOSEException { service = new RsaKeyService(tempDir); service.afterPropertiesSet(); } @Test void shouldGenerateKeyPair() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { byte[] privKeyBytes = Files.readAllBytes(tempDir.resolve("pat_id_rsa")); byte[] pubKeyBytes = Files.readAllBytes(tempDir.resolve("pat_id_rsa.pub")); var pubKeySpec = new X509EncodedKeySpec(pubKeyBytes); var privKeySpec = new PKCS8EncodedKeySpec(privKeyBytes); var keyFactory = KeyFactory.getInstance(RsaKeyService.ALGORITHM); var privKey = (RSAPrivateCrtKey) keyFactory.generatePrivate(privKeySpec); var pubKey = (RSAPublicKey) keyFactory.generatePublic(pubKeySpec); assertEquals(privKey.getModulus(), pubKey.getModulus()); assertEquals(privKey.getPublicExponent(), pubKey.getPublicExponent()); } @Test void shouldReadPublicKey() throws IOException { var realPubKeyBytes = Files.readAllBytes(tempDir.resolve("pat_id_rsa.pub")); StepVerifier.create(service.readPublicKey()) .assertNext(bytes -> assertArrayEquals(realPubKeyBytes, bytes)) .verifyComplete(); } @Test void shouldDecryptMessageCorrectly() { final String message = "halo"; var mono = service.readPublicKey() .map(pubKeyBytes -> { var pubKeySpec = new X509EncodedKeySpec(pubKeyBytes); try { var keyFactory = KeyFactory.getInstance(RsaKeyService.ALGORITHM); var pubKey = keyFactory.generatePublic(pubKeySpec); var cipher = Cipher.getInstance(RsaKeyService.TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, pubKey); return cipher.doFinal(message.getBytes()); } catch (NoSuchAlgorithmException | InvalidKeySpecException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) { throw Exceptions.propagate(e); } }) .flatMap(service::decrypt) .map(String::new); StepVerifier.create(mono) .expectNext(message) .verifyComplete(); } @Test void shouldFailToDecryptMessage() { StepVerifier.create(service.decrypt("invalid-bytes".getBytes())) .verifyError(InvalidEncryptedMessageException.class); } @Test void shouldGetKeyIdFromJwk() { assertTrue(StringUtils.hasText(service.getKeyId())); } @Test void shouldGetJwk() { var jwk = service.getJwk(); assertEquals("RSA", jwk.getKeyType().getValue()); assertEquals(JWSAlgorithm.RS256, jwk.getAlgorithm()); assertEquals(KeyUse.SIGNATURE, jwk.getKeyUse()); assertEquals(Set.of(SIGN, VERIFY), jwk.getKeyOperations()); } } ================================================ FILE: application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java ================================================ package run.halo.app.security.authentication.login; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import io.github.resilience4j.ratelimiter.RateLimiter; import io.github.resilience4j.ratelimiter.RateLimiterConfig; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import java.time.Duration; import java.util.Base64; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.security.authentication.CryptoService; import run.halo.app.security.authentication.exception.TooManyRequestsException; @ExtendWith(MockitoExtension.class) class LoginAuthenticationConverterTest { @Mock ServerWebExchange exchange; @Mock CryptoService cryptoService; @Mock RateLimiterRegistry rateLimiterRegistry; @InjectMocks LoginAuthenticationConverter converter; MultiValueMap formData; @BeforeEach void setUp() { formData = new LinkedMultiValueMap<>(); lenient().when(exchange.getFormData()).thenReturn(Mono.just(formData)); var request = mock(ServerHttpRequest.class); var headers = new HttpHeaders(); when(request.getHeaders()).thenReturn(headers); when(exchange.getRequest()).thenReturn(request); when(rateLimiterRegistry.rateLimiter("authentication-from-ip-unknown", "authentication")) .thenReturn(RateLimiter.ofDefaults("authentication")); } @Test void shouldTriggerRateLimit() { var username = "username"; var password = "password"; formData.add("username", username); formData.add("password", Base64.getEncoder().encodeToString(password.getBytes())); var rateLimiter = RateLimiter.of("authentication", RateLimiterConfig.custom() .limitForPeriod(1) .limitRefreshPeriod(Duration.ofSeconds(1)) .timeoutDuration(Duration.ofMillis(0)) .build()); assertTrue(rateLimiter.acquirePermission(1)); when(rateLimiterRegistry.rateLimiter("authentication-from-ip-unknown", "authentication")) .thenReturn(rateLimiter); StepVerifier.create(converter.convert(exchange)) .expectError(TooManyRequestsException.class) .verify(); verify(cryptoService, never()).decrypt(password.getBytes()); } @Test void applyUsernameAndPasswordThenCreatesTokenSuccess() { var username = "username"; var password = "password"; var decryptedPassword = "decrypted password"; formData.add("username", username); formData.add("password", Base64.getEncoder().encodeToString(password.getBytes())); when(cryptoService.decrypt(password.getBytes())) .thenReturn(Mono.just(decryptedPassword.getBytes())); StepVerifier.create(converter.convert(exchange)) .expectNext(new UsernamePasswordAuthenticationToken(username, decryptedPassword)) .verifyComplete(); verify(cryptoService).decrypt(password.getBytes()); } @Test void applyPasswordWithoutBase64FormatThenBadCredentialsException() { var username = "username"; var password = "+invalid-base64-format-password"; formData.add("username", username); formData.add("password", password); StepVerifier.create(converter.convert(exchange)) .verifyError(BadCredentialsException.class); } @Test void applyUsernameAndInvalidPasswordThenBadCredentialsException() { var username = "username"; var password = "password"; formData.add("username", username); formData.add("password", Base64.getEncoder().encodeToString(password.getBytes())); when(cryptoService.decrypt(password.getBytes())) .thenReturn(Mono.error(() -> new InvalidEncryptedMessageException("invalid message"))); StepVerifier.create(converter.convert(exchange)) .verifyError(BadCredentialsException.class); verify(cryptoService).decrypt(password.getBytes()); } } ================================================ FILE: application/src/test/java/run/halo/app/security/authentication/login/PublicKeyRouteBuilderTest.java ================================================ package run.halo.app.security.authentication.login; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Base64; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; import run.halo.app.security.authentication.CryptoService; @ExtendWith(MockitoExtension.class) class PublicKeyRouteBuilderTest { WebTestClient webClient; @Mock CryptoService cryptoService; @BeforeEach void setUp() { webClient = WebTestClient.bindToRouterFunction( new PublicKeyRouteBuilder(cryptoService).build() ).build(); } @Test void shouldReadPublicKey() { var publicKeyStr = "public-key"; var encoder = Base64.getEncoder(); when(cryptoService.readPublicKey()).thenReturn(Mono.just(publicKeyStr.getBytes())); webClient.get().uri("/login/public-key") .exchange() .expectStatus().isOk() .expectBody(PublicKeyRouteBuilder.PublicKeyResponse.class) .consumeWith(result -> { var response = result.getResponseBody(); assertNotNull(response); assertEquals(encoder.encodeToString(publicKeyStr.getBytes()), response.getBase64Format()); }); verify(cryptoService).readPublicKey(); } } ================================================ FILE: application/src/test/java/run/halo/app/security/authentication/pat/PatTest.java ================================================ package run.halo.app.security.authentication.pat; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.reactive.server.WebTestClient; import run.halo.app.security.PersonalAccessToken; @SpringBootTest @AutoConfigureWebTestClient class PatTest { @Autowired WebTestClient webClient; @Test @WithMockUser(username = "faker", password = "${noop}password", roles = "super-role") void generatePat() { var requestPat = new PersonalAccessToken(); var spec = requestPat.getSpec(); spec.setRoles(List.of("super-role")); spec.setName("Fake PAT"); webClient.post() .uri("/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens") .bodyValue(requestPat) .exchange() .expectStatus().isOk() .expectBody(PersonalAccessToken.class) .value(pat -> { var annotations = pat.getMetadata().getAnnotations(); assertTrue(annotations.containsKey("security.halo.run/access-token")); }); } } ================================================ FILE: application/src/test/java/run/halo/app/security/authentication/rememberme/PersistentTokenBasedRememberMeServicesTest.java ================================================ package run.halo.app.security.authentication.rememberme; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Duration; import java.time.Instant; import java.util.Date; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.web.authentication.rememberme.CookieTheftException; import org.springframework.security.web.authentication.rememberme.InvalidCookieException; import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken; import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException; import org.springframework.security.web.server.WebFilterExchange; import reactor.core.publisher.Mono; /** * Tests for {@link PersistentTokenBasedRememberMeServices}. * * @author guqing * @since 2.17.0 */ @ExtendWith(MockitoExtension.class) class PersistentTokenBasedRememberMeServicesTest { @Mock private CookieSignatureKeyResolver cookieSignatureKeyResolver; @Mock private ReactiveUserDetailsService userDetailsService; @Mock private RememberMeCookieResolver rememberMeCookieResolver; @Mock private PersistentRememberMeTokenRepository tokenRepository; @InjectMocks private PersistentTokenBasedRememberMeServices persistentTokenBasedRememberMeServices; @Nested class ProcessAutoLoginCookieTest { @Test void invalidCookieTest() { var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); assertThatThrownBy(() -> persistentTokenBasedRememberMeServices.processAutoLoginCookie( new String[] {"test"}, exchange).block()) .isInstanceOf(InvalidCookieException.class) .hasMessage("Cookie token did not contain 2 tokens, but contained '[test]'"); } @Test void noPersistentTokenFoundTest() { var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); when(tokenRepository.getTokenForSeries(eq("test-series"))) .thenReturn(Mono.empty()); assertThatThrownBy(() -> persistentTokenBasedRememberMeServices.processAutoLoginCookie( new String[] {"test-series", "test"}, exchange).block() ).isInstanceOf(RememberMeAuthenticationException.class) .hasMessage("No persistent token found for series id: test-series"); } @Test void tokenMismatchTest() { var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); when(tokenRepository.getTokenForSeries(eq("fake-series"))) .thenReturn(Mono.just( new PersistentRememberMeToken("test", "fake-series", "other-token-value", new Date())) ); when(tokenRepository.removeUserTokens(eq("test"))).thenReturn(Mono.empty()); assertThatThrownBy(() -> persistentTokenBasedRememberMeServices.processAutoLoginCookie( new String[] {"fake-series", "token-value"}, exchange).block()) .isInstanceOf(CookieTheftException.class) .hasMessage( "Invalid remember-me token (Series/token) mismatch. Implies previous cookie " + "theft attack."); } @Test void rememberMeLoginExpiredTest() { var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); when(tokenRepository.getTokenForSeries(eq("fake-series"))) .thenReturn(Mono.just( new PersistentRememberMeToken("test", "fake-series", "token-value", new Date(Instant.now().minusSeconds(10).toEpochMilli()))) ); when(rememberMeCookieResolver.getCookieMaxAge()).thenReturn(Duration.ofSeconds(5)); assertThatThrownBy(() -> persistentTokenBasedRememberMeServices.processAutoLoginCookie( new String[] {"fake-series", "token-value"}, exchange).block()) .isInstanceOf(RememberMeAuthenticationException.class) .hasMessage("Remember-me login has expired"); } @Test void successfulTest() { var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); when(tokenRepository.getTokenForSeries(eq("fake-series"))) .thenReturn(Mono.just( new PersistentRememberMeToken("test", "fake-series", "token-value", new Date())) ); when(rememberMeCookieResolver.getCookieMaxAge()).thenReturn(Duration.ofSeconds(5)); var generatedTokenValue = new AtomicReference(); when(tokenRepository.updateToken(eq("fake-series"), any(), any())) .thenAnswer(invocation -> { var tokenValue = (String) invocation.getArgument(1); generatedTokenValue.compareAndSet(null, tokenValue); return Mono.empty(); }); when(userDetailsService.findByUsername(eq("test"))).thenReturn(Mono.empty()); persistentTokenBasedRememberMeServices.processAutoLoginCookie( new String[] {"fake-series", "token-value"}, exchange) .block(); verify(rememberMeCookieResolver).setRememberMeCookie(eq(exchange), eq(persistentTokenBasedRememberMeServices.encodeCookie( new String[] {"fake-series", generatedTokenValue.get()}))); } } @Test void onLoginSuccessTest() { var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); var authentication = new UsernamePasswordAuthenticationToken("test", "test"); when(tokenRepository.createNewToken(any())).thenReturn(Mono.empty()); persistentTokenBasedRememberMeServices.onLoginSuccess(exchange, authentication).block(); verify(rememberMeCookieResolver).setRememberMeCookie(eq(exchange), any()); } @Test void onLogoutTest() { var authentication = new UsernamePasswordAuthenticationToken("test", "test"); when(tokenRepository.removeUserTokens(eq("test"))).thenReturn(Mono.empty()); var filterExchange = mock(WebFilterExchange.class); persistentTokenBasedRememberMeServices.onLogout(filterExchange, authentication).block(); } } ================================================ FILE: application/src/test/java/run/halo/app/security/authentication/rememberme/RememberTokenCleanerTest.java ================================================ package run.halo.app.security.authentication.rememberme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.spy; import java.time.Duration; import java.time.Instant; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; /** * Test for {@link RememberTokenCleaner}. * * @author guqing * @since 2.17.0 */ @ExtendWith(MockitoExtension.class) class RememberTokenCleanerTest { @InjectMocks private RememberTokenCleaner rememberTokenCleaner; @Test void test() { var spyRememberTokenCleaner = spy(rememberTokenCleaner); Mockito.doReturn(Duration.ofSeconds(30)).when(spyRememberTokenCleaner).getTokenValidity(); var expiredTime = spyRememberTokenCleaner.getExpirationThreshold(); var creationTime = Instant.now().minus(Duration.ofSeconds(31)); // creationTime < expirationThreshold means it has expired assertThat(creationTime).isBefore(expiredTime); // not expired creationTime = Instant.now().minus(Duration.ofSeconds(29)); assertThat(creationTime).isAfter(expiredTime); } } ================================================ FILE: application/src/test/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServicesTest.java ================================================ package run.halo.app.security.authentication.rememberme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.userdetails.User; import org.springframework.test.context.junit.jupiter.SpringExtension; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; /** * Tests for {@link TokenBasedRememberMeServices}. * * @author guqing * @since 2.16.0 */ @ExtendWith({SpringExtension.class, MockitoExtension.class}) class TokenBasedRememberMeServicesTest { @Mock CookieSignatureKeyResolver cookieSignatureKeyResolver; @InjectMocks private TokenBasedRememberMeServices tokenBasedRememberMeServices; @Test void retrieveUserName() { var authentication = new TestingAuthenticationToken("fake-user", "test"); var username = tokenBasedRememberMeServices.retrieveUserName(authentication); var userDetails = new User("zhangsan", "test", List.of()); authentication = new TestingAuthenticationToken(userDetails, "test"); username = tokenBasedRememberMeServices.retrieveUserName(authentication); assertThat(username).isEqualTo("zhangsan"); } @Test void makeTokenSignatureTest() { when(cookieSignatureKeyResolver.resolveSigningKey()).thenReturn(Mono.just("fake-key")); var expireMs = 1716435187323L; tokenBasedRememberMeServices.makeTokenSignature(expireMs, "fake-user", "pwd-1", TokenBasedRememberMeServices.DEFAULT_ALGORITHM) .as(StepVerifier::create) .expectNext("29f1c7ccbb489741392d27ba5c30f30d05c79ee66289b6d6da5b431bba99a0c7") .verifyComplete(); } @Test void encodeCookieTest() { var expireMs = 1716435187323L; var cookieTokens = new String[] {"fake-user", Long.toString(expireMs), TokenBasedRememberMeServices.DEFAULT_ALGORITHM, "29f1c7ccbb489741392d27ba5c30f30d05c79ee66289b6d6da5b431bba99a0c7"}; var encode = tokenBasedRememberMeServices.encodeCookie(cookieTokens); assertThat(encode) .isEqualTo("ZmFrZS11c2VyOjE3MTY0MzUxODczMjM6U0hBLTI1NjoyOWYxYzdjY2JiNDg5NzQxMz" + "kyZDI3YmE1YzMwZjMwZDA1Yzc5ZWU2NjI4OWI2ZDZkYTViNDMxYmJhOTlhMGM3"); } @Test void decodeCookieTest() { var cookieValue = "YWRtaW46MTcxODk2NDE3NDgwODpTSEE" + "tMjU2OmNkOTM0ZTAyZWQ4NGJmMzc1ZTA4MmE1OWU4YTA3NTNiMzA3ODg1MjZmYzA3Yjgy" + "YzVmY2Y3YmJiYzdjYzRkNWU"; // 123 % 4 = 3, so we need to add 1 '=' to make it a multiple of 4 for // spring-security/gh-15127 assertThat(cookieValue.length()).isEqualTo(123); var cookie = tokenBasedRememberMeServices.decodeCookie(cookieValue); assertThat(cookie).containsExactly("admin", "1718964174808", "SHA-256", "cd934e02ed84bf375e082a59e8a0753b30788526fc07b82c5fcf7bbbc7cc4d5e"); cookieValue = "ZmFrZS11c2VyOjE3MTY0MzUxODczMjM6U0hBLTI1NjoyOWYxYzdjY2JiNDg5NzQxMz" + "kyZDI3YmE1YzMwZjMwZDA1Yzc5ZWU2NjI4OWI2ZDZkYTViNDMxYmJhOTlhMGM3"; assertThat(cookieValue.length()).isEqualTo(128); cookie = tokenBasedRememberMeServices.decodeCookie(cookieValue); assertThat(cookie).containsExactly("fake-user", "1716435187323", "SHA-256", "29f1c7ccbb489741392d27ba5c30f30d05c79ee66289b6d6da5b431bba99a0c7"); } } ================================================ FILE: application/src/test/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSettingsTest.java ================================================ package run.halo.app.security.authentication.twofactor; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.params.provider.Arguments.arguments; import java.util.stream.Stream; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; class TwoFactorAuthSettingsTest { @ParameterizedTest @MethodSource("isAvailableCases") void isAvailableTest(TwoFactorAuthSettings settings, boolean expectAvailable) { assertEquals(expectAvailable, settings.isAvailable()); } static Stream isAvailableCases() { return Stream.of( arguments(settings(false, true, true), false), arguments(settings(false, false, false), false), arguments(settings(false, false, true), false), arguments(settings(false, true, false), false), arguments(settings(true, true, true), true), arguments(settings(true, false, false), false), arguments(settings(true, false, true), true), arguments(settings(true, true, false), false) ); } static TwoFactorAuthSettings settings(boolean enabled, boolean emailVerified, boolean totpConfigured) { var settings = new TwoFactorAuthSettings(); settings.setEnabled(enabled); settings.setEmailVerified(emailVerified); settings.setTotpConfigured(totpConfigured); return settings; } } ================================================ FILE: application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java ================================================ package run.halo.app.security.authorization; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static run.halo.app.security.authorization.AuthorityUtils.authoritiesToRoles; import static run.halo.app.security.authorization.AuthorityUtils.containsSuperRole; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; import org.springframework.security.core.authority.SimpleGrantedAuthority; class AuthorityUtilsTest { @Test void authoritiesToRolesTest() { var authorities = List.of( new SimpleGrantedAuthority("ROLE_admin"), new SimpleGrantedAuthority("ROLE_owner"), new SimpleGrantedAuthority("ROLE_manager"), new SimpleGrantedAuthority("faker"), new SimpleGrantedAuthority("SCOPE_system:read") ); var roles = authoritiesToRoles(authorities); assertEquals(Set.of("admin", "owner", "manager"), roles); } @Test void containsSuperRoleTest() { assertTrue(containsSuperRole(Set.of("super-role"))); assertTrue(containsSuperRole(Set.of("super-role", "admin"))); assertFalse(containsSuperRole(Set.of("admin"))); } } ================================================ FILE: application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java ================================================ package run.halo.app.security.authorization; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; import static run.halo.app.core.extension.Role.ROLE_AGGREGATE_LABEL_PREFIX; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.lang.NonNull; import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.Role.PolicyRule; import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.infra.AnonymousUserConst; @SpringBootTest @AutoConfigureWebTestClient @DirtiesContext @Import(AuthorizationTest.TestConfig.class) class AuthorizationTest { @Autowired WebTestClient webClient; @MockitoSpyBean ReactiveUserDetailsService userDetailsService; @MockitoSpyBean ReactiveUserDetailsPasswordService userDetailsPasswordService; @MockitoSpyBean RoleService roleService; @Autowired ExtensionClient client; @BeforeEach void setUp() { webClient = webClient.mutateWith(csrf()); } @Test void anonymousUserAccessProtectedApi() { when(userDetailsService.findByUsername(eq(AnonymousUserConst.PRINCIPAL))) .thenReturn(Mono.empty()); webClient.get().uri("/apis/fake.halo.run/v1/posts") .header("X-Requested-With", "XMLHttpRequest") .exchange() .expectStatus().isUnauthorized(); webClient.get().uri("/apis/fake.halo.run/v1/posts") .exchange() .expectStatus().isFound() .expectHeader().location("/login?authentication_required"); verify(roleService, times(2)).listDependenciesFlux(anySet()); } @Test void anonymousUserAccessAuthenticationFreeApi() { when(userDetailsService.findByUsername(eq(AnonymousUserConst.PRINCIPAL))) .thenReturn(Mono.empty()); Role role = new Role(); role.setMetadata(new Metadata()); role.getMetadata().setName(AnonymousUserConst.Role); role.setRules(new ArrayList<>()); PolicyRule policyRule = new PolicyRule.Builder() .apiGroups("fake.halo.run") .verbs("list") .resources("posts") .build(); role.getRules().add(policyRule); when(roleService.listDependenciesFlux(anySet())).thenReturn(Flux.just(role)); webClient.get().uri("/apis/fake.halo.run/v1/posts").exchange().expectStatus() .isOk() .expectBody(String.class).isEqualTo("returned posts"); webClient.get().uri("/apis/fake.halo.run/v1/posts/hello-halo") .header("X-Requested-With", "XMLHttpRequest") .exchange() .expectStatus() .isUnauthorized(); webClient.get().uri("/apis/fake.halo.run/v1/posts/hello-halo") .exchange() .expectStatus() .isFound() .expectHeader().location("/login?authentication_required"); verify(roleService, times(3)).listDependenciesFlux(anySet()); } @Test @WithMockUser(username = "user", roles = "post.read") void authenticatedUserAccessAuthenticationFreeApi() { Role role = new Role(); role.setMetadata(new Metadata()); role.getMetadata().setName(AnonymousUserConst.Role); role.setRules(new ArrayList<>()); PolicyRule policyRule = new PolicyRule.Builder() .apiGroups("fake.halo.run") .verbs("list") .resources("posts") .build(); role.getRules().add(policyRule); when(roleService.listDependenciesFlux(anySet())).thenReturn(Flux.just(role)); webClient.get().uri("/apis/fake.halo.run/v1/posts").exchange().expectStatus() .isOk() .expectBody(String.class).isEqualTo("returned posts"); verify(roleService).listDependenciesFlux(anySet()); } @Test void anonymousUserShouldAccessResourcesByAggregatedRoles() { // create a role var role = new Role(); role.setMetadata(new Metadata()); role.getMetadata().setName("fake-role-with-aggregate-to-anonymous"); role.getMetadata().setLabels(new HashMap<>(Map.of( ROLE_AGGREGATE_LABEL_PREFIX + AnonymousUserConst.Role, "true" ))); role.setRules(new ArrayList<>()); var policyRule = new PolicyRule.Builder() .apiGroups("fake.halo.run") .verbs("list") .resources("fakes") .build(); role.getRules().add(policyRule); client.create(role); webClient.get().uri("/apis/fake.halo.run/v1/fakes").exchange() .expectStatus() .isOk(); } @TestConfiguration static class TestConfig { @Bean public RouterFunction postRoute() { return RouterFunctions.route() .GET("/apis/fake.halo.run/v1/posts", request -> ServerResponse.ok() .contentType(MediaType.TEXT_PLAIN) .bodyValue("returned posts") ) .PUT("/apis/fake.halo.run/v1/posts/{name}", request -> ServerResponse.ok() .contentType(MediaType.TEXT_PLAIN) .bodyValue("updated post " + request.pathVariable("name")) ) .GET("/apis/fake.halo.run/v1/fakes", request -> ServerResponse.ok().build()) .build(); } @NonNull Mono queryPosts(ServerRequest request) { return ServerResponse.ok().contentType(MediaType.TEXT_PLAIN) .bodyValue("returned posts"); } @NonNull Mono updatePost(ServerRequest request) { var name = request.pathVariable("name"); return ServerResponse.ok().contentType(MediaType.TEXT_PLAIN) .bodyValue("updated post " + name); } } } ================================================ FILE: application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java ================================================ package run.halo.app.security.authorization; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.method; import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.authenticated; import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpMethod; import org.springframework.security.core.userdetails.User; import reactor.core.publisher.Flux; import reactor.test.StepVerifier; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.Role.PolicyRule; import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.Metadata; @ExtendWith(MockitoExtension.class) class DefaultRuleResolverTest { @Mock RoleService roleService; @InjectMocks DefaultRuleResolver ruleResolver; @Test void visitRules() { when(roleService.listDependenciesFlux(Set.of("ruleReadPost"))) .thenReturn(Flux.just(mockRole())); var fakeUser = new User("admin", "123456", createAuthorityList("ROLE_ruleReadPost")); var authentication = authenticated(fakeUser, fakeUser.getPassword(), fakeUser.getAuthorities()); var cases = getRequestResolveCases(); cases.forEach(requestResolveCase -> { var httpMethod = HttpMethod.valueOf(requestResolveCase.method); var request = method(httpMethod, requestResolveCase.url).build(); var requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); StepVerifier.create(ruleResolver.visitRules(authentication, requestInfo)) .assertNext( visitor -> assertEquals(requestResolveCase.expected, visitor.isAllowed())) .verifyComplete(); }); verify(roleService, times(cases.size())).listDependenciesFlux(Set.of("ruleReadPost")); } @Test void visitRulesForUserspaceScope() { when(roleService.listDependenciesFlux(Set.of("ruleReadPost"))) .thenReturn(Flux.just(mockRole())); var fakeUser = new User("admin", "123456", createAuthorityList("ROLE_ruleReadPost")); var authentication = authenticated(fakeUser, fakeUser.getPassword(), fakeUser.getAuthorities()); var cases = List.of( new RequestResolveCase("/api/v1/categories", "POST", true), new RequestResolveCase("/api/v1/categories", "DELETE", true), new RequestResolveCase("/api/v1/userspaces/bar/categories", "DELETE", false), new RequestResolveCase("/api/v1/userspaces/admin/categories", "DELETE", true), new RequestResolveCase("/api/v1/posts", "GET", true), new RequestResolveCase("/api/v1/userspaces/foo/posts", "GET", false), new RequestResolveCase("/api/v1/userspaces/admin/posts", "GET", true) ); cases.forEach(requestResolveCase -> { var httpMethod = HttpMethod.valueOf(requestResolveCase.method); var request = method(httpMethod, requestResolveCase.url).build(); var requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); StepVerifier.create(ruleResolver.visitRules(authentication, requestInfo)) .assertNext( visitor -> assertEquals(requestResolveCase.expected, visitor.isAllowed())) .verifyComplete(); }); } Role mockRole() { var role = new Role(); var rules = List.of( new PolicyRule.Builder().apiGroups("").resources("posts").verbs("list", "get").build(), new PolicyRule.Builder().apiGroups("").resources("categories").verbs("*").build(), new PolicyRule.Builder().apiGroups("api.plugin.halo.run") .resources("plugins/users") .resourceNames("foo/bar").verbs("*").build(), new PolicyRule.Builder().apiGroups("api.plugin.halo.run") .resources("plugins/users") .resourceNames("foo").verbs("*").build(), new PolicyRule.Builder().nonResourceURLs("/healthy").verbs("get", "post", "head") .build()); role.setRules(rules); var metadata = new Metadata(); metadata.setName("ruleReadPost"); role.setMetadata(metadata); return role; } List getRequestResolveCases() { return List.of(new RequestResolveCase("/api/v1/tags", "GET", false), new RequestResolveCase("/api/v1/tags/tagName", "GET", false), new RequestResolveCase("/api/v1/categories/aName", "GET", true), new RequestResolveCase("/api/v1//categories", "POST", true), new RequestResolveCase("/api/v1/categories", "DELETE", true), new RequestResolveCase("/api/v1/posts", "GET", true), new RequestResolveCase("/api/v1/posts/aName", "GET", true), new RequestResolveCase("/api/v1/posts", "DELETE", false), new RequestResolveCase("/api/v1/posts/aName", "UPDATE", false), // group resource url new RequestResolveCase("/apis/group/v1/posts", "GET", false), // plugin custom resource url new RequestResolveCase("/apis/api.plugin.halo.run/v1alpha1/plugins/foo/users", "GET", true), new RequestResolveCase("/apis/api.plugin.halo.run/v1alpha1/plugins/foo/users/bar", "GET", true), new RequestResolveCase("/apis/api.plugin.halo.run/v1alpha1/plugins/foo/posts/bar", "GET", false), // non resource url new RequestResolveCase("/healthy", "GET", true), new RequestResolveCase("/healthy", "POST", true), new RequestResolveCase("/healthy", "HEAD", true), new RequestResolveCase("//healthy", "GET", false), new RequestResolveCase("/healthy/name", "GET", false), new RequestResolveCase("/healthy1", "GET", false), new RequestResolveCase("//healthy//name", "GET", false), new RequestResolveCase("/", "GET", false)); } record RequestResolveCase(String url, String method, boolean expected) { } } ================================================ FILE: application/src/test/java/run/halo/app/security/authorization/PolicyRuleTest.java ================================================ package run.halo.app.security.authorization; import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.json.JSONException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import run.halo.app.core.extension.Role; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link Role.PolicyRule}. * * @author guqing * @since 2.0.0 */ class PolicyRuleTest { private ObjectMapper objectMapper; @BeforeEach void setUp() { objectMapper = JsonUtils.DEFAULT_JSON_MAPPER; } @Test public void constructPolicyRule() throws JsonProcessingException, JSONException { Role.PolicyRule policyRule = new Role.PolicyRule(null, null, null, null, null); assertThat(policyRule).isNotNull(); JSONAssert.assertEquals(""" { "apiGroups": [], "resources": [], "resourceNames": [], "nonResourceURLs": [], "verbs": [] } """, JsonUtils.objectToJson(policyRule), true); Role.PolicyRule policyByBuilder = new Role.PolicyRule.Builder().build(); JSONAssert.assertEquals(""" { "apiGroups": [], "resources": [], "resourceNames": [], "nonResourceURLs": [], "verbs": [] } """, JsonUtils.objectToJson(policyByBuilder), true); Role.PolicyRule policyNonNull = new Role.PolicyRule.Builder() .apiGroups("group") .resources("resource-1", "resource-2") .resourceNames("resourceName") .nonResourceURLs("non resource url") .verbs("verbs") .build(); JsonNode expected = objectMapper.readTree(""" { "apiGroups": [ "group" ], "resources": [ "resource-1", "resource-2" ], "resourceNames": [ "resourceName" ], "nonResourceURLs": [ "non resource url" ], "verbs": [ "verbs" ] } """); JsonNode policyNonNullJson = objectMapper.valueToTree(policyNonNull); assertThat(policyNonNullJson).isEqualTo(expected); } } ================================================ FILE: application/src/test/java/run/halo/app/security/authorization/RbacRequestEvaluationTest.java ================================================ package run.halo.app.security.authorization; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import org.junit.jupiter.api.Test; import run.halo.app.core.extension.Role; /** * Tests for {@link RbacRequestEvaluation}. * * @author guqing * @since 2.4.0 */ class RbacRequestEvaluationTest { @Test void resourceNameMatches() { RbacRequestEvaluation rbacRequestEvaluation = new RbacRequestEvaluation(); assertThat(matchResourceName(rbacRequestEvaluation, "", "fake/test")).isTrue(); assertThat(matchResourceName(rbacRequestEvaluation, "", "fake")).isTrue(); assertThat(matchResourceName(rbacRequestEvaluation, "", "")).isTrue(); assertThat(matchResourceName(rbacRequestEvaluation, "*", null)).isTrue(); assertThat(matchResourceName(rbacRequestEvaluation, "*/test", "fake/test")).isTrue(); assertThat(matchResourceName(rbacRequestEvaluation, "*/test", "hello/test")).isTrue(); assertThat(matchResourceName(rbacRequestEvaluation, "*/test", "hello/fake")).isFalse(); assertThat(matchResourceName(rbacRequestEvaluation, "test/*", "hello/fake")).isFalse(); assertThat(matchResourceName(rbacRequestEvaluation, "test/*", "test/fake")).isTrue(); assertThat(matchResourceName(rbacRequestEvaluation, "test/*", "test")).isTrue(); assertThat(matchResourceName(rbacRequestEvaluation, "test/*", "hello")).isFalse(); assertThat(matchResourceName(rbacRequestEvaluation, "*/*", "test/fake")).isTrue(); assertThat(matchResourceName(rbacRequestEvaluation, "*/*", "test")).isTrue(); assertThat(matchResourceName(rbacRequestEvaluation, "*", "test")).isTrue(); assertThat(matchResourceName(rbacRequestEvaluation, "*", "hello")).isTrue(); } boolean matchResourceName(RbacRequestEvaluation rbacRequestEvaluation, String rule, String requestedName) { return rbacRequestEvaluation.resourceNameMatches(new Role.PolicyRule.Builder() .resourceNames(rule) .build(), requestedName); } } ================================================ FILE: application/src/test/java/run/halo/app/security/authorization/RequestInfoResolverTest.java ================================================ package run.halo.app.security.authorization; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.method; import java.util.HashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.http.HttpMethod; /** * Tests for {@link RequestInfoFactory}. * * @author guqing * @see RequestInfo * @since 2.0.0 */ public class RequestInfoResolverTest { @Test void shouldResolveAsWatchRequestWhenRequestIsWebSocket() { var request = method(HttpMethod.GET, "/apis/fake.halo.run/v1alpha1/fakes") .header("Upgrade", "websocket") .header("Connection", "Upgrade") .build(); RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); assertThat(requestInfo).isNotNull(); assertThat(requestInfo.getVerb()).isEqualTo("watch"); } @Test void shouldNotResolveAsWatchRequestWhenRequestIsNotWebSocket() { var request = method(HttpMethod.GET, "/apis/fake.halo.run/v1alpha1/fakes") .header("Upgrade", "websocket") .build(); RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); assertThat(requestInfo).isNotNull(); assertThat(requestInfo.getVerb()).isEqualTo("list"); } @Test public void requestInfoTest() { for (SuccessCase successCase : getTestRequestInfos()) { final var request = method(HttpMethod.valueOf(successCase.method), successCase.url) .build(); RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); assertNotNull(requestInfo, successCase::toString); assertEquals(successCase.expectedVerb, requestInfo.getVerb(), successCase::toString); assertThat(requestInfo.getApiPrefix()).isEqualTo(successCase.expectedAPIPrefix); assertThat(requestInfo.getApiGroup()).isEqualTo(successCase.expectedAPIGroup); assertThat(requestInfo.getApiVersion()).isEqualTo(successCase.expectedAPIVersion); assertThat(requestInfo.getResource()).isEqualTo(successCase.expectedResource); assertThat(requestInfo.getSubresource()).isEqualTo(successCase.expectedSubresource); assertThat(requestInfo.getName()).isEqualTo(successCase.expectedName); assertThat(requestInfo.getParts()).isEqualTo(successCase.expectedParts); } } @Test public void nonApiRequestInfoTest() { Map map = new HashMap<>(); map.put("simple groupless", new NonApiCase("/api/version/resource", true)); map.put("simple group", new NonApiCase("/apis/group/version/resource/name/subresource", true)); map.put("more steps", new NonApiCase("/api/version/resource/name/subresource", true)); map.put("group list", new NonApiCase("/apis/batch/v1/job", true)); map.put("group get", new NonApiCase("/apis/batch/v1/job/foo", true)); map.put("group subresource", new NonApiCase("/apis/batch/v1/job/foo/scale", true)); // bad case map.put("bad root", new NonApiCase("/not-api/version/resource", false)); map.put("group without enough steps", new NonApiCase("/apis/extensions/v1beta1", false)); map.put("group without enough steps 2", new NonApiCase("/apis/extensions/v1beta1/", false)); map.put("not enough steps", new NonApiCase("/api/version", false)); map.put("one step", new NonApiCase("/api", false)); map.put("zero step", new NonApiCase("/", false)); map.put("empty", new NonApiCase("", false)); map.forEach((k, v) -> { var request = method(HttpMethod.GET, v.url).build(); RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); if (requestInfo.isResourceRequest() != v.expected) { throw new RuntimeException( String.format("%s: expected %s, actual %s", k, v.expected, requestInfo.isResourceRequest())); } }); } @Test void pluginsScopedAndPluginManage() { List testCases = List.of( new CustomSuccessCase("DELETE", "/apis/api.plugin.halo.run/v1/plugins/other/posts", "delete", "apis", "api.plugin.halo.run", "v1", "", "plugins", "posts", "", "", new String[] {"plugins", "other", "posts"}), // api group identification new CustomSuccessCase("POST", "/apis/api.plugin.halo.run/v1/plugins/other/posts/foo", "create", "apis", "api.plugin.halo.run", "v1", "", "plugins", "posts", "other", "foo", new String[] {"plugins", "other", "posts", "foo"}), // api version identification new CustomSuccessCase("POST", "/apis/api.plugin.halo.run/v1beta3/plugins/other/posts/bar", "create", "apis", "api.plugin.halo.run", "v1beta3", "", "plugins", "posts", "other", "bar", new String[] {"plugins", "other", "posts", "bar"})); // 以 /apis 开头的 plugins 资源为 core 中管理插件使用的资源 for (CustomSuccessCase successCase : testCases) { var request = method(HttpMethod.valueOf(successCase.method), successCase.url).build(); RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); assertThat(requestInfo).isNotNull(); assertRequestInfoCase(successCase, requestInfo); } List pluginScopedCases = List.of( new CustomSuccessCase("DELETE", "/apis/api.plugin.halo.run/v1/plugins/other/posts", "delete", "apis", "api.plugin.halo.run", "v1", "", "plugins", "posts", "other", "", new String[] {"plugins", "other", "posts"}), // api group identification new CustomSuccessCase("POST", "/apis/api.plugin.halo.run/v1/plugins/other/posts/some-name", "create", "apis", "api.plugin.halo.run", "v1", "other", "plugins", "posts", "other", "some-name", new String[] {"plugins", "other", "posts", "some-name"})); for (CustomSuccessCase pluginScopedCase : pluginScopedCases) { var request = method(HttpMethod.valueOf(pluginScopedCase.method), pluginScopedCase.url).build(); RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); assertThat(requestInfo).isNotNull(); assertRequestInfoCase(pluginScopedCase, requestInfo); } } private void assertRequestInfoCase(CustomSuccessCase pluginScopedCase, RequestInfo requestInfo) { assertThat(requestInfo.getVerb()).isEqualTo(pluginScopedCase.expectedVerb); assertThat(requestInfo.getParts()).isEqualTo(pluginScopedCase.expectedParts); assertThat(requestInfo.getApiGroup()).isEqualTo(pluginScopedCase.expectedAPIGroup); assertThat(requestInfo.getResource()).isEqualTo(pluginScopedCase.expectedResource); assertThat(requestInfo.getSubresource()) .isEqualTo(pluginScopedCase.expectedSubresource()); assertThat(requestInfo.getSubName()) .isEqualTo(pluginScopedCase.expectedSubName()); } @Test public void errorCaseTest() { List errorCases = List.of(new ErrorCases("no resource path", "/"), new ErrorCases("just apiversion", "/api/version/"), new ErrorCases("just prefix, group, version", "/apis/group/version/"), new ErrorCases("apiversion with no resource", "/api/version/"), new ErrorCases("bad prefix", "/badprefix/version/resource"), new ErrorCases("missing api group", "/apis/version/resource")); for (ErrorCases errorCase : errorCases) { var request = method(HttpMethod.GET, errorCase.url).build(); RequestInfo apiRequestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); if (apiRequestInfo.isResourceRequest()) { throw new RuntimeException( String.format("%s: expected non-resource request", errorCase.desc)); } } List postCases = List.of(new ErrorCases("api resource has name and no subresource but post", "/api/version/themes/install"), new ErrorCases("apis resource has name and no subresource but post", "/apis/api.halo.run/v1alpha1/themes/install")); for (ErrorCases errorCase : postCases) { var request = method(HttpMethod.POST, errorCase.url).build(); RequestInfo apiRequestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); if (apiRequestInfo.isResourceRequest()) { throw new RuntimeException( String.format("%s: expected non-resource request", errorCase.desc)); } } } public record NonApiCase(String url, boolean expected) { } public record ErrorCases(String desc, String url) { } public record SuccessCase(String method, String url, String expectedVerb, String expectedAPIPrefix, String expectedAPIGroup, String expectedAPIVersion, String expectedNamespace, String expectedResource, String expectedSubresource, String expectedName, String[] expectedParts) { } public record CustomSuccessCase(String method, String url, String expectedVerb, String expectedAPIPrefix, String expectedAPIGroup, String expectedAPIVersion, String expectedNamespace, String expectedResource, String expectedSubresource, String expectedName, String expectedSubName, String[] expectedParts) { } List getTestRequestInfos() { String namespaceAll = "*"; return List.of( new SuccessCase("GET", "/api/v1/namespaces", "list", "api", "", "v1", "", "namespaces", "", "", new String[] {"namespaces"}), new SuccessCase("GET", "/api/v1/namespaces/other", "get", "api", "", "v1", "other", "namespaces", "", "other", new String[] {"namespaces", "other"}), new SuccessCase("GET", "/api/v1/namespaces/other/posts", "list", "api", "", "v1", "other", "posts", "", "", new String[] {"posts"}), new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo", "get", "api", "", "v1", "other", "posts", "", "foo", new String[] {"posts", "foo"}), new SuccessCase("HEAD", "/api/v1/namespaces/other/posts/foo", "get", "api", "", "v1", "other", "posts", "", "foo", new String[] {"posts", "foo"}), new SuccessCase("GET", "/api/v1/posts", "list", "api", "", "v1", namespaceAll, "posts", "", "", new String[] {"posts"}), new SuccessCase("HEAD", "/api/v1/posts", "list", "api", "", "v1", namespaceAll, "posts", "", "", new String[] {"posts"}), new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo", "get", "api", "", "v1", "other", "posts", "", "foo", new String[] {"posts", "foo"}), new SuccessCase("GET", "/api/v1/namespaces/other/posts", "list", "api", "", "v1", "other", "posts", "", "", new String[] {"posts"}), // special verbs new SuccessCase("GET", "/api/v1/proxy/namespaces/other/posts/foo", "proxy", "api", "", "v1", "other", "posts", "", "foo", new String[] {"posts", "foo"}), new SuccessCase("GET", "/api/v1/proxy/namespaces/other/posts/foo/subpath/not/a/subresource", "proxy", "api", "", "v1", "other", "posts", "", "foo", new String[] {"posts", "foo", "subpath", "not", "a", "subresource"}), new SuccessCase("GET", "/api/v1/watch/posts", "watch", "api", "", "v1", namespaceAll, "posts", "", "", new String[] {"posts"}), new SuccessCase("GET", "/api/v1/posts?watch=true", "watch", "api", "", "v1", namespaceAll, "posts", "", "", new String[] {"posts"}), new SuccessCase("GET", "/api/v1/posts?watch=false", "list", "api", "", "v1", namespaceAll, "posts", "", "", new String[] {"posts"}), new SuccessCase("GET", "/api/v1/watch/namespaces/other/posts", "watch", "api", "", "v1", "other", "posts", "", "", new String[] {"posts"}), new SuccessCase("GET", "/api/v1/namespaces/other/posts?watch=true", "watch", "api", "", "v1", "other", "posts", "", "", new String[] {"posts"}), new SuccessCase("GET", "/api/v1/namespaces/other/posts?watch=false", "list", "api", "", "v1", "other", "posts", "", "", new String[] {"posts"}), // subresource identification new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo/status", "get", "api", "", "v1", "other", "posts", "status", "foo", new String[] {"posts", "foo", "status"}), new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo/proxy/subpath", "get", "api", "", "v1", "other", "posts", "proxy", "foo", new String[] {"posts", "foo", "proxy", "subpath"}), new SuccessCase("PUT", "/api/v1/namespaces/other/finalize", "update", "api", "", "v1", "other", "namespaces", "finalize", "other", new String[] {"namespaces", "other", "finalize"}), new SuccessCase("PUT", "/api/v1/namespaces/other/status", "update", "api", "", "v1", "other", "namespaces", "status", "other", new String[] {"namespaces", "other", "status"}), // verb identification new SuccessCase("PATCH", "/api/v1/namespaces/other/posts/foo", "patch", "api", "", "v1", "other", "posts", "", "foo", new String[] {"posts", "foo"}), new SuccessCase("DELETE", "/api/v1/namespaces/other/posts/foo", "delete", "api", "", "v1", "other", "posts", "", "foo", new String[] {"posts", "foo"}), new SuccessCase("POST", "/api/v1/namespaces/other/posts", "create", "api", "", "v1", "other", "posts", "", "", new String[] {"posts"}), // deletecollection verb identification new SuccessCase("DELETE", "/api/v1/nodes?all=true", "deletecollection", "api", "", "v1", "", "nodes", "", "", new String[] {"nodes"}), new SuccessCase("DELETE", "/api/v1/namespaces?all=false", "delete", "api", "", "v1", "", "namespaces", "", "", new String[] {"namespaces"}), new SuccessCase("DELETE", "/api/v1/namespaces/other/posts?all=true", "deletecollection", "api", "", "v1", "other", "posts", "", "", new String[] {"posts"}), new SuccessCase("DELETE", "/apis/extensions/v1/namespaces/other/posts?all=true", "deletecollection", "apis", "extensions", "v1", "other", "posts", "", "", new String[] {"posts"}), // api group identification new SuccessCase("POST", "/apis/extensions/v1/namespaces/other/posts", "create", "apis", "extensions", "v1", "other", "posts", "", "", new String[] {"posts"}), // api version identification new SuccessCase("POST", "/apis/extensions/v1beta3/namespaces/other/posts", "create", "apis", "extensions", "v1beta3", "other", "posts", "", "", new String[] {"posts"})); } } ================================================ FILE: application/src/test/java/run/halo/app/security/device/DeviceServiceImplTest.java ================================================ package run.halo.app.security.device; import static org.assertj.core.api.Assertions.assertThat; import java.util.stream.Stream; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; /** * Tests for {@link DeviceServiceImpl}. * * @author guqing * @since 2.17.0 */ class DeviceServiceImplTest { static Stream deviceInfoParseTest() { return Stream.of( Arguments.of( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like " + "Gecko) Chrome/126.0.0.0 Safari/537.36", "Mac OS X 10.15.7", "Chrome 126.0" ), Arguments.of( "Mozilla/5.0 (Phone; OpenHarmony 5.0) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/114.0.0.0 Safari/537.36 ArkWeb/4.1.6.1 Mobile HuaweiBrowser/5.0.4" + ".300", "OpenHarmony 5.0", "Chrome 114.0" ) ); } @ParameterizedTest @MethodSource void deviceInfoParseTest(String userAgent, String expectedOs, String expectedBrowser) { var info = DeviceServiceImpl.DeviceInfo.parse(userAgent); assertThat(info.os()).isEqualTo(expectedOs); assertThat(info.browser()).isEqualTo(expectedBrowser); } } ================================================ FILE: application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java ================================================ package run.halo.app.security.jackson2; import static org.junit.jupiter.api.Assertions.assertEquals; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Function; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.core.userdetails.User; import org.springframework.security.jackson2.SecurityJackson2Modules; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.web.authentication.switchuser.SwitchUserGrantedAuthority; import run.halo.app.security.authentication.login.HaloUser; import run.halo.app.security.authentication.oauth2.HaloOAuth2AuthenticationToken; import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; class HaloSecurityJacksonModuleTest { ObjectMapper objectMapper; @BeforeEach void setUp() { this.objectMapper = Jackson2ObjectMapperBuilder.json() .modules(SecurityJackson2Modules.getModules(this.getClass().getClassLoader())) .modules(modules -> modules.add(new HaloSecurityJackson2Module())) .indentOutput(true) .build(); } @Test void codecHaloUserTest() throws JsonProcessingException { codecAssert(haloUser -> UsernamePasswordAuthenticationToken.authenticated(haloUser, haloUser.getPassword(), haloUser.getAuthorities())); } @Test void codecTwoFactorAuthenticationTokenTest() throws JsonProcessingException { codecAssert(haloUser -> { var authentication = UsernamePasswordAuthenticationToken.authenticated(haloUser, haloUser.getPassword(), haloUser.getAuthorities()); return new TwoFactorAuthentication(authentication); }); } @Test void codecHaloOAuth2AuthenticationTokenTest() throws JsonProcessingException { codecAssert(haloUser -> { var oauth2User = new DefaultOAuth2User(List.of(), Map.of("name", "halo"), "name"); var oauth2Token = new OAuth2AuthenticationToken(oauth2User, List.of(), "github"); return new HaloOAuth2AuthenticationToken(haloUser, oauth2Token); }); } @Test void shouldReadSwitchUserGrantedAuthority() throws JsonProcessingException { codecAssert(haloUser -> { var authentication = UsernamePasswordAuthenticationToken.authenticated( haloUser.getUsername(), haloUser.getPassword(), haloUser.getAuthorities() ); var switchUserGrantedAuthority = new SwitchUserGrantedAuthority("ADMIN", authentication); var extendedAuthorities = new ArrayList<>(authentication.getAuthorities()); extendedAuthorities.add(switchUserGrantedAuthority); authentication = UsernamePasswordAuthenticationToken.authenticated( authentication.getPrincipal(), authentication.getCredentials(), extendedAuthorities ); return authentication; }); } void codecAssert(Function authenticationConverter) throws JsonProcessingException { var userDetails = User.withUsername("faker") .password("123456") .authorities("ROLE_USER") .build(); var haloUser = new HaloUser(userDetails, true, "fake-encrypted-secret"); var authentication = authenticationConverter.apply(haloUser); var securityContext = new SecurityContextImpl(authentication); var securityContextJson = objectMapper.writeValueAsString(securityContext); var deserializedSecurityContext = objectMapper.readValue(securityContextJson, SecurityContext.class); assertEquals(deserializedSecurityContext, securityContext); } } ================================================ FILE: application/src/test/java/run/halo/app/security/preauth/SystemSetupEndpointTest.java ================================================ package run.halo.app.security.preauth; import static org.assertj.core.api.Assertions.assertThat; import java.util.Properties; import org.junit.jupiter.api.Test; /** * Tests for {@link SystemSetupEndpoint}. * * @author guqing * @since 2.20.0 */ class SystemSetupEndpointTest { @Test void placeholderTest() { var properties = new Properties(); properties.setProperty("username", "guqing"); properties.setProperty("timestamp", "2024-09-30"); var str = SystemSetupEndpoint.PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders(""" ${username} ${timestamp} """, properties); assertThat(str).isEqualTo(""" guqing 2024-09-30 """); } } ================================================ FILE: application/src/test/java/run/halo/app/security/session/InMemoryReactiveIndexedSessionRepositoryTest.java ================================================ package run.halo.app.security.session; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.session.ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.test.StepVerifier; /** * Tests for {@link InMemoryReactiveIndexedSessionRepository}. * * @author guqing * @since 2.15.0 */ class InMemoryReactiveIndexedSessionRepositoryTest { private InMemoryReactiveIndexedSessionRepository sessionRepository; @BeforeEach void setUp() { sessionRepository = new InMemoryReactiveIndexedSessionRepository(new ConcurrentHashMap<>()); } @Test void principalNameIndexTest() { sessionRepository.createSession() .doOnNext(session -> { session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, "test"); }) .map(session -> sessionRepository.indexResolver.resolveIndexesFor(session)) .as(StepVerifier::create) .consumeNextWith(map -> { assertThat(map).containsEntry( PRINCIPAL_NAME_INDEX_NAME, "test"); }); sessionRepository.findByPrincipalName("test") .as(StepVerifier::create) .expectNextCount(1) .verifyComplete(); sessionRepository.findByIndexNameAndIndexValue( PRINCIPAL_NAME_INDEX_NAME, "test") .as(StepVerifier::create) .expectNextCount(1) .verifyComplete(); } @Test void saveTest() { var indexKey = createSession("fake-session-1", "test"); assertThat(sessionRepository.getSessionIdIndexMap()).hasSize(1); assertThat( sessionRepository.getSessionIdIndexMap().containsValue(Set.of(indexKey))).isTrue(); assertThat(sessionRepository.getIndexSessionIdMap()).hasSize(1); assertThat(sessionRepository.getIndexSessionIdMap().containsKey(indexKey)).isTrue(); assertThat(sessionRepository.getIndexSessionIdMap().get(indexKey)).isEqualTo( Set.of("fake-session-1")); } @Test void saveToUpdateTest() { // same session id will update the index createSession("fake-session-1", "test"); var indexKey2 = createSession("fake-session-1", "test2"); assertThat(sessionRepository.getSessionIdIndexMap()).hasSize(1); assertThat( sessionRepository.getSessionIdIndexMap().containsValue(Set.of(indexKey2))).isTrue(); assertThat(sessionRepository.getIndexSessionIdMap()).hasSize(1); assertThat(sessionRepository.getIndexSessionIdMap().containsKey(indexKey2)).isTrue(); assertThat(sessionRepository.getIndexSessionIdMap().get(indexKey2)).isEqualTo( Set.of("fake-session-1")); } @Test void deleteByIdTest() { createSession("fake-session-2", "test1"); sessionRepository.deleteById("fake-session-2") .as(StepVerifier::create) .verifyComplete(); assertThat(sessionRepository.getSessionIdIndexMap()).isEmpty(); assertThat(sessionRepository.getIndexSessionIdMap()).isEmpty(); } InMemoryReactiveIndexedSessionRepository.IndexKey createSession(String sessionId, String principalName) { var indexKey = new InMemoryReactiveIndexedSessionRepository.IndexKey( PRINCIPAL_NAME_INDEX_NAME, principalName); sessionRepository.createSession() .doOnNext(session -> { session.setAttribute(indexKey.attributeName(), indexKey.attributeValue()); session.setId(sessionId); }) .flatMap(sessionRepository::save) .as(StepVerifier::create) .verifyComplete(); return indexKey; } } ================================================ FILE: application/src/test/java/run/halo/app/security/switchuser/SwitchUserConfigurerTest.java ================================================ package run.halo.app.security.switchuser; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.core.userdetails.User; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; @SpringBootTest @AutoConfigureWebTestClient class SwitchUserConfigurerTest { @Autowired WebTestClient webClient; @MockitoSpyBean ReactiveUserDetailsService userDetailsService; @Test @WithMockUser(username = "admin", roles = "super-role") void shouldSwitchWithSuperRole() { when(userDetailsService.findByUsername("faker")) .thenReturn(Mono.fromSupplier(() -> User.withUsername("faker") .password("password") .roles("user") .build())); var result = webClient.mutateWith(csrf()) .post() .uri("/login/impersonate?username={username}&redirect_uri={redirect_uri}", "faker", "/fake-success" ) .exchange() .expectStatus().isFound() .expectHeader().location("/fake-success") .expectCookie().exists("SESSION") .expectBody().returnResult(); var session = result.getResponseCookies().getFirst("SESSION"); assertNotNull(session); } @Test @WithSwitchUser( username = "admin", roles = {"super-role"}, targetUsername = "faker", targetRoles = {"user"} ) void shouldLogoutSuccessfully() { webClient.mutateWith(csrf()) .post().uri("/logout/impersonate?redirect_uri={redirect_uri}", "/fake-logout-success") .exchange() .expectStatus().isFound() .expectHeader().location("/fake-logout-success"); } @Test @WithMockUser(username = "admin", roles = "non-super-role") void shouldNotSwitchWithoutSuperRole() { webClient.mutateWith(csrf()) .post() .uri("/login/impersonate?username={username}&redirect_uri={redirect_uri}", "faker", "/fake-success" ) .exchange() .expectStatus().isForbidden(); } } ================================================ FILE: application/src/test/java/run/halo/app/security/switchuser/SwitchUserSecurityContextFactory.java ================================================ package run.halo.app.security.switchuser; import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.authenticated; import static org.springframework.security.web.server.authentication.SwitchUserWebFilter.ROLE_PREVIOUS_ADMINISTRATOR; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.test.context.support.WithSecurityContextFactory; import org.springframework.security.web.authentication.switchuser.SwitchUserGrantedAuthority; class SwitchUserSecurityContextFactory implements WithSecurityContextFactory { @Override public SecurityContext createSecurityContext(WithSwitchUser annotation) { var username = annotation.username(); var roles = annotation.roles(); var switchToUsername = annotation.targetUsername(); var switchToRoles = annotation.targetRoles(); var currentAuthentication = authenticated(username, "password", AuthorityUtils.createAuthorityList(roles)); var switchAuthority = new SwitchUserGrantedAuthority(ROLE_PREVIOUS_ADMINISTRATOR, currentAuthentication); var targetAuthorities = AuthorityUtils.createAuthorityList(switchToRoles); targetAuthorities.add(switchAuthority); var authentication = authenticated(switchToUsername, "password", targetAuthorities); return new SecurityContextImpl(authentication); } } ================================================ FILE: application/src/test/java/run/halo/app/security/switchuser/WithSwitchUser.java ================================================ package run.halo.app.security.switchuser; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import org.springframework.security.test.context.support.WithSecurityContext; @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited @Documented @WithSecurityContext(factory = SwitchUserSecurityContextFactory.class) @interface WithSwitchUser { String username(); String targetUsername(); String[] roles() default {}; String[] targetRoles() default {}; } ================================================ FILE: application/src/test/java/run/halo/app/theme/CompositeTemplateResolverTest.java ================================================ package run.halo.app.theme; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.Test; import org.thymeleaf.templateresolver.ITemplateResolver; import org.thymeleaf.templateresolver.TemplateResolution; import run.halo.app.infra.exception.NotFoundException; class CompositeTemplateResolverTest { @Test void shouldGetBlankNameIfNoResolvers() { var resolver = new CompositeTemplateResolver(null); assertEquals("", resolver.getName()); } @Test void shouldGetNameIfResolversProvided() { var resolverA = mock(ITemplateResolver.class); when(resolverA.getName()).thenReturn("A"); var resolverB = mock(ITemplateResolver.class); when(resolverB.getName()).thenReturn("B"); var resolver = new CompositeTemplateResolver(List.of(resolverB, resolverA)); assertEquals("B, A", resolver.getName()); } @Test void shouldGetNullOrder() { var resolver = new CompositeTemplateResolver(null); assertNull(resolver.getOrder()); } @Test void shouldThrowNotFoundExceptionIfNoResolvers() { var resolver = new CompositeTemplateResolver(null); assertThrows( NotFoundException.class, () -> resolver.resolveTemplate(null, null, null, null) ); } @Test void shouldThrowNotFoundExceptionIfAllResolversReturnNull() { var resolverA = mock(ITemplateResolver.class); when(resolverA.resolveTemplate(null, null, null, null)).thenReturn(null); var resolverB = mock(ITemplateResolver.class); when(resolverB.resolveTemplate(null, null, null, null)).thenReturn(null); var resolver = new CompositeTemplateResolver(List.of(resolverA, resolverB)); assertThrows( NotFoundException.class, () -> resolver.resolveTemplate(null, null, null, null) ); } @Test void shouldResolveTemplateIfResolvedByOneOfResolvers() { var resolverA = mock(ITemplateResolver.class); var resolution = mock(TemplateResolution.class); when(resolverA.resolveTemplate(null, null, null, null)) .thenReturn(resolution); var resolverB = mock(ITemplateResolver.class); when(resolverB.resolveTemplate(null, null, null, null)) .thenReturn(null); var resolver = new CompositeTemplateResolver(List.of(resolverA, resolverB)); assertEquals(resolution, resolver.resolveTemplate(null, null, null, null)); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java ================================================ package run.halo.app.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.ApplicationContext; import org.thymeleaf.IEngineConfiguration; import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.Context; import org.thymeleaf.spring6.dialect.SpringStandardDialect; import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; import org.thymeleaf.standard.expression.IStandardVariableExpressionEvaluator; import org.thymeleaf.templateresolver.StringTemplateResolver; import org.thymeleaf.templateresource.ITemplateResource; import org.thymeleaf.templateresource.StringTemplateResource; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.theme.dialect.HaloProcessorDialect; /** * Tests expression parser for reactive return value. * * @author guqing * @see ReactiveSpelVariableExpressionEvaluator * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) public class ReactiveFinderExpressionParserTests { @Mock private ApplicationContext applicationContext; @Mock private ObjectProvider extensionGetterProvider; @Mock private SystemConfigFetcher environmentFetcher; private TemplateEngine templateEngine; @BeforeEach void setUp() { HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect(); templateEngine = new TemplateEngine(); templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect() { @Override public IStandardVariableExpressionEvaluator getVariableExpressionEvaluator() { return new ReactiveSpelVariableExpressionEvaluator(); } })); templateEngine.addTemplateResolver(new TestTemplateResolver()); lenient().when(applicationContext.getBean(eq(SystemConfigFetcher.class))) .thenReturn(environmentFetcher); when(applicationContext.getBeanProvider(ExtensionGetter.class)) .thenReturn(extensionGetterProvider); when(extensionGetterProvider.getIfUnique()).thenReturn(null); lenient().when(environmentFetcher.fetchComment()) .thenReturn(Mono.just(new SystemSetting.Comment())); } @Test void javascriptInlineParser() { Context context = getContext(); context.setVariable("target", new TestReactiveFinder()); context.setVariable("genericMap", Map.of("key", "value")); String result = templateEngine.process("javascriptInline", context); assertThat(result).isEqualTo("""

value

ruibaby

guqing

bar

"""); } static class TestReactiveFinder { public Mono getName() { return Mono.just("guqing"); } public Flux names() { return Flux.just("guqing", "johnniang", "ruibaby"); } public Flux users() { return Flux.just( new TestUser("guqing"), new TestUser("ruibaby"), new TestUser("johnniang") ); } public Flux objectJsonNodeFlux() { ObjectNode objectNode = JsonUtils.DEFAULT_JSON_MAPPER.createObjectNode(); objectNode.put("name", "guqing"); return Flux.just(objectNode); } public Mono> mapMono() { return Mono.just(Map.of("foo", "bar")); } public Mono arrayNodeMono() { ArrayNode arrayNode = JsonUtils.DEFAULT_JSON_MAPPER.createArrayNode(); arrayNode.add(arrayNode.objectNode().put("foo", "bar")); return Mono.just(arrayNode); } } record TestUser(String name) { } private Context getContext() { Context context = new Context(); context.setVariable( ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME, new ThymeleafEvaluationContext(applicationContext, null)); return context; } static class TestTemplateResolver extends StringTemplateResolver { @Override protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration, String ownerTemplate, String template, Map templateResolutionAttributes) { return new StringTemplateResource("""

"""); } } } ================================================ FILE: application/src/test/java/run/halo/app/theme/SiteSettingVariablesAcquirerTest.java ================================================ package run.halo.app.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.github.zafarkhaja.semver.Version; import java.net.MalformedURLException; import java.net.URL; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.theme.finders.vo.SiteSettingVo; /** * Tests for {@link SiteSettingVariablesAcquirer}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) public class SiteSettingVariablesAcquirerTest { @Mock private ExternalUrlSupplier externalUrlSupplier; @Mock private SystemVersionSupplier systemVersionSupplier; @Mock private SystemConfigFetcher environmentFetcher; @InjectMocks private SiteSettingVariablesAcquirer siteSettingVariablesAcquirer; @Test void acquireWhenExternalUrlSet() throws MalformedURLException { var url = new URL("https://halo.run"); when(externalUrlSupplier.getURL(any())).thenReturn(url); when(systemVersionSupplier.get()).thenReturn(Version.parse("0.0.0-alpha.1")); when(environmentFetcher.getConfig()).thenReturn(Mono.just(Map.of())); siteSettingVariablesAcquirer.acquire(mock(ServerWebExchange.class)) .as(StepVerifier::create) .consumeNextWith(result -> { assertThat(result).containsKey("site"); assertThat(result.get("site")).isInstanceOf(SiteSettingVo.class); var site = (SiteSettingVo) result.get("site"); assertThat(site) .extracting(SiteSettingVo::url) .isEqualTo(url); assertThat(site) .extracting(SiteSettingVo::version) .isEqualTo("0.0.0-alpha.1"); }) .verifyComplete(); verify(externalUrlSupplier).getURL(any()); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/ThemeContextTest.java ================================================ package run.halo.app.theme; import java.nio.file.Path; import org.json.JSONException; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link ThemeContext}. * * @author guqing * @since 2.0.0 */ class ThemeContextTest { @Test void constructorBuilderTest() throws JSONException { var path = Path.of("/tmp/themes/testTheme"); var testTheme = ThemeContext.builder() .name("testTheme") .path(path) .active(true) .build(); var got = JsonUtils.objectToJson(testTheme); var expect = String.format(""" { "name": "testTheme", "path": "%s", "active": true } """, path.toUri()); JSONAssert.assertEquals(expect, got, false); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/ThemeIntegrationTest.java ================================================ package run.halo.app.theme; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; import java.util.LinkedHashSet; import java.util.List; import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Menu; import run.halo.app.core.extension.MenuItem; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.infra.InitializationStateGetter; import run.halo.app.infra.utils.HaloUtils; import run.halo.app.security.AfterSecurityWebFilter; import run.halo.app.theme.router.ModelConst; @SpringBootTest @Import(ThemeIntegrationTest.TestConfig.class) @AutoConfigureWebTestClient @DirtiesContext public class ThemeIntegrationTest { @Autowired WebTestClient webClient; @MockitoBean InitializationStateGetter initializationStateGetter; @Autowired ExtensionClient client; @BeforeEach void setUp() { when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true)); // create a menu item var menuItem = new MenuItem(); menuItem.setMetadata(new Metadata()); menuItem.getMetadata().setName("main-menu-home"); menuItem.setSpec(new MenuItem.MenuItemSpec()); menuItem.getSpec().setDisplayName("Home"); menuItem.getSpec().setHref("/"); client.create(menuItem); // create a primary menu var menu = new Menu(); menu.setMetadata(new Metadata()); menu.getMetadata().setName("main-menu"); menu.setSpec(new Menu.Spec()); menu.getSpec().setDisplayName("Mail Menu"); menu.getSpec().setMenuItems(new LinkedHashSet<>(List.of("main-menu-home"))); client.create(menu); } @TestConfiguration static class TestConfig { @Bean RouterFunction noTemplateExistsRoute() { return RouterFunctions.route() .GET( "/no-template-exists", request -> ServerResponse.ok().render("no-template-exists") ) .build(); } @Bean RouterFunction noCacheRoute() { return RouterFunctions.route() .GET( "/should-not-cache", request -> ServerResponse.ok().render("no-template-exists") ) .before(HaloUtils.noCache()) .build(); } @Bean AfterSecurityWebFilter poweredByHaloTemplateEngineCheckFilter() { var matcher = pathMatchers(HttpMethod.GET, "/should-not-cache"); return (exchange, chain) -> chain.filter(exchange) .flatMap(v -> matcher.matches(exchange) .filter(MatchResult::isMatch) .switchIfEmpty(Mono.fromRunnable(() -> { assertNull(exchange.getAttribute(ModelConst.NO_CACHE)); assertTrue(exchange.getRequiredAttribute( ModelConst.POWERED_BY_HALO_TEMPLATE_ENGINE) ); }).then(Mono.empty())) .doOnNext(m -> { assertTrue(exchange.getRequiredAttribute(ModelConst.NO_CACHE)); assertFalse(exchange.getRequiredAttribute( ModelConst.POWERED_BY_HALO_TEMPLATE_ENGINE) ); }) ) .then(); } } @Test void shouldRespondNotFoundIfNoTemplateFound() { webClient.get() .uri("/no-template-exists") .accept(MediaType.TEXT_HTML) .exchange() .expectStatus().isNotFound() .expectBody(String.class) .value(Matchers.containsString("Template no-template-exists was not found")); webClient.get() .uri("/should-not-cache") .accept(MediaType.TEXT_HTML) .exchange() .expectStatus().isNotFound() .expectBody(String.class) .value(Matchers.containsString("Template no-template-exists was not found")); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/ThemeLinkBuilderTest.java ================================================ package run.halo.app.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.lenient; import java.net.URI; import java.net.URISyntaxException; import java.nio.file.Paths; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.infra.ExternalUrlSupplier; /** * Tests for {@link ThemeLinkBuilder}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class ThemeLinkBuilderTest { @Mock private ExternalUrlSupplier externalUrlSupplier; @BeforeEach void setUp() { // Mock external url supplier lenient().when(externalUrlSupplier.get()).thenReturn(URI.create("")); } @Test void processTemplateLinkWithNoActive() { ThemeLinkBuilder themeLinkBuilder = new ThemeLinkBuilder(getTheme(false), externalUrlSupplier); String link = "/post"; String processed = themeLinkBuilder.processLink(null, link); assertThat(processed).isEqualTo("/post?preview-theme=test-theme"); processed = themeLinkBuilder.processLink(null, "/post?foo=bar"); assertThat(processed).isEqualTo("/post?foo=bar&preview-theme=test-theme"); } @Test void processTemplateLinkWithActive() { ThemeLinkBuilder themeLinkBuilder = new ThemeLinkBuilder(getTheme(true), externalUrlSupplier); String link = "/post"; String processed = themeLinkBuilder.processLink(null, link); assertThat(processed).isEqualTo("/post"); } @Test void processAssetsLink() { // activated theme ThemeLinkBuilder themeLinkBuilder = new ThemeLinkBuilder(getTheme(true), externalUrlSupplier); String link = "/assets/css/style.css"; String processed = themeLinkBuilder.processLink(null, link); assertThat(processed).isEqualTo("/themes/test-theme/assets/css/style.css"); // preview theme getTheme(false); link = "/assets/js/main.js"; processed = themeLinkBuilder.processLink(null, link); assertThat(processed).isEqualTo("/themes/test-theme/assets/js/main.js"); } @Test void processNullLink() { ThemeLinkBuilder themeLinkBuilder = new ThemeLinkBuilder(getTheme(false), externalUrlSupplier); String link = null; String processed = themeLinkBuilder.processLink(null, link); assertThat(processed).isEqualTo(null); // empty link link = ""; processed = themeLinkBuilder.processLink(null, link); assertThat(processed).isEqualTo("/?preview-theme=test-theme"); } @Test void processAbsoluteLink() { ThemeLinkBuilder themeLinkBuilder = new ThemeLinkBuilder(getTheme(false), externalUrlSupplier); String link = "https://github.com/halo-dev"; String processed = themeLinkBuilder.processLink(null, link); assertThat(processed).isEqualTo(link); link = "http://example.com"; processed = themeLinkBuilder.processLink(null, link); assertThat(processed).isEqualTo(link); } @Test void linkInSite() throws URISyntaxException { URI uri = new URI(""); // relative link is always in site assertThat(ThemeLinkBuilder.linkInSite(uri, "/post")).isTrue(); // absolute link is not in site assertThat(ThemeLinkBuilder.linkInSite(uri, "https://example.com")).isFalse(); uri = new URI("https://example.com"); // link in externalUrl is in site link assertThat(ThemeLinkBuilder.linkInSite(uri, "http://example.com/hello/world")).isTrue(); // scheme is different but authority is same assertThat(ThemeLinkBuilder.linkInSite(uri, "https://example.com/hello/world")).isTrue(); // scheme is same and authority is different assertThat(ThemeLinkBuilder.linkInSite(uri, "http://halo.run/hello/world")).isFalse(); // scheme is different and authority is different assertThat(ThemeLinkBuilder.linkInSite(uri, "https://halo.run/hello/world")).isFalse(); // port is different uri = new URI("http://localhost:8090"); assertThat(ThemeLinkBuilder.linkInSite(uri, "http://localhost:3000")).isFalse(); } private ThemeContext getTheme(boolean isActive) { return ThemeContext.builder() .name("test-theme") .path(Paths.get("/themes/test-theme")) .active(isActive) .build(); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/ThemeLocaleContextResolverTest.java ================================================ package run.halo.app.theme; import static java.util.Locale.CANADA; import static java.util.Locale.CHINA; import static java.util.Locale.CHINESE; import static java.util.Locale.ENGLISH; import static java.util.Locale.GERMAN; import static java.util.Locale.GERMANY; import static java.util.Locale.JAPAN; import static java.util.Locale.JAPANESE; import static java.util.Locale.KOREA; import static java.util.Locale.UK; import static java.util.Locale.US; import static org.assertj.core.api.Assertions.assertThat; import static run.halo.app.theme.ThemeLocaleContextResolver.LANGUAGE_COOKIE_NAME; import static run.halo.app.theme.ThemeLocaleContextResolver.TIME_ZONE_COOKIE_NAME; import java.util.Arrays; import java.util.Collections; import java.util.Locale; import java.util.TimeZone; import org.junit.jupiter.api.Test; import org.springframework.context.i18n.TimeZoneAwareLocaleContext; import org.springframework.http.HttpCookie; import org.springframework.http.HttpHeaders; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.web.server.ServerWebExchange; /** * Test for {@link ThemeLocaleContextResolver}. * * @author guqing * @since 2.0.0 */ class ThemeLocaleContextResolverTest { private final ThemeLocaleContextResolver resolver = new ThemeLocaleContextResolver(); @Test public void resolveTimeZone() { TimeZoneAwareLocaleContext localeContext = (TimeZoneAwareLocaleContext) this.resolver.resolveLocaleContext( exchangeTimeZone(CHINA)); assertThat(localeContext.getTimeZone()).isNotNull(); assertThat(localeContext.getTimeZone()) .isEqualTo(TimeZone.getTimeZone("America/Adak")); assertThat(localeContext.getLocale()).isNotNull(); assertThat(localeContext.getLocale().getLanguage()).isEqualTo("en"); } @Test public void resolve() { assertThat(this.resolver.resolveLocaleContext(exchange(CANADA)).getLocale()) .isEqualTo(CANADA); assertThat(this.resolver.resolveLocaleContext(exchange(US, CANADA)).getLocale()) .isEqualTo(US); } @Test public void resolveFromParam() { assertThat(this.resolver.resolveLocaleContext(exchangeForParam("en")).getLocale()) .isEqualTo(ENGLISH); assertThat(this.resolver.resolveLocaleContext(exchangeForParam("zh")).getLocale()) .isEqualTo(CHINESE); assertThat(this.resolver.resolveLocaleContext(exchangeForParam("zh-CN")).getLocale()) .isEqualTo(CHINA); assertThat(this.resolver.resolveLocaleContext(exchangeForParam("zh-cn")).getLocale()) .isEqualTo(CHINA); } @Test public void resolvePreferredSupported() { this.resolver.setSupportedLocales(Collections.singletonList(CANADA)); assertThat(this.resolver.resolveLocaleContext(exchange(US, CANADA)).getLocale()).isEqualTo( CANADA); } @Test public void resolvePreferredNotSupported() { this.resolver.setSupportedLocales(Collections.singletonList(CANADA)); assertThat(this.resolver.resolveLocaleContext(exchange(US, UK)).getLocale()).isEqualTo(US); } @Test public void resolvePreferredNotSupportedWithDefault() { this.resolver.setSupportedLocales(Arrays.asList(US, JAPAN)); this.resolver.setDefaultLocale(JAPAN); assertThat(this.resolver.resolveLocaleContext(exchange(KOREA)).getLocale()).isEqualTo( JAPAN); } @Test public void resolvePreferredAgainstLanguageOnly() { this.resolver.setSupportedLocales(Collections.singletonList(ENGLISH)); assertThat( this.resolver.resolveLocaleContext(exchange(GERMANY, US, UK)).getLocale()).isEqualTo( ENGLISH); } @Test public void resolvePreferredAgainstCountryIfPossible() { this.resolver.setSupportedLocales(Arrays.asList(ENGLISH, UK)); assertThat( this.resolver.resolveLocaleContext(exchange(GERMANY, US, UK)).getLocale()).isEqualTo( UK); } @Test public void resolvePreferredAgainstLanguageWithMultipleSupportedLocales() { this.resolver.setSupportedLocales(Arrays.asList(GERMAN, US)); assertThat( this.resolver.resolveLocaleContext(exchange(GERMANY, US, UK)).getLocale()).isEqualTo( GERMAN); } @Test public void resolveMissingAcceptLanguageHeader() { MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); MockServerWebExchange exchange = MockServerWebExchange.from(request); assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isNull(); } @Test public void resolveMissingAcceptLanguageHeaderWithDefault() { this.resolver.setDefaultLocale(US); MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); MockServerWebExchange exchange = MockServerWebExchange.from(request); assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(US); } @Test public void resolveEmptyAcceptLanguageHeader() { MockServerHttpRequest request = MockServerHttpRequest.get("/").header(HttpHeaders.ACCEPT_LANGUAGE, "").build(); MockServerWebExchange exchange = MockServerWebExchange.from(request); assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isNull(); } @Test public void resolveEmptyAcceptLanguageHeaderWithDefault() { this.resolver.setDefaultLocale(US); MockServerHttpRequest request = MockServerHttpRequest.get("/").header(HttpHeaders.ACCEPT_LANGUAGE, "").build(); MockServerWebExchange exchange = MockServerWebExchange.from(request); assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(US); } @Test public void resolveInvalidAcceptLanguageHeader() { MockServerHttpRequest request = MockServerHttpRequest.get("/").header(HttpHeaders.ACCEPT_LANGUAGE, "en_US").build(); MockServerWebExchange exchange = MockServerWebExchange.from(request); assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isNull(); } @Test public void resolveInvalidAcceptLanguageHeaderWithDefault() { this.resolver.setDefaultLocale(US); MockServerHttpRequest request = MockServerHttpRequest.get("/").header(HttpHeaders.ACCEPT_LANGUAGE, "en_US").build(); MockServerWebExchange exchange = MockServerWebExchange.from(request); assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(US); } @Test public void defaultLocale() { this.resolver.setDefaultLocale(JAPANESE); MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); MockServerWebExchange exchange = MockServerWebExchange.from(request); assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(JAPANESE); request = MockServerHttpRequest.get("/").acceptLanguageAsLocales(US).build(); exchange = MockServerWebExchange.from(request); assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(US); } @Test void resolveUnderminedLocale() { var request = MockServerHttpRequest.get("/") .header(HttpHeaders.ACCEPT_LANGUAGE, "und") .build(); var exchange = MockServerWebExchange.from(request); assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isNull(); } private ServerWebExchange exchange(Locale... locales) { return MockServerWebExchange.from( MockServerHttpRequest.get("").acceptLanguageAsLocales(locales)); } private ServerWebExchange exchangeTimeZone(Locale... locales) { return MockServerWebExchange.from( MockServerHttpRequest.get("").acceptLanguageAsLocales(locales) .cookie(new HttpCookie(TIME_ZONE_COOKIE_NAME, "America/Adak")) .cookie(new HttpCookie(LANGUAGE_COOKIE_NAME, "en"))); } private ServerWebExchange exchangeForParam(String language) { return MockServerWebExchange.from( MockServerHttpRequest.get("/index?language=" + language)); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/ViewNameResolverTest.java ================================================ package run.halo.app.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.when; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.boot.thymeleaf.autoconfigure.ThymeleafProperties; import org.springframework.http.HttpMethod; import org.springframework.mock.web.reactive.function.server.MockServerRequest; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; /** * Tests for {@link DefaultViewNameResolver}. * * @author guqing * @since 2.0.0 */ @ExtendWith({SpringExtension.class, MockitoExtension.class}) class ViewNameResolverTest { @Mock ThemeResolver themeResolver; @Mock ThymeleafProperties thymeleafProperties; @InjectMocks DefaultViewNameResolver viewNameResolver; @TempDir Path themePath; @BeforeEach void setUp() { when(thymeleafProperties.getSuffix()).thenReturn(ThymeleafProperties.DEFAULT_SUFFIX); } @Test void resolveViewNameOrDefault() throws URISyntaxException, IOException { var templatesPath = themePath.resolve("templates"); if (!Files.exists(templatesPath)) { Files.createDirectory(templatesPath); } Files.createFile(templatesPath.resolve("post_news.html")); Files.createFile(templatesPath.resolve("post_docs.html")); var exchange = Mockito.mock(ServerWebExchange.class); when(themeResolver.getTheme(exchange)) .thenReturn(Mono.fromSupplier(() -> ThemeContext.builder() .name("fake-theme") .path(themePath) .active(true) .build()) ); MockServerRequest request = MockServerRequest.builder() .uri(new URI("/")).method(HttpMethod.GET) .exchange(exchange) .build(); viewNameResolver.resolveViewNameOrDefault(request, "post_news", "post") .as(StepVerifier::create) .expectNext("post_news") .verifyComplete(); // post_docs.html String viewName = "post_docs" + thymeleafProperties.getSuffix(); viewNameResolver.resolveViewNameOrDefault(request, viewName, "post") .as(StepVerifier::create) .expectNext(viewName) .verifyComplete(); viewNameResolver.resolveViewNameOrDefault(request, "post_nothing", "post") .as(StepVerifier::create) .expectNext("post") .verifyComplete(); } @Test void processName() { var suffix = thymeleafProperties.getSuffix(); assertThat(viewNameResolver.computeResourceName("post_news")) .isEqualTo("post_news" + suffix); assertThat( viewNameResolver.computeResourceName("post_news" + suffix)) .isEqualTo("post_news" + suffix); assertThat(viewNameResolver.computeResourceName("post_news.test")) .isEqualTo("post_news.test" + suffix); assertThatThrownBy(() -> viewNameResolver.computeResourceName(null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Name must not be null"); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java ================================================ package run.halo.app.theme.dialect; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.ApplicationContext; import org.thymeleaf.IEngineConfiguration; import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.Context; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IProcessableElementTag; import org.thymeleaf.processor.element.IElementTagStructureHandler; import org.thymeleaf.spring6.dialect.SpringStandardDialect; import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; import org.thymeleaf.templateresolver.StringTemplateResolver; import org.thymeleaf.templateresource.ITemplateResource; import org.thymeleaf.templateresource.StringTemplateResource; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** * Tests for {@link CommentElementTagProcessor}. * * @author guqing * @see ExtensionComponentsFinder * @see HaloProcessorDialect * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class CommentElementTagProcessorTest { @Mock private ApplicationContext applicationContext; @Mock private ExtensionGetter extensionGetter; @Mock private ObjectProvider extensionGetterProvider; @Mock private SystemConfigFetcher environmentFetcher; private TemplateEngine templateEngine; @BeforeEach void setUp() { HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect(); templateEngine = new TemplateEngine(); templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect())); templateEngine.addTemplateResolver(new TestTemplateResolver()); lenient().when(applicationContext.getBean(eq(ExtensionGetter.class))) .thenReturn(extensionGetter); when(applicationContext.getBeanProvider(ExtensionGetter.class)) .thenReturn(extensionGetterProvider); when(extensionGetterProvider.getIfUnique()).thenReturn(null); } @Test void doProcess() { Context context = getContext(); when(applicationContext.getBean(eq(SystemConfigFetcher.class))) .thenReturn(environmentFetcher); var commentSetting = mock(SystemSetting.Comment.class); when(environmentFetcher.fetchComment()) .thenReturn(Mono.just(commentSetting)); when(commentSetting.getEnable()).thenReturn(true); when(extensionGetter.getEnabledExtensions(eq(CommentWidget.class))) .thenReturn(Flux.empty()); String result = templateEngine.process("commentWidget", context); assertThat(result).isEqualTo("""

comment widget:

\s """); when(extensionGetter.getEnabledExtensions(eq(CommentWidget.class))) .thenReturn(Flux.just(new DefaultCommentWidget())); result = templateEngine.process("commentWidget", context); assertThat(result).isEqualTo("""

comment widget:

Comment in default widget

"""); } static class DefaultCommentWidget implements CommentWidget { @Override public void render(ITemplateContext context, IProcessableElementTag tag, IElementTagStructureHandler structureHandler) { structureHandler.replaceWith("

Comment in default widget

", false); } } private Context getContext() { Context context = new Context(); context.setVariable( ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME, new ThymeleafEvaluationContext(applicationContext, null)); return context; } static class TestTemplateResolver extends StringTemplateResolver { @Override protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration, String ownerTemplate, String template, Map templateResolutionAttributes) { if (template.equals("commentWidget")) { return new StringTemplateResource(commentWidget()); } return null; } private String commentWidget() { return """

comment widget:

"""; } } } ================================================ FILE: application/src/test/java/run/halo/app/theme/dialect/CommentEnabledVariableProcessorTest.java ================================================ package run.halo.app.theme.dialect; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationContext; import org.thymeleaf.context.WebEngineContext; import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; import org.thymeleaf.web.IWebExchange; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** * Tests for {@link CommentEnabledVariableProcessor}. * * @author guqing * @since 2.9.0 */ @ExtendWith(MockitoExtension.class) class CommentEnabledVariableProcessorTest { @Mock private ApplicationContext applicationContext; @Mock private ExtensionGetter extensionGetter; @Mock private SystemConfigFetcher environmentFetcher; @BeforeEach void setUp() { lenient().when(applicationContext.getBean(eq(ExtensionGetter.class))) .thenReturn(extensionGetter); } @Test void getCommentWidget() { when(applicationContext.getBean(eq(SystemConfigFetcher.class))) .thenReturn(environmentFetcher); SystemSetting.Comment commentSetting = mock(SystemSetting.Comment.class); when(environmentFetcher.fetchComment()) .thenReturn(Mono.just(commentSetting)); CommentWidget commentWidget = mock(CommentWidget.class); when(extensionGetter.getEnabledExtensions(CommentWidget.class)) .thenReturn(Flux.just(commentWidget)); WebEngineContext webContext = mock(WebEngineContext.class); var evaluationContext = mock(ThymeleafEvaluationContext.class); when(webContext.getVariable( eq(ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME))) .thenReturn(evaluationContext); when(evaluationContext.getApplicationContext()).thenReturn(applicationContext); IWebExchange webExchange = mock(IWebExchange.class); when(webContext.getExchange()).thenReturn(webExchange); // comment disabled when(commentSetting.getEnable()).thenReturn(true); assertThat( CommentEnabledVariableProcessor.getCommentWidget(webContext).isPresent()).isTrue(); // comment enabled when(commentSetting.getEnable()).thenReturn(false); assertThat( CommentEnabledVariableProcessor.getCommentWidget(webContext).isPresent()).isFalse(); // comment enabled and ENABLE_COMMENT_ATTRIBUTE is true when(commentSetting.getEnable()).thenReturn(true); when(webExchange.getAttributeValue(CommentWidget.ENABLE_COMMENT_ATTRIBUTE)) .thenReturn(true); assertThat( CommentEnabledVariableProcessor.getCommentWidget(webContext).isPresent()).isTrue(); // comment enabled and ENABLE_COMMENT_ATTRIBUTE is false when(commentSetting.getEnable()).thenReturn(true); when(webExchange.getAttributeValue(CommentWidget.ENABLE_COMMENT_ATTRIBUTE)) .thenReturn(false); assertThat( CommentEnabledVariableProcessor.getCommentWidget(webContext).isPresent()).isFalse(); // comment enabled and ENABLE_COMMENT_ATTRIBUTE is null when(commentSetting.getEnable()).thenReturn(true); when(webExchange.getAttributeValue(CommentWidget.ENABLE_COMMENT_ATTRIBUTE)) .thenReturn(null); assertThat( CommentEnabledVariableProcessor.getCommentWidget(webContext).isPresent()).isTrue(); // comment enabled and ENABLE_COMMENT_ATTRIBUTE is 'false' when(commentSetting.getEnable()).thenReturn(true); when(webExchange.getAttributeValue(CommentWidget.ENABLE_COMMENT_ATTRIBUTE)) .thenReturn("false"); assertThat( CommentEnabledVariableProcessor.getCommentWidget(webContext).isPresent()).isFalse(); } @Test void populateAllowCommentAttribute() { WebEngineContext webContext = mock(WebEngineContext.class); IWebExchange webExchange = mock(IWebExchange.class); when(webContext.getExchange()).thenReturn(webExchange); CommentEnabledVariableProcessor.populateAllowCommentAttribute(webContext, true); verify(webExchange).setAttributeValue( eq(CommentEnabledVariableProcessor.COMMENT_ENABLED_MODEL_ATTRIBUTE), eq(true)); CommentEnabledVariableProcessor.populateAllowCommentAttribute(webContext, false); verify(webExchange).setAttributeValue( eq(CommentEnabledVariableProcessor.COMMENT_ENABLED_MODEL_ATTRIBUTE), eq(false)); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorIntegrationTest.java ================================================ package run.halo.app.theme.dialect; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.ApplicationContext; import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.thymeleaf.IEngineConfiguration; import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.Context; import org.thymeleaf.spring6.dialect.SpringStandardDialect; import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; import org.thymeleaf.templateresolver.StringTemplateResolver; import org.thymeleaf.templateresource.ITemplateResource; import org.thymeleaf.templateresource.StringTemplateResource; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.Metadata; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.SinglePageFinder; import run.halo.app.theme.finders.vo.PostVo; import run.halo.app.theme.router.ModelConst; /** * Integration tests for {@link ContentTemplateHeadProcessor}. * * @author guqing * @see HaloProcessorDialect * @see GlobalHeadInjectionProcessor * @see ContentTemplateHeadProcessor * @see TemplateHeadProcessor * @see TemplateGlobalHeadProcessor * @see TemplateFooterElementTagProcessor * @since 2.7.0 */ @ExtendWith(MockitoExtension.class) class ContentTemplateHeadProcessorIntegrationTest { @Mock private ApplicationContext applicationContext; @Mock private PostFinder postFinder; @Mock private SinglePageFinder singlePageFinder; @Mock private SystemConfigFetcher fetcher; @Mock ExtensionGetter extensionGetter; private TemplateEngine templateEngine; @BeforeEach void setUp() { HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect(); templateEngine = new TemplateEngine(); templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect())); templateEngine.addTemplateResolver(new TestTemplateResolver()); Map map = new HashMap<>(); map.put("postTemplateHeadProcessor", new ContentTemplateHeadProcessor(postFinder, singlePageFinder)); map.put("templateGlobalHeadProcessor", new TemplateGlobalHeadProcessor(fetcher)); map.put("seoProcessor", new GlobalSeoProcessor(fetcher)); map.put("duplicateMetaTagProcessor", new DuplicateMetaTagProcessor()); lenient().when(applicationContext.getBeansOfType(eq(TemplateHeadProcessor.class))) .thenReturn(map); SystemSetting.Seo seo = new SystemSetting.Seo(); seo.setKeywords("global keywords"); seo.setDescription("global description"); lenient().when(fetcher.fetch(eq(SystemSetting.Seo.GROUP), eq(SystemSetting.Seo.class))) .thenReturn(Mono.just(seo)); SystemSetting.CodeInjection codeInjection = new SystemSetting.CodeInjection(); codeInjection.setGlobalHead( ""); codeInjection.setContentHead( ""); lenient().when(fetcher.fetch(eq(SystemSetting.CodeInjection.GROUP), eq(SystemSetting.CodeInjection.class))).thenReturn(Mono.just(codeInjection)); lenient().when(fetcher.fetch(eq(SystemSetting.Seo.GROUP), eq(SystemSetting.Seo.class))) .thenReturn(Mono.empty()); lenient().when(applicationContext.getBeanProvider(ExtensionGetter.class)) .thenAnswer(invocation -> { var objectProvider = mock(ObjectProvider.class); when(objectProvider.getIfUnique()).thenReturn(extensionGetter); return objectProvider; }); lenient().when(extensionGetter.getExtensions(TemplateHeadProcessor.class)).thenReturn( Flux.fromIterable(map.values()).sort(AnnotationAwareOrderComparator.INSTANCE) ); lenient().when(applicationContext.getBean(eq(SystemConfigFetcher.class))) .thenReturn(fetcher); lenient().when(fetcher.fetchComment()).thenReturn(Mono.just(new SystemSetting.Comment())); } @Test void overrideGlobalMetaTest() { Context context = getContext(); context.setVariable("name", "fake-post"); // template id flag is used by TemplateGlobalHeadProcessor context.setVariable(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.POST.getValue()); List> htmlMetas = new ArrayList<>(); htmlMetas.add(mutableMetaMap("keyword", "postK1,postK2")); htmlMetas.add(mutableMetaMap("description", "post-description")); htmlMetas.add(mutableMetaMap("other", "post-other-meta")); Post.PostSpec postSpec = new Post.PostSpec(); postSpec.setHtmlMetas(htmlMetas); Metadata metadata = new Metadata(); metadata.setName("fake-post"); PostVo postVo = PostVo.builder().spec(postSpec).metadata(metadata).build(); when(postFinder.getByName(eq("fake-post"))).thenReturn(Mono.just(postVo)); String result = templateEngine.process("post", context); /* this test case shows: 1. global seo meta keywords and description is overridden by content head meta 2. global head meta is overridden by content head meta 3. but global head meta is not overridden by global seo meta */ var outputSettings = new Document.OutputSettings().prettyPrint(true); var actual = Jsoup.parse(result).outputSettings(outputSettings).html(); var expected = Jsoup.parse(""" Post detail this is body """ ).outputSettings(outputSettings).html(); assertThat(actual).isEqualTo(expected); } Map mutableMetaMap(String nameValue, String contentValue) { Map map = new HashMap<>(); map.put("name", nameValue); map.put("content", contentValue); return map; } private Context getContext() { Context context = new Context(); context.setVariable( ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME, new ThymeleafEvaluationContext(applicationContext, null)); return context; } static class TestTemplateResolver extends StringTemplateResolver { @Override protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration, String ownerTemplate, String template, Map templateResolutionAttributes) { if (template.equals("post")) { return new StringTemplateResource(postTemplate()); } return null; } private String postTemplate() { return """ Post detail this is body """; } } } ================================================ FILE: application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorTest.java ================================================ package run.halo.app.theme.dialect; import static org.assertj.core.api.Assertions.assertThat; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; /** * Tests for {@link ContentTemplateHeadProcessor}. * * @author guqing * @since 2.5.0 */ class ContentTemplateHeadProcessorTest { @Nested class ExcerptToMetaDescriptionTest { @Test void toMetaWhenExcerptIsNull() { List> htmlMetas = new ArrayList<>(); htmlMetas.add(createMetaMap("keywords", "test")); var result = ContentTemplateHeadProcessor.excerptToMetaDescriptionIfAbsent(htmlMetas, null); assertThat(result).hasSize(2); assertThat(result.get(0)).containsEntry("name", "keywords"); assertThat(result.get(1)).containsEntry("name", "description") .containsEntry("content", ""); } @Test void toMetaWhenWhenHtmlMetaIsNull() { var result = ContentTemplateHeadProcessor.excerptToMetaDescriptionIfAbsent(null, null); assertThat(result).hasSize(1); assertThat(result.get(0)).containsEntry("name", "description") .containsEntry("content", ""); } @Test void toMetaWhenWhenExcerptNotEmpty() { List> htmlMetas = new ArrayList<>(); htmlMetas.add(createMetaMap("keywords", "test")); var result = ContentTemplateHeadProcessor.excerptToMetaDescriptionIfAbsent(htmlMetas, "test excerpt"); assertThat(result).hasSize(2); assertThat(result.get(0)).containsEntry("name", "keywords"); assertThat(result.get(1)).containsEntry("name", "description") .containsEntry("content", "test excerpt"); } @Test void toMetaWhenWhenDescriptionExistsAndEmpty() { List> htmlMetas = new ArrayList<>(); htmlMetas.add(createMetaMap("keywords", "test")); htmlMetas.add(createMetaMap("description", "")); var result = ContentTemplateHeadProcessor.excerptToMetaDescriptionIfAbsent(htmlMetas, "test excerpt"); assertThat(result).hasSize(2); assertThat(result.get(0)).containsEntry("name", "keywords"); assertThat(result.get(1)).containsEntry("name", "description") .containsEntry("content", "test excerpt"); } @Test void toMetaWhenWhenDescriptionExistsAndNotEmpty() { List> htmlMetas = new ArrayList<>(); htmlMetas.add(createMetaMap("keywords", "test")); htmlMetas.add(createMetaMap("description", "test description")); var result = ContentTemplateHeadProcessor.excerptToMetaDescriptionIfAbsent(htmlMetas, "test excerpt"); assertThat(result).hasSize(2); assertThat(result.get(0)).containsEntry("name", "keywords"); assertThat(result.get(1)).containsEntry("name", "description") .containsEntry("content", "test description"); } Map createMetaMap(String nameValue, String contentValue) { Map metaMap = new HashMap<>(); metaMap.put("name", nameValue); metaMap.put("content", contentValue); return metaMap; } } } ================================================ FILE: application/src/test/java/run/halo/app/theme/dialect/DuplicateMetaTagProcessorTest.java ================================================ package run.halo.app.theme.dialect; import static org.assertj.core.api.Assertions.assertThat; import java.util.regex.Matcher; import org.junit.jupiter.api.Test; /** * Tests for {@link DuplicateMetaTagProcessor}. * * @author guqing * @since 2.8.0 */ class DuplicateMetaTagProcessorTest { @Test void extractMetaTag() { // normal String text = ""; Matcher matcher = DuplicateMetaTagProcessor.META_PATTERN.matcher(text); assertThat(matcher.find()).isTrue(); assertThat(matcher.group(1)).isEqualTo("description"); // name and content are not in the general order text = ""; matcher = DuplicateMetaTagProcessor.META_PATTERN.matcher(text); assertThat(matcher.find()).isTrue(); assertThat(matcher.group(1)).isEqualTo("keywords"); // no closing slash text = ""; matcher = DuplicateMetaTagProcessor.META_PATTERN.matcher(text); assertThat(matcher.find()).isTrue(); assertThat(matcher.group(1)).isEqualTo("keywords"); // multiple line breaks and other stuff text = """ """; matcher = DuplicateMetaTagProcessor.META_PATTERN.matcher(text); assertThat(matcher.find()).isTrue(); assertThat(matcher.group(1)).isEqualTo("description"); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/dialect/GeneratorMetaProcessorTest.java ================================================ package run.halo.app.theme.dialect; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import java.io.FileNotFoundException; import java.net.URISyntaxException; import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.util.ResourceUtils; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.infra.InitializationStateGetter; import run.halo.app.theme.ThemeContext; import run.halo.app.theme.ThemeResolver; @SpringBootTest @AutoConfigureWebTestClient class GeneratorMetaProcessorTest { @Autowired WebTestClient webClient; @MockitoBean InitializationStateGetter initializationStateGetter; @MockitoBean ThemeResolver themeResolver; @BeforeEach void setUp() throws FileNotFoundException, URISyntaxException { when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true)); var themeContext = ThemeContext.builder() .name("default") .path(Path.of(ResourceUtils.getURL("classpath:themes/default").toURI())) .active(true) .build(); when(themeResolver.getTheme(any(ServerWebExchange.class))) .thenReturn(Mono.just(themeContext)); } @Test void requestIndexPage() { webClient.get().uri("/") .exchange() .expectStatus().isOk() .expectBody() .consumeWith(System.out::println) .xpath("/html/head/meta[@name=\"generator\"][starts-with(@content, \"Halo \")]") .exists(); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/dialect/HaloPostTemplateHandlerTest.java ================================================ package run.halo.app.theme.dialect; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.thymeleaf.spring6.expression.ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME; import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.ApplicationContext; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.engine.ITemplateHandler; import org.thymeleaf.model.IOpenElementTag; import org.thymeleaf.model.IStandaloneElementTag; import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; import reactor.core.publisher.Mono; import run.halo.app.plugin.extensionpoint.ExtensionGetter; @ExtendWith(MockitoExtension.class) class HaloPostTemplateHandlerTest { HaloPostTemplateHandler postHandler; @Mock ITemplateContext templateContext; @Mock ITemplateHandler next; @Mock ApplicationContext applicationContext; @Mock IStandaloneElementTag standaloneElementTag; @Mock IOpenElementTag openElementTag; @Mock ObjectProvider extensionGetterProvider; @Mock ExtensionGetter extensionGetter; @BeforeEach void setUp() { postHandler = new HaloPostTemplateHandler(); var evaluationContext = mock(ThymeleafEvaluationContext.class); when(evaluationContext.getApplicationContext()).thenReturn(applicationContext); when(templateContext.getVariable(THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME)) .thenReturn(evaluationContext); when(applicationContext.getBeanProvider(ExtensionGetter.class)) .thenReturn(extensionGetterProvider); when(extensionGetterProvider.getIfUnique()).thenReturn(extensionGetter); } @ParameterizedTest @MethodSource("provideEmptyElementTagProcessors") void shouldHandleStandaloneElementIfNoElementTagProcessors( List processors ) { when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) .thenReturn(processors); postHandler.setContext(templateContext); postHandler.setNext(next); postHandler.handleStandaloneElement(standaloneElementTag); verify(next).handleStandaloneElement(standaloneElementTag); } @Test void shouldHandleStandaloneElementIfOneElementTagProcessorProvided() { var processor = mock(ElementTagPostProcessor.class); var newTag = mock(IStandaloneElementTag.class); when(processor.process(SecureTemplateContextWrapper.wrap(templateContext), standaloneElementTag)) .thenReturn(Mono.just(newTag)); when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) .thenReturn(List.of(processor)); postHandler.setContext(templateContext); postHandler.setNext(next); postHandler.handleStandaloneElement(standaloneElementTag); verify(next).handleStandaloneElement(newTag); } @Test void shouldHandleStandaloneElementIfTagTypeChanged() { var processor = mock(ElementTagPostProcessor.class); var newTag = mock(IStandaloneElementTag.class); when(processor.process(SecureTemplateContextWrapper.wrap(templateContext), standaloneElementTag)) .thenReturn(Mono.just(newTag)); when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) .thenReturn(List.of(processor)); postHandler.setContext(templateContext); postHandler.setNext(next); postHandler.handleStandaloneElement(standaloneElementTag); verify(next).handleStandaloneElement(newTag); } @Test void shouldHandleStandaloneElementIfMoreElementTagProcessorsProvided() { var processor1 = mock(ElementTagPostProcessor.class); var processor2 = mock(ElementTagPostProcessor.class); var newTag1 = mock(IStandaloneElementTag.class); var newTag2 = mock(IStandaloneElementTag.class); when(processor1.process(SecureTemplateContextWrapper.wrap(templateContext), standaloneElementTag)) .thenReturn(Mono.just(newTag1)); when(processor2.process(SecureTemplateContextWrapper.wrap(templateContext), newTag1)) .thenReturn(Mono.just(newTag2)); when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) .thenReturn(List.of(processor1, processor2)); postHandler.setContext(templateContext); postHandler.setNext(next); postHandler.handleStandaloneElement(standaloneElementTag); verify(next).handleStandaloneElement(newTag2); } @Test void shouldNotHandleIfProcessedTagTypeChanged() { var processor = mock(ElementTagPostProcessor.class); var newTag = mock(IOpenElementTag.class); when(processor.process(SecureTemplateContextWrapper.wrap(templateContext), standaloneElementTag)) .thenReturn(Mono.just(newTag)); when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) .thenReturn(List.of(processor)); postHandler.setContext(templateContext); postHandler.setNext(next); assertThrows(ClassCastException.class, () -> postHandler.handleStandaloneElement(standaloneElementTag) ); } static Stream> provideEmptyElementTagProcessors() { return Stream.of( null, List.of() ); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java ================================================ package run.halo.app.theme.dialect; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import com.google.common.collect.ImmutableSortedMap; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.ApplicationContext; import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.thymeleaf.IEngineConfiguration; import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.Context; import org.thymeleaf.spring6.dialect.SpringStandardDialect; import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; import org.thymeleaf.templateresolver.StringTemplateResolver; import org.thymeleaf.templateresource.ITemplateResource; import org.thymeleaf.templateresource.StringTemplateResource; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.Metadata; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.SystemSetting.CodeInjection; import run.halo.app.infra.SystemSetting.Seo; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.theme.Constant; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.SinglePageFinder; import run.halo.app.theme.finders.vo.PostVo; import run.halo.app.theme.finders.vo.UserVo; import run.halo.app.theme.router.ModelConst; /** * Tests for {@link HaloProcessorDialect}. * * @author guqing * @see HaloProcessorDialect * @see GlobalHeadInjectionProcessor * @see ContentTemplateHeadProcessor * @see TemplateHeadProcessor * @see TemplateGlobalHeadProcessor * @see TemplateFooterElementTagProcessor * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class HaloProcessorDialectTest { @Mock private ApplicationContext applicationContext; @Mock private PostFinder postFinder; @Mock private SinglePageFinder singlePageFinder; @Mock private SystemConfigFetcher fetcher; @Mock ExtensionGetter extensionGetter; private TemplateEngine templateEngine; @BeforeEach void setUp() { HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect(); templateEngine = new TemplateEngine(); templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect())); templateEngine.addTemplateResolver(new TestTemplateResolver()); Map map = new HashMap<>(); map.put("postTemplateHeadProcessor", new ContentTemplateHeadProcessor(postFinder, singlePageFinder)); map.put("templateGlobalHeadProcessor", new TemplateGlobalHeadProcessor(fetcher)); map.put("faviconHeadProcessor", new DefaultFaviconHeadProcessor(fetcher)); map.put("globalSeoProcessor", new GlobalSeoProcessor(fetcher)); map.put("indexSeoProcessor", new IndexSeoProcessor(fetcher)); CodeInjection codeInjection = new CodeInjection(); codeInjection.setContentHead(""); codeInjection.setGlobalHead(""); codeInjection.setFooter("
hello this is global footer.
"); lenient().when(fetcher.fetch(eq(CodeInjection.GROUP), eq(CodeInjection.class))) .thenReturn(Mono.just(codeInjection)); lenient().when(applicationContext.getBean(eq(SystemConfigFetcher.class))) .thenReturn(fetcher); lenient().when(fetcher.fetch(eq(Seo.GROUP), eq(Seo.class))) .thenReturn(Mono.empty()); lenient().when(applicationContext.getBeanProvider(ExtensionGetter.class)) .then(invocation -> { @SuppressWarnings("unchecked") ObjectProvider objectProvider = mock(ObjectProvider.class); when(objectProvider.getIfUnique()).thenReturn(extensionGetter); return objectProvider; }); lenient().when(extensionGetter.getExtensions(TemplateHeadProcessor.class)).thenReturn( Flux.fromIterable(map.values()).sort(AnnotationAwareOrderComparator.INSTANCE) ); lenient().when(fetcher.fetchComment()) .thenReturn(Mono.just(new SystemSetting.Comment())); } @Test void globalHeadAndFooterProcessors() { SystemSetting.Basic basic = new SystemSetting.Basic(); basic.setFavicon("favicon.ico"); when(fetcher.fetch(eq(SystemSetting.Basic.GROUP), eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic)); when(extensionGetter.getExtensions(TemplateFooterProcessor.class)) .thenReturn(Flux.empty()); Context context = getContext(); String result = templateEngine.process("index", context); assertThat(result).isEqualTo(""" Index

index

"""); } @Test void contentHeadAndFooterAndPostProcessors() { Context context = getContext(); context.setVariable("name", "fake-post"); // template id flag is used by TemplateGlobalHeadProcessor context.setVariable(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.POST.getValue()); List> htmlMetas = new ArrayList<>(); htmlMetas.add(ImmutableSortedMap.of("name", "post-meta-V1", "content", "post-meta-V1")); htmlMetas.add(ImmutableSortedMap.of("name", "post-meta-V2", "content", "post-meta-V2")); Post.PostSpec postSpec = new Post.PostSpec(); postSpec.setHtmlMetas(htmlMetas); Metadata metadata = new Metadata(); metadata.setName("fake-post"); PostVo postVo = PostVo.builder() .spec(postSpec) .metadata(metadata).build(); when(postFinder.getByName(eq("fake-post"))).thenReturn(Mono.just(postVo)); SystemSetting.Basic basic = new SystemSetting.Basic(); basic.setFavicon(null); when(fetcher.fetch(eq(SystemSetting.Basic.GROUP), eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic)); when(extensionGetter.getExtensions(TemplateFooterProcessor.class)) .thenReturn(Flux.empty()); String result = templateEngine.process("post", context); assertThat(result).isEqualTo(""" Post \ \ \

post

"""); } @Test void shouldSetMetaDescriptionIfContainingMetaDescriptionVariable() { var context = getContext(); context.setVariable(Constant.META_DESCRIPTION_VARIABLE_NAME, "Fake description"); when(fetcher.fetch(Seo.GROUP, Seo.class)).thenReturn(Mono.empty()); when(fetcher.fetch(SystemSetting.Basic.GROUP, SystemSetting.Basic.class)) .thenReturn(Mono.empty()); var result = templateEngine.process("seo", context); assertTrue(result.contains(""" \ """)); } @Test void blockSeo() { final Context context = getContext(); Seo seo = new Seo(); seo.setBlockSpiders(true); when(fetcher.fetch(eq(Seo.GROUP), eq(Seo.class))).thenReturn(Mono.just(seo)); SystemSetting.Basic basic = new SystemSetting.Basic(); basic.setFavicon("favicon.ico"); when(fetcher.fetch(eq(SystemSetting.Basic.GROUP), eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic)); String result = templateEngine.process("seo", context); assertThat(result).isEqualTo(""" Seo Test \ seo setting test. """); } @Test void indexSeoWithKeywordsAndDescription() { Context context = getContext(); context.setVariable(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.INDEX.getValue()); Seo seo = new Seo(); seo.setKeywords("K1, K2, K3"); seo.setDescription("This is a description."); when(fetcher.fetch(eq(Seo.GROUP), eq(Seo.class))).thenReturn(Mono.just(seo)); SystemSetting.Basic basic = new SystemSetting.Basic(); basic.setFavicon("favicon.ico"); when(fetcher.fetch(eq(SystemSetting.Basic.GROUP), eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic)); String result = templateEngine.process("seo", context); assertThat(result).isEqualTo(""" Seo Test seo setting test. """); } @Nested class AnnotationExpressionObjectFactoryTest { @Test void getWhenAnnotationsIsNull() { Context context = getContext(); context.setVariable("user", createUser()); String result = templateEngine.process("annotationsGetExpression", context); assertThat(result).isEqualTo("

\n"); } @Test void getWhenAnnotationsExists() { Context context = getContext(); UserVo user = createUser(); user.getMetadata().setAnnotations(Map.of("background", "fake-background")); context.setVariable("user", user); String result = templateEngine.process("annotationsGetExpression", context); assertThat(result).isEqualTo("

fake-background

\n"); } @Test void getOrDefaultWhenAnnotationsIsNull() { Context context = getContext(); UserVo user = createUser(); user.getMetadata().setAnnotations(Map.of("background", "red")); context.setVariable("user", user); String result = templateEngine.process("annotationsGetOrDefaultExpression", context); assertThat(result).isEqualTo("

red

\n"); } @Test void getOrDefaultWhenAnnotationsExists() { Context context = getContext(); context.setVariable("user", createUser()); String result = templateEngine.process("annotationsGetOrDefaultExpression", context); assertThat(result).isEqualTo("

default-value

\n"); } @Test void containsWhenAnnotationsIsNull() { Context context = getContext(); context.setVariable("user", createUser()); String result = templateEngine.process("annotationsContainsExpression", context); assertThat(result).isEqualTo("

false

\n"); } @Test void containsWhenAnnotationsIsNotNull() { Context context = getContext(); UserVo user = createUser(); user.getMetadata().setAnnotations(Map.of("background", "")); context.setVariable("user", user); String result = templateEngine.process("annotationsContainsExpression", context); assertThat(result).isEqualTo("

true

\n"); } UserVo createUser() { User user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName("fake-user"); user.setSpec(new User.UserSpec()); return UserVo.from(user); } } private Context getContext() { Context context = new Context(); context.setVariable( ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME, new ThymeleafEvaluationContext(applicationContext, null)); return context; } static class TestTemplateResolver extends StringTemplateResolver { @Override protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration, String ownerTemplate, String template, Map templateResolutionAttributes) { if (template.equals(DefaultTemplateEnum.INDEX.getValue())) { return new StringTemplateResource(indexTemplate()); } if (template.equals(DefaultTemplateEnum.POST.getValue())) { return new StringTemplateResource(postTemplate()); } if (template.equals("seo")) { return new StringTemplateResource(seoTemplate()); } if (template.equals("annotationsGetExpression")) { return new StringTemplateResource(annotationsGetExpression()); } if (template.equals("annotationsGetOrDefaultExpression")) { return new StringTemplateResource(annotationsGetOrDefaultExpression()); } if (template.equals("annotationsContainsExpression")) { return new StringTemplateResource(annotationsContainsExpression()); } return null; } private String indexTemplate() { return commonTemplate().formatted("Index", """

index

"""); } private String postTemplate() { return commonTemplate().formatted("Post", """

post

"""); } private String commonTemplate() { return """ %s %s """; } private String seoTemplate() { return """ Seo Test seo setting test. """; } private String annotationsGetExpression() { return """

"""; } private String annotationsGetOrDefaultExpression() { return """

"""; } private String annotationsContainsExpression() { return """

"""; } } } ================================================ FILE: application/src/test/java/run/halo/app/theme/dialect/HaloSpringSecurityDialectTest.java ================================================ package run.halo.app.theme.dialect; import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.params.provider.Arguments.arguments; import static org.springframework.http.MediaType.TEXT_HTML; import java.util.List; import java.util.Locale; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.WebContext; import org.thymeleaf.extras.springsecurity6.util.SpringSecurityContextUtils; import org.thymeleaf.spring6.SpringWebFluxTemplateEngine; import org.thymeleaf.spring6.web.webflux.SpringWebFluxWebApplication; import org.thymeleaf.templateresolver.StringTemplateResolver; // @ExtendWith(MockitoExtension.class) @SpringBootTest class HaloSpringSecurityDialectTest { TemplateEngine templateEngine; @Autowired ServerSecurityContextRepository securityContextRepository; @Autowired ObjectProvider expressionHandler; @BeforeEach void setUp() { var haloSpringSecurityDialect = new HaloSpringSecurityDialect(securityContextRepository, expressionHandler); templateEngine = new SpringWebFluxTemplateEngine(); templateEngine.addTemplateResolver(new StringTemplateResolver()); templateEngine.addDialect(haloSpringSecurityDialect); } static Stream shouldEvaluateSecAuthorizeAttr() { return Stream.of( arguments( "Evaluate sec:authorize to true when role match", List.of("ROLE_ADMIN"), """

Admin

\ """, """

Admin

\ """), arguments( "Evaluate sec:authorize to false when role not match", List.of("ROLE_USER"), """

\ """, "") ); } @ParameterizedTest(name = "{0}") @MethodSource void shouldEvaluateSecAuthorizeAttr(String name, List authorities, String template, String expected) { var request = MockServerHttpRequest.get("/halo-sec-authorize").build(); var exchange = new MockServerWebExchange.Builder(request).build(); var webExchange = SpringWebFluxWebApplication.buildApplication(null) .buildExchange(exchange, Locale.getDefault(), TEXT_HTML, UTF_8); var context = new WebContext(webExchange); var authentication = new UsernamePasswordAuthenticationToken("fake-user", "fake-credential", AuthorityUtils.createAuthorityList(authorities)); var securityContext = new SecurityContextImpl(authentication); context.setVariable(SpringSecurityContextUtils.SECURITY_CONTEXT_MODEL_ATTRIBUTE_NAME, securityContext); var result = templateEngine.process(template, context); assertEquals(expected, result); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/dialect/InjectionExcluderProcessorTest.java ================================================ package run.halo.app.theme.dialect; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; /** * Tests for {@link InjectionExcluderProcessor}. * * @author guqing * @since 2.20.0 */ class InjectionExcluderProcessorTest { @Nested class PageInjectionExcluderTest { final InjectionExcluderProcessor.PageInjectionExcluder pageInjectionExcluder = new InjectionExcluderProcessor.PageInjectionExcluder(); @Test void excludeTest() { var cases = new String[] { "login", "signup", "logout", "password-reset/email/reset", "error/404", "error/500", "challenges/totp" }; for (String templateName : cases) { assertThat(pageInjectionExcluder.isExcluded(templateName)).isTrue(); } } @Test void shouldNotExcludeTest() { var cases = new String[] { "index", "post", "page", "category", "tag", "archive", "search", "feed", "sitemap", "robots", "custom", "error", "login.html", }; for (String templateName : cases) { assertThat(pageInjectionExcluder.isExcluded(templateName)).isFalse(); } } } } ================================================ FILE: application/src/test/java/run/halo/app/theme/dialect/LinkExpressionObjectDialectTest.java ================================================ package run.halo.app.theme.dialect; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; /** * Tests for {@link LinkExpressionObjectDialect}. * * @author guqing * @since 2.0.0 */ class LinkExpressionObjectDialectTest { private final LinkExpressionObjectDialect linkExpressionObjectDialect = new LinkExpressionObjectDialect(); @Test void getExpressionObjectFactory() { assertThat(linkExpressionObjectDialect.getName()) .isEqualTo("themeLink"); assertThat(linkExpressionObjectDialect.getExpressionObjectFactory()) .isInstanceOf(DefaultLinkExpressionFactory.class); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessorTest.java ================================================ package run.halo.app.theme.dialect; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.HashSet; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.ApplicationContext; import org.thymeleaf.IEngineConfiguration; import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.Context; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IModel; import org.thymeleaf.model.IProcessableElementTag; import org.thymeleaf.processor.IProcessor; import org.thymeleaf.processor.element.IElementTagStructureHandler; import org.thymeleaf.spring6.dialect.SpringStandardDialect; import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; import org.thymeleaf.templateresolver.StringTemplateResolver; import org.thymeleaf.templateresource.ITemplateResource; import org.thymeleaf.templateresource.StringTemplateResource; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** * Tests for {@link TemplateFooterElementTagProcessor}. * * @author guqing * @since 2.17.0 */ @ExtendWith(MockitoExtension.class) class TemplateFooterElementTagProcessorTest { @Mock private ApplicationContext applicationContext; @Mock ExtensionGetter extensionGetter; @Mock private SystemConfigFetcher fetcher; private TemplateEngine templateEngine; @BeforeEach void setUp() { HaloProcessorDialect haloProcessorDialect = new MockHaloProcessorDialect(); templateEngine = new TemplateEngine(); templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect())); templateEngine.addTemplateResolver(new MockTemplateResolver()); SystemSetting.CodeInjection codeInjection = new SystemSetting.CodeInjection(); codeInjection.setFooter( "

Powered by Halo

"); lenient().when(fetcher.fetch(eq(SystemSetting.CodeInjection.GROUP), eq(SystemSetting.CodeInjection.class))).thenReturn(Mono.just(codeInjection)); lenient().when(applicationContext.getBeanProvider(ExtensionGetter.class)) .thenAnswer(invocation -> { var objectProvider = mock(ObjectProvider.class); when(objectProvider.getIfUnique()).thenReturn(extensionGetter); return objectProvider; }); lenient().when(applicationContext.getBean(eq(SystemConfigFetcher.class))) .thenReturn(fetcher); } @Test void footerProcessorTest() { when(extensionGetter.getExtensions(TemplateFooterProcessor.class)) .thenReturn(Flux.just(new FakeFooterCodeInjection())); String result = templateEngine.process("fake-template", getContext()); // footer injected code is not processable assertThat(result).isEqualToIgnoringWhitespace("""

Powered by Halo

© 2024 guqing's blog
"""); } private Context getContext() { Context context = new Context(); context.setVariable( ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME, new ThymeleafEvaluationContext(applicationContext, null)); return context; } static class MockTemplateResolver extends StringTemplateResolver { @Override protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration, String ownerTemplate, String template, Map templateResolutionAttributes) { return new StringTemplateResource(""" """); } } static class MockHaloProcessorDialect extends HaloProcessorDialect { @Override public Set getProcessors(String dialectPrefix) { var processors = new HashSet(); processors.add(new TemplateFooterElementTagProcessor(dialectPrefix)); return processors; } } static class FakeFooterCodeInjection implements TemplateFooterProcessor { @Override public Mono process(ITemplateContext context, IProcessableElementTag tag, IElementTagStructureHandler structureHandler, IModel model) { var factory = context.getModelFactory(); // regular footer text var copyRight = factory.createText("
© 2024 guqing's blog
"); model.add(copyRight); // variable footer text model.add(factory.createText("
")); return Mono.empty(); } } } ================================================ FILE: application/src/test/java/run/halo/app/theme/endpoint/ThemeEndpointTest.java ================================================ package run.halo.app.theme.endpoint; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.web.reactive.function.BodyInserters.fromMultipartData; import java.io.File; import java.io.IOException; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.reactivestreams.Publisher; import org.springframework.core.io.FileSystemResource; import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.util.FileSystemUtils; import org.springframework.util.ResourceUtils; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Theme; import run.halo.app.core.user.service.SettingConfigService; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.ReactiveUrlDataBufferFetcher; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.ThemeRootGetter; import run.halo.app.theme.TemplateEngineManager; import run.halo.app.theme.service.ThemeService; /** * Tests for {@link ThemeEndpoint}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class ThemeEndpointTest { @Mock ThemeRootGetter themeRoot; @Mock ThemeService themeService; @Mock TemplateEngineManager templateEngineManager; @Mock private ReactiveExtensionClient client; @Mock private SystemConfigFetcher environmentFetcher; @Mock private ReactiveUrlDataBufferFetcher urlDataBufferFetcher; @Mock private SettingConfigService settingConfigService; @InjectMocks ThemeEndpoint themeEndpoint; private Path tmpHaloWorkDir; WebTestClient webTestClient; private File defaultTheme; @BeforeEach void setUp() throws IOException { tmpHaloWorkDir = Files.createTempDirectory("halo-theme-endpoint-test"); lenient().when(themeRoot.get()).thenReturn(tmpHaloWorkDir); defaultTheme = ResourceUtils.getFile("classpath:themes/test-theme.zip"); webTestClient = WebTestClient .bindToRouterFunction(themeEndpoint.endpoint()) .build(); } @AfterEach void tearDown() throws IOException { FileSystemUtils.deleteRecursively(tmpHaloWorkDir); } @Nested class UpgradeTest { @Test void shouldNotOkIfThemeNotInstalled() { var bodyBuilder = new MultipartBodyBuilder(); bodyBuilder.part("file", new FileSystemResource(defaultTheme)) .contentType(MediaType.MULTIPART_FORM_DATA); when(themeService.upgrade(eq("invalid-missing-manifest"), isA(Publisher.class))) .thenReturn( Mono.error(() -> new ServerWebInputException("Failed to upgrade theme"))); webTestClient.post() .uri("/themes/invalid-missing-manifest/upgrade") .body(fromMultipartData(bodyBuilder.build())) .exchange() .expectStatus().isBadRequest(); verify(themeService).upgrade(eq("invalid-missing-manifest"), isA(Publisher.class)); } @Test void shouldUpgradeSuccessfullyIfThemeInstalled() { var bodyBuilder = new MultipartBodyBuilder(); bodyBuilder.part("file", new FileSystemResource(defaultTheme)) .contentType(MediaType.MULTIPART_FORM_DATA); var metadata = new Metadata(); metadata.setName("default"); var newTheme = new Theme(); newTheme.setMetadata(metadata); when(themeService.upgrade(eq("default"), isA(Publisher.class))) .thenReturn(Mono.just(newTheme)); when(templateEngineManager.clearCache(eq("default"))) .thenReturn(Mono.empty()); webTestClient.post() .uri("/themes/default/upgrade") .body(fromMultipartData(bodyBuilder.build())) .exchange() .expectStatus().isOk(); verify(themeService).upgrade(eq("default"), isA(Publisher.class)); verify(templateEngineManager, times(1)).clearCache(eq("default")); } @Test void upgradeFromUri() { var uri = URI.create("https://example.com/test-theme.zip"); var metadata = new Metadata(); metadata.setName("default"); var fakeTheme = new Theme(); fakeTheme.setMetadata(metadata); when(themeService.upgrade(eq("default"), any())) .thenReturn(Mono.just(fakeTheme)); when(templateEngineManager.clearCache(eq("default"))) .thenReturn(Mono.empty()); var body = new ThemeEndpoint.UpgradeFromUriRequest(uri); webTestClient.post() .uri("/themes/default/upgrade-from-uri") .bodyValue(body) .exchange() .expectStatus().isOk() .expectBody(Theme.class).isEqualTo(fakeTheme); verify(themeService).upgrade(eq("default"), any()); verify(templateEngineManager, times(1)).clearCache(eq("default")); } } @Test void install() { var multipartBodyBuilder = new MultipartBodyBuilder(); multipartBodyBuilder.part("file", new FileSystemResource(defaultTheme)) .contentType(MediaType.MULTIPART_FORM_DATA); var installedTheme = new Theme(); var metadata = new Metadata(); metadata.setName("fake-name"); installedTheme.setMetadata(metadata); when(themeService.install(any())).thenReturn(Mono.just(installedTheme)); webTestClient.post() .uri("/themes/install") .body(fromMultipartData(multipartBodyBuilder.build())) .exchange() .expectStatus().isOk() .expectBody(Theme.class) .isEqualTo(installedTheme); verify(themeService).install(any()); when(themeService.install(any())).thenReturn( Mono.error(new RuntimeException("Fake exception"))); // Verify the theme is installed. webTestClient.post() .uri("/themes/install") .body(fromMultipartData(multipartBodyBuilder.build())) .exchange() .expectStatus().is5xxServerError(); } @Test void installFromUri() { final URI uri = URI.create("https://example.com/test-theme.zip"); var metadata = new Metadata(); metadata.setName("fake-theme"); var theme = new Theme(); theme.setMetadata(metadata); when(themeService.install(any())).thenReturn(Mono.just(theme)); var body = new ThemeEndpoint.UpgradeFromUriRequest(uri); webTestClient.post() .uri("/themes/-/install-from-uri") .bodyValue(body) .exchange() .expectStatus().isOk() .expectBody(Theme.class).isEqualTo(theme); verify(themeService).install(any()); } @Test void reloadTheme() { when(themeService.reloadTheme(any())).thenReturn(Mono.empty()); webTestClient.put() .uri("/themes/fake/reload") .exchange() .expectStatus().isOk(); } @Test void resetSettingConfig() { when(themeService.resetSettingConfig(any())).thenReturn(Mono.empty()); webTestClient.put() .uri("/themes/fake/reset-config") .exchange() .expectStatus().isOk(); } @Nested class UpdateThemeConfigTest { @Test void updateJsonConfigTest() { Theme theme = new Theme(); theme.setMetadata(new Metadata()); theme.setSpec(new Theme.ThemeSpec()); theme.getSpec().setConfigMapName("fake-config-map"); when(client.fetch(eq(Theme.class), eq("fake-theme"))).thenReturn(Mono.just(theme)); when(settingConfigService.upsertConfig(eq("fake-config-map"), any())) .thenReturn(Mono.empty()); webTestClient.put() .uri("/themes/fake-theme/json-config") .body(Mono.just(Map.of()), Map.class) .exchange() .expectStatus().is2xxSuccessful(); } } @Test void fetchActivatedTheme() { var theme = new Theme(); theme.setMetadata(new Metadata()); theme.getMetadata().setName("fake-activated"); when(themeService.fetchActivatedTheme()).thenReturn(Mono.just(theme)); webTestClient.get() .uri("/themes/-/activation") .exchange() .expectStatus().isOk() .expectBody(Theme.class) .isEqualTo(theme); } @Test void fetchThemeSetting() { Theme theme = new Theme(); theme.setMetadata(new Metadata()); theme.getMetadata().setName("fake"); theme.setSpec(new Theme.ThemeSpec()); theme.getSpec().setSettingName("fake-setting"); when(client.fetch(eq(Setting.class), eq("fake-setting"))) .thenReturn(Mono.just(new Setting())); when(client.fetch(eq(Theme.class), eq("fake"))).thenReturn(Mono.just(theme)); webTestClient.get() .uri("/themes/fake/setting") .exchange() .expectStatus().isOk(); verify(client).fetch(eq(Setting.class), eq("fake-setting")); verify(client).fetch(eq(Theme.class), eq("fake")); } @Test void fetchThemeJsonConfigTest() { Theme theme = new Theme(); theme.setMetadata(new Metadata()); theme.getMetadata().setName("fake"); theme.setSpec(new Theme.ThemeSpec()); theme.getSpec().setConfigMapName("fake-config"); when(settingConfigService.fetchConfig(eq("fake-config"))).thenReturn(Mono.empty()); when(client.fetch(eq(Theme.class), eq("fake"))).thenReturn(Mono.just(theme)); webTestClient.get() .uri("/themes/fake/json-config") .exchange() .expectStatus().isOk(); verify(settingConfigService).fetchConfig(eq("fake-config")); verify(client).fetch(eq(Theme.class), eq("fake")); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/engine/DefaultThemeTemplateAvailabilityProviderTest.java ================================================ package run.halo.app.theme.engine; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; import java.io.FileNotFoundException; import java.net.URISyntaxException; import java.nio.file.Path; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.boot.thymeleaf.autoconfigure.ThymeleafProperties; import org.springframework.util.ResourceUtils; import run.halo.app.theme.ThemeContext; @ExtendWith(MockitoExtension.class) class DefaultThemeTemplateAvailabilityProviderTest { @InjectMocks DefaultThemeTemplateAvailabilityProvider provider; @Mock ThymeleafProperties thymeleafProperties; @Test void templateAvailableTest() throws FileNotFoundException, URISyntaxException { var themeUrl = ResourceUtils.getURL("classpath:themes/default"); var themePath = Path.of(themeUrl.toURI()); when(thymeleafProperties.getSuffix()).thenReturn(".html"); var themeContext = ThemeContext.builder() .name("default") .path(themePath) .build(); boolean templateAvailable = provider.isTemplateAvailable(themeContext, "fake"); assertFalse(templateAvailable); templateAvailable = provider.isTemplateAvailable(themeContext, "index"); assertTrue(templateAvailable); templateAvailable = provider.isTemplateAvailable(themeContext, "timezone"); assertTrue(templateAvailable); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/engine/PluginClassloaderTemplateResolverTest.java ================================================ package run.halo.app.theme.engine; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.pf4j.PluginManager; /** * Tests for {@link PluginClassloaderTemplateResolver}. * * @author guqing * @since 2.11.0 */ @ExtendWith(MockitoExtension.class) class PluginClassloaderTemplateResolverTest { @Mock private PluginManager haloPluginManager; @InjectMocks private PluginClassloaderTemplateResolver templateResolver; @Test void matchPluginTemplateWhenOwnerTemplateMatch() { var result = templateResolver.matchPluginTemplate("plugin:fake-plugin:doc", "modules/layout"); assertThat(result.matches()).isTrue(); assertThat(result.pluginName()).isEqualTo("fake-plugin"); assertThat(result.templateName()).isEqualTo("modules/layout"); assertThat(result.ownerTemplateName()).isEqualTo("doc"); } @Test void matchPluginTemplateWhenDoesNotMatch() { var result = templateResolver.matchPluginTemplate("doc", "modules/layout"); assertThat(result.matches()).isFalse(); } @Test void matchPluginTemplateWhenTemplateMatch() { var result = templateResolver.matchPluginTemplate("doc", "plugin:fake-plugin:modules/layout"); assertThat(result.matches()).isTrue(); assertThat(result.pluginName()).isEqualTo("fake-plugin"); assertThat(result.templateName()).isEqualTo("modules/layout"); assertThat(result.ownerTemplateName()).isEqualTo("doc"); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/finders/FinderRegistryTest.java ================================================ package run.halo.app.theme.finders; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationContext; /** * Tests for {@link FinderRegistry}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class FinderRegistryTest { private DefaultFinderRegistry finderRegistry; @Mock private ApplicationContext applicationContext; @BeforeEach void setUp() { finderRegistry = new DefaultFinderRegistry(applicationContext); } @Test void registerFinder() { assertThatThrownBy(() -> { finderRegistry.putFinder(new Object()); }).isInstanceOf(IllegalStateException.class) .hasMessage("Finder must be annotated with @Finder"); String s = finderRegistry.putFinder(new FakeFinder()); assertThat(s).isEqualTo("test"); } @Test void removeFinder() { String s = finderRegistry.putFinder(new FakeFinder()); assertThat(s).isEqualTo("test"); Object test = finderRegistry.get("test"); assertThat(test).isNotNull(); finderRegistry.removeFinder(s); test = finderRegistry.get("test"); assertThat(test).isNull(); } @Test void getFinders() { assertThat(finderRegistry.getFinders()).hasSize(0); finderRegistry.putFinder(new FakeFinder()); Map finders = finderRegistry.getFinders(); assertThat(finders).hasSize(1); } @Finder("test") static class FakeFinder { } } ================================================ FILE: application/src/test/java/run/halo/app/theme/finders/impl/CategoryFinderImplTest.java ================================================ package run.halo.app.theme.finders.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; import com.fasterxml.jackson.core.type.TypeReference; import java.io.IOException; import java.nio.file.Files; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; import org.json.JSONException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.data.domain.Sort; import org.springframework.util.ResourceUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.content.CategoryService; import run.halo.app.core.extension.content.Category; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.theme.finders.vo.CategoryTreeVo; import run.halo.app.theme.finders.vo.CategoryVo; /** * Tests for {@link CategoryFinderImpl}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class CategoryFinderImplTest { @Mock private ReactiveExtensionClient client; @Mock private CategoryService categoryService; private CategoryFinderImpl categoryFinder; @BeforeEach void setUp() { categoryFinder = new CategoryFinderImpl(client, categoryService); lenient().when(categoryService.isCategoryHidden(any())).thenReturn(Mono.just(false)); } @Test void getByName() throws JSONException { when(client.fetch(eq(Category.class), eq("hello"))) .thenReturn(Mono.just(category())); CategoryVo categoryVo = categoryFinder.getByName("hello").block(); categoryVo.getMetadata().setCreationTimestamp(null); JSONAssert.assertEquals(""" { "metadata": { "name": "hello", "annotations": { "K1": "V1" } }, "spec": { "displayName": "displayName-1", "slug": "slug-1", "description": "description-1", "cover": "cover-1", "template": "template-1", "priority": 0, "children": [ "C1", "C2" ], "preventParentPostCascadeQuery": false, "hideFromList": false } } """, JsonUtils.objectToJson(categoryVo), true); } @Test void list() { ListResult categories = new ListResult<>(1, 10, 3, categories().stream() .sorted(CategoryFinderImpl.defaultComparator()) .toList()); when(client.listBy(eq(Category.class), any(ListOptions.class), any(PageRequest.class))) .thenReturn(Mono.just(categories)); ListResult list = categoryFinder.list(1, 10).block(); assertThat(list.getItems()).hasSize(3); assertThat(list.get().map(categoryVo -> categoryVo.getMetadata().getName()).toList()) .isEqualTo(List.of("c3", "c2", "hello")); } @Test void listAsTree() { when(client.listAll(eq(Category.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.fromIterable(categoriesForTree())); List treeVos = categoryFinder.listAsTree().collectList().block(); assertThat(treeVos).hasSize(1); } @Test void listSubTreeByName() { when(client.listAll(eq(Category.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.fromIterable(categoriesForTree())); List treeVos = categoryFinder.listAsTree("E").collectList().block(); assertThat(treeVos.get(0).getMetadata().getName()).isEqualTo("E"); assertThat(treeVos.get(0).getChildren()).hasSize(2); assertThat(treeVos.get(0).getChildren().get(0).getMetadata().getName()).isEqualTo("A"); assertThat(treeVos.get(0).getChildren().get(1).getMetadata().getName()).isEqualTo("C"); } /** * Test for {@link CategoryFinderImpl#listAsTree()}. * * @see Fix #2532 */ @Test void listAsTreeMore() { when(client.listAll(eq(Category.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.fromIterable(moreCategories())); List treeVos = categoryFinder.listAsTree().collectList().block(); String s = visualizeTree(treeVos); assertThat(s).isEqualTo(""" 全部 (7) ├── FIT2CLOUD (4) │ ├── DataEase (0) │ ├── Halo (2) │ ├── MeterSphere (0) │ └── JumpServer (0) └── 默认分类 (3) """); } @Nested class CategoryPostCountTest { /** *

Structure below.

*
         * 全部 (35)
         * ├── FIT2CLOUD (15)
         * │   ├── DataEase (10)
         * │   │   ├── SubNode1 (4)
         * │   │   │   ├── Leaf1 (2)
         * │   │   │   ├── Leaf2 (2)
         * │   │   ├── SubNode2 (6)  (independent)
         * │   │       ├── IndependentChild1 (3)
         * │   │       ├── IndependentChild2 (3)
         * │   ├── IndependentNode (5)  (independent)
         * │       ├── IndependentChild3 (2)
         * │       ├── IndependentChild4 (3)
         * ├── AnotherRootChild (20)
         * │   ├── Child1 (8)
         * │   │   ├── SubChild1 (3)
         * │   │   │   ├── DeepNode1 (1)
         * │   │   │   ├── DeepNode2 (1)
         * │   │   │   │   ├── DeeperNode (1)
         * │   │   ├── SubChild2 (5)
         * │   │       ├── DeepNode3 (2)  (independent)
         * │   │           ├── DeepNode4 (1)
         * │   │           ├── DeepNode5 (1)
         * │   ├── Child2 (12)
         * │       ├── IndependentSubNode (12)  (independent)
         * │           ├── SubNode3 (6)
         * │           ├── SubNode4 (6)
         * 
*/ private List categories; @BeforeEach void setUp() throws IOException { var file = ResourceUtils.getFile("classpath:categories/independent-post-count.json"); var json = Files.readString(file.toPath()); categories = JsonUtils.jsonToObject(json, new TypeReference<>() { }); } @Test void computePostCountFromTree() { when(client.listAll(eq(Category.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.fromIterable(categories)); var treeVos = categoryFinder.listAsTree("全部") .collectList().block(); assertThat(treeVos).hasSize(1); String s = visualizeTree(treeVos.get(0).getChildren()); assertThat(s).isEqualTo(""" 全部 (84) ├── AnotherRootChild (51) │ ├── Child1 (19) │ │ ├── SubChild1 (6) │ │ │ ├── DeepNode1 (1) │ │ │ └── DeepNode2 (2) │ │ │ └── DeeperNode (1) │ │ └── SubChild2 (5) │ │ └── DeepNode3 (4) (Independent) │ │ ├── DeepNode4 (1) │ │ └── DeepNode5 (1) │ └── Child2 (12) │ └── IndependentSubNode (24) (Independent) │ ├── SubNode3 (6) │ └── SubNode4 (6) └── FIT2CLOUD (33) ├── DataEase (18) │ ├── SubNode1 (8) │ │ ├── Leaf1 (2) │ │ └── Leaf2 (2) │ └── SubNode2 (12) (Independent) │ ├── IndependentChild1 (3) │ └── IndependentChild2 (3) └── IndependentNode (10) (Independent) ├── IndependentChild3 (2) └── IndependentChild4 (3) """); } @Test void getBreadcrumbsTest() { when(client.listAll(eq(Category.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.fromIterable(categories)); // first level var breadcrumbs = categoryFinder.getBreadcrumbs("全部").collectList().block(); assertThat(toNames(breadcrumbs)).containsSequence("全部"); // second level breadcrumbs = categoryFinder.getBreadcrumbs("AnotherRootChild").collectList().block(); assertThat(toNames(breadcrumbs)).containsSequence("全部", "AnotherRootChild"); // more levels breadcrumbs = categoryFinder.getBreadcrumbs("DeepNode5").collectList().block(); assertThat(toNames(breadcrumbs)).containsSequence("全部", "AnotherRootChild", "Child1", "SubChild2", "DeepNode3", "DeepNode5"); breadcrumbs = categoryFinder.getBreadcrumbs("IndependentChild4").collectList().block(); assertThat(toNames(breadcrumbs)).containsSequence("全部", "FIT2CLOUD", "IndependentNode", "IndependentChild4"); breadcrumbs = categoryFinder.getBreadcrumbs("SubNode4").collectList().block(); assertThat(toNames(breadcrumbs)).containsSequence("全部", "AnotherRootChild", "Child2", "IndependentSubNode", "SubNode4"); // not exist breadcrumbs = categoryFinder.getBreadcrumbs("not-exist").collectList().block(); assertThat(toNames(breadcrumbs)).isEmpty(); } @Test void getBreadcrumbsForHiddenTest() { Map categoryMap = categories.stream() .collect( Collectors.toMap(item -> item.getMetadata().getName(), Function.identity())); var category = categoryMap.get("IndependentNode"); category.getSpec().setHideFromList(true); when(client.listAll(eq(Category.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.fromIterable(categoryMap.values())); when(categoryService.isCategoryHidden(eq("IndependentChild4"))) .thenReturn(Mono.just(true)); var breadcrumbs = categoryFinder.getBreadcrumbs("IndependentChild4") .collectList().block(); assertThat(toNames(breadcrumbs)).containsSequence("全部", "FIT2CLOUD", "IndependentNode", "IndependentChild4"); } static List toNames(List categories) { if (categories == null) { return List.of(); } return categories.stream() .map(category -> category.getMetadata().getName()) .toList(); } } private List categoriesForTree() { /* * D * ├── E * │ ├── A * │ │ └── B * │ └── C * └── G * ├── F * └── H */ Category d = category(); d.getMetadata().setName("D"); d.getSpec().setChildren(List.of("E", "G", "F")); Category e = category(); e.getMetadata().setName("E"); e.getSpec().setChildren(List.of("A", "C")); Category a = category(); a.getMetadata().setName("A"); a.getSpec().setChildren(List.of("B")); Category b = category(); b.getMetadata().setName("B"); b.getSpec().setChildren(null); Category c = category(); c.getMetadata().setName("C"); c.getSpec().setChildren(null); Category g = category(); g.getMetadata().setName("G"); g.getSpec().setChildren(null); Category f = category(); f.getMetadata().setName("F"); f.getSpec().setChildren(List.of("H")); Category h = category(); h.getMetadata().setName("H"); h.getSpec().setChildren(null); return List.of(d, e, a, b, c, g, f, h); } /** * Visualize a tree. */ String visualizeTree(List categoryTreeVos) { Category.CategorySpec categorySpec = new Category.CategorySpec(); categorySpec.setSlug("/"); categorySpec.setDisplayName("全部"); Integer postCount = categoryTreeVos.stream() .map(CategoryTreeVo::getPostCount) .filter(Objects::nonNull) .reduce(Integer::sum) .orElse(0); CategoryTreeVo root = CategoryTreeVo.builder() .spec(categorySpec) .postCount(postCount) .children(categoryTreeVos) .metadata(new Metadata()) .build(); StringBuilder stringBuilder = new StringBuilder(); root.print(stringBuilder, "", ""); return stringBuilder.toString(); } private List categories() { Category category2 = JsonUtils.deepCopy(category()); category2.getMetadata().setName("c2"); category2.getSpec().setPriority(2); Category category3 = JsonUtils.deepCopy(category()); category3.getMetadata().setName("c3"); category3.getMetadata().setCreationTimestamp(Instant.now().plusSeconds(20)); category3.getSpec().setPriority(2); return List.of(category2, category(), category3); } private Category category() { final Category category = new Category(); Metadata metadata = new Metadata(); metadata.setName("hello"); metadata.setAnnotations(Map.of("K1", "V1")); metadata.setCreationTimestamp(Instant.now()); category.setMetadata(metadata); Category.CategorySpec categorySpec = new Category.CategorySpec(); categorySpec.setSlug("slug-1"); categorySpec.setDisplayName("displayName-1"); categorySpec.setCover("cover-1"); categorySpec.setDescription("description-1"); categorySpec.setTemplate("template-1"); categorySpec.setPriority(0); categorySpec.setChildren(List.of("C1", "C2")); category.setSpec(categorySpec); return category; } private List moreCategories() { // see also https://github.com/halo-dev/halo/issues/2643 String s = """ [ { "spec":{ "displayName":"默认分类", "slug":"default", "description":"这是你的默认分类,如不需要,删除即可。", "cover":"", "template":"", "priority":1, "children":[ ] }, "status":{ "permalink":"/categories/default", "postCount":3, "visiblePostCount":3 }, "apiVersion":"content.halo.run/v1alpha1", "kind":"Category", "metadata":{ "name":"76514a40-6ef1-4ed9-b58a-e26945bde3ca", "version":16, "creationTimestamp":"2022-10-08T06:17:47.589181Z" } }, { "spec":{ "displayName":"MeterSphere", "slug":"metersphere", "description":"", "cover":"", "template":"", "priority":2, "children":[ ] }, "status":{ "permalink":"/categories/metersphere", "postCount":0, "visiblePostCount":0 }, "apiVersion":"content.halo.run/v1alpha1", "kind":"Category", "metadata":{ "finalizers":[ "category-protection" ], "name":"acf09686-d5a7-4227-ba8c-3aeff063f12f", "version":13, "creationTimestamp":"2022-10-08T06:32:36.650974Z" } }, { "spec":{ "displayName":"DataEase", "slug":"dataease", "description":"", "cover":"", "template":"", "priority":0, "children":[ ] }, "status":{ "permalink":"/categories/dataease", "postCount":0, "visiblePostCount":0 }, "apiVersion":"content.halo.run/v1alpha1", "kind":"Category", "metadata":{ "finalizers":[ "category-protection" ], "name":"bd95f914-22fc-4de5-afcc-a9ffba2f6401", "version":13, "creationTimestamp":"2022-10-08T06:32:53.353838Z" } }, { "spec":{ "displayName":"FIT2CLOUD", "slug":"fit2cloud", "description":"", "cover":"", "template":"", "priority":0, "children":[ "bd95f914-22fc-4de5-afcc-a9ffba2f6401", "e1150fd9-4512-453c-9186-f8de9c156c3d", "acf09686-d5a7-4227-ba8c-3aeff063f12f", "ed064d5e-2b6f-4123-8114-78d0c6f2c4e2", "non-existent-children-name" ] }, "status":{ "permalink":"/categories/fit2cloud", "postCount":2, "visiblePostCount":2 }, "apiVersion":"content.halo.run/v1alpha1", "kind":"Category", "metadata":{ "finalizers":[ "category-protection" ], "name":"c25c17ae-4a7b-43c5-a424-76950b9622cd", "version":14, "creationTimestamp":"2022-10-08T06:32:27.802025Z" } }, { "spec":{ "displayName":"Halo", "slug":"halo", "description":"", "cover":"", "template":"", "priority":1, "children":[ ] }, "status":{ "permalink":"/categories/halo", "postCount":2, "visiblePostCount":2 }, "apiVersion":"content.halo.run/v1alpha1", "kind":"Category", "metadata":{ "finalizers":[ "category-protection" ], "name":"e1150fd9-4512-453c-9186-f8de9c156c3d", "version":15, "creationTimestamp":"2022-10-08T06:32:42.991788Z" } }, { "spec":{ "displayName":"JumpServer", "slug":"jumpserver", "description":"", "cover":"", "template":"", "priority":3, "children":[ ] }, "status":{ "permalink":"/categories/jumpserver", "postCount":0, "visiblePostCount":0 }, "apiVersion":"content.halo.run/v1alpha1", "kind":"Category", "metadata":{ "finalizers":[ "category-protection" ], "name":"ed064d5e-2b6f-4123-8114-78d0c6f2c4e2", "version":13, "creationTimestamp":"2022-10-08T06:33:00.557435Z" } } ] """; return JsonUtils.jsonToObject(s, new TypeReference<>() { }); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImplTest.java ================================================ package run.halo.app.theme.finders.impl; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.function.Predicate; import java.util.stream.Stream; import org.json.JSONException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.stubbing.Answer; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.test.context.junit.jupiter.SpringExtension; import reactor.core.publisher.Mono; import run.halo.app.core.counter.CounterService; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Post; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link CommentFinderImpl}. * * @author guqing * @since 2.0.0 */ @ExtendWith({SpringExtension.class, MockitoExtension.class}) class CommentPublicQueryServiceImplTest { @Mock ReactiveExtensionClient client; @Mock UserService userService; @Mock CounterService counterService; @InjectMocks CommentPublicQueryServiceImpl commentPublicQueryService; @BeforeEach void setUp() { when(userService.getUserOrGhost(eq("fake-user"))).thenReturn(Mono.just(createUser())); } @Nested class ListCommentTest { @Test void desensitizeComment() throws JSONException { var commentOwner = new Comment.CommentOwner(); commentOwner.setName("fake-user"); commentOwner.setDisplayName("Fake User"); commentOwner.setAnnotations(new HashMap<>() { { put(Comment.CommentOwner.KIND_EMAIL, "mail@halo.run"); } }); var comment = commentForCompare("1", null, true, 0); comment.getSpec().setIpAddress("127.0.0.1"); comment.getSpec().setOwner(commentOwner); Counter counter = new Counter(); counter.setUpvote(0); when(counterService.getByName(any())).thenReturn(Mono.just(counter)); var result = commentPublicQueryService.toCommentVo(comment).block(); result.getMetadata().setCreationTimestamp(null); result.getSpec().setCreationTime(null); JSONAssert.assertEquals(""" { "metadata":{ "name":"1" }, "spec":{ "owner":{ "name":"", "displayName":"Fake User", "annotations":{ } }, "ipAddress":"", "priority":0, "top":true }, "owner":{ "kind":"User", "displayName":"fake-display-name" }, "stats":{ "upvote":0 } } """, JsonUtils.objectToJson(result), true); } Comment commentForCompare(String name, Instant creationTime, boolean top, int priority) { Comment comment = new Comment(); comment.setMetadata(new Metadata()); comment.getMetadata().setName(name); comment.getMetadata().setCreationTimestamp(Instant.now()); comment.setSpec(new Comment.CommentSpec()); comment.getSpec().setCreationTime(creationTime); comment.getSpec().setTop(top); comment.getSpec().setPriority(priority); return comment; } @SuppressWarnings("unchecked") private void mockWhenListComment() { // Mock Comment commentNotApproved = createComment(); commentNotApproved.getMetadata().setName("comment-not-approved"); commentNotApproved.getSpec().setApproved(false); Comment commentApproved = createComment(); commentApproved.getMetadata().setName("comment-approved"); commentApproved.getSpec().setApproved(true); Comment notApprovedWithAnonymous = createComment(); notApprovedWithAnonymous.getMetadata().setName("comment-not-approved-anonymous"); notApprovedWithAnonymous.getSpec().setApproved(false); notApprovedWithAnonymous.getSpec().getOwner().setName(AnonymousUserConst.PRINCIPAL); Comment commentApprovedButAnotherOwner = createComment(); commentApprovedButAnotherOwner.getMetadata() .setName("comment-approved-but-another-owner"); commentApprovedButAnotherOwner.getSpec().setApproved(true); commentApprovedButAnotherOwner.getSpec().getOwner().setName("another"); Comment commentNotApprovedAndAnotherOwner = createComment(); commentNotApprovedAndAnotherOwner.getMetadata() .setName("comment-not-approved-and-another"); commentNotApprovedAndAnotherOwner.getSpec().setApproved(false); commentNotApprovedAndAnotherOwner.getSpec().getOwner().setName("another"); Comment notApprovedAndAnotherRef = createComment(); notApprovedAndAnotherRef.getMetadata() .setName("comment-not-approved-and-another-ref"); notApprovedAndAnotherRef.getSpec().setApproved(false); Ref anotherRef = Ref.of("another-fake-post", GroupVersionKind.fromExtension(Post.class)); notApprovedAndAnotherRef.getSpec().setSubjectRef(anotherRef); when(client.list(eq(Comment.class), any(), any(), eq(1), eq(10)) ).thenAnswer((Answer>>) invocation -> { Predicate predicate = invocation.getArgument(1, Predicate.class); List comments = Stream.of( commentNotApproved, commentApproved, commentApprovedButAnotherOwner, commentNotApprovedAndAnotherOwner, notApprovedWithAnonymous, notApprovedAndAnotherRef ).filter(predicate).toList(); return Mono.just(new ListResult<>(1, 10, comments.size(), comments)); }); extractedUser(); when(client.fetch(eq(User.class), any())).thenReturn(Mono.just(createUser())); Counter counter = new Counter(); counter.setUpvote(9); when(counterService.getByName(any())).thenReturn(Mono.just(counter)); } Comment createComment() { Comment comment = new Comment(); comment.setMetadata(new Metadata()); comment.getMetadata().setName("fake-comment"); comment.setSpec(new Comment.CommentSpec()); comment.setStatus(new Comment.CommentStatus()); comment.getSpec().setRaw("fake-raw"); comment.getSpec().setContent("fake-content"); comment.getSpec().setHidden(false); comment.getSpec() .setSubjectRef(Ref.of("fake-post", GroupVersionKind.fromExtension(Post.class))); Comment.CommentOwner commentOwner = new Comment.CommentOwner(); commentOwner.setKind(User.KIND); commentOwner.setName("fake-user"); commentOwner.setDisplayName("fake-display-name"); comment.getSpec().setOwner(commentOwner); return comment; } } private void extractedUser() { User another = createUser(); another.getMetadata().setName("another"); when(userService.getUserOrGhost(eq("another"))).thenReturn(Mono.just(another)); User ghost = createUser(); ghost.getMetadata().setName("ghost"); when(userService.getUserOrGhost(eq("ghost"))).thenReturn(Mono.just(ghost)); when(userService.getUserOrGhost(eq("fake-user"))).thenReturn(Mono.just(createUser())); when(userService.getUserOrGhost(any())).thenReturn(Mono.just(ghost)); } User createUser() { User user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName("fake-user"); user.setSpec(new User.UserSpec()); user.getSpec().setDisplayName("fake-display-name"); return user; } } ================================================ FILE: application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceIntegrationTest.java ================================================ package run.halo.app.theme.finders.impl; import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.stream.Collectors; import org.json.JSONException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.annotation.DirtiesContext; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Reply; import run.halo.app.extension.Extension; import run.halo.app.extension.ExtensionStoreUtil; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.extension.SchemeManager; import run.halo.app.extension.store.ReactiveExtensionStoreClient; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.infra.exception.DuplicateNameException; import run.halo.app.infra.utils.JsonUtils; @DirtiesContext @SpringBootTest class CommentPublicQueryServiceIntegrationTest { @Autowired private SchemeManager schemeManager; @Autowired private ReactiveExtensionClient client; @Autowired private ReactiveExtensionStoreClient storeClient; Mono deleteImmediately(Extension extension) { var name = extension.getMetadata().getName(); var scheme = schemeManager.get(extension.getClass()); // delete from db var storeName = ExtensionStoreUtil.buildStoreName(scheme, name); return storeClient.delete(storeName, extension.getMetadata().getVersion()) .thenReturn(extension); } @Nested class CommentListTest { private final List storedComments = commentsForStore(); @Autowired private CommentPublicQueryServiceImpl commentPublicQueryService; @BeforeEach void setUp() { Flux.fromIterable(storedComments) .flatMap(comment -> client.create(comment)) .as(StepVerifier::create) .expectNextCount(storedComments.size()) .verifyComplete(); } @AfterEach void tearDown() { Flux.fromIterable(storedComments) .flatMap(CommentPublicQueryServiceIntegrationTest.this::deleteImmediately) .as(StepVerifier::create) .expectNextCount(storedComments.size()) .verifyComplete(); } @Test void listWhenUserNotLogin() { Ref ref = Ref.of("fake-post", GroupVersionKind.fromExtension(Post.class)); commentPublicQueryService.list(ref, 1, 10) .as(StepVerifier::create) .consumeNextWith(listResult -> { assertThat(listResult.getTotal()).isEqualTo(2); assertThat(listResult.getItems().size()).isEqualTo(2); assertThat(listResult.getItems().get(0).getMetadata().getName()) .isEqualTo("comment-approved"); }) .verifyComplete(); } @Test @WithMockUser(username = AnonymousUserConst.PRINCIPAL) void listWhenUserIsAnonymous() { Ref ref = Ref.of("fake-post", GroupVersionKind.fromExtension(Post.class)); commentPublicQueryService.list(ref, 1, 10) .as(StepVerifier::create) .consumeNextWith(listResult -> { assertThat(listResult.getTotal()).isEqualTo(2); assertThat(listResult.getItems().size()).isEqualTo(2); assertThat(listResult.getItems().get(0).getMetadata().getName()) .isEqualTo("comment-approved"); }) .verifyComplete(); } @Test @WithMockUser(username = "fake-user") void listWhenUserLoggedIn() { Ref ref = Ref.of("fake-post", GroupVersionKind.fromExtension(Post.class)); commentPublicQueryService.list(ref, 1, 10) .as(StepVerifier::create) .consumeNextWith(listResult -> { assertThat(listResult.getTotal()).isEqualTo(3); assertThat(listResult.getItems().size()).isEqualTo(3); assertThat(listResult.getItems().get(0).getMetadata().getName()) .isEqualTo("comment-approved"); assertThat(listResult.getItems().get(1).getMetadata().getName()) .isEqualTo("comment-approved-but-another-owner"); assertThat(listResult.getItems().get(2).getMetadata().getName()) .isEqualTo("comment-not-approved"); }) .verifyComplete(); } List commentsForStore() { // Mock Comment commentNotApproved = fakeComment(); commentNotApproved.getMetadata().setName("comment-not-approved"); commentNotApproved.getSpec().setApproved(false); Comment commentApproved = fakeComment(); commentApproved.getMetadata().setName("comment-approved"); commentApproved.getSpec().setApproved(true); Comment notApprovedWithAnonymous = fakeComment(); notApprovedWithAnonymous.getMetadata().setName("comment-not-approved-anonymous"); notApprovedWithAnonymous.getSpec().setApproved(false); notApprovedWithAnonymous.getSpec().getOwner().setName(AnonymousUserConst.PRINCIPAL); Comment commentApprovedButAnotherOwner = fakeComment(); commentApprovedButAnotherOwner.getMetadata() .setName("comment-approved-but-another-owner"); commentApprovedButAnotherOwner.getSpec().setApproved(true); commentApprovedButAnotherOwner.getSpec().getOwner().setName("another"); Comment commentNotApprovedAndAnotherOwner = fakeComment(); commentNotApprovedAndAnotherOwner.getMetadata() .setName("comment-not-approved-and-another"); commentNotApprovedAndAnotherOwner.getSpec().setApproved(false); commentNotApprovedAndAnotherOwner.getSpec().getOwner().setName("another"); Comment notApprovedAndAnotherRef = fakeComment(); notApprovedAndAnotherRef.getMetadata() .setName("comment-not-approved-and-another-ref"); notApprovedAndAnotherRef.getSpec().setApproved(false); Ref anotherRef = Ref.of("another-fake-post", GroupVersionKind.fromExtension(Post.class)); notApprovedAndAnotherRef.getSpec().setSubjectRef(anotherRef); return List.of( commentNotApproved, commentApproved, commentApprovedButAnotherOwner, commentNotApprovedAndAnotherOwner, notApprovedWithAnonymous, notApprovedAndAnotherRef ); } Comment fakeComment() { Comment comment = createComment(); comment.getMetadata().setDeletionTimestamp(null); comment.getMetadata().setName("fake-comment"); comment.getSpec().setRaw("fake-raw"); comment.getSpec().setContent("fake-content"); comment.getSpec().setHidden(false); comment.getSpec() .setSubjectRef(Ref.of("fake-post", GroupVersionKind.fromExtension(Post.class))); Comment.CommentOwner commentOwner = new Comment.CommentOwner(); commentOwner.setKind(User.KIND); commentOwner.setName("fake-user"); commentOwner.setDisplayName("fake-display-name"); comment.getSpec().setOwner(commentOwner); return comment; } } @Nested class CommentDefaultSortTest { private final List commentList = createCommentList(); @BeforeEach void setUp() { Flux.fromIterable(commentList) .flatMap(comment -> client.create(comment)) .as(StepVerifier::create) .expectNextCount(commentList.size()) .verifyComplete(); } @AfterEach void tearDown() { Flux.fromIterable(commentList) .flatMap(CommentPublicQueryServiceIntegrationTest.this::deleteImmediately) .as(StepVerifier::create) .expectNextCount(commentList.size()) .verifyComplete(); } @Test void sortTest() { var comments = client.listAll(Comment.class, new ListOptions(), CommentPublicQueryServiceImpl.defaultCommentSort()) .collectList() .block(); assertThat(comments).isNotNull(); var result = comments.stream() .map(comment -> comment.getMetadata().getName()) .collect(Collectors.joining(", ")); assertThat(result).isEqualTo("1, 2, 4, 3, 5, 6, 10, 14, 9, 8, 7, 11, 12, 13"); } List createCommentList() { // 1, now + 1s, top, 0 // 2, now + 2s, top, 1 // 3, now + 3s, top, 2 // 4, now + 4s, top, 2 // 5, now + 4s, top, 3 // 6, now + 4s, no, 0 // 7, now + 1s, no, 0 // 8, now + 2s, no, 0 // 9, now + 3s, no, 0 // 10, null, no, 0 // 11, null, no, 1 // 12, null, no, 3 // 13, now + 3s, no, 3 Instant now = Instant.now(); var comment1 = commentForCompare("1", now.plusSeconds(1), true, 0); var comment2 = commentForCompare("2", now.plusSeconds(2), true, 1); var comment3 = commentForCompare("3", now.plusSeconds(3), true, 2); var comment4 = commentForCompare("4", now.plusSeconds(4), true, 2); var comment5 = commentForCompare("5", now.plusSeconds(4), true, 3); var comment6 = commentForCompare("6", now.plusSeconds(4), true, 3); var comment7 = commentForCompare("7", now.plusSeconds(1), false, 0); var comment8 = commentForCompare("8", now.plusSeconds(2), false, 0); var comment9 = commentForCompare("9", now.plusSeconds(3), false, 0); var comment10 = commentForCompare("10", now.plusSeconds(3), false, 0); var comment11 = commentForCompare("11", now, false, 0); var comment12 = commentForCompare("12", now, false, 1); var comment13 = commentForCompare("13", now, false, 3); var comment14 = commentForCompare("14", now.plusSeconds(3), false, 3); return List.of(comment1, comment2, comment3, comment4, comment5, comment6, comment7, comment8, comment9, comment10, comment11, comment12, comment13, comment14); } Comment commentForCompare(String name, Instant creationTime, boolean top, int priority) { var comment = createComment(); comment.getMetadata().setName(name); comment.getMetadata().setCreationTimestamp(creationTime); comment.getSpec().setCreationTime(creationTime); comment.getSpec().setTop(top); comment.getSpec().setPriority(priority); return comment; } } @Nested class ListReplyTest { private final List storedReplies = mockRelies(); @Autowired private CommentPublicQueryServiceImpl commentPublicQueryService; @BeforeEach void setUp() { // create comment var comment = createComment(); client.create(comment) .onErrorResume(DuplicateNameException.class, e -> Mono.just(comment)) .as(StepVerifier::create) .expectNextCount(1) .verifyComplete(); Flux.fromIterable(storedReplies) .flatMap(reply -> client.create(reply)) .as(StepVerifier::create) .expectNextCount(storedReplies.size()) .verifyComplete(); } @AfterEach void tearDown() { Flux.fromIterable(storedReplies) .flatMap(CommentPublicQueryServiceIntegrationTest.this::deleteImmediately) .as(StepVerifier::create) .expectNextCount(storedReplies.size()) .verifyComplete(); } @Test void listWhenUserNotLogin() { commentPublicQueryService.listReply("fake-comment", 1, 10) .as(StepVerifier::create) .consumeNextWith(listResult -> { assertThat(listResult.getTotal()).isEqualTo(2); assertThat(listResult.getItems().size()).isEqualTo(2); assertThat(listResult.getItems().get(0).getMetadata().getName()) .isEqualTo("reply-approved"); }) .verifyComplete(); } @Test @WithMockUser(username = AnonymousUserConst.PRINCIPAL) void listWhenUserIsAnonymous() { commentPublicQueryService.listReply("fake-comment", 1, 10) .as(StepVerifier::create) .consumeNextWith(listResult -> { assertThat(listResult.getTotal()).isEqualTo(2); assertThat(listResult.getItems().size()).isEqualTo(2); assertThat(listResult.getItems().get(0).getMetadata().getName()) .isEqualTo("reply-approved"); }) .verifyComplete(); } @Test @WithMockUser(username = "fake-user") void listWhenUserLoggedIn() { commentPublicQueryService.listReply("fake-comment", 1, 10) .as(StepVerifier::create) .consumeNextWith(listResult -> { assertThat(listResult.getTotal()).isEqualTo(3); assertThat(listResult.getItems().size()).isEqualTo(3); assertThat(listResult.getItems().get(0).getMetadata().getName()) .isEqualTo("reply-approved"); assertThat(listResult.getItems().get(1).getMetadata().getName()) .isEqualTo("reply-approved-but-another-owner"); assertThat(listResult.getItems().get(2).getMetadata().getName()) .isEqualTo("reply-not-approved"); }) .verifyComplete(); } @Test void desensitizeReply() throws JSONException { var reply = createReply(); reply.getSpec().getOwner() .setAnnotations(new HashMap<>() { { put(Comment.CommentOwner.KIND_EMAIL, "mail@halo.run"); } }); reply.getSpec().setIpAddress("127.0.0.1"); var result = commentPublicQueryService.toReplyVo(reply).block(); result.getMetadata().setCreationTimestamp(null); var jsonObject = JsonUtils.jsonToObject(fakeReplyJson(), JsonNode.class); ((ObjectNode) jsonObject.get("owner")) .put("displayName", "已删除用户"); JSONAssert.assertEquals(jsonObject.toString(), JsonUtils.objectToJson(result), true); } String fakeReplyJson() { return """ { "metadata":{ "name":"fake-reply" }, "spec":{ "raw":"fake-raw", "content":"fake-content", "owner":{ "kind":"User", "name":"", "displayName":"fake-display-name", "annotations":{ "email-hash": \ "79783106d88279c6c8f94f1f4dec22bdb9f90a8d14c9d6c6628a11430e236cbf" } }, "creationTime": "2024-03-11T06:23:42.923294424Z", "ipAddress":"", "hidden": false, "allowNotification": false, "top": false, "priority": 0, "commentName":"fake-comment" }, "owner":{ "kind":"User", "displayName":"fake-display-name" }, "stats":{ "upvote":0 } } """; } private List mockRelies() { // Mock Reply notApproved = createReply(); notApproved.getMetadata().setName("reply-not-approved"); notApproved.getSpec().setApproved(false); Reply approved = createReply(); approved.getMetadata().setName("reply-approved"); approved.getSpec().setApproved(true); Reply notApprovedWithAnonymous = createReply(); notApprovedWithAnonymous.getMetadata().setName("reply-not-approved-anonymous"); notApprovedWithAnonymous.getSpec().setApproved(false); notApprovedWithAnonymous.getSpec().getOwner().setName(AnonymousUserConst.PRINCIPAL); Reply approvedButAnotherOwner = createReply(); approvedButAnotherOwner.getMetadata() .setName("reply-approved-but-another-owner"); approvedButAnotherOwner.getSpec().setApproved(true); approvedButAnotherOwner.getSpec().getOwner().setName("another"); Reply notApprovedAndAnotherOwner = createReply(); notApprovedAndAnotherOwner.getMetadata() .setName("reply-not-approved-and-another"); notApprovedAndAnotherOwner.getSpec().setApproved(false); notApprovedAndAnotherOwner.getSpec().getOwner().setName("another"); Reply notApprovedAndAnotherCommentName = createReply(); notApprovedAndAnotherCommentName.getMetadata() .setName("reply-approved-and-another-comment-name"); notApprovedAndAnotherCommentName.getSpec().setApproved(false); notApprovedAndAnotherCommentName.getSpec().setCommentName("another-fake-comment"); return List.of( notApproved, approved, approvedButAnotherOwner, notApprovedAndAnotherOwner, notApprovedWithAnonymous, notApprovedAndAnotherCommentName ); } Reply createReply() { var reply = JsonUtils.jsonToObject(fakeReplyJson(), Reply.class); reply.getMetadata().setName("fake-reply"); reply.getSpec().setRaw("fake-raw"); reply.getSpec().setContent("fake-content"); reply.getSpec().setHidden(false); reply.getSpec().setCommentName("fake-comment"); Comment.CommentOwner commentOwner = new Comment.CommentOwner(); commentOwner.setKind(User.KIND); commentOwner.setName("fake-user"); commentOwner.setDisplayName("fake-display-name"); reply.getSpec().setOwner(commentOwner); return reply; } } Comment createComment() { return JsonUtils.jsonToObject(""" { "spec": { "raw": "fake-raw", "content": "fake-content", "owner": { "kind": "User", "name": "fake-user" }, "userAgent": "", "ipAddress": "", "approvedTime": "2024-02-28T09:15:16.095Z", "creationTime": "2024-02-28T06:23:42.923294424Z", "priority": 0, "top": false, "allowNotification": false, "approved": true, "hidden": false, "subjectRef": { "group": "content.halo.run", "version": "v1alpha1", "kind": "SinglePage", "name": "67" }, "lastReadTime": "2024-02-29T03:39:04.230Z" }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Comment", "metadata": { "name": "fake-comment", "creationTimestamp": "2024-02-28T06:23:42.923439037Z" } } """, Comment.class); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/finders/impl/MenuFinderImplTest.java ================================================ package run.halo.app.theme.finders.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Flux; import reactor.util.function.Tuple2; import reactor.util.function.Tuples; import run.halo.app.core.extension.Menu; import run.halo.app.core.extension.MenuItem; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.theme.finders.vo.MenuVo; /** * Tests for {@link MenuFinderImpl}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class MenuFinderImplTest { @Mock private ReactiveExtensionClient client; @InjectMocks private MenuFinderImpl menuFinder; @Test void listAsTree() { Tuple2, List> tuple = testTree(); Mockito.when(client.list(eq(Menu.class), eq(null), eq(null))) .thenReturn(Flux.fromIterable(tuple.getT1())); Mockito.when(client.list(eq(MenuItem.class), eq(null), any())) .thenReturn(Flux.fromIterable(tuple.getT2())); List menuVos = menuFinder.listAsTree().collectList().block(); assertThat(visualizeTree(menuVos)).isEqualTo(""" D └── E ├── A │ └── B └── C X └── G Y └── F └── H """); } /** * Visualize a tree. */ String visualizeTree(List menuVos) { StringBuilder stringBuilder = new StringBuilder(); for (MenuVo menuVo : menuVos) { menuVo.print(stringBuilder); } return stringBuilder.toString(); } Tuple2, List> testTree() { /* * D * ├── E * │ ├── A * │ │ └── B * │ └── C * X── G * Y── F * └── H */ Menu menuD = menu("D", of("E")); Menu menuX = menu("X", of("G")); Menu menuY = menu("Y", of("F")); MenuItem itemE = menuItem("E", of("A", "C", "non-existent-children-name")); MenuItem itemG = menuItem("G", null); MenuItem itemF = menuItem("F", of("H")); MenuItem itemA = menuItem("A", of("B")); MenuItem itemB = menuItem("B", null); MenuItem itemC = menuItem("C", null); MenuItem itemH = menuItem("H", null); return Tuples.of(List.of(menuD, menuX, menuY), List.of(itemE, itemG, itemF, itemA, itemB, itemC, itemH)); } LinkedHashSet of(String... names) { LinkedHashSet list = new LinkedHashSet<>(); Collections.addAll(list, names); return list; } Menu menu(String name, LinkedHashSet menuItemNames) { Menu menu = new Menu(); Metadata metadata = new Metadata(); metadata.setName(name); menu.setMetadata(metadata); Menu.Spec spec = new Menu.Spec(); spec.setDisplayName(name); spec.setMenuItems(menuItemNames); menu.setSpec(spec); return menu; } MenuItem menuItem(String name, LinkedHashSet childrenNames) { MenuItem menuItem = new MenuItem(); Metadata metadata = new Metadata(); metadata.setName(name); menuItem.setMetadata(metadata); MenuItem.MenuItemSpec spec = new MenuItem.MenuItemSpec(); spec.setPriority(0); spec.setDisplayName(name); spec.setChildren(childrenNames); menuItem.setSpec(spec); MenuItem.MenuItemStatus status = new MenuItem.MenuItemStatus(); menuItem.setStatus(status); return menuItem; } } ================================================ FILE: application/src/test/java/run/halo/app/theme/finders/impl/PluginFinderImplTest.java ================================================ package run.halo.app.theme.finders.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.pf4j.DefaultVersionManager; import org.pf4j.PluginDescriptor; import org.pf4j.PluginManager; import org.pf4j.PluginState; import org.pf4j.PluginWrapper; /** * Tests for {@link PluginFinderImpl}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class PluginFinderImplTest { @Mock private PluginManager haloPluginManager; @InjectMocks private PluginFinderImpl pluginFinder; @Test void available() { assertThat(pluginFinder.available(null)).isFalse(); boolean available = pluginFinder.available("fake-plugin"); assertThat(available).isFalse(); PluginWrapper mockPluginWrapper = Mockito.mock(PluginWrapper.class); when(haloPluginManager.getPlugin(eq("fake-plugin"))) .thenReturn(mockPluginWrapper); when(mockPluginWrapper.getPluginState()).thenReturn(PluginState.RESOLVED); available = pluginFinder.available("fake-plugin"); assertThat(available).isFalse(); when(mockPluginWrapper.getPluginState()).thenReturn(PluginState.STARTED); available = pluginFinder.available("fake-plugin"); assertThat(available).isTrue(); } @Test void availableWithVersionTest() { when(haloPluginManager.getVersionManager()).thenReturn(new DefaultVersionManager()); assertThatThrownBy(() -> pluginFinder.available("fake-plugin", null)) .isInstanceOf(IllegalArgumentException.class); boolean available = pluginFinder.available("fake-plugin", "1.0.0"); assertThat(available).isFalse(); PluginWrapper mockPluginWrapper = Mockito.mock(PluginWrapper.class); when(haloPluginManager.getPlugin(eq("fake-plugin"))) .thenReturn(mockPluginWrapper); when(mockPluginWrapper.getPluginState()).thenReturn(PluginState.STARTED); var descriptor = mock(PluginDescriptor.class); when(mockPluginWrapper.getDescriptor()).thenReturn(descriptor); when(descriptor.getVersion()).thenReturn("1.0.0"); available = pluginFinder.available("fake-plugin", "1.0.0"); assertThat(available).isTrue(); available = pluginFinder.available("fake-plugin", ">=1.0.0"); assertThat(available).isTrue(); available = pluginFinder.available("fake-plugin", "<2.0.0"); assertThat(available).isTrue(); available = pluginFinder.available("fake-plugin", "2.0.0"); assertThat(available).isFalse(); available = pluginFinder.available("fake-plugin", "<1.0.0"); assertThat(available).isFalse(); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplIntegrationTest.java ================================================ package run.halo.app.theme.finders.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.thymeleaf.IEngineConfiguration; import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.Context; import org.thymeleaf.spring6.SpringTemplateEngine; import org.thymeleaf.spring6.dialect.SpringStandardDialect; import org.thymeleaf.standard.expression.IStandardVariableExpressionEvaluator; import org.thymeleaf.templateresolver.StringTemplateResolver; import org.thymeleaf.templateresource.ITemplateResource; import org.thymeleaf.templateresource.StringTemplateResource; import reactor.core.publisher.Mono; import run.halo.app.extension.ListResult; import run.halo.app.theme.ReactiveSpelVariableExpressionEvaluator; import run.halo.app.theme.finders.PostPublicQueryService; /** * Tests for {@link PostFinderImpl}. * * @author guqing * @since 2.19.0 */ @ExtendWith(MockitoExtension.class) class PostFinderImplIntegrationTest { private TemplateEngine templateEngine; @Mock private PostPublicQueryService postPublicQueryService; @InjectMocks private PostFinderImpl postFinder; @Mock private TemplateResourceComputer templateResourceComputer; @BeforeEach void setUp() { templateEngine = new SpringTemplateEngine(); templateEngine.setDialect(new SpringStandardDialect() { @Override public IStandardVariableExpressionEvaluator getVariableExpressionEvaluator() { return ReactiveSpelVariableExpressionEvaluator.INSTANCE; } }); templateEngine.addTemplateResolver(new TestTemplateResolver(templateResourceComputer)); } @Test void listTest() { var context = new Context(); context.setVariable("postFinder", postFinder); // empty param when(templateResourceComputer.compute(eq("post"))).thenReturn(new StringTemplateResource(""" """)); when(postPublicQueryService.list(any(), any())) .thenReturn(Mono.just(ListResult.emptyResult())); var result = templateEngine.process("post", context); assertThat(result).isEqualToIgnoringWhitespace( "ListResult(page=0, size=0, total=0, items=[])"); when(templateResourceComputer.compute(eq("post"))).thenReturn(new StringTemplateResource(""" """)); result = templateEngine.process("post", context); assertThat(result).isEqualToIgnoringWhitespace(""); } static class TestTemplateResolver extends StringTemplateResolver { private final TemplateResourceComputer templateResourceComputer; TestTemplateResolver(TemplateResourceComputer templateResourceComputer) { this.templateResourceComputer = templateResourceComputer; } @Override protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration, String ownerTemplate, String template, Map templateResolutionAttributes) { return templateResourceComputer.compute(template); } } interface TemplateResourceComputer { ITemplateResource compute(String template); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java ================================================ package run.halo.app.theme.finders.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Predicate; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Sort; import reactor.core.publisher.Mono; import run.halo.app.content.PostService; import run.halo.app.core.counter.CounterService; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.theme.finders.CategoryFinder; import run.halo.app.theme.finders.ContributorFinder; import run.halo.app.theme.finders.PostPublicQueryService; import run.halo.app.theme.finders.TagFinder; import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.finders.vo.PostArchiveVo; import run.halo.app.theme.finders.vo.PostArchiveYearMonthVo; import run.halo.app.theme.router.DefaultQueryPostPredicateResolver; /** * Tests for {@link PostFinderImpl}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class PostFinderImplTest { @Mock private ReactiveExtensionClient client; @Mock private CounterService counterService; @Mock private PostService postService; @Mock private CategoryFinder categoryFinder; @Mock private TagFinder tagFinder; @Mock private ContributorFinder contributorFinder; @Mock private PostPublicQueryService publicQueryService; @InjectMocks private PostFinderImpl postFinder; @Test void predicate() { Predicate predicate = new DefaultQueryPostPredicateResolver().getPredicate().block(); assertThat(predicate).isNotNull(); List strings = posts().stream().filter(predicate) .map(post -> post.getMetadata().getName()) .toList(); assertThat(strings).isEqualTo(List.of("post-1", "post-2", "post-6")); } @Test void archives() { List listedPostVos = postsForArchives().stream() .map(ListedPostVo::from) .toList(); ListResult listResult = new ListResult<>(1, 10, 3, listedPostVos); when(publicQueryService.list(any(), any(PageRequest.class))) .thenReturn(Mono.just(listResult)); ListResult archives = postFinder.archives(1, 10).block(); assertThat(archives).isNotNull(); List items = archives.getItems(); assertThat(items.size()).isEqualTo(2); assertThat(items.get(0).getYear()).isEqualTo("2022"); assertThat(items.get(0).getMonths().size()).isEqualTo(1); List months = items.get(0).getMonths(); assertThat(months.get(0).getMonth()).isEqualTo("12"); assertThat(months.get(0).getPosts()).hasSize(2); assertThat(items.get(1).getYear()).isEqualTo("2021"); assertThat(items.get(1).getMonths()).hasSize(1); assertThat(items.get(1).getMonths().get(0).getMonth()).isEqualTo("01"); } List postsForArchives() { Post post1 = post(1); post1.getSpec().setPublish(true); post1.getSpec().setPublishTime(Instant.parse("2021-01-01T00:00:00Z")); post1.getMetadata().setCreationTimestamp(Instant.now()); Post post2 = post(2); post2.getSpec().setPublish(true); post2.getSpec().setPublishTime(Instant.parse("2022-12-01T00:00:00Z")); post2.getMetadata().setCreationTimestamp(Instant.now()); Post post3 = post(3); post3.getSpec().setPublish(true); post3.getSpec().setPublishTime(Instant.parse("2022-12-03T00:00:00Z")); post3.getMetadata().setCreationTimestamp(Instant.now()); return List.of(post1, post2, post3); } List posts() { // 置顶的排前面按 priority 排序 // 再根据发布时间排序 // 相同再根据名称排序 // 6, 2, 1, 5, 4, 3 Post post1 = post(1); post1.getSpec().setPinned(false); post1.getSpec().setPublishTime(Instant.now().plusSeconds(20)); Post post2 = post(2); post2.getSpec().setPinned(true); post2.getSpec().setPriority(2); post2.getSpec().setPublishTime(Instant.now()); Post post3 = post(3); post3.getSpec().setDeleted(true); post3.getSpec().setPublishTime(Instant.now()); Post post4 = post(4); post4.getSpec().setVisible(Post.VisibleEnum.PRIVATE); post4.getSpec().setPublishTime(Instant.now()); Post post5 = post(5); post5.getSpec().setPublish(false); post5.getMetadata().getLabels().clear(); post5.getSpec().setPublishTime(Instant.now()); Post post6 = post(6); post6.getSpec().setPinned(true); post6.getSpec().setPriority(3); post6.getSpec().setPublishTime(Instant.now()); return List.of(post1, post2, post3, post4, post5, post6); } Post post(int i) { final Post post = new Post(); Metadata metadata = new Metadata(); metadata.setName("post-" + i); metadata.setCreationTimestamp(Instant.now()); metadata.setAnnotations(Map.of("K1", "V1")); metadata.setLabels(new HashMap<>()); metadata.getLabels().put(Post.PUBLISHED_LABEL, "true"); post.setMetadata(metadata); Post.PostSpec postSpec = new Post.PostSpec(); postSpec.setDeleted(false); postSpec.setAllowComment(true); postSpec.setPublishTime(Instant.now()); postSpec.setPinned(false); postSpec.setPriority(0); postSpec.setPublish(true); postSpec.setVisible(Post.VisibleEnum.PUBLIC); postSpec.setTitle("title-" + i); postSpec.setSlug("slug-" + i); post.setSpec(postSpec); Post.PostStatus postStatus = new Post.PostStatus(); postStatus.setPermalink("/post-" + i); postStatus.setContributors(List.of("contributor-1", "contributor-2")); postStatus.setExcerpt("hello world!"); post.setStatus(postStatus); return post; } @Nested class PostQueryTest { @Test void toPageRequestTest() { var query = new PostFinderImpl.PostQuery(); var result = query.toPageRequest(); assertThat(result.getSort()).isEqualTo(PostFinderImpl.defaultSort()); query.setSort(List.of("spec.publishTime,desc")); result = query.toPageRequest(); assertThat(result.getSort()) .isEqualTo(Sort.by(Sort.Order.desc("spec.publishTime")) .and(PostFinderImpl.defaultSort())); } } } ================================================ FILE: application/src/test/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImplTest.java ================================================ package run.halo.app.theme.finders.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.content.ContentWrapper; import run.halo.app.content.TestPost; import run.halo.app.core.extension.content.Post; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.theme.ReactivePostContentHandler; /** * Tests for {@link PostPublicQueryServiceImpl}. * * @author guqing * @since 2.7.0 */ @ExtendWith(MockitoExtension.class) class PostPublicQueryServiceImplTest { @Mock private ExtensionGetter extensionGetter; @InjectMocks private PostPublicQueryServiceImpl postPublicQueryService; @Test void extendPostContent() { when(extensionGetter.getEnabledExtensions( eq(ReactivePostContentHandler.class))).thenReturn( Flux.just(new PostContentHandlerB(), new PostContentHandlerA(), new PostContentHandlerC())); Post post = TestPost.postV1(); post.getMetadata().setName("fake-post"); ContentWrapper contentWrapper = ContentWrapper.builder().content("fake-content").raw("fake-raw").rawType("markdown") .build(); postPublicQueryService.extendPostContent(post, contentWrapper) .as(StepVerifier::create).consumeNextWith(contentVo -> { assertThat(contentVo.getContent()).isEqualTo("fake-content-B-A-C"); }).verifyComplete(); } static class PostContentHandlerA implements ReactivePostContentHandler { @Override public Mono handle(PostContentContext postContent) { postContent.setContent(postContent.getContent() + "-A"); return Mono.just(postContent); } } static class PostContentHandlerB implements ReactivePostContentHandler { @Override public Mono handle(PostContentContext postContent) { postContent.setContent(postContent.getContent() + "-B"); return Mono.just(postContent); } } static class PostContentHandlerC implements ReactivePostContentHandler { @Override public Mono handle(PostContentContext postContent) { postContent.setContent(postContent.getContent() + "-C"); return Mono.just(postContent); } } } ================================================ FILE: application/src/test/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImplTest.java ================================================ package run.halo.app.theme.finders.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.lang.NonNull; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.content.ContentWrapper; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.Metadata; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.theme.ReactiveSinglePageContentHandler; /** * Tests for {@link SinglePageConversionServiceImpl}. * * @author guqing * @since 2.7.0 */ @ExtendWith(MockitoExtension.class) class SinglePageConversionServiceImplTest { @Mock private ExtensionGetter extensionGetter; @InjectMocks private SinglePageConversionServiceImpl pageConversionService; @Test void extendPageContent() { when(extensionGetter.getEnabledExtensions( eq(ReactiveSinglePageContentHandler.class))) .thenReturn( Flux.just(new PageContentHandlerB(), new PageContentHandlerA(), new PageContentHandlerC()) ); ContentWrapper contentWrapper = ContentWrapper.builder() .content("fake-content") .raw("fake-raw") .rawType("markdown") .build(); SinglePage singlePage = new SinglePage(); singlePage.setMetadata(new Metadata()); singlePage.getMetadata().setName("fake-page"); pageConversionService.extendPageContent(singlePage, contentWrapper) .as(StepVerifier::create) .consumeNextWith(contentVo -> { assertThat(contentVo.getContent()).isEqualTo("fake-content-B-A-C"); }) .verifyComplete(); } static class PageContentHandlerA implements ReactiveSinglePageContentHandler { @Override public Mono handle( @NonNull SinglePageContentContext pageContent) { pageContent.setContent(pageContent.getContent() + "-A"); return Mono.just(pageContent); } } static class PageContentHandlerB implements ReactiveSinglePageContentHandler { @Override public Mono handle( @NonNull SinglePageContentContext pageContent) { pageContent.setContent(pageContent.getContent() + "-B"); return Mono.just(pageContent); } } static class PageContentHandlerC implements ReactiveSinglePageContentHandler { @Override public Mono handle( @NonNull SinglePageContentContext pageContent) { pageContent.setContent(pageContent.getContent() + "-C"); return Mono.just(pageContent); } } } ================================================ FILE: application/src/test/java/run/halo/app/theme/finders/impl/SinglePageFinderImplTest.java ================================================ package run.halo.app.theme.finders.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.theme.finders.SinglePageConversionService; import run.halo.app.theme.finders.vo.SinglePageVo; /** * Tests for {@link SinglePageFinderImpl}. * * @author guqing * @since 2.0.1 */ @ExtendWith(MockitoExtension.class) class SinglePageFinderImplTest { @Mock private ReactiveExtensionClient client; @Mock private SinglePageConversionService singlePageConversionService; @InjectMocks private SinglePageFinderImpl singlePageFinder; @Test void getByName() { // fix gh-2992 String fakePageName = "fake-page"; SinglePage singlePage = new SinglePage(); singlePage.setMetadata(new Metadata()); singlePage.getMetadata().setName(fakePageName); singlePage.getMetadata().setLabels(Map.of(SinglePage.PUBLISHED_LABEL, "true")); singlePage.setSpec(new SinglePage.SinglePageSpec()); singlePage.getSpec().setOwner("fake-owner"); singlePage.getSpec().setReleaseSnapshot("fake-release"); singlePage.getSpec().setPublish(true); singlePage.getSpec().setDeleted(false); singlePage.getSpec().setVisible(Post.VisibleEnum.PUBLIC); singlePage.setStatus(new SinglePage.SinglePageStatus()); when(client.get(eq(SinglePage.class), eq(fakePageName))) .thenReturn(Mono.just(singlePage)); when(singlePageConversionService.convertToVo(eq(singlePage))) .thenReturn(Mono.just(mock(SinglePageVo.class))); singlePageFinder.getByName(fakePageName) .as(StepVerifier::create) .consumeNextWith(page -> assertThat(page).isNotNull()) .verifyComplete(); verify(client).get(SinglePage.class, fakePageName); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/finders/impl/TagFinderImplTest.java ================================================ package run.halo.app.theme.finders.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.json.JSONException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.data.domain.Sort; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.theme.finders.vo.TagVo; /** * Tests for {@link TagFinderImpl}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class TagFinderImplTest { @Mock private ReactiveExtensionClient client; private TagFinderImpl tagFinder; @BeforeEach void setUp() { tagFinder = new TagFinderImpl(client); } @Test void getByName() throws JSONException { when(client.fetch(eq(Tag.class), eq("t1"))) .thenReturn(Mono.just(tag(1))); TagVo tagVo = tagFinder.getByName("t1").block(); tagVo.getMetadata().setCreationTimestamp(null); JSONAssert.assertEquals(""" { "metadata": { "name": "t1", "annotations": { "K1": "V1" } }, "spec": { "displayName": "displayName-1", "slug": "slug-1", "color": "color-1", "cover": "cover-1" }, "status": { "permalink": "permalink-1", "postCount": 2, "visiblePostCount": 1 }, "postCount": 1 } """, JsonUtils.objectToJson(tagVo), true); } @Test void listAll() { when(client.listAll(eq(Tag.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.fromIterable( tags().stream().sorted(TagFinderImpl.DEFAULT_COMPARATOR.reversed()).toList() ) ); List tags = tagFinder.listAll().collectList().block(); assertThat(tags).hasSize(3); assertThat(tags.stream() .map(tag -> tag.getMetadata().getName()) .collect(Collectors.toList())) .isEqualTo(List.of("t3", "t2", "t1")); } List tags() { Tag tag1 = tag(1); Tag tag2 = tag(2); tag2.getMetadata().setCreationTimestamp(Instant.now().plusSeconds(1)); Tag tag3 = tag(3); tag3.getMetadata().setCreationTimestamp(Instant.now().plusSeconds(2)); // sorted: 3, 2, 1 return List.of(tag2, tag1, tag3); } Tag tag(int i) { final Tag tag = new Tag(); Metadata metadata = new Metadata(); metadata.setName("t" + i); metadata.setAnnotations(Map.of("K1", "V1")); metadata.setCreationTimestamp(Instant.now()); tag.setMetadata(metadata); Tag.TagSpec tagSpec = new Tag.TagSpec(); tagSpec.setDisplayName("displayName-" + i); tagSpec.setSlug("slug-" + i); tagSpec.setColor("color-" + i); tagSpec.setCover("cover-" + i); tag.setSpec(tagSpec); Tag.TagStatus tagStatus = new Tag.TagStatus(); tagStatus.setPermalink("permalink-" + i); tagStatus.setPostCount(2); tagStatus.setVisiblePostCount(1); tag.setStatus(tagStatus); return tag; } } ================================================ FILE: application/src/test/java/run/halo/app/theme/finders/impl/ThumbnailFinderImplTest.java ================================================ package run.halo.app.theme.finders.impl; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.net.URI; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.attachment.thumbnail.ThumbnailService; /** * Tests for {@link ThumbnailFinderImpl}. * * @author guqing * @since 2.20.0 */ @ExtendWith(MockitoExtension.class) class ThumbnailFinderImplTest { @Mock ThumbnailService thumbnailService; @InjectMocks ThumbnailFinderImpl thumbnailFinder; @Test void shouldNotGenWhenUriIsInvalid() { thumbnailFinder.gen("invalid uri", "l") .as(StepVerifier::create) .expectNext("invalid uri") .verifyComplete(); verify(thumbnailService, never()).get(any(), any()); } @Test void shouldGenWhenUriIsValid() { when(thumbnailService.get(any(), any())) .thenReturn(Mono.just(URI.create("/test-thumb.jpg"))); thumbnailFinder.gen("/test.jpg", "l") .as(StepVerifier::create) .expectNext("/test-thumb.jpg") .verifyComplete(); verify(thumbnailService).get(any(), any()); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/finders/vo/UserVoTest.java ================================================ package run.halo.app.theme.finders.vo; import static org.assertj.core.api.Assertions.assertThat; import java.time.Instant; import org.json.JSONException; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import run.halo.app.core.extension.User; import run.halo.app.extension.Metadata; import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link UserVo}. * * @author guqing * @since 2.0.1 */ class UserVoTest { @Test void from() throws JSONException { User user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName("fake-user"); user.setSpec(new User.UserSpec()); user.getSpec().setPassword("123456"); user.getSpec().setEmail("example@example.com"); user.getSpec().setAvatar("avatar"); user.getSpec().setDisplayName("fake-user-display-name"); user.getSpec().setBio("user bio"); user.getSpec().setDisabled(false); user.getSpec().setPhone("123456789"); user.getSpec().setRegisteredAt(Instant.parse("2022-01-01T00:00:00.00Z")); user.getSpec().setLoginHistoryLimit(5); user.getSpec().setTwoFactorAuthEnabled(false); user.setStatus(new User.UserStatus()); UserVo userVo = UserVo.from(user); JSONAssert.assertEquals(""" { "metadata": { "name": "fake-user" }, "spec": { "displayName": "fake-user-display-name", "avatar": "avatar", "email": "example@example.com", "emailVerified": false, "phone": "123456789", "password": "[PROTECTED]", "bio": "user bio", "registeredAt": "2022-01-01T00:00:00Z", "twoFactorAuthEnabled": false, "disabled": false, "loginHistoryLimit": 5 }, "status": { } } """, JsonUtils.objectToJson(userVo), true); } @Test void fromWhenStatusIsNull() { User user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName("fake-user"); user.setSpec(new User.UserSpec()); UserVo userVo = UserVo.from(user); assertThat(userVo).isNotNull(); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java ================================================ package run.halo.app.theme.message; import static org.assertj.core.api.Assertions.assertThat; import java.io.FileNotFoundException; import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Path; import java.util.Locale; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.util.ResourceUtils; import run.halo.app.theme.ThemeContext; /** * @author guqing * @since 2.0.0 */ class ThemeMessageResolutionUtilsTest { private URL defaultThemeUrl; @BeforeEach void setUp() throws FileNotFoundException { defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default"); } @Test void resolveMessagesForTemplateForDefault() throws URISyntaxException { Map properties = ThemeMessageResolutionUtils.resolveMessagesForTemplate(Locale.CHINESE, getTheme()); assertThat(properties).isEqualTo(Map.of("index.welcome", "欢迎来到首页", "title", "来自 i18n/zh.properties 的标题")); } @Test void resolveMessagesForTemplateForEnglish() throws URISyntaxException { Map properties = ThemeMessageResolutionUtils.resolveMessagesForTemplate(Locale.ENGLISH, getTheme()); assertThat(properties).isEqualTo(Map.of("index.welcome", "Welcome to the index", "title", "这是来自 i18n/default.properties 的标题")); } ThemeContext getTheme() throws URISyntaxException { return ThemeContext.builder() .name("default") .path(Path.of(defaultThemeUrl.toURI())) .active(true) .build(); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java ================================================ package run.halo.app.theme.message; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import java.io.FileNotFoundException; import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.context.annotation.Bean; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.util.ResourceUtils; import org.springframework.web.reactive.function.server.RequestPredicates; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.infra.InitializationStateGetter; import run.halo.app.theme.ThemeContext; import run.halo.app.theme.ThemeResolver; /** * Tests for {@link ThemeMessageResolver}. * * @author guqing * @since 2.0.0 */ @SpringBootTest @AutoConfigureWebTestClient public class ThemeMessageResolverIntegrationTest { @MockitoSpyBean private ThemeResolver themeResolver; private URL defaultThemeUrl; private URL otherThemeUrl; @MockitoSpyBean private InitializationStateGetter initializationStateGetter; @Autowired private WebTestClient webTestClient; @BeforeEach void setUp() throws FileNotFoundException, URISyntaxException { when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true)); defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default"); otherThemeUrl = ResourceUtils.getURL("classpath:themes/other"); when(themeResolver.getTheme(any(ServerWebExchange.class))) .thenReturn(Mono.just(createDefaultContext())); } @Test void messageResolverWhenDefaultTheme() { webTestClient.get() .uri("/?language=zh") .exchange() .expectStatus() .isOk() .expectBody() .xpath("/html/body/div[1]").isEqualTo("zh") .xpath("/html/body/div[2]").isEqualTo("欢迎来到首页"); } @Test void messageResolverForEnLanguageWhenDefaultTheme() { webTestClient.get() .uri("/?language=en") .exchange() .expectStatus() .isOk() .expectBody() .xpath("/html/body/div[1]").isEqualTo("en") .xpath("/html/body/div[2]").isEqualTo("Welcome to the index"); } @Test void shouldUseDefaultWhenLanguageNotSupport() { webTestClient.get() .uri("/index?language=foo") .exchange() .expectStatus() .isOk() .expectBody() // make sure the "templates/index.properties" file is precedence over the // "i18n/default.properties". .xpath("/html/head/title").isEqualTo("Title from index.properties") .xpath("/html/body/div[1]").isEqualTo("foo") .xpath("/html/body/div[2]").isEqualTo("欢迎来到首页"); } @Test void switchTheme() throws URISyntaxException { webTestClient.get() .uri("/index?language=zh") .exchange() .expectStatus() .isOk() .expectBody() .xpath("/html/head/title").isEqualTo("来自 index_zh.properties 的标题") .xpath("/html/body/div[1]").isEqualTo("zh") .xpath("/html/body/div[2]").isEqualTo("欢迎来到首页") ; // For other theme when(themeResolver.getTheme(any(ServerWebExchange.class))) .thenReturn(Mono.just(createOtherContext())); webTestClient.get() .uri("/index?language=zh") .exchange() .expectBody() .xpath("/html/head/title").isEqualTo("Other theme title") .xpath("/html/body/p").isEqualTo("Other 首页"); webTestClient.get() .uri("/index?language=en") .exchange() .expectBody() .xpath("/html/head/title").isEqualTo("Other theme title") .xpath("/html/body/p").isEqualTo("other index"); } ThemeContext createDefaultContext() throws URISyntaxException { return ThemeContext.builder() .name("default") .path(Path.of(defaultThemeUrl.toURI())) .active(true) .build(); } ThemeContext createOtherContext() throws URISyntaxException { return ThemeContext.builder() .name("other") .path(Path.of(otherThemeUrl.toURI())) .active(false) .build(); } @TestConfiguration static class MessageResolverConfig { @Bean RouterFunction routeTestIndex() { return RouterFunctions .route(RequestPredicates.GET("/").or(RequestPredicates.GET("/index")) .and(RequestPredicates.accept(MediaType.TEXT_HTML)), request -> ServerResponse.ok().render("index")); } } } ================================================ FILE: application/src/test/java/run/halo/app/theme/router/EmptyView.java ================================================ package run.halo.app.theme.router; import java.util.Map; import org.springframework.http.MediaType; import org.springframework.web.server.ServerWebExchange; import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveView; import reactor.core.publisher.Mono; /** * Empty view for test. * * @author guqing * @since 2.0.0 */ public class EmptyView extends ThymeleafReactiveView { public EmptyView() { } @Override public Mono render(Map model, MediaType contentType, ServerWebExchange exchange) { return Mono.empty(); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/router/PageUrlUtilsTest.java ================================================ package run.halo.app.theme.router; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; /** * Tests for {@link PageUrlUtils}. * * @author guqing * @since 2.0.0 */ class PageUrlUtilsTest { static String s = "/tags"; static String s1 = "/tags/page/1"; static String s2 = "/tags/page/2"; static String s3 = "/tags/y/m/page/2"; static String s4 = "/tags/y/m"; static String s5 = "/tags/y/m/page/3"; @Test void nextPageUrl() { long totalPage = 10; assertThat(PageUrlUtils.nextPageUrl(s, totalPage)) .isEqualTo("/tags/page/2"); assertThat(PageUrlUtils.nextPageUrl(s2, totalPage)) .isEqualTo("/tags/page/3"); assertThat(PageUrlUtils.nextPageUrl(s3, totalPage)) .isEqualTo("/tags/y/m/page/3"); assertThat(PageUrlUtils.nextPageUrl(s4, totalPage)) .isEqualTo("/tags/y/m/page/2"); assertThat(PageUrlUtils.nextPageUrl(s5, totalPage)) .isEqualTo("/tags/y/m/page/4"); // The number of pages does not exceed the total number of pages totalPage = 1; assertThat(PageUrlUtils.nextPageUrl("/tags/page/1", totalPage)) .isEqualTo("/tags/page/1"); totalPage = 0; assertThat(PageUrlUtils.nextPageUrl("/tags", totalPage)) .isEqualTo("/tags/page/1"); } @Test void prevPageUrl() { assertThat(PageUrlUtils.prevPageUrl(s)) .isEqualTo("/tags"); assertThat(PageUrlUtils.prevPageUrl(s1)) .isEqualTo("/tags"); assertThat(PageUrlUtils.prevPageUrl(s2)) .isEqualTo("/tags"); assertThat(PageUrlUtils.prevPageUrl(s3)) .isEqualTo("/tags/y/m"); assertThat(PageUrlUtils.prevPageUrl(s4)) .isEqualTo("/tags/y/m"); assertThat(PageUrlUtils.prevPageUrl(s5)) .isEqualTo("/tags/y/m/page/2"); assertThat(PageUrlUtils.prevPageUrl("/page/2")) .isEqualTo("/"); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/router/PreviewRouterFunctionTest.java ================================================ package run.halo.app.theme.router; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.HandlerStrategies; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.result.view.ViewResolver; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.content.PostService; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.theme.ViewNameResolver; import run.halo.app.theme.finders.PostPublicQueryService; import run.halo.app.theme.finders.SinglePageConversionService; import run.halo.app.theme.finders.vo.ContributorVo; import run.halo.app.theme.finders.vo.PostVo; import run.halo.app.theme.finders.vo.SinglePageVo; /** * Tests for {@link PreviewRouterFunction}. * * @author guqing * @since 2.6.x */ @ExtendWith({SpringExtension.class, MockitoExtension.class}) class PreviewRouterFunctionTest { @Mock ReactiveExtensionClient client; @Mock PostPublicQueryService postPublicQueryService; @Mock ViewNameResolver viewNameResolver; @Mock ViewResolver viewResolver; @Mock PostService postService; @Mock SinglePageConversionService singlePageConversionService; @InjectMocks PreviewRouterFunction previewRouterFunction; WebTestClient webTestClient; @BeforeEach void setUp() { webTestClient = WebTestClient.bindToRouterFunction(previewRouterFunction.previewRouter()) .handlerStrategies(HandlerStrategies.builder() .viewResolver(viewResolver) .build()) .build(); } @Test @WithMockUser(username = "testuser") void previewPost() { when(viewResolver.resolveViewName(any(), any())) .thenReturn(Mono.just(new EmptyView() { @Override public Mono render(Map model, MediaType contentType, ServerWebExchange exchange) { return super.render(model, contentType, exchange); } })); Post post = new Post(); post.setMetadata(new Metadata()); post.getMetadata().setName("post1"); post.setSpec(new Post.PostSpec()); post.getSpec().setOwner("testuser"); post.getSpec().setHeadSnapshot("snapshot1"); post.getSpec().setBaseSnapshot("snapshot2"); post.getSpec().setTemplate("postTemplate"); when(client.fetch(eq(Post.class), eq("post1"))).thenReturn(Mono.just(post)); PostVo postVo = PostVo.from(post); postVo.setContributors(contributorVos()); when(postPublicQueryService.convertToVo(eq(post), eq(post.getSpec().getHeadSnapshot()))) .thenReturn(Mono.just(postVo)); when(viewNameResolver.resolveViewNameOrDefault(any(ServerRequest.class), eq("postTemplate"), eq("post"))).thenReturn(Mono.just("postView")); webTestClient.get().uri("/preview/posts/post1") .exchange() .expectStatus().isOk(); verify(viewResolver).resolveViewName(any(), any()); verify(postPublicQueryService).convertToVo(eq(post), eq(post.getSpec().getHeadSnapshot())); verify(client).fetch(eq(Post.class), eq("post1")); } @Test public void previewPostWhenUnAuthenticated() { webTestClient.get().uri("/preview/posts/post1") .exchange() .expectStatus().isEqualTo(404); } @Test @WithMockUser(username = "testuser") public void previewSinglePage() { when(viewResolver.resolveViewName(any(), any())) .thenReturn(Mono.just(new EmptyView() { @Override public Mono render(Map model, MediaType contentType, ServerWebExchange exchange) { return super.render(model, contentType, exchange); } })); SinglePage singlePage = new SinglePage(); singlePage.setMetadata(new Metadata()); singlePage.getMetadata().setName("page1"); singlePage.setSpec(new SinglePage.SinglePageSpec()); singlePage.getSpec().setOwner("testuser"); singlePage.getSpec().setHeadSnapshot("snapshot1"); singlePage.getSpec().setTemplate("pageTemplate"); when(client.fetch(SinglePage.class, "page1")).thenReturn(Mono.just(singlePage)); SinglePageVo singlePageVo = SinglePageVo.from(singlePage); singlePageVo.setContributors(contributorVos()); when(singlePageConversionService.convertToVo(singlePage, "snapshot1")) .thenReturn(Mono.just(singlePageVo)); when(viewNameResolver.resolveViewNameOrDefault(any(ServerRequest.class), eq("pageTemplate"), eq("page"))).thenReturn(Mono.just("pageView")); webTestClient.get().uri("/preview/singlepages/page1") .exchange() .expectStatus().isOk(); verify(viewResolver).resolveViewName(any(), any()); verify(client).fetch(eq(SinglePage.class), eq("page1")); } @Test public void previewSinglePageWhenUnAuthenticated() { webTestClient.get().uri("/preview/singlepages/page1") .exchange() .expectStatus().isEqualTo(404); } @Test @WithMockUser(username = AnonymousUserConst.PRINCIPAL) public void previewWithAnonymousUser() { webTestClient.get().uri("/preview/singlepages/page1") .exchange() .expectStatus().isEqualTo(404); } List contributorVos() { ContributorVo contributorA = ContributorVo.builder() .name("fake-user") .build(); ContributorVo contributorB = ContributorVo.builder() .name("testuser") .build(); return List.of(contributorA, contributorB); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/router/ReactiveQueryPostPredicateResolverTest.java ================================================ package run.halo.app.theme.router; import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.junit.jupiter.SpringExtension; import reactor.test.StepVerifier; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.Metadata; /** * Tests for {@link ReactiveQueryPostPredicateResolver}. * * @author guqing * @since 2.9.0 */ @ExtendWith(SpringExtension.class) class ReactiveQueryPostPredicateResolverTest { private ReactiveQueryPostPredicateResolver postPredicateResolver; @BeforeEach void setUp() { postPredicateResolver = new DefaultQueryPostPredicateResolver(); } @Test void getPredicateWithoutAuth() { postPredicateResolver.getPredicate() .as(StepVerifier::create) .consumeNextWith(predicate -> { Post post = new Post(); post.setMetadata(new Metadata()); post.getMetadata().setName("fake-post"); post.setSpec(new Post.PostSpec()); post.getSpec().setDeleted(false); post.getMetadata().setLabels(Map.of(Post.PUBLISHED_LABEL, "true")); post.getSpec().setVisible(Post.VisibleEnum.PRIVATE); assertThat(predicate.test(post)).isFalse(); post.getSpec().setVisible(Post.VisibleEnum.PUBLIC); assertThat(predicate.test(post)).isTrue(); post.getMetadata().setLabels(Map.of(Post.PUBLISHED_LABEL, "false")); assertThat(predicate.test(post)).isFalse(); }) .verifyComplete(); } @Test @WithMockUser(username = "halo") void getPredicateWithAuth() { postPredicateResolver.getPredicate() .as(StepVerifier::create) .consumeNextWith(predicate -> { Post post = new Post(); post.setMetadata(new Metadata()); post.getMetadata().setName("fake-post"); post.setSpec(new Post.PostSpec()); post.getSpec().setDeleted(false); post.getSpec().setOwner("halo"); post.getMetadata().setLabels(Map.of(Post.PUBLISHED_LABEL, "true")); post.getSpec().setVisible(Post.VisibleEnum.PRIVATE); assertThat(predicate.test(post)).isTrue(); post.getSpec().setOwner("guqing"); assertThat(predicate.test(post)).isFalse(); post.getSpec().setOwner("halo"); post.getSpec().setVisible(Post.VisibleEnum.PUBLIC); assertThat(predicate.test(post)).isTrue(); post.getSpec().setDeleted(true); assertThat(predicate.test(post)).isFalse(); post.getSpec().setVisible(Post.VisibleEnum.INTERNAL); assertThat(predicate.test(post)).isFalse(); }) .verifyComplete(); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/router/SinglePageRouteTest.java ================================================ package run.halo.app.theme.router; import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.net.URI; import java.time.Instant; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.i18n.SimpleLocaleContext; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.reactive.function.server.MockServerRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.HandlerStrategies; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.result.view.ViewResolver; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.i18n.LocaleContextResolver; import org.springframework.web.util.UriUtils; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.ViewNameResolver; import run.halo.app.theme.finders.SinglePageFinder; import run.halo.app.theme.finders.vo.SinglePageVo; import run.halo.app.theme.router.SinglePageRoute.NameSlugPair; /** * Tests for {@link SinglePageRoute}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class SinglePageRouteTest { @Mock ViewNameResolver viewNameResolver; @Mock SinglePageFinder singlePageFinder; @Mock ViewResolver viewResolver; @Mock ExtensionClient client; @Mock LocaleContextResolver localeContextResolver; @Mock TitleVisibilityIdentifyCalculator titleVisibilityIdentifyCalculator; @InjectMocks SinglePageRoute singlePageRoute; @Test void handlerFunction() { // fix gh-3448 when(viewNameResolver.resolveViewNameOrDefault(any(ServerRequest.class), any(), any())) .thenReturn(Mono.just(DefaultTemplateEnum.POST.getValue())); String pageName = "fake-page"; when(viewResolver.resolveViewName(any(), any())) .thenReturn(Mono.just(new EmptyView() { @Override public Mono render(Map model, MediaType contentType, ServerWebExchange exchange) { assertThat(model).containsKey(ModelConst.TEMPLATE_ID); assertThat(model.get(ModelConst.TEMPLATE_ID)) .isEqualTo(DefaultTemplateEnum.SINGLE_PAGE.getValue()); assertThat(model.get("name")) .isEqualTo(pageName); assertThat(model.get("plural")).isEqualTo("singlepages"); assertThat(model.get("singlePage")).isNotNull(); assertThat(model.get("groupVersionKind")) .isEqualTo(GroupVersionKind.fromExtension(SinglePage.class)); return super.render(model, contentType, exchange); } })); SinglePage singlePage = new SinglePage(); singlePage.setMetadata(new Metadata()); singlePage.getMetadata().setName(pageName); singlePage.setSpec(new SinglePage.SinglePageSpec()); when(singlePageFinder.getByName(eq(pageName))) .thenReturn(Mono.just(SinglePageVo.from(singlePage))); HandlerFunction handlerFunction = singlePageRoute.handlerFunction(pageName); RouterFunction routerFunction = RouterFunctions.route().GET("/archives/{name}", handlerFunction).build(); WebTestClient webTestClient = WebTestClient.bindToRouterFunction(routerFunction) .handlerStrategies(HandlerStrategies.builder() .viewResolver(viewResolver) .build()) .build(); when(localeContextResolver.resolveLocaleContext(any())) .thenReturn(new SimpleLocaleContext(Locale.getDefault())); webTestClient.get() .uri("/archives/fake-name") .exchange() .expectStatus().isOk(); } @Test void shouldNotThrowErrorIfSlugNameContainsSpecialChars() { var specialChars = "/with-special-chars-{}-[]-{{}}-{[]}-[{}]"; var specialCharsUri = URI.create(UriUtils.encodePath(specialChars, UTF_8)); var mockHttpRequest = MockServerHttpRequest.get(specialCharsUri.toString()) .accept(MediaType.TEXT_HTML) .build(); var mockExchange = MockServerWebExchange.from(mockHttpRequest); var request = MockServerRequest.builder() .exchange(mockExchange) .uri(specialCharsUri) .method(HttpMethod.GET) .header(HttpHeaders.ACCEPT, MediaType.TEXT_HTML_VALUE) .build(); var nameSlugPair = new NameSlugPair("fake-single-page", specialChars); singlePageRoute.setQuickRouteMap(Map.of(nameSlugPair, r -> ServerResponse.ok().build())); StepVerifier.create(singlePageRoute.route(request)) .expectNextCount(1) .verifyComplete(); } @Nested class SinglePageReconcilerTest { @Test void shouldRemoveRouteIfSinglePageUnpublished() { var name = "fake-single-page"; var page = newSinglePage(name, false); when(client.fetch(SinglePage.class, name)).thenReturn( Optional.of(page)); var routeMap = Mockito.>>mock( invocation -> new HashMap>()); singlePageRoute.setQuickRouteMap(routeMap); var result = singlePageRoute.reconcile(new Reconciler.Request(name)); assertNotNull(result); assertFalse(result.reEnqueue()); verify(client).fetch(SinglePage.class, name); verify(routeMap).remove(NameSlugPair.from(page)); } @Test void shouldAddRouteIfSinglePagePublished() { var name = "fake-single-page"; var page = newSinglePage(name, true); when(client.fetch(SinglePage.class, name)).thenReturn( Optional.of(page)); var routeMap = Mockito.>>mock( invocation -> new HashMap>()); singlePageRoute.setQuickRouteMap(routeMap); var result = singlePageRoute.reconcile(new Reconciler.Request(name)); assertNotNull(result); assertFalse(result.reEnqueue()); verify(client).fetch(SinglePage.class, name); verify(routeMap).put(eq(NameSlugPair.from(page)), any()); } @Test void shouldRemoveRouteIfSinglePageDeleted() { var name = "fake-single-page"; var page = newDeletedSinglePage(name); when(client.fetch(SinglePage.class, name)).thenReturn( Optional.of(page)); var routeMap = Mockito.>>mock( invocation -> new HashMap>()); singlePageRoute.setQuickRouteMap(routeMap); var result = singlePageRoute.reconcile(new Reconciler.Request(name)); assertNotNull(result); assertFalse(result.reEnqueue()); verify(client).fetch(SinglePage.class, name); verify(routeMap).remove(NameSlugPair.from(page)); } @Test void shouldRemoveRouteIfSinglePageRecycled() { var name = "fake-single-page"; var page = newRecycledSinglePage(name); when(client.fetch(SinglePage.class, name)).thenReturn( Optional.of(page)); var routeMap = Mockito.>>mock( invocation -> new HashMap>()); singlePageRoute.setQuickRouteMap(routeMap); var result = singlePageRoute.reconcile(new Reconciler.Request(name)); assertNotNull(result); assertFalse(result.reEnqueue()); verify(client).fetch(SinglePage.class, name); verify(routeMap).remove(NameSlugPair.from(page)); } SinglePage newSinglePage(String name, boolean published) { var metadata = new Metadata(); metadata.setName(name); var page = new SinglePage(); page.setMetadata(metadata); var spec = new SinglePage.SinglePageSpec(); spec.setSlug("/fake-slug"); page.setSpec(spec); var status = new SinglePage.SinglePageStatus(); page.setStatus(status); SinglePage.changePublishedState(page, published); return page; } SinglePage newDeletedSinglePage(String name) { var page = newSinglePage(name, true); page.getMetadata().setDeletionTimestamp(Instant.now()); return page; } SinglePage newRecycledSinglePage(String name) { var page = newSinglePage(name, true); page.getSpec().setDeleted(true); return page; } } } ================================================ FILE: application/src/test/java/run/halo/app/theme/router/factories/ArchiveRouteFactoryTest.java ================================================ package run.halo.app.theme.router.factories; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.theme.finders.PostFinder; /** * Tests for {@link ArchiveRouteFactory}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class ArchiveRouteFactoryTest extends RouteFactoryTestSuite { @Mock private PostFinder postFinder; @InjectMocks private ArchiveRouteFactory archiveRouteFactory; @Test void create() { String prefix = "/new-archives"; RouterFunction routerFunction = archiveRouteFactory.create(prefix); WebTestClient client = getWebTestClient(routerFunction); client.get() .uri(prefix) .exchange() .expectStatus().isOk(); client.get() .uri(prefix + "/page/1") .exchange() .expectStatus().isOk(); client.get() .uri(prefix + "/2022/09") .exchange() .expectStatus().isOk(); client.get() .uri(prefix + "/2022/08/page/1") .exchange() .expectStatus().isOk(); client.get() .uri(prefix + "/2022/8/page/1") .exchange() .expectStatus() .isEqualTo(HttpStatus.NOT_FOUND); client.get() .uri("/nothing") .exchange() .expectStatus() .isEqualTo(HttpStatus.NOT_FOUND); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/router/factories/AuthorPostsRouteFactoryTest.java ================================================ package run.halo.app.theme.router.factories; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.extension.ReactiveExtensionClient; /** * Tests for {@link AuthorPostsRouteFactory}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class AuthorPostsRouteFactoryTest extends RouteFactoryTestSuite { @Mock ReactiveExtensionClient client; @InjectMocks AuthorPostsRouteFactory authorPostsRouteFactory; @Test void create() { var spyAuthorRoute = spy(authorPostsRouteFactory); doReturn(Mono.just(true)).when(spyAuthorRoute).hasPostManageRole(anyString()); RouterFunction routerFunction = spyAuthorRoute.create(null); WebTestClient webClient = getWebTestClient(routerFunction); when(client.fetch(eq(User.class), eq("fake-user"))) .thenReturn(Mono.just(new User())); webClient.get() .uri("/authors/fake-user") .exchange() .expectStatus().isOk(); webClient.get() .uri("/authors/fake-user/page/p") .exchange() .expectStatus().isNotFound(); webClient.get() .uri("/authors/fake-user/page/abc") .exchange() .expectStatus().isNotFound(); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/router/factories/CategoriesRouteFactoryTest.java ================================================ package run.halo.app.theme.router.factories; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Flux; import run.halo.app.theme.finders.CategoryFinder; /** * Tests for {@link CategoriesRouteFactory}. * * @author guqing * @since 2.0.0 */ class CategoriesRouteFactoryTest extends RouteFactoryTestSuite { @Mock private CategoryFinder categoryFinder; @InjectMocks private CategoriesRouteFactory categoriesRouteFactory; @Test void create() { String prefix = "/topics"; RouterFunction routerFunction = categoriesRouteFactory.create(prefix); WebTestClient webClient = getWebTestClient(routerFunction); when(categoryFinder.listAsTree()) .thenReturn(Flux.empty()); webClient.get() .uri(prefix) .exchange() .expectStatus().isOk(); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/router/factories/IndexRouteFactoryTest.java ================================================ package run.halo.app.theme.router.factories; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.theme.finders.PostFinder; /** * Tests for {@link IndexRouteFactory}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class IndexRouteFactoryTest extends RouteFactoryTestSuite { @Mock private PostFinder postFinder; @InjectMocks private IndexRouteFactory indexRouteFactory; @Test void create() { RouterFunction routerFunction = indexRouteFactory.create("/"); WebTestClient webTestClient = getWebTestClient(routerFunction); webTestClient.get() .uri("/") .exchange() .expectStatus().isOk(); webTestClient.get() .uri("/page/1") .exchange() .expectStatus().isOk(); webTestClient.get() .uri("/page/abc") .exchange() .expectStatus().isNotFound(); webTestClient.get() .uri("/page/2f") .exchange() .expectStatus().isNotFound(); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/router/factories/PostRouteFactoryTest.java ================================================ package run.halo.app.theme.router.factories; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import java.util.Locale; import java.util.Map; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.i18n.SimpleLocaleContext; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.i18n.LocaleContextResolver; import reactor.core.publisher.Mono; import run.halo.app.content.TestPost; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.ViewNameResolver; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.vo.PostVo; import run.halo.app.theme.router.DefaultQueryPostPredicateResolver; import run.halo.app.theme.router.EmptyView; import run.halo.app.theme.router.ModelConst; import run.halo.app.theme.router.ReactiveQueryPostPredicateResolver; import run.halo.app.theme.router.TitleVisibilityIdentifyCalculator; /** * Tests for {@link PostRouteFactory}. * * @author guqing * @since 2.3.0 */ @ExtendWith(MockitoExtension.class) class PostRouteFactoryTest extends RouteFactoryTestSuite { @Mock private PostFinder postFinder; @Mock private ViewNameResolver viewNameResolver; @Mock private ReactiveExtensionClient client; @Mock private ReactiveQueryPostPredicateResolver predicateResolver; @Mock private LocaleContextResolver localeContextResolver; @Mock private TitleVisibilityIdentifyCalculator titleVisibilityIdentifyCalculator; @InjectMocks private PostRouteFactory postRouteFactory; @Test void shouldBeSameResultWhenParsePattenMultiply() { var parser = new PostRouteFactory.PatternParser("/?p={slug}"); Assertions.assertTrue(parser.isQueryParamPattern()); parser = new PostRouteFactory.PatternParser("/?p={slug}"); Assertions.assertTrue(parser.isQueryParamPattern()); } @Test void create() { Post post = TestPost.postV1(); Map labels = MetadataUtil.nullSafeLabels(post); labels.put(Post.PUBLISHED_LABEL, "true"); post.getMetadata().setName("fake-name"); post.getSpec().setDeleted(false); post.getSpec().setVisible(Post.VisibleEnum.PUBLIC); when(postFinder.getByName(eq("fake-name"))).thenReturn(Mono.just(PostVo.from(post))); when(client.fetch(eq(Post.class), eq("fake-name"))).thenReturn(Mono.just(post)); when(viewNameResolver.resolveViewNameOrDefault(any(ServerRequest.class), any(), any())) .thenReturn(Mono.just(DefaultTemplateEnum.POST.getValue())); when(predicateResolver.getPredicate()) .thenReturn(new DefaultQueryPostPredicateResolver().getPredicate()); RouterFunction routerFunction = postRouteFactory.create("/archives/{name}"); WebTestClient webTestClient = getWebTestClient(routerFunction); when(localeContextResolver.resolveLocaleContext(any())) .thenReturn(new SimpleLocaleContext(Locale.getDefault())); when(viewResolver.resolveViewName(any(), any())) .thenReturn(Mono.just(new EmptyView() { @Override public Mono render(Map model, MediaType contentType, ServerWebExchange exchange) { assertThat(model).containsKey(ModelConst.TEMPLATE_ID); assertThat(model.get(ModelConst.TEMPLATE_ID)) .isEqualTo(DefaultTemplateEnum.POST.getValue()); assertThat(model.get("name")) .isEqualTo(post.getMetadata().getName()); assertThat(model.get("plural")).isEqualTo("posts"); assertThat(model.get("post")).isNotNull(); assertThat(model.get("groupVersionKind")) .isEqualTo(GroupVersionKind.fromExtension(Post.class)); return super.render(model, contentType, exchange); } })); webTestClient.get() .uri("/archives/fake-name") .exchange() .expectStatus().isOk(); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/router/factories/RouteFactoryTest.java ================================================ package run.halo.app.theme.router.factories; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemSetting; import run.halo.app.theme.router.ModelConst; /** * Tests for {@link RouteFactory}. * * @author guqing * @since 2.3.0 */ @ExtendWith(MockitoExtension.class) class RouteFactoryTest extends RouteFactoryTestSuite { @Test void configuredPageSize() { SystemSetting.Post post = new SystemSetting.Post(); post.setPostPageSize(1); post.setArchivePageSize(2); post.setCategoryPageSize(3); post.setTagPageSize(null); post.setAuthorPageSize(4); when(environmentFetcher.fetchPost()).thenReturn(Mono.just(post)); TestRouteFactory routeFactory = new TestRouteFactory(); assertThat( routeFactory.configuredPageSize(environmentFetcher, SystemSetting.Post::getTagPageSize) .block()).isEqualTo(ModelConst.DEFAULT_PAGE_SIZE); assertThat( routeFactory.configuredPageSize(environmentFetcher, SystemSetting.Post::getPostPageSize) .block()).isEqualTo(post.getPostPageSize()); assertThat( routeFactory.configuredPageSize(environmentFetcher, SystemSetting.Post::getCategoryPageSize).block()) .isEqualTo(post.getCategoryPageSize()); assertThat( routeFactory.configuredPageSize(environmentFetcher, SystemSetting.Post::getArchivePageSize).block()) .isEqualTo(post.getArchivePageSize()); assertThat( routeFactory.configuredPageSize(environmentFetcher, SystemSetting.Post::getAuthorPageSize).block()) .isEqualTo(post.getAuthorPageSize()); } static class TestRouteFactory implements RouteFactory { @Override public RouterFunction create(String pattern) { return null; } } } ================================================ FILE: application/src/test/java/run/halo/app/theme/router/factories/RouteFactoryTestSuite.java ================================================ package run.halo.app.theme.router.factories; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import java.net.URISyntaxException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.HandlerStrategies; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.result.view.ViewResolver; import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.theme.router.EmptyView; /** * Abstract test for {@link RouteFactory}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) abstract class RouteFactoryTestSuite { @Mock protected SystemConfigFetcher environmentFetcher; @Mock protected ViewResolver viewResolver; @BeforeEach final void setUpParent() throws URISyntaxException { lenient().when(environmentFetcher.fetchPost()) .thenReturn(Mono.just(new SystemSetting.Post())); lenient().when(environmentFetcher.fetch(eq(SystemSetting.ThemeRouteRules.GROUP), eq(SystemSetting.ThemeRouteRules.class))).thenReturn(Mono.just(getThemeRouteRules())); lenient().when(viewResolver.resolveViewName(any(), any())) .thenReturn(Mono.just(new EmptyView())); setUp(); } public void setUp() { } public SystemSetting.ThemeRouteRules getThemeRouteRules() { SystemSetting.ThemeRouteRules themeRouteRules = new SystemSetting.ThemeRouteRules(); themeRouteRules.setArchives("archives"); themeRouteRules.setPost("/archives/{slug}"); themeRouteRules.setTags("tags"); themeRouteRules.setCategories("categories"); return themeRouteRules; } public WebTestClient getWebTestClient(RouterFunction routeFunction) { return WebTestClient.bindToRouterFunction(routeFunction) .handlerStrategies(HandlerStrategies.builder() .viewResolver(viewResolver) .build()) .build(); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/router/factories/TagPostRouteFactoryTest.java ================================================ package run.halo.app.theme.router.factories; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.TagFinder; import run.halo.app.theme.finders.vo.TagVo; /** * Tests for @link TagPostRouteFactory}. * * @author guqing * @since 2.0.0 */ @ExtendWith(MockitoExtension.class) class TagPostRouteFactoryTest extends RouteFactoryTestSuite { @Mock private ReactiveExtensionClient client; @Mock private TagFinder tagFinder; @Mock private PostFinder postFinder; @InjectMocks TagPostRouteFactory tagPostRouteFactory; @Test void create() { when(client.listBy(eq(Tag.class), any(), any(PageRequest.class))) .thenReturn(Mono.just(ListResult.emptyResult())); WebTestClient webTestClient = getWebTestClient(tagPostRouteFactory.create("/new-tags")); webTestClient.get() .uri("/new-tags/tag-slug-1") .exchange() .expectStatus().isNotFound(); Tag tag = new Tag(); tag.setMetadata(new Metadata()); tag.getMetadata().setName("fake-tag-name"); tag.setSpec(new Tag.TagSpec()); tag.getSpec().setSlug("tag-slug-2"); when(client.listBy(eq(Tag.class), any(), any(PageRequest.class))) .thenReturn(Mono.just(new ListResult<>(List.of(tag)))); when(tagFinder.getByName(eq(tag.getMetadata().getName()))) .thenReturn(Mono.just(TagVo.from(tag))); webTestClient.get() .uri("/new-tags/tag-slug-2") .exchange() .expectStatus().isOk(); webTestClient.get() .uri("/new-tags/tag-slug-2/page/1") .exchange() .expectStatus().isOk(); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/service/ThemeServiceImplTest.java ================================================ package run.halo.app.theme.service; import static java.nio.file.Files.createTempDirectory; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static run.halo.app.infra.utils.FileUtils.deleteRecursivelyAndSilently; import static run.halo.app.infra.utils.FileUtils.zip; import com.github.zafarkhaja.semver.Version; import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.function.Consumer; import org.json.JSONException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.stubbing.Answer; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.util.ResourceUtils; import org.springframework.util.StreamUtils; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.AnnotationSetting; import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Theme; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Unstructured; import run.halo.app.extension.exception.ExtensionException; import run.halo.app.infra.SystemConfigFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.infra.ThemeRootGetter; import run.halo.app.infra.exception.ThemeInstallationException; import run.halo.app.infra.utils.JsonUtils; @ExtendWith(MockitoExtension.class) class ThemeServiceImplTest { @Mock ReactiveExtensionClient client; @Mock ThemeRootGetter themeRoot; @Mock SystemVersionSupplier systemVersionSupplier; @Mock SystemConfigFetcher systemConfigFetcher; @InjectMocks ThemeServiceImpl themeService; Path tmpDir; @BeforeEach void setUp() throws IOException { tmpDir = createTempDirectory("halo-theme-service-test-"); lenient().when(themeRoot.get()).thenReturn(tmpDir.resolve("themes")); // init the folder Files.createDirectory(themeRoot.get()); lenient().when(systemVersionSupplier.get()).thenReturn(Version.parse("0.0.0")); } @AfterEach void cleanUp() { deleteRecursivelyAndSilently(tmpDir); } Path prepareTheme(String themeFilename) throws IOException, URISyntaxException { var defaultThemeUri = ResourceUtils.getURL("classpath:themes/" + themeFilename).toURI(); var defaultThemeZipPath = tmpDir.resolve("default.zip"); zip(Path.of(defaultThemeUri), defaultThemeZipPath); return defaultThemeZipPath; } Theme createTheme() { return createTheme(theme -> { }); } Theme createTheme(Consumer customizer) { var metadata = new Metadata(); metadata.setName("default"); var spec = new Theme.ThemeSpec(); spec.setDisplayName("Default"); var theme = new Theme(); theme.setMetadata(metadata); theme.setSpec(spec); customizer.accept(theme); return theme; } Unstructured convert(Theme theme) { return Unstructured.OBJECT_MAPPER.convertValue(theme, Unstructured.class); } Flux content(Path path) { return DataBufferUtils.read( path, DefaultDataBufferFactory.sharedInstance, StreamUtils.BUFFER_SIZE); } @Nested class UpgradeTest { @Test void shouldFailIfThemeNotInstalledBefore() throws IOException, URISyntaxException { var themeZipPath = prepareTheme("other"); when(client.fetch(Theme.class, "default")).thenReturn(Mono.empty()); StepVerifier.create(themeService.upgrade("default", content(themeZipPath))) .verifyError(ServerWebInputException.class); verify(client).fetch(Theme.class, "default"); } @Test void shouldUpgradeSuccessfully() throws IOException, URISyntaxException { var themeZipPath = prepareTheme("other"); var oldTheme = createTheme(); when(client.fetch(Theme.class, "default")) // for old theme check .thenReturn(Mono.just(oldTheme)) // for theme deletion .thenReturn(Mono.just(oldTheme)) // for theme deleted check .thenReturn(Mono.empty()); when(client.get(Theme.class, "default")).thenReturn(Mono.just(oldTheme)); when(client.update(oldTheme)).thenReturn(Mono.just(createTheme(t -> { t.getSpec().setDisplayName("New fake theme"); }))); StepVerifier.create(themeService.upgrade("default", content(themeZipPath))) .consumeNextWith(newTheme -> { assertEquals("default", newTheme.getMetadata().getName()); assertEquals("New fake theme", newTheme.getSpec().getDisplayName()); }) .verifyComplete(); verify(client).fetch(Theme.class, "default"); verify(client, never()).delete(oldTheme); } } @Nested class InstallTest { @Test void shouldInstallSuccessfully() throws IOException, URISyntaxException { var defaultThemeZipPath = prepareTheme("default"); when(client.create(isA(Theme.class))).thenReturn(Mono.just(createTheme())); StepVerifier.create(themeService.install(content(defaultThemeZipPath))) .consumeNextWith(theme -> { assertEquals("default", theme.getMetadata().getName()); assertEquals("Default", theme.getSpec().getDisplayName()); }) .verifyComplete(); } @Test void shouldFailWhenPersistentError() throws IOException, URISyntaxException { var defaultThemeZipPath = prepareTheme("default"); when(client.create(isA(Theme.class))).thenReturn( Mono.error(() -> new ExtensionException("Failed to create the extension"))); StepVerifier.create(themeService.install(content(defaultThemeZipPath))) .verifyError(ExtensionException.class); } @Test void shouldFailWhenThemeManifestIsInvalid() throws IOException, URISyntaxException { var defaultThemeZipPath = prepareTheme("invalid-missing-manifest"); StepVerifier.create(themeService.install(content(defaultThemeZipPath))) .verifyError(ThemeInstallationException.class); } } @Test void reloadThemeWhenSettingNameSetBeforeThenDeleteSetting() throws IOException { Theme theme = new Theme(); theme.setMetadata(new Metadata()); theme.getMetadata().setName("fake-theme"); theme.setSpec(new Theme.ThemeSpec()); theme.getSpec().setDisplayName("Hello"); theme.getSpec().setSettingName("fake-setting"); when(client.fetch(Theme.class, "fake-theme")) .thenReturn(Mono.just(theme)); when(client.delete(any(Setting.class))).thenReturn(Mono.empty()); Setting setting = new Setting(); setting.setMetadata(new Metadata()); setting.setSpec(new Setting.SettingSpec()); setting.getSpec().setForms(List.of()); when(client.fetch(Setting.class, "fake-setting")) .thenReturn(Mono.just(setting)); Path themeWorkDir = themeRoot.get().resolve(theme.getMetadata().getName()); if (!Files.exists(themeWorkDir)) { Files.createDirectories(themeWorkDir); } Files.writeString(themeWorkDir.resolve("settings.yaml"), """ apiVersion: v1alpha1 kind: Setting metadata: name: fake-setting spec: forms: - group: sns label: 社交资料 formSchema: - $el: h1 children: Register """); Files.writeString(themeWorkDir.resolve("theme.yaml"), """ apiVersion: v1alpha1 kind: Theme metadata: name: fake-theme spec: displayName: Fake Theme """); when(client.update(any(Theme.class))) .thenAnswer((Answer>) invocation -> { Theme argument = invocation.getArgument(0); return Mono.just(argument); }); when(client.list(eq(AnnotationSetting.class), any(), eq(null))).thenReturn(Flux.empty()); themeService.reloadTheme("fake-theme") .as(StepVerifier::create) .consumeNextWith(themeUpdated -> { try { JSONAssert.assertEquals(""" { "spec": { "displayName": "Fake Theme", "version": "*", "requires": "*" }, "apiVersion": "theme.halo.run/v1alpha1", "kind": "Theme", "metadata": { "name": "fake-theme" } } """, JsonUtils.objectToJson(themeUpdated), true); } catch (JSONException e) { throw new RuntimeException(e); } }) .verifyComplete(); // delete fake-setting verify(client, times(1)).delete(any(Setting.class)); // Will not be created verify(client, times(0)).create(any(Setting.class)); } @Test void reloadThemeWhenSettingNameNotSetBefore() throws IOException { Theme theme = new Theme(); theme.setMetadata(new Metadata()); theme.getMetadata().setName("fake-theme"); theme.setSpec(new Theme.ThemeSpec()); theme.getSpec().setDisplayName("Hello"); when(client.fetch(Theme.class, "fake-theme")) .thenReturn(Mono.just(theme)); Setting setting = new Setting(); setting.setMetadata(new Metadata()); setting.setSpec(new Setting.SettingSpec()); setting.getSpec().setForms(List.of()); when(client.fetch(eq(Setting.class), eq(null))).thenReturn(Mono.empty()); Path themeWorkDir = themeRoot.get().resolve(theme.getMetadata().getName()); if (!Files.exists(themeWorkDir)) { Files.createDirectories(themeWorkDir); } Files.writeString(themeWorkDir.resolve("settings.yaml"), """ apiVersion: v1alpha1 kind: Setting metadata: name: fake-setting spec: forms: - group: sns label: 社交资料 formSchema: - $el: h1 children: Register """); Files.writeString(themeWorkDir.resolve("theme.yaml"), """ apiVersion: v1alpha1 kind: Theme metadata: name: fake-theme spec: displayName: Fake Theme settingName: fake-setting """); when(client.update(any(Theme.class))) .thenAnswer((Answer>) invocation -> { Theme argument = invocation.getArgument(0); return Mono.just(argument); }); when(client.create(any(Unstructured.class))) .thenAnswer((Answer>) invocation -> { Unstructured argument = invocation.getArgument(0); JSONAssert.assertEquals(""" { "spec": { "forms": [ { "group": "sns", "label": "社交资料", "formSchema": [ { "$el": "h1", "children": "Register" } ] } ] }, "apiVersion": "v1alpha1", "kind": "Setting", "metadata": { "name": "fake-setting", "labels": { "theme.halo.run/theme-name": "fake-theme" } } } """, JsonUtils.objectToJson(argument), true); return Mono.just(invocation.getArgument(0)); }); when(client.list(eq(AnnotationSetting.class), any(), eq(null))).thenReturn(Flux.empty()); when(client.fetch(eq(Setting.GVK), eq("fake-setting"))) .thenReturn(Mono.empty()); themeService.reloadTheme("fake-theme") .as(StepVerifier::create) .consumeNextWith(themeUpdated -> { try { JSONAssert.assertEquals(""" { "spec": { "settingName": "fake-setting", "displayName": "Fake Theme", "version": "*", "requires": "*" }, "apiVersion": "theme.halo.run/v1alpha1", "kind": "Theme", "metadata": { "name": "fake-theme" } } """, JsonUtils.objectToJson(themeUpdated), true); } catch (JSONException e) { throw new RuntimeException(e); } }) .verifyComplete(); } @Test void resetSettingConfig() { Theme theme = new Theme(); theme.setMetadata(new Metadata()); theme.getMetadata().setName("fake-theme"); theme.setSpec(new Theme.ThemeSpec()); theme.getSpec().setSettingName("fake-setting"); theme.getSpec().setConfigMapName("fake-config"); theme.getSpec().setDisplayName("Hello"); when(client.fetch(Theme.class, "fake-theme")) .thenReturn(Mono.just(theme)); Setting setting = new Setting(); setting.setMetadata(new Metadata()); setting.getMetadata().setName("fake-setting"); setting.setSpec(new Setting.SettingSpec()); var formSchemaItem = Map.of("name", "email", "value", "example@exmple.com"); Setting.SettingForm settingForm = new Setting.SettingForm(); settingForm.setGroup("basic"); settingForm.setFormSchema(List.of(formSchemaItem)); setting.getSpec().setForms(List.of(settingForm)); when(client.fetch(eq(Setting.class), eq("fake-setting"))) .thenReturn(Mono.just(setting)); ConfigMap configMap = new ConfigMap(); configMap.setMetadata(new Metadata()); configMap.getMetadata().setName("fake-config"); when(client.fetch(eq(ConfigMap.class), eq("fake-config"))) .thenReturn(Mono.just(configMap)); when(client.update(any(ConfigMap.class))) .thenAnswer((Answer>) invocation -> { ConfigMap argument = invocation.getArgument(0); JSONAssert.assertEquals(""" { "data": { "basic": "{\\"email\\":\\"example@exmple.com\\"}" }, "apiVersion": "v1alpha1", "kind": "ConfigMap", "metadata": { "name": "fake-config" } } """, JsonUtils.objectToJson(argument), true); return Mono.just(invocation.getArgument(0)); }); themeService.resetSettingConfig("fake-theme") .as(StepVerifier::create) .consumeNextWith(next -> { assertThat(next).isNotNull(); }) .verifyComplete(); verify(client, times(1)) .fetch(eq(Setting.class), eq(setting.getMetadata().getName())); verify(client, times(1)).fetch(eq(ConfigMap.class), eq("fake-config")); verify(client, times(1)).update(any(ConfigMap.class)); } @Test void shouldFetchSystemSetting() { var themeSetting = new SystemSetting.Theme(); themeSetting.setActive("fake-theme"); when(systemConfigFetcher.fetch(SystemSetting.Theme.GROUP, SystemSetting.Theme.class)) .thenReturn(Mono.just(themeSetting)); StepVerifier.create(themeService.fetchSystemSetting()) .expectNext(themeSetting) .verifyComplete(); } @Test void shouldFetchActivatedTheme() { var themeSetting = new SystemSetting.Theme(); themeSetting.setActive("fake-theme"); when(systemConfigFetcher.fetch(SystemSetting.Theme.GROUP, SystemSetting.Theme.class)) .thenReturn(Mono.just(themeSetting)); var theme = createTheme(); when(client.fetch(Theme.class, "fake-theme")).thenReturn(Mono.just(theme)); StepVerifier.create(themeService.fetchActivatedTheme()) .expectNext(theme) .verifyComplete(); } @Test void shouldFetchActivatedThemeName() { var themeSetting = new SystemSetting.Theme(); themeSetting.setActive("fake-theme"); when(systemConfigFetcher.fetch(SystemSetting.Theme.GROUP, SystemSetting.Theme.class)) .thenReturn(Mono.just(themeSetting)); StepVerifier.create(themeService.fetchActivatedThemeName()) .expectNext("fake-theme") .verifyComplete(); } } ================================================ FILE: application/src/test/java/run/halo/app/theme/utils/PatternUtilsTest.java ================================================ package run.halo.app.theme.utils; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; import run.halo.app.infra.SystemSetting.ThemeRouteRules; class PatternUtilsTest { @ParameterizedTest @CsvSource({ "test, /test", "/test/, /test", "/test, /test", "test/, /test", "path/to/resource/, /path/to/resource" }) void normalizePatternTest(String pattern, String expected) { assertEquals(expected, PatternUtils.normalizePattern(pattern)); } @ParameterizedTest @ValueSource(strings = { "", " ", "/", " /", "/ " }) void shouldThrowExceptionWhen(String pattern) { assertThrows(IllegalArgumentException.class, () -> PatternUtils.normalizePattern(pattern)); } @ParameterizedTest @CsvSource({ "/posts/{slug}, /archives, /categories, /posts/{slug}", "/archives/{slug}, /blog/archives, /categories, /blog/archives/{slug}", "/categories/{slug}, /archives, /blog/categories, /blog/categories/{slug}", "archives/{slug}, blog/archives, /categories, /blog/archives/{slug}", "categories/{slug}, /archives, blog/categories, /blog/categories/{slug}", """ /archives/{year}/{month}/{slug}/, /blog/archives/, /categories, \ /blog/archives/{year}/{month}/{slug}""", """ /categories/{category}/{slug}/, /archives, /blog/categories/, \ /blog/categories/{category}/{slug}""", """ /archives/categories/{slug}, /blog/archives, /blog/categories, \ /blog/archives/categories/{slug}""", }) void normalizePostPatternTest( String postPattern, String archivesPattern, String categoriesPattern, String expected ) { var rules = new ThemeRouteRules(); rules.setPost(postPattern); rules.setArchives(archivesPattern); rules.setCategories(categoriesPattern); var result = PatternUtils.normalizePostPattern(rules); assertEquals(expected, result); } } ================================================ FILE: application/src/test/java/run/halo/app/ui/WebSocketServerWebExchangeMatcherTest.java ================================================ package run.halo.app.ui; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import reactor.test.StepVerifier; import run.halo.app.infra.ui.WebSocketServerWebExchangeMatcher; class WebSocketServerWebExchangeMatcherTest { @Test void shouldMatchIfWebSocketProtocol() { var httpRequest = MockServerHttpRequest.get("") .header(HttpHeaders.CONNECTION, HttpHeaders.UPGRADE) .header(HttpHeaders.UPGRADE, "websocket") .build(); var wsExchange = MockServerWebExchange.from(httpRequest); var wsMatcher = new WebSocketServerWebExchangeMatcher(); StepVerifier.create(wsMatcher.matches(wsExchange)) .consumeNextWith(result -> assertTrue(result.isMatch())) .verifyComplete(); } @Test void shouldNotMatchIfNotWebSocketProtocol() { var httpRequest = MockServerHttpRequest.get("") .header(HttpHeaders.CONNECTION, HttpHeaders.UPGRADE) .header(HttpHeaders.UPGRADE, "not-a-websocket") .build(); var wsExchange = MockServerWebExchange.from(httpRequest); var wsMatcher = new WebSocketServerWebExchangeMatcher(); StepVerifier.create(wsMatcher.matches(wsExchange)) .consumeNextWith(result -> assertFalse(result.isMatch())) .verifyComplete(); } } ================================================ FILE: application/src/test/java/run/halo/app/ui/WebSocketUtilsTest.java ================================================ package run.halo.app.ui; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import run.halo.app.infra.ui.WebSocketUtils; class WebSocketUtilsTest { @Nested class IsWebSocketTest { @Test void shouldBeWebSocketIfHeadersContaining() { var headers = new HttpHeaders(); headers.add("Connection", "Upgrade"); headers.add("Upgrade", "websocket"); assertTrue(WebSocketUtils.isWebSocketUpgrade(headers)); } @Test void shouldNotBeWebSocketIfHeaderValuesAreIncorrect() { var headers = new HttpHeaders(); headers.add("Connection", "keep-alive"); headers.add("Upgrade", "websocket"); assertFalse(WebSocketUtils.isWebSocketUpgrade(headers)); } @Test void shouldNotBeWebSocketIfMissingUpgradeHeader() { var headers = new HttpHeaders(); headers.add("Connection", "Upgrade"); assertFalse(WebSocketUtils.isWebSocketUpgrade(headers)); } @Test void shouldNotBeWebSocketIfMissingConnectionHeader() { var headers = new HttpHeaders(); headers.add("Connection", "Upgrade"); assertFalse(WebSocketUtils.isWebSocketUpgrade(headers)); } @Test void shouldNotBeWebSocketIfMissingHeaders() { var headers = new HttpHeaders(); assertFalse(WebSocketUtils.isWebSocketUpgrade(headers)); } } } ================================================ FILE: application/src/test/java/run/halo/app/webfilter/LocaleChangeWebFilterTest.java ================================================ package run.halo.app.webfilter; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.http.HttpCookie; import org.springframework.http.MediaType; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; import run.halo.app.infra.webfilter.LocaleChangeWebFilter; import run.halo.app.theme.ThemeLocaleContextResolver; class LocaleChangeWebFilterTest { LocaleChangeWebFilter filter; @BeforeEach void setUp() { var themeLocaleContextResolver = new ThemeLocaleContextResolver(); filter = new LocaleChangeWebFilter(themeLocaleContextResolver); } @Test void shouldRespondLanguageCookie() { WebFilterChain webFilterChain = filterExchange -> { var languageCookie = filterExchange.getResponse().getCookies().getFirst("language"); assertNotNull(languageCookie); assertEquals("zh-CN", languageCookie.getValue()); return Mono.empty(); }; var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/home") .accept(MediaType.TEXT_HTML) .queryParam("language", "zh-CN") .build() ); this.filter.filter(exchange, webFilterChain).block(); } @Test void shouldNotRespondLanguageCookieIfChanged() { WebFilterChain webFilterChain = filterExchange -> { var languageCookie = filterExchange.getResponse().getCookies().getFirst("language"); assertNotNull(languageCookie); assertEquals("zh-CN", languageCookie.getValue()); return Mono.empty(); }; var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/home") .accept(MediaType.TEXT_HTML) .cookie(new HttpCookie("language", "zh-HK")) .queryParam("language", "zh-CN") .build() ); this.filter.filter(exchange, webFilterChain).block(); } @Test void shouldNotRespondLanguageCookieIfNotChanged() { WebFilterChain webFilterChain = filterExchange -> { var languageCookie = filterExchange.getResponse().getCookies().getFirst("language"); assertNull(languageCookie); return Mono.empty(); }; var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/home") .accept(MediaType.TEXT_HTML) .cookie(new HttpCookie("language", "zh-CN")) .queryParam("language", "zh-CN") .build() ); this.filter.filter(exchange, webFilterChain).block(); } @Test void shouldNotRespondLanguageCookieWithUndeterminedLanguageTag() { WebFilterChain webFilterChain = filterExchange -> { var languageCookie = filterExchange.getResponse().getCookies().getFirst("language"); assertNull(languageCookie); return Mono.empty(); }; var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/home") .accept(MediaType.TEXT_HTML) .queryParam("language", "invalid_language_tag") .build() ); this.filter.filter(exchange, webFilterChain).block(); } @ParameterizedTest @MethodSource("provideInvalidRequest") void shouldNotRespondLanguageCookieIfRequestNotMatch(MockServerHttpRequest mockRequest) { WebFilterChain webFilterChain = filterExchange -> { var languageCookie = filterExchange.getResponse().getCookies().getFirst("language"); assertNull(languageCookie); return Mono.empty(); }; var exchange = MockServerWebExchange.from(mockRequest); this.filter.filter(exchange, webFilterChain).block(); } static Stream provideInvalidRequest() { return Stream.of( MockServerHttpRequest.get("/home") .accept(MediaType.ALL) .queryParam("language", "zh-CN") .build(), MockServerHttpRequest.get("/home") .accept(MediaType.APPLICATION_JSON) .queryParam("language", "zh-CN") .build(), MockServerHttpRequest.post("/home") .accept(MediaType.TEXT_HTML) .queryParam("language", "zh-CN") .build(), MockServerHttpRequest.get("/home") .accept(MediaType.TEXT_HTML) .build() ); } } ================================================ FILE: application/src/test/resources/apiToken.salt ================================================ bySwF9ZxJ2JpQYs830+eA3Fw6O7F/ULDvyYGEPaZKwY= ================================================ FILE: application/src/test/resources/app.key ================================================ -----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDOjnDY1K1lrOrK ETfKfDlVGVbPCiy+TDmTaXg4SWjdHUpXfqbXMkSX/j2dJ/ECqb/FtsvVxiSwRieG 3MWDKWlNRz0C0QKrsoDYbcvLf68uc7L5eKFZhu0AkXP4T5BIbdMXH8V0+5e+6R+n eHahFhMyaiYoHVrPMrW2Jn9iWIXuNTDpg9VFhejN4jG1wQqIu1puKeGYPQvtfNO5 Ef5cQdEFCvFfuDQvNhLgI1f798qY6EVFfRo2S3LLCut3wfDzRZiUN4Kz8qYz42Zv 97GS1gW/lfcEsmBApov9xiIaUzUECN35XbZYMK5Y4gfhAseZ+tlj+YarEiPjAtL1 JPUehCmRAgMBAAECggEAKmaI+ammsoFtbO9d4X3gkvxxmmx/RM0G4KC84ekH0qPp l85S10flVsIEydbiHWbVC/P7IbXb4Cd2g7OcA9GjYQ6nkoVvI+mvkz3uoKZkQofT jGxbyrHswroY8Tb76jJJK60E7n+a5cCbE9ihmW2boTSzAncMJg5FyM9cRMbhL0Vz h90/gE2U8awQ8Ug47BN1Dk/awxB9f5zVqI+LCGC0Py0/oQudjSaqPihydTsuqkhV xNu3NMcL/POt9WxmYyJFDJRW3+EYraPumdUsIWw8p4JJDt1jkyNpSbjGhu8vzRYX 0QSo1pa3VrDY4guEMk4RdJsKJDqQPTvCTTgDYBzFlQKBgQDq98CRLTwqHSEWyVKN 0KRujhVAVEmLDvPxZ2tVaMM37RanCHYSfHLiYCD54rUv7BFWjQ+hfq3iHUpgrefN KRS9e01mT0f24sAsWfhrFzrhlHaQStFgOw4uvwIDCfzrBeQQsqcAvWSjNr8CqSMX UIGz9oB6EP39PT3QxT3oYf3ItwKBgQDhC6WN78+0sf2zlptQ7V02eWaRfePtQfmb ow3c9aF8V7sSwDzjInqV5Bva4RyftYRTYZttBiANjGZ1pSNPi/2p7b+0hxJ1pPf8 6VcFDJBGLbFYNDWOux13KRJToMY0ckzSeBXgkWLVFSfESuoXzy+8bj5eMavJLg6L 2Ek6q6mH9wKBgBZmmE0+6sV5EXaCqwQqKAMCOLRxVLGVM1yIZ4s0+aeTSt2RyO/q PWmnkH1CR9PRxbVirWLQGPO9pyGgcsD0ca2+25otZMb8xyVzTmOnS03GQadv+pYa CzgZra9sfFhLr3qIDbPcWoPU7FDsnxPR8QufLJB2nkBOXl5Q753/+ZnxAoGBAI47 GisWwaNmSv3R1d/T5PGk0Jprgj5VUDh5WS2pYKKBoA49yT2UcP2C6cfwNnMJ+dPp AJ5rHJ7zeV4pPKPtyig3xs2GALixxrnlj8X1Jsnz3v3sIV1QDVNedeK83ggPpVXv 54PC3z/k2vlIj6L0oyroUiqeIgBIR5FC5SVbkQ4JAoGBAOEGQkqw1xR3fd27J6/R s9hOhItPnjExf5yqeg0nbZYIGd+6PiaVBBWUefZDDS79KUwTiqiHGP7iEVghJr9C xJI9odzY8WQJ+Q9ZQy1VQfP5mkRUTTkABhykXfWsHckO7yP6c3kwNIOOki8QPrmY 3GKNb5HtQVpazCvrB5PFh65g -----END PRIVATE KEY----- ================================================ FILE: application/src/test/resources/app.pub ================================================ -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzo5w2NStZazqyhE3ynw5 VRlWzwosvkw5k2l4OElo3R1KV36m1zJEl/49nSfxAqm/xbbL1cYksEYnhtzFgylp TUc9AtECq7KA2G3Ly3+vLnOy+XihWYbtAJFz+E+QSG3TFx/FdPuXvukfp3h2oRYT MmomKB1azzK1tiZ/YliF7jUw6YPVRYXozeIxtcEKiLtabinhmD0L7XzTuRH+XEHR BQrxX7g0LzYS4CNX+/fKmOhFRX0aNktyywrrd8Hw80WYlDeCs/KmM+Nmb/exktYF v5X3BLJgQKaL/cYiGlM1BAjd+V22WDCuWOIH4QLHmfrZY/mGqxIj4wLS9ST1HoQp kQIDAQAB -----END PUBLIC KEY----- ================================================ FILE: application/src/test/resources/application.yaml ================================================ server: port: 8090 spring: output: ansi: enabled: detect r2dbc: name: halo-test generate-unique-name: true sql: init: mode: always platform: h2 messages: basename: config.i18n.messages halo: work-dir: ${user.home}/halo-next-test external-url: "http://${server.address:localhost}:${server.port}" security: initializer: disabled: true oauth2: jwt: public-key-location: classpath:app.pub private-key-location: classpath:app.key extension: controller: disabled: true search-engine: lucene: enabled: false springdoc: api-docs: enabled: false logging: level: run.halo.app: debug org.springframework.r2dbc: DEBUG ================================================ FILE: application/src/test/resources/backups/backup-for-restoration/extensions.data ================================================ [{"name":"fake-extension-store","data":"ZmFrZS1kYXRh","version":1024}] ================================================ FILE: application/src/test/resources/backups/backup-for-restoration/workdir/fake-file ================================================ halo ================================================ FILE: application/src/test/resources/categories/independent-post-count.json ================================================ [ { "spec": { "displayName": "全部", "children": ["FIT2CLOUD", "AnotherRootChild"] }, "status": { "visiblePostCount": 35 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "全部", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "FIT2CLOUD", "children": ["DataEase", "IndependentNode"] }, "status": { "visiblePostCount": 15 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "FIT2CLOUD", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "DataEase", "children": ["SubNode1", "SubNode2"] }, "status": { "visiblePostCount": 10 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "DataEase", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "SubNode1", "children": ["Leaf1", "Leaf2"] }, "status": { "visiblePostCount": 4 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "SubNode1", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "Leaf1", "children": [] }, "status": { "visiblePostCount": 2 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "Leaf1", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "Leaf2", "children": [] }, "status": { "visiblePostCount": 2 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "Leaf2", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "SubNode2", "preventParentPostCascadeQuery": true, "children": ["IndependentChild1", "IndependentChild2"] }, "status": { "visiblePostCount": 6 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "SubNode2", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "IndependentChild1", "children": [] }, "status": { "visiblePostCount": 3 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "IndependentChild1", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "IndependentChild2", "children": [] }, "status": { "visiblePostCount": 3 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "IndependentChild2", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "IndependentNode", "preventParentPostCascadeQuery": true, "children": ["IndependentChild3", "IndependentChild4"] }, "status": { "visiblePostCount": 5 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "IndependentNode", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "IndependentChild3", "children": [] }, "status": { "visiblePostCount": 2 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "IndependentChild3", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "IndependentChild4", "children": [] }, "status": { "visiblePostCount": 3 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "IndependentChild4", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "AnotherRootChild", "children": ["Child1", "Child2"] }, "status": { "visiblePostCount": 20 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "AnotherRootChild", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "Child1", "children": ["SubChild1", "SubChild2"] }, "status": { "visiblePostCount": 8 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "Child1", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "SubChild1", "children": ["DeepNode1", "DeepNode2"] }, "status": { "visiblePostCount": 3 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "SubChild1", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "DeepNode1", "children": [] }, "status": { "visiblePostCount": 1 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "DeepNode1", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "DeepNode2", "children": ["DeeperNode"] }, "status": { "visiblePostCount": 1 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "DeepNode2", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "DeeperNode", "children": [] }, "status": { "visiblePostCount": 1 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "DeeperNode", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "SubChild2", "children": ["DeepNode3"] }, "status": { "visiblePostCount": 5 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "SubChild2", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "DeepNode3", "preventParentPostCascadeQuery": true, "children": ["DeepNode4", "DeepNode5"] }, "status": { "visiblePostCount": 2 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "DeepNode3", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "DeepNode4", "children": [] }, "status": { "visiblePostCount": 1 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "DeepNode4", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "DeepNode5", "children": [] }, "status": { "visiblePostCount": 1 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "DeepNode5", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "Child2", "children": ["IndependentSubNode"] }, "status": { "visiblePostCount": 12 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "Child2", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "IndependentSubNode", "preventParentPostCascadeQuery": true, "children": ["SubNode3", "SubNode4"] }, "status": { "visiblePostCount": 12 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "IndependentSubNode", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "SubNode3", "children": [] }, "status": { "visiblePostCount": 6 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "SubNode3", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } }, { "spec": { "displayName": "SubNode4", "children": [] }, "status": { "visiblePostCount": 6 }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Category", "metadata": { "name": "SubNode4", "version": 0, "creationTimestamp": "2024-06-14T06:17:47.589181Z" } } ] ================================================ FILE: application/src/test/resources/config/i18n/messages.properties ================================================ problemDetail.title.run.halo.app.infra.exception.handlers.I18nExceptionTest$ErrorResponseException=Error Response problemDetail.internalServerError=Something went wrong, please try again later. problemDetail.run.halo.app.infra.exception.handlers.I18nExceptionTest$ErrorResponseException=Message argument is {0}. error.somethingWentWrong=Something went wrong, argument is {0}. problemDetail.title.internalServerError=Internal Server Error problemDetail.title.conflict=Conflict problemDetail.conflict=Conflict detected. ================================================ FILE: application/src/test/resources/config/i18n/messages_zh.properties ================================================ problemDetail.title.run.halo.app.infra.exception.handlers.I18nExceptionTest$ErrorResponseException=发生错误 problemDetail.run.halo.app.infra.exception.handlers.I18nExceptionTest$ErrorResponseException=参数:{0}。 error.somethingWentWrong=发生了一些错误,参数:{0}。 ================================================ FILE: application/src/test/resources/console/assets/fake.txt ================================================ fake. ================================================ FILE: application/src/test/resources/console/index.html ================================================ console index ================================================ FILE: application/src/test/resources/file-type-detect/index.html ================================================ ================================================ FILE: application/src/test/resources/file-type-detect/index.js ================================================ ================================================ FILE: application/src/test/resources/file-type-detect/test.json ================================================ ================================================ FILE: application/src/test/resources/folder-to-zip/examplefile ================================================ Here is an example file. ================================================ FILE: application/src/test/resources/plugin/plugin-0.0.1/extensions/reverseProxy.yaml ================================================ apiVersion: plugin.halo.run/v1alpha1 kind: ReverseProxy metadata: name: reverse-proxy-template labels: plugin.halo.run/pluginName: io.github.guqing.apples rules: - path: /static/** file: directory: static - path: /admin/** file: directory: admin ================================================ FILE: application/src/test/resources/plugin/plugin-0.0.1/extensions/roles.yaml ================================================ apiVersion: v1alpha1 kind: Role metadata: name: role-template-view-apples labels: halo.run/role-template: "true" annotations: halo.run/module: "Apples Management" halo.run/alias-name: "Apples View" rules: - apiGroups: [ "apples.guqing.github.com" ] resources: [ "apples" ] verbs: [ "get", "list" ] ================================================ FILE: application/src/test/resources/plugin/plugin-0.0.1/extensions/setting.yaml ================================================ apiVersion: v1alpha1 kind: Setting metadata: name: fake-setting spec: forms: [ ] ================================================ FILE: application/src/test/resources/plugin/plugin-0.0.1/extensions/test.yml ================================================ apiVersion: plugin.halo.run/v1alpha1 kind: ReverseProxy metadata: name: reverse-proxy-template labels: plugin.halo.run/pluginName: io.github.guqing.apples rules: - path: /static/** file: directory: static - path: /admin/** file: directory: admin ================================================ FILE: application/src/test/resources/plugin/plugin-0.0.1/plugin.yaml ================================================ apiVersion: v1 kind: Plugin metadata: name: plugin-1 spec: # 'version' is a valid semantic version string (see semver.org). version: 0.0.1 requires: ">=2.0.0" author: name: guqing logo: https://guqing.xyz/avatar pluginDependencies: "banana": "0.0.1" homepage: https://github.com/guqing/halo-plugin-1 displayName: "a name to show" description: "Tell me more about this plugin." license: - name: MIT ================================================ FILE: application/src/test/resources/plugin/plugin-0.0.2/plugin.yaml ================================================ apiVersion: v1 kind: Plugin metadata: name: fake-plugin spec: # 'version' is a valid semantic version string (see semver.org). version: 0.0.2 requires: ">=2.0.0" author: name: johnniang logo: https://halo.run/avatar homepage: https://github.com/halo-sigs/halo-plugin-1 displayName: "Fake Display Name" description: "Fake description" license: - name: GPLv3 ================================================ FILE: application/src/test/resources/plugin/plugin-for-finder/META-INF/plugin-components.idx ================================================ # Generated by Halo run.halo.fake.FakePlugin ================================================ FILE: application/src/test/resources/plugin/plugin-for-reverseproxy/static/test.txt ================================================ Fake content. ================================================ FILE: application/src/test/resources/plugin/plugin.yaml ================================================ apiVersion: v1 kind: Plugin metadata: name: plugin-1 spec: # 'version' is a valid semantic version string (see semver.org). version: 0.0.1 requires: ">=2.0.0" author: name: guqing logo: https://guqing.xyz/avatar pluginDependencies: "banana": "0.0.1" homepage: https://github.com/guqing/halo-plugin-1 displayName: "a name to show" description: "Tell me more about this plugin." license: - name: MIT ================================================ FILE: application/src/test/resources/themes/default/i18n/default.properties ================================================ index.welcome=欢迎来到首页 title=这是来自 i18n/default.properties 的标题 ================================================ FILE: application/src/test/resources/themes/default/i18n/en.properties ================================================ index.welcome=Welcome to the index ================================================ FILE: application/src/test/resources/themes/default/i18n/zh.properties ================================================ title=来自 i18n/zh.properties 的标题 ================================================ FILE: application/src/test/resources/themes/default/templates/index.html ================================================ Title index
================================================ FILE: application/src/test/resources/themes/default/templates/index.properties ================================================ title=Title from index.properties ================================================ FILE: application/src/test/resources/themes/default/templates/index_zh.properties ================================================ title=来自 index_zh.properties 的标题 ================================================ FILE: application/src/test/resources/themes/default/templates/timezone.html ================================================

================================================ FILE: application/src/test/resources/themes/default/theme.yaml ================================================ apiVersion: theme.halo.run/v1alpha1 kind: Theme metadata: name: default spec: displayName: Default author: name: halo-dev website: https://halo.run description: Default theme for Halo 2.0 logo: https://halo.run/logo website: https://github.com/halo-sigs/theme-default repo: https://github.com/halo-sigs/theme-default.git version: 1.0.0 require: 2.0.0 ================================================ FILE: application/src/test/resources/themes/invalid-missing-manifest/i18n/default.properties ================================================ index.welcome=\u6B22\u8FCE\u6765\u5230\u9996\u9875 ================================================ FILE: application/src/test/resources/themes/invalid-missing-manifest/i18n/en.properties ================================================ index.welcome=Welcome to the index ================================================ FILE: application/src/test/resources/themes/invalid-missing-manifest/templates/index.html ================================================ Title index
================================================ FILE: application/src/test/resources/themes/invalid-missing-manifest/templates/timezone.html ================================================

================================================ FILE: application/src/test/resources/themes/other/i18n/default.properties ================================================ index.welcome=Other \u9996\u9875 ================================================ FILE: application/src/test/resources/themes/other/i18n/en.properties ================================================ index.welcome=other index ================================================ FILE: application/src/test/resources/themes/other/templates/index.html ================================================ Other theme title

================================================ FILE: application/src/test/resources/themes/other/theme.yaml ================================================ apiVersion: theme.halo.run/v1alpha1 kind: Theme metadata: name: default spec: displayName: Default author: name: halo-dev website: https://halo.run description: Default theme for Halo 2.0 logo: https://halo.run/logo website: https://github.com/halo-sigs/theme-default repo: https://github.com/halo-sigs/theme-default.git version: 1.0.1 require: 2.0.0 ================================================ FILE: application/src/test/resources/ui/console.html ================================================ console index ================================================ FILE: application/src/test/resources/ui/uc.html ================================================ uc index ================================================ FILE: application/src/test/resources/ui/ui-assets/fake.txt ================================================ fake. ================================================ FILE: buildSrc/build.gradle ================================================ plugins { id 'groovy-gradle-plugin' } ================================================ FILE: buildSrc/src/main/groovy/UploadBundleTask.groovy ================================================ import groovy.json.JsonSlurper import org.gradle.api.DefaultTask import org.gradle.api.artifacts.repositories.PasswordCredentials import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.* import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.time.Duration import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import static java.net.http.HttpRequest.BodyPublishers.ofFile import static java.net.http.HttpRequest.BodyPublishers.ofString import static java.nio.charset.StandardCharsets.UTF_8 /** * Task to upload a bundle to Sonatype Central Repository. * @author JohnNiang */ abstract class UploadBundleTask extends DefaultTask { @InputFile abstract RegularFileProperty getBundleFile(); @Input @Optional abstract Property getPublishingType(); @Input abstract Property getCredentials(); UploadBundleTask() { getPublishingType().convention(PublishingType.AUTOMATIC) } @TaskAction void upload() { if (project.version.toString().endsWith("-SNAPSHOT")) { logger.lifecycle("Detected SNAPSHOT version, uploading to maven-snapshots repository...") uploadSnapshotBundle() logger.lifecycle("Snapshot bundle uploaded successfully.") return } if (checkIfPublished()) { logger.lifecycle("The component ${project.group}:${project.name}:${project.version} is already published, skipping upload.") return } def deploymentId = uploadReleaseBundle() def maxWait = Duration.ofMinutes(12) def pollingInterval = Duration.ofSeconds(10) def maxRetry = (int) (maxWait.toMillis() / pollingInterval.toMillis()) def retry = 0 logger.lifecycle("Preparing to check deployment state for deployment ID: ${deploymentId}, max retries: ${maxRetry}, polling interval: ${pollingInterval.toSeconds()} seconds") while (retry++ <= maxRetry) { def state = checkDeploymentState(deploymentId) if (state == null) { throw new RuntimeException("Failed to check deployment state for deployment ID: ${deploymentId}") } if (state == DeploymentState.PUBLISHED) { println("Bundle published successfully with deployment ID: ${deploymentId}") break } if (state == DeploymentState.VALIDATED) { println("Bundle validated successfully with deployment ID: ${deploymentId}") break } if (state == DeploymentState.FAILED) { throw new RuntimeException("Bundle deployment failed with deployment ID: ${deploymentId}") } if (state == DeploymentState.VALIDATING) { logger.lifecycle('Bundle is being validated, please wait...') } else if (state == DeploymentState.PENDING) { logger.lifecycle('Bundle is pending, please wait...') } println("Deployment state: ${state}, retrying(${retry}) in ${pollingInterval.toSeconds()} seconds...") sleep(pollingInterval.toMillis()) } } boolean checkIfPublished() { createHttpClient().withCloseable { client -> def endpoint = "https://central.sonatype.com/api/v1/publisher/published?namespace=${project.group}&name=${project.name}&version=${project.version}"; logger.debug("Checking if published at endpoint: ${endpoint}") def request = HttpRequest.newBuilder(URI.create(endpoint)) .header("Authorization", bearerAuthorizationHeader) .GET() .build() def response = client.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)) if (response.statusCode() != 200) { throw new RuntimeException("Failed to check if published: status: ${response.statusCode()} - body: ${response.body()}") } logger.debug("Response body: ${response.body()}") def result = new JsonSlurper().parseText(response.body()) logger.debug("Check if published result: ${result}") return result.published as boolean } } DeploymentState checkDeploymentState(String deploymentId) { createHttpClient().withCloseable { client -> def endpoint = "https://central.sonatype.com/api/v1/publisher/status?id=${deploymentId}" def request = HttpRequest.newBuilder(URI.create(endpoint)) .header("Authorization", bearerAuthorizationHeader) .POST(HttpRequest.BodyPublishers.noBody()) .build() def response = client.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)) if (response.statusCode() != 200) { throw new RuntimeException("Failed to check deployment status: status: ${response.statusCode()} - body: ${response.body()}") } logger.debug("Response body: ${response.body()}") def status = new JsonSlurper().parseText(response.body()) logger.debug("Deployment status: ${status}") return status.deploymentState as DeploymentState } } void uploadSnapshotBundle() { logger.lifecycle('Starting to upload snapshot bundle: {}', bundleFile.asFile.get()) createHttpClient().withCloseable { client -> new ZipInputStream(bundleFile.asFile.get().newInputStream()).withCloseable { zis -> ZipEntry entry while ((entry = zis.nextEntry) != null) { if (entry.directory) { continue } def relativePath = entry.name def endpoint = "https://central.sonatype.com/repository/maven-snapshots/$relativePath" def request = HttpRequest.newBuilder(URI.create(endpoint)) .header("Authorization", basicAuthorizationHeader) .header("Content-Type", "application/octet-stream") .PUT(HttpRequest.BodyPublishers.ofByteArray(zis.readAllBytes())) .build() def response = client.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)) logger.debug('Response status code: {}, body: {}', response.statusCode(), response.body()) if (response.statusCode() != 200 && response.statusCode() != 201) { throw new RuntimeException("Failed to upload snapshot bundle: status: ${response.statusCode()} - entry: ${relativePath} - body : ${response.body()}") } logger.lifecycle('Uploaded snapshot entry: {}, status: {}', relativePath, response.statusCode()) } } } } String uploadReleaseBundle() { logger.lifecycle('Starting to upload release bundle: {}', bundleFile.asFile.get()) createHttpClient().withCloseable { client -> // See https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html for more def boundary = "------haloformboundary${UUID.randomUUID().toString().replace('-', '')}" def crlf = "\r\n" def delimiter = "${crlf}--${boundary}" def endpoint = "https://central.sonatype.com/api/v1/publisher/upload?publishingType=${publishingType.get()}" def publishers = new ArrayList() publishers.add(ofString("${delimiter}${crlf}")) publishers.add(ofString("""\ Content-Disposition: form-data; name="bundle"; filename="${bundleFile.get().asFile.name}"\ ${crlf}\ """)) publishers.add(ofString("Content-Type: application/octet-stream${crlf}${crlf}")) publishers.add(ofFile(bundleFile.get().asFile.toPath())) publishers.add(ofString("${delimiter}--${crlf}")) def request = HttpRequest.newBuilder(URI.create(endpoint)) .header("Authorization", bearerAuthorizationHeader) .header("Content-Type", "multipart/form-data; boundary=${boundary}") .POST(HttpRequest.BodyPublishers.concat(publishers.toArray(HttpRequest.BodyPublisher[]::new))) .build() def response = client.send(request, HttpResponse.BodyHandlers.ofString(UTF_8)) if (logger.debugEnabled) { logger.debug('Response status code: {}, body: {}', response.statusCode(), response.body()) } if (response.statusCode() != 201 && response.statusCode() != 200) { throw new RuntimeException("Failed to upload bundle: status: ${response.statusCode()} - body: ${response.body()}") } def deploymentId = response.body().trim() logger.lifecycle('Uploaded release bundle successfully, deployment ID: {}', deploymentId) return deploymentId } } @Internal String getBearerAuthorizationHeader() { def encoded = Base64.encoder.encodeToString("${credentials.get().username}:${credentials.get().password}".getBytes(UTF_8)) return "Bearer ${encoded}" } @Internal String getBasicAuthorizationHeader() { def encoded = Base64.encoder.encodeToString("${credentials.get().username}:${credentials.get().password}".getBytes(UTF_8)) return "Basic ${encoded}" } static HttpClient createHttpClient() { return HttpClient.newBuilder() .connectTimeout(Duration.ofMinutes(1)) .build() } enum PublishingType { AUTOMATIC, USER_MANAGED, ; } enum DeploymentState { PENDING, VALIDATING, VALIDATED, PUBLISHING, PUBLISHED, FAILED, ; } } ================================================ FILE: buildSrc/src/main/groovy/halo.publish.gradle ================================================ plugins { id 'maven-publish' id 'signing' } def internalRepo = layout.buildDirectory.dir('repos/internal') publishing { publications.register('mavenJava', MavenPublication) { pom { url = 'https://github.com/halo-dev/halo' licenses { license { name = 'The GNU General Public License v3.0' url = 'https://www.gnu.org/licenses/gpl-3.0.en.html' } } developers { developer { id = 'johnniang' name = 'JohnNiang' email = 'johnniang@foxmail.com' } } scm { connection = 'scm:git:https://github.com/halo-dev/halo.git' developerConnection = 'scm:git:ssh://git@github.com:halo-dev/halo.git' url = 'https://github.com/halo-dev/halo' } } } repositories { maven { name = 'internal' url = internalRepo } } } signing { sign publishing.publications.mavenJava } tasks.register('createBundle', Zip) { group = PublishingPlugin.PUBLISH_TASK_GROUP dependsOn tasks.named('publishAllPublicationsToInternalRepository') from(internalRepo) archiveBaseName = "${project.group}.${project.name}" } tasks.register('uploadBundle', UploadBundleTask) { group = PublishingPlugin.PUBLISH_TASK_GROUP credentials = providers.credentials(PasswordCredentials, "portal") bundleFile = tasks.named('createBundle', Zip).map { it.archiveFile }.get() } tasks.register('cleanInternalRepo', Delete) { group = PublishingPlugin.PUBLISH_TASK_GROUP delete internalRepo } tasks.named('publishAllPublicationsToInternalRepository') { dependsOn tasks.named('cleanInternalRepo') } tasks.named('publish') { dependsOn tasks.named('uploadBundle') } ================================================ FILE: config/checkstyle/checkstyle.xml ================================================ ================================================ FILE: docs/authentication/README.md ================================================ # Halo 认证方式 目前 Halo 支持的认证方式有: - 基本认证(Basic Auth) - 表单登录(Form Login) 计划支持的认证方式有: - [个人令牌认证(Personal Access Token)](https://github.com/halo-dev/halo/issues/1309) - [OAuth2](https://oauth.net/2/) ## 基本认证 这是最简单的一种认证方式,通过简单设置 HTTP 请求头 `Authorization: Basic xxxyyyzzz==` 即可实现认证,访问 Halo API,例如: ```bash ╰─❯ curl -u "admin:P@88w0rd" -H "Accept: application/json" http://localhost:8090/api/v1alpha1/users 或者 ╰─❯ echo -n "admin:P@88w0rd" | base64 YWRtaW46UEA4OHcwcmQ= ╰─❯ curl -H "Authorization: Basic YWRtaW46UEA4OHcwcmQ=" -H "Accept: application/json" http://localhost:8090/api/v1alpha1/users ``` ## 表单认证 这是一种比较常用的认证方式,只需提供用户名和密码以及 `CSRF 令牌`(用于防止重复提交和跨站请求伪造)。 - 表单参数 | 参数名 | 类型 | 说明 | | ---------- | ------ | ------------------------------------- | | username | form | 用户名 | | password | form | 密码 | | _csrf | form | `CSRF` 令牌。由客户端随机生成。 | | XSRF-TOKEN | cookie | 跨站请求伪造令牌,和 `_csrf` 的值一致 | - HTTP 200 响应 仅在请求头 `Accept` 中包含 `application/json` 时发生,响应示例如下所示: ```bash ╰─❯ curl 'http://localhost:8090/login' \ -H 'Accept: application/json' \ -H 'Cookie: XSRF-TOKEN=1ff67e0c-6f2c-4cf9-afb5-81bc1015b8e5' \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-raw '_csrf=1ff67e0c-6f2c-4cf9-afb5-81bc1015b8e5&username=admin&password=P@88w0rd' ``` ```bash < HTTP/1.1 200 OK < Vary: Origin < Vary: Access-Control-Request-Method < Vary: Access-Control-Request-Headers < Content-Type: application/json < Content-Length: 161 < Cache-Control: no-cache, no-store, max-age=0, must-revalidate < Pragma: no-cache < Expires: 0 < X-Content-Type-Options: nosniff < X-Frame-Options: DENY < X-XSS-Protection: 1 ; mode=block < Referrer-Policy: no-referrer < Set-Cookie: SESSION=d04db9f7-d2a6-4b7c-9845-ef790eb4a980; Path=/; HttpOnly; SameSite=Lax ``` ```json { "username": "admin", "authorities": [ { "authority": "ROLE_super-role" } ], "accountNonExpired": true, "accountNonLocked": true, "credentialsNonExpired": true, "enabled": true } ``` - HTTP 302 响应 仅在请求头 `Accept` 中不包含 `application/json`才会发生,响应示例如下所示: ```bash ╰─❯ curl 'http://localhost:8090/login' \ -H 'Accept: */*' \ -H 'Cookie: XSRF-TOKEN=1ff67e0c-6f2c-4cf9-afb5-81bc1015b8e5' \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-raw '_csrf=1ff67e0c-6f2c-4cf9-afb5-81bc1015b8e5&username=admin&password=P@88w0rd' ``` ```bash < HTTP/1.1 302 Found < Vary: Origin < Vary: Access-Control-Request-Method < Vary: Access-Control-Request-Headers < Location: /console/ < Cache-Control: no-cache, no-store, max-age=0, must-revalidate < Pragma: no-cache < Expires: 0 < X-Content-Type-Options: nosniff < X-Frame-Options: DENY < X-XSS-Protection: 1 ; mode=block < Referrer-Policy: no-referrer < Set-Cookie: SESSION=9ce6ad3f-7eba-4de5-abca-650b4721c7ac; Path=/; HttpOnly; SameSite=Lax < content-length: 0 ``` 未来计划支持“记住我(Remember Me)”功能。 ## Personal Access Token ### 背景 Halo 是一款现代化的开源 CMS / 建站系统,为了便于开发者和用户利用 API 访问网站数据,Halo 支持了 Personal Access Token(以下简称 PAT)功能。 用户可以在 Halo 的后台生成 PAT,它是一个随机字符串,用于在 API 请求头里提供验证身份用。Halo 后端在接收请求时会校验 PAT 的值,如果匹配就会允许访问相应的 API 数据。 这种 PAT 机制避免了直接使用用户名密码的安全隐患,开发者可以为每个 PAT 设置访问范围、过期时间等。同时使用随机 PAT 也增加了安全性。这为开发 Halo 插件和应用提供了更安全简便的认证方式。 相比直接暴露服务端 API,这种 PAT 机制也更标准化和安全可控。Halo 在参考业内主流做法的基础上,引入了 PAT,以便于生态系统的开放与丰富。 ### 设计 PAT 以 `pat_` 开头,剩余部分为随机字符串,随机字符串可以是 [JWT](https://datatracker.ietf.org/doc/html/rfc7519)、UUID 或其他经过加密的随机字符串。目前,Halo 的实现是 `pat_` + `JWT` 的形式,例如: ```text pat_eyJraWQiOiJabUNtcWhJX2FuaFlWQW5aRlVTS0lOckxXRFhqaEp1Nk9ZRGRtcW13Rno4IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJhZG1pbiIsInJvbGVzIjpbInN1cGVyLXJvbGUiXSwicGF0X25hbWUiOiJwYXQtYWRtaW4tSVdvbFEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwOTAvIiwiZXhwIjoxNjk0NjcyMDc5LCJpYXQiOjE2OTQ1ODU3MjAsImp0aSI6IjE3ZWFkNzlkLTRkMjctYjg4NS02YjAzLTM4Y2JlYzQxMmFlMyJ9.xiq36NZIM3_ynBx-l0scGdfX-89aJi6uV7HJz_kNnuT78CFmxD-XTpncK1E-hqPdQSrSwyG4gT1pVO17UmUCoyoAkZKKKVk_seFwxdbygIueo2UJA5kVw1Naf_6iLtNkAXxAiYUpd8ihIwvVedhmOMQ9UUfd4QKZDR1XnTW4EAteWBi7b0pWqSa4h5lv7TpmAECY_KDAGrBRGGhc9AxsrGYPNZo68n2QGJ5BjH29vfdQaZz4vwsgKxG1WJ9Y7c8cQI9JN8EyQD_n560NWAaoFnRi1qL3nexvhjq8EVyGVyM48aKA02UcyvI9cxZFk6ZgnzmUsMjyA6ZL7wuexkujVqmc3iO5plBDCjW7oMe1zPQq-gEJXJU6gdr_SHcGG1BjamoekCkOeNT3CPzA_-5j3AVlj7FTFQkbn_h-kV07mfNO45BVVKsMb08HrN6iEk7TOX7SxN0s2gFc3xYVcXBMveLtftOfXs04SvSFCfTDeJH_Jy-3lYb_GLOji7xSc6FgRbuAwmzHLlsgBT4NJhR_0dZ-jNsCDIQCIC3iDc0qbcNTJYYocT77YaQzIkleFIXyPiV0RsNPmSTEDGiDlctsZ-AmcGCDQ-UmW8SIFBrA93OHncvb47o0-uBwZLdF_we4S90hJlNiAPVhhrBMtCoTJotyrODMEzwbLIukvewFXp8 ``` 示例 Token 中 JWT 部分所对应的 Header 如下: ```json { "kid": "ZmCmqhI_anhYVAnZFUSKINrLWDXjhJu6OYDdmqmwFz8", "alg": "RS256" } ``` Payload 如下: ```json { "sub": "admin", "roles": [ "super-role" ], "pat_name": "pat-admin-IWolQ", "iss": "http://localhost:8090/", "exp": 1694672079, "iat": 1694585720, "jti": "17ead79d-4d27-b885-6b03-38cbec412ae3" } ``` ### 使用方式 #### 生成 PAT Halo 专门提供了生成 PAT 的端口:`/apis/api.console.security.halo.run/v1alpha1/users/-/personalaccesstokens`。创建 PAT 请求示例如下: ```shell curl -u admin:admin -X 'POST' \ 'http://localhost:8090/apis/api.console.security.halo.run/v1alpha1/users/-/personalaccesstokens' \ -H 'accept: application/json' \ -H 'Content-Type: application/json' \ -d '{ "spec": { "name": "My PAT", "description": "This is my first PAT.", "expiresAt": "2023-09-15T02:42:35.136Z" "roles": [""] } }' ``` ```json { "spec": { "description": "This is my first PAT.", "expiresAt": "2023-09-16T02:42:35.136Z", "roles": [], "username": "admin", "revoked": false, "tokenId": "0b897d9c-56d7-5541-2662-110b70e3f9fd" }, "apiVersion": "security.halo.run/v1alpha1", "kind": "PersonalAccessToken", "metadata": { "generateName": "pat-admin-", "name": "pat-admin-lobkm", "annotations": { "security.halo.run/access-token": "pat_eyJraWQiOiJabUNtcWhJX2FuaFlWQW5aRlVTS0lOckxXRFhqaEp1Nk9ZRGRtcW13Rno4IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJhZG1pbiIsInJvbGVzIjpbXSwicGF0X25hbWUiOiJwYXQtYWRtaW4tbG9ia20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwOTAvIiwiZXhwIjoxNjk0ODMyMTU1LCJpYXQiOjE2OTQ3NDcyOTgsImp0aSI6IjBiODk3ZDljLTU2ZDctNTU0MS0yNjYyLTExMGI3MGUzZjlmZCJ9.UVFYzKmz3bUk7fV6xh_CpuNJA-BR8bci-DIJ7o0fk-hayHXFHr_-7HMrVn7iZcphryqmk0RLv7Zsu_AjY9Qn9iCYybBJBycU0tUJzhDexRtj1ViJtlsraoYxLNSYpJK1hcPngeJuiMa9FZrYGp0k_7GX1NddoXLUBI9orN9DbdKmmJXtvigaxPCp52Mu7fBtVsTmO5fk_y2CglqRl_tkLRpFSgUbERKOqKItctDFRg-WUALBYEpXbhZIXBMuTCsJwhniBMpc1Uu_a1Dqa3K5hDgfHTeUADY2BuhEdYJCODPCzmdfWMNqxYSKQT5JFYoDv-ed6cRqNjKeNvd1IPT3RDkVt_fbo8KPrzvkgIjIzni-Wlwe-pXXQbj_n8iax-jkeK526iu8q2CLptxYxLGD0j8htKZramrov4UkK_eIsotEZZfqig9sYVU5_b442WhOWatdB_pbKj7h-YK1Cb2ueg5kl73bcbBu63b8edJZClp6xr72az343SfBZdwrT_JJ5HR0hJmckAMR_U4qvGWrJ-dobXDgY9Oz-qObfiyglzn0Wrz4HRPlmqDFr2o6TMV7UVjQiV77tDzaNbaXVevXGPS5MaZr313dia7XLpIV3QopXma7rDR6Xnqg7ftDQb5vAvsjwN-JsVabAsdFeCo6ejE1slAD9ZQrD88kgfAIuX4" }, "version": 0, "creationTimestamp": "2023-09-15T03:08:18.875350Z" } } ``` 请求体说明如下表所示: | 属性名 | 描述 | |-------------|----------------------------------------------------------------------------------------------------| | name | PAT 名称。必填。 | | description | PAT 描述。非必填。 | | expiresAt | PAT 过期时间,一旦创建不可修改,或修改无效。如果不填写,则表示 PAT 无过期时间。 | | roles | 授权给 PAT 的角色,必须包含在当前用户所拥有的角色内。如果设置为 `null` 或者 `[]`,则表示当前 PAT 仅会拥有 `anonymous` 和 `authenticated` 角色。 | 响应体说明如下所示: | 属性路径 | 描述 | |-----------------------------------------------------|----------------------------------------------| | security.halo.run/access-token | 生成好的 PAT。需要注意的是,这个 PAT 不会保存在数据库中,所以仅有一次保存机会。 | #### 使用 PAT 向 Halo 发送请求时,携带 Header:`Authorization: Bearer $PAT` 即可。示例如下: ```shell curl http://localhost:8090/apis/api.console.halo.run/v1alpha1/users/- \ -H "Authorization: Bearer pat_eyJraWQiOiJabUNtcWhJX2FuaFlWQW5aRlVTS0lOckxXRFhqaEp1Nk9ZRGRtcW13Rno4IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJhZG1pbiIsInJvbGVzIjpbXSwicGF0X25hbWUiOiJwYXQtYWRtaW4tbG9ia20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwOTAvIiwiZXhwIjoxNjk0ODMyMTU1LCJpYXQiOjE2OTQ3NDcyOTgsImp0aSI6IjBiODk3ZDljLTU2ZDctNTU0MS0yNjYyLTExMGI3MGUzZjlmZCJ9.UVFYzKmz3bUk7fV6xh_CpuNJA-BR8bci-DIJ7o0fk-hayHXFHr_-7HMrVn7iZcphryqmk0RLv7Zsu_AjY9Qn9iCYybBJBycU0tUJzhDexRtj1ViJtlsraoYxLNSYpJK1hcPngeJuiMa9FZrYGp0k_7GX1NddoXLUBI9orN9DbdKmmJXtvigaxPCp52Mu7fBtVsTmO5fk_y2CglqRl_tkLRpFSgUbERKOqKItctDFRg-WUALBYEpXbhZIXBMuTCsJwhniBMpc1Uu_a1Dqa3K5hDgfHTeUADY2BuhEdYJCODPCzmdfWMNqxYSKQT5JFYoDv-ed6cRqNjKeNvd1IPT3RDkVt_fbo8KPrzvkgIjIzni-Wlwe-pXXQbj_n8iax-jkeK526iu8q2CLptxYxLGD0j8htKZramrov4UkK_eIsotEZZfqig9sYVU5_b442WhOWatdB_pbKj7h-YK1Cb2ueg5kl73bcbBu63b8edJZClp6xr72az343SfBZdwrT_JJ5HR0hJmckAMR_U4qvGWrJ-dobXDgY9Oz-qObfiyglzn0Wrz4HRPlmqDFr2o6TMV7UVjQiV77tDzaNbaXVevXGPS5MaZr313dia7XLpIV3QopXma7rDR6Xnqg7ftDQb5vAvsjwN-JsVabAsdFeCo6ejE1slAD9ZQrD88kgfAIuX4" ``` ================================================ FILE: docs/backup-and-restore.md ================================================ # 备份和恢复 Proposal ## Motivation 目前,Halo 2.x 支持多种数据库:H2、MySQL、MariaDB、Microsoft SQL Server、Oracle 和 PostgreSQL,虽然数据库有备份和恢复的功能,但是仍然缺少应用级别的备份和恢复功能。Halo 的数据不仅限于数据库中的数据,还包含工作目录下的数据,例如主题、插件和日志等。 ## Goals - 全站备份,包括数据库中的数据和工作目录的数据。 - 全站恢复,包括恢复数据库中的数据和工作目录的数据。 - 用户可控制备份文件存储的时间。 - 对于工作目录的数据,用户可选择性备份和恢复。 - 用户可指定备份权限到任意用户。 ## Non-Goals - 仅备份部分自定义资源。 - 仅备份和恢复文章 Markdown。 - 定时备份。 - 加密备份文件。 - 备份文件自动上传至对象存储。 ## Use Cases - 从某种数据库(例如:H2)迁移至另外的数据库(例如:MySQL),不会因为 SQL 的兼容性而影响迁移。 - 定时完整备份 Halo,并存储至对象存储,一旦发生意外可随时恢复。 ## Requirements - 仅支持 2.8.x 及以上的 Halo。 - 恢复的数据的 creationTimestamp 可能会被当前时间覆盖。 ## Draft 恢复数据之前需要完整备份当前 Halo,以便恢复过程中发生错误导致无法回滚。 备份文件将存储在 `${halo.work-dir}/backups/halo-full-backup-2023.07.03-17:52:59.zip`。 备份整站可能需要大量的时间,所以我们需要创建自定义模型(Backup)用于保存用户创建备份的请求,并异步执行备份操作,最终将结果反馈至自定义模型数据中。 Backup 模型样例如下: - 备份成功样例 ```yaml apiVersion: migration.halo.run/v1alpha1 kind: Backup metadata: name: halo-full-backup-xyz creationTimestamp: 2023.07.04-10:25:30 spec: format: zip autoDeleteWhen: 2023.07.10-00:00:00Z status: phase: Succeeded startTimestamp: 2023.07.04-10:25:31 completionTimestamp: 2023.07.04-10:26:30 filename: halo-full-backup-2023-07-04-10-25-30.zip size: 1024 # data unit: bytes ``` - 备份失败样例 ```yaml apiVersion: migration.halo.run/v1alpha1 kind: Backup metadata: name: halo-full-backup-xyz creationTimestamp: 2023.07.04-10:25:30 spec: compressionFormat: zip | 7z | tar | tar.gz # 压缩格式 status: startTimestamp: 2023.07.04-10:25:31 # Pending: 刚刚创建好 Backup 资源,等待 Reconciler reconcile。 # Running: Reconciler 正在备份 Halo。 # Succeeded: Reconciler 成功执行备份 Halo 操作。 # Failed: 备份 Halo 失败。 phase: Failed failureReason: DatabaseConnectionReset | UnsupportedCompression # 机器可识别的信息 failureMessage: The database connection reset. # 人类可阅读的信息 ``` 同时,BackupReconciler 将负责备份操作,并更新 Backup 数据。 请求示例如下: ```text POST /apis/migration.halo.run/v1alpha1/backups Content-Type: application/json ``` ### 备份 准备好所有的备份内容后,需要计算摘要并保存,以便后期恢复校验备份文件完整性使用。 #### 数据库备份和恢复 因为 Halo 的 [Extension 设计](https://github.com/halo-dev/rfcs/tree/main/extension),所以 Halo 的在数据库中的数据备份相对比较简单,只需要简单备份 ExtensionStore 即可。恢复同理。 #### 工作目录备份和恢复 Halo 工作目录样例如下所示: ```text ├── application.yaml ├── attachments │   └── upload │   └── image_2023-06-09_16-24-41.png ├── db │   └── halo-next.mv.db ├── indices │   └── posts │   ├── _a.cfe │   ├── _a.cfs │   ├── _a.si │   ├── segments_h │   └── write.lock ├── keys │   ├── id_rsa │   └── id_rsa.pub ├── logs │   ├── halo.log │   ├── halo.log.2023-06-01.0.gz │   ├── halo.log.2023-06-02.0.gz │   ├── halo.log.2023-06-05.0.gz │   └── halo.log.2023-06-26.0.gz ├── plugins │   ├── PluginCommentWidget-1.5.0.jar │   ├── PluginFeed-1.1.1.jar │   ├── PluginSearchWidget-1.0.0.jar │   ├── PluginSitemap-1.0.2.jar │   └── configs └── themes ├── theme-earth │   ├── README.md │   ├── settings.yaml │   ├── templates │   │   ├── archives.html │   │   ├── assets │   │   │   ├── dist │   │   │   │   ├── main.iife.js │   │   │   │   └── style.css │   │   │   └── images │   │   │   ├── default-avatar.svg │   │   │   └── default-background.png │   │   ├── author.html │   │   ├── category.html │   │   ├── error │   │   │   └── error.html │   │   ├── index.html │   │   ├── links.html │   │   ├── modules │   │   │   ├── category-filter.html │   │   │   ├── category-tree.html │   │   │   ├── featured-post-card.html │   │   │   ├── footer.html │   │   │   ├── header.html │   │   │   ├── hero.html │   │   │   ├── layout.html │   │   │   ├── post-card.html │   │   │   ├── sidebar.html │   │   │   ├── tag-filter.html │   │   │   └── widgets │   │   │   ├── categories.html │   │   │   ├── latest-comments.html │   │   │   ├── popular-posts.html │   │   │   ├── profile.html │   │   │   └── tags.html │   │   ├── page.html │   │   ├── post.html │   │   ├── tag.html │   │   └── tags.html │   └── theme.yaml ``` 备份时需要过滤 `db`、backups` 和 `indices` 目录。 #### 备份文件结构 备份文件主要包含自定义资源(`extensions.data`)和工作目录(`workdir.data`)的数据。 - `extensions.data` 前期可考虑使用 JSON 来存储所有的 ExtensionStore 数据。 - `workdir.data` 对工作目录进行 `ZIP` 压缩。 - config.yaml(备份配置) 主要用于描述 `extensions.data` 和 `workdir.data` 压缩格式,后续可扩展备份与恢复相关的配置。例如: ```yaml compressions: extensions: json | others workdir: zip | others ``` 前期可不实现该功能。 ### 恢复 用户通过上传备份文件的方式进行恢复。当且仅当博客未初始化阶段才能进行恢复操作,否则可能会造成数据不一致。 请求示例如下: ```text POST /apis/migration.halo.run/v1alpha1/restorations Content-Type: multipart/form-data; boundary="boundary" ''' --boundary Content-Disposition: form-data; name="backupfile"; filename="halo-full-backup.zip" Content-Type: application/zip ''' ``` 恢复步骤如下: 1. 解压缩备份文件。 2. 校验备份文件的完整性。 2. 恢复所有 ExtensionStore。 3. 覆盖当前工作目录。 4. 备份完成。 > 需要注意内存占用问题。 ## TBDs - 数据备份期间可能会存在数据的创建、更新和删除。 我们将忽略这些数据变化。 - 是否支持在初始化博客后恢复数据? 支持。不过可能会覆盖掉已有的数据。 ================================================ FILE: docs/cache/page.md ================================================ # 缓存 缓存在各个领域用得非常广泛,例如 CPU 的三级缓存,可加速从主内存中获取数据到处理器。Halo 的主要应用以博客为主,页面更新不会特别频繁,大多数情况下,实时渲染的结果都是没有变化的。如果能够缓存这些不经常变更的页面,可减少数据库访问,加快访问速度。 Halo 采用由 Spring 框架提供的 Caching 作为缓存框架。该缓存框架面对各种缓存实现,提供了统一的访问入口,后续更换缓存仅需修改少量代码和配置。 Halo 默认提供了 CacheProperties 用于启用/禁用缓存,示例如下: ```yaml halo: caches: page: disabled: true others: disabled: false ``` # 页面缓存 页面缓存包括缓存响应体、响应头和响应状态。页面缓存规则如下: 1. 仅缓存模板引擎所渲染的页面。 2. 仅缓存 `Content-Type` 为 `text/html` 的页面。 3. 仅缓存响应状态为 `HTTP 200(OK)`。 4. 请求访问为 `GET`。 缓存详情见下表: | 术语 | 值 | |------|----------------| | 名称 | `page` | | 失效时间 | 距最近一次访问 `1` 小时 | | 缓存数量 | `10,000` 个 | ================================================ FILE: docs/developer-guide/custom-endpoint.md ================================================ # 系统自定义 API 系统自定义 API 是一组特殊的 API,因为自定义模型 API 无法满足要求,需要开发者自己实现。 但是系统自定义 API 有一个统一的前缀:`/apis/api.console.halo.run/v1alpha1/`,剩余的部分可随意定义。 ## 如何在系统中创建一个系统自定义 API 1. 实现 `run.halo.app.core.extension.endpoint.CustomEndpoint` 接口 2. 将实现类设置为 Spring Bean 关于用户的自定义 API 实现类如下: ```java @Component public class UserEndpoint implements CustomEndpoint { private final ExtensionClient client; public UserEndpoint(ExtensionClient client) { this.client = client; } Mono me(ServerRequest request) { return ReactiveSecurityContextHolder.getContext() .map(ctx -> { var name = ctx.getAuthentication().getName(); return client.fetch(User.class, name) .orElseThrow(() -> new ExtensionNotFoundException(name)); }) .flatMap(user -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(user)); } @Override public RouterFunction endpoint() { return SpringdocRouteBuilder.route() .GET("/users/-", this::me, builder -> builder.operationId("GetCurrentUserDetail") .description("Get current user detail") .tag("api.console.halo.run/v1alpha1/User") .response(responseBuilder().implementation(User.class))) // 这里可添加其他自定义 API .build(); } } ``` 这样我们就可以启动 Halo,访问 Swagger UI 文档地址,并进行测试。 ================================================ FILE: docs/developer-guide/plugin-configuration-properties.md ================================================ # 插件外部配置 插件外部配置功能允许用户在特定目录添加插件相关的配置,插件启动的时候能够自动读取到该配置。 ## 配置优先级 > 优先级从上到下由高到低。 1. `${halo.work-dir}/plugins/configs/${plugin-id}.{yaml|yml}` 2. `classpath:/config.{yaml|yml}` 插件开发者可在 `Class Path` 下 添加 `config.{yaml|yml}` 作为默认配置。当 `.yaml` 和 `.yml` 同时出现时,以 `.yml` 的配置将会被忽略。 ## 插件中定义配置并使用 - `src/main/java/my/plugin/MyPluginProperties.java` ```java @Data @ConfigurationProperties public class MyPluginProperties { private String encryptKey; private String certPath; } ``` - `src/main/java/my/plugin/MyPluginConfiguration.java` ```java @EnableConfigurationProperties(MyPluginProperties.class) @Configuration public class MyPluginConfiguration { } ``` - `src/main/java/my/plugin/MyPlugin.java` ```java @Component @Slf4j public class MyPlugin extends BasePlugin { private final MyPluginProperties storeProperties; public MyPlugin(PluginWrapper wrapper, MyPluginProperties storeProperties) { super(wrapper); this.storeProperties = storeProperties; } @Override public void start() { log.info("My plugin properties: {}", storeProperties); } } ``` - `src/main/resources/config.yaml` ```yaml encryptKey: encrytkey== certPath: /path/to/cert ``` ## 插件使用者配置 - `${halo.work-dir}/plugins/configs/${plugin-id}.{yaml|yml}` ```yaml encryptKey: override encrytkey== certPath: /another/path/to/cert ``` ## 可能存在的问题 - 增加未来实现"集群"架构的难度。 ================================================ FILE: docs/email-verification/README.md ================================================ ## 背景 在 Halo 中,邮箱作为用户主要的身份识别和通信方式,不仅有助于确保用户提供的邮箱地址的有效性和所有权,还对于减少滥用行为、提高账户安全性以及确保用户可以接收重要通知(如密码重置、注册新账户、确认重要操作等)至关重要。 邮箱验证是用户管理过程中的一个关键组成部分,可以帮助维护了一个健康、可靠的用户基础,并且为系统管理员提供了一个额外的安全和管理手段,因此实现一个高效、安全且用户友好的邮箱验证功能至关重要。 ## 需求 1. **用户注册验证**:确保新用户在注册过程中提供有效的邮箱地址。邮箱验证作为新用户激活其账户的必要步骤,有助于减少虚假账户和提升用户的整体质量。 2. **密码重置和安全操作**:在用户忘记密码或需要重置密码时,向已验证的邮箱地址发送密码重置链接来确保安全性。 3. **用户通知**:验证邮箱地址有助于确保用户可以接收到重要通知,如文章被评论、有新回复等。 ## 目标 - 支持用户在修改邮箱后支持重新进行邮箱验证。 - 允许用户在未收到邮件或邮件过期时重新请求发送验证邮件。 - 避免邮件通知被滥用,如频繁发送验证邮件,需要添加限制。 - 验证码过期机制,以确保验证邮件的有效性和安全性。 ## 非目标 - 不考虑用户多邮箱地址的验证。 ## 方案 ### EmailVerificationManager 通过使用 guava 提供的 Cache 来实现一个 EmailVerificationManager 来管理邮箱验证的缓存。 ```java class EmailVerificationManager { private final Cache emailVerificationCodeCache = CacheBuilder.newBuilder() .expireAfterWrite(CODE_EXPIRATION_MINUTES, TimeUnit.MINUTES) .maximumSize(10000) .build(); private final Cache blackListCache = CacheBuilder.newBuilder() .expireAfterWrite(Duration.ofHours(1)) .maximumSize(1000) .build(); record UsernameEmail(String username, String email) { } @Data @Accessors(chain = true) static class Verification { private String code; private AtomicInteger attempts; } } ``` 当用户请求发送验证邮件时,会生成一个随机的验证码,并将其存储在缓存中,默认有效期为 10 分钟,当十分钟内用户未验证成功,验证码会自动过期被缓存清除。 用户可以在十分钟内重新请求发送验证邮件,此时会生成一个新的验证码有效期依然为 10 分钟。但会限制用户发送频率,同一个用户的邮箱发送验证邮件的时间间隔不得小于 1 分钟,以防止滥用。 当用户请求验证邮箱时,会从缓存中获取验证码,如果验证码不存在或已过期,会提示验证码无效或已过期,如果验证码存在且未过期,会进行验证码的比对,如果验证码不正确,会提示验证码无效,如果验证码正确,会将用户邮箱地址标记为已验证,并从缓存中清除验证码。 如果用户反复使用 code 验证邮箱,会记录失败次数,如果达到了默认的最大尝试次数(默认为 5 次),将被加入黑名单,需要 1 小时后才能重新验证邮件。 根据上述规则: - 每个验证码有10分钟的有效期。 - 在这10分钟内,如果失败次数超过5次,用户会被加入黑名单,禁止验证1小时。 - 如果在10分钟内尝试了5次且失败,然后请求重新发送验证码,可以再次尝试5次。 那么: - 在不触发黑名单的情况下,每10分钟可以尝试5次。 - 一小时内,可以尝试 (60/10) * 5 = 30 次,前提是每10分钟都请求一次新的验证码。 - 但是,如果在任何10分钟内尝试超过5次,则会被禁止1小时。 因此,为了最大化尝试次数而不触发黑名单,每小时可以尝试 30 次,预计一天内(24h)最多可以尝试 720 次验证码。 验证码的组成为随机的 6 为数字,可能组合总数:一个 6 位数字的验证码可以从 000000 到 999999,总共有 10 6 种可能的组合。 10 6 / 720 = 1388,因此,预计最坏情况下需要 1388 天可以破解验证码。这个时间足够长,可以认为非常安全的。 ### 提供 APIs 用于处理验证请求 - `POST /apis/v1alpha1/users/-/send-verification-email`:用于请求发送验证邮件来验证邮箱地址。 - `POST /apis/v1alpha1/users/-/verify-email`:用于根据邮箱验证码来验证邮箱地址。 以上两个 APIs 认证用户都可以访问,但会对请求进行限制,请求间隔不得小于 1 分钟,以防止滥用。 并且会在用户个人资料 API 中添加 emailVerified 字段,用于标识用户邮箱是否已验证。 ### 验证码邮件通知 只会通过用户请求验证的邮箱地址发送验证邮件,并且提供了以下变量用户自定义通知模板: - **username**: 请求验证邮件地址的用户名。 - **code**: 验证码。 - **expirationAtMinutes**: 验证码过期时间(分钟)。 验证邮件默认模板示例内容如下: ```markdown guqing 你好: 使用下面的动态验证码(OTP)验证您的电子邮件地址。 277436 动态验证码的有效期为 10 分钟。如果您没有尝试验证您的电子邮件地址,请忽略此电子邮件。 guqing's blog ``` ### 安全和异常处理 - 确保所有敏感数据安全传输,当验证码不正确或过期时,只应该提示一个通用的错误信息防止用户猜测或爆破验证码。 - 异常提示多语言支持。 ## 结论 通过实施上述方案,考虑到了以下情况: 1. 新邮箱验证请求 2. 用户邮箱地址更新 3. 用户请求重新发送验证邮件 4. 邮件发送失败 5. 验证码有效期 6. 发送频率限制 7. 验证状态的指示和反馈 我们将能够提供一个安全、可靠且用户友好的邮箱验证功能。 ================================================ FILE: docs/extension-points/authentication.md ================================================ # Halo 认证扩展点 此前,Halo 提供了 AdditionalWebFilter 作为扩展点供插件扩展认证相关的功能。但是近期我们明确了 AdditionalWebFilter 的使用用途,故不再作为认证的扩展点。 目前,Halo 提供了三种认证扩展点:表单登录认证、普通认证和匿名认证。 ## 表单登录(FormLogin) 示例如下: ```java import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; import run.halo.app.security.FormLoginSecurityWebFilter; @Component public class MyFormLoginSecurityWebFilter implements FormLoginSecurityWebFilter { @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { // Do your logic here return chain.filter(exchange); } } ``` ## 普通认证(Authentication) 示例如下: ```java import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; import run.halo.app.security.AuthenticationSecurityWebFilter; @Component public class MyAuthenticationSecurityWebFilter implements AuthenticationSecurityWebFilter { @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { // Do your logic here return chain.filter(exchange); } } ``` ## 匿名认证(Anonymous Authentication 示例如下: ```java import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; import run.halo.app.security.AnonymousAuthenticationSecurityWebFilter; @Component public class MyAnonymousAuthenticationSecurityWebFilter implements AnonymousAuthenticationSecurityWebFilter { @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { // Do your logic here return chain.filter(exchange); } } ``` ## 前置过滤器(BeforeSecurityWebFilter) 主要用于在进行认证之前的一些处理。需要注意的是,当前过滤器中无法直接通过 ReactiveSecurityContextHolder 获取 SecurityContext。示例如下: ```java public class MyBeforeSecurityWebFilter implements BeforeSecurityWebFilter { @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { // Do your logic here return chain.filter(exchange); } } ``` ## 后置过滤器(AfterSecurityWebFilter) 主要用于进行认证之后的一些处理。在当前过滤器中,可以通过 ReactiveSecurityContextHolder 获取 SecurityContext。示例如下: ```java public class MyAfterSecurityWebFilter implements AfterSecurityWebFilter { @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { return ReactiveSecurityContextHolder.getContext() .switchIfEmpty(Mono.defer(() -> { // do something... return chain.filter(exchange).then(Mono.empty()); })) .flatMap(securityContext -> { // do something... return chain.filter(exchange); }); } } ``` --- 我们在实现扩展点的时候需要注意:如果当前请求不满足认证条件,请一定要调用 `chain.filter(exchange)`,给其他 filter 留下机会。 后续会根据需求实现其他认证相关的扩展点。 ================================================ FILE: docs/extension-points/content.md ================================================ # 内容扩展点 ## 文章内容扩展点 文章内容扩展点用于在主题端文章内容渲染之前对文章内容进行修改,比如添加广告、添加版权声明、插入脚本等。 ## 使用方式 在插件中通过实现 `run.halo.app.theme.ReactivePostContentHandler` 接口来实现文章内容扩展。 以下是一个扩展文章内容支持 Katex 的示例: ```javascript String katexScript=""" """; ``` 然后在 `handle` 方法中将 Katex 的脚本字符串插入到内容前面: ```java @Component public class KatexPostContentHandler implements ReactivePostContentHandler { @Override public Mono handle(PostContentContext postContent) { postContent.setContent(katexScript + "\n" + postContent.getContent()); return Mono.just(postContent); } } ``` 定义了扩展点实现(扩展),还需要在插件的 `resources/extensions` 目录下添加对扩展的声明: ```yaml # resources/extensions/extension-definitions.yml apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionDefinition metadata: name: ext-def-katex-post-content spec: className: run.halo.katex.KatexPostContentHandler # 文章内容扩展点的名称,固定值 extensionPointName: reactive-post-content-handler displayName: "KatexPostContentHandler" description: "Katex support for post content." ``` ## 自定义页面内容扩展点 自定义页面(SinglePage)内容扩展点用于在主题端自定义页面内容渲染之前对内容进行修改,比如添加广告、添加版权声明、插入脚本等。 ## 使用方式 在插件中通过实现 `run.halo.app.theme.ReactiveSinglePageContentHandler` 接口来实现内容扩展。 以下是一个扩展内容支持 Katex 的示例: ```java @Component public class KatexSinglePageContentHandler implements ReactiveSinglePageContentHandler { @Override public Mono handle(SinglePageContentContext pageContent) { String katexScript = ""; // 参考文章内容扩展点的示例脚本块 pageContent.setContent(katexScript + "\n" + pageContent.getContent()); return Mono.just(pageContent); } } ``` 在插件的 `resources/extensions` 目录下添加对自定义页面内容扩展的声明: ```yaml # resources/extensions/extension-definitions.yml apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionDefinition metadata: name: ext-def-katex-singlepage-content spec: className: run.halo.katex.KatexSinglePageContentHandler # 自定义页面内容扩展点的名称,固定值 extensionPointName: reactive-post-content-handler displayName: "KatexSinglePageContentHandler" description: "Katex support for single page content." ``` ================================================ FILE: docs/extension-points/search-engine.md ================================================ # 搜索引擎扩展点 随着 Halo 的不断发展,搜索引擎模块也逐渐完善。搜索引擎模块是 Halo 的核心模块之一,它负责为 Halo 提供全文搜索功能。搜索引擎模块目前仅支持本地全文搜索引擎 [Lucene](https://lucene.apache.org/),其他搜索引擎的支持,如 Solr、MeiliSearch 或 ElasticSearch,需要通过插件来实现。 搜索引擎模块包含两个扩展点,分别是搜索引擎扩展和搜索文档扩展。搜索引擎扩展主要负责索引文档的添加、更新、删除和重建,搜索文档扩展则主要用于扩展文档类型,不仅限于文章类型。 从 Halo 2.17 开始,Halo 利用事件机制收集来自核心和插件中所发布的文档,其中也包含了文档类型用于区分。所以插件中可以通过发布事件的方式来控制文档的添加、更新、删除和重建,重建操作所需要的数据则由搜索文档扩展提供。 ## 搜索引擎扩展(`run.halo.app.search.SearchEngine`) 如果插件想要扩展搜索引擎,如 Solr、MeiliSearch 或者 ElasticSearch,可以通过实现 `SearchEngine` 接口来实现。 具体实现可参考 Halo 的 Lucene 搜索引擎实现:`run.halo.app.search.LuceneSearchEngine`。 ## 搜索文档扩展(`run.halo.app.search.HaloDocumentsProvider`) 如果插件想要扩展搜索文档类型,可以通过实现 `HaloDocumentsProvider` 接口来实现。具体实现可参考 Halo 的默认实现:`run.halo.app.search.post.PostHaloDocumentsProvider`。 - 添加文档示例如下所示 ```java class HaloDocumentAddExample { private final ApplicationEventPublisher eventPublisher; void addDocuments() { // concrete Halo documents List documents = ...; eventPublisher.publishEvent(new HaloDocumentAddRequestEvent(this, documents)); } } ``` - 删除文档示例如下所示 ```java class HaloDocumentDeleteExample { private final ApplicationEventPublisher eventPublisher; void deleteDocuments() { Set docIds = ...; eventPublisher.publishEvent(new HaloDocumentDeleteRequestEvent(this, docIds)); } } ``` - 重建索引示例如下所示: ```java class HaloDocumentRebuildExample { private final ApplicationEventPublisher eventPublisher; void rebuildDocument() { eventPublisher.publishEvent(new HaloDocumentRebuildRequestEvent(this)); } } ``` ================================================ FILE: docs/full-text-search/README.md ================================================ # 在 Halo 中实践全文搜索 主题端需全文搜索接口用于模糊搜索文章,且对效率要求极高。已经有对应的 Issue 提出,可参考:。 实现全文搜索的本地方案最好的就是 Apache 旗下开源的 [Lucene](https://lucene.apache.org/) ,不过 [Hibernate Search](https://hibernate.org/search/) 也基于 Lucene 实现了全文搜索。Halo 2.0 的自定义模型并不是直接在 Hibernate 上构建的,也就是说 Hibernate 在 Halo 2.0 只是一个可选项,故我们最终可能并不会采用 Hibernate Search,即使它有很多优势。 Halo 也可以学习 Hibernate 适配多种搜索引擎,如 Lucene、ElasticSearch、MeiliSearch 等。默认实现为 Lucene,对于用户来说,这种实现方式部署成本最低。 ## 搜索接口设计 ### 搜索参数 字段如下所示: - keyword: string. 关键字 - sort: string[]. 搜索字段和排序方式 - offset: number. 本次查询结果偏移数 - limit: number. 本次查询的结果最大条数 例如: ```bash http://localhost:8090/apis/api.halo.run/v1alpha1/posts?keyword=halo&sort=title.asc&sort=publishTimestamp,desc&offset=20&limit=10 ``` ### 搜索结果 ```yaml hits: - name: halo01 title: Halo 01 permalink: /posts/halo01 categories: - a - b tags: - c - d - name: halo02 title: Halo 02 permalink: /posts/halo02 categories: - a - b tags: - c - d query: "halo" total: 100 limit: 20 offset: 10 processingTimeMills: 2 ``` #### 搜索结果分页问题 目前,大多数搜索引擎为了性能问题,并没有直接提供分页功能,或者不推荐分页。 请参考: - - - - 综合以上讨论,我们暂定不支持分页。不过允许设置单次查询的记录数(limit <= max_limit)。 #### 中文搜索优化 Lucene 默认的分析器,对中文的分词不够友好,我们需要借助外部依赖或者外部整理好的词库帮助我们更好的对中文句子分词,以便优化中文搜索结果。 以下是关于中文分析器的 Java 库: - - - - - ### 搜索引擎样例 #### MeiliSearch ```bash curl 'http://localhost:7700/indexes/movies/search' \ -H 'Accept: */*' \ -H 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5' \ -H 'Authorization: Bearer MASTER_KEY' \ -H 'Connection: keep-alive' \ -H 'Content-Type: application/json' \ -H 'Cookie: logged_in=yes; adminer_permanent=; XSRF-TOKEN=75995791-980a-4f3e-81fb-2e199d8f3934' \ -H 'Origin: http://localhost:7700' \ -H 'Referer: http://localhost:7700/' \ -H 'Sec-Fetch-Dest: empty' \ -H 'Sec-Fetch-Mode: cors' \ -H 'Sec-Fetch-Site: same-origin' \ -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.26' \ -H 'X-Meilisearch-Client: Meilisearch mini-dashboard (v0.2.2) ; Meilisearch instant-meilisearch (v0.8.2) ; Meilisearch JavaScript (v0.27.0)' \ -H 'sec-ch-ua: "Microsoft Edge";v="107", "Chromium";v="107", "Not=A?Brand";v="24"' \ -H 'sec-ch-ua-mobile: ?0' \ -H 'sec-ch-ua-platform: "Windows"' \ --data-raw '{"q":"halo","attributesToHighlight":["*"],"highlightPreTag":"","highlightPostTag":"","limit":21}' \ --compressed ``` ```json { "hits": [ { "id": 108761, "title": "I Am... Yours: An Intimate Performance at Wynn Las Vegas", "overview": "Filmed at the Encore Theater at Wynn Las Vegas, this extraordinary concert features performances of over 30 songs from Beyoncé’s three multi-platinum solo releases, Destiny’s Child catalog and a few surprises. This amazing concert includes the #1 hits, “Single Ladies (Put A Ring On It),” “If I Were A Boy,” “Halo,” “Sweet Dreams” and showcases a gut-wrenching performance of “That’s Why You’re Beautiful.” Included on \"I AM... YOURS An Intimate Performance At Wynn Las Vegas,\" is a biographical storytelling woven between many songs and exclusive behind-the-scenes footage.", "genres": ["Music", "Documentary"], "poster": "https://image.tmdb.org/t/p/w500/j8n1XQNfw874Ka7SS3HQLCVNBxb.jpg", "release_date": 1258934400, "_formatted": { "id": "108761", "title": "I Am... Yours: An Intimate Performance at Wynn Las Vegas", "overview": "Filmed at the Encore Theater at Wynn Las Vegas, this extraordinary concert features performances of over 30 songs from Beyoncé’s three multi-platinum solo releases, Destiny’s Child catalog and a few surprises. This amazing concert includes the #1 hits, “Single Ladies (Put A Ring On It),” “If I Were A Boy,” “Halo,” “Sweet Dreams” and showcases a gut-wrenching performance of “That’s Why You’re Beautiful.” Included on \"I AM... YOURS An Intimate Performance At Wynn Las Vegas,\" is a biographical storytelling woven between many songs and exclusive behind-the-scenes footage.", "genres": ["Music", "Documentary"], "poster": "https://image.tmdb.org/t/p/w500/j8n1XQNfw874Ka7SS3HQLCVNBxb.jpg", "release_date": "1258934400" } } ], "estimatedTotalHits": 10, "query": "halo", "limit": 21, "offset": 0, "processingTimeMs": 2 } ``` ![MeiliSearch UI](./meilisearch.jpg) #### Algolia ```bash curl 'https://og53ly1oqh-dsn.algolia.net/1/indexes/*/queries?x-algolia-agent=Algolia%20for%20JavaScript%20(4.14.2)%3B%20Browser%20(lite)%3B%20docsearch%20(3.2.1)%3B%20docsearch-react%20(3.2.1)%3B%20docusaurus%20(2.1.0)&x-algolia-api-key=739f2a55c6d13d93af146c22a4885669&x-algolia-application-id=OG53LY1OQH' \ -H 'Accept: */*' \ -H 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5' \ -H 'Connection: keep-alive' \ -H 'Origin: https://docs.halo.run' \ -H 'Referer: https://docs.halo.run/' \ -H 'Sec-Fetch-Dest: empty' \ -H 'Sec-Fetch-Mode: cors' \ -H 'Sec-Fetch-Site: cross-site' \ -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.26' \ -H 'content-type: application/x-www-form-urlencoded' \ -H 'sec-ch-ua: "Microsoft Edge";v="107", "Chromium";v="107", "Not=A?Brand";v="24"' \ -H 'sec-ch-ua-mobile: ?0' \ -H 'sec-ch-ua-platform: "Windows"' \ --data-raw '{"requests":[{"query":"halo","indexName":"docs","params":"attributesToRetrieve=%5B%22hierarchy.lvl0%22%2C%22hierarchy.lvl1%22%2C%22hierarchy.lvl2%22%2C%22hierarchy.lvl3%22%2C%22hierarchy.lvl4%22%2C%22hierarchy.lvl5%22%2C%22hierarchy.lvl6%22%2C%22content%22%2C%22type%22%2C%22url%22%5D&attributesToSnippet=%5B%22hierarchy.lvl1%3A5%22%2C%22hierarchy.lvl2%3A5%22%2C%22hierarchy.lvl3%3A5%22%2C%22hierarchy.lvl4%3A5%22%2C%22hierarchy.lvl5%3A5%22%2C%22hierarchy.lvl6%3A5%22%2C%22content%3A5%22%5D&snippetEllipsisText=%E2%80%A6&highlightPreTag=%3Cmark%3E&highlightPostTag=%3C%2Fmark%3E&hitsPerPage=20&facetFilters=%5B%22language%3Azh-Hans%22%2C%5B%22docusaurus_tag%3Adefault%22%2C%22docusaurus_tag%3Adocs-default-1.6%22%5D%5D"}]}' \ --compressed ``` ```json { "results": [ { "hits": [ { "content": null, "hierarchy": { "lvl0": "Documentation", "lvl1": "使用 Docker Compose 部署 Halo", "lvl2": "更新容器组 ​", "lvl3": null, "lvl4": null, "lvl5": null, "lvl6": null }, "type": "lvl2", "url": "https://docs.halo.run/getting-started/install/other/docker-compose/#更新容器组", "objectID": "4ccfa93009143feb6e423274a4944496267beea8", "_snippetResult": { "hierarchy": { "lvl1": { "value": "… Docker Compose 部署 Halo", "matchLevel": "full" }, "lvl2": { "value": "更新容器组 ​", "matchLevel": "none" } } }, "_highlightResult": { "hierarchy": { "lvl0": { "value": "Documentation", "matchLevel": "none", "matchedWords": [] }, "lvl1": { "value": "使用 Docker Compose 部署 Halo", "matchLevel": "full", "fullyHighlighted": false, "matchedWords": ["halo"] }, "lvl2": { "value": "更新容器组 ​", "matchLevel": "none", "matchedWords": [] } }, "hierarchy_camel": [ { "lvl0": { "value": "Documentation", "matchLevel": "none", "matchedWords": [] }, "lvl1": { "value": "使用 Docker Compose 部署 Halo", "matchLevel": "full", "fullyHighlighted": false, "matchedWords": ["halo"] }, "lvl2": { "value": "更新容器组 ​", "matchLevel": "none", "matchedWords": [] } } ] } } ], "nbHits": 113, "page": 0, "nbPages": 6, "hitsPerPage": 20, "exhaustiveNbHits": true, "exhaustiveTypo": true, "exhaustive": { "nbHits": true, "typo": true }, "query": "halo", "params": "query=halo&attributesToRetrieve=%5B%22hierarchy.lvl0%22%2C%22hierarchy.lvl1%22%2C%22hierarchy.lvl2%22%2C%22hierarchy.lvl3%22%2C%22hierarchy.lvl4%22%2C%22hierarchy.lvl5%22%2C%22hierarchy.lvl6%22%2C%22content%22%2C%22type%22%2C%22url%22%5D&attributesToSnippet=%5B%22hierarchy.lvl1%3A5%22%2C%22hierarchy.lvl2%3A5%22%2C%22hierarchy.lvl3%3A5%22%2C%22hierarchy.lvl4%3A5%22%2C%22hierarchy.lvl5%3A5%22%2C%22hierarchy.lvl6%3A5%22%2C%22content%3A5%22%5D&snippetEllipsisText=%E2%80%A6&highlightPreTag=%3Cmark%3E&highlightPostTag=%3C%2Fmark%3E&hitsPerPage=20&facetFilters=%5B%22language%3Azh-Hans%22%2C%5B%22docusaurus_tag%3Adefault%22%2C%22docusaurus_tag%3Adocs-default-1.6%22%5D%5D", "index": "docs", "renderingContent": {}, "processingTimeMS": 1, "processingTimingsMS": { "total": 1 } } ] } ``` ![Algolia UI](./algolia.png) #### Wiki ```bash curl 'https://wiki.fit2cloud.com/rest/api/search?cql=siteSearch%20~%20%22halo%22%20AND%20type%20in%20(%22space%22%2C%22user%22%2C%22com.atlassian.confluence.extra.team-calendars%3Acalendar-content-type%22%2C%22attachment%22%2C%22page%22%2C%22com.atlassian.confluence.extra.team-calendars%3Aspace-calendars-view-content-type%22%2C%22blogpost%22)&start=20&limit=20&excerpt=highlight&expand=space.icon&includeArchivedSpaces=false&src=next.ui.search' \ -H 'authority: wiki.fit2cloud.com' \ -H 'accept: */*' \ -H 'accept-language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5' \ -H 'cache-control: no-cache, no-store, must-revalidate' \ -H 'cookie: _ga=GA1.2.1720479041.1657188862; seraph.confluence=89915546%3A6fc1394f8d537ffa08fb679e6e4dd64993448051; mywork.tab.tasks=false; JSESSIONID=5347D8618AC5883DE9B702E77152170D' \ -H 'expires: 0' \ -H 'pragma: no-cache' \ -H 'referer: https://wiki.fit2cloud.com/' \ -H 'sec-ch-ua: "Microsoft Edge";v="107", "Chromium";v="107", "Not=A?Brand";v="24"' \ -H 'sec-ch-ua-mobile: ?0' \ -H 'sec-ch-ua-platform: "Windows"' \ -H 'sec-fetch-dest: empty' \ -H 'sec-fetch-mode: cors' \ -H 'sec-fetch-site: same-origin' \ -H 'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.26' \ --compressed ``` ```json { "results": [ { "content": { "id": "76722", "type": "page", "status": "current", "title": "2.3 测试 - 接口", "restrictions": {}, "_links": { "webui": "/pages/viewpage.action?pageId=721", "tinyui": "/x/8K_SB", "self": "https://wiki.halo.run/rest/api/content/76720" }, "_expandable": { "container": "", "metadata": "", "extensions": "", "operations": "", "children": "", "history": "/rest/api/content/7670/history", "ancestors": "", "body": "", "version": "", "descendants": "", "space": "/rest/api/space/IT" } }, "title": "2.3 接口 - 接口", "excerpt": "另存为新用例", "url": "/pages/viewpage.action?pageId=7672", "resultGlobalContainer": { "title": "IT 客户", "displayUrl": "/display/IT" }, "entityType": "content", "iconCssClass": "aui-icon content-type-page", "lastModified": "2022-05-11T22:40:53.000+08:00", "friendlyLastModified": "五月 11, 2022", "timestamp": 1652280053000 } ], "start": 20, "limit": 20, "size": 20, "totalSize": 70, "cqlQuery": "siteSearch ~ \"halo\" AND type in (\"space\",\"user\",\"com.atlassian.confluence.extra.team-calendars:calendar-content-type\",\"attachment\",\"page\",\"com.atlassian.confluence.extra.team-calendars:space-calendars-view-content-type\",\"blogpost\")", "searchDuration": 36, "_links": { "base": "https://wiki.halo.run", "context": "" } } ``` ### FAQ #### 是否需要统一参数和响应体结构? 以下是关于统一参数和响应体结构的优缺点分析: 优点: - 主题端搜索结果 UI 更加一致,不会因为使用不同搜索引擎导致 UI 上的变动 缺点: - 无法完全发挥出对应的搜索引擎的实力。比如某个搜索引擎有很实用的功能,而某些搜索引擎没有。 - Halo Core 需要适配不同的搜索引擎,比较繁琐 #### 是否需要提供扩展点集成其他搜索引擎? 既然 Lucene 非常强大,且暂时已经能够满足我们的要求,我们为什么还需要集成其他搜索引擎呢? - Lucene 目前是作为 Halo 的依赖使用的,也就意味着只支持 Halo 单实例部署,阻碍未来 Halo 无状态化的趋势。 - 相反,其他搜索引擎(例如 Solr、MeiliSearch、ElasticSearch 等)都可以独立部署,Halo 只需要利用对应的 SDK 和搜索引擎沟通即可,无论 Halo 是否是多实例部署。 ================================================ FILE: docs/index/README.md ================================================ # 索引机制 RFC ## 背景 目前 Halo 使用 Extension 机制来存储和获取数据以便支持更好的扩展性,所以设计之初就存在查询数据时会将对应 Extension 的所有数据查询到内存中处理的问题,这会导致当分页查询和条件查询时也会有大批量无效数据被加载到内存中,随着 Halo 用户的数据量的增长,如果没有一个方案来解决这样的数据查询问题会对 Halo 用户的服务器内存资源有较高的要求,因此本篇提出使用索引机制来解决数据查询问题,以便提高查询效率和减少内存开销。 ## 目标 - **提高查询效率**:加快数据检索速度。通过使用索引,数据库可以快速定位到数据行的位置,从而减少必须读取的数据量。 - **减少网络和内存开销:** 没有索引前查询数据会将 Extension 的所有数据都传输到应用对网络和内存开销都很大,通过索引定位确切的数据来减少不必要的消耗。 - **优化排序操作**:通过索引加速排序操作,因此需要索引本身有序。 - **索引存储可扩展**:索引虽然能提高查询效率,但它会占用额外的存储空间,如果过大可以考虑在磁盘上读写等方式来减少对内存的占用。 ## 非目标 - 索引的持久化存储,前期只考虑在内存中存储索引,如果后续索引过大可以考虑在磁盘上读写等方式来减少对内存的占用。 - 索引的自动维护,索引的维护需要考虑到索引的数据是否改变,如果改变则需要更新索引,这个改变的判断不好界定,所以先不考虑索引的自动维护。 - 索引的前置验证,比如在启动时验证索引的完整性和正确性,但目前每次启动都会重新构建索引,所以先不考虑索引的前置验证。 - 多线程构建索引,目前索引的构建是单线程的,如果后续索引过大可以考虑多线程构建索引。 ## 方案 索引是一种存储数据结构,可提供对数据集中字段的高效查找。索引将 Extension 中的字段映射到 Extension 的名称,以便在查询特定字段时不需要完整的扫描。 ### 索引结构 每个 Extension 声明的索引都会被创建为一个 keySpace 与索引信息的映射, 类如对附件分组的一个对名称的索引示例如下: ```javascript { "/registry/storage.halo.run/groups": [{ name: "specName", spec: { // a function that returns the value of the index key indexFunc: function(doc) { return doc.spec.name; }, order: 'asc', unique: false }, v: 1, ready: false }, { name: "metadata.labels", spec: { indexFunc: function(doc) { var labels = obj.getMetadata().getLabels(); if (labels == null) { return Set.of(); } return labels.entrySet() .stream() .map(entry -> entry.getKey() + "=" + entry.getValue()) .collect(Collectors.toSet()); }, order: 'asc', unique: false }, v: 1, ready: true, }] } ``` - `name: specName` 表示索引的名称,每个 Extension 声明的索引名称不能重复,通常为字段路径如 `metadata.name`。 - `spec.indexFunc` 用于生成索引键,索引键是一个字符串数组,每个字符串都是一个索引键值,索引键值是一个字符串。 - `spec.order` 用于记录索引键的排序方式,可选值为 `asc` 或 `desc`,`asc` 表示升序,`desc` 表示降序。 - `spec.unique` 用于标识是否为唯一索引以在添加索引时进行唯一性检查。 - `v` 用于记录索引结构的版本以防止后续为优化导致索引结构改变时便于检查重建索引。 - `ready` 用于记录该索引是否构建完成,当开始构建该索引键索引记录时为 false,如果构建完成则修改为 true,如果因为断电等导致索引构建不完整则 ready 会是 false,下次启动时需要重新开始构建。 对于每个 Extension 都有一个默认的唯一索引 `metadata.name` 其 entries 与 Extension 每一条记录唯一对应。 ### 索引构建 索引是通过对 Extension 数据执行完整扫描来构建的。 1. **针对特定 Extension 数据集的操作**: 当构建索引时,操作是针对特定的 Extension 数据进行的。将 `ready` 置为 `false` 2. **扫描 Extension 数据集**: 构建索引的关键步骤是扫描 Extension 数据集中的每一条记录。这个扫描过程并不是基于数据库中所有数据的顺序,而是专注于该 Extension 数据集内的数据。当构建索引时会锁定对该 Extension 的写操作。 3. **生成索引键(KeyString键)**:对于 Extension 数据集中的每个 Extension,会根据其索引字段生成 KeyString 键。String 为 Extension 的 `metadata.name` 用于定位 Extension 在数据库中的位置。 4. **排序和插入操作**: 生成的键会被插入到一个外部排序器中,以确保它们的顺序。排序后,这些键按顺序批量加载到索引中。 5. 释放对该 Extension 写操作的锁定完成了索引构建。 对于后续 Extension 和索引的更新需要在同一个事务中以确保一致性。 ```json { "metadata.name": { "group-1": [] }, "specName": { "zhangsan": [ "metadata-name-1" ], "lisi": [ "metadata-name-2" ] }, "halo.run/hidden": { "true": [ "metadata-name-3" ], "false": [ "metadata-name-4" ] } } ``` ### 索引前置验证 1. 每次启动后先检查索引是否 ready 2. `metadata.name` 索引条目的数量始终与数据库中记录的 Extension 数量一致 3. 如果排序顺序为升序,则索引条目按递增顺序排列。 4. 如果排序顺序为降序,则索引条目按降序排列。 5. 每个索引的索引条目数量不超过数据库中记录的对应 Extension 数量。 ### 索引在 Extension 的声明 手动注册索引 ```java public class IndexSpec { private String name; private IndexAttribute indexFunc; private OrderType order; private boolean unique; public enum OrderType { ASC, DESC } // Getters and other methods... } IndexSpecs indexSpecs = indexSpecRegistry.indexFor(Person.class); indexSpecs.add(new IndexSpec() .setName("spec.name") .setOrder(IndexSpec.OrderType.DESC) .setIndexFunc(IndexAttributeFactory.simpleAttribute(Person.class, e -> e.getSpec().getName()) ) .setUnique(false)); ``` 用于普通索引的注解 ```java @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) // 用于类和注解的注解 public @interface Index { String name(); // 索引名称 String field(); // 需要索引的字段 } ``` Indexes 注解用于声明多个索引 ```java @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface Indexes { Index[] value() default {}; // Index注解数组 } ``` ```java @Data @Indexes({ @Index(name = "specName", field = "spec.name"), @Index(name = "creationTimestamp", field = "metadata.creationTimestamp"), }) @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) @GVK(group = "my-plugin.guqing.io", version = "v1alpha1", kind = "Person", plural = "persons", singular = "person") public class Person extends Extension { @Schema(description = "The description on name field", maxLength = 100) private String name; @Schema(description = "The description on age field", maximum = "150", minimum = "0") private Integer age; @Schema(description = "The description on gender field") private Gender gender; public enum Gender { MALE, FEMALE, } } ``` 不论是手动注册索引还是通过注解注册索引都由 IndexSpecRegistry 管理。 ```java public interface IndexSpecRegistry { /** *

Create a new {@link IndexSpecs} for the given {@link Extension} type.

*

The returned {@link IndexSpecs} is always includes some default {@link IndexSpec} that * does not need to be registered again:

*
    *
  • {@link Metadata#getName()} for unique primary index spec named metadata_name
  • *
  • {@link Metadata#getCreationTimestamp()} for creation_timestamp index spec
  • *
  • {@link Metadata#getDeletionTimestamp()} for deletion_timestamp index spec
  • *
  • {@link Metadata#getLabels()} for labels index spec
  • *
* * @param extensionType must not be {@literal null}. * @param the extension type * @return the {@link IndexSpecs} for the given {@link Extension} type. */ IndexSpecs indexFor(Class extensionType); /** * Get {@link IndexSpecs} for the given {@link Extension} type registered before. * * @param extensionType must not be {@literal null}. * @param the extension type * @return the {@link IndexSpecs} for the given {@link Extension} type. * @throws IllegalArgumentException if no {@link IndexSpecs} found for the given * {@link Extension} type. */ IndexSpecs getIndexSpecs(Class extensionType); boolean contains(Class extensionType); void removeIndexSpecs(Class extensionType); /** * Get key space for an extension type. * * @param scheme is a scheme of an Extension. * @return key space(never null) */ @NonNull String getKeySpace(Scheme scheme); } ``` 对于添加了索引的 Extension 可以使用 `IndexedQueryEngine` 来查询数据: ```java public interface IndexedQueryEngine { /** * Page retrieve the object records by the given {@link GroupVersionKind} and * {@link ListOptions}. * * @param type the type of the object must exist in * {@link run.halo.app.extension.SchemeManager}. * @param options the list options to use for retrieving the object records. * @param page which page to retrieve and how large the page should be. * @return a collection of {@link Metadata#getName()} for the given page. */ ListResult retrieve(GroupVersionKind type, ListOptions options, PageRequest page); /** * Retrieve all the object records by the given {@link GroupVersionKind} and * {@link ListOptions}. * * @param type the type of the object must exist in {@link run.halo.app.extension.SchemeManager} * @param options the list options to use for retrieving the object records * @return a collection of {@link Metadata#getName()} */ List retrieveAll(GroupVersionKind type, ListOptions options); } ``` 但为了简便起见,会在 ReactiveExtensionClient 中提供一个 `listBy` 方法来查询数据: ```java public interface ReactiveExtensionClient { //... Mono> listBy(Class type, ListOptions options, PageRequest pageable); } ``` 其中 `ListOptions` 包含两部分,`LabelSelector` 和 `FieldSelector`,一个常见的手动构建的 `ListOptions` 示例: ```java var listOptions = new ListOptions(); listOptions.setLabelSelector(LabelSelector.builder() .eq("key1", "value1").build()); listOptions.setFieldSelector(FieldSelector.builder() .eq("slug", "slug1").build()); ``` 为了兼容以前的写法,对于 APIs 中可以继续使用 `run.halo.app.extension.router.IListRequest`,然后使用工具类转换即可得到 `ListOptions` 和 `PageRequest`。 ```java class QueryListRequest implements IListRequest { public ListOptions toListOptions() { return labelAndFieldSelectorToListOptions(getLabelSelector(), getFieldSelector()); } public PageRequest toPageRequest() { return PageRequestImpl.of(getPage(), getSize(), getSort()); } } ``` ### Reconciler 对于 Reconciler 来说,之前每次由 DefaultController 启动对于需要 `syncAllOnStart` 的 Reconciler 都是获取所有对应的 Extension 数据,然后再进行 Reconcile,这样会导致每次都将所有的 Extension 数据加载到内存中,随着数据量的增加导致内存占用过大,当有了索引后只获取所有 Extension 的 `metadata.name` 来触发 reconcile 即可。 GcReconciler 也从索引中获取 `metadata.deletionTimestamp` 不为空的 Extension 的 `metadata.name` 来触发 reconcile 以减少全量加载数据的操作。 ================================================ FILE: docs/notification/README.md ================================================ ## 背景 在 Halo 系统中,具有用户协作属性,如当用户发布文章后被访客评论而访客希望在作者回复评论时被提醒以此完成进一步互动,而在没有通知功能的情况下无法满足诸如以下描述的使用场景: 1. 访客只能在评论后一段时间内访问被评论的文章查看是否被回复。 2. Halo 的用户注册功能无法让用户验证邮箱地址让恶意注册变的更容易。 在这些场景下,为了让用户收到通知或验证消息以及管理和处理这些通知,我们需要设计一个通知功能,以实现根据用户的订阅和偏好推送通知并管理通知。 ## 已有需求 - 访客评论文章后希望收到被回复的通知,而文章作者也希望收到文章被评论的通知。 - 用户注册功能希望验证注册者填写的邮箱实现一个邮箱只能注册一个账号,防止占用别人邮箱,在一定程度上减少恶意注册问题。 - 关于应用市场插件,管理员希望在用户下单后能收到新订单通知。 - 付费订阅插件场景,希望给付费订阅用户推送付费文章的浏览链接。 ## 目标 设计一个通知功能,可以根据以下目标,实现订阅和推送通知: - 支持扩展多种通知方式,例如邮件、短信、Slack 等。 - 支持通知条件并可扩展,例如 Halo 有新文章发布事件如果用户订阅了新文章发布事件但付费订阅插件决定了此文章只有付费用户才可收到通知、按照付费等级不同决定是否发送新文章通知给对应用户等需要通过实现通知条件的扩展点来满足对应需求。 - 支持定制化选项,例如是否开启通知、通知时段等。 - 支持通知流程,例如通知的发送、接收、查看、标记等。 - 通知内容支持多语言。 - 事件类型可扩展,插件可能需要定义自己的事件以通知到订阅事件的用户,如应用市场插件。 ## 非目标 - Halo 只会实现站内消息和邮件通知,更多通知方式需要插件去扩展。 - 定时通知、通知频率或摘要通知功能属于非必要功能,可由插件去扩展。 - 多语言支持,目前只会支持中文和英文两种,更多语言支持不是此阶段的目标。 - 可定制的通知模板:通知默认模板由事件定义者提供,如需修改可考虑使用特定的 Notifier 去适配事件。 ## 方案 为了实现上述目标,我们设计了以下方案: ### 通知数据模型 #### 通知事件类别和事件 首先通过定义事件来声明此通知事件包含的数据和发送此事件时默认使用的模板。 `ReasonType` 是一个自定义模型,用于定义事件类别,一个事件类别由多个事件表示。 ```yaml apiVersion: notification.halo.run/v1alpha1 kind: ReasonType metadata: name: comment spec: displayName: "Comment Received" description: "The user has received a comment on an post." properties: - name: postName type: string description: "The name of the post." optional: false - name: postTitle type: string optional: true - name: commenter type: string description: "The email address of the user who has left the comment." optional: false - name: comment type: string description: "The content of the comment." optional: false ``` `Reason` 是一个自定义模型,用于定义通知原因,它属于 `ReasonType` 的实例。 当有事件触发时,创建 `Reason` 资源来触发通知,如当文章收到一个新评论时: ```yaml apiVersion: notification.halo.run/v1alpha1 kind: Reason metadata: name: comment-axgu spec: # a name of ReasonType reasonType: comment author: 'guqing' subject: apiVersion: 'content.halo.run/v1alpha1' kind: Post name: 'post-axgu' title: 'Hello World' url: 'https://guqing.xyz/archives/1' attributes: postName: "post-fadp" commenter: "guqing" comment: "Hello! This is your first notification." ``` #### Subscription `Subscription` 自定义模型,定义了特定事件时与要被通知的订阅者之间的关系, 其中 `subscriber` 表示订阅者用户, `unsubscribeToken` 表示退订时的身份验证 token, `reason` 订阅者感兴趣的事件。 用户可以通过 `Subscription` 来订阅自己感兴趣的事件,当事件触发时会收到通知: ```yaml apiVersion: notification.halo.run/v1alpha1 kind: Subscription metadata: name: user-a-sub spec: subscriber: name: guqing unsubscribeToken: xxxxxxxxxxxx reason: reasonType: new-comment-on-post subject: apiVersion: content.halo.run/v1alpha1 kind: Post name: 'post-axgu' # expression: 'props.owner == "guqing"' ``` - `spec.reason.subject`:用于根据事件的主体的匹配感兴趣的事件,如果不指定 name 则表示匹配主体与 kind 和 apiVersion 相同的一类事件。 - `spec.expression`:根据表达式匹配感兴趣的事件,例如 `props.owner == "guqing"` 表示只有当事件的属性(reason attributes)的 owner 等于 guqing 时才会触发通知。表达式符合 SpEL 表达式语法,但结果只能是布尔值。参考:[增强 Subscription 模型以支持表达式匹配](https://github.com/halo-dev/halo/issues/5632) > 当 `spec.expression` 和 `spec.reason.subject` 同时存在时,以 `spec.reason.subject` 的结果为准,不建议同时使用。 订阅退订链接 API 规则:`/apis/api.notification.halo.run/v1alpha1/subscriptions/{name}/unsubscribe?token={unsubscribeToken}`。 #### 用户通知偏好设置 通过在用户偏好设置的 ConfigMap 中存储一个 `notification` key 用于保存事件类型与通知方式的关系设置,当用户订阅了如 ' new-comment-on-post' 事件时会获取对应的通知方式来给用户发送通知。 ```yaml apiVersion: v1alpha1 kind: ConfigMap metadata: name: user-preferences-guqing data: notification: | { reasonTypeNotification: { 'new-comment-on-post': { enabled: true, notifiers: [ email-notifier, sms-notifier ] }, new-post: { enabled: true, notifiers: [ email-notifier, webhook-router-notifier ] } }, } ``` #### Notification 站内通知 当用户订阅到事件后会创建 `Notification`, 它与通知方式(notifier)无关,`recipient` 为用户名,类似站内通知,如用户 `guqing` 订阅了评论事件那么当监听到评论事件时会创建一条记录可以在个人中心的通知列表看到一条通知消息。 ```yaml apiVersion: notification.halo.run/v1alpha1 kind: Notification metadata: name: notification-abc spec: # username recipient: "guqing" reason: 'comment-axgu' title: 'notification-title' rawContent: 'notification-raw-body' htmlContent: 'notification-html' unread: true lastReadAt: '2023-08-04T17:01:45Z' ``` 个人中心通知自定义 APIs: 1. 获取个人中心获取用户通知列表的 APIs 规则: `GET /apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications` 2. 将通知标记为已读:`PUT /apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/mark-as-read` 3. 批量将通知标记为已读:`PUT /apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/mark-specified-as-read` #### 通知模板 `NotificationTemplate` 自定义模型用于定义事件的通知模板,当事件触发时会根据事件的通知模板来渲染通知内容。 它通过定义 `reasonSelector` 来引用事件类别,当事件触发时会根据用户的语言偏好和触发事件的类别来选择一个最佳的通知模板。 选择通知模板的规则为: 1. 根据用户设置的语言,选择从通知模板中定义的 `spec.reasonSelector.language` 的值从更具体到不太具体的顺序(例如,gl_ES 的值将比 gl 的值具有更高的优先级)。 2. 当通过语言成功匹配到模板时,匹配到的结果可能不止一个,如 `language` 为 `zh_CN` 的模板有三个那么会根据 `NotificationTemplate` 的 `metadata.creationTimestamp` 字段来选择一个最新的模板。 这样的规则有助于用户可以个性化定制某些事件的模板内容。 模板语法使用 ThymeleafEngine 渲染,纯文本模板使用 `textual` 模板模式,语法参考: [usingthymeleaf.html#textual-syntax](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#textual-syntax) `HTML` 则使用标准表达式语法在标签属性中取值,语法参考:[standard-expression-syntax](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#standard-expression-syntax) 在通知中心渲染模板时会在 `ReasonAttributes` 中提供额外属性包括: - site.title: 站点标题 - site.subtitle: 站点副标题 - site.logo: 站点 LOGO - site.url: 站点访问地址 - subscriber.id: 如果是用户则为用户名, 如果是匿名用户则为 `annoymousUser#email` - subscriber.displayName: 邮箱地址或`@username` - unsubscribeUrl: 退订链接,用于取消订阅 因此,任何模板都可以使用这几个属性,但事件定义者需要注意避免使用这些保留属性。 ```yaml apiVersion: notification.halo.run/v1alpha1 kind: NotificationTemplate metadata: name: template-new-comment-on-post spec: reasonSelector: reasonType: new-comment-on-post language: zh_CN template: title: "你的文章 [(${postTitle})] 收到了一条新评论" body: | [(${commenter})] 评论了你的文章 [(${postTitle})],内容如下: [(${comment})] ``` #### 通知器声明及扩展 `NotifierDescriptor` 自定义模型用于声明通知器,通过它来描述通知器的名称、描述和关联的 `ExtensionDefinition` 名称,让用户可以在用户界面知道通知器是什么以及它可以做什么, 还让 NotificationCenter 知道如何加载通知器和准备通知器需要的设置以发送通知。 ```yaml apiVersion: notification.halo.run/v1alpha1 kind: NotifierDescriptor metadata: name: email-notifier spec: displayName: '邮件通知器' description: '支持通过邮件的方式发送通知。' notifierExtName: '通知对应的扩展名称' senderSettingRef: name: 'email-notifier' group: 'sender' receiverSettingRef: name: 'email-notifier' group: 'receiver' ``` 通知器声明了 senderSettingRef 和 receiverSettingRef 后,对应用户端可以通过以下 APIs 获取和保存配置: 管理员获取和保存通知器发送配置的 APIs: 1. 获取通知器发送方配置:`GET /apis/api.console.halo.run/v1alpha1/notifiers/{name}/sender-config` 2. 保存通知器发送方配置:`POST /apis/api.console.halo.run/v1alpha1/notifiers/{name}/sender-config` 个人中心用户获取和保存对应通知器接收消息配置的 APIs: 1. 获取通知器接收消息配置:`GET /apis/api.notification.halo.run/v1alpha1/notifiers/{name}/receiver-config` 2. 获取通知器接收消息配置:`POST /apis/api.notification.halo.run/v1alpha1/notifiers/{name}/receiver-config` 通知器扩展点用于实现发送通知的方式: ```java public interface ReactiveNotifier extends ExtensionPoint { /** * Notify user. * * @param context notification context must not be null */ Mono notify(NotificationContext context); } @Data public class NotificationContext { private Message message; private ObjectNode receiverConfig; private ObjectNode senderConfig; @Data static class Message { private MessagePayload payload; private Subject subject; private String recipient; private Instant timestamp; } @Data public static class Subject { private String apiVersion; private String kind; private String name; private String title; private String url; } @Data static class MessagePayload { private String title; private String rawBody; private String htmlBody; private ReasonAttributes attributes; } } ``` 通知数据结构交互图 ![Notification datastructures interaction](./image-knhw.png) 通知功能 UI 设计 ![notification-ui.png](notification-ui.png) ### 通知模块功能 - 发送通知:当触发通知事件时,系统会根据 subscriber 的偏好设置获取到事件对应的通知方式再根据偏好设置自动发送通知。 - 接收通知:用户可以选择接收通知的方式,例如邮件、短信、自定义路由通知等。 - 查看通知:用户可以在 Halo 中查看所有的通知,包括已读和未读的通知。 - 标记通知:用户可以标记通知为已读或未读状态,以便更好地管理和处理通知。 ### 通知管理列表条件筛选 我们支持以下通知条件筛选策略: - 按事件类型:列出特定类型的事件通知,例如新文章,新评论、状态更新等。 - 按已读状态:根据通知是否已读列出,方便用户查看未读通知。 - 按关键词:列出通知中包含特定关键词的事件通知,例如包含用户名称、标题等关键词的通知。 - 按时间:列出在特定时间段内发生的事件通知,例如最近一周、最近一个月等时间段内的通知。 ### 定制化选项 如果后续有足够的使用场景,可以考虑支持以下定制化选项: - 通知时间段:用户可以设置通知的时间段,例如只在工作时间内推送通知。 - 通知频率:用户可以设置通知的频率,例如每天、每周、每月等。 - 摘要通知:用户可以设置接收每周摘要,总结一周内的通知合并为一条通知并通过如邮件等方式接收。 ## 结论 通过以上方案和实现,我们设计了一个通知功能,可以根据用户的需求和偏好,自动筛选和推送通知。同时,为了支持更多的事件类型、通知方式和通知条件筛选策略,系统具有良好的可扩展性。 ================================================ FILE: docs/plugin/shared-event.md ================================================ # 插件中如何发送共享事件(SharedEvent) 在插件中,可以通过共享事件(SharedEvent)来发送消息。 共享事件是一种特殊的事件,它可以被核心和所有插件订阅。 ## 订阅共享事件 目前,核心中已经提供了不少的共享事件,例如 `run.halo.app.event.post.PostPublishedEvent`、`run.halo.app.event.post.PostUpdatedEvent` ,这些事件由核心发布,核心和插件均可订阅。请看下面的示例: ```java @Component public class PostPublishedEventListener implements ApplicationListener { @Override public void onApplicationEvent(PostPublishedEvent event) { // Do something } } ``` 或者通过 `@EventListener` 注解实现, ```java @Component public class PostPublishedEventListener { @EventListener // @Async // 如果需要异步处理,可以添加此注解 public void onPostPublished(PostPublishedEvent event) { // Do something } } ``` > 需要注意的是,只有被 `@SharedEvent` 注解标记的事件才能够被其他插件或者核心订阅。 ## 发送共享事件 在插件中,我们可以通过 `ApplicationEventPublisher` 来发送共享事件,请看下面的示例: ```java @Service public class PostService { private final ApplicationEventPublisher eventPublisher; public PostService(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } public void publishPost(Post post) { // Do something eventPublisher.publishEvent(new PostPublishedEvent(post)); } } ``` ## 创建共享事件 在插件中,我们可以创建自定义的共享事件,供其他插件订阅,示例如下: ```java @SharedEvent public class MySharedEvent extends ApplicationEvent { public MySharedEvent(Object source) { super(source); } } ``` > 需要注意的是: > 1. 共享事件必须继承 `ApplicationEvent`。 > 2. 共享事件必须被 `@SharedEvent` 注解标记。 > 3. 如果想要被其他插件订阅,则需要将该事件类发布到 Maven 仓库中,供其他插件引用。 ================================================ FILE: docs/plugin/websocket.md ================================================ # 插件中如何实现 WebSocket ## 背景 > https://github.com/halo-dev/halo/issues/5285 越来越多的开发者在开发插件过程中需要及时高效获取某些资源的最新状态,但是因为在插件中不支持 WebSocket,故只能选择定时轮训的方式来解决。 在插件中支持 WebSocket 的功能需要 Halo Core 来适配并制定规则以方便插件实现 WebSocket。 ## 实现 插件中实现 WebSocket 的代码样例如下所示: ```java @Component public class MyWebSocketEndpoint implements WebSocketEndpoint { @Override public GroupVersion groupVersion() { return GroupVersion.parseApiVersion("my-plugin.halowrite.com/v1alpha1"); } @Override public String urlPath() { return "/resources"; } @Override public WebSocketHandler handler() { return session -> { var messages = session.receive() .map(message -> { var payload = message.getPayloadAsText(); return session.textMessage(payload.toUpperCase()); }); return session.send(messages); }; } } ``` 插件安装成功后,可以通过 `/apis/my-plugin.halowrite.com/v1alpha1/resources` 进行访问。 示例如下所示: ```bash websocat --basic-auth admin:admin ws://127.0.0.1:8090/apis/my-plugin.halowrite.com/v1alpha1/resources ``` 同样地,WebSocket 相关的 API 仍然受当前权限系统管理。 ================================================ FILE: e2e/Dockerfile ================================================ FROM ghcr.io/linuxsuren/api-testing:v0.0.17 WORKDIR /workspace COPY testsuite.yaml . CMD [ "atest", "run", "-p", "testsuite.yaml", "--level=trace", "--request-ignore-error", "--report=md" ] ================================================ FILE: e2e/Makefile ================================================ all: ./start.sh ./start.sh compose-postgres.yaml ./start.sh compose-mysql.yaml demo: docker-compose up halo ================================================ FILE: e2e/README.md ================================================ Please add the corresponding e2e (aka end-to-end) test cases if you add or update APIs. ## How to work * Start and watch the [docker-compose](https://docs.docker.com/compose/) via [the script](start.sh) * It has three containers: database, Halo, and testing * Run the e2e testing via [api-testing](https://github.com/LinuxSuRen/api-testing) * It will run the test cases from top to bottom * You can add the necessary asserts to it ## Run locally Please follow these steps if you want to run the e2e testing locally. > Please make sure you have installed docker-compose v2 * Build project via `./gradlew clean build -x check` in root directory of this repository * Build image via `docker build . -t ghcr.io/halo-dev/halo-dev:main` * Change the directory to `e2e`, then execute `./start.sh` ## Run Halo only Please run the following command if you only want to run Halo. ```shell docker-compose up halo ``` ================================================ FILE: e2e/compose-mysql.yaml ================================================ version: '3.1' services: testing: build: context: . dockerfile: Dockerfile links: - halo depends_on: halo: condition: service_healthy halo: image: ghcr.io/halo-dev/halo-dev:${TAG:-main} healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8090/actuator/health/readiness"] interval: 30s timeout: 5s retries: 5 start_period: 30s command: - --spring.r2dbc.url=r2dbc:pool:mysql://mysql:3306/halo - --spring.r2dbc.username=root - --spring.r2dbc.password=halo - --spring.sql.init.platform=mysql links: - mysql depends_on: mysql: condition: service_healthy mysql: image: mysql:8.1.0 container_name: mysql restart: on-failure:3 command: - --default-authentication-plugin=caching_sha2_password - --character-set-server=utf8mb4 - --collation-server=utf8mb4_general_ci - --explicit_defaults_for_timestamp=true healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"] interval: 10s timeout: 5s retries: 5 environment: - MYSQL_ROOT_PASSWORD=halo - MYSQL_DATABASE=halo ================================================ FILE: e2e/compose-postgres.yaml ================================================ version: '3.1' services: testing: build: context: . dockerfile: Dockerfile links: - halo depends_on: halo: condition: service_healthy halo: image: ghcr.io/halo-dev/halo-dev:${TAG:-main} healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8090/actuator/health/readiness"] interval: 30s timeout: 5s retries: 5 start_period: 30s command: - --spring.r2dbc.url=r2dbc:pool:postgresql://postgres/halo - --spring.r2dbc.username=halo # PostgreSQL 的密码,请保证与下方 POSTGRES_PASSWORD 的变量值一致。 - --spring.r2dbc.password=openpostgresql - --spring.sql.init.platform=postgresql # 外部访问地址,请根据实际需要修改 # - --halo.external-url=http://localhost:8090/ ports: - 8090:8090 links: - postgres depends_on: postgres: condition: service_healthy postgres: image: postgres:15.4 container_name: postgres restart: on-failure:3 healthcheck: test: [ "CMD", "pg_isready" ] interval: 10s timeout: 5s retries: 5 environment: - POSTGRES_PASSWORD=openpostgresql - POSTGRES_USER=halo - POSTGRES_DB=halo - PGUSER=halo ================================================ FILE: e2e/compose.yaml ================================================ version: '3.1' services: testing: build: context: . dockerfile: Dockerfile links: - halo depends_on: halo: condition: service_healthy halo: image: ghcr.io/halo-dev/halo-dev:${TAG:-main} healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8090/actuator/health/readiness"] interval: 30s timeout: 5s retries: 5 start_period: 30s ports: - 8090:8090 ================================================ FILE: e2e/start.sh ================================================ #!/bin/bash file=$1 if [ "$file" == "" ] then file=compose.yaml fi docker-compose -f "$file" down docker-compose -f "$file" up --build testing --exit-code-from testing --remove-orphans ================================================ FILE: e2e/testsuite.yaml ================================================ name: halo api: | {{default "http://halo:8090" (env "SERVER")}}/apis param: postName: "{{randAlpha 6}}" userName: "{{randAlpha 6}}" roleName: "{{randAlpha 6}}" notificationName: "{{randAlpha 6}}" auth: "Basic YWRtaW46MTIzNDU2" items: - name: setup request: api: | {{default "http://halo:8090" (env "SERVER")}}/system/setup method: POST header: Content-Type: application/x-www-form-urlencoded Accept: application/json body: | siteTitle=testing&username={{.param.userName}}&password=123456&email=testing@halo.run expect: statusCode: 204 - name: createPost request: api: /api.console.halo.run/v1alpha1/posts method: POST header: Authorization: "{{.param.auth}}" Content-Type: application/json body: | { "post": { "spec": { "title": "{{.param.postName}}", "slug": "{{.param.postName}}", "template": "", "cover": "", "deleted": false, "publish": false, "pinned": false, "allowComment": true, "visible": "PUBLIC", "priority": 0, "excerpt": { "autoGenerate": true, "raw": "" }, "categories": [], "tags": [], "htmlMetas": [] }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Post", "metadata": { "name": "c31f2192-c992-47b9-86b4-f3fc0605360e", "annotations": { "content.halo.run/preferred-editor": "default" } } }, "content": { "raw": "

{{.param.postName}}

", "content": "

{{.param.postName}}

", "rawType": "HTML" } } - name: listPosts request: api: /api.console.halo.run/v1alpha1/posts?keyword={{.param.postName}} expect: verify: - data.total == 1 - name: recyclePost request: api: /api.console.halo.run/v1alpha1/posts/{{(index .listPosts.items 0).post.metadata.name}}/recycle method: PUT - name: recover request: api: /content.halo.run/v1alpha1/posts/{{(index .listPosts.items 0).post.metadata.name}} method: DELETE ## Users - name: createUser request: api: /api.console.halo.run/v1alpha1/users method: POST header: Content-Type: application/json body: | { "avatar": "", "bio": "{{randAlpha 6}}", "displayName": "{{randAlpha 6}}", "email": "test@halo.com", "name": "{{.param.userName}}", "password": "{{randAlpha 6}}", "phone": "", "roles": [] } - name: updateUserPass request: api: /api.console.halo.run/v1alpha1/users/{{.param.userName}}/password method: PUT header: Content-Type: application/json body: | { "password": "{{randAlpha 3}}" } - name: grantPermission request: api: /api.console.halo.run/v1alpha1/users/{{.param.userName}}/permissions method: POST header: Content-Type: application/json body: | { "roles": [ "guest" ] } - name: sendPasswordResetEmail request: api: | /api.halo.run/v1alpha1/users/-/send-password-reset-email method: POST header: Content-Type: application/json body: | { "username": "{{.param.userName}}", "email": "{{.param.email}}" } expect: statusCode: 204 - name: resetPasswordByToken request: api: | /api.halo.run/v1alpha1/users/{{.param.userName}}/reset-password method: PUT header: Content-Type: application/json body: | { "newPassword": "{{randAlpha 6}}", "token": "{{randAlpha 6}}" } expect: statusCode: 403 ## Roles - name: createRole request: api: | {{default "http://halo:8090" (env "SERVER")}}/api/v1alpha1/roles method: POST header: Content-Type: application/json body: | { "apiVersion": "v1alpha1", "kind": "Role", "metadata": { "name": "", "generateName": "role-", "labels": {}, "annotations": { "rbac.authorization.halo.run/dependencies": "[\"role-template-manage-appstore\"]", "rbac.authorization.halo.run/display-name": "{{.param.roleName}}" } }, "rules": [] } expect: statusCode: 201 - name: listRoles request: api: | {{default "http://halo:8090" (env "SERVER")}}/api/v1alpha1/roles expect: verify: - data.total >= 3 - name: deleteRole request: api: | {{default "http://halo:8090" (env "SERVER")}}/api/v1alpha1/roles/{{(index .listRoles.items 0).metadata.name}} method: DELETE ## Plugins - name: installPlugin request: api: /api.console.halo.run/v1alpha1/plugins/-/install-from-uri method: POST header: Content-Type: application/json body: | { "uri": "https://github.com/Stonewuu/halo-plugin-sitepush/releases/download/1.3.1/halo-plugin-sitepush-1.3.1.jar" } - name: pluginList request: api: /api.console.halo.run/v1alpha1/plugins expect: verify: - data.total >= 1 - name: inActivePlugins request: api: /api.console.halo.run/v1alpha1/plugins?enabled=false&keyword=&page=0&size=0 expect: verify: - data.total == 1 - name: disablePlugin request: api: /api.console.halo.run/v1alpha1/plugins/PluginSitePush/plugin-state method: PUT header: Content-Type: application/json body: | { "enable": false } - name: enablePlugin request: api: /api.console.halo.run/v1alpha1/plugins/PluginSitePush/plugin-state method: PUT header: Content-Type: application/json body: | { "enable": true } - name: resetPlugin request: api: /api.console.halo.run/v1alpha1/plugins/PluginSitePush/reset-config method: PUT header: Content-Type: application/json - name: uninstallPlugin request: api: /plugin.halo.run/v1alpha1/plugins/PluginSitePush method: DELETE # Notifications - name: createNotification request: api: /notification.halo.run/v1alpha1/notifications method: POST body: | { "spec": { "recipient": "admin", "reason": "fake-reason", "title": "test 评论了你的页面《关于我》", "rawContent": "Fake raw content", "htmlContent": "

Fake html content

", "unread": true }, "apiVersion": "notification.halo.run/v1alpha1", "kind": "Notification", "metadata": { "name": "{{.param.notificationName}}" } } header: Content-Type: application/json expect: statusCode: 201 - name: getNotificationByName request: api: /notification.halo.run/v1alpha1/notifications/{{.param.notificationName}} method: GET expect: statusCode: 200 verify: - data.spec.reason == "fake-reason" - data.spec.title == "test 评论了你的页面《关于我》" - name: deleteUserNotification request: api: | /api.notification.halo.run/v1alpha1/userspaces/admin/notifications/{{.param.notificationName}} method: DELETE - name: deleteUser request: api: | {{default "http://halo:8090" (env "SERVER")}}/api/v1alpha1/users/{{.param.userName}} method: DELETE ================================================ FILE: gradle/libs.versions.toml ================================================ [versions] lucene = '10.3.2' resilience4j = '2.3.0' therapi = '0.15.0' checkstyle = "12.1.0" [libraries] lucene-core = { module = 'org.apache.lucene:lucene-core', version.ref = 'lucene' } lucene-queryparser = { module = 'org.apache.lucene:lucene-queryparser', version.ref = 'lucene' } lucene-highlighter = { module = 'org.apache.lucene:lucene-highlighter', version.ref = 'lucene' } lucene-backward-codecs = { module = 'org.apache.lucene:lucene-backward-codecs', version.ref = 'lucene' } lucene-analyzers-common = { module = 'org.apache.lucene:lucene-analysis-common', version.ref = 'lucene' } therapi-runtime-javadoc = { module = 'com.github.therapi:therapi-runtime-javadoc', version.ref = 'therapi' } therapi-runtime-javadoc-scribe = { module = 'com.github.therapi:therapi-runtime-javadoc-scribe', version.ref = 'therapi' } resilience4j-springboot3 = { module = 'io.github.resilience4j:resilience4j-spring-boot3', version.ref = 'resilience4j' } resilience4j-reactor = { module = 'io.github.resilience4j:resilience4j-reactor', version.ref = 'resilience4j' } apache-commons-lang3 = 'org.apache.commons:commons-lang3:3.20.0' apache-tika-core = 'org.apache.tika:tika-core:3.2.3' encoding-base62 = 'io.seruco.encoding:base62:0.1.3' pf4j = 'org.pf4j:pf4j:3.15.0' guava = 'com.google.guava:guava:33.5.0-jre' java-diff-utils = 'io.github.java-diff-utils:java-diff-utils:4.16' jsoup = 'org.jsoup:jsoup:1.22.1' json-patch = 'com.github.java-json-tools:json-patch:1.13' springdoc-openapi = 'org.springdoc:springdoc-openapi-starter-webflux-ui:3.0.1' openapi-schema-validator = 'org.openapi4j:openapi-schema-validator:1.0.7' bouncycastle-bcpkix = 'org.bouncycastle:bcpkix-jdk18on:1.83' twofactor-auth = 'com.j256.two-factor-auth:two-factor-auth:1.3' thumbnailator = 'net.coobird:thumbnailator:0.4.21' r2dbc-migrate-starter = 'name.nkonev.r2dbc-migrate:r2dbc-migrate-spring-boot-starter:4.0.1' [bundles] lucene = ['lucene-core', 'lucene-queryparser', 'lucene-highlighter', 'lucene-backward-codecs', 'lucene-analyzers-common'] resilience4j = ['resilience4j-springboot3', 'resilience4j-reactor'] apache = ['apache-commons-lang3', 'apache-tika-core'] therapi = ['therapi-runtime-javadoc', 'therapi-runtime-javadoc-scribe'] [plugins] spring-boot = 'org.springframework.boot:4.0.3' spring-dependency-management = 'io.spring.dependency-management:1.1.7' git-properties = 'com.gorylenko.gradle-git-properties:2.5.2' undercouch-download = 'de.undercouch.download:5.6.0' lombok = 'io.freefair.lombok:9.2.0' checksum = 'org.gradle.crypto.checksum:1.4.0' node = 'com.github.node-gradle.node:7.1.0' openapi-generator = 'org.openapi.generator:7.12.0' springdoc-openapi = 'org.springdoc.openapi-gradle-plugin:1.9.0' versions = 'com.github.ben-manes.versions:0.53.0' ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ version=2.23.0-SNAPSHOT jsonassert.version=2.0-rc1 r2dbc-mariadb.version=1.4.0 ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @rem SPDX-License-Identifier: Apache-2.0 @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: hack/cherry_pick_pull.sh ================================================ #!/usr/bin/env bash # Copyright 2015 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Usage Instructions: https://git.k8s.io/community/contributors/devel/sig-release/cherry-picks.md # Checkout a PR from GitHub. (Yes, this is sitting in a Git tree. How # meta.) Assumes you care about pulls from remote "upstream" and # checks them out to a branch named: # automated-cherry-pick-of--- set -o errexit set -o nounset set -o pipefail REPO_ROOT="$(git rev-parse --show-toplevel)" declare -r REPO_ROOT cd "${REPO_ROOT}" STARTINGBRANCH=$(git symbolic-ref --short HEAD) declare -r STARTINGBRANCH declare -r REBASEMAGIC="${REPO_ROOT}/.git/rebase-apply" DRY_RUN=${DRY_RUN:-""} REGENERATE_DOCS=${REGENERATE_DOCS:-""} UPSTREAM_REMOTE=${UPSTREAM_REMOTE:-upstream} FORK_REMOTE=${FORK_REMOTE:-origin} MAIN_REPO_ORG=${MAIN_REPO_ORG:-$(git remote get-url "$UPSTREAM_REMOTE" | awk '{gsub(/http[s]:\/\/|git@/,"")}1' | awk -F'[@:./]' 'NR==1{print $3}')} MAIN_REPO_NAME=${MAIN_REPO_NAME:-$(git remote get-url "$UPSTREAM_REMOTE" | awk '{gsub(/http[s]:\/\/|git@/,"")}1' | awk -F'[@:./]' 'NR==1{print $4}')} if [[ -z ${GITHUB_USER:-} ]]; then echo "Please export GITHUB_USER= (or GH organization, if that's where your fork lives)" exit 1 fi if ! command -v gh > /dev/null; then echo "Can't find 'gh' tool in PATH, please install from https://github.com/cli/cli" exit 1 fi if [[ "$#" -lt 2 ]]; then echo "${0} ...: cherry pick one or more onto and leave instructions for proposing pull request" echo echo " Checks out and handles the cherry-pick of (possibly multiple) for you." echo " Examples:" echo " $0 upstream/release-3.14 12345 # Cherry-picks PR 12345 onto upstream/release-3.14 and proposes that as a PR." echo " $0 upstream/release-3.14 12345 56789 # Cherry-picks PR 12345, then 56789 and proposes the combination as a single PR." echo echo " Set the DRY_RUN environment var to skip git push and creating PR." echo " This is useful for creating patches to a release branch without making a PR." echo " When DRY_RUN is set the script will leave you in a branch containing the commits you cherry-picked." echo echo " Set the REGENERATE_DOCS environment var to regenerate documentation for the target branch after picking the specified commits." echo " This is useful when picking commits containing changes to API documentation." echo echo " Set UPSTREAM_REMOTE (default: upstream) and FORK_REMOTE (default: origin)" echo " to override the default remote names to what you have locally." echo echo " For merge process info, see https://git.k8s.io/community/contributors/devel/sig-release/cherry-picks.md" exit 2 fi # Checks if you are logged in. Will error/bail if you are not. gh auth status if git_status=$(git status --porcelain --untracked=no 2>/dev/null) && [[ -n "${git_status}" ]]; then echo "!!! Dirty tree. Clean up and try again." exit 1 fi if [[ -e "${REBASEMAGIC}" ]]; then echo "!!! 'git rebase' or 'git am' in progress. Clean up and try again." exit 1 fi declare -r BRANCH="$1" shift 1 declare -r PULLS=( "$@" ) function join { local IFS="$1"; shift; echo "$*"; } PULLDASH=$(join - "${PULLS[@]/#/#}") # Generates something like "#12345-#56789" declare -r PULLDASH PULLSUBJ=$(join " " "${PULLS[@]/#/#}") # Generates something like "#12345 #56789" declare -r PULLSUBJ echo "+++ Updating remotes..." git remote update "${UPSTREAM_REMOTE}" "${FORK_REMOTE}" if ! git log -n1 --format=%H "${BRANCH}" >/dev/null 2>&1; then echo "!!! '${BRANCH}' not found. The second argument should be something like ${UPSTREAM_REMOTE}/release-0.21." echo " (In particular, it needs to be a valid, existing remote branch that I can 'git checkout'.)" exit 1 fi NEWBRANCHREQ="automated-cherry-pick-of-${PULLDASH}" # "Required" portion for tools. declare -r NEWBRANCHREQ NEWBRANCH="$(echo "${NEWBRANCHREQ}-${BRANCH}" | sed 's/\//-/g')" declare -r NEWBRANCH NEWBRANCHUNIQ="${NEWBRANCH}-$(date +%s)" declare -r NEWBRANCHUNIQ echo "+++ Creating local branch ${NEWBRANCHUNIQ}" cleanbranch="" gitamcleanup=false function return_to_kansas { if [[ "${gitamcleanup}" == "true" ]]; then echo echo "+++ Aborting in-progress git am." git am --abort >/dev/null 2>&1 || true fi # return to the starting branch and delete the PR text file if [[ -z "${DRY_RUN}" ]]; then echo echo "+++ Returning you to the ${STARTINGBRANCH} branch and cleaning up." git checkout -f "${STARTINGBRANCH}" >/dev/null 2>&1 || true if [[ -n "${cleanbranch}" ]]; then git branch -D "${cleanbranch}" >/dev/null 2>&1 || true fi fi } trap return_to_kansas EXIT SUBJECTS=() function make-a-pr() { local rel rel="$(basename "${BRANCH}")" echo echo "+++ Creating a pull request on GitHub at ${GITHUB_USER}:${NEWBRANCH}" local numandtitle numandtitle=$(printf '%s\n' "${SUBJECTS[@]}") prtext=$(cat <&2 exit 1 fi done if [[ "${conflicts}" != "true" ]]; then echo "!!! git am failed, likely because of an in-progress 'git am' or 'git rebase'" exit 1 fi } # set the subject subject=$(grep -m 1 "^Subject" "/tmp/${pull}.patch" | sed -e 's/Subject: \[PATCH//g' | sed 's/.*] //') SUBJECTS+=("#${pull}: ${subject}") # remove the patch file from /tmp rm -f "/tmp/${pull}.patch" done gitamcleanup=false # Re-generate docs (if needed) if [[ -n "${REGENERATE_DOCS}" ]]; then echo echo "Regenerating docs..." if ! hack/generate-docs.sh; then echo echo "hack/generate-docs.sh FAILED to complete." exit 1 fi fi if [[ -n "${DRY_RUN}" ]]; then echo "!!! Skipping git push and PR creation because you set DRY_RUN." echo "To return to the branch you were in when you invoked this script:" echo echo " git checkout ${STARTINGBRANCH}" echo echo "To delete this branch:" echo echo " git branch -D ${NEWBRANCHUNIQ}" exit 0 fi if git remote -v | grep ^"${FORK_REMOTE}" | grep "${MAIN_REPO_ORG}/${MAIN_REPO_NAME}.git"; then echo "!!! You have ${FORK_REMOTE} configured as your ${MAIN_REPO_ORG}/${MAIN_REPO_NAME}.git" echo "This isn't normal. Leaving you with push instructions:" echo echo "+++ First manually push the branch this script created:" echo echo " git push REMOTE ${NEWBRANCHUNIQ}:${NEWBRANCH}" echo echo "where REMOTE is your personal fork (maybe ${UPSTREAM_REMOTE}? Consider swapping those.)." echo "OR consider setting UPSTREAM_REMOTE and FORK_REMOTE to different values." echo make-a-pr cleanbranch="" exit 0 fi echo echo "+++ I'm about to do the following to push to GitHub (and I'm assuming ${FORK_REMOTE} is your personal fork):" echo echo " git push ${FORK_REMOTE} ${NEWBRANCHUNIQ}:${NEWBRANCH}" echo read -p "+++ Proceed (anything but 'y' aborts the cherry-pick)? [y/n] " -r if ! [[ "${REPLY}" =~ ^[yY]$ ]]; then echo "Aborting." >&2 exit 1 fi git push "${FORK_REMOTE}" -f "${NEWBRANCHUNIQ}:${NEWBRANCH}" make-a-pr ================================================ FILE: platform/application/build.gradle ================================================ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { id 'java-platform' id 'halo.publish' alias(libs.plugins.spring.boot) apply false } group = 'run.halo.tools.platform' description = 'Platform of application.' javaPlatform { allowDependencies() } dependencies { api platform(SpringBootPlugin.BOM_COORDINATES) constraints { api libs.bundles.lucene api libs.bundles.apache api libs.bundles.therapi api libs.springdoc.openapi api libs.openapi.schema.validator api libs.bouncycastle.bcpkix api libs.encoding.base62 api libs.pf4j api libs.guava api libs.java.diff.utils api libs.jsoup api libs.json.patch api libs.bundles.resilience4j api libs.twofactor.auth api libs.thumbnailator api "org.springframework.integration:spring-integration-core" api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6" } } publishing { publications.named('mavenJava', MavenPublication) { from components.javaPlatform pom { name = 'Application platform.' description = "$project.description" } } } ================================================ FILE: platform/plugin/build.gradle ================================================ plugins { id 'java-platform' id 'halo.publish' } group = 'run.halo.tools.platform' description = 'This is the platform that other plugins depend on. ' + 'We can put the plugin API as a dependency at here.' javaPlatform { allowDependencies() } dependencies { api platform(project(':platform:application')) constraints { api project(':api') // TODO other plugin API dependencies // e.g.: api 'halo.run.plugin:links-api:1.1.0' } } publishing { publications.named('mavenJava', MavenPublication) { from components.javaPlatform pom { name = 'Plugin platform' description = "$project.description" } } } ================================================ FILE: settings.gradle ================================================ pluginManagement { repositories { maven { url = 'https://repo.spring.io/milestone' } gradlePluginPortal() } } rootProject.name = 'halo' include 'api', 'application', 'platform:application', 'platform:plugin', 'ui' ================================================ FILE: ui/.editorconfig ================================================ root=true [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = false insert_final_newline = true ================================================ FILE: ui/.husky/pre-commit ================================================ cd ui && pnpm exec lint-staged ================================================ FILE: ui/.npmrc ================================================ strict-peer-dependencies=false auto-install-peers=true ================================================ FILE: ui/.oxfmtrc.json ================================================ { "$schema": "./node_modules/oxfmt/configuration_schema.json", "sortTailwindcss": {}, "sortImports": { "newlinesBetween": false }, "trailingComma": "es5", "printWidth": 80, "sortPackageJson": true, "tabWidth": 2, "useTabs": false, "insertFinalNewline": true, "ignorePatterns": [ "packages/api-client/src", "docs", "**/dist/**", "build/**", "storybook-static", ".idea" ], "overrides": [ { "files": ["**/*.html"], "options": { "printWidth": 120 } } ] } ================================================ FILE: ui/Makefile ================================================ SHELL := /usr/bin/env bash -o errexit -o pipefail -o nounset install: ## Install console pnpm install build-packages: install ## Build packages of console pnpm build:packages build: build-packages ## Build console pnpm build lint: build-packages ## Lint console pnpm lint pnpm typecheck test: build-packages ## Test console pnpm test:unit check: lint test ## Check console dev: build-packages ## Run console with development environment pnpm dev api-client-gen: install ## Generate API client pnpm api-client:gen help: ## print this help @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z0-9_-]+:.*?## / {gsub("\\\\n",sprintf("\n%22c",""), $$2);printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) ================================================ FILE: ui/build.gradle ================================================ plugins { id 'idea' id 'base' alias(libs.plugins.node) alias(libs.plugins.openapi.generator) } idea { module { excludeDirs += file('node_modules/') excludeDirs += file('packages').listFiles().collect { file(it.path + '/node_modules/') } excludeDirs += file('packages').listFiles().collect { file(it.path + '/dist/') } } } pnpmInstall { nodeModulesOutputFilter { exclude('**') } } tasks.named('clean') { dependsOn tasks.named('doClean') } tasks.register('doClean', Delete) { delete layout.buildDirectory delete fileTree('packages') { include '*/dist/**' } } tasks.named('assemble') { dependsOn tasks.named('doBuild') } tasks.register('doBuild', PnpmTask) { dependsOn tasks.named('buildPackages') pnpmCommand = ['run', 'build'] inputs.files(fileTree(layout.projectDirectory) { include 'console-src/**', 'uc-src/**', 'src/**', 'public/**', '*.js', '*.json', '*.yaml', '*.html' exclude '**/node_modules/**', '**/build/**', '**/dist/**' }) outputs.dir(layout.buildDirectory.dir('dist')) } tasks.register('buildPackages', PnpmTask) { dependsOn tasks.named('pnpmInstall') inputs.files(fileTree('packages') { exclude '**/node_modules/**', '**/dist/**' }) inputs.file('package.json') pnpmCommand = ['run', 'build:packages'] outputs.files(fileTree('packages') { include '*/dist/**' }) } tasks.register('test', PnpmTask) { dependsOn tasks.named('buildPackages') pnpmCommand = ['run', 'test:unit'] shouldRunAfter tasks.named('lint'), tasks.named('typecheck') } tasks.register('lint', PnpmTask) { dependsOn tasks.named('buildPackages') pnpmCommand = ['run', 'lint'] } tasks.register('typecheck', PnpmTask) { dependsOn tasks.named('buildPackages') pnpmCommand = ['run', 'typecheck'] } tasks.named('check') { dependsOn tasks.named('lint'), tasks.named('typecheck'), tasks.named('test') } tasks.register('dev', PnpmTask) { dependsOn tasks.named('buildPackages') pnpmCommand = ['run', 'dev'] } ================================================ FILE: ui/console-src/App.vue ================================================ ================================================ FILE: ui/console-src/components/snapshots/BaseSnapshots.vue ================================================ ================================================ FILE: ui/console-src/components/snapshots/SnapshotContent.vue ================================================ ================================================ FILE: ui/console-src/components/snapshots/SnapshotDiffContent.vue ================================================ ================================================ FILE: ui/console-src/components/snapshots/SnapshotListItem.vue ================================================ ================================================ FILE: ui/console-src/components/snapshots/query-keys.ts ================================================ import type { Ref } from "vue"; export const SNAPSHOTS_QUERY_KEY = ( cacheKey: Ref, name?: Ref ) => [`core:${cacheKey}:snapshots`, name]; export const SNAPSHOT_QUERY_KEY = ( cacheKey: Ref, name?: Ref, snapshotNames?: Ref ) => [`core:${cacheKey}:snapshot`, name, snapshotNames]; export const SNAPSHOT_DIFF_QUERY_KEY = ( cacheKey: Ref, name?: Ref, snapshotNames?: Ref ) => [`core:${cacheKey}:snapshot-diff`, name, snapshotNames]; ================================================ FILE: ui/console-src/composables/use-content-snapshot.ts ================================================ import { coreApiClient } from "@halo-dev/api-client"; import { nextTick, ref, watch, type Ref } from "vue"; interface SnapshotContent { version: Ref; handleFetchSnapshot: () => Promise; } export function useContentSnapshot( snapshotName: Ref ): SnapshotContent { const version = ref(0); watch(snapshotName, () => { nextTick(() => { handleFetchSnapshot(); }); }); const handleFetchSnapshot = async () => { if (!snapshotName.value) { return; } const { data } = await coreApiClient.content.snapshot.getSnapshot({ name: snapshotName.value, }); version.value = data.metadata.version || 0; }; return { version, handleFetchSnapshot, }; } ================================================ FILE: ui/console-src/composables/use-dashboard-stats.ts ================================================ import { consoleApiClient } from "@halo-dev/api-client"; import { useQuery } from "@tanstack/vue-query"; export function useDashboardStats() { const { data } = useQuery({ queryKey: ["dashboard-stats"], queryFn: async () => { const { data } = await consoleApiClient.system.getStats(); return data; }, }); return { data }; } ================================================ FILE: ui/console-src/composables/use-entity-extension-points.ts ================================================ import type { EntityFieldItem } from "@halo-dev/ui-shared"; import { useQuery } from "@tanstack/vue-query"; import { computed, toValue, type ComputedRef, type Ref } from "vue"; import { usePluginModuleStore } from "@/stores/plugin"; export function useEntityFieldItemExtensionPoint( extensionPointName: string, entity: Ref, presets: ComputedRef ) { const { pluginModules } = usePluginModuleStore(); return useQuery({ queryKey: computed(() => [ "core:extension-points:list-item:fields", extensionPointName, toValue(entity), ]), queryFn: async () => { const itemsFromPlugins: EntityFieldItem[] = []; for (const pluginModule of pluginModules) { const { extensionPoints } = pluginModule; if (!extensionPoints?.[extensionPointName]) { continue; } const items = extensionPoints[extensionPointName]( entity ) as EntityFieldItem[]; itemsFromPlugins.push(...items); } const allItems = [...presets.value, ...itemsFromPlugins].sort( (a, b) => a.priority - b.priority ); const start: EntityFieldItem[] = []; const end: EntityFieldItem[] = []; for (const item of allItems) { if (item.position === "start") { start.push(item); } else if (item.position === "end") { end.push(item); } } return { start, end, }; }, enabled: computed(() => !!presets.value && !!entity.value), }); } ================================================ FILE: ui/console-src/composables/use-operation-extension-points.ts ================================================ import type { OperationItem } from "@halo-dev/ui-shared"; import { useQuery } from "@tanstack/vue-query"; import { computed, toValue, type ComputedRef, type Ref } from "vue"; import { usePluginModuleStore } from "@/stores/plugin"; export function useOperationItemExtensionPoint( extensionPointName: string, entity: Ref, presets: ComputedRef[]> ) { const { pluginModules } = usePluginModuleStore(); return useQuery({ queryKey: computed(() => [ "core:extension-points:operation-items", extensionPointName, toValue(entity), ]), queryFn: async () => { const itemsFromPlugins: OperationItem[] = []; for (const pluginModule of pluginModules) { const { extensionPoints } = pluginModule; if (!extensionPoints?.[extensionPointName]) { continue; } const items = extensionPoints[extensionPointName]( entity ) as OperationItem[]; itemsFromPlugins.push(...items); } return [...presets.value, ...itemsFromPlugins].sort( (a, b) => a.priority - b.priority ); }, enabled: computed(() => !!presets.value && !!entity.value), }); } ================================================ FILE: ui/console-src/composables/use-save-keybinding.ts ================================================ import { useEventListener } from "@vueuse/core"; import { useDebounceFn } from "@vueuse/shared"; import { nextTick } from "vue"; import { isMac } from "@/utils/device"; export function useSaveKeybinding(fn: () => void) { const debouncedFn = useDebounceFn(() => { fn(); }, 300); useEventListener(window, "keydown", (e: KeyboardEvent) => { if (isMac ? e.metaKey : e.ctrlKey) { if (e.key === "s") { e.preventDefault(); nextTick(() => { debouncedFn(); }); } } }); } ================================================ FILE: ui/console-src/composables/use-slugify.ts ================================================ import { FormType, stores, utils } from "@halo-dev/ui-shared"; import ShortUniqueId from "short-unique-id"; import { slugify } from "transliteration"; import { computed, watch, type Ref } from "vue"; const uid = new ShortUniqueId(); type SlugStrategy = (value?: string) => string; const strategies: Record = { generateByTitle: (value?: string) => slugify(value || "", { trim: true }), shortUUID: () => uid.randomUUID(8), UUID: () => utils.id.uuid(), timestamp: () => new Date().getTime().toString(), }; const onceStrategies = new Set(["shortUUID", "UUID", "timestamp"]); export default function useSlugify( source: Ref, target: Ref, auto: Ref, formType: FormType ) { const globalInfoStore = stores.globalInfo(); const currentStrategy = computed( () => globalInfoStore.globalInfo?.postSlugGenerationStrategy || "generateByTitle" ); const generateSlug = (value: string): string => { const strategy = formType === FormType.POST ? strategies[currentStrategy.value] : strategies.generateByTitle; return strategy(value); }; const handleGenerateSlug = (forceUpdate = false) => { if ( !forceUpdate && onceStrategies.has(currentStrategy.value) && target.value ) { return; } target.value = generateSlug(source.value); }; watch( source, () => { if (auto.value) { handleGenerateSlug(true); } }, { immediate: true } ); return { handleGenerateSlug, }; } ================================================ FILE: ui/console-src/layouts/BasicLayout.vue ================================================ ================================================ FILE: ui/console-src/layouts/BlankLayout.vue ================================================ ================================================ FILE: ui/console-src/main.ts ================================================ import modules from "@console/modules"; import { useThemeStore } from "@console/stores/theme"; import { stores } from "@halo-dev/ui-shared"; import { createPinia } from "pinia"; import "@/setup/setupStyles"; import { createApp } from "vue"; import { setLanguage, setupI18n } from "@/locales"; import { setupApiClient } from "@/setup/setupApiClient"; import { setupComponents } from "@/setup/setupComponents"; import { setupCoreModules, setupPluginModules } from "@/setup/setupModules"; import "core-js/es/object/has-own"; import { setupUserPermissions } from "@/setup/setupUserPermissions"; import { setupVueQuery } from "@/setup/setupVueQuery"; import App from "./App.vue"; import router from "./router"; const app = createApp(App); setupComponents(app); setupI18n(app); setupVueQuery(app); setupApiClient(); app.use(createPinia()); async function loadActivatedTheme() { const themeStore = useThemeStore(); await themeStore.fetchActivatedTheme(); } await initApp(); async function initApp() { try { setupCoreModules({ app, router, platform: "console", modules }); const currentUserStore = stores.currentUser(); await currentUserStore.fetchCurrentUser(); const globalInfoStore = stores.globalInfo(); await globalInfoStore.fetchGlobalInfo(); await setLanguage(); if (currentUserStore.isAnonymous) { return; } await setupUserPermissions(app); try { await setupPluginModules({ app, router, platform: "console" }); } catch (e) { console.error("Failed to load plugins", e); } await loadActivatedTheme(); } catch (e) { console.error(e); } finally { app.use(router); app.mount("#app"); } } ================================================ FILE: ui/console-src/modules/contents/attachments/AttachmentList.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/AttachmentDetailModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/AttachmentError.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/AttachmentGroupBadge.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/AttachmentGroupEditingModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/AttachmentGroupList.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/AttachmentListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/AttachmentLoading.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/AttachmentPoliciesListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/AttachmentPoliciesModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/AttachmentPolicyBadge.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/AttachmentPolicyEditingModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/AttachmentSelectorModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/AttachmentUploadArea.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/AttachmentUploadModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/DisplayNameEditForm.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/UploadFromUrl.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/selector-providers/CoreSelectorProvider.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/selector-providers/components/AttachmentSelectorListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/selector-providers/components/GroupFilter.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/components/selector-providers/components/PolicyFilter.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/attachments/composables/use-attachment-group.ts ================================================ import type { Group, GroupV1alpha1ApiListGroupRequest, } from "@halo-dev/api-client"; import { coreApiClient, paginate } from "@halo-dev/api-client"; import { useQuery } from "@tanstack/vue-query"; import type { Ref } from "vue"; interface useFetchAttachmentGroupReturn { groups: Ref; isLoading: Ref; handleFetchGroups: () => void; } export function useFetchAttachmentGroup(): useFetchAttachmentGroupReturn { const { data, isLoading, refetch } = useQuery({ queryKey: ["attachment-groups"], queryFn: async () => { return await paginate( (params) => coreApiClient.storage.group.listGroup(params), { size: 1000, labelSelector: ["!halo.run/hidden"], sort: ["metadata.creationTimestamp,asc"], } ); }, refetchInterval(data) { const hasDeletingGroup = data?.some( (group) => !!group.metadata.deletionTimestamp ); return hasDeletingGroup ? 1000 : false; }, }); return { groups: data, isLoading, handleFetchGroups: refetch, }; } ================================================ FILE: ui/console-src/modules/contents/attachments/composables/use-attachment-policy.ts ================================================ import type { Policy, PolicyTemplate, PolicyTemplateV1alpha1ApiListPolicyTemplateRequest, PolicyV1alpha1ApiListPolicyRequest, } from "@halo-dev/api-client"; import { coreApiClient, paginate } from "@halo-dev/api-client"; import { useQuery } from "@tanstack/vue-query"; import { attachmentPolicyLabels } from "@/constants/labels"; export function useFetchAttachmentPolicy() { return useQuery({ queryKey: ["attachment-policies"], queryFn: async () => { const policies = await paginate< PolicyV1alpha1ApiListPolicyRequest, Policy >((params) => coreApiClient.storage.policy.listPolicy(params), { size: 1000, }); return policies.sort((a, b) => { const priorityA = parseInt( a.metadata.labels?.[attachmentPolicyLabels.PRIORITY] || "0", 10 ); const priorityB = parseInt( b.metadata.labels?.[attachmentPolicyLabels.PRIORITY] || "0", 10 ); return priorityB - priorityA; }); }, refetchInterval(data) { const hasDeletingPolicy = data?.some( (policy) => !!policy.metadata.deletionTimestamp ); return hasDeletingPolicy ? 1000 : false; }, }); } export function useFetchAttachmentPolicyTemplate() { return useQuery({ queryKey: ["attachment-policy-templates"], queryFn: async () => { return await paginate< PolicyTemplateV1alpha1ApiListPolicyTemplateRequest, PolicyTemplate >( (params) => coreApiClient.storage.policyTemplate.listPolicyTemplate(params), { size: 1000, } ); }, }); } ================================================ FILE: ui/console-src/modules/contents/attachments/composables/use-attachment.ts ================================================ import type { Attachment } from "@halo-dev/api-client"; import { consoleApiClient, coreApiClient } from "@halo-dev/api-client"; import { Dialog, Toast } from "@halo-dev/components"; import { useQuery } from "@tanstack/vue-query"; import { computed, nextTick, ref, watch, type ComputedRef, type Ref, } from "vue"; import { useI18n } from "vue-i18n"; interface useAttachmentControlReturn { attachments: Ref; isLoading: Ref; isFetching: Ref; selectedAttachment: Ref; selectedAttachments: ComputedRef; selectedAttachmentNames: Ref>; checkedAll: Ref; total: Ref; handleFetchAttachments: () => void; handleSelectPrevious: () => void; handleSelectNext: () => void; handleDeleteInBatch: () => void; handleCheckAll: (checkAll: boolean) => void; handleSelect: (attachment: Attachment | undefined) => void; isChecked: (attachment: Attachment) => boolean; handleReset: () => void; } export function useAttachmentControl(filterOptions: { policyName?: Ref; groupName?: Ref; user?: Ref; accepts?: Ref; keyword?: Ref; sort?: Ref; page: Ref; size: Ref; }): useAttachmentControlReturn { const { t } = useI18n(); const { user, policyName, groupName, keyword, sort, page, size, accepts } = filterOptions; const selectedAttachment = ref(); const selectedAttachmentNames = ref>(new Set()); const checkedAll = ref(false); const total = ref(0); const hasPrevious = ref(false); const hasNext = ref(false); const { data, isLoading, isFetching, refetch } = useQuery({ queryKey: [ "attachments", policyName, keyword, groupName, user, accepts, page, size, sort, ], queryFn: async () => { const isUnGrouped = groupName?.value === "ungrouped"; const fieldSelectorMap: Record = { "spec.policyName": policyName?.value, "spec.ownerName": user?.value, "spec.groupName": isUnGrouped ? undefined : groupName?.value, }; const fieldSelector = Object.entries(fieldSelectorMap) .map(([key, value]) => { if (value) { return `${key}=${value}`; } }) .filter(Boolean) as string[]; const { data } = await consoleApiClient.storage.attachment.searchAttachments({ fieldSelector, page: page.value, size: size.value, ungrouped: isUnGrouped, accepts: accepts?.value, keyword: keyword?.value, sort: [sort?.value as string].filter(Boolean), }); total.value = data.total; hasPrevious.value = data.hasPrevious; hasNext.value = data.hasNext; return data.items; }, refetchInterval(data) { const hasDeletingAttachment = data?.some( (attachment) => !!attachment.metadata.deletionTimestamp ); return hasDeletingAttachment ? 1000 : false; }, }); const handleSelectPrevious = async () => { if (!data.value) return; const index = data.value?.findIndex( (attachment) => attachment.metadata.name === selectedAttachment.value?.metadata.name ); if (index === undefined) return; if (index > 0) { selectedAttachment.value = data.value[index - 1]; return; } if (index === 0 && hasPrevious.value) { page.value--; await nextTick(); await refetch(); selectedAttachment.value = data.value[data.value.length - 1]; } }; const handleSelectNext = async () => { if (!data.value) return; const index = data.value?.findIndex( (attachment) => attachment.metadata.name === selectedAttachment.value?.metadata.name ); if (index === undefined) return; if (index < data.value?.length - 1) { selectedAttachment.value = data.value[index + 1]; return; } if (index === data.value.length - 1 && hasNext.value) { page.value++; await nextTick(); await refetch(); selectedAttachment.value = data.value[0]; } }; const handleDeleteInBatch = () => { Dialog.warning({ title: t("core.attachment.operations.delete_in_batch.title"), description: t("core.common.dialog.descriptions.cannot_be_recovered"), confirmType: "danger", confirmText: t("core.common.buttons.confirm"), cancelText: t("core.common.buttons.cancel"), onConfirm: async () => { try { const promises = Array.from(selectedAttachmentNames.value).map( (name) => { return coreApiClient.storage.attachment.deleteAttachment({ name, }); } ); await Promise.all(promises); selectedAttachmentNames.value.clear(); Toast.success(t("core.common.toast.delete_success")); } catch (e) { console.error("Failed to delete attachments", e); } finally { await refetch(); } }, }); }; const handleCheckAll = (checkAll: boolean) => { if (checkAll) { data.value?.forEach((attachment) => { selectedAttachmentNames.value.add(attachment.metadata.name); }); } else { selectedAttachmentNames.value.clear(); } }; const handleSelect = async (attachment: Attachment | undefined) => { if (!attachment) return; if (selectedAttachmentNames.value.has(attachment.metadata.name)) { selectedAttachmentNames.value.delete(attachment.metadata.name); return; } selectedAttachmentNames.value.add(attachment.metadata.name); }; watch( () => selectedAttachmentNames.value.size, (newValue) => { checkedAll.value = newValue === data.value?.length; } ); const isChecked = (attachment: Attachment) => { return selectedAttachmentNames.value.has(attachment.metadata.name); }; const handleReset = () => { page.value = 1; selectedAttachment.value = undefined; selectedAttachmentNames.value.clear(); checkedAll.value = false; }; const selectedAttachments = computed(() => { return ( data.value?.filter((attachment) => selectedAttachmentNames.value.has(attachment.metadata.name) ) || [] ); }); return { attachments: data, isLoading, isFetching, selectedAttachment, selectedAttachments, selectedAttachmentNames, checkedAll, total, handleFetchAttachments: refetch, handleSelectPrevious, handleSelectNext, handleDeleteInBatch, handleCheckAll, handleSelect, isChecked, handleReset, }; } ================================================ FILE: ui/console-src/modules/contents/attachments/module.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import { IconFolder } from "@halo-dev/components"; import { definePlugin, utils } from "@halo-dev/ui-shared"; import { defineAsyncComponent, markRaw } from "vue"; declare module "vue" { interface GlobalComponents { AttachmentSelectorModal: | (typeof import("@console/modules/contents/attachments/components/AttachmentSelectorModal.vue"))["default"] | (typeof import("@uc/modules/contents/attachments/components/AttachmentSelectorModal.vue"))["default"]; } } export default definePlugin({ components: { AttachmentSelectorModal: defineAsyncComponent({ loader: () => { if (utils.permission.has(["system:attachments:manage"])) { return import("@console/modules/contents/attachments/components/AttachmentSelectorModal.vue"); } return import("@uc/modules/contents/attachments/components/AttachmentSelectorModal.vue"); }, }), }, routes: [ { path: "/attachments", name: "AttachmentsRoot", component: BasicLayout, meta: { title: "core.attachment.title", permissions: ["system:attachments:view"], menu: { name: "core.sidebar.menu.items.attachments", group: "content", icon: markRaw(IconFolder), priority: 3, mobile: true, }, }, children: [ { path: "", name: "Attachments", component: () => import("./AttachmentList.vue"), }, ], }, ], }); ================================================ FILE: ui/console-src/modules/contents/comments/CommentList.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/comments/components/CommentDetailModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/comments/components/CommentEditor.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/comments/components/CommentListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/comments/components/DefaultCommentContent.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/comments/components/DefaultCommentEditor.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/comments/components/OwnerButton.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/comments/components/ReplyCreationModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/comments/components/ReplyDetailModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/comments/components/ReplyListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/comments/components/SubjectQueryCommentList.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/comments/components/SubjectQueryCommentListModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/comments/composables/use-comment-last-readtime-mutate.ts ================================================ import { coreApiClient, type ListedComment } from "@halo-dev/api-client"; import { useMutation, useQueryClient } from "@tanstack/vue-query"; export const useCommentLastReadTimeMutate = (comment: ListedComment) => { const queryClient = useQueryClient(); return useMutation({ mutationKey: ["update-comment-last-read-time"], mutationFn: async () => { const { data } = await coreApiClient.content.comment.patchComment( { name: comment.comment.metadata.name, jsonPatchInner: [ { op: "add", path: "/spec/lastReadTime", value: new Date().toISOString(), }, ], }, { mute: true, } ); if (data.status?.unreadReplyCount) { throw new Error("Unread reply count is not 0, retry"); } return data; }, retry: 5, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["core:comments"] }); }, }); }; ================================================ FILE: ui/console-src/modules/contents/comments/composables/use-comments-fetch.ts ================================================ import { consoleApiClient, type ListedCommentList } from "@halo-dev/api-client"; import { useQuery } from "@tanstack/vue-query"; import type { Ref } from "vue"; export default function useCommentsFetch( queryKey: string, page: Ref, size: Ref, approved: Ref, sort: Ref, user: Ref, keyword: Ref, subjectRefKey?: Ref ) { return useQuery({ queryKey: [ queryKey, page, size, approved, sort, user, keyword, subjectRefKey, ], queryFn: async () => { const fieldSelectorMap: Record = { "spec.approved": approved.value, "spec.subjectRef": subjectRefKey?.value, }; const fieldSelector = Object.entries(fieldSelectorMap) .map(([key, value]) => { if (value !== undefined) { return `${key}=${value}`; } }) .filter(Boolean) as string[]; const defaultSort = [ "metadata.creationTimestamp,desc", "status.lastReplyTime,desc", ]; const { data } = await consoleApiClient.content.comment.listComments({ fieldSelector, page: page.value, size: size.value, sort: sort.value ? [sort.value] : defaultSort, keyword: keyword.value, ownerName: user.value, ownerKind: user.value ? "User" : undefined, }); return data; }, refetchInterval(data) { const hasDeletingData = data?.items.some( (comment) => !!comment.comment.metadata.deletionTimestamp ); return hasDeletingData ? 1000 : false; }, }); } ================================================ FILE: ui/console-src/modules/contents/comments/composables/use-content-provider-extension-point.ts ================================================ import type { CommentContentProvider } from "@halo-dev/ui-shared"; import { useQuery } from "@tanstack/vue-query"; import { markRaw } from "vue"; import { usePluginModuleStore } from "@/stores/plugin"; import DefaultCommentContent from "../components/DefaultCommentContent.vue"; export function useContentProviderExtensionPoint() { const defaultProvider: CommentContentProvider = { component: markRaw(DefaultCommentContent), }; const { pluginModules } = usePluginModuleStore(); return useQuery({ queryKey: ["core:comment:list-item:content:provider"], queryFn: async () => { const result: CommentContentProvider[] = []; for (const pluginModule of pluginModules) { const callbackFunction = pluginModule?.extensionPoints?.["comment:list-item:content:replace"]; if (typeof callbackFunction !== "function") { continue; } const item = await callbackFunction(); result.push(item); } if (result.length) { return result[0]; } return defaultProvider; }, }); } ================================================ FILE: ui/console-src/modules/contents/comments/composables/use-subject-ref.ts ================================================ import type { Extension, ListedComment, Post, SinglePage, } from "@halo-dev/api-client"; import type { CommentSubjectRefProvider, CommentSubjectRefResult, } from "@halo-dev/ui-shared"; import { computed, onMounted, shallowRef } from "vue"; import { useI18n } from "vue-i18n"; import { usePluginModuleStore } from "@/stores/plugin"; export function useSubjectRef(comment: ListedComment) { const { t } = useI18n(); const SubjectRefProviders = shallowRef([ { kind: "Post", group: "content.halo.run", resolve: (subject: Extension): CommentSubjectRefResult => { const post = subject as Post; return { label: t("core.comment.subject_refs.post"), title: post.spec.title, externalUrl: post.status?.permalink, route: { name: "PostEditor", query: { name: post.metadata.name, }, }, }; }, }, { kind: "SinglePage", group: "content.halo.run", resolve: (subject: Extension): CommentSubjectRefResult => { const singlePage = subject as SinglePage; return { label: t("core.comment.subject_refs.page"), title: singlePage.spec.title, externalUrl: singlePage.status?.permalink, route: { name: "SinglePageEditor", query: { name: singlePage.metadata.name, }, }, }; }, }, ]); const { pluginModules } = usePluginModuleStore(); onMounted(() => { for (const pluginModule of pluginModules) { const callbackFunction = pluginModule?.extensionPoints?.["comment:subject-ref:create"]; if (typeof callbackFunction !== "function") { continue; } const providers = callbackFunction(); SubjectRefProviders.value = [...SubjectRefProviders.value, ...providers]; } }); const subjectRefResult = computed(() => { const { subject } = comment; if (!subject) { return { label: t("core.comment.subject_refs.unknown"), title: t("core.comment.subject_refs.unknown"), }; } const subjectRef = SubjectRefProviders.value.find( (provider) => provider.kind === subject.kind && subject.apiVersion.startsWith(provider.group) ); if (!subjectRef) { return { label: t("core.comment.subject_refs.unknown"), title: t("core.comment.subject_refs.unknown"), }; } return subjectRef.resolve(subject); }); return { subjectRefResult, }; } ================================================ FILE: ui/console-src/modules/contents/comments/module.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import { IconMessage, VLoading } from "@halo-dev/components"; import { definePlugin } from "@halo-dev/ui-shared"; import { defineAsyncComponent, markRaw } from "vue"; import SubjectQueryCommentListModal from "./components/SubjectQueryCommentListModal.vue"; declare module "vue" { interface GlobalComponents { SubjectQueryCommentList: (typeof import("./components/SubjectQueryCommentList.vue"))["default"]; SubjectQueryCommentListModal: (typeof import("./components/SubjectQueryCommentListModal.vue"))["default"]; } } export default definePlugin({ components: { SubjectQueryCommentList: defineAsyncComponent({ loader: () => import("./components/SubjectQueryCommentList.vue"), loadingComponent: VLoading, }), SubjectQueryCommentListModal, }, routes: [ { path: "/comments", name: "CommentsRoot", component: BasicLayout, meta: { title: "core.comment.title", searchable: true, permissions: ["system:comments:view"], menu: { name: "core.sidebar.menu.items.comments", group: "content", icon: markRaw(IconMessage), priority: 2, mobile: true, }, }, children: [ { path: "", name: "Comments", component: () => import("./CommentList.vue"), }, ], }, ], }); ================================================ FILE: ui/console-src/modules/contents/pages/DeletedSinglePageList.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/pages/SinglePageEditor.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/pages/SinglePageList.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/pages/SinglePageSnapshots.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/pages/components/SinglePageListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/pages/components/SinglePageSettingModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/pages/components/entity-fields/ContributorsField.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/pages/components/entity-fields/CoverField.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/pages/components/entity-fields/PublishStatusField.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/pages/components/entity-fields/PublishTimeField.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/pages/components/entity-fields/TitleField.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/pages/components/entity-fields/VisibleField.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/pages/composables/use-page-update-mutate.ts ================================================ import type { SinglePage } from "@halo-dev/api-client"; import { coreApiClient } from "@halo-dev/api-client"; import { Toast } from "@halo-dev/components"; import { useMutation } from "@tanstack/vue-query"; import { useI18n } from "vue-i18n"; export function usePageUpdateMutate() { const { t } = useI18n(); return useMutation({ mutationKey: ["singlePage-update"], mutationFn: async (page: SinglePage) => { const { data: latestSinglePage } = await coreApiClient.content.singlePage.getSinglePage({ name: page.metadata.name, }); return coreApiClient.content.singlePage.updateSinglePage( { name: page.metadata.name, singlePage: { ...latestSinglePage, spec: page.spec, metadata: { ...latestSinglePage.metadata, annotations: page.metadata.annotations, }, }, }, { mute: true, } ); }, retry: 3, onError: (error) => { console.error("Failed to update singlePage", error); Toast.error(t("core.common.toast.server_internal_error")); }, }); } ================================================ FILE: ui/console-src/modules/contents/pages/module.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import { IconPages } from "@halo-dev/components"; import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; export default definePlugin({ routes: [ { path: "/single-pages", name: "SinglePagesRoot", component: BasicLayout, meta: { title: "core.page.title", searchable: true, permissions: ["system:singlepages:view"], menu: { name: "core.sidebar.menu.items.single_pages", group: "content", icon: markRaw(IconPages), priority: 1, }, }, children: [ { path: "", name: "SinglePages", component: () => import("./SinglePageList.vue"), }, { path: "deleted", name: "DeletedSinglePages", component: () => import("./DeletedSinglePageList.vue"), meta: { title: "core.deleted_page.title", searchable: true, permissions: ["system:singlepages:view"], }, }, { path: "editor", name: "SinglePageEditor", component: () => import("./SinglePageEditor.vue"), meta: { title: "core.page_editor.title", searchable: true, hideFooter: true, permissions: ["system:singlepages:manage"], }, }, ], }, { path: "/single-pages/snapshots", name: "SinglePageSnapshots", component: () => import("./SinglePageSnapshots.vue"), meta: { title: "core.page_snapshots.title", searchable: false, hideFooter: true, permissions: ["system:singlepages:manage"], }, }, ], }); ================================================ FILE: ui/console-src/modules/contents/posts/DeletedPostList.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/PostEditor.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/PostList.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/PostSnapshots.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/categories/CategoryList.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/categories/components/CategoryListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/categories/components/__tests__/CategoryEditingModal.spec.ts ================================================ import messages from "@intlify/unplugin-vue-i18n/messages"; import { VueQueryPlugin } from "@tanstack/vue-query"; import { mount } from "@vue/test-utils"; import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it } from "vitest"; import { createI18n } from "vue-i18n"; import CategoryEditingModal from "../CategoryEditingModal.vue"; describe("CategoryEditingModal", function () { beforeEach(() => { setActivePinia(createPinia()); }); it("should render", function () { expect( mount(CategoryEditingModal, { global: { plugins: [ createI18n({ legacy: false, locale: "en", messages, }), VueQueryPlugin, ], }, }) ).toBeDefined(); }); }); ================================================ FILE: ui/console-src/modules/contents/posts/categories/composables/use-post-category.ts ================================================ import { coreApiClient, paginate, type Category, type CategoryV1alpha1ApiListCategoryRequest, } from "@halo-dev/api-client"; import { useQuery } from "@tanstack/vue-query"; import { ref } from "vue"; import { buildCategoriesTree, type CategoryTreeNode } from "../utils"; export function usePostCategory() { const categoriesTree = ref([] as CategoryTreeNode[]); const { data: categories, isLoading, refetch, } = useQuery({ queryKey: ["post-categories"], queryFn: async () => { return await paginate( (params) => coreApiClient.content.category.listCategory(params), { size: 1000, sort: ["metadata.creationTimestamp,desc"], } ); }, refetchInterval(data) { const hasAbnormalCategory = data?.some( (category) => !!category.metadata.deletionTimestamp || !category.status?.permalink ); return hasAbnormalCategory ? 1000 : false; }, onSuccess(data) { categoriesTree.value = buildCategoriesTree(data); }, }); return { categories, categoriesTree, isLoading, handleFetchCategories: refetch, }; } ================================================ FILE: ui/console-src/modules/contents/posts/categories/utils/__tests__/index.spec.ts ================================================ import type { Category } from "@halo-dev/api-client"; import { describe, expect, it } from "vitest"; import { buildCategoriesTree, convertCategoryTreeToCategory, convertTreeToCategories, getCategoryPath, resetCategoriesTreePriority, sortCategoriesTree, type CategoryTreeNode, } from "../index"; function createMockCategory( name: string, priority = 0, children: string[] = [] ): Category { return { metadata: { name, annotations: {}, labels: {}, version: 1, creationTimestamp: new Date().toISOString(), }, apiVersion: "content.halo.run/v1alpha1", kind: "Category", spec: { displayName: `Category ${name}`, slug: name, description: `Description for ${name}`, cover: "", template: "", priority: priority, children: children, }, }; } function createMockCategoryTreeNode( name: string, priority = 0, children: CategoryTreeNode[] = [] ): CategoryTreeNode { return { metadata: { name, annotations: {}, labels: {}, version: 1, creationTimestamp: new Date().toISOString(), }, apiVersion: "content.halo.run/v1alpha1", kind: "Category", spec: { displayName: `Category ${name}`, slug: name, description: `Description for ${name}`, cover: "", template: "", priority: priority, children: children.map((child) => child.metadata.name), }, children: children, }; } describe("buildCategoriesTree", () => { it("should convert flat category array to tree structure", () => { // Prepare test data const categories: Category[] = [ createMockCategory("parent1", 0, ["child1", "child2"]), createMockCategory("child1", 0), createMockCategory("child2", 1), createMockCategory("parent2", 1, ["child3"]), createMockCategory("child3", 0), ]; // Execute test const result = buildCategoriesTree(categories); // Verify results expect(result.length).toBe(2); // Should have two root nodes expect(result[0].metadata.name).toBe("parent1"); // First root node should be parent1 expect(result[1].metadata.name).toBe("parent2"); // Second root node should be parent2 expect(result[0].children.length).toBe(2); // parent1 should have two children expect(result[1].children.length).toBe(1); // parent2 should have one child expect(result[0].children[0].metadata.name).toBe("child1"); expect(result[0].children[1].metadata.name).toBe("child2"); expect(result[1].children[0].metadata.name).toBe("child3"); }); it("should handle empty array input", () => { const result = buildCategoriesTree([]); expect(result).toEqual([]); }); it("should handle categories without parent-child relationships", () => { const categories: Category[] = [ createMockCategory("category1", 0), createMockCategory("category2", 1), createMockCategory("category3", 2), ]; const result = buildCategoriesTree(categories); expect(result.length).toBe(3); expect(result.every((node) => node.children.length === 0)).toBe(true); }); it("should handle multi-level nested category structure", () => { const categories: Category[] = [ createMockCategory("root", 0, ["level1"]), createMockCategory("level1", 0, ["level2"]), createMockCategory("level2", 0, ["level3"]), createMockCategory("level3", 0), ]; const result = buildCategoriesTree(categories); expect(result.length).toBe(1); expect(result[0].metadata.name).toBe("root"); expect(result[0].children[0].metadata.name).toBe("level1"); expect(result[0].children[0].children[0].metadata.name).toBe("level2"); expect(result[0].children[0].children[0].children[0].metadata.name).toBe( "level3" ); }); }); describe("sortCategoriesTree", () => { it("should sort category tree by priority", () => { const categoriesTree: CategoryTreeNode[] = [ createMockCategoryTreeNode("node3", 2), createMockCategoryTreeNode("node1", 0), createMockCategoryTreeNode("node2", 1), ]; const result = sortCategoriesTree(categoriesTree); expect(result[0].metadata.name).toBe("node1"); expect(result[1].metadata.name).toBe("node2"); expect(result[2].metadata.name).toBe("node3"); }); it("should recursively sort child nodes", () => { const categoriesTree: CategoryTreeNode[] = [ createMockCategoryTreeNode("parent1", 0, [ createMockCategoryTreeNode("child3", 2), createMockCategoryTreeNode("child1", 0), createMockCategoryTreeNode("child2", 1), ]), ]; const result = sortCategoriesTree(categoriesTree); expect(result[0].children[0].metadata.name).toBe("child1"); expect(result[0].children[1].metadata.name).toBe("child2"); expect(result[0].children[2].metadata.name).toBe("child3"); }); it("should handle empty array input", () => { const result = sortCategoriesTree([]); expect(result).toEqual([]); }); }); describe("resetCategoriesTreePriority", () => { it("should reset priority values of all nodes in the tree", () => { const categoriesTree: CategoryTreeNode[] = [ createMockCategoryTreeNode("node1", 5), createMockCategoryTreeNode("node2", 10), createMockCategoryTreeNode("node3", 15), ]; const result = resetCategoriesTreePriority(categoriesTree); expect(result[0].spec.priority).toBe(0); expect(result[1].spec.priority).toBe(1); expect(result[2].spec.priority).toBe(2); }); it("should recursively reset child node priorities", () => { const categoriesTree: CategoryTreeNode[] = [ createMockCategoryTreeNode("parent", 5, [ createMockCategoryTreeNode("child1", 10), createMockCategoryTreeNode("child2", 15), ]), ]; const result = resetCategoriesTreePriority(categoriesTree); expect(result[0].spec.priority).toBe(0); expect(result[0].children[0].spec.priority).toBe(0); expect(result[0].children[1].spec.priority).toBe(1); }); it("should handle empty array input", () => { const result = resetCategoriesTreePriority([]); expect(result).toEqual([]); }); }); describe("convertTreeToCategories", () => { it("should convert tree structure back to flat category array", () => { const child1 = createMockCategoryTreeNode("child1", 0); const child2 = createMockCategoryTreeNode("child2", 1); const parent = createMockCategoryTreeNode("parent", 0, [child1, child2]); const categoriesTree: CategoryTreeNode[] = [parent]; const result = convertTreeToCategories(categoriesTree); expect(result.length).toBe(3); // Verify parent node const parentCategory = result.find((c) => c.metadata.name === "parent"); expect(parentCategory).toBeDefined(); expect(parentCategory?.spec.children).toContain("child1"); expect(parentCategory?.spec.children).toContain("child2"); // Verify child nodes const child1Category = result.find((c) => c.metadata.name === "child1"); const child2Category = result.find((c) => c.metadata.name === "child2"); expect(child1Category).toBeDefined(); expect(child2Category).toBeDefined(); expect(child1Category?.spec.children).toEqual([]); expect(child2Category?.spec.children).toEqual([]); }); it("should handle multi-level nested structure", () => { const level3 = createMockCategoryTreeNode("level3", 0); const level2 = createMockCategoryTreeNode("level2", 0, [level3]); const level1 = createMockCategoryTreeNode("level1", 0, [level2]); const root = createMockCategoryTreeNode("root", 0, [level1]); const categoriesTree: CategoryTreeNode[] = [root]; const result = convertTreeToCategories(categoriesTree); expect(result.length).toBe(4); const rootCategory = result.find((c) => c.metadata.name === "root"); const level1Category = result.find((c) => c.metadata.name === "level1"); const level2Category = result.find((c) => c.metadata.name === "level2"); const level3Category = result.find((c) => c.metadata.name === "level3"); expect(rootCategory?.spec.children).toContain("level1"); expect(level1Category?.spec.children).toContain("level2"); expect(level2Category?.spec.children).toContain("level3"); expect(level3Category?.spec.children).toEqual([]); }); it("should handle empty array input", () => { const result = convertTreeToCategories([]); expect(result).toEqual([]); }); }); describe("convertCategoryTreeToCategory", () => { it("should convert a single tree node to a category object", () => { const child1 = createMockCategoryTreeNode("child1", 0); const child2 = createMockCategoryTreeNode("child2", 1); const parent = createMockCategoryTreeNode("parent", 0, [child1, child2]); const result = convertCategoryTreeToCategory(parent); expect(result.metadata.name).toBe("parent"); expect(result.spec.children).toContain("child1"); expect(result.spec.children).toContain("child2"); expect(result.spec.children?.length).toBe(2); // eslint-disable-next-line expect((result as any).children).toBeUndefined(); }); it("should handle nodes without children", () => { const node = createMockCategoryTreeNode("node", 0); const result = convertCategoryTreeToCategory(node); expect(result.metadata.name).toBe("node"); expect(result.spec.children).toEqual([]); }); }); describe("getCategoryPath", () => { it("should return path from root to specified category", () => { const level3 = createMockCategoryTreeNode("level3", 0); const level2 = createMockCategoryTreeNode("level2", 0, [level3]); const level1 = createMockCategoryTreeNode("level1", 0, [level2]); const root = createMockCategoryTreeNode("root", 0, [level1]); const categoriesTree: CategoryTreeNode[] = [root]; const result = getCategoryPath(categoriesTree, "level3"); expect(result).toBeDefined(); expect(result?.length).toBe(4); expect(result?.[0].metadata.name).toBe("root"); expect(result?.[1].metadata.name).toBe("level1"); expect(result?.[2].metadata.name).toBe("level2"); expect(result?.[3].metadata.name).toBe("level3"); }); it("should handle case when category is not found", () => { const categoriesTree: CategoryTreeNode[] = [ createMockCategoryTreeNode("node1", 0), createMockCategoryTreeNode("node2", 1), ]; const result = getCategoryPath(categoriesTree, "nonexistent"); expect(result).toBeUndefined(); }); it("should handle multiple branches", () => { const child1 = createMockCategoryTreeNode("child1", 0); const child2 = createMockCategoryTreeNode("child2", 1); const target = createMockCategoryTreeNode("target", 0); const branch1 = createMockCategoryTreeNode("branch1", 0, [child1, child2]); const branch2 = createMockCategoryTreeNode("branch2", 1, [target]); const root = createMockCategoryTreeNode("root", 0, [branch1, branch2]); const categoriesTree: CategoryTreeNode[] = [root]; const result = getCategoryPath(categoriesTree, "target"); expect(result).toBeDefined(); expect(result?.length).toBe(3); expect(result?.[0].metadata.name).toBe("root"); expect(result?.[1].metadata.name).toBe("branch2"); expect(result?.[2].metadata.name).toBe("target"); }); it("should handle empty array input", () => { const result = getCategoryPath([], "any"); expect(result).toBeUndefined(); }); }); ================================================ FILE: ui/console-src/modules/contents/posts/categories/utils/index.ts ================================================ import type { Category } from "@halo-dev/api-client"; import { cloneDeep } from "es-toolkit"; export interface CategoryTreeNode extends Category { children: CategoryTreeNode[]; } export function buildCategoriesTree( categories: Category[] ): CategoryTreeNode[] { const categoriesToUpdate = cloneDeep(categories); const categoriesMap: Record = {}; const parentMap: Record = {}; categoriesToUpdate.forEach((category) => { categoriesMap[category.metadata.name] = { ...category, children: [], } as CategoryTreeNode; if (category.spec.children) { category.spec.children.forEach((child) => { parentMap[child] = category.metadata.name; }); } }); categoriesToUpdate.forEach((category) => { const parentName = parentMap[category.metadata.name]; if (parentName && categoriesMap[parentName]) { categoriesMap[parentName].children.push( categoriesMap[category.metadata.name] ); } }); const categoriesTree = Object.values(categoriesMap).filter( (node) => parentMap[node.metadata.name] === undefined ); return sortCategoriesTree(categoriesTree); } export function sortCategoriesTree( categoriesTree: CategoryTreeNode[] ): CategoryTreeNode[] { return categoriesTree .sort((a, b) => { if (a.spec.priority < b.spec.priority) { return -1; } if (a.spec.priority > b.spec.priority) { return 1; } return 0; }) .map((category) => { if (category.children && category.children.length) { return { ...category, children: sortCategoriesTree(category.children), }; } return category; }); } export function resetCategoriesTreePriority( categoriesTree: CategoryTreeNode[] ): CategoryTreeNode[] { for (let i = 0; i < categoriesTree.length; i++) { categoriesTree[i].spec.priority = i; if (categoriesTree[i].children && categoriesTree[i].children.length) { resetCategoriesTreePriority(categoriesTree[i].children); } } return categoriesTree; } export function convertTreeToCategories(categoriesTree: CategoryTreeNode[]) { const categories: Category[] = []; const categoriesMap = new Map(); const convertCategory = (node: CategoryTreeNode | undefined) => { if (!node) { return; } const children = node.children || []; categoriesMap.set(node.metadata.name, { ...node, spec: { ...node.spec, children: children.map((child) => child.metadata.name), }, }); children.forEach((child) => { convertCategory(child); }); }; categoriesTree.forEach((node) => { convertCategory(node); }); categoriesMap.forEach((node) => { categories.push(node); }); return categories; } export function convertCategoryTreeToCategory( categoryTree: CategoryTreeNode ): Category { const childNames = categoryTree.children.map((child) => child.metadata.name); const { children: _, ...categoryWithoutChildren } = categoryTree; return { ...categoryWithoutChildren, spec: { ...categoryTree.spec, children: childNames, }, }; } export const getCategoryPath = ( categories: CategoryTreeNode[], name: string, path: CategoryTreeNode[] = [] ): CategoryTreeNode[] | undefined => { for (const category of categories) { if (category.metadata && category.metadata.name === name) { return path.concat([category]); } if (category.children && category.children.length) { const found = getCategoryPath( category.children, name, path.concat([category]) ); if (found) { return found; } } } }; ================================================ FILE: ui/console-src/modules/contents/posts/components/PostBatchSettingModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/components/PostListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/components/PostSettingModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/components/__tests__/PostSettingModal.spec.ts ================================================ import messages from "@intlify/unplugin-vue-i18n/messages"; import { VueQueryPlugin } from "@tanstack/vue-query"; import { mount } from "@vue/test-utils"; import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it } from "vitest"; import { createI18n } from "vue-i18n"; import PostSettingModal from "../PostSettingModal.vue"; describe("PostSettingModal", () => { beforeEach(() => { setActivePinia(createPinia()); }); it("should render", () => { const wrapper = mount( { components: { PostSettingModal, }, template: ``, }, { global: { plugins: [ VueQueryPlugin, createI18n({ legacy: false, locale: "en", messages, }), ], }, } ); expect(wrapper).toBeDefined(); }); }); ================================================ FILE: ui/console-src/modules/contents/posts/components/entity-fields/ContributorsField.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/components/entity-fields/CoverField.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/components/entity-fields/PublishStatusField.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/components/entity-fields/PublishTimeField.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/components/entity-fields/TitleField.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/components/entity-fields/VisibleField.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/composables/use-post-update-mutate.ts ================================================ import type { Post } from "@halo-dev/api-client"; import { coreApiClient } from "@halo-dev/api-client"; import { Toast } from "@halo-dev/components"; import { useMutation } from "@tanstack/vue-query"; import { useI18n } from "vue-i18n"; export function usePostUpdateMutate() { const { t } = useI18n(); return useMutation({ mutationKey: ["post-update"], mutationFn: async (post: Post) => { const { data: latestPost } = await coreApiClient.content.post.getPost({ name: post.metadata.name, }); return await coreApiClient.content.post.updatePost( { name: post.metadata.name, post: { ...latestPost, spec: post.spec, metadata: { ...latestPost.metadata, annotations: post.metadata.annotations, }, }, }, { mute: true, } ); }, retry: 3, onError: (error) => { console.error("Failed to update post", error); Toast.error(t("core.common.toast.server_internal_error")); }, }); } ================================================ FILE: ui/console-src/modules/contents/posts/module.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import BlankLayout from "@console/layouts/BlankLayout.vue"; import { IconBookRead } from "@halo-dev/components"; import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; export default definePlugin({ routes: [ { path: "/posts", name: "PostsRoot", component: BasicLayout, meta: { title: "core.post.title", searchable: true, permissions: ["system:posts:view"], menu: { name: "core.sidebar.menu.items.posts", group: "content", icon: markRaw(IconBookRead), priority: 0, mobile: true, }, }, children: [ { path: "", name: "Posts", component: () => import("./PostList.vue"), }, { path: "deleted", name: "DeletedPosts", component: () => import("./DeletedPostList.vue"), meta: { title: "core.deleted_post.title", searchable: true, permissions: ["system:posts:view"], }, }, { path: "editor", name: "PostEditor", component: () => import("./PostEditor.vue"), meta: { title: "core.post_editor.title", searchable: true, hideFooter: true, permissions: ["system:posts:manage"], }, }, { path: "categories", component: BlankLayout, children: [ { path: "", name: "Categories", component: () => import("./categories/CategoryList.vue"), meta: { title: "core.post_category.title", searchable: true, permissions: ["system:posts:view"], }, }, ], }, { path: "tags", component: BlankLayout, children: [ { path: "", name: "Tags", component: () => import("./tags/TagList.vue"), meta: { title: "core.post_tag.title", searchable: true, permissions: ["system:posts:view"], }, }, ], }, ], }, { path: "/posts/snapshots", name: "PostSnapshots", component: () => import("./PostSnapshots.vue"), meta: { title: "core.post_snapshots.title", searchable: false, hideFooter: true, permissions: ["system:posts:manage"], }, }, ], }); ================================================ FILE: ui/console-src/modules/contents/posts/tags/TagList.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/tags/components/PostTag.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/tags/components/TagEditingModal.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/tags/components/TagListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/contents/posts/tags/composables/use-post-tag.ts ================================================ import type { Tag, TagV1alpha1ConsoleApiListPostTagsRequest, } from "@halo-dev/api-client"; import { consoleApiClient, coreApiClient, paginate, } from "@halo-dev/api-client"; import { Dialog, Toast } from "@halo-dev/components"; import { useQuery, type QueryObserverResult } from "@tanstack/vue-query"; import { ref, watch, type Ref } from "vue"; import { useI18n } from "vue-i18n"; interface usePostTagReturn { tags: Ref; total: Ref; hasPrevious: Ref; hasNext: Ref; isLoading: Ref; isFetching: Ref; handleFetchTags: () => Promise>; handleDelete: (tag: Tag) => void; handleDeleteInBatch: (tagNames: string[]) => Promise; } export function usePostTag(filterOptions?: { sort?: Ref; page?: Ref; size?: Ref; keyword?: Ref; }): usePostTagReturn { const { t } = useI18n(); const { sort, page, size, keyword } = filterOptions || {}; const total = ref(0); const hasPrevious = ref(false); const hasNext = ref(false); const { data: tags, isLoading, isFetching, refetch, } = useQuery({ queryKey: ["post-tags", sort, page, size, keyword], queryFn: async () => { const { data } = await consoleApiClient.content.tag.listPostTags({ page: page?.value || 0, size: size?.value || 0, sort: [sort?.value as string].filter(Boolean) || [ "metadata.creationTimestamp,desc", ], keyword: keyword?.value, }); total.value = data.total; hasPrevious.value = data.hasPrevious; hasNext.value = data.hasNext; return data.items; }, refetchInterval(data) { const hasAbnormalTag = data?.some( (tag) => !!tag.metadata.deletionTimestamp || !tag.status?.permalink ); return hasAbnormalTag ? 1000 : false; }, }); const handleDelete = async (tag: Tag) => { Dialog.warning({ title: t("core.post_tag.operations.delete.title"), description: t("core.post_tag.operations.delete.description"), confirmType: "danger", confirmText: t("core.common.buttons.confirm"), cancelText: t("core.common.buttons.cancel"), onConfirm: async () => { try { await coreApiClient.content.tag.deleteTag({ name: tag.metadata.name, }); Toast.success(t("core.common.toast.delete_success")); } catch (e) { console.error("Failed to delete tag", e); } finally { await refetch(); } }, }); }; const handleDeleteInBatch = (tagNames: string[]) => { return new Promise((resolve) => { Dialog.warning({ title: t("core.post_tag.operations.delete_in_batch.title"), description: t("core.common.dialog.descriptions.cannot_be_recovered"), confirmType: "danger", confirmText: t("core.common.buttons.confirm"), cancelText: t("core.common.buttons.cancel"), onConfirm: async () => { try { await Promise.all( tagNames.map((tagName) => { coreApiClient.content.tag.deleteTag({ name: tagName, }); }) ); Toast.success(t("core.common.toast.delete_success")); resolve(); } catch (e) { console.error("Failed to delete tags in batch", e); } finally { await refetch(); } }, }); }); }; watch( () => [sort?.value, keyword?.value], () => { if (page?.value) { page.value = 1; } } ); return { tags, total, hasPrevious, hasNext, isLoading, isFetching, handleFetchTags: refetch, handleDelete, handleDeleteInBatch, }; } export function useAllPostTagsQuery() { return useQuery({ queryKey: ["core:post-tags:all"], queryFn: async () => { return await paginate( (params) => consoleApiClient.content.tag.listPostTags(params), { sort: ["metadata.creationTimestamp,desc"], size: 1000, } ); }, refetchInterval(data) { const hasAbnormalTag = data?.some( (tag) => !!tag.metadata.deletionTimestamp || !tag.status?.permalink ); return hasAbnormalTag ? 1000 : false; }, }); } ================================================ FILE: ui/console-src/modules/dashboard/Dashboard.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/DashboardDesigner.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/components/ActionButton.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/components/WidgetCard.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/components/WidgetConfigFormModal.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/components/WidgetEditableItem.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/components/WidgetHubModal.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/components/WidgetViewItem.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/composables/use-dashboard-extension-point.ts ================================================ import type { DashboardWidgetDefinition } from "@halo-dev/ui-shared"; import { onMounted, shallowRef } from "vue"; import { usePluginModuleStore } from "@/stores/plugin"; const EXTENSION_POINT_NAME = "console:dashboard:widgets:create"; export function useDashboardExtensionPoint() { const { pluginModuleMap } = usePluginModuleStore(); const widgetDefinitions = shallowRef([]); onMounted(async () => { const finalDefinitions: DashboardWidgetDefinition[] = []; for (const [name, module] of Object.entries(pluginModuleMap)) { try { const callbackFunction = module?.extensionPoints?.[EXTENSION_POINT_NAME]; if (typeof callbackFunction !== "function") { continue; } const definitions = await callbackFunction(); // Reset id definitions.forEach((definition) => { definition.id = `${name}-${definition.id}`; }); finalDefinitions.push(...definitions); } catch (error) { console.error(`Error processing plugin module:`, name, error); } } widgetDefinitions.value = finalDefinitions; }); return { widgetDefinitions }; } ================================================ FILE: ui/console-src/modules/dashboard/composables/use-dashboard-widgets-fetch.ts ================================================ import { ucApiClient } from "@halo-dev/api-client"; import type { DashboardResponsiveLayout, DashboardWidget, } from "@halo-dev/ui-shared"; import { useQuery } from "@tanstack/vue-query"; import { cloneDeep } from "es-toolkit"; import { computed, ref, type Ref } from "vue"; import { DefaultResponsiveLayouts } from "../widgets/defaults"; export function useDashboardWidgetsFetch(breakpoint: Ref) { const layouts = ref({}); const layout = ref([]); const originalLayout = ref([]); const { isLoading } = useQuery({ queryKey: ["core:dashboard:widgets", breakpoint], queryFn: async () => { const { data } = await ucApiClient.user.preference.getMyPreference({ group: "dashboard-widgets", }); if (!data) { return null; } return data as DashboardResponsiveLayout; }, cacheTime: 0, onSuccess: (data) => { layouts.value = data || DefaultResponsiveLayouts; const layoutData = layouts.value[breakpoint.value] || layouts.value["lg"] || []; layout.value = layoutData; originalLayout.value = cloneDeep(layoutData); }, enabled: computed(() => !!breakpoint.value), }); return { layouts, layout, originalLayout, isLoading, }; } export function useDashboardWidgetsViewFetch(breakpoint: Ref) { return useQuery({ queryKey: ["core:dashboard:widgets:view", breakpoint], queryFn: async () => { const { data } = await ucApiClient.user.preference.getMyPreference({ group: "dashboard-widgets", }); const layouts = (data || DefaultResponsiveLayouts) as DashboardResponsiveLayout; return { layouts, layout: layouts[breakpoint.value] || layouts.lg || [], }; }, enabled: computed(() => !!breakpoint.value), }); } ================================================ FILE: ui/console-src/modules/dashboard/module.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import { IconDashboard } from "@halo-dev/components"; import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; import WidgetCard from "./components/WidgetCard.vue"; declare module "vue" { interface GlobalComponents { WidgetCard: (typeof import("./components/WidgetCard.vue"))["default"]; } } export default definePlugin({ components: { WidgetCard, }, routes: [ { path: "/", component: BasicLayout, name: "Root", redirect: "/dashboard", children: [ { path: "dashboard", name: "Dashboard", component: () => import("./Dashboard.vue"), meta: { title: "core.dashboard.title", searchable: true, menu: { name: "core.sidebar.menu.items.dashboard", group: "dashboard", icon: markRaw(IconDashboard), priority: 0, mobile: true, }, }, }, { path: "dashboard/designer", name: "DashboardDesigner", component: () => import("./DashboardDesigner.vue"), meta: { title: "core.dashboard_designer.title", searchable: false, }, }, ], }, ], }); ================================================ FILE: ui/console-src/modules/dashboard/styles/dashboard.css ================================================ .vue-grid-layout { @apply -m-[10px]; } .vue-grid-item { transition: none !important; } .vue-grid-item.vue-grid-placeholder { @apply bg-gray-200 !important; @apply opacity-100 !important; } ================================================ FILE: ui/console-src/modules/dashboard/widgets/defaults.ts ================================================ import type { DashboardResponsiveLayout } from "@halo-dev/ui-shared"; const DefaultResponsiveLayouts: DashboardResponsiveLayout = { lg: [ { i: "79187a8d-1c35-497f-a55e-05c0a6258660", x: 0, y: 0, w: 3, h: 3, minW: 2, minH: 2, id: "core:post:stats", config: { enable_animation: true, }, }, { i: "96a9ac85-ec47-4c4b-bd02-184ee047f6a5", x: 6, y: 0, w: 3, h: 3, minW: 2, minH: 2, id: "core:comment:stats", config: { enable_animation: true, }, }, { i: "ee42743e-06d8-4106-9481-42443bb8b2f0", x: 3, y: 0, w: 3, h: 3, minW: 2, minH: 2, id: "core:user:stats", config: { enable_animation: true, }, }, { i: "19a92835-c12d-486b-9546-f16bd7a7da98", x: 9, y: 0, w: 3, h: 3, minW: 2, minH: 2, id: "core:view:stats", config: { enable_animation: true, }, }, { i: "e45cd079-a406-42de-a196-e11c64f6d893", x: 0, y: 3, w: 6, h: 12, minW: 3, minH: 6, id: "core:quick-action", config: { enabled_items: [ "core:user-center", "core:theme-preview", "core:new-post", "core:new-page", "core:upload-attachment", "core:theme-manage", "core:plugin-manage", "core:new-user", "core:refresh-search-engine", ], }, }, { i: "3bcd12eb-44ee-48e8-a37f-3f5d73470e2d", x: 6, y: 3, w: 6, h: 12, minW: 3, minH: 6, id: "core:notifications", config: {}, }, ], xs: [ { i: "2026e492-0832-4a27-b44b-ab076e31ec17", x: 0, y: 0, w: 2, h: 3, minW: 2, minH: 2, id: "core:post:stats", config: { enable_animation: true, }, }, { i: "7c868acd-10e8-4e92-99f3-ff00b4aa4ec4", x: 2, y: 0, w: 2, h: 3, minW: 2, minH: 2, id: "core:user:stats", config: { enable_animation: true, }, }, { i: "90b458dc-679e-4b59-b0a9-86b02cf6ae59", x: 0, y: 3, w: 2, h: 3, minW: 2, minH: 2, id: "core:comment:stats", config: { enable_animation: true, }, }, { i: "bb71aa3a-1170-4e1d-a0f3-290b4ca77ca1", x: 2, y: 3, w: 2, h: 3, minW: 2, minH: 2, id: "core:view:stats", config: { enable_animation: true, }, }, { i: "c3e467a7-9a15-4732-9ba1-45f36a49d25e", x: 0, y: 6, w: 6, h: 12, minW: 3, minH: 6, id: "core:quick-action", config: { enabled_items: [ "core:user-center", "core:theme-preview", "core:new-post", "core:new-page", "core:upload-attachment", "core:theme-manage", "core:plugin-manage", "core:new-user", "core:refresh-search-engine", ], }, }, { i: "24005fc4-e725-4e6d-95ae-f83026ffc178", x: 0, y: 18, w: 6, h: 12, minW: 3, minH: 6, id: "core:notifications", config: {}, }, ], xxs: [ { i: "2026e492-0832-4a27-b44b-ab076e31ec17", x: 0, y: 0, w: 2, h: 3, minW: 2, minH: 2, id: "core:post:stats", config: { enable_animation: true, }, }, { i: "7c868acd-10e8-4e92-99f3-ff00b4aa4ec4", x: 2, y: 0, w: 2, h: 3, minW: 2, minH: 2, id: "core:user:stats", config: { enable_animation: true, }, }, { i: "90b458dc-679e-4b59-b0a9-86b02cf6ae59", x: 0, y: 3, w: 2, h: 3, minW: 2, minH: 2, id: "core:comment:stats", config: { enable_animation: true, }, }, { i: "bb71aa3a-1170-4e1d-a0f3-290b4ca77ca1", x: 2, y: 3, w: 2, h: 3, minW: 2, minH: 2, id: "core:view:stats", config: { enable_animation: true, }, }, { i: "c3e467a7-9a15-4732-9ba1-45f36a49d25e", x: 0, y: 6, w: 6, h: 12, minW: 3, minH: 6, id: "core:quick-action", config: { enabled_items: [ "core:user-center", "core:theme-preview", "core:new-post", "core:new-page", "core:upload-attachment", "core:theme-manage", "core:plugin-manage", "core:new-user", "core:refresh-search-engine", ], }, }, { i: "24005fc4-e725-4e6d-95ae-f83026ffc178", x: 0, y: 18, w: 6, h: 12, minW: 3, minH: 6, id: "core:notifications", config: {}, }, ], md: [ { i: "d86c4b16-9cf5-4d1e-9f46-fc79693c76f2", x: 0, y: 0, w: 3, h: 3, minW: 2, minH: 2, id: "core:post:stats", config: { enable_animation: true, }, }, { i: "31a29ecc-e2d6-449c-9850-994e2fd6d9c6", x: 6, y: 0, w: 3, h: 3, minW: 2, minH: 2, id: "core:comment:stats", config: { enable_animation: true, }, }, { i: "b3cc217f-5008-49bd-b6a3-b760c1afc4f9", x: 3, y: 0, w: 3, h: 3, minW: 2, minH: 2, id: "core:user:stats", config: { enable_animation: true, }, }, { i: "b7afa21f-c24b-4fa2-9c98-de06e3c48687", x: 9, y: 0, w: 3, h: 3, minW: 2, minH: 2, id: "core:view:stats", config: { enable_animation: true, }, }, { i: "1f329a9f-d1aa-4337-98b3-34d8732b9143", x: 0, y: 3, w: 6, h: 12, minW: 3, minH: 6, id: "core:quick-action", config: { enabled_items: [ "core:user-center", "core:theme-preview", "core:new-post", "core:new-page", "core:upload-attachment", "core:theme-manage", "core:plugin-manage", "core:new-user", "core:refresh-search-engine", ], }, }, { i: "ac9a9984-da16-4d81-adc6-8c64ffcbde5e", x: 6, y: 3, w: 6, h: 12, minW: 3, minH: 6, id: "core:notifications", config: {}, }, ], sm: [ { i: "61298416-1b09-426d-914c-ba7664825626", x: 0, y: 0, w: 3, h: 3, minW: 2, minH: 2, id: "core:post:stats", config: { enable_animation: true, }, }, { i: "44932f76-7667-4b77-99e7-9778c8f54062", x: 0, y: 3, w: 3, h: 3, minW: 2, minH: 2, id: "core:comment:stats", config: { enable_animation: true, }, }, { i: "d6508199-b7a3-47dc-aabf-4e3634760b78", x: 3, y: 0, w: 3, h: 3, minW: 2, minH: 2, id: "core:user:stats", config: { enable_animation: true, }, }, { i: "3525a65d-2c69-4c0f-a378-6c9f1f06ad4c", x: 3, y: 3, w: 3, h: 3, minW: 2, minH: 2, id: "core:view:stats", config: { enable_animation: true, }, }, { i: "1bf7eba4-2805-495b-85cd-ebd9ecd9ab82", x: 0, y: 6, w: 6, h: 12, minW: 3, minH: 6, id: "core:quick-action", config: { enabled_items: [ "core:user-center", "core:theme-preview", "core:new-post", "core:new-page", "core:upload-attachment", "core:theme-manage", "core:plugin-manage", "core:new-user", "core:refresh-search-engine", ], }, }, { i: "06dc350c-db0e-46ea-9718-cef1e5bca339", x: 0, y: 18, w: 6, h: 12, minW: 3, minH: 6, id: "core:notifications", config: {}, }, ], }; export { DefaultResponsiveLayouts }; ================================================ FILE: ui/console-src/modules/dashboard/widgets/index.ts ================================================ import type { DashboardWidgetDefinition } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; import { i18n } from "@/locales"; import CommentStatsWidget from "./presets/comments/CommentStatsWidget.vue"; import PendingCommentsWidget from "./presets/comments/PendingCommentsWidget.vue"; import IframeWidget from "./presets/core/iframe/IframeWidget.vue"; import QuickActionWidget from "./presets/core/quick-action/QuickActionWidget.vue"; import StackWidget from "./presets/core/stack/StackWidget.vue"; import UpvotesStatsWidget from "./presets/core/upvotes-stats/UpvotesStatsWidget.vue"; import ViewsStatsWidget from "./presets/core/view-stats/ViewsStatsWidget.vue"; import PostStatsWidget from "./presets/posts/PostStatsWidget.vue"; import RecentPublishedWidget from "./presets/posts/RecentPublishedWidget.vue"; import SinglePageStatsWidget from "./presets/single-pages/SinglePageStatsWidget.vue"; import NotificationWidget from "./presets/users/NotificationWidget.vue"; import UserStatsWidget from "./presets/users/UserStatsWidget.vue"; export const internalWidgetDefinitions: DashboardWidgetDefinition[] = [ { id: "core:post:stats", component: markRaw(PostStatsWidget), group: "core.dashboard.widgets.groups.post", configFormKitSchema: () => [ { $formkit: "checkbox", label: i18n.global.t( "core.dashboard.widgets.common_form.fields.enable_animation.label" ), name: "enable_animation", }, ], defaultConfig: { enable_animation: true, }, defaultSize: { w: 3, h: 3, minH: 2, minW: 2, }, }, { id: "core:post:recent-published", component: markRaw(RecentPublishedWidget), group: "core.dashboard.widgets.groups.post", defaultConfig: {}, defaultSize: { w: 6, h: 12, minH: 6, minW: 3, }, permissions: ["system:posts:view"], }, { id: "core:singlepage:stats", component: markRaw(SinglePageStatsWidget), group: "core.dashboard.widgets.groups.page", configFormKitSchema: () => [ { $formkit: "checkbox", label: i18n.global.t( "core.dashboard.widgets.common_form.fields.enable_animation.label" ), name: "enable_animation", }, ], defaultConfig: { enable_animation: true, }, defaultSize: { w: 3, h: 3, minH: 2, minW: 2, }, permissions: ["system:singlepages:view"], }, { id: "core:comment:stats", component: markRaw(CommentStatsWidget), group: "core.dashboard.widgets.groups.comment", configFormKitSchema: () => [ { $formkit: "checkbox", label: i18n.global.t( "core.dashboard.widgets.common_form.fields.enable_animation.label" ), name: "enable_animation", }, ], defaultConfig: { enable_animation: true, }, defaultSize: { w: 3, h: 3, minH: 2, minW: 2, }, permissions: [], }, { id: "core:comment:pending", component: markRaw(PendingCommentsWidget), group: "core.dashboard.widgets.groups.comment", defaultConfig: {}, defaultSize: { w: 6, h: 12, minH: 6, minW: 3, }, permissions: ["system:comments:view"], }, { id: "core:user:stats", component: markRaw(UserStatsWidget), group: "core.dashboard.widgets.groups.user", configFormKitSchema: () => [ { $formkit: "checkbox", label: i18n.global.t( "core.dashboard.widgets.common_form.fields.enable_animation.label" ), name: "enable_animation", }, ], defaultConfig: { enable_animation: true, }, defaultSize: { w: 3, h: 3, minH: 2, minW: 2, }, }, { id: "core:view:stats", component: markRaw(ViewsStatsWidget), group: "core.dashboard.widgets.groups.other", configFormKitSchema: () => [ { $formkit: "checkbox", label: i18n.global.t( "core.dashboard.widgets.common_form.fields.enable_animation.label" ), name: "enable_animation", }, ], defaultConfig: { enable_animation: true, }, defaultSize: { w: 3, h: 3, minH: 2, minW: 2, }, }, { id: "core:upvotes:stats", component: markRaw(UpvotesStatsWidget), group: "core.dashboard.widgets.groups.other", configFormKitSchema: () => [ { $formkit: "checkbox", label: i18n.global.t( "core.dashboard.widgets.common_form.fields.enable_animation.label" ), name: "enable_animation", }, ], defaultConfig: { enable_animation: true, }, defaultSize: { w: 3, h: 3, minH: 2, minW: 2, }, }, { id: "core:quick-action", component: markRaw(QuickActionWidget), group: "core.dashboard.widgets.groups.other", defaultConfig: { enabled_items: [ "core:user-center", "core:theme-preview", "core:new-post", "core:new-page", "core:upload-attachment", "core:theme-manage", "core:plugin-manage", "core:new-user", "core:refresh-search-engine", ], }, defaultSize: { w: 6, h: 12, minH: 6, minW: 3, }, }, { id: "core:notifications", component: markRaw(NotificationWidget), group: "core.dashboard.widgets.groups.other", defaultConfig: {}, defaultSize: { w: 6, h: 12, minH: 6, minW: 3, }, }, { id: "core:stack", component: markRaw(StackWidget), group: "core.dashboard.widgets.groups.other", defaultConfig: {}, defaultSize: { w: 6, h: 12, minH: 1, minW: 1, }, }, { id: "core:iframe", component: markRaw(IframeWidget), group: "core.dashboard.widgets.groups.other", configFormKitSchema: () => [ { $formkit: "text", label: i18n.global.t( "core.dashboard.widgets.presets.iframe.config.fields.title.label" ), name: "title", }, { $formkit: "url", label: "URL", name: "url", validation: "required|url", }, ], defaultConfig: {}, defaultSize: { w: 6, h: 12, minH: 2, minW: 2, }, }, ]; ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/comments/CommentItem.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/comments/CommentStatsWidget.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/comments/PendingCommentsWidget.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/core/iframe/IframeWidget.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/core/quick-action/QuickActionItem.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/core/quick-action/QuickActionWidget.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/core/quick-action/ThemePreviewItem.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/core/quick-action/composables/use-dashboard-extension-point.ts ================================================ import type { DashboardWidgetQuickActionItem } from "@halo-dev/ui-shared"; import { onMounted, shallowRef } from "vue"; import { usePluginModuleStore } from "@/stores/plugin"; const EXTENSION_POINT_NAME = "console:dashboard:widgets:internal:quick-action:item:create"; export function useDashboardQuickActionExtensionPoint() { const { pluginModuleMap } = usePluginModuleStore(); const quickActionItems = shallowRef([]); onMounted(async () => { const result: DashboardWidgetQuickActionItem[] = []; for (const [name, module] of Object.entries(pluginModuleMap)) { try { const callbackFunction = module?.extensionPoints?.[EXTENSION_POINT_NAME]; if (typeof callbackFunction !== "function") { continue; } const definitions = await callbackFunction(); // Reset id definitions.forEach((definition) => { definition.id = `${name}-${definition.id}`; }); result.push(...definitions); } catch (error) { console.error(`Error processing plugin module:`, name, error); } } quickActionItems.value = result; }); return { quickActionItems }; } ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/core/stack/StackWidget.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/core/stack/StackWidgetConfigModal.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/core/stack/components/IndexIndicator.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/core/stack/components/WidgetEditableItem.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/core/stack/components/WidgetViewItem.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/core/stack/types.ts ================================================ export interface SimpleWidget { i: string; id: string; config?: Record; } export interface StackWidgetConfig { auto_play?: boolean; auto_play_interval?: number; widgets: SimpleWidget[]; } ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/core/upvotes-stats/UpvotesStatsWidget.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/core/view-stats/ViewsStatsWidget.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/posts/PostStatsWidget.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/posts/RecentPublishedWidget.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/posts/components/PostListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/single-pages/SinglePageStatsWidget.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/users/NotificationWidget.vue ================================================ ================================================ FILE: ui/console-src/modules/dashboard/widgets/presets/users/UserStatsWidget.vue ================================================ ================================================ FILE: ui/console-src/modules/index.ts ================================================ import type { PluginModule } from "@halo-dev/ui-shared"; const modules = import.meta.glob("./**/module.ts", { eager: true, import: "default", }) as Record; export default modules; ================================================ FILE: ui/console-src/modules/interface/menus/Menus.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/menus/components/MenuEditingModal.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/menus/components/MenuItemEditingModal.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/menus/components/MenuList.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/menus/module.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import { IconListSettings } from "@halo-dev/components"; import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; export default definePlugin({ components: {}, routes: [ { path: "/menus", name: "MenusRoot", component: BasicLayout, meta: { title: "core.menu.title", searchable: true, permissions: ["system:menus:view"], menu: { name: "core.sidebar.menu.items.menus", group: "interface", icon: markRaw(IconListSettings), priority: 1, }, }, children: [ { path: "", name: "Menus", component: () => import("./Menus.vue"), }, ], }, ], }); ================================================ FILE: ui/console-src/modules/interface/menus/utils/__tests__/__snapshots__/index.spec.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`buildMenuItemsTree > should match snapshot 1`] = ` [ { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "411a3639-bf0d-4266-9cfb-14184259dab5", "version": 1, }, "spec": { "children": [], "displayName": "首页", "href": "https://ryanc.cc/", "priority": 0, }, }, { "apiVersion": "v1alpha1", "children": [ { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-07-28T06:50:32.777556Z", "name": "caeef383-3828-4039-9114-6f9ad3b4a37e", "version": 4, }, "spec": { "children": [], "displayName": "Halo", "href": "https://ryanc.cc/categories/halo", "priority": 0, }, }, { "apiVersion": "v1alpha1", "children": [ { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", "version": 1, }, "spec": { "children": [], "displayName": "Spring Boot", "href": "https://ryanc.cc/categories/spring-boot", "priority": 0, }, }, ], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "ded1943d-9fdb-4563-83ee-2f04364872e0", "version": 1, }, "spec": { "children": [ "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", ], "displayName": "Java", "href": "https://ryanc.cc/categories/java", "priority": 1, }, }, ], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:19:37.252228Z", "name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564", "version": 12, }, "spec": { "children": [ "caeef383-3828-4039-9114-6f9ad3b4a37e", "ded1943d-9fdb-4563-83ee-2f04364872e0", ], "displayName": "文章分类", "href": "https://ryanc.cc/categories", "priority": 1, }, }, ] `; exports[`convertMenuTreeItemToMenuItem > should handle node with empty children 1`] = ` { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-01-01T00:00:00Z", "name": "test", "version": 1, }, "spec": { "children": [], "displayName": "test", "href": "#", "priority": 0, }, } `; exports[`convertMenuTreeItemToMenuItem > should match snapshot 1`] = ` { "apiVersion": "v1alpha1", "children": [ { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-07-28T06:50:32.777556Z", "name": "caeef383-3828-4039-9114-6f9ad3b4a37e", "version": 4, }, "spec": { "children": [], "displayName": "Halo", "href": "https://ryanc.cc/categories/halo", "priority": 0, }, }, { "apiVersion": "v1alpha1", "children": [ { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", "version": 1, }, "spec": { "children": [], "displayName": "Spring Boot", "href": "https://ryanc.cc/categories/spring-boot", "priority": 0, }, }, ], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "ded1943d-9fdb-4563-83ee-2f04364872e0", "version": 1, }, "spec": { "children": [ "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", ], "displayName": "Java", "href": "https://ryanc.cc/categories/java", "priority": 1, }, }, ], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:19:37.252228Z", "name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564", "version": 12, }, "spec": { "children": [ "caeef383-3828-4039-9114-6f9ad3b4a37e", "ded1943d-9fdb-4563-83ee-2f04364872e0", ], "displayName": "文章分类", "href": "https://ryanc.cc/categories", "priority": 1, }, } `; exports[`convertMenuTreeItemToMenuItem > should match snapshot 2`] = ` { "apiVersion": "v1alpha1", "children": [ { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", "version": 1, }, "spec": { "children": [], "displayName": "Spring Boot", "href": "https://ryanc.cc/categories/spring-boot", "priority": 0, }, }, ], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "ded1943d-9fdb-4563-83ee-2f04364872e0", "version": 1, }, "spec": { "children": [ "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", ], "displayName": "Java", "href": "https://ryanc.cc/categories/java", "priority": 1, }, } `; exports[`convertTreeToMenuItems > will match snapshot 1`] = ` [ { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "411a3639-bf0d-4266-9cfb-14184259dab5", "version": 1, }, "spec": { "children": [], "displayName": "首页", "href": "https://ryanc.cc/", "priority": 0, }, }, { "apiVersion": "v1alpha1", "children": [ { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-07-28T06:50:32.777556Z", "name": "caeef383-3828-4039-9114-6f9ad3b4a37e", "version": 4, }, "spec": { "children": [], "displayName": "Halo", "href": "https://ryanc.cc/categories/halo", "priority": 0, }, }, { "apiVersion": "v1alpha1", "children": [ { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", "version": 1, }, "spec": { "children": [], "displayName": "Spring Boot", "href": "https://ryanc.cc/categories/spring-boot", "priority": 0, }, }, ], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "ded1943d-9fdb-4563-83ee-2f04364872e0", "version": 1, }, "spec": { "children": [ "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", ], "displayName": "Java", "href": "https://ryanc.cc/categories/java", "priority": 1, }, }, ], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:19:37.252228Z", "name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564", "version": 12, }, "spec": { "children": [ "caeef383-3828-4039-9114-6f9ad3b4a37e", "ded1943d-9fdb-4563-83ee-2f04364872e0", ], "displayName": "文章分类", "href": "https://ryanc.cc/categories", "priority": 1, }, }, { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-07-28T06:50:32.777556Z", "name": "caeef383-3828-4039-9114-6f9ad3b4a37e", "version": 4, }, "spec": { "children": [], "displayName": "Halo", "href": "https://ryanc.cc/categories/halo", "priority": 0, }, }, { "apiVersion": "v1alpha1", "children": [ { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", "version": 1, }, "spec": { "children": [], "displayName": "Spring Boot", "href": "https://ryanc.cc/categories/spring-boot", "priority": 0, }, }, ], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "ded1943d-9fdb-4563-83ee-2f04364872e0", "version": 1, }, "spec": { "children": [ "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", ], "displayName": "Java", "href": "https://ryanc.cc/categories/java", "priority": 1, }, }, { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", "version": 1, }, "spec": { "children": [], "displayName": "Spring Boot", "href": "https://ryanc.cc/categories/spring-boot", "priority": 0, }, }, ] `; exports[`resetMenuItemsTreePriority > should match snapshot 1`] = ` [ { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "411a3639-bf0d-4266-9cfb-14184259dab5", "version": 1, }, "spec": { "children": [], "displayName": "首页", "href": "https://ryanc.cc/", "priority": 0, }, }, { "apiVersion": "v1alpha1", "children": [ { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-07-28T06:50:32.777556Z", "name": "caeef383-3828-4039-9114-6f9ad3b4a37e", "version": 4, }, "spec": { "children": [], "displayName": "Halo", "href": "https://ryanc.cc/categories/halo", "priority": 0, }, }, { "apiVersion": "v1alpha1", "children": [ { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", "version": 1, }, "spec": { "children": [], "displayName": "Spring Boot", "href": "https://ryanc.cc/categories/spring-boot", "priority": 0, }, }, ], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "ded1943d-9fdb-4563-83ee-2f04364872e0", "version": 1, }, "spec": { "children": [ "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", ], "displayName": "Java", "href": "https://ryanc.cc/categories/java", "priority": 1, }, }, ], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:19:37.252228Z", "name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564", "version": 12, }, "spec": { "children": [ "caeef383-3828-4039-9114-6f9ad3b4a37e", "ded1943d-9fdb-4563-83ee-2f04364872e0", ], "displayName": "文章分类", "href": "https://ryanc.cc/categories", "priority": 1, }, }, ] `; exports[`sortMenuItemsTree > will match snapshot 1`] = ` [ { "apiVersion": "v1alpha1", "children": [ { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:22:03.377364Z", "name": "ded1943d-9fdb-4563-83ee-2f04364872e0", "version": 0, }, "spec": { "displayName": "Java", "href": "https://ryanc.cc/categories/java", "priority": 0, }, }, { "apiVersion": "v1alpha1", "children": [], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-07-28T06:50:32.777556Z", "name": "caeef383-3828-4039-9114-6f9ad3b4a37e", "version": 4, }, "spec": { "displayName": "Halo", "href": "https://ryanc.cc/categories/halo", "priority": 1, }, }, ], "kind": "MenuItem", "metadata": { "creationTimestamp": "2022-08-05T04:19:37.252228Z", "name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564", "version": 12, }, "spec": { "displayName": "文章分类", "href": "https://ryanc.cc/categories", "priority": 0, }, }, ] `; ================================================ FILE: ui/console-src/modules/interface/menus/utils/__tests__/index.spec.ts ================================================ import type { MenuItem } from "@halo-dev/api-client"; import { describe, expect, it } from "vitest"; import type { MenuTreeItem } from "../index"; import { buildMenuItemsTree, convertMenuTreeItemToMenuItem, convertTreeToMenuItems, getChildrenNames, resetMenuItemsTreePriority, sortMenuItemsTree, } from "../index"; const rawMenuItems: MenuItem[] = [ { spec: { displayName: "文章分类", href: "https://ryanc.cc/categories", children: [ "caeef383-3828-4039-9114-6f9ad3b4a37e", "ded1943d-9fdb-4563-83ee-2f04364872e0", ], priority: 1, }, apiVersion: "v1alpha1", kind: "MenuItem", metadata: { name: "40e4ba86-5c7e-43c2-b4c0-cee376d26564", version: 12, creationTimestamp: "2022-08-05T04:19:37.252228Z", }, }, { spec: { displayName: "Halo", href: "https://ryanc.cc/categories/halo", children: [], priority: 0, }, apiVersion: "v1alpha1", kind: "MenuItem", metadata: { name: "caeef383-3828-4039-9114-6f9ad3b4a37e", version: 4, creationTimestamp: "2022-07-28T06:50:32.777556Z", }, }, { spec: { displayName: "Java", href: "https://ryanc.cc/categories/java", children: ["96b636bb-3e4a-44d1-8ea7-f9da9e876f45"], priority: 1, }, apiVersion: "v1alpha1", kind: "MenuItem", metadata: { name: "ded1943d-9fdb-4563-83ee-2f04364872e0", version: 1, creationTimestamp: "2022-08-05T04:22:03.377364Z", }, }, { spec: { displayName: "Spring Boot", href: "https://ryanc.cc/categories/spring-boot", children: [], priority: 0, }, apiVersion: "v1alpha1", kind: "MenuItem", metadata: { name: "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", version: 1, creationTimestamp: "2022-08-05T04:22:03.377364Z", }, }, { spec: { displayName: "首页", href: "https://ryanc.cc/", children: [], priority: 0, }, apiVersion: "v1alpha1", kind: "MenuItem", metadata: { name: "411a3639-bf0d-4266-9cfb-14184259dab5", version: 1, creationTimestamp: "2022-08-05T04:22:03.377364Z", }, }, ]; describe("buildMenuItemsTree", () => { it("should match snapshot", () => { const tree = buildMenuItemsTree(rawMenuItems); expect(tree).toMatchSnapshot(); }); it("should be sorted correctly and children at top level", () => { const menuItems = buildMenuItemsTree(rawMenuItems); expect(menuItems[0].spec.priority).toBe(0); expect(menuItems[1].spec.priority).toBe(1); expect(menuItems[1].children[0].spec.priority).toBe(0); expect(menuItems[1].children[1].spec.priority).toBe(1); expect(menuItems[1].children[1].children[0].spec.priority).toBe(0); expect(menuItems[0].spec.displayName).toBe("首页"); expect(menuItems[1].spec.displayName).toBe("文章分类"); expect(menuItems[1].children[0].spec.displayName).toBe("Halo"); expect(menuItems[1].children[1].spec.displayName).toBe("Java"); expect(menuItems[1].children[1].children[0].spec.displayName).toBe( "Spring Boot" ); }); it("should handle empty input", () => { expect(buildMenuItemsTree([])).toEqual([]); }); }); describe("convertTreeToMenuItems", () => { it("will match snapshot", function () { const menuTreeItems = buildMenuItemsTree(rawMenuItems); expect(convertTreeToMenuItems(menuTreeItems)).toMatchSnapshot(); }); it("should handle empty input", () => { expect(convertTreeToMenuItems([])).toEqual([]); }); }); describe("sortMenuItemsTree", () => { it("will match snapshot", () => { const tree: MenuTreeItem[] = [ { apiVersion: "v1alpha1", kind: "MenuItem", metadata: { creationTimestamp: "2022-08-05T04:19:37.252228Z", name: "40e4ba86-5c7e-43c2-b4c0-cee376d26564", version: 12, }, spec: { priority: 0, displayName: "文章分类", href: "https://ryanc.cc/categories", }, children: [ { apiVersion: "v1alpha1", kind: "MenuItem", metadata: { creationTimestamp: "2022-07-28T06:50:32.777556Z", name: "caeef383-3828-4039-9114-6f9ad3b4a37e", version: 4, }, spec: { priority: 1, displayName: "Halo", href: "https://ryanc.cc/categories/halo", }, children: [], }, { apiVersion: "v1alpha1", kind: "MenuItem", metadata: { creationTimestamp: "2022-08-05T04:22:03.377364Z", name: "ded1943d-9fdb-4563-83ee-2f04364872e0", version: 0, }, spec: { priority: 0, displayName: "Java", href: "https://ryanc.cc/categories/java", }, children: [], }, ], }, ]; expect(sortMenuItemsTree(tree)).toMatchSnapshot(); }); }); describe("resetMenuItemsTreePriority", () => { it("should match snapshot", function () { expect( resetMenuItemsTreePriority(buildMenuItemsTree(rawMenuItems)) ).toMatchSnapshot(); }); }); describe("getChildrenNames", () => { it("should return correct names", () => { const menuTreeItems = buildMenuItemsTree(rawMenuItems); expect(getChildrenNames(menuTreeItems[0])).toEqual([]); expect(getChildrenNames(menuTreeItems[1])).toEqual([ "caeef383-3828-4039-9114-6f9ad3b4a37e", "ded1943d-9fdb-4563-83ee-2f04364872e0", "96b636bb-3e4a-44d1-8ea7-f9da9e876f45", ]); expect(getChildrenNames(menuTreeItems[1].children[0])).toEqual([]); }); it("should handle empty children", () => { const node: MenuTreeItem = { apiVersion: "v1alpha1", kind: "MenuItem", metadata: { name: "test", version: 1, creationTimestamp: "2022-01-01T00:00:00Z", }, spec: { displayName: "test", href: "#", children: [], priority: 0, }, children: [], }; expect(getChildrenNames(node)).toEqual([]); }); }); describe("convertMenuTreeItemToMenuItem", () => { it("should match snapshot", () => { const menuTreeItems = buildMenuItemsTree(rawMenuItems); expect(convertMenuTreeItemToMenuItem(menuTreeItems[1])).toMatchSnapshot(); expect( convertMenuTreeItemToMenuItem(menuTreeItems[1].children[1]) ).toMatchSnapshot(); }); it("should return correct MenuItem", () => { const menuTreeItems = buildMenuItemsTree(rawMenuItems); expect( convertMenuTreeItemToMenuItem(menuTreeItems[1]).spec.displayName ).toBe("文章分类"); expect( convertMenuTreeItemToMenuItem(menuTreeItems[1]).spec.children ).toStrictEqual([ "caeef383-3828-4039-9114-6f9ad3b4a37e", "ded1943d-9fdb-4563-83ee-2f04364872e0", ]); }); it("should handle node with empty children", () => { const node: MenuTreeItem = { apiVersion: "v1alpha1", kind: "MenuItem", metadata: { name: "test", version: 1, creationTimestamp: "2022-01-01T00:00:00Z", }, spec: { displayName: "test", href: "#", children: [], priority: 0, }, children: [], }; expect(convertMenuTreeItemToMenuItem(node)).toMatchSnapshot(); }); }); ================================================ FILE: ui/console-src/modules/interface/menus/utils/index.ts ================================================ import type { MenuItem } from "@halo-dev/api-client"; import { cloneDeep } from "es-toolkit"; export interface MenuTreeItem extends MenuItem { children: MenuTreeItem[]; } /** * Convert a flat array of menu items into a menu tree with children at the top level. * * @param menuItems */ export function buildMenuItemsTree(menuItems: MenuItem[]): MenuTreeItem[] { const menuItemsToUpdate = cloneDeep(menuItems); const menuItemsMap: Record = {}; const parentMap: Record = {}; menuItemsToUpdate.forEach((menuItem) => { menuItemsMap[menuItem.metadata.name] = { ...menuItem, children: [], }; (menuItem.spec.children as string[]).forEach((child) => { parentMap[child] = menuItem.metadata.name; }); }); Object.values(menuItemsMap).forEach((menuTreeItem) => { const parentName = parentMap[menuTreeItem.metadata.name]; if (parentName && menuItemsMap[parentName]) { menuItemsMap[parentName].children.push(menuTreeItem); } }); const menuTreeItems = Object.values(menuItemsMap).filter( (node) => parentMap[node.metadata.name] === undefined ); return sortMenuItemsTree(menuTreeItems); } /** * Sort a menu tree by priority. * * @param menuTreeItems */ export function sortMenuItemsTree( menuTreeItems: MenuTreeItem[] ): MenuTreeItem[] { return menuTreeItems .sort((a, b) => { const aPriority = a.spec.priority ?? 0; const bPriority = b.spec.priority ?? 0; if (aPriority < bPriority) { return -1; } if (aPriority > bPriority) { return 1; } return 0; }) .map((menuTreeItem) => { if (menuTreeItem.children.length) { return { ...menuTreeItem, children: sortMenuItemsTree(menuTreeItem.children), }; } return menuTreeItem; }); } /** * Reset the menu tree item's priority. * * @param menuItems */ export function resetMenuItemsTreePriority( menuItems: MenuTreeItem[] ): MenuTreeItem[] { for (let i = 0; i < menuItems.length; i++) { menuItems[i].spec.priority = i; if (menuItems[i].children) { resetMenuItemsTreePriority(menuItems[i].children); } } return menuItems; } /** * Convert a menu tree items into a flat array of menu. * * @param menuTreeItems */ export function convertTreeToMenuItems(menuTreeItems: MenuTreeItem[]) { const menuItems: MenuItem[] = []; const menuItemsMap = new Map(); const convertMenuItem = (node: MenuTreeItem | undefined) => { if (!node) { return; } const children = node.children || []; const { ...rest } = node; menuItemsMap.set(node.metadata.name, { ...rest, spec: { ...node.spec, children: children.map((child) => child.metadata.name), }, }); children.forEach((child) => { convertMenuItem(child); }); }; menuTreeItems.forEach((node) => { convertMenuItem(node); }); menuItemsMap.forEach((node) => { menuItems.push(node); }); return menuItems; } export function getChildrenNames(menuTreeItem: MenuTreeItem): string[] { const childrenNames: string[] = []; function getChildrenNamesRecursive(menuTreeItem: MenuTreeItem) { if (menuTreeItem.children) { menuTreeItem.children.forEach((child) => { childrenNames.push(child.metadata.name); getChildrenNamesRecursive(child); }); } } getChildrenNamesRecursive(menuTreeItem); return childrenNames; } /** * Convert {@link MenuTreeItem} to {@link MenuItem} with flat children name array. * * @param menuTreeItem */ export function convertMenuTreeItemToMenuItem( menuTreeItem: MenuTreeItem ): MenuItem { const childNames = (menuTreeItem.children || []).map( (child) => child.metadata.name ); const { ...rest } = menuTreeItem; return { ...rest, spec: { ...menuTreeItem.spec, children: childNames, }, }; } ================================================ FILE: ui/console-src/modules/interface/themes/ThemeDetail.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/themes/ThemeSetting.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/themes/components/ThemeListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/themes/components/ThemeListModal.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/themes/components/list-tabs/InstalledThemes.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/themes/components/list-tabs/LocalUpload.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/themes/components/list-tabs/NotInstalledThemes.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/themes/components/list-tabs/RemoteDownload.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/themes/components/operation/MoreOperationItem.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/themes/components/operation/UninstallOperationItem.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/themes/components/preview/ThemePreviewListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/themes/components/preview/ThemePreviewModal.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/themes/composables/use-theme.ts ================================================ import { useThemeStore } from "@console/stores/theme"; import type { Theme } from "@halo-dev/api-client"; import { consoleApiClient } from "@halo-dev/api-client"; import { Dialog, Toast } from "@halo-dev/components"; import { useFileDialog } from "@vueuse/core"; import { merge } from "es-toolkit"; import { storeToRefs } from "pinia"; import type { ComputedRef, Ref } from "vue"; import { computed, ref } from "vue"; import { useI18n } from "vue-i18n"; interface useThemeLifeCycleReturn { loading: Ref; isActivated: ComputedRef; getFailedMessage: () => string | undefined; handleActiveTheme: (reload?: boolean) => void; handleResetSettingConfig: () => void; } export function useThemeLifeCycle( theme: Ref ): useThemeLifeCycleReturn { const { t } = useI18n(); const loading = ref(false); const themeStore = useThemeStore(); const { activatedTheme } = storeToRefs(themeStore); const isActivated = computed(() => { return activatedTheme?.value?.metadata.name === theme.value?.metadata.name; }); const getFailedMessage = (): string | undefined => { if (!(theme.value?.status?.phase === "FAILED")) { return; } const condition = theme.value.status.conditions?.[0]; if (condition) { return [condition.type, condition.message].join(":"); } }; const handleActiveTheme = async (reload?: boolean) => { Dialog.info({ title: t("core.theme.operations.active.title"), description: theme.value?.spec.displayName, confirmText: t("core.common.buttons.confirm"), cancelText: t("core.common.buttons.cancel"), onConfirm: async () => { try { if (!theme.value) return; await consoleApiClient.theme.theme.activateTheme({ name: theme.value?.metadata.name, }); Toast.success(t("core.theme.operations.active.toast_success")); if (reload) { window.location.reload(); } } catch (e) { console.error("Failed to active theme", e); } finally { themeStore.fetchActivatedTheme(); } }, }); }; const handleResetSettingConfig = async () => { Dialog.warning({ title: t("core.theme.operations.reset.title"), description: t("core.theme.operations.reset.description"), confirmType: "danger", confirmText: t("core.common.buttons.confirm"), cancelText: t("core.common.buttons.cancel"), onConfirm: async () => { try { if (!theme?.value) { return; } await consoleApiClient.theme.theme.resetThemeConfig({ name: theme.value.metadata.name as string, }); Toast.success(t("core.theme.operations.reset.toast_success")); } catch (e) { console.error("Failed to reset theme setting config", e); } }, }); }; return { loading, isActivated, getFailedMessage, handleActiveTheme, handleResetSettingConfig, }; } export function useThemeCustomTemplates(type: "post" | "page" | "category") { const themeStore = useThemeStore(); const { t } = useI18n(); const templates = computed(() => { const defaultTemplate = [ { label: t("core.theme.custom_templates.default"), value: "", }, ]; if (!themeStore.activatedTheme) { return defaultTemplate; } const { customTemplates } = themeStore.activatedTheme.spec; if (!customTemplates?.[type]) { return defaultTemplate; } return [ ...defaultTemplate, ...(customTemplates[type]?.map((template) => { return { value: template.file, label: template.name || template.file, }; }) || []), ]; }); return { templates, }; } interface ExportData { themeName: string; version: string; settingName: string; configMapName: string; configs: Record; } export function useThemeConfigFile(theme: Ref) { const { t } = useI18n(); const handleExportThemeConfiguration = async () => { if (!theme.value) { console.error("No selected or activated theme"); return; } const { data } = await consoleApiClient.theme.theme.fetchThemeJsonConfig({ name: theme?.value?.metadata.name as string, }); if (!data) { console.error("Failed to fetch theme config"); return; } const themeName = theme.value.metadata.name; const exportData: ExportData = { themeName: themeName, version: theme.value.spec.version || "", settingName: theme.value.spec.settingName || "", configMapName: theme.value.spec.configMapName || "", configs: data as Record, }; const exportStr = JSON.stringify(exportData, null, 2); const blob = new Blob([exportStr], { type: "application/json" }); const temporaryExportUrl = URL.createObjectURL(blob); const temporaryLinkTag = document.createElement("a"); temporaryLinkTag.href = temporaryExportUrl; temporaryLinkTag.download = `export-${themeName}-config-${Date.now().toString()}.json`; document.body.appendChild(temporaryLinkTag); temporaryLinkTag.click(); document.body.removeChild(temporaryLinkTag); URL.revokeObjectURL(temporaryExportUrl); }; const { open: openSelectImportFileDialog, onChange: handleImportThemeConfiguration, } = useFileDialog({ accept: "application/json", multiple: false, directory: false, reset: true, }); handleImportThemeConfiguration(async (files) => { if (files === null || files.length === 0) { return; } const configText = await files[0].text(); const configJson = JSON.parse(configText || "{}") as ExportData; if (!configJson.configs) { return; } if (!configJson.themeName || !configJson.version) { Toast.error( t("core.theme.operations.import_configuration.invalid_format") ); return; } if (!theme.value) { console.error("No selected or activated theme"); return; } if (configJson.themeName !== theme.value.metadata.name) { Toast.error( t("core.theme.operations.import_configuration.mismatched_theme") ); return; } if (configJson.version !== theme.value.spec.version) { Dialog.warning({ title: t( "core.theme.operations.import_configuration.version_mismatch.title" ), description: t( "core.theme.operations.import_configuration.version_mismatch.description" ), confirmText: t("core.common.buttons.confirm"), cancelText: t("core.common.buttons.cancel"), onConfirm: () => { handleSaveConfigMap(configJson.configs); }, onCancel() { return; }, }); return; } handleSaveConfigMap(configJson.configs); }); const handleSaveConfigMap = async (importData: Record) => { if (!theme.value) { return; } const { data: originalData } = await consoleApiClient.theme.theme.fetchThemeJsonConfig({ name: theme.value.metadata.name as string, }); if (!originalData) { return; } await consoleApiClient.theme.theme.updateThemeJsonConfig({ name: theme.value.metadata.name, body: merge(originalData, importData), }); Toast.success(t("core.common.toast.save_success")); }; return { handleExportThemeConfiguration, openSelectImportFileDialog, }; } ================================================ FILE: ui/console-src/modules/interface/themes/constants/index.ts ================================================ export const THEME_ALREADY_EXISTS_TYPE = "https://halo.run/probs/theme-alreay-exists"; ================================================ FILE: ui/console-src/modules/interface/themes/layouts/ThemeLayout.vue ================================================ ================================================ FILE: ui/console-src/modules/interface/themes/module.ts ================================================ import { IconPalette } from "@halo-dev/components"; import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; export default definePlugin({ components: {}, routes: [ { path: "/theme", name: "ThemeRoot", component: () => import("./layouts/ThemeLayout.vue"), meta: { title: "core.theme.title", searchable: true, permissions: ["system:themes:view"], menu: { name: "core.sidebar.menu.items.themes", group: "interface", icon: markRaw(IconPalette), priority: 0, }, }, children: [ { path: "", name: "ThemeDetail", component: () => import("./ThemeDetail.vue"), }, { path: "settings/:group", name: "ThemeSetting", component: () => import("./ThemeSetting.vue"), meta: { title: "core.theme.settings.title", permissions: ["system:themes:view"], }, }, ], }, ], }); ================================================ FILE: ui/console-src/modules/interface/themes/types/index.ts ================================================ export interface ThemeInstallationErrorResponse { detail: string; instance: string; themeName: string; requestId: string; status: number; timestamp: string; title: string; type: string; } ================================================ FILE: ui/console-src/modules/system/auth-providers/AuthProviderDetail.vue ================================================ ================================================ FILE: ui/console-src/modules/system/auth-providers/AuthProviders.vue ================================================ ================================================ FILE: ui/console-src/modules/system/auth-providers/components/AuthProviderListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/system/auth-providers/components/AuthProvidersSection.vue ================================================ ================================================ FILE: ui/console-src/modules/system/auth-providers/module.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import { definePlugin } from "@halo-dev/ui-shared"; export default definePlugin({ routes: [ { path: "/users/auth-providers", component: BasicLayout, children: [ { path: "", name: "AuthProviders", component: () => import("./AuthProviders.vue"), meta: { title: "core.identity_authentication.title", searchable: true, permissions: ["*"], }, }, { path: ":name", name: "AuthProviderDetail", component: () => import("./AuthProviderDetail.vue"), meta: { title: "core.identity_authentication.detail.title", permissions: ["*"], }, }, ], }, ], }); ================================================ FILE: ui/console-src/modules/system/backup/Backups.vue ================================================ ================================================ FILE: ui/console-src/modules/system/backup/components/BackupListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/system/backup/composables/use-backup.ts ================================================ import { BackupStatusPhaseEnum, coreApiClient, paginate, type Backup, type BackupV1alpha1ApiListBackupRequest, } from "@halo-dev/api-client"; import { Dialog, Toast } from "@halo-dev/components"; import { utils } from "@halo-dev/ui-shared"; import { useQuery, useQueryClient } from "@tanstack/vue-query"; import { useI18n } from "vue-i18n"; export function useBackupFetch() { return useQuery({ queryKey: ["backups"], queryFn: async () => { return await paginate( (params) => coreApiClient.migration.backup.listBackup(params), { size: 1000, } ); }, refetchInterval(data) { const deletingBackups = data?.filter((backup) => { return !!backup.metadata.deletionTimestamp; }); if (deletingBackups?.length) { return 1000; } const pendingBackups = data?.filter((backup) => { return ( backup.status?.phase === BackupStatusPhaseEnum.Pending || backup.status?.phase === BackupStatusPhaseEnum.Running ); }); if (pendingBackups?.length) { return 3000; } return false; }, }); } export function useBackup() { const { t } = useI18n(); const queryClient = useQueryClient(); const handleCreate = async () => { Dialog.info({ title: t("core.backup.operations.create.title"), description: t("core.backup.operations.create.description"), confirmText: t("core.common.buttons.confirm"), cancelText: t("core.common.buttons.cancel"), async onConfirm() { await coreApiClient.migration.backup.createBackup({ backup: { apiVersion: "migration.halo.run/v1alpha1", kind: "Backup", metadata: { generateName: "backup-", name: "", }, spec: { expiresAt: utils.date.dayjs().add(7, "day").toISOString(), }, }, }); queryClient.invalidateQueries({ queryKey: ["backups"] }); Toast.success(t("core.backup.operations.create.toast_success")); }, }); }; return { handleCreate }; } ================================================ FILE: ui/console-src/modules/system/backup/module.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import { IconServerLine } from "@halo-dev/components"; import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; export default definePlugin({ components: {}, routes: [ { path: "/backup", name: "BackupRoot", component: BasicLayout, meta: { title: "core.backup.title", searchable: true, permissions: ["system:migrations:manage"], menu: { name: "core.sidebar.menu.items.backup", group: "system", icon: markRaw(IconServerLine), priority: 4, }, }, children: [ { path: "", name: "Backup", component: () => import("./Backups.vue"), }, ], }, ], }); ================================================ FILE: ui/console-src/modules/system/backup/tabs/List.vue ================================================ ================================================ FILE: ui/console-src/modules/system/backup/tabs/Restore.vue ================================================ ================================================ FILE: ui/console-src/modules/system/overview/Overview.vue ================================================ ================================================ FILE: ui/console-src/modules/system/overview/components/ExternalUrlForm.vue ================================================ ================================================ FILE: ui/console-src/modules/system/overview/components/ExternalUrlItem.vue ================================================ ================================================ FILE: ui/console-src/modules/system/overview/module.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import { IconTerminalBoxLine } from "@halo-dev/components"; import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; export default definePlugin({ components: {}, routes: [ { path: "/overview", name: "OverviewRoot", component: BasicLayout, meta: { title: "core.overview.title", searchable: true, permissions: ["system:actuator:manage"], menu: { name: "core.sidebar.menu.items.overview", group: "system", icon: markRaw(IconTerminalBoxLine), priority: 3, }, }, children: [ { path: "", name: "Overview", component: () => import("./Overview.vue"), }, ], }, ], }); ================================================ FILE: ui/console-src/modules/system/plugins/PluginDetail.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/PluginExtensionPointSettings.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/PluginList.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/PluginConditionsModal.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/PluginDetailModal.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/PluginInstallationModal.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/PluginListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/entity-fields/AuthorField.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/entity-fields/LogoField.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/entity-fields/ReloadField.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/entity-fields/SwitchField.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/entity-fields/TitleField.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/extension-points/ExtensionDefinitionListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/extension-points/ExtensionDefinitionMultiInstanceView.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/extension-points/ExtensionDefinitionSingletonView.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/installation-tabs/LocalUpload.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/installation-tabs/RemoteDownload.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/tabs/Detail.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/components/tabs/Setting.vue ================================================ ================================================ FILE: ui/console-src/modules/system/plugins/composables/use-extension-definition-fetch.ts ================================================ import type { ExtensionDefinition, ExtensionDefinitionV1alpha1ApiListExtensionDefinitionRequest, ExtensionPointDefinition, } from "@halo-dev/api-client"; import { coreApiClient, paginate } from "@halo-dev/api-client"; import { useQuery } from "@tanstack/vue-query"; import { computed, type Ref } from "vue"; export function useExtensionDefinitionFetch( extensionPointDefinition: Ref ) { return useQuery({ queryKey: ["extension-definitions", extensionPointDefinition], queryFn: async () => { return await paginate< ExtensionDefinitionV1alpha1ApiListExtensionDefinitionRequest, ExtensionDefinition >( (params) => coreApiClient.plugin.extensionDefinition.listExtensionDefinition( params ), { size: 1000, fieldSelector: [ `spec.extensionPointName=${extensionPointDefinition.value?.metadata.name}`, ], } ); }, enabled: computed(() => !!extensionPointDefinition.value), }); } ================================================ FILE: ui/console-src/modules/system/plugins/composables/use-plugin.ts ================================================ import { PluginStatusPhaseEnum, consoleApiClient, coreApiClient, type Plugin, type SettingForm, } from "@halo-dev/api-client"; import { Dialog, Toast, VLoading } from "@halo-dev/components"; import { utils, type PluginTab } from "@halo-dev/ui-shared"; import { useMutation, useQuery } from "@tanstack/vue-query"; import { useRouteQuery } from "@vueuse/router"; import type { ComputedRef, Ref } from "vue"; import { computed, defineAsyncComponent, ref, shallowRef } from "vue"; import { useI18n } from "vue-i18n"; import { usePluginModuleStore } from "@/stores/plugin"; interface usePluginLifeCycleReturn { isStarted: ComputedRef; getStatusDotState: () => string; getStatusMessage: () => string | undefined; changeStatus: () => void; changingStatus: Ref; uninstall: (deleteExtensions?: boolean) => void; } export function usePluginLifeCycle( plugin?: Ref ): usePluginLifeCycleReturn { const { t } = useI18n(); const isStarted = computed(() => { return ( plugin?.value?.status?.phase === PluginStatusPhaseEnum.Started && plugin.value?.spec.enabled ); }); const getStatusDotState = () => { const { phase } = plugin?.value?.status || {}; const { enabled } = plugin?.value?.spec || {}; if (enabled && phase === PluginStatusPhaseEnum.Failed) { return "error"; } if (phase === PluginStatusPhaseEnum.Disabling) { return "warning"; } return "default"; }; const getStatusMessage = () => { if (!plugin?.value) return; const { phase } = plugin.value.status || {}; if ( phase === PluginStatusPhaseEnum.Failed || phase === PluginStatusPhaseEnum.Disabling ) { const lastCondition = plugin.value.status?.conditions?.[0]; return ( [lastCondition?.reason, lastCondition?.message] .filter(Boolean) .join(": ") || "Unknown" ); } // Starting up if ( phase !== (PluginStatusPhaseEnum.Started || PluginStatusPhaseEnum.Failed) ) { return t("core.common.status.starting_up"); } }; const { isLoading: changingStatus, mutate: changeStatus } = useMutation({ mutationKey: ["change-plugin-status"], mutationFn: async () => { if (!plugin?.value) return; const { enabled } = plugin.value.spec; return await consoleApiClient.plugin.plugin.changePluginRunningState({ name: plugin.value.metadata.name, pluginRunningStateRequest: { enable: !enabled, }, }); }, retry: 3, retryDelay: 1000, onSuccess() { window.location.reload(); }, }); const uninstall = (deleteExtensions?: boolean) => { if (!plugin?.value) return; const { enabled } = plugin.value.spec; Dialog.warning({ title: `${ deleteExtensions ? t("core.plugin.operations.uninstall_and_delete_config.title") : t("core.plugin.operations.uninstall.title") }`, description: `${ enabled ? t("core.plugin.operations.uninstall_when_enabled.description") : t("core.common.dialog.descriptions.cannot_be_recovered") }`, confirmType: "danger", confirmText: `${ enabled ? t("core.plugin.operations.uninstall_when_enabled.confirm_text") : t("core.common.buttons.uninstall") }`, cancelText: t("core.common.buttons.cancel"), onConfirm: async () => { if (!plugin.value) return; try { await consoleApiClient.plugin.plugin.changePluginRunningState({ name: plugin.value.metadata.name, pluginRunningStateRequest: { enable: false, }, }); await coreApiClient.plugin.plugin.deletePlugin({ name: plugin.value.metadata.name, }); // delete plugin setting and configMap if (deleteExtensions) { const { settingName, configMapName } = plugin.value.spec; if (settingName) { await coreApiClient.setting.deleteSetting( { name: settingName, }, { mute: true, } ); } if (configMapName) { await coreApiClient.configMap.deleteConfigMap( { name: configMapName, }, { mute: true, } ); } } Toast.success(t("core.common.toast.uninstall_success")); } catch (e) { console.error("Failed to uninstall plugin", e); } finally { window.location.reload(); } }, }); }; return { isStarted, getStatusDotState, getStatusMessage, changeStatus, changingStatus, uninstall, }; } export function usePluginBatchOperations(names: Ref) { const { t } = useI18n(); function handleUninstallInBatch(deleteExtensions: boolean) { Dialog.warning({ title: `${ deleteExtensions ? t( "core.plugin.operations.uninstall_and_delete_config_in_batch.title" ) : t("core.plugin.operations.uninstall_in_batch.title") }`, description: t("core.common.dialog.descriptions.cannot_be_recovered"), confirmType: "danger", confirmText: t("core.common.buttons.uninstall"), cancelText: t("core.common.buttons.cancel"), onConfirm: async () => { try { for (let i = 0; i < names.value.length; i++) { await coreApiClient.plugin.plugin.deletePlugin({ name: names.value[i], }); if (deleteExtensions) { const { data: plugin } = await coreApiClient.plugin.plugin.getPlugin({ name: names.value[i], }); const { settingName, configMapName } = plugin.spec; if (settingName) { await coreApiClient.setting.deleteSetting( { name: settingName, }, { mute: true, } ); } if (configMapName) { await coreApiClient.configMap.deleteConfigMap( { name: configMapName, }, { mute: true, } ); } } } window.location.reload(); } catch (e) { console.error("Failed to uninstall plugin in batch", e); } }, }); } function handleChangeStatusInBatch(enabled: boolean) { Dialog.info({ title: enabled ? t("core.plugin.operations.change_status_in_batch.activate_title") : t("core.plugin.operations.change_status_in_batch.inactivate_title"), confirmText: t("core.common.buttons.confirm"), cancelText: t("core.common.buttons.cancel"), onConfirm: async () => { try { for (let i = 0; i < names.value.length; i++) { await consoleApiClient.plugin.plugin.changePluginRunningState({ name: names.value[i], pluginRunningStateRequest: { enable: enabled, }, }); } window.location.reload(); } catch (e) { console.error("Failed to change plugin status in batch", e); } }, }); } return { handleUninstallInBatch, handleChangeStatusInBatch }; } export function usePluginDetailTabs( pluginName: Ref, recordsActiveTab: boolean ) { const { t } = useI18n(); const initialTabs = [ { id: "detail", label: t("core.plugin.tabs.detail"), component: defineAsyncComponent({ loader: () => import("../components/tabs/Detail.vue"), loadingComponent: VLoading, }), }, ]; const tabs = shallowRef(initialTabs); const activeTab = recordsActiveTab ? useRouteQuery("tab", tabs.value[0].id) : ref(tabs.value[0].id); const { data: plugin } = useQuery({ queryKey: ["plugin", pluginName], queryFn: async () => { const { data } = await coreApiClient.plugin.plugin.getPlugin({ name: pluginName.value as string, }); return data; }, async onSuccess(data) { if ( !data.spec.settingName || !utils.permission.has(["system:plugins:manage"]) ) { tabs.value = [...initialTabs, ...(await getTabsFromExtensions())]; } }, }); const { data: setting } = useQuery({ queryKey: ["plugin-setting", plugin], queryFn: async () => { const { data } = await consoleApiClient.plugin.plugin.fetchPluginSetting({ name: plugin.value?.metadata.name as string, }); return data; }, enabled: computed(() => { return ( !!plugin.value && !!plugin.value.spec.settingName && utils.permission.has(["system:plugins:manage"]) ); }), async onSuccess(data) { if (data) { const { forms } = data.spec; tabs.value = [ ...initialTabs, ...(await getTabsFromExtensions()), ...forms.map((item: SettingForm) => { return { id: item.group, label: item.label || "", component: defineAsyncComponent({ loader: () => import("../components/tabs/Setting.vue"), loadingComponent: VLoading, }), }; }), ] as PluginTab[]; } }, }); async function getTabsFromExtensions() { const { pluginModuleMap } = usePluginModuleStore(); const currentPluginModule = pluginModuleMap[pluginName.value as string]; if (!currentPluginModule) { return []; } const callbackFunction = currentPluginModule?.extensionPoints?.["plugin:self:tabs:create"]; if (typeof callbackFunction !== "function") { return []; } const pluginTabs = await callbackFunction(); return pluginTabs.filter((tab) => { return utils.permission.has(tab.permissions || []); }); } return { plugin, setting, tabs, activeTab, }; } ================================================ FILE: ui/console-src/modules/system/plugins/constants/index.ts ================================================ export const PLUGIN_ALREADY_EXISTS_TYPE = "https://halo.run/probs/plugin-alreay-exists"; ================================================ FILE: ui/console-src/modules/system/plugins/module.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import { IconPlug } from "@halo-dev/components"; import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; import type { RouteRecordRaw } from "vue-router"; import PluginDetailModal from "./components/PluginDetailModal.vue"; declare module "vue" { interface GlobalComponents { PluginDetailModal: (typeof import("./components/PluginDetailModal.vue"))["default"]; } } export default definePlugin({ components: { PluginDetailModal, }, routes: [ { path: "/plugins", name: "PluginsRoot", component: BasicLayout, meta: { title: "core.plugin.title", searchable: true, permissions: ["system:plugins:view"], menu: { name: "core.sidebar.menu.items.plugins", group: "system", icon: markRaw(IconPlug), priority: 0, }, }, children: [ { path: "", name: "Plugins", component: () => import("./PluginList.vue"), }, { path: "extension-point-settings", name: "PluginExtensionPointSettings", component: () => import("./PluginExtensionPointSettings.vue"), meta: { title: "core.plugin.extension-settings.title", hideFooter: true, permissions: ["*"], }, }, { path: ":name", name: "PluginDetail", component: () => import("./PluginDetail.vue"), meta: { title: "core.plugin.detail.title", permissions: ["system:plugins:view"], }, }, ], } as RouteRecordRaw, ], }); ================================================ FILE: ui/console-src/modules/system/plugins/types/index.ts ================================================ export interface PluginInstallationErrorResponse { detail: string; instance: string; pluginName: string; requestId: string; status: number; timestamp: string; title: string; type: string; } ================================================ FILE: ui/console-src/modules/system/roles/RoleDetail.vue ================================================ ================================================ FILE: ui/console-src/modules/system/roles/RoleList.vue ================================================ ================================================ FILE: ui/console-src/modules/system/roles/components/RoleEditingModal.vue ================================================ ================================================ FILE: ui/console-src/modules/system/roles/module.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import { definePlugin } from "@halo-dev/ui-shared"; export default definePlugin({ components: {}, routes: [ { path: "/users/roles", component: BasicLayout, children: [ { path: "", name: "Roles", component: () => import("./RoleList.vue"), meta: { title: "core.role.title", searchable: true, permissions: ["system:roles:view"], }, }, { path: ":name", name: "RoleDetail", component: () => import("./RoleDetail.vue"), meta: { title: "core.role.detail.title", permissions: ["system:roles:view"], }, }, ], }, ], }); ================================================ FILE: ui/console-src/modules/system/settings/SystemSettings.vue ================================================ ================================================ FILE: ui/console-src/modules/system/settings/module.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import { IconSettings } from "@halo-dev/components"; import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; export default definePlugin({ components: {}, routes: [ { path: "/settings", name: "SettingsRoot", component: BasicLayout, meta: { title: "core.setting.title", permissions: ["system:settings:view"], menu: { name: "core.sidebar.menu.items.settings", group: "system", icon: markRaw(IconSettings), priority: 2, }, }, children: [ { path: "", name: "SystemSetting", component: () => import("./SystemSettings.vue"), }, ], }, ], }); ================================================ FILE: ui/console-src/modules/system/settings/tabs/NotificationSetting.vue ================================================ ================================================ FILE: ui/console-src/modules/system/settings/tabs/Notifications.vue ================================================ ================================================ FILE: ui/console-src/modules/system/settings/tabs/Setting.vue ================================================ ================================================ FILE: ui/console-src/modules/system/tools/Tools.vue ================================================ ================================================ FILE: ui/console-src/modules/system/tools/module.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import { IconToolsFill } from "@halo-dev/components"; import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; export default definePlugin({ components: {}, routes: [ { path: "/tools", name: "ToolsRoot", component: BasicLayout, meta: { title: "core.tool.title", menu: { name: "core.sidebar.menu.items.tools", group: "system", icon: markRaw(IconToolsFill), priority: 5, }, }, children: [ { path: "", name: "Tools", component: () => import("./Tools.vue"), }, ], }, ], }); ================================================ FILE: ui/console-src/modules/system/users/UserDetail.vue ================================================ ================================================ FILE: ui/console-src/modules/system/users/UserList.vue ================================================ ================================================ FILE: ui/console-src/modules/system/users/components/GrantPermissionModal.vue ================================================ ================================================ FILE: ui/console-src/modules/system/users/components/RolesView.vue ================================================ ================================================ FILE: ui/console-src/modules/system/users/components/UserCreationModal.vue ================================================ ================================================ FILE: ui/console-src/modules/system/users/components/UserEditingModal.vue ================================================ ================================================ FILE: ui/console-src/modules/system/users/components/UserListItem.vue ================================================ ================================================ FILE: ui/console-src/modules/system/users/components/UserPasswordChangeModal.vue ================================================ ================================================ FILE: ui/console-src/modules/system/users/composables/use-role.ts ================================================ import { coreApiClient, paginate, type Role, type RoleV1alpha1ApiListRoleRequest, } from "@halo-dev/api-client"; import { useQuery } from "@tanstack/vue-query"; import { roleLabels } from "@/constants/labels"; export function useFetchRoles() { return useQuery({ queryKey: ["core:roles"], queryFn: async () => { return await paginate( (params) => coreApiClient.role.listRole(params), { size: 1000, labelSelector: [`!${roleLabels.TEMPLATE}`], } ); }, refetchInterval(data) { const hasDeletingRole = data?.some( (item) => !!item.metadata.deletionTimestamp ); return hasDeletingRole ? 1000 : false; }, }); } export function useFetchRoleTemplates() { return useQuery({ queryKey: ["core:role-templates"], queryFn: async () => { return await paginate( (params) => coreApiClient.role.listRole(params), { size: 1000, labelSelector: [`${roleLabels.TEMPLATE}=true`, "!halo.run/hidden"], } ); }, }); } ================================================ FILE: ui/console-src/modules/system/users/composables/use-user.ts ================================================ import { consoleApiClient } from "@halo-dev/api-client"; import { Dialog, Toast } from "@halo-dev/components"; import { useI18n } from "vue-i18n"; export function useUserEnableDisable() { const { t } = useI18n(); const handleEnableOrDisableUser = ({ name, operation, onSuccess, }: { name: string; operation: "enable" | "disable"; onSuccess?: () => void; }) => { const operations = { enable: { title: t("core.user.operations.enable.title"), description: t("core.user.operations.enable.description"), request: (name: string) => consoleApiClient.user.enableUser({ username: name }), message: t("core.common.toast.enable_success"), }, disable: { title: t("core.user.operations.disable.title"), description: t("core.user.operations.disable.description"), request: (name: string) => consoleApiClient.user.disableUser({ username: name }), message: t("core.common.toast.disable_success"), }, }; const operationConfig = operations[operation]; Dialog.warning({ title: operationConfig.title, description: operationConfig.description, confirmType: "danger", confirmText: t("core.common.buttons.confirm"), cancelText: t("core.common.buttons.cancel"), onConfirm: async () => { try { await operationConfig.request(name); Toast.success(operationConfig.message); onSuccess?.(); } catch (e) { console.error("Failed to enable or disable user", e); } }, }); }; return { handleEnableOrDisableUser, }; } ================================================ FILE: ui/console-src/modules/system/users/module.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import { IconUserSettings } from "@halo-dev/components"; import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; export default definePlugin({ routes: [ { path: "/users", name: "UsersRoot", component: BasicLayout, meta: { title: "core.user.title", searchable: true, permissions: ["system:users:view"], menu: { name: "core.sidebar.menu.items.users", group: "system", icon: markRaw(IconUserSettings), priority: 1, mobile: true, }, }, children: [ { path: "", name: "Users", component: () => import("./UserList.vue"), }, { path: ":name", name: "UserDetail", component: () => import("./UserDetail.vue"), meta: { title: "core.user.detail.title", }, }, ], }, ], }); ================================================ FILE: ui/console-src/modules/system/users/tabs/Detail.vue ================================================ ================================================ FILE: ui/console-src/router/constant.ts ================================================ import type { MenuGroupType } from "@halo-dev/ui-shared"; export const coreMenuGroups: MenuGroupType[] = [ { id: "dashboard", name: undefined, priority: 0, }, { id: "content", name: "core.sidebar.menu.groups.content", priority: 1, }, { id: "interface", name: "core.sidebar.menu.groups.interface", priority: 2, }, { id: "system", name: "core.sidebar.menu.groups.system", priority: 3, }, { id: "tool", name: "core.sidebar.menu.groups.tool", priority: 4, }, ]; ================================================ FILE: ui/console-src/router/guards/auth-check.ts ================================================ import { stores } from "@halo-dev/ui-shared"; import type { Router } from "vue-router"; export function setupAuthCheckGuard(router: Router) { router.beforeEach((_to, _, next) => { const currentUserStore = stores.currentUser(); if (currentUserStore.isAnonymous) { window.location.href = `/login?redirect_uri=${encodeURIComponent( window.location.href )}`; return; } next(); }); } ================================================ FILE: ui/console-src/router/guards/permission.ts ================================================ import type { Role } from "@halo-dev/api-client"; import { stores, utils } from "@halo-dev/ui-shared"; import type { RouteLocationNormalized, Router } from "vue-router"; import { rbacAnnotations } from "@/constants/annotations"; import { SUPER_ROLE_NAME } from "@/constants/constants"; export function setupPermissionGuard(router: Router) { router.beforeEach(async (to, _, next) => { const currentUserStore = stores.currentUser(); if (isConsoleAccessDisallowed(currentUserStore.currentUser?.roles)) { window.location.href = "/uc"; return; } if ( await checkRoutePermissions( to, utils.permission.getUserPermissions() || [] ) ) { next(); } else { next({ name: "Forbidden" }); } }); } function isConsoleAccessDisallowed(currentRoles?: Role[]): boolean { if (currentRoles?.some((role) => role.metadata.name === SUPER_ROLE_NAME)) { return false; } return ( currentRoles?.some( (role) => role.metadata.annotations?.[rbacAnnotations.DISALLOW_ACCESS_CONSOLE] === "true" ) || false ); } async function checkRoutePermissions( to: RouteLocationNormalized, uiPermissions: string[] ): Promise { const { meta } = to; if (!meta?.permissions) { return true; } if (typeof meta.permissions === "function") { try { return await meta.permissions(uiPermissions); } catch (e) { console.error( `Error checking permissions for route ${String(to.name)}:`, e ); return false; } } return utils.permission.has(meta.permissions as string[]); } ================================================ FILE: ui/console-src/router/index.ts ================================================ import routesConfig from "@console/router/routes.config"; import { createRouter, createWebHistory, type RouteLocationNormalized, type RouteLocationNormalizedLoaded, } from "vue-router"; import { setupStopImplicitSubmission } from "@/formkit/plugins/stop-implicit-submission"; import { setupProcessBarGuard } from "@/router/process-bar"; import { setupAuthCheckGuard } from "./guards/auth-check"; import { setupPermissionGuard } from "./guards/permission"; const router = createRouter({ history: createWebHistory("/console/"), routes: routesConfig, scrollBehavior: ( to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded ) => { if (to.name !== from.name) { return { left: 0, top: 0 }; } }, }); setupAuthCheckGuard(router); setupPermissionGuard(router); setupProcessBarGuard(router); setupStopImplicitSubmission(router); export default router; ================================================ FILE: ui/console-src/router/routes.config.ts ================================================ import BasicLayout from "@console/layouts/BasicLayout.vue"; import type { RouteRecordRaw } from "vue-router"; export const routes: Array = [ { path: "/:pathMatch(.*)*", component: BasicLayout, children: [ { path: "", name: "NotFound", component: () => import("@/views/exceptions/NotFound.vue"), }, ], }, { path: "/403", component: BasicLayout, children: [ { path: "", name: "Forbidden", component: () => import("@/views/exceptions/Forbidden.vue"), }, ], }, ]; export default routes; ================================================ FILE: ui/console-src/stores/theme.ts ================================================ import type { Theme } from "@halo-dev/api-client"; import { consoleApiClient } from "@halo-dev/api-client"; import { utils } from "@halo-dev/ui-shared"; import { defineStore } from "pinia"; import { ref } from "vue"; export const useThemeStore = defineStore("theme", () => { const activatedTheme = ref(); async function fetchActivatedTheme() { if (!utils.permission.has(["system:themes:view"])) { return; } try { const { data } = await consoleApiClient.theme.theme.fetchActivatedTheme({ mute: true, }); if (data) { activatedTheme.value = data; } } catch (e) { console.error("Failed to fetch active theme", e); } } return { activatedTheme, fetchActivatedTheme }; }); ================================================ FILE: ui/console.html ================================================
================================================ FILE: ui/docs/components/README.md ================================================ # Console 组件介绍 目前 Console 的组件包含基础组件(`@halo-dev/components`)和 Console 端的业务组件,这两种组件都可以在插件中使用。 ## 业务组件 ### AnnotationsForm 此组件用于为自定义模型设置 annotations 数据,同时支持自定义 key / value 和自定义表单,表单定义方式可以参考: 使用方式: ```vue ``` 其中,kind 和 group 为必填项,分别表示模型的 kind 和 group。 ================================================ FILE: ui/docs/custom-formkit-input/README.md ================================================ # 自定义 FormKit 输入组件 ## 原由 目前在 Console 端的所有表单都使用了 FormKit,但 FormKit 内置的 Input 组件并不满足所有的需求,因此需要自定义一些 Input 组件。此外,为了插件和主题能够更加方便的使用系统内的一些数据,所以同样需要自定义一些带数据的选择组件。 ## 使用方式 目前已提供以下类型: - `code`: 代码编辑器 - 参数 1. language: 目前支持 `yaml`, `html`, `css`, `javascript`, `json` 2. height: 编辑器高度,如:`100px` - `attachment`: 附件选择 - 参数 1. accepts:允许上传的文件类型,如:`image/*` - `repeater`: 定义一个对象集合,可以让使用者可视化的操作集合。 - 参数 1. min: 最小数量,默认为 `0` 2. max: 最大数量,默认为 `Infinity`,即无限制。 3. addLabel: 添加按钮的文本,默认为 `添加` 4. addButton: 是否显示添加按钮,默认为 `true` 5. upControl: 是否显示上移按钮,默认为 `true` 6. downControl: 是否显示下移按钮,默认为 `true` 7. insertControl: 是否显示插入按钮,默认为 `true` 8. removeControl: 是否显示删除按钮,默认为 `true` - `list`: 动态列表,定义一个数组列表。 - 参数 1. itemType: 列表项的数据类型,用于初始化数据类型,可选参数 `string`, `number`, `boolean`, `object`,默认为 `string` 2. min: 最小数量,默认为 `0` 3. max: 最大数量,默认为 `Infinity`,即无限制。 4. addLabel: 添加按钮的文本,默认为 `添加` 5. addButton: 是否显示添加按钮,默认为 `true` 6. upControl: 是否显示上移按钮,默认为 `true` 7. downControl: 是否显示下移按钮,默认为 `true` 8. insertControl: 是否显示插入按钮,默认为 `true` 9. removeControl: 是否显示删除按钮,默认为 `true` - `menuCheckbox`:选择一组菜单 - `menuRadio`:选择一个菜单 - `menuSelect`: 通用菜单选择组件,支持单选、多选、排序 - `menuItemSelect`:选择菜单项 - `postSelect`:选择文章 - `singlePageSelect`:选择自定义页面 - `categorySelect`:选择分类 - 参数 1. multiple: 是否多选,默认为 `false` - `categoryCheckbox`:选择多个分类 - `tagSelect`:选择标签 - 参数 1. multiple: 是否多选,默认为 `false` - `tagCheckbox`:选择多个标签 - `verificationForm`: 远程验证一组数据是否符合某个规则 - 参数 1. action: 对目标数据进行验证的接口地址 2. label: 验证按钮文本 3. buttonAttrs: 验证按钮的额外属性 - `secret`: 用于选择或者管理密钥(Secret) - 参数 1. requiredKeys:用于确认所需密钥的字段名称,数组类型,每个元素包含 `key` 和 `help` 两个属性。 - `select`: 自定义的选择器组件,用于在备选项中选择一个或多个选项 - 参数 1. `options`:静态数据源。当 `action` 或 `remote` 存在时,此参数无效。 2. `action`:远程动态数据源的接口地址。 3. `requestOption`: 动态数据源的请求参数,可以通过此参数来指定如何获取数据,适配不同的接口。当 `action` 存在时,此参数有效。 4. `remote`:标识当前是否由用户自定义的远程数据源。 5. `remoteOption`:当 `remote` 为 `true` 时,此配置项必须存在,用于为 Select 组件提供处理搜索及查询键值对的方法。 6. `remoteOptimize`:是否开启远程数据源优化,默认为 `true`。开启后,将会对远程数据源进行优化,减少请求次数。仅在动态数据源下有效。 7. `allowCreate`:是否允许创建新选项,默认为 `false`。仅在静态数据源下有效,需要同时开启 `searchable`。 8. `clearable`:是否允许清空选项,默认为 `false`。 9. `multiple`:是否多选,默认为 `false`。 10. `maxCount`:多选时最大可选数量,默认为 `Infinity`。仅在多选时有效。 11. `sortable`:是否支持拖动排序,默认为 `false`。仅在多选时有效。 12. `searchable`:是否支持搜索内容,默认为 `false`。 13. `autoSelect`:当 value 不存在时,是否自动选择第一个选项,默认为 `true`。仅在单选时有效。 在 Vue 单组件中使用: ```vue ``` 在 FormKit Schema 中使用(插件 / 主题设置表单定义): ```yaml - $formkit: menuRadio name: menus label: 底部菜单组 ``` ### select select 是一个选择器类型的输入组件,使用者可以从一批待选数据中选择一个或多个选项。它支持单选、多选操作,并且支持静态数据及远程动态数据加载等多种方式。 #### 在 Vue SFC 中以组件形式使用 静态数据源: ```vue ``` 动态数据源: ```vue ``` #### 在 FormKit Schema 中使用 静态数据源: ```yaml - $formkit: select name: countries label: What country makes the best food? sortable: true multiple: true clearable: true placeholder: Select a country options: - label: China value: cn - label: France value: fr - label: Germany value: de - label: Spain value: es - label: Italy value: ie - label: Greece value: gr ``` 远程动态数据源: 支持远程动态数据源,通过 `action` 和 `requestOption` 参数来指定如何获取数据。 请求的接口将会自动拼接 `page`、`size` 与 `keyword` 参数,其中 `keyword` 为搜索关键词。 ```yaml - $formkit: select name: postName label: Choose an post clearable: true action: /apis/api.console.halo.run/v1alpha1/posts requestOption: method: GET pageField: page sizeField: size totalField: total itemsField: items labelField: post.spec.title valueField: post.metadata.name fieldSelectorKey: metadata.name ``` > [!NOTE] > 当远程数据具有分页时,可能会出现默认选项不在第一页的情况,此时 Select 组件将会发送另一个查询请求,以获取默认选项的数据。此接口会携带如下参数 `fieldSelector: ${requestOption.fieldSelectorKey}=(value1,value2,value3)`。 > 其中,value1, value2, value3 为默认选项的值。返回值与查询一致,通过 `requestOption` 解析。 ### list list 是一个数组类型的输入组件,可以让使用者可视化的操作数组。它支持动态添加、删除、上移、下移、插入数组项等操作。 在 Vue SFC 中以组件形式使用: ```vue ``` 在 FormKit Schema 中使用: ```yaml - $formkit: list name: users label: Users addLabel: Add User min: 1 max: 3 itemType: string children: - $formkit: text index: "$index" validation: required ``` > [!NOTE] > `list` 组件有且只有一个子节点,并且必须为子节点传递 `index` 属性。若想提供多个字段,则建议使用 `group` 组件包裹。 最终得到的数据类似于: ```json { "users": [ "Jack", "John" ] } ``` ### Repeater Repeater 是一个集合类型的输入组件,可以让使用者可视化的操作集合。 在 Vue SFC 中以组件形式使用: ```vue ``` 在 FormKit Schema 中使用: ```yaml - $formkit: repeater name: users label: Users addLabel: Add User min: 1 max: 3 items: - $formkit: text name: full_name label: Full Name validation: required - $formkit: email name: email label: Email validation: required|email ``` 最终得到的数据类似于: ```json [ { "full_name": "Jack", "email": "jack@example.com" }, { "full_name": "John", "email": "john@example.com" } ] ``` ================================================ FILE: ui/docs/extension-points/backup.md ================================================ # 备份页面选项卡扩展点 ## 原由 在 Halo 2.8 中提供了基础备份和恢复的功能,此扩展点是为了提供给插件开发者针对备份扩展更多功能,比如定时备份设置、备份到第三方云存储等。 ## 定义方式 ```ts import { definePlugin } from "@halo-dev/ui-shared"; import BackupStorage from "@/views/BackupStorage.vue"; import { markRaw } from "vue"; export default definePlugin({ components: {}, routes: [], extensionPoints: { "backup:tabs:create": () => { return [ { id: "storage", label: "备份位置", component: markRaw(BackupStorage), }, ]; }, }, }); ``` BackupTab 类型: ```ts import type { Component, Raw } from "vue"; export interface BackupTab { id: string; label: string; component: Raw; permissions?: string[]; } ``` ================================================ FILE: ui/docs/extension-points/comment-content.md ================================================ # 评论列表内容显示扩展点 用于替换 Halo 在 Console 的默认评论列表内容显示组件。 > 注意: > 此扩展点并非通用扩展点,由于 Halo 早期设定,Halo 在前台的评论组件 UI 部分由 [评论组件插件](http://github.com/halo-dev/plugin-comment-widget) 提供,而在此插件的后续版本中提供了富文本渲染的功能,所以为了保持 Console 的评论列表内容显示与前台一致,所以专为此插件提供了替换输入框的扩展点。 ## 定义方式 ```ts import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; import CommentContent from "./components/CommentContent.vue"; export default definePlugin({ extensionPoints: { "comment:list-item:content:replace": () => { return { component: markRaw(CommentContent), }; }, }, }); ``` 其中,组件需要包含的 props 如下: 1. `content`:评论内容,`html` 格式。 ================================================ FILE: ui/docs/extension-points/comment-editor.md ================================================ # 评论编辑器扩展点 用于替换 Halo 在 Console 的默认评论输入框。 > 注意: > 此扩展点并非通用扩展点,由于 Halo 早期设定,Halo 在前台的评论组件 UI 部分由 [评论组件插件](http://github.com/halo-dev/plugin-comment-widget) 提供,而在此插件的后续版本中提供了富文本编辑器的功能,所以为了保持 Console 的评论输入框与前台一致,所以专为此插件提供了替换输入框的扩展点。 ## 定义方式 ```ts import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; import CommentEditor from "./components/CommentEditor.vue"; export default definePlugin({ extensionPoints: { "comment:editor:replace": () => { return { component: markRaw(CommentEditor), }; }, }, }); ``` 其中,组件需要包含的 props 如下: 1. `autoFocus`:是否自动聚焦,需要在组件中判断是否为 `true`,然后聚焦输入框。 需要定义的 emit 如下: 1. `(event: "update", value: { content: string; characterCount: number })`:向调用方传递内容和字符数更新的事件。 ================================================ FILE: ui/docs/extension-points/comment-subject-ref.md ================================================ # 评论来源显示拓展点 在 Console 中,评论管理列表的评论来源默认仅支持显示来自文章和页面的评论,如果其他插件中的业务模块也使用了评论,那么就可以通过该拓展点来扩展评论来源的显示。 ## 定义方式 假设以文章为例: ```ts import { definePlugin } from "@halo-dev/ui-shared"; import type { CommentSubjectRefResult } from "@halo-dev/ui-shared"; import type { Extension } from "@halo-dev/api-client"; import type { Post } from "./types"; export default definePlugin({ components: {}, extensionPoints: { "comment:subject-ref:create": () => { return [ { kind: "Post", group: "post.halo.run", resolve: (subject: Extension): CommentSubjectRefResult => { const post = subject as Post; return { label: "文章", title: post.spec.title, externalUrl: post.status.permalink, route: { name: "PostEditor", params: { name: post.metadata.name } }, }; }, }, ]; }, }, }); ``` 类型定义如下: ```ts type CommentSubjectRefProvider = { kind: string; // 自定义模型的类型 group: string; // 自定义模型的分组 resolve: (subject: Extension) => CommentSubjectRefResult; } interface CommentSubjectRefResult { label: string; // 来源名称(类型) title: string; // 来源标题 route?: RouteLocationRaw; // Console 的路由,可以设置为来源的详情或者编辑页面 externalUrl?: string; // 访问地址,可以设置为前台资源的访问地址 } ``` ================================================ FILE: ui/docs/extension-points/dashboard.md ================================================ # 仪表盘扩展点 ## 概述 仪表盘扩展点允许插件为 Halo 的控制台仪表盘添加自定义小部件和快速操作项。通过这些扩展点,插件可以: - 创建自定义的仪表盘小部件来展示特定数据或功能 - 为快速操作小部件添加自定义操作项 ## console:dashboard:widgets:create 此扩展点用于创建自定义的仪表盘小部件。 ### 定义方式 ```ts import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; import MyCustomWidget from "./components/MyCustomWidget.vue"; export default definePlugin({ extensionPoints: { "console:dashboard:widgets:create": () => { return [ { id: "my-custom-widget", component: markRaw(MyCustomWidget), group: "my-plugin", configFormKitSchema: [ { $formkit: "text", name: "title", label: "标题", value: "默认标题", }, { $formkit: "number", name: "refresh_interval", label: "刷新间隔(秒)", value: 30, min: 10, }, ], defaultConfig: { title: "我的自定义小部件", refresh_interval: 30, }, defaultSize: { w: 6, h: 8, minW: 3, minH: 4, maxW: 12, maxH: 16, }, permissions: ["plugin:my-plugin:view"], }, ]; }, }, }); ``` ### DashboardWidgetDefinition 类型 ```ts export interface DashboardWidgetDefinition { id: string; // 小部件唯一标识符 component: Raw; // 小部件 Vue 组件 group: string; // 小部件分组,用于在小部件库中分类显示 configFormKitSchema?: | Record[] | (() => Promise[]>) | (() => Record[]); // 配置表单 FormKit 定义,支持异步函数 defaultConfig?: Record; // 默认配置 defaultSize: { // 默认尺寸 w: number; // 宽度(网格单位),根据不同屏幕尺寸,网格单位不同,可参考:{ lg: 12, md: 12, sm: 6, xs: 4 } h: number; // 高度(网格单位) minW?: number; // 最小宽度 minH?: number; // 最小高度 maxW?: number; // 最大宽度 maxH?: number; // 最大高度 }; permissions?: string[]; // 访问权限 } ``` ### 小部件组件开发 ```vue ``` **小部件组件的属性与事件:** | 属性 | 类型 | 说明 | |---------------|-------------------------|--------------| | `editMode` | boolean | 是否为编辑模式 | | `previewMode` | boolean | 是否为预览模式 | | `config` | Record | 小部件配置 | | 事件 | 说明 | |-----------------|------------------| | `update:config` | 小部件配置更新事件 | **WidgetCard 组件的属性与插槽:** | 属性 | 类型 | 说明 | |---------------|-------------------------|--------------| | `title` | string | 小部件标题 | | `bodyClass` | string[] | 小部件内容区域样式 | | 插槽 | 说明 | |-----------|------------| | `title` | 小部件标题 | | `default` | 小部件内容 | | `actions` | 小部件操作项 | **重要说明:** - 小部件组件必须使用 `WidgetCard` 作为根组件,此组件已经在全局注册,不需要导入 - 支持 `editMode` 和 `previewMode` 两种模式,当仪表盘处于编辑页面时,`editMode` 为 `true`,在小组件选择列表时,`previewMode` 为 `true`。可以根据这两个属性来控制小部件的显示内容 - `update:config` 事件通常不需要实现,已经在内部实现了打开配置表单的功能,此事件用于自行实现配置表单 - 使用 `markRaw()` 包装组件以避免响应式转换 ## console:dashboard:widgets:internal:quick-action:item:create 此扩展点用于为快速操作小部件添加自定义操作项。 ### 定义方式 ```ts import { definePlugin } from "@halo-dev/ui-shared"; import { markRaw } from "vue"; import { IconPlug } from "@halo-dev/components"; import { useRouter } from "vue-router"; export default definePlugin({ extensionPoints: { "console:dashboard:widgets:internal:quick-action:item:create": () => { return [ { id: "my-plugin-action", icon: markRaw(IconPlug), title: "我的插件操作", action: () => { // do something }, permissions: ["plugin:my-plugin:manage"], }, ]; }, }, }); ``` ### 自定义组件操作项 你也可以提供自定义组件而不是标准的操作项: ```ts import CustomActionItem from "./components/CustomActionItem.vue"; export default definePlugin({ extensionPoints: { "console:dashboard:widgets:internal:quick-action:item:create": () => { return [ { id: "custom-action", component: markRaw(CustomActionItem), permissions: ["plugin:my-plugin:view"], }, ]; }, }, }); ``` 自定义组件: ```vue ``` ### DashboardWidgetQuickActionItem 类型 ```ts interface DashboardWidgetQuickActionBaseItem { id: string; // 操作项唯一标识符 permissions?: string[]; // 访问权限 } interface DashboardWidgetQuickActionComponentItem extends DashboardWidgetQuickActionBaseItem { component: Raw; // 自定义组件 icon?: Raw; // 图标(可选) title?: string; // 标题(可选) action?: () => void; // 点击操作(可选) } interface DashboardWidgetQuickActionStandardItem extends DashboardWidgetQuickActionBaseItem { component?: never; // 不使用自定义组件 icon: Raw; // 图标(必需) title: string; // 标题(必需) action: () => void; // 点击操作(必需) } interface DashboardWidgetQuickActionRouteItem extends DashboardWidgetQuickActionBaseItem { component?: never; // 不使用自定义组件 action?: never; // 不使用 action 回调 icon: Raw; // 图标组件(必需) title: string; // 标题文本(必需) route: RouteLocationRaw; // 导航目标路由(必需),可以是路由名称、路径或完整路由配置 } export type DashboardWidgetQuickActionItem = | DashboardWidgetQuickActionComponentItem | DashboardWidgetQuickActionStandardItem | DashboardWidgetQuickActionRouteItem; ``` ## 权限控制 两个扩展点都支持权限控制: - **小部件权限**:通过 `permissions` 字段控制小部件的显示 - **操作项权限**:通过 `permissions` 字段控制快速操作项的显示 权限检查会自动进行,用户只能看到有权限访问的小部件和操作项。 ================================================ FILE: ui/docs/extension-points/default-editor–extension.md ================================================ # 默认编辑器扩展点 该扩展点用于扩展默认编辑器的功能,包括 Tiptap Extension,以及工具栏、悬浮工具栏、Slash Command。 ## 定义方式 ```ts import ExtensionFoo from "./tiptap/extension-foo.ts" export default definePlugin({ extensionPoints: { "default:editor:extension:create": () => { return [ExtensionFoo]; }, }, }); ``` 其中,`ExtensionFoo` 是一个 Tiptap Extension,可以参考 [Tiptap 文档](https://tiptap.dev/) 和 [https://github.com/halo-sigs/richtext-editor/blob/main/docs/extension.md](https://github.com/halo-sigs/richtext-editor/blob/main/docs/extension.md)。 ================================================ FILE: ui/docs/extension-points/editor.md ================================================ # 编辑器集成扩展点 ## 定义方式 ```ts import MarkdownEditor from "./components/MarkdownEditor.vue" export default definePlugin({ extensionPoints: { "editor:create": () => { return [ { name: "markdown-editor", displayName: "Markdown", logo: "logo.png" component: markRaw(MarkdownEditor), rawType: "markdown", }, ]; }, }, }); ``` - name: 编辑器名称,用于标识编辑器 - displayName: 编辑器显示名称 - component: 编辑器组件 - rawType: 编辑器支持的原始类型,可以完全由插件定义。但必须保证最终能够将渲染后的 html 设置到 content 中。 ## 组件 组件必须设置两个 `v-model` 绑定。即 `v-model:raw` 和 `v-model:content`,以下是示例: ```vue