commit 3be20f90fc8e3a8d7e0b61f6332b106017cfd113 Author: zhouhongshuo <409581486@qq.com> Date: Mon Aug 26 00:08:18 2024 +0800 chushihua diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..1c64b43 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,49 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/docker-in-docker +{ + "name": "Halo Dev Container", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/base:bullseye", + + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "version": "20.10", + "enableNonRootDocker": "true", + "moby": "true" + }, + "ghcr.io/devcontainers/features/java:1": { + "version": "17", + "jdkDistro": "tem" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "20" + }, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "Vue.volar", + "vscodevim.vim", + "shengchen.vscode-checkstyle", + "streetsidesoftware.code-spell-checker", + "vscjava.vscode-gradle", + "vmware.vscode-boot-dev-pack", + "vscjava.vscode-java-pack", + "bradlc.vscode-tailwindcss" + ] + } + } + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "docker --version", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..494ab7a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +ui +.github +.git \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8508475 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,513 @@ +[*] +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 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c161ab5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.js linguist-language=Java +*.css linguist-language=Java +*.ftl linguist-language=FreeMarker +*.html linguist-language=Vue diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..2644645 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ["https://afdian.com/a/halo-dev"] diff --git a/.github/ISSUE_TEMPLATE/bug_report.en.yml b/.github/ISSUE_TEMPLATE/bug_report.en.yml new file mode 100644 index 0000000..0ab1c8b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.en.yml @@ -0,0 +1,80 @@ +name: Bug Report +description: File a bug report +labels: [bug] +body: + - type: markdown + id: preface + attributes: + value: | + 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. + + **Before submitting, please check:** + + - You have searched for related issues in the [issues](https://github.com/halo-dev/halo/issues) list. + - 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). + - You have tried disabling all plugins to rule out plugins as the cause of the problem. + - If it is an issue with plugins and themes, please submit it in the respective plugin and theme repositories. + - 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.)." diff --git a/.github/ISSUE_TEMPLATE/bug_report.zh.yml b/.github/ISSUE_TEMPLATE/bug_report.zh.yml new file mode 100644 index 0000000..0cc3f6b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.zh.yml @@ -0,0 +1,80 @@ +name: Bug 反馈 +description: 提交 Bug 反馈 +labels: [bug] +body: + - type: markdown + id: preface + attributes: + value: | + 感谢你花时间填写此错误报告!在开始之前,我们非常推荐阅读一遍[《开源软件指南》](https://opensource.guide/zh-hans/),这会在很大程度上提高我们彼此的效率。 + + **在提交之前,请检查:** + + - 已经在 [issues](https://github.com/halo-dev/halo/issues) 列表中搜索了相关问题。 + - 这是 Halo 项目本身存在的问题,如果是非项目本身的问题(如:安装部署问题),建议在 [Discussions](https://github.com/halo-dev/halo/discussions) 提交。 + - 已经尝试过停用所有的插件,排除是插件导致的问题。 + - 如果是插件和主题的问题,请在对应的插件和主题仓库提交。 + - 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: "如果你还有其他需要提供的信息,可以在这里填写(可以提供截图、视频等)。" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..fbbb7b5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: 对 Halo 有其他问题 + url: https://bbs.halo.run + about: 如果你对 Halo 有其他想要提问的,我们欢迎到我们的官方社区进行提问。 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.en.yml b/.github/ISSUE_TEMPLATE/feature_request.en.yml new file mode 100644 index 0000000..e00b707 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.en.yml @@ -0,0 +1,38 @@ +name: Feature Request +description: File a feature request +body: + - type: markdown + id: preface + attributes: + value: | + 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. + + **Before submitting, please check:** + + - You have searched for related issues in the [issues](https://github.com/halo-dev/halo/issues) list. + - 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). + - If it is a feature suggestion for plugins and themes, please submit it in the respective plugin and theme repositories. + - 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.)." \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.zh.yml b/.github/ISSUE_TEMPLATE/feature_request.zh.yml new file mode 100644 index 0000000..5768cb3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.zh.yml @@ -0,0 +1,38 @@ +name: 新特性建议 +description: 提交新特性建议 +body: + - type: markdown + id: preface + attributes: + value: | + 你好!感谢你为 Halo 提交新特性建议。在开始之前,我们非常推荐阅读一遍[《开源软件指南》](https://opensource.guide/zh-hans/),这会在很大程度上提高我们彼此的效率。 + + **在提交之前,请检查:** + + - 已经在 [issues](https://github.com/halo-dev/halo/issues) 列表中搜索了相关问题。 + - 这是和 Halo 相关的特性,如果是非项目本身的问题,建议在 [Discussions](https://github.com/halo-dev/halo/discussions) 提交。 + - 如果是插件和主题特性建议,请在对应的插件和主题仓库提交。 + - 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: "如果你还有其他需要提供的信息,可以在这里填写(可以提供截图、视频等)。" \ No newline at end of file diff --git a/.github/actions/docker-buildx-push/action.yaml b/.github/actions/docker-buildx-push/action.yaml new file mode 100644 index 0000000..d377b43 --- /dev/null +++ b/.github/actions/docker-buildx-push/action.yaml @@ -0,0 +1,75 @@ +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: "" + 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 }} + 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: 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' }} diff --git a/.github/actions/setup-env/action.yaml b/.github/actions/setup-env/action.yaml new file mode 100644 index 0000000..b373b75 --- /dev/null +++ b/.github/actions/setup-env/action.yaml @@ -0,0 +1,40 @@ +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: "20" + + pnpm-version: + description: pnpm version. + required: false + default: "9" + + java-version: + description: Java version. + required: false + default: "17" + +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 }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a910d63 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..59c3107 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,59 @@ + + +#### 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 +``` diff --git a/.github/workflows/halo.yaml b/.github/workflows/halo.yaml new file mode 100644 index 0000000..b83bae4 --- /dev/null +++ b/.github/workflows/halo.yaml @@ -0,0 +1,126 @@ +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 check + - 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 && ./gradlew downloadPluginPresets && ./gradlew build -x check + - 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/* + + 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 }} + 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db22387 --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +### 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 + +### 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/ diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 0000000..512cc0a --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,8 @@ +# This configuration file was automatically generated by Gitpod. +# Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) +# and commit this file to your remote git repository to share the goodness with others. + +# Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart + +tasks: + - init: ./gradlew clean build -x check && sdk install java 17.0.3-ms diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b799482 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a3ed620 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ +> 欢迎你参与 Halo 的开发,下面是参与代码贡献的指南,以供参考。 + +### 代码贡献步骤 + +#### 0. 提交 issue + +任何新功能或者功能改进建议都先提交 issue 讨论一下再进行开发,bug 修复可以直接提交 pull request。 + +#### 1. Fork 此仓库 + +点击右上角的 `fork` 按钮即可。 + +#### 2. Clone 仓库到本地 + +```bash +git clone https://github.com/{YOUR_USERNAME}/halo + +git submodule init + +git submodule update +``` + +#### 3. 创建新的开发分支 + +```bash +git checkout -b {BRANCH_NAME} +``` + +#### 4. 提交代码 + +```bash +git push origin {BRANCH_NAME} +``` + +#### 5. 提交 pull request + +回到自己的仓库页面,选择 `New pull request` 按钮,创建 `Pull request` 到原仓库的 `main` 分支。 + +然后等待我们 Review 即可,如有 `Change Request`,再本地修改之后再次提交即可。 + +#### 6. 更新主仓库代码到自己的仓库 + +```bash +git remote add upstream git@github.com:halo-dev/halo.git + +git pull upstream main + +git push +``` + +### E2E + +Please consider adding some [e2e test cases](e2e/README.md) to make sure the APIs work as expected. + +### 开发规范 + +请参考 [https://docs.halo.run/developer-guide/core/code-style](https://docs.halo.run/developer-guide/core/code-style),请确保所有代码格式化之后再提交。 + +### Usage of Cherry Pick Script + +We can use the cherry pick script to cherry-pick commits in pull request as follows: + +```bash +GITHUB_USER={your_github_user} hack/cherry_pick_pull.sh upstream/{target_branch} {pull_request_number} +``` + +> This script is from . + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..369303d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +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="-Xmx256m -Xms256m" \ + 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} ${@}"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + 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 +. diff --git a/OWNERS b/OWNERS new file mode 100644 index 0000000..a707d8b --- /dev/null +++ b/OWNERS @@ -0,0 +1,11 @@ +reviewers: +- ruibaby +- guqing +- JohnNiang +- wan92hen +- LIlGG + +approvers: +- ruibaby +- guqing +- JohnNiang diff --git a/README.md b/README.md new file mode 100644 index 0000000..737e67f --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +

+ + Halo logo + +

+ +

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

+ +

+GitHub release +Docker pulls +GitHub last commit +GitHub Workflow Status +Codecov percentage +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) + +------------------------------ + +## 快速开始 + +```bash +docker run -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.18 +``` + +以上仅作为体验使用,详细部署文档请查阅: + +## 在线体验 + +- 环境地址: +- 后台地址: +- 用户名:`demo` +- 密码:`P@ssw0rd123..` + +## 生态 + +可访问 [官方应用市场](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 协议开源,请遵守开源协议。 + +## 赞助 + +如果 Halo 对你有帮助,欢迎[赞助我们](https://afdian.com/a/halo-dev),感谢以下赞助者对 Halo 项目的支持: + +

+ + sponsors + +

+ +## 贡献 + +参考 [CONTRIBUTING](https://github.com/halo-dev/halo/blob/main/CONTRIBUTING.md)。 + + + +## 状态 + +![Repobeats analytics](https://repobeats.axiom.co/api/embed/ad008b2151c22e7cf734d2688befaa795d593b95.svg "Repobeats analytics image") diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c2fa5cd --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,42 @@ +# 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]: +``` diff --git a/api-docs/openapi/v3_0/aggregated.json b/api-docs/openapi/v3_0/aggregated.json new file mode 100644 index 0000000..ec15fbc --- /dev/null +++ b/api-docs/openapi/v3_0/aggregated.json @@ -0,0 +1,23049 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Halo", + "version": "2.19.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": { + "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/indices/post": { + "post": { + "deprecated": true, + "description": "Build or rebuild post indices for full text search. This method is deprecated, please use POST /indices/-/rebuild instead.", + "operationId": "BuildPostIndices", + "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/plugin-presets": { + "get": { + "description": "List all plugin presets in the system.", + "operationId": "ListPluginPresets", + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Plugin" + } + } + } + }, + "description": "default response" + } + }, + "tags": [ + "PluginV1alpha1Console" + ] + } + }, + "/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}/config": { + "get": { + "description": "Fetch configMap of plugin by configured configMapName.", + "operationId": "fetchPluginConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ConfigMap" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "PluginV1alpha1Console" + ] + }, + "put": { + "description": "Update the configMap of plugin setting.", + "operationId": "updatePluginConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigMap" + } + } + }, + "required": true + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ConfigMap" + } + } + }, + "description": "default response" + } + }, + "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": "Publish 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/system/initialize": { + "post": { + "description": "Initialize system", + "operationId": "initialize", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SystemInitializationRequest" + } + } + } + }, + "responses": { + "201": { + "description": "System initialization successfully.", + "headers": { + "Location": { + "description": "Redirect URL.", + "schema": { + "type": "string" + }, + "style": "simple" + } + } + } + }, + "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}/config": { + "get": { + "description": "Fetch configMap of theme by configured configMapName.", + "operationId": "fetchThemeConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ConfigMap" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "ThemeV1alpha1Console" + ] + }, + "put": { + "description": "Update the configMap of theme setting.", + "operationId": "updateThemeConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigMap" + } + } + }, + "required": true + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ConfigMap" + } + } + }, + "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}/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/indices/post": { + "get": { + "deprecated": true, + "description": "Search posts with fuzzy query. This method is deprecated, please use POST /indices/-/search instead.", + "operationId": "SearchPost", + "parameters": [ + { + "description": "Keyword to search", + "in": "query", + "name": "keyword", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Limit of search results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Highlight pre tag", + "in": "query", + "name": "highlightPreTag", + "schema": { + "type": "string" + } + }, + { + "description": "Highlight post tag", + "in": "query", + "name": "highlightPostTag", + "schema": { + "type": "string" + } + } + ], + "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.halo.run/v1alpha1/users/-/send-password-reset-email": { + "post": { + "description": "Send password reset email when forgot password", + "operationId": "SendPasswordResetEmail", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PasswordResetEmailRequest" + } + } + }, + "required": true + }, + "responses": { + "204 NO_CONTENT": { + "content": {}, + "description": "default response" + } + }, + "tags": [ + "UserV1alpha1Public" + ] + } + }, + "/apis/api.halo.run/v1alpha1/users/-/send-register-verify-email": { + "post": { + "description": "Send registration verification email, which can be called when mustVerifyEmailOnRegistration in user settings is true", + "operationId": "SendRegisterVerifyEmail", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/RegisterVerifyEmailRequest" + } + } + }, + "required": true + }, + "responses": { + "204 NO_CONTENT": { + "content": {}, + "description": "default response" + } + }, + "tags": [ + "UserV1alpha1Public" + ] + } + }, + "/apis/api.halo.run/v1alpha1/users/-/signup": { + "post": { + "description": "Sign up a new user", + "operationId": "SignUp", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SignUpRequest" + } + } + }, + "required": true + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "UserV1alpha1Public" + ] + } + }, + "/apis/api.halo.run/v1alpha1/users/{name}/reset-password": { + "put": { + "description": "Reset password by token", + "operationId": "ResetPasswordByToken", + "parameters": [ + { + "description": "The name of the user", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "204 NO_CONTENT": { + "content": {}, + "description": "default response" + } + }, + "tags": [ + "UserV1alpha1Public" + ] + } + }, + "/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": [ + "api.notification.halo.run/v1alpha1/Subscription" + ] + } + }, + "/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/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.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/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/plugin.halo.run/v1alpha1/searchengines": { + "get": { + "description": "List SearchEngine", + "operationId": "listSearchEngine", + "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/SearchEngineList" + } + } + }, + "description": "Response searchengines" + } + }, + "tags": [ + "SearchEngineV1alpha1" + ] + }, + "post": { + "description": "Create SearchEngine", + "operationId": "createSearchEngine", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SearchEngine" + } + } + }, + "description": "Fresh searchengine" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SearchEngine" + } + } + }, + "description": "Response searchengines created just now" + } + }, + "tags": [ + "SearchEngineV1alpha1" + ] + } + }, + "/apis/plugin.halo.run/v1alpha1/searchengines/{name}": { + "delete": { + "description": "Delete SearchEngine", + "operationId": "deleteSearchEngine", + "parameters": [ + { + "description": "Name of searchengine", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response searchengine deleted just now" + } + }, + "tags": [ + "SearchEngineV1alpha1" + ] + }, + "get": { + "description": "Get SearchEngine", + "operationId": "getSearchEngine", + "parameters": [ + { + "description": "Name of searchengine", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SearchEngine" + } + } + }, + "description": "Response single searchengine" + } + }, + "tags": [ + "SearchEngineV1alpha1" + ] + }, + "patch": { + "description": "Patch SearchEngine", + "operationId": "patchSearchEngine", + "parameters": [ + { + "description": "Name of searchengine", + "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/SearchEngine" + } + } + }, + "description": "Response searchengine patched just now" + } + }, + "tags": [ + "SearchEngineV1alpha1" + ] + }, + "put": { + "description": "Update SearchEngine", + "operationId": "updateSearchEngine", + "parameters": [ + { + "description": "Name of searchengine", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SearchEngine" + } + } + }, + "description": "Updated searchengine" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SearchEngine" + } + } + }, + "description": "Response searchengines updated just now" + } + }, + "tags": [ + "SearchEngineV1alpha1" + ] + } + }, + "/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/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/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.content.halo.run/v1alpha1/attachments": { + "post": { + "description": "Create attachment for the given post.", + "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.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}/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.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" + ] + } + }, + "/login/public-key": { + "get": { + "description": "Read public key for encrypting password.", + "operationId": "GetPublicKey", + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PublicKeyResponse" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "Login" + ] + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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": [ + "authenticationUrl", + "displayName" + ], + "type": "object", + "properties": { + "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" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "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" + }, + "lastModifiedTime": { + "type": "string", + "format": "date-time" + }, + "size": { + "type": "integer", + "format": "int64" + } + } + }, + "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" + }, + "phase": { + "type": "string", + "enum": [ + "PENDING", + "RUNNING", + "SUCCEEDED", + "FAILED" + ] + }, + "size": { + "type": "integer", + "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" + }, + "postTemplate": { + "maxLength": 255, + "type": "string" + }, + "preventParentPostCascadeQuery": { + "type": "boolean" + }, + "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", + "format": "int32" + }, + "visiblePostCount": { + "type": "integer", + "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" + } + } + }, + "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": 6, + "type": "string", + "description": "New password." + } + } + }, + "ChangePasswordRequest": { + "required": [ + "password" + ], + "type": "object", + "properties": { + "password": { + "minLength": 6, + "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" + }, + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "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" + }, + "owner": { + "$ref": "#/components/schemas/CommentEmailOwner" + }, + "raw": { + "minLength": 1, + "type": "string" + }, + "subjectRef": { + "$ref": "#/components/schemas/Ref" + } + } + }, + "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", + "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" + } + } + }, + "CommentStatsVo": { + "type": "object", + "properties": { + "upvote": { + "type": "integer", + "format": "int32" + } + } + }, + "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", + "format": "date-time" + }, + "message": { + "maxLength": 32768, + "type": "string" + }, + "reason": { + "maxLength": 1024, + "pattern": "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$", + "type": "string" + }, + "status": { + "type": "string", + "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" + } + } + }, + "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" + } + } + }, + "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": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "raw": { + "type": "string" + }, + "rawType": { + "type": "string" + } + } + }, + "ContentUpdateParam": { + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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": "int32" + }, + "comments": { + "type": "integer", + "format": "int32" + }, + "posts": { + "type": "integer", + "format": "int32" + }, + "upvotes": { + "type": "integer", + "format": "int32" + }, + "users": { + "type": "integer", + "format": "int32" + }, + "visits": { + "type": "integer", + "format": "int32" + } + } + }, + "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" + }, + "enable": { + "type": "boolean" + }, + "encryption": { + "type": "string" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer", + "format": "int32" + }, + "sender": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "EmailVerifyRequest": { + "required": [ + "email" + ], + "type": "object", + "properties": { + "email": { + "type": "string" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + }, + "kind": { + "type": "string" + } + } + }, + "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" + } + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "content": { + "type": "string" + }, + "creationTimestamp": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": "string" + }, + "exposed": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "metadataName": { + "type": "string" + }, + "ownerName": { + "type": "string" + }, + "permalink": { + "type": "string" + }, + "published": { + "type": "boolean" + }, + "recycled": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updateTimestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "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" + } + } + }, + "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": { + "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" + }, + "privileged": { + "type": "boolean" + }, + "supportsBinding": { + "type": "boolean" + }, + "unbindingUrl": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "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" + } + } + } + }, + "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." + }, + "LoginHistory": { + "required": [ + "loginAt", + "sourceIp", + "successful", + "userAgent" + ], + "type": "object", + "properties": { + "loginAt": { + "type": "string", + "format": "date-time" + }, + "reason": { + "type": "string" + }, + "sourceIp": { + "type": "string" + }, + "successful": { + "type": "boolean" + }, + "userAgent": { + "type": "string" + } + } + }, + "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" + } + }, + "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." + }, + "MenuItemVo": { + "required": [ + "metadata" + ], + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "parentName": { + "type": "string" + }, + "spec": { + "$ref": "#/components/schemas/MenuItemSpec" + }, + "status": { + "$ref": "#/components/schemas/MenuItemStatus" + } + } + }, + "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": "Names of menu children below this menu.", + "items": { + "type": "string", + "description": "Names of menu children below this menu." + } + } + }, + "description": "The spec of menu." + }, + "MenuVo": { + "required": [ + "metadata" + ], + "type": "object", + "properties": { + "menuItems": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MenuItemVo" + } + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/MenuSpec" + } + } + }, + "Metadata": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "creationTimestamp": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "deletionTimestamp": { + "type": "string", + "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" + } + }, + "name": { + "type": "string", + "description": "Metadata name" + }, + "version": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, + "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": { + "current": { + "$ref": "#/components/schemas/PostVo" + }, + "next": { + "$ref": "#/components/schemas/PostVo" + }, + "previous": { + "$ref": "#/components/schemas/PostVo" + } + } + }, + "Notification": { + "required": [ + "apiVersion", + "kind", + "metadata" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/NotificationSpec" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "PasswordRequest": { + "required": [ + "password" + ], + "type": "object", + "properties": { + "password": { + "type": "string" + } + } + }, + "PasswordResetEmailRequest": { + "required": [ + "email", + "username" + ], + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "username": { + "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" + } + } + }, + "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" + }, + "pluginClass": { + "type": "string", + "deprecated": true + }, + "pluginDependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "repo": { + "type": "string" + }, + "requires": { + "type": "string" + }, + "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" + } + } + }, + "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", + "items": { + "type": "string" + } + }, + "nonResourceURLs": { + "type": "array", + "items": { + "type": "string" + } + }, + "resourceNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "verbs": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "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" + } + } + }, + "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": [ + "post" + ], + "type": "object", + "properties": { + "content": { + "$ref": "#/components/schemas/ContentUpdateParam" + }, + "post": { + "$ref": "#/components/schemas/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" + }, + "slug": { + "minLength": 1, + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "string" + }, + "title": { + "minLength": 1, + "type": "string" + }, + "visible": { + "type": "string", + "default": "PUBLIC", + "enum": [ + "PUBLIC", + "INTERNAL", + "PRIVATE" + ] + } + } + }, + "PostStatus": { + "required": [ + "phase" + ], + "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" + }, + "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" + } + } + } + }, + "PublicKeyResponse": { + "type": "object", + "properties": { + "base64Format": { + "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" + } + } + }, + "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" + } + } + }, + "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": [ + "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" + }, + "RegisterVerifyEmailRequest": { + "required": [ + "email" + ], + "type": "object", + "properties": { + "email": { + "type": "string" + } + } + }, + "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" + }, + "owner": { + "$ref": "#/components/schemas/CommentEmailOwner" + }, + "quoteReply": { + "type": "string" + }, + "raw": { + "minLength": 1, + "type": "string" + } + } + }, + "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", + "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" + } + } + }, + "ResetPasswordRequest": { + "required": [ + "newPassword", + "token" + ], + "type": "object", + "properties": { + "newPassword": { + "minLength": 6, + "type": "string" + }, + "token": { + "type": "string" + } + } + }, + "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" + } + } + } + }, + "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", + "items": { + "$ref": "#/components/schemas/Subject" + } + } + } + }, + "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" + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "SearchEngine": { + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/SearchEngineSpec" + } + } + }, + "SearchEngineList": { + "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/SearchEngine" + } + }, + "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" + } + } + }, + "SearchEngineSpec": { + "required": [ + "displayName" + ], + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "logo": { + "type": "string" + }, + "postSearchImpl": { + "type": "string" + }, + "settingRef": { + "$ref": "#/components/schemas/Ref" + }, + "website": { + "type": "string" + } + } + }, + "SearchOption": { + "required": [ + "keyword" + ], + "type": "object", + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "filterExposed": { + "type": "boolean" + }, + "filterPublished": { + "type": "boolean" + }, + "filterRecycled": { + "type": "boolean" + }, + "highlightPostTag": { + "type": "string" + }, + "highlightPreTag": { + "type": "string" + }, + "includeCategoryNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "includeOwnerNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "includeTagNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "includeTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "keyword": { + "type": "string" + }, + "limit": { + "maximum": 1000, + "minimum": 1, + "type": "integer", + "format": "int32" + } + } + }, + "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" + } + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "stringData": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "type": { + "type": "string" + } + } + }, + "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" + } + } + }, + "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" + } + } + } + }, + "SignUpRequest": { + "required": [ + "password", + "user" + ], + "type": "object", + "properties": { + "password": { + "minLength": 6, + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/User" + }, + "verifyCode": { + "maxLength": 6, + "minLength": 6, + "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" + } + } + }, + "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" + } + } + }, + "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" + }, + "slug": { + "minLength": 1, + "type": "string" + }, + "template": { + "type": "string" + }, + "title": { + "minLength": 1, + "type": "string" + }, + "visible": { + "type": "string", + "default": "PUBLIC", + "enum": [ + "PUBLIC", + "INTERNAL", + "PRIVATE" + ] + } + } + }, + "SinglePageStatus": { + "required": [ + "phase" + ], + "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" + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + }, + "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" + } + } + }, + "StatsVo": { + "type": "object", + "properties": { + "comment": { + "type": "integer", + "format": "int32" + }, + "upvote": { + "type": "integer", + "format": "int32" + }, + "visit": { + "type": "integer", + "format": "int32" + } + } + }, + "Subject": { + "type": "object", + "properties": { + "apiGroup": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "Subscription": { + "required": [ + "apiVersion", + "kind", + "metadata" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/SubscriptionSpec" + } + } + }, + "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" + }, + "SystemInitializationRequest": { + "required": [ + "password", + "username" + ], + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "minLength": 3, + "type": "string" + }, + "siteTitle": { + "type": "string" + }, + "username": { + "minLength": 1, + "type": "string" + } + } + }, + "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" + }, + "cover": { + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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", + "version" + ], + "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" + }, + "require": { + "type": "string", + "description": "Deprecated, use `requires` instead.", + "deprecated": true + }, + "requires": { + "type": "string" + }, + "settingName": { + "type": "string" + }, + "version": { + "type": "string" + }, + "website": { + "type": "string", + "deprecated": true + } + } + }, + "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" + ] + } + } + }, + "TotpAuthLinkResponse": { + "type": "object", + "properties": { + "authLink": { + "type": "string", + "format": "uri" + }, + "rawSecret": { + "type": "string" + } + } + }, + "TotpRequest": { + "required": [ + "code", + "password", + "secret" + ], + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "password": { + "type": "string" + }, + "secret": { + "type": "string" + } + } + }, + "TwoFactorAuthSettings": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + }, + "emailVerified": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "totpConfigured": { + "type": "boolean" + } + } + }, + "UpgradeFromUriRequest": { + "required": [ + "uri" + ], + "type": "object", + "properties": { + "uri": { + "type": "string", + "format": "uri" + } + } + }, + "UpgradeRequest": { + "required": [ + "file" + ], + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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": [ + "accessToken", + "displayName", + "providerUserId", + "registrationId", + "username" + ], + "type": "object", + "properties": { + "accessToken": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "profileUrl": { + "type": "string" + }, + "providerUserId": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "registrationId": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "username": { + "type": "string" + } + } + }, + "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": { + "lastLoginAt": { + "type": "string", + "format": "date-time" + }, + "loginHistories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LoginHistory" + } + }, + "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" + } + } + } +} \ No newline at end of file diff --git a/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json new file mode 100644 index 0000000..b91a91f --- /dev/null +++ b/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json @@ -0,0 +1,6456 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Halo", + "version": "2.19.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": { + "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/indices/post": { + "post": { + "deprecated": true, + "description": "Build or rebuild post indices for full text search. This method is deprecated, please use POST /indices/-/rebuild instead.", + "operationId": "BuildPostIndices", + "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/plugin-presets": { + "get": { + "description": "List all plugin presets in the system.", + "operationId": "ListPluginPresets", + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Plugin" + } + } + } + }, + "description": "default response" + } + }, + "tags": [ + "PluginV1alpha1Console" + ] + } + }, + "/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}/config": { + "get": { + "description": "Fetch configMap of plugin by configured configMapName.", + "operationId": "fetchPluginConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ConfigMap" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "PluginV1alpha1Console" + ] + }, + "put": { + "description": "Update the configMap of plugin setting.", + "operationId": "updatePluginConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigMap" + } + } + }, + "required": true + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ConfigMap" + } + } + }, + "description": "default response" + } + }, + "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": "Publish 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/system/initialize": { + "post": { + "description": "Initialize system", + "operationId": "initialize", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SystemInitializationRequest" + } + } + } + }, + "responses": { + "201": { + "description": "System initialization successfully.", + "headers": { + "Location": { + "description": "Redirect URL.", + "schema": { + "type": "string" + }, + "style": "simple" + } + } + } + }, + "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}/config": { + "get": { + "description": "Fetch configMap of theme by configured configMapName.", + "operationId": "fetchThemeConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ConfigMap" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "ThemeV1alpha1Console" + ] + }, + "put": { + "description": "Update the configMap of theme setting.", + "operationId": "updateThemeConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigMap" + } + } + }, + "required": true + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ConfigMap" + } + } + }, + "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}/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.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" + ] + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "AuthProviderSpec": { + "required": [ + "authenticationUrl", + "displayName" + ], + "type": "object", + "properties": { + "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" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "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" + }, + "lastModifiedTime": { + "type": "string", + "format": "date-time" + }, + "size": { + "type": "integer", + "format": "int64" + } + } + }, + "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" + }, + "postTemplate": { + "maxLength": 255, + "type": "string" + }, + "preventParentPostCascadeQuery": { + "type": "boolean" + }, + "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", + "format": "int32" + }, + "visiblePostCount": { + "type": "integer", + "format": "int32" + } + } + }, + "ChangeOwnPasswordRequest": { + "required": [ + "oldPassword", + "password" + ], + "type": "object", + "properties": { + "oldPassword": { + "type": "string", + "description": "Old password." + }, + "password": { + "minLength": 6, + "type": "string", + "description": "New password." + } + } + }, + "ChangePasswordRequest": { + "required": [ + "password" + ], + "type": "object", + "properties": { + "password": { + "minLength": 6, + "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" + }, + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "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" + }, + "owner": { + "$ref": "#/components/schemas/CommentEmailOwner" + }, + "raw": { + "minLength": 1, + "type": "string" + }, + "subjectRef": { + "$ref": "#/components/schemas/Ref" + } + } + }, + "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", + "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" + } + } + }, + "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", + "format": "date-time" + }, + "message": { + "maxLength": 32768, + "type": "string" + }, + "reason": { + "maxLength": 1024, + "pattern": "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$", + "type": "string" + }, + "status": { + "type": "string", + "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" + } + } + }, + "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" + } + } + }, + "ConfigMapRef": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "minLength": 1, + "type": "string" + } + } + }, + "Content": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "raw": { + "type": "string" + }, + "rawType": { + "type": "string" + } + } + }, + "ContentUpdateParam": { + "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" + } + } + }, + "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": "int32" + }, + "comments": { + "type": "integer", + "format": "int32" + }, + "posts": { + "type": "integer", + "format": "int32" + }, + "upvotes": { + "type": "integer", + "format": "int32" + }, + "users": { + "type": "integer", + "format": "int32" + }, + "visits": { + "type": "integer", + "format": "int32" + } + } + }, + "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" + }, + "enable": { + "type": "boolean" + }, + "encryption": { + "type": "string" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer", + "format": "int32" + }, + "sender": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "EmailVerifyRequest": { + "required": [ + "email" + ], + "type": "object", + "properties": { + "email": { + "type": "string" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "ListedAuthProvider": { + "required": [ + "displayName", + "name" + ], + "type": "object", + "properties": { + "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" + }, + "privileged": { + "type": "boolean" + }, + "supportsBinding": { + "type": "boolean" + }, + "unbindingUrl": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "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." + }, + "LoginHistory": { + "required": [ + "loginAt", + "sourceIp", + "successful", + "userAgent" + ], + "type": "object", + "properties": { + "loginAt": { + "type": "string", + "format": "date-time" + }, + "reason": { + "type": "string" + }, + "sourceIp": { + "type": "string" + }, + "successful": { + "type": "boolean" + }, + "userAgent": { + "type": "string" + } + } + }, + "Metadata": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "creationTimestamp": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "deletionTimestamp": { + "type": "string", + "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" + } + }, + "name": { + "type": "string", + "description": "Metadata name" + }, + "version": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + }, + "pluginClass": { + "type": "string", + "deprecated": true + }, + "pluginDependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "repo": { + "type": "string" + }, + "requires": { + "type": "string" + }, + "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" + } + } + }, + "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", + "items": { + "type": "string" + } + }, + "nonResourceURLs": { + "type": "array", + "items": { + "type": "string" + } + }, + "resourceNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "verbs": { + "type": "array", + "items": { + "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" + } + } + }, + "PostRequest": { + "required": [ + "post" + ], + "type": "object", + "properties": { + "content": { + "$ref": "#/components/schemas/ContentUpdateParam" + }, + "post": { + "$ref": "#/components/schemas/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" + }, + "slug": { + "minLength": 1, + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "string" + }, + "title": { + "minLength": 1, + "type": "string" + }, + "visible": { + "type": "string", + "default": "PUBLIC", + "enum": [ + "PUBLIC", + "INTERNAL", + "PRIVATE" + ] + } + } + }, + "PostStatus": { + "required": [ + "phase" + ], + "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" + }, + "inProgress": { + "type": "boolean" + }, + "lastModifyTime": { + "type": "string", + "format": "date-time" + }, + "observedVersion": { + "type": "integer", + "format": "int64" + }, + "permalink": { + "type": "string" + }, + "phase": { + "type": "string" + } + } + }, + "Ref": { + "required": [ + "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" + }, + "owner": { + "$ref": "#/components/schemas/CommentEmailOwner" + }, + "quoteReply": { + "type": "string" + }, + "raw": { + "minLength": 1, + "type": "string" + } + } + }, + "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", + "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" + } + } + }, + "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" + } + } + }, + "SinglePageRequest": { + "required": [ + "content", + "page" + ], + "type": "object", + "properties": { + "content": { + "$ref": "#/components/schemas/ContentUpdateParam" + }, + "page": { + "$ref": "#/components/schemas/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" + }, + "slug": { + "minLength": 1, + "type": "string" + }, + "template": { + "type": "string" + }, + "title": { + "minLength": 1, + "type": "string" + }, + "visible": { + "type": "string", + "default": "PUBLIC", + "enum": [ + "PUBLIC", + "INTERNAL", + "PRIVATE" + ] + } + } + }, + "SinglePageStatus": { + "required": [ + "phase" + ], + "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" + }, + "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" + } + } + }, + "SystemInitializationRequest": { + "required": [ + "password", + "username" + ], + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "minLength": 3, + "type": "string" + }, + "siteTitle": { + "type": "string" + }, + "username": { + "minLength": 1, + "type": "string" + } + } + }, + "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" + }, + "cover": { + "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" + } + } + }, + "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" + } + } + }, + "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", + "version" + ], + "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" + }, + "require": { + "type": "string", + "description": "Deprecated, use `requires` instead.", + "deprecated": true + }, + "requires": { + "type": "string" + }, + "settingName": { + "type": "string" + }, + "version": { + "type": "string" + }, + "website": { + "type": "string", + "deprecated": true + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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": { + "lastLoginAt": { + "type": "string", + "format": "date-time" + }, + "loginHistories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LoginHistory" + } + }, + "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" + } + } + } +} \ No newline at end of file diff --git a/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json new file mode 100644 index 0000000..8015200 --- /dev/null +++ b/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json @@ -0,0 +1,12697 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Halo", + "version": "2.19.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/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/plugin.halo.run/v1alpha1/searchengines": { + "get": { + "description": "List SearchEngine", + "operationId": "listSearchEngine", + "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/SearchEngineList" + } + } + }, + "description": "Response searchengines" + } + }, + "tags": [ + "SearchEngineV1alpha1" + ] + }, + "post": { + "description": "Create SearchEngine", + "operationId": "createSearchEngine", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SearchEngine" + } + } + }, + "description": "Fresh searchengine" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SearchEngine" + } + } + }, + "description": "Response searchengines created just now" + } + }, + "tags": [ + "SearchEngineV1alpha1" + ] + } + }, + "/apis/plugin.halo.run/v1alpha1/searchengines/{name}": { + "delete": { + "description": "Delete SearchEngine", + "operationId": "deleteSearchEngine", + "parameters": [ + { + "description": "Name of searchengine", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response searchengine deleted just now" + } + }, + "tags": [ + "SearchEngineV1alpha1" + ] + }, + "get": { + "description": "Get SearchEngine", + "operationId": "getSearchEngine", + "parameters": [ + { + "description": "Name of searchengine", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SearchEngine" + } + } + }, + "description": "Response single searchengine" + } + }, + "tags": [ + "SearchEngineV1alpha1" + ] + }, + "patch": { + "description": "Patch SearchEngine", + "operationId": "patchSearchEngine", + "parameters": [ + { + "description": "Name of searchengine", + "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/SearchEngine" + } + } + }, + "description": "Response searchengine patched just now" + } + }, + "tags": [ + "SearchEngineV1alpha1" + ] + }, + "put": { + "description": "Update SearchEngine", + "operationId": "updateSearchEngine", + "parameters": [ + { + "description": "Name of searchengine", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SearchEngine" + } + } + }, + "description": "Updated searchengine" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SearchEngine" + } + } + }, + "description": "Response searchengines updated just now" + } + }, + "tags": [ + "SearchEngineV1alpha1" + ] + } + }, + "/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/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/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" + } + } + }, + "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" + } + } + }, + "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": [ + "authenticationUrl", + "displayName" + ], + "type": "object", + "properties": { + "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" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "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" + }, + "phase": { + "type": "string", + "enum": [ + "PENDING", + "RUNNING", + "SUCCEEDED", + "FAILED" + ] + }, + "size": { + "type": "integer", + "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" + }, + "postTemplate": { + "maxLength": 255, + "type": "string" + }, + "preventParentPostCascadeQuery": { + "type": "boolean" + }, + "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", + "format": "int32" + }, + "visiblePostCount": { + "type": "integer", + "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", + "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", + "format": "date-time" + }, + "message": { + "maxLength": 32768, + "type": "string" + }, + "reason": { + "maxLength": 1024, + "pattern": "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$", + "type": "string" + }, + "status": { + "type": "string", + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + }, + "kind": { + "type": "string" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "LoginHistory": { + "required": [ + "loginAt", + "sourceIp", + "successful", + "userAgent" + ], + "type": "object", + "properties": { + "loginAt": { + "type": "string", + "format": "date-time" + }, + "reason": { + "type": "string" + }, + "sourceIp": { + "type": "string" + }, + "successful": { + "type": "boolean" + }, + "userAgent": { + "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" + } + }, + "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": "Names of menu children below this menu.", + "items": { + "type": "string", + "description": "Names of menu children below this menu." + } + } + }, + "description": "The spec of menu." + }, + "Metadata": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "creationTimestamp": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "deletionTimestamp": { + "type": "string", + "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" + } + }, + "name": { + "type": "string", + "description": "Metadata name" + }, + "version": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + }, + "pluginClass": { + "type": "string", + "deprecated": true + }, + "pluginDependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "repo": { + "type": "string" + }, + "requires": { + "type": "string" + }, + "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" + } + } + }, + "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", + "items": { + "type": "string" + } + }, + "nonResourceURLs": { + "type": "array", + "items": { + "type": "string" + } + }, + "resourceNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "verbs": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "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" + } + } + }, + "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" + }, + "slug": { + "minLength": 1, + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "string" + }, + "title": { + "minLength": 1, + "type": "string" + }, + "visible": { + "type": "string", + "default": "PUBLIC", + "enum": [ + "PUBLIC", + "INTERNAL", + "PRIVATE" + ] + } + } + }, + "PostStatus": { + "required": [ + "phase" + ], + "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" + }, + "inProgress": { + "type": "boolean" + }, + "lastModifyTime": { + "type": "string", + "format": "date-time" + }, + "observedVersion": { + "type": "integer", + "format": "int64" + }, + "permalink": { + "type": "string" + }, + "phase": { + "type": "string" + } + } + }, + "Ref": { + "required": [ + "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", + "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" + } + } + } + }, + "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", + "items": { + "$ref": "#/components/schemas/Subject" + } + } + } + }, + "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" + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "SearchEngine": { + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/SearchEngineSpec" + } + } + }, + "SearchEngineList": { + "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/SearchEngine" + } + }, + "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" + } + } + }, + "SearchEngineSpec": { + "required": [ + "displayName" + ], + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "logo": { + "type": "string" + }, + "postSearchImpl": { + "type": "string" + }, + "settingRef": { + "$ref": "#/components/schemas/Ref" + }, + "website": { + "type": "string" + } + } + }, + "Secret": { + "required": [ + "apiVersion", + "kind", + "metadata" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "data": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "byte" + } + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "stringData": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "type": { + "type": "string" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + }, + "slug": { + "minLength": 1, + "type": "string" + }, + "template": { + "type": "string" + }, + "title": { + "minLength": 1, + "type": "string" + }, + "visible": { + "type": "string", + "default": "PUBLIC", + "enum": [ + "PUBLIC", + "INTERNAL", + "PRIVATE" + ] + } + } + }, + "SinglePageStatus": { + "required": [ + "phase" + ], + "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" + }, + "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" + }, + "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" + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "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" + }, + "cover": { + "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" + } + } + }, + "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" + } + } + }, + "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", + "version" + ], + "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" + }, + "require": { + "type": "string", + "description": "Deprecated, use `requires` instead.", + "deprecated": true + }, + "requires": { + "type": "string" + }, + "settingName": { + "type": "string" + }, + "version": { + "type": "string" + }, + "website": { + "type": "string", + "deprecated": true + } + } + }, + "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" + ] + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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": [ + "accessToken", + "displayName", + "providerUserId", + "registrationId", + "username" + ], + "type": "object", + "properties": { + "accessToken": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "profileUrl": { + "type": "string" + }, + "providerUserId": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "registrationId": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "username": { + "type": "string" + } + } + }, + "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": { + "lastLoginAt": { + "type": "string", + "format": "date-time" + }, + "loginHistories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LoginHistory" + } + }, + "permalink": { + "type": "string" + } + } + } + }, + "securitySchemes": { + "basicAuth": { + "scheme": "basic", + "type": "http" + }, + "bearerAuth": { + "bearerFormat": "JWT", + "scheme": "bearer", + "type": "http" + } + } + } +} \ No newline at end of file diff --git a/api-docs/openapi/v3_0/apis_public.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_public.api_v1alpha1.json new file mode 100644 index 0000000..92502e8 --- /dev/null +++ b/api-docs/openapi/v3_0/apis_public.api_v1alpha1.json @@ -0,0 +1,2096 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Halo", + "version": "2.19.0-SNAPSHOT" + }, + "servers": [ + { + "url": "http://localhost:8091", + "description": "Generated server url" + } + ], + "security": [ + { + "basicAuth": [], + "bearerAuth": [] + } + ], + "paths": { + "/apis/api.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": "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", + "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", + "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/indices/post": { + "get": { + "deprecated": true, + "description": "Search posts with fuzzy query. This method is deprecated, please use POST /indices/-/search instead.", + "operationId": "SearchPost", + "parameters": [ + { + "description": "Keyword to search", + "in": "query", + "name": "keyword", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Limit of search results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Highlight pre tag", + "in": "query", + "name": "highlightPreTag", + "schema": { + "type": "string" + } + }, + { + "description": "Highlight post tag", + "in": "query", + "name": "highlightPostTag", + "schema": { + "type": "string" + } + } + ], + "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.halo.run/v1alpha1/users/-/send-password-reset-email": { + "post": { + "description": "Send password reset email when forgot password", + "operationId": "SendPasswordResetEmail", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PasswordResetEmailRequest" + } + } + }, + "required": true + }, + "responses": { + "204 NO_CONTENT": { + "content": {}, + "description": "default response" + } + }, + "tags": [ + "UserV1alpha1Public" + ] + } + }, + "/apis/api.halo.run/v1alpha1/users/-/send-register-verify-email": { + "post": { + "description": "Send registration verification email, which can be called when mustVerifyEmailOnRegistration in user settings is true", + "operationId": "SendRegisterVerifyEmail", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/RegisterVerifyEmailRequest" + } + } + }, + "required": true + }, + "responses": { + "204 NO_CONTENT": { + "content": {}, + "description": "default response" + } + }, + "tags": [ + "UserV1alpha1Public" + ] + } + }, + "/apis/api.halo.run/v1alpha1/users/-/signup": { + "post": { + "description": "Sign up a new user", + "operationId": "SignUp", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SignUpRequest" + } + } + }, + "required": true + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "UserV1alpha1Public" + ] + } + }, + "/apis/api.halo.run/v1alpha1/users/{name}/reset-password": { + "put": { + "description": "Reset password by token", + "operationId": "ResetPasswordByToken", + "parameters": [ + { + "description": "The name of the user", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "204 NO_CONTENT": { + "content": {}, + "description": "default response" + } + }, + "tags": [ + "UserV1alpha1Public" + ] + } + } + }, + "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" + } + } + }, + "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" + }, + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "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" + }, + "owner": { + "$ref": "#/components/schemas/CommentEmailOwner" + }, + "raw": { + "minLength": 1, + "type": "string" + }, + "subjectRef": { + "$ref": "#/components/schemas/Ref" + } + } + }, + "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", + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "HaloDocument": { + "required": [ + "content", + "id", + "metadataName", + "ownerName", + "permalink", + "title", + "type" + ], + "type": "object", + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "content": { + "type": "string" + }, + "creationTimestamp": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": "string" + }, + "exposed": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "metadataName": { + "type": "string" + }, + "ownerName": { + "type": "string" + }, + "permalink": { + "type": "string" + }, + "published": { + "type": "boolean" + }, + "recycled": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updateTimestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "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" + } + } + }, + "LoginHistory": { + "required": [ + "loginAt", + "sourceIp", + "successful", + "userAgent" + ], + "type": "object", + "properties": { + "loginAt": { + "type": "string", + "format": "date-time" + }, + "reason": { + "type": "string" + }, + "sourceIp": { + "type": "string" + }, + "successful": { + "type": "boolean" + }, + "userAgent": { + "type": "string" + } + } + }, + "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": { + "displayName": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "parentName": { + "type": "string" + }, + "spec": { + "$ref": "#/components/schemas/MenuItemSpec" + }, + "status": { + "$ref": "#/components/schemas/MenuItemStatus" + } + } + }, + "MenuSpec": { + "required": [ + "displayName" + ], + "type": "object", + "properties": { + "displayName": { + "type": "string", + "description": "The display name of the menu." + }, + "menuItems": { + "uniqueItems": true, + "type": "array", + "description": "Names of menu children below this menu.", + "items": { + "type": "string", + "description": "Names of menu children below this menu." + } + } + } + }, + "MenuVo": { + "required": [ + "metadata" + ], + "type": "object", + "properties": { + "menuItems": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MenuItemVo" + } + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/MenuSpec" + } + } + }, + "Metadata": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "creationTimestamp": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "deletionTimestamp": { + "type": "string", + "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" + } + }, + "name": { + "type": "string", + "description": "Metadata name" + }, + "version": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, + "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" + } + } + }, + "PasswordResetEmailRequest": { + "required": [ + "email", + "username" + ], + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "Ref": { + "required": [ + "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" + }, + "RegisterVerifyEmailRequest": { + "required": [ + "email" + ], + "type": "object", + "properties": { + "email": { + "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" + } + } + }, + "ReplyRequest": { + "required": [ + "content", + "raw" + ], + "type": "object", + "properties": { + "allowNotification": { + "type": "boolean", + "default": false + }, + "content": { + "minLength": 1, + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/CommentEmailOwner" + }, + "quoteReply": { + "type": "string" + }, + "raw": { + "minLength": 1, + "type": "string" + } + } + }, + "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", + "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" + } + } + }, + "ResetPasswordRequest": { + "required": [ + "newPassword", + "token" + ], + "type": "object", + "properties": { + "newPassword": { + "minLength": 6, + "type": "string" + }, + "token": { + "type": "string" + } + } + }, + "SearchOption": { + "required": [ + "keyword" + ], + "type": "object", + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "filterExposed": { + "type": "boolean" + }, + "filterPublished": { + "type": "boolean" + }, + "filterRecycled": { + "type": "boolean" + }, + "highlightPostTag": { + "type": "string" + }, + "highlightPreTag": { + "type": "string" + }, + "includeCategoryNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "includeOwnerNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "includeTagNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "includeTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "keyword": { + "type": "string" + }, + "limit": { + "maximum": 1000, + "minimum": 1, + "type": "integer", + "format": "int32" + } + } + }, + "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" + } + } + }, + "SignUpRequest": { + "required": [ + "password", + "user" + ], + "type": "object", + "properties": { + "password": { + "minLength": 6, + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/User" + }, + "verifyCode": { + "maxLength": 6, + "minLength": 6, + "type": "string" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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": { + "lastLoginAt": { + "type": "string", + "format": "date-time" + }, + "loginHistories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LoginHistory" + } + }, + "permalink": { + "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" + } + } + } +} \ No newline at end of file diff --git a/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json new file mode 100644 index 0000000..fc82fe6 --- /dev/null +++ b/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json @@ -0,0 +1,1978 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Halo", + "version": "2.19.0-SNAPSHOT" + }, + "servers": [ + { + "url": "http://localhost:8091", + "description": "Generated server url" + } + ], + "security": [ + { + "basicAuth": [], + "bearerAuth": [] + } + ], + "paths": { + "/apis/uc.api.content.halo.run/v1alpha1/attachments": { + "post": { + "description": "Create attachment for the given post.", + "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.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}/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.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" + ] + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + }, + "postTemplate": { + "maxLength": 255, + "type": "string" + }, + "preventParentPostCascadeQuery": { + "type": "boolean" + }, + "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", + "format": "int32" + }, + "visiblePostCount": { + "type": "integer", + "format": "int32" + } + } + }, + "Condition": { + "required": [ + "lastTransitionTime", + "status", + "type" + ], + "type": "object", + "properties": { + "lastTransitionTime": { + "type": "string", + "format": "date-time" + }, + "message": { + "maxLength": 32768, + "type": "string" + }, + "reason": { + "maxLength": 1024, + "pattern": "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$", + "type": "string" + }, + "status": { + "type": "string", + "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" + } + } + }, + "Contributor": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "name": { + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "Metadata": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "creationTimestamp": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "deletionTimestamp": { + "type": "string", + "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" + } + }, + "name": { + "type": "string", + "description": "Metadata name" + }, + "version": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, + "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" + } + } + }, + "PasswordRequest": { + "required": [ + "password" + ], + "type": "object", + "properties": { + "password": { + "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" + } + } + }, + "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" + }, + "slug": { + "minLength": 1, + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "string" + }, + "title": { + "minLength": 1, + "type": "string" + }, + "visible": { + "type": "string", + "default": "PUBLIC", + "enum": [ + "PUBLIC", + "INTERNAL", + "PRIVATE" + ] + } + } + }, + "PostStatus": { + "required": [ + "phase" + ], + "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" + }, + "inProgress": { + "type": "boolean" + }, + "lastModifyTime": { + "type": "string", + "format": "date-time" + }, + "observedVersion": { + "type": "integer", + "format": "int64" + }, + "permalink": { + "type": "string" + }, + "phase": { + "type": "string" + } + } + }, + "Ref": { + "required": [ + "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" + }, + "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" + } + } + }, + "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" + }, + "cover": { + "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", + "format": "uri" + }, + "rawSecret": { + "type": "string" + } + } + }, + "TotpRequest": { + "required": [ + "code", + "password", + "secret" + ], + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "password": { + "type": "string" + }, + "secret": { + "type": "string" + } + } + }, + "TwoFactorAuthSettings": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + }, + "emailVerified": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "totpConfigured": { + "type": "boolean" + } + } + }, + "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" + } + } + } +} \ No newline at end of file diff --git a/api/build.gradle b/api/build.gradle new file mode 100644 index 0000000..717b853 --- /dev/null +++ b/api/build.gradle @@ -0,0 +1,101 @@ +plugins { + id 'java-library' + id 'halo.publish' + id 'jacoco' + id "io.freefair.lombok" +} + +group = 'run.halo.app' +description = 'API of halo project, connecting by other projects.' + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +compileJava.options.encoding = "UTF-8" +compileTestJava.options.encoding = "UTF-8" +javadoc.options.encoding = "UTF-8" + +repositories { + mavenCentral() + maven { url 'https://repo.spring.io/milestone' } + maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots' } +} + +dependencies { + api platform(project(':platform:application')) + + api 'org.springframework.boot:spring-boot-starter-actuator' + api 'org.springframework.boot:spring-boot-starter-data-jpa' + 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' + + // 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' + + // 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" + + // 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 "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' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'io.projectreactor:reactor-test' +} + +java { + withSourcesJar() + withJavadocJar() +} + +tasks.named('test') { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +tasks.named('jacocoTestReport') { + reports { + xml.required = true + html.required = false + } +} diff --git a/api/src/main/java/run/halo/app/content/ContentWrapper.java b/api/src/main/java/run/halo/app/content/ContentWrapper.java new file mode 100644 index 0000000..e29185a --- /dev/null +++ b/api/src/main/java/run/halo/app/content/ContentWrapper.java @@ -0,0 +1,43 @@ +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(); + } +} diff --git a/api/src/main/java/run/halo/app/content/ExcerptGenerator.java b/api/src/main/java/run/halo/app/content/ExcerptGenerator.java new file mode 100644 index 0000000..d25b681 --- /dev/null +++ b/api/src/main/java/run/halo/app/content/ExcerptGenerator.java @@ -0,0 +1,32 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/content/PatchUtils.java b/api/src/main/java/run/halo/app/content/PatchUtils.java new file mode 100644 index 0000000..1813c75 --- /dev/null +++ b/api/src/main/java/run/halo/app/content/PatchUtils.java @@ -0,0 +1,88 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/content/PostContentService.java b/api/src/main/java/run/halo/app/content/PostContentService.java new file mode 100644 index 0000000..e8fb6ed --- /dev/null +++ b/api/src/main/java/run/halo/app/content/PostContentService.java @@ -0,0 +1,15 @@ +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); +} diff --git a/api/src/main/java/run/halo/app/content/comment/CommentSubject.java b/api/src/main/java/run/halo/app/content/comment/CommentSubject.java new file mode 100644 index 0000000..f170623 --- /dev/null +++ b/api/src/main/java/run/halo/app/content/comment/CommentSubject.java @@ -0,0 +1,26 @@ +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) { + } +} diff --git a/api/src/main/java/run/halo/app/core/endpoint/WebSocketEndpoint.java b/api/src/main/java/run/halo/app/core/endpoint/WebSocketEndpoint.java new file mode 100644 index 0000000..33d141d --- /dev/null +++ b/api/src/main/java/run/halo/app/core/endpoint/WebSocketEndpoint.java @@ -0,0 +1,34 @@ +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(); + +} diff --git a/api/src/main/java/run/halo/app/core/extension/AnnotationSetting.java b/api/src/main/java/run/halo/app/core/extension/AnnotationSetting.java new file mode 100644 index 0000000..e8e2bfd --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/AnnotationSetting.java @@ -0,0 +1,36 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/AuthProvider.java b/api/src/main/java/run/halo/app/core/extension/AuthProvider.java new file mode 100644 index 0000000..5f496c6 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/AuthProvider.java @@ -0,0 +1,80 @@ +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.ToString; +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 bindingUrl; + + private String unbindUrl; + + private int priority; + + @Schema(requiredMode = NOT_REQUIRED) + private SettingRef settingRef; + + @Schema(requiredMode = NOT_REQUIRED) + private ConfigMapRef configMapRef; + } + + @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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/Counter.java b/api/src/main/java/run/halo/app/core/extension/Counter.java new file mode 100644 index 0000000..9e7ed04 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/Counter.java @@ -0,0 +1,41 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/Device.java b/api/src/main/java/run/halo/app/core/extension/Device.java new file mode 100644 index 0000000..40f4043 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/Device.java @@ -0,0 +1,65 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/Menu.java b/api/src/main/java/run/halo/app/core/extension/Menu.java new file mode 100644 index 0000000..02117a4 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/Menu.java @@ -0,0 +1,39 @@ +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; + + @Schema(description = "Names of menu children below this menu.") + @ArraySchema( + arraySchema = @Schema(description = "Menu items of this menu."), + schema = @Schema(description = "Name of menu item.") + ) + private LinkedHashSet menuItems; + + } + +} diff --git a/api/src/main/java/run/halo/app/core/extension/MenuItem.java b/api/src/main/java/run/halo/app/core/extension/MenuItem.java new file mode 100644 index 0000000..51301d0 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/MenuItem.java @@ -0,0 +1,84 @@ +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( + 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; + + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/Plugin.java b/api/src/main/java/run/halo/app/core/extension/Plugin.java new file mode 100644 index 0000000..8d1db1b --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/Plugin.java @@ -0,0 +1,164 @@ +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 { + + @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 = "*"; + + @Deprecated + private String pluginClass; + + 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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/RememberMeToken.java b/api/src/main/java/run/halo/app/core/extension/RememberMeToken.java new file mode 100644 index 0000000..e0a143e --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/RememberMeToken.java @@ -0,0 +1,37 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/ReverseProxy.java b/api/src/main/java/run/halo/app/core/extension/ReverseProxy.java new file mode 100644 index 0000000..5cdf922 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/ReverseProxy.java @@ -0,0 +1,29 @@ +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) { + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/Role.java b/api/src/main/java/run/halo/app/core/extension/Role.java new file mode 100644 index 0000000..ebda43f --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/Role.java @@ -0,0 +1,187 @@ +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); + } + } + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/RoleBinding.java b/api/src/main/java/run/halo/app/core/extension/RoleBinding.java new file mode 100644 index 0000000..09ad638 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/RoleBinding.java @@ -0,0 +1,169 @@ +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))); + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/Setting.java b/api/src/main/java/run/halo/app/core/extension/Setting.java new file mode 100644 index 0000000..8820d00 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/Setting.java @@ -0,0 +1,51 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/Theme.java b/api/src/main/java/run/halo/app/core/extension/Theme.java new file mode 100644 index 0000000..ccb7b80 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/Theme.java @@ -0,0 +1,177 @@ +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 lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +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; +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; + + @Deprecated(forRemoval = true, since = "2.7.0") + private String website; + + private String homepage; + + private String repo; + + private String issues; + + private String version; + + @Deprecated(forRemoval = true, since = "2.2.0") + @Schema(description = "Deprecated, use `requires` instead.") + private String require; + + @Schema(requiredMode = NOT_REQUIRED) + private String requires; + + private String settingName; + + private String configMapName; + + private List license; + + @Schema + private CustomTemplates customTemplates; + + @NonNull + public String getVersion() { + return StringUtils.defaultString(this.version, WILDCARD); + } + + /** + * if requires is not empty, then return requires, else return require or {@code WILDCARD}. + * + * @return requires to satisfies system version + */ + @NonNull + public String getRequires() { + if (StringUtils.isNotBlank(this.requires)) { + return this.requires; + } + return StringUtils.defaultString(this.require, WILDCARD); + } + + /** + * Compatible with {@link #website} property. + */ + public String getHomepage() { + return StringUtils.defaultString(this.homepage, this.website); + } + } + + @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"); + ThemeStatus status = ObjectUtils.defaultIfNull(theme.getStatus(), new ThemeStatus()); + theme.setStatus(status); + + ConditionList conditions = + ObjectUtils.defaultIfNull(status.getConditions(), new ConditionList()); + 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; + } + +} diff --git a/api/src/main/java/run/halo/app/core/extension/User.java b/api/src/main/java/run/halo/app/core/extension/User.java new file mode 100644 index 0000000..f925bc9 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/User.java @@ -0,0 +1,117 @@ +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 java.util.List; +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 Instant lastLoginAt; + + private String permalink; + + private List loginHistories; + + } + + @Data + public static class LoginHistory { + + @Schema(requiredMode = REQUIRED) + private Instant loginAt; + + @Schema(requiredMode = REQUIRED) + private String sourceIp; + + @Schema(requiredMode = REQUIRED) + private String userAgent; + + @Schema(requiredMode = REQUIRED) + private Boolean successful; + + private String reason; + + } + +} diff --git a/api/src/main/java/run/halo/app/core/extension/UserConnection.java b/api/src/main/java/run/halo/app/core/extension/UserConnection.java new file mode 100644 index 0000000..1b9fb72 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/UserConnection.java @@ -0,0 +1,83 @@ +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 display name for the user's connection to the OAuth provider. + */ + @Schema(requiredMode = REQUIRED) + private String displayName; + + /** + * The URL to the user's profile page on the OAuth provider. + * For example, the user's GitHub profile URL. + */ + private String profileUrl; + + /** + * The URL to the user's avatar image on the OAuth provider. + * For example, the user's GitHub avatar URL. + */ + private String avatarUrl; + + /** + * The access token provided by the OAuth provider. + */ + @Schema(requiredMode = REQUIRED) + private String accessToken; + + /** + * The refresh token provided by the OAuth provider (if applicable). + */ + private String refreshToken; + + private Instant expiresAt; + + private Instant updatedAt; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/Attachment.java b/api/src/main/java/run/halo/app/core/extension/attachment/Attachment.java new file mode 100644 index 0000000..974c977 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/attachment/Attachment.java @@ -0,0 +1,68 @@ +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.Set; +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 = "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") + 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; + + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/Constant.java b/api/src/main/java/run/halo/app/core/extension/attachment/Constant.java new file mode 100644 index 0000000..9528df6 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/attachment/Constant.java @@ -0,0 +1,28 @@ +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"; + +} diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/Group.java b/api/src/main/java/run/halo/app/core/extension/attachment/Group.java new file mode 100644 index 0000000..dcc6179 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/attachment/Group.java @@ -0,0 +1,48 @@ +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; + + } + +} diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/Policy.java b/api/src/main/java/run/halo/app/core/extension/attachment/Policy.java new file mode 100644 index 0000000..450548a --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/attachment/Policy.java @@ -0,0 +1,39 @@ +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 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; + + } + +} diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/PolicyTemplate.java b/api/src/main/java/run/halo/app/core/extension/attachment/PolicyTemplate.java new file mode 100644 index 0000000..509f08a --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/attachment/PolicyTemplate.java @@ -0,0 +1,34 @@ +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; + + } + +} diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentHandler.java b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentHandler.java new file mode 100644 index 0000000..38369f7 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentHandler.java @@ -0,0 +1,74 @@ +package run.halo.app.core.extension.attachment.endpoint; + +import java.net.URI; +import java.time.Duration; +import org.pf4j.ExtensionPoint; +import org.springframework.http.codec.multipart.FilePart; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.attachment.Attachment; +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(); + } + + interface UploadContext { + + FilePart file(); + + Policy policy(); + + ConfigMap configMap(); + + } + + interface DeleteContext { + Attachment attachment(); + + Policy policy(); + + ConfigMap configMap(); + } + +} diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/DeleteOption.java b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/DeleteOption.java new file mode 100644 index 0000000..1235954 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/DeleteOption.java @@ -0,0 +1,9 @@ +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 { +} diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/SimpleFilePart.java b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/SimpleFilePart.java new file mode 100644 index 0000000..e2a9d26 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/SimpleFilePart.java @@ -0,0 +1,40 @@ +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); + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/UploadOption.java b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/UploadOption.java new file mode 100644 index 0000000..0a050ef --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/UploadOption.java @@ -0,0 +1,23 @@ +package run.halo.app.core.extension.attachment.endpoint; + +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.Policy; +import run.halo.app.extension.ConfigMap; + +public record UploadOption(FilePart file, + Policy policy, + ConfigMap configMap) 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); + } + +} diff --git a/api/src/main/java/run/halo/app/core/extension/content/Category.java b/api/src/main/java/run/halo/app/core/extension/content/Category.java new file mode 100644 index 0000000..245f681 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/content/Category.java @@ -0,0 +1,117 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/content/Comment.java b/api/src/main/java/run/halo/app/core/extension/content/Comment.java new file mode 100644 index 0000000..b77dc4e --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/content/Comment.java @@ -0,0 +1,164 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/content/Constant.java b/api/src/main/java/run/halo/app/core/extension/content/Constant.java new file mode 100644 index 0000000..154b4ba --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/content/Constant.java @@ -0,0 +1,13 @@ +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"; +} diff --git a/api/src/main/java/run/halo/app/core/extension/content/Post.java b/api/src/main/java/run/halo/app/core/extension/content/Post.java new file mode 100644 index 0000000..0cc785d --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/content/Post.java @@ -0,0 +1,245 @@ +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 { + @Schema(requiredMode = RequiredMode.REQUIRED) + 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; + } + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/content/Reply.java b/api/src/main/java/run/halo/app/core/extension/content/Reply.java new file mode 100644 index 0000000..b7b1dfe --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/content/Reply.java @@ -0,0 +1,56 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/content/SinglePage.java b/api/src/main/java/run/halo/app/core/extension/content/SinglePage.java new file mode 100644 index 0000000..3742482 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/content/SinglePage.java @@ -0,0 +1,120 @@ +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)); + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/content/Snapshot.java b/api/src/main/java/run/halo/app/core/extension/content/Snapshot.java new file mode 100644 index 0000000..3e58127 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/content/Snapshot.java @@ -0,0 +1,90 @@ +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(); + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/content/Tag.java b/api/src/main/java/run/halo/app/core/extension/content/Tag.java new file mode 100644 index 0000000..ea0654f --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/content/Tag.java @@ -0,0 +1,85 @@ +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; + + /** + * 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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/endpoint/CustomEndpoint.java b/api/src/main/java/run/halo/app/core/extension/endpoint/CustomEndpoint.java new file mode 100644 index 0000000..86acd19 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/endpoint/CustomEndpoint.java @@ -0,0 +1,20 @@ +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"); + } + +} diff --git a/api/src/main/java/run/halo/app/core/extension/endpoint/SortResolver.java b/api/src/main/java/run/halo/app/core/extension/endpoint/SortResolver.java new file mode 100644 index 0000000..9b9b9e6 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/endpoint/SortResolver.java @@ -0,0 +1,31 @@ +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); + } + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/notification/Notification.java b/api/src/main/java/run/halo/app/core/extension/notification/Notification.java new file mode 100644 index 0000000..b10f08d --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/notification/Notification.java @@ -0,0 +1,58 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/notification/NotificationTemplate.java b/api/src/main/java/run/halo/app/core/extension/notification/NotificationTemplate.java new file mode 100644 index 0000000..a7b0c8f --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/notification/NotificationTemplate.java @@ -0,0 +1,59 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/notification/NotifierDescriptor.java b/api/src/main/java/run/halo/app/core/extension/notification/NotifierDescriptor.java new file mode 100644 index 0000000..e7adb3f --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/notification/NotifierDescriptor.java @@ -0,0 +1,54 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/notification/Reason.java b/api/src/main/java/run/halo/app/core/extension/notification/Reason.java new file mode 100644 index 0000000..3a83521 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/notification/Reason.java @@ -0,0 +1,72 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/notification/ReasonType.java b/api/src/main/java/run/halo/app/core/extension/notification/ReasonType.java new file mode 100644 index 0000000..222819f --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/notification/ReasonType.java @@ -0,0 +1,58 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/notification/Subscription.java b/api/src/main/java/run/halo/app/core/extension/notification/Subscription.java new file mode 100644 index 0000000..bd19c21 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/notification/Subscription.java @@ -0,0 +1,144 @@ +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(); + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java b/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java new file mode 100644 index 0000000..275460b --- /dev/null +++ b/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java @@ -0,0 +1,94 @@ +package run.halo.app.core.extension.service; + +import java.net.URI; +import java.time.Duration; +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.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); + +} diff --git a/api/src/main/java/run/halo/app/event/post/PostDeletedEvent.java b/api/src/main/java/run/halo/app/event/post/PostDeletedEvent.java new file mode 100644 index 0000000..7330f8e --- /dev/null +++ b/api/src/main/java/run/halo/app/event/post/PostDeletedEvent.java @@ -0,0 +1,24 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/event/post/PostEvent.java b/api/src/main/java/run/halo/app/event/post/PostEvent.java new file mode 100644 index 0000000..1d672b2 --- /dev/null +++ b/api/src/main/java/run/halo/app/event/post/PostEvent.java @@ -0,0 +1,27 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/event/post/PostPublishedEvent.java b/api/src/main/java/run/halo/app/event/post/PostPublishedEvent.java new file mode 100644 index 0000000..0c446d8 --- /dev/null +++ b/api/src/main/java/run/halo/app/event/post/PostPublishedEvent.java @@ -0,0 +1,12 @@ +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); + } + +} diff --git a/api/src/main/java/run/halo/app/event/post/PostUnpublishedEvent.java b/api/src/main/java/run/halo/app/event/post/PostUnpublishedEvent.java new file mode 100644 index 0000000..52c1c03 --- /dev/null +++ b/api/src/main/java/run/halo/app/event/post/PostUnpublishedEvent.java @@ -0,0 +1,12 @@ +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); + } + +} diff --git a/api/src/main/java/run/halo/app/event/post/PostUpdatedEvent.java b/api/src/main/java/run/halo/app/event/post/PostUpdatedEvent.java new file mode 100644 index 0000000..74c9123 --- /dev/null +++ b/api/src/main/java/run/halo/app/event/post/PostUpdatedEvent.java @@ -0,0 +1,12 @@ +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); + } + +} diff --git a/api/src/main/java/run/halo/app/event/post/PostVisibleChangedEvent.java b/api/src/main/java/run/halo/app/event/post/PostVisibleChangedEvent.java new file mode 100644 index 0000000..c3579f8 --- /dev/null +++ b/api/src/main/java/run/halo/app/event/post/PostVisibleChangedEvent.java @@ -0,0 +1,30 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/extension/AbstractExtension.java b/api/src/main/java/run/halo/app/extension/AbstractExtension.java new file mode 100644 index 0000000..65f6b6f --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/AbstractExtension.java @@ -0,0 +1,30 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/extension/Comparators.java b/api/src/main/java/run/halo/app/extension/Comparators.java new file mode 100644 index 0000000..c03e5e5 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/Comparators.java @@ -0,0 +1,37 @@ +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(); + } +} diff --git a/api/src/main/java/run/halo/app/extension/ConfigMap.java b/api/src/main/java/run/halo/app/extension/ConfigMap.java new file mode 100644 index 0000000..abcf422 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/ConfigMap.java @@ -0,0 +1,33 @@ +package run.halo.app.extension; + +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + *

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"; + + 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; + } +} diff --git a/api/src/main/java/run/halo/app/extension/DefaultExtensionMatcher.java b/api/src/main/java/run/halo/app/extension/DefaultExtensionMatcher.java new file mode 100644 index 0000000..a3026fe --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/DefaultExtensionMatcher.java @@ -0,0 +1,57 @@ +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.QueryFactory; +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 = QueryFactory.all(); + if (hasFieldSelector()) { + fieldQuery = QueryFactory.and(fieldQuery, fieldSelector.query()); + } + listOptions.setFieldSelector(new FieldSelector(fieldQuery)); + return client.indexedQueryEngine().retrieve(getGvk(), + listOptions, PageRequestImpl.ofSize(1)).getTotal() > 0; + } + + boolean hasFieldSelector() { + return fieldSelector != null && fieldSelector.query() != null; + } + + boolean hasLabelSelector() { + return labelSelector != null && !CollectionUtils.isEmpty(labelSelector.getMatchers()); + } +} diff --git a/api/src/main/java/run/halo/app/extension/Extension.java b/api/src/main/java/run/halo/app/extension/Extension.java new file mode 100644 index 0000000..87dbf21 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/Extension.java @@ -0,0 +1,23 @@ +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()); + } +} diff --git a/api/src/main/java/run/halo/app/extension/ExtensionClient.java b/api/src/main/java/run/halo/app/extension/ExtensionClient.java new file mode 100644 index 0000000..b64dba0 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/ExtensionClient.java @@ -0,0 +1,96 @@ +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. + */ + ListResult list(Class type, Predicate predicate, + Comparator comparator, int page, int size); + + List listAll(Class type, ListOptions options, Sort sort); + + ListResult listBy(Class type, ListOptions options, + PageRequest page); + + /** + * 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); + + IndexedQueryEngine indexedQueryEngine(); + + void watch(Watcher watcher); + +} diff --git a/api/src/main/java/run/halo/app/extension/ExtensionMatcher.java b/api/src/main/java/run/halo/app/extension/ExtensionMatcher.java new file mode 100644 index 0000000..ccc2582 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/ExtensionMatcher.java @@ -0,0 +1,23 @@ +package run.halo.app.extension; + +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.extension.router.selector.LabelSelector; + +public interface ExtensionMatcher { + @Deprecated(since = "2.17.0", forRemoval = true) + default GroupVersionKind getGvk() { + return null; + } + + @Deprecated(since = "2.17.0", forRemoval = true) + default LabelSelector getLabelSelector() { + return null; + } + + @Deprecated(since = "2.17.0", forRemoval = true) + default FieldSelector getFieldSelector() { + return null; + } + + boolean match(Extension extension); +} diff --git a/api/src/main/java/run/halo/app/extension/ExtensionOperator.java b/api/src/main/java/run/halo/app/extension/ExtensionOperator.java new file mode 100644 index 0000000..293426f --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/ExtensionOperator.java @@ -0,0 +1,80 @@ +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); + } +} diff --git a/api/src/main/java/run/halo/app/extension/ExtensionUtil.java b/api/src/main/java/run/halo/app/extension/ExtensionUtil.java new file mode 100644 index 0000000..cb50cd3 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/ExtensionUtil.java @@ -0,0 +1,61 @@ +package run.halo.app.extension; + +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.Query; +import run.halo.app.extension.index.query.QueryFactory; + +public enum ExtensionUtil { + ; + + 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 QueryFactory.isNull("metadata.deletionTimestamp"); + } + + /** + * Default sort by creation timestamp desc and name asc. + * + * @return Sort + */ + public static Sort defaultSort() { + return Sort.by( + Sort.Order.desc("metadata.creationTimestamp"), + Sort.Order.asc("metadata.name") + ); + } + +} diff --git a/api/src/main/java/run/halo/app/extension/GVK.java b/api/src/main/java/run/halo/app/extension/GVK.java new file mode 100644 index 0000000..ebc023a --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/GVK.java @@ -0,0 +1,42 @@ +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(); + +} diff --git a/api/src/main/java/run/halo/app/extension/GroupKind.java b/api/src/main/java/run/halo/app/extension/GroupKind.java new file mode 100644 index 0000000..d1fc850 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/GroupKind.java @@ -0,0 +1,11 @@ +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) { +} diff --git a/api/src/main/java/run/halo/app/extension/GroupVersion.java b/api/src/main/java/run/halo/app/extension/GroupVersion.java new file mode 100644 index 0000000..bf00f93 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/GroupVersion.java @@ -0,0 +1,40 @@ +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); + }; + } +} diff --git a/api/src/main/java/run/halo/app/extension/GroupVersionKind.java b/api/src/main/java/run/halo/app/extension/GroupVersionKind.java new file mode 100644 index 0000000..2686ca8 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/GroupVersionKind.java @@ -0,0 +1,64 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/extension/JsonExtension.java b/api/src/main/java/run/halo/app/extension/JsonExtension.java new file mode 100644 index 0000000..c140df3 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/JsonExtension.java @@ -0,0 +1,259 @@ +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) +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)); + } + } + } +} diff --git a/api/src/main/java/run/halo/app/extension/ListOptions.java b/api/src/main/java/run/halo/app/extension/ListOptions.java new file mode 100644 index 0000000..09c72cf --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/ListOptions.java @@ -0,0 +1,127 @@ +package run.halo.app.extension; + +import java.util.List; +import lombok.Data; +import lombok.experimental.Accessors; +import run.halo.app.extension.index.query.Query; +import run.halo.app.extension.index.query.QueryFactory; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.extension.router.selector.LabelSelector; +import run.halo.app.extension.router.selector.SelectorMatcher; + +@Data +@Accessors(chain = true) +public class ListOptions { + private LabelSelector labelSelector; + private FieldSelector fieldSelector; + + @Override + public String toString() { + var sb = new StringBuilder(); + if (fieldSelector != null) { + var query = fieldSelector.query().toString(); + sb.append("fieldSelector: ") + .append(query.startsWith("(") ? query : "(" + query + ")"); + } + if (labelSelector != null) { + if (!sb.isEmpty()) { + sb.append(", "); + } + sb.append("labelSelector: (").append(labelSelector).append(")"); + } + return sb.toString(); + } + + 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().getMatchers(), 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) { + this.query = (this.query == null ? query : QueryFactory.and(this.query, query)); + return this; + } + + /** + * Or the given query to the current query. + */ + public ListOptionsBuilder orQuery(Query query) { + this.query = (this.query == null ? query : QueryFactory.or(this.query, query)); + 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 givenMatchers, + ListOptionsBuilder listOptionsBuilder) { + super(givenMatchers); + this.listOptionsBuilder = listOptionsBuilder; + } + + public LabelSelectorBuilder(ListOptionsBuilder listOptionsBuilder) { + this.listOptionsBuilder = listOptionsBuilder; + } + + public ListOptionsBuilder end() { + return this.listOptionsBuilder; + } + } +} diff --git a/api/src/main/java/run/halo/app/extension/ListResult.java b/api/src/main/java/run/halo/app/extension/ListResult.java new file mode 100644 index 0000000..679e0ea --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/ListResult.java @@ -0,0 +1,161 @@ +package run.halo.app.extension; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +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; + + public ListResult(int page, int size, long total, 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(); + } +} diff --git a/api/src/main/java/run/halo/app/extension/Metadata.java b/api/src/main/java/run/halo/app/extension/Metadata.java new file mode 100644 index 0000000..c6418b5 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/Metadata.java @@ -0,0 +1,55 @@ +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; + +} diff --git a/api/src/main/java/run/halo/app/extension/MetadataOperator.java b/api/src/main/java/run/halo/app/extension/MetadataOperator.java new file mode 100644 index 0000000..f731228 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/MetadataOperator.java @@ -0,0 +1,99 @@ +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) +@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); + + static boolean metadataDeepEquals(MetadataOperator left, MetadataOperator right) { + if (left == null && right == null) { + return true; + } + if (left == null || right == null) { + return false; + } + if (!Objects.equals(left.getName(), right.getName())) { + return false; + } + if (!Objects.equals(left.getLabels(), right.getLabels())) { + return false; + } + if (!Objects.equals(left.getAnnotations(), right.getAnnotations())) { + return false; + } + if (!Objects.equals(left.getCreationTimestamp(), right.getCreationTimestamp())) { + return false; + } + if (!Objects.equals(left.getDeletionTimestamp(), right.getDeletionTimestamp())) { + return false; + } + if (!Objects.equals(left.getVersion(), right.getVersion())) { + return false; + } + if (!Objects.equals(left.getFinalizers(), right.getFinalizers())) { + return false; + } + return true; + } +} diff --git a/api/src/main/java/run/halo/app/extension/MetadataUtil.java b/api/src/main/java/run/halo/app/extension/MetadataUtil.java new file mode 100644 index 0000000..9670786 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/MetadataUtil.java @@ -0,0 +1,45 @@ +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"; + + /** + * 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; + } +} diff --git a/api/src/main/java/run/halo/app/extension/PageRequest.java b/api/src/main/java/run/halo/app/extension/PageRequest.java new file mode 100644 index 0000000..d2ed458 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/PageRequest.java @@ -0,0 +1,65 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/extension/PageRequestImpl.java b/api/src/main/java/run/halo/app/extension/PageRequestImpl.java new file mode 100644 index 0000000..62da566 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/PageRequestImpl.java @@ -0,0 +1,90 @@ +package run.halo.app.extension; + +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; + +import org.springframework.data.domain.Sort; +import org.springframework.util.Assert; + +public class PageRequestImpl implements PageRequest { + + 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) { + pageSize = 0; + } + 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; + } +} diff --git a/api/src/main/java/run/halo/app/extension/ReactiveExtensionClient.java b/api/src/main/java/run/halo/app/extension/ReactiveExtensionClient.java new file mode 100644 index 0000000..586f713 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/ReactiveExtensionClient.java @@ -0,0 +1,96 @@ +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. + */ + Mono> list(Class type, Predicate predicate, + Comparator comparator, int page, int size); + + Flux listAll(Class type, ListOptions options, Sort sort); + + Mono> listBy(Class type, ListOptions options, + PageRequest pageable); + + /** + * 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); + + 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); + + IndexedQueryEngine indexedQueryEngine(); + + void watch(Watcher watcher); + +} diff --git a/api/src/main/java/run/halo/app/extension/Ref.java b/api/src/main/java/run/halo/app/extension/Ref.java new file mode 100644 index 0000000..d275e6c --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/Ref.java @@ -0,0 +1,77 @@ +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.springframework.lang.NonNull; + +@Data +@Schema(description = "Extension reference object. The name is mandatory") +public class Ref { + + @Schema(description = "Extension group") + private String group; + + @Schema(description = "Extension version") + private String version; + + @Schema(description = "Extension kind") + 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); + } + +} diff --git a/api/src/main/java/run/halo/app/extension/Scheme.java b/api/src/main/java/run/halo/app/extension/Scheme.java new file mode 100644 index 0000000..a0deb49 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/Scheme.java @@ -0,0 +1,92 @@ +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(), + schema); + } + + /** + * 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(); + } +} diff --git a/api/src/main/java/run/halo/app/extension/SchemeManager.java b/api/src/main/java/run/halo/app/extension/SchemeManager.java new file mode 100644 index 0000000..d051a80 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/SchemeManager.java @@ -0,0 +1,65 @@ +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 run.halo.app.extension.exception.SchemeNotFoundException; +import run.halo.app.extension.index.IndexSpecs; + +public interface SchemeManager { + + void register(@NonNull Scheme scheme); + + void register(@NonNull Scheme scheme, Consumer specsConsumer); + + /** + * Registers an Extension using its type. + * + * @param type is Extension type. + * @param Extension class. + */ + default void register(Class type) { + register(Scheme.buildFromType(type)); + } + + default void register(Class type, Consumer specsConsumer) { + register(Scheme.buildFromType(type), 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); + } + +} diff --git a/api/src/main/java/run/halo/app/extension/Secret.java b/api/src/main/java/run/halo/app/extension/Secret.java new file mode 100644 index 0000000..ce029d8 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/Secret.java @@ -0,0 +1,54 @@ +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; + +} diff --git a/api/src/main/java/run/halo/app/extension/Unstructured.java b/api/src/main/java/run/halo/app/extension/Unstructured.java new file mode 100644 index 0000000..2ea80b5 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/Unstructured.java @@ -0,0 +1,283 @@ +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.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.Collections; +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 lombok.EqualsAndHashCode; + +/** + * 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) +@SuppressWarnings("rawtypes") +public class Unstructured implements Extension { + + public static final ObjectMapper OBJECT_MAPPER = Json.mapper(); + + private final Map data; + + public Unstructured() { + this(new HashMap()); + } + + public Unstructured(Map data) { + this.data = data; + } + + public Map getData() { + return Collections.unmodifiableMap(data); + } + + @Override + public String getApiVersion() { + return (String) data.get("apiVersion"); + } + + @Override + public String getKind() { + return (String) data.get("kind"); + } + + @Override + public MetadataOperator getMetadata() { + return new UnstructuredMetadata(); + } + + @EqualsAndHashCode(exclude = "version") + class UnstructuredMetadata implements MetadataOperator { + + @Override + public String getName() { + return (String) getNestedValue(data, "metadata", "name").orElse(null); + } + + @Override + public String getGenerateName() { + return (String) getNestedValue(data, "metadata", "generateName").orElse(null); + } + + @Override + public Map getLabels() { + return getNestedStringStringMap(data, "metadata", "labels").orElse(null); + } + + @Override + public Map getAnnotations() { + return getNestedStringStringMap(data, "metadata", "annotations").orElse(null); + } + + @Override + public Long getVersion() { + return getNestedLong(data, "metadata", "version").orElse(null); + } + + @Override + public Instant getCreationTimestamp() { + return getNestedInstant(data, "metadata", "creationTimestamp").orElse(null); + } + + @Override + public Instant getDeletionTimestamp() { + return getNestedInstant(data, "metadata", "deletionTimestamp").orElse(null); + } + + @Override + public Set getFinalizers() { + return getNestedStringSet(data, "metadata", "finalizers").orElse(null); + } + + @Override + public void setName(String name) { + setNestedValue(data, name, "metadata", "name"); + } + + @Override + public void setGenerateName(String generateName) { + setNestedValue(data, generateName, "metadata", "generateName"); + } + + @Override + public void setLabels(Map labels) { + setNestedValue(data, labels, "metadata", "labels"); + } + + @Override + public void setAnnotations(Map annotations) { + setNestedValue(data, annotations, "metadata", "annotations"); + } + + @Override + public void setVersion(Long version) { + setNestedValue(data, version, "metadata", "version"); + } + + @Override + public void setCreationTimestamp(Instant creationTimestamp) { + setNestedValue(data, creationTimestamp, "metadata", "creationTimestamp"); + } + + @Override + public void setDeletionTimestamp(Instant deletionTimestamp) { + setNestedValue(data, deletionTimestamp, "metadata", "deletionTimestamp"); + } + + @Override + public void setFinalizers(Set finalizers) { + setNestedValue(data, finalizers, "metadata", "finalizers"); + } + } + + + @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); + } + + } + + 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); + } +} diff --git a/api/src/main/java/run/halo/app/extension/Watcher.java b/api/src/main/java/run/halo/app/extension/Watcher.java new file mode 100644 index 0000000..210189b --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/Watcher.java @@ -0,0 +1,89 @@ +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; + } + } +} diff --git a/api/src/main/java/run/halo/app/extension/WatcherExtensionMatchers.java b/api/src/main/java/run/halo/app/extension/WatcherExtensionMatchers.java new file mode 100644 index 0000000..d8fce31 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/WatcherExtensionMatchers.java @@ -0,0 +1,91 @@ +package run.halo.app.extension; + +import java.util.Objects; +import lombok.Builder; +import lombok.Getter; +import org.springframework.util.Assert; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.extension.router.selector.LabelSelector; + +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.requireNonNullElse(onAddMatcher, emptyMatcher(client, gvk)); + this.onUpdateMatcher = + Objects.requireNonNullElse(onUpdateMatcher, emptyMatcher(client, gvk)); + this.onDeleteMatcher = + Objects.requireNonNullElse(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(); + } + + /** + * Remove this method when the deprecated methods are removed. + */ + ExtensionMatcher delegateExtensionMatcher(ExtensionMatcher matcher) { + return new ExtensionMatcher() { + + @Override + public GroupVersionKind getGvk() { + return matcher.getGvk(); + } + + @Override + public LabelSelector getLabelSelector() { + return matcher.getLabelSelector(); + } + + @Override + public FieldSelector getFieldSelector() { + return matcher.getFieldSelector(); + } + + @Override + public boolean match(Extension extension) { + return extension.groupVersionKind().equals(gvk) && matcher.match(extension); + } + }; + } +} diff --git a/api/src/main/java/run/halo/app/extension/WatcherPredicates.java b/api/src/main/java/run/halo/app/extension/WatcherPredicates.java new file mode 100644 index 0000000..62a6afd --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/WatcherPredicates.java @@ -0,0 +1,99 @@ +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); + } + + } +} diff --git a/api/src/main/java/run/halo/app/extension/controller/Controller.java b/api/src/main/java/run/halo/app/extension/controller/Controller.java new file mode 100644 index 0000000..680f09b --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/controller/Controller.java @@ -0,0 +1,11 @@ +package run.halo.app.extension.controller; + +import reactor.core.Disposable; + +public interface Controller extends Disposable { + + String getName(); + + void start(); + +} diff --git a/api/src/main/java/run/halo/app/extension/controller/ControllerBuilder.java b/api/src/main/java/run/halo/app/extension/controller/ControllerBuilder.java new file mode 100644 index 0000000..6c36e25 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/controller/ControllerBuilder.java @@ -0,0 +1,146 @@ +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; + } + // In order to be compatible with the previous version of the code + // The previous version of the code determined syncAllListOptions through onAddMatcher + // TODO Will be removed later + if (onAddMatcher != null) { + return new ListOptions() + .setLabelSelector(onAddMatcher.getLabelSelector()) + .setFieldSelector(onAddMatcher.getFieldSelector()); + } + return new ListOptions(); + } +} diff --git a/api/src/main/java/run/halo/app/extension/controller/DefaultController.java b/api/src/main/java/run/halo/app/extension/controller/DefaultController.java new file mode 100644 index 0000000..532acfd --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/controller/DefaultController.java @@ -0,0 +1,251 @@ +package run.halo.app.extension.controller; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +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.apache.commons.lang3.concurrent.BasicThreadFactory; +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 ExecutorService 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) { + 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, + Executors.newFixedThreadPool(workerCount, threadFactory(name)), workerCount); + } + + private static ThreadFactory threadFactory(String name) { + return new BasicThreadFactory.Builder() + .namingPattern(name + "-t-%d") + .daemon(false) + .uncaughtExceptionHandler((t, e) -> + log.error("Controller " + t.getName() + " encountered an error unexpectedly", e)) + .build(); + } + + @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; + log.info("Starting controller {}", name); + IntStream.range(0, getWorkerCount()) + .mapToObj(i -> new Worker()) + .forEach(executor::submit); + } + + /** + * 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); + if (synchronizer != null) { + synchronizer.start(); + } + 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) { + 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(); + } + + executor.shutdownNow(); + try { + if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { + log.warn("Wait timeout for controller {} shutdown", name); + } else { + log.info("Controller {} is disposed", name); + } + } catch (InterruptedException e) { + log.warn("Interrupted while waiting for controller {} shutdown", name); + } finally { + queue.dispose(); + } + } + + @Override + public boolean isDisposed() { + return disposed; + } + + public boolean isStarted() { + return started; + } +} diff --git a/api/src/main/java/run/halo/app/extension/controller/DefaultQueue.java b/api/src/main/java/run/halo/app/extension/controller/DefaultQueue.java new file mode 100644 index 0000000..e3556f5 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/controller/DefaultQueue.java @@ -0,0 +1,159 @@ +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(); + } + +} diff --git a/api/src/main/java/run/halo/app/extension/controller/ExtensionWatcher.java b/api/src/main/java/run/halo/app/extension/controller/ExtensionWatcher.java new file mode 100644 index 0000000..3e0f854 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/controller/ExtensionWatcher.java @@ -0,0 +1,76 @@ +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; + } + +} diff --git a/api/src/main/java/run/halo/app/extension/controller/Reconciler.java b/api/src/main/java/run/halo/app/extension/controller/Reconciler.java new file mode 100644 index 0000000..c8a6c92 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/controller/Reconciler.java @@ -0,0 +1,24 @@ +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); + } + } +} diff --git a/api/src/main/java/run/halo/app/extension/controller/RequestQueue.java b/api/src/main/java/run/halo/app/extension/controller/RequestQueue.java new file mode 100644 index 0000000..f95acc6 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/controller/RequestQueue.java @@ -0,0 +1,89 @@ +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); + } + } +} diff --git a/api/src/main/java/run/halo/app/extension/controller/RequestSynchronizer.java b/api/src/main/java/run/halo/app/extension/controller/RequestSynchronizer.java new file mode 100644 index 0000000..bdb7ddf --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/controller/RequestSynchronizer.java @@ -0,0 +1,73 @@ +package run.halo.app.extension.controller; + +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.GroupVersionKind; +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.IndexedQueryEngine; + +@Slf4j +public class RequestSynchronizer implements Synchronizer { + + private final ExtensionClient client; + + private final GroupVersionKind type; + + private final boolean syncAllOnStart; + + private volatile boolean disposed = false; + + private final IndexedQueryEngine indexedQueryEngine; + + 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.groupVersionKind(); + this.watcher = watcher; + this.indexedQueryEngine = client.indexedQueryEngine(); + this.listOptions = listOptions; + } + + @Override + public void start() { + if (isDisposed() || started) { + return; + } + log.info("Starting request({}) synchronizer...", type); + started = true; + + if (syncAllOnStart) { + indexedQueryEngine.retrieveAll(type, listOptions, Sort.by("metadata.creationTimestamp")) + .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; + } +} diff --git a/api/src/main/java/run/halo/app/extension/controller/RequeueException.java b/api/src/main/java/run/halo/app/extension/controller/RequeueException.java new file mode 100644 index 0000000..bbb96f2 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/controller/RequeueException.java @@ -0,0 +1,31 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/extension/controller/Synchronizer.java b/api/src/main/java/run/halo/app/extension/controller/Synchronizer.java new file mode 100644 index 0000000..00299f0 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/controller/Synchronizer.java @@ -0,0 +1,9 @@ +package run.halo.app.extension.controller; + +import reactor.core.Disposable; + +public interface Synchronizer extends Disposable { + + void start(); + +} diff --git a/api/src/main/java/run/halo/app/extension/exception/ExtensionException.java b/api/src/main/java/run/halo/app/extension/exception/ExtensionException.java new file mode 100644 index 0000000..7222974 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/exception/ExtensionException.java @@ -0,0 +1,26 @@ +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); + } +} diff --git a/api/src/main/java/run/halo/app/extension/exception/SchemeNotFoundException.java b/api/src/main/java/run/halo/app/extension/exception/SchemeNotFoundException.java new file mode 100644 index 0000000..ee6b01d --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/exception/SchemeNotFoundException.java @@ -0,0 +1,18 @@ +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}); + } + +} diff --git a/api/src/main/java/run/halo/app/extension/index/AbstractIndexAttribute.java b/api/src/main/java/run/halo/app/extension/index/AbstractIndexAttribute.java new file mode 100644 index 0000000..bfe56a7 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/AbstractIndexAttribute.java @@ -0,0 +1,33 @@ +package run.halo.app.extension.index; + +import lombok.EqualsAndHashCode; +import org.springframework.util.Assert; +import run.halo.app.extension.Extension; +import run.halo.app.extension.GVK; + +@EqualsAndHashCode +public abstract class AbstractIndexAttribute implements IndexAttribute { + private final Class objectType; + + /** + * Creates a new {@link AbstractIndexAttribute} for the given object type. + * + * @param objectType must not be {@literal null}. + */ + public AbstractIndexAttribute(Class objectType) { + Assert.notNull(objectType, "Object type must not be null"); + Assert.state(isValidExtension(objectType), + "Invalid extension type, make sure you have annotated it with @" + GVK.class + .getSimpleName()); + this.objectType = objectType; + } + + @Override + public Class getObjectType() { + return this.objectType; + } + + boolean isValidExtension(Class type) { + return type.getAnnotation(GVK.class) != null; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/FunctionalIndexAttribute.java b/api/src/main/java/run/halo/app/extension/index/FunctionalIndexAttribute.java new file mode 100644 index 0000000..c33f0cd --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/FunctionalIndexAttribute.java @@ -0,0 +1,49 @@ +package run.halo.app.extension.index; + +import java.util.Set; +import java.util.function.Function; +import lombok.EqualsAndHashCode; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import run.halo.app.extension.Extension; + +@EqualsAndHashCode(callSuper = true) +public class FunctionalIndexAttribute + extends AbstractIndexAttribute { + + @EqualsAndHashCode.Exclude + private final Function valueFunc; + + /** + * Creates a new {@link FunctionalIndexAttribute} for the given object type and value function. + * + * @param objectType must not be {@literal null}. + * @param valueFunc value function must not be {@literal null}. + */ + public FunctionalIndexAttribute(Class objectType, + Function valueFunc) { + super(objectType); + Assert.notNull(valueFunc, "Value function must not be null"); + this.valueFunc = valueFunc; + } + + @Override + public Set getValues(Extension object) { + var value = getValue(object); + return value == null ? Set.of() : Set.of(value); + } + + /** + * Gets the value for the given object. + * + * @param object the object to get the value for. + * @return returns the value for the given object. + */ + @Nullable + public String getValue(Extension object) { + if (getObjectType().isInstance(object)) { + return valueFunc.apply(getObjectType().cast(object)); + } + throw new IllegalArgumentException("Object type does not match"); + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/FunctionalMultiValueIndexAttribute.java b/api/src/main/java/run/halo/app/extension/index/FunctionalMultiValueIndexAttribute.java new file mode 100644 index 0000000..adbc529 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/FunctionalMultiValueIndexAttribute.java @@ -0,0 +1,41 @@ +package run.halo.app.extension.index; + +import java.util.Set; +import java.util.function.Function; +import lombok.EqualsAndHashCode; +import org.springframework.util.Assert; +import run.halo.app.extension.Extension; + +@EqualsAndHashCode(callSuper = true) +public class FunctionalMultiValueIndexAttribute + extends AbstractIndexAttribute { + + @EqualsAndHashCode.Exclude + private final Function> valueFunc; + + /** + * Creates a new {@link FunctionalIndexAttribute} for the given object type and value function. + * + * @param objectType object type must not be {@literal null}. + * @param valueFunc value function must not be {@literal null}. + */ + public FunctionalMultiValueIndexAttribute(Class objectType, + Function> valueFunc) { + super(objectType); + Assert.notNull(valueFunc, "Value function must not be null"); + this.valueFunc = valueFunc; + } + + @Override + public Set getValues(Extension object) { + if (getObjectType().isInstance(object)) { + return getNonNullValues(getObjectType().cast(object)); + } + throw new IllegalArgumentException("Object type does not match"); + } + + private Set getNonNullValues(E object) { + var values = valueFunc.apply(object); + return values == null ? Set.of() : values; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/IndexAttribute.java b/api/src/main/java/run/halo/app/extension/index/IndexAttribute.java new file mode 100644 index 0000000..a75e40a --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/IndexAttribute.java @@ -0,0 +1,23 @@ +package run.halo.app.extension.index; + +import java.util.Set; +import run.halo.app.extension.Extension; + +public interface IndexAttribute { + + /** + * Specify this class is belonged to which extension. + * + * @return the extension class. + */ + Class getObjectType(); + + /** + * Get the value of the attribute. + * + * @param object the object to get value from. + * @param the type of the object. + * @return the value of the attribute must not be null. + */ + Set getValues(E object); +} diff --git a/api/src/main/java/run/halo/app/extension/index/IndexAttributeFactory.java b/api/src/main/java/run/halo/app/extension/index/IndexAttributeFactory.java new file mode 100644 index 0000000..81755f0 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/IndexAttributeFactory.java @@ -0,0 +1,20 @@ +package run.halo.app.extension.index; + +import java.util.Set; +import java.util.function.Function; +import lombok.experimental.UtilityClass; +import run.halo.app.extension.Extension; + +@UtilityClass +public class IndexAttributeFactory { + + public static IndexAttribute simpleAttribute(Class type, + Function valueFunc) { + return new FunctionalIndexAttribute<>(type, valueFunc); + } + + public static IndexAttribute multiValueAttribute(Class type, + Function> valueFunc) { + return new FunctionalMultiValueIndexAttribute<>(type, valueFunc); + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/IndexDescriptor.java b/api/src/main/java/run/halo/app/extension/index/IndexDescriptor.java new file mode 100644 index 0000000..086ffe8 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/IndexDescriptor.java @@ -0,0 +1,20 @@ +package run.halo.app.extension.index; + +import lombok.Data; +import lombok.ToString; + +@Data +@ToString(callSuper = true) +public class IndexDescriptor { + + private final IndexSpec spec; + + /** + * Record whether the index is ready, managed by {@code IndexBuilder}. + */ + private boolean ready; + + public IndexDescriptor(IndexSpec spec) { + this.spec = spec; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/IndexEntry.java b/api/src/main/java/run/halo/app/extension/index/IndexEntry.java new file mode 100644 index 0000000..d6ec03e --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/IndexEntry.java @@ -0,0 +1,132 @@ +package run.halo.app.extension.index; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.NavigableSet; +import run.halo.app.extension.Metadata; + +/** + *

{@link IndexEntry} used to store the mapping between index key and + * {@link Metadata#getName()}.

+ *

For example, if we have a {@link Metadata} with name {@code foo} and labels {@code bar=1} + * and {@code baz=2}, then the index entry will be:

+ *
+ *     bar=1 -> foo
+ *     baz=2 -> foo
+ *     
+ *

And if we have another {@link Metadata} with name {@code bar} and labels {@code bar=1} + * and {@code baz=3}, then the index entry will be:

+ *
+ *     bar=1 -> foo, bar
+ *     baz=2 -> foo
+ *     baz=3 -> bar
+ * 
+ *

{@link #getIndexDescriptor()} describes the owner of this index entry.

+ *

Index entries is ordered by key, and the order is determined by + * {@link IndexSpec#getOrder()}.

+ *

Do not modify the returned result for all methods of this class.

+ *

This class is thread-safe.

+ * + * @author guqing + * @since 2.12.0 + */ +public interface IndexEntry { + + /** + * Acquires the read lock for reading such as {@link #getObjectNamesBy(String)}, + * {@link #entries()}, {@link #indexedKeys()}, because the returned result set of these + * methods is not immutable. + */ + void acquireReadLock(); + + /** + * Releases the read lock. + */ + void releaseReadLock(); + + /** + *

Adds a new entry to this index entry.

+ *

For example, if we have a {@link Metadata} with name {@code foo} and labels {@code bar=1} + * and {@code baz=2} and index order is {@link IndexSpec.OrderType#ASC}, then the index entry + * will be:

+ *
+     *     bar=1 -> foo
+     *     baz=2 -> foo
+     * 
+ * + * @param indexKeys index keys + * @param objectKey object key (usually is {@link Metadata#getName()}). + */ + void addEntry(List indexKeys, String objectKey); + + /** + * Removes the entry with the given {@code indexedKey} and {@code objectKey}. + * + * @param indexedKey indexed key + * @param objectKey object key (usually is {@link Metadata#getName()}). + */ + void removeEntry(String indexedKey, String objectKey); + + /** + * Removes all entries with the given {@code objectKey}. + * + * @param objectKey object key(usually is {@link Metadata#getName()}). + */ + void remove(String objectKey); + + /** + * Returns the {@link IndexDescriptor} of this entry. + * + * @return the {@link IndexDescriptor} of this entry. + */ + IndexDescriptor getIndexDescriptor(); + + /** + * Returns the indexed keys of this entry in order. + * + * @return distinct indexed keys of this entry. + */ + NavigableSet indexedKeys(); + + /** + *

Returns the entries of this entry in order.

+ *

Note That: Any modification to the returned result will affect the original data + * directly.

+ * + * @return entries of this entry. + */ + Collection> entries(); + + /** + *

Returns the position of the object name in the indexed attribute value mapping for + * sorting.

+ * For example: + *
+     * metadata.name | field1
+     * ------------- | ------
+     * foo           | 1
+     * bar           | 2
+     * baz           | 2
+     * 
+ * "field1" is the indexed attribute, and the position of the object name in the indexed + * attribute + * value mapping for sorting is: + *
+     * foo -> 0
+     * bar -> 1
+     * baz -> 1
+     * 
+ * "bar" and "baz" have the same value, so they have the same position. + */ + Map getIdPositionMap(); + + /** + * Returns the object names of this entry in order. + * + * @return object names of this entry. + */ + List getObjectNamesBy(String indexKey); + + void clear(); +} diff --git a/api/src/main/java/run/halo/app/extension/index/IndexEntryOperator.java b/api/src/main/java/run/halo/app/extension/index/IndexEntryOperator.java new file mode 100644 index 0000000..5f3360b --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/IndexEntryOperator.java @@ -0,0 +1,55 @@ +package run.halo.app.extension.index; + +import java.util.Collection; +import java.util.NavigableSet; +import java.util.Set; + +public interface IndexEntryOperator { + + /** + * Search all values that key less than the target key. + * + * @param key target key + * @param orEqual whether to include the value of the target key + * @return object names that key less than the target key + */ + NavigableSet lessThan(String key, boolean orEqual); + + /** + * Search all values that key greater than the target key. + * + * @param key target key + * @param orEqual whether to include the value of the target key + * @return object names that key greater than the target key + */ + NavigableSet greaterThan(String key, boolean orEqual); + + /** + * Search all values that key in the range of [start, end]. + * + * @param start start key + * @param end end key + * @param startInclusive whether to include the value of the start key + * @param endInclusive whether to include the value of the end key + * @return object names that key in the range of [start, end] + */ + NavigableSet range(String start, String end, boolean startInclusive, + boolean endInclusive); + + /** + * Find all values that key equals to the target key. + * + * @param key target key + * @return object names that key equals to the target key + */ + NavigableSet find(String key); + + NavigableSet findIn(Collection keys); + + /** + * Get all values in the index entry. + * + * @return a set of all object names + */ + Set getValues(); +} diff --git a/api/src/main/java/run/halo/app/extension/index/IndexEntryOperatorImpl.java b/api/src/main/java/run/halo/app/extension/index/IndexEntryOperatorImpl.java new file mode 100644 index 0000000..8570e31 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/IndexEntryOperatorImpl.java @@ -0,0 +1,112 @@ +package run.halo.app.extension.index; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.NavigableSet; +import java.util.Set; +import java.util.TreeSet; +import org.springframework.util.Assert; + +public class IndexEntryOperatorImpl implements IndexEntryOperator { + private final IndexEntry indexEntry; + + public IndexEntryOperatorImpl(IndexEntry indexEntry) { + this.indexEntry = indexEntry; + } + + private static NavigableSet createNavigableSet() { + return new TreeSet<>(KeyComparator.INSTANCE); + } + + @Override + public NavigableSet lessThan(String key, boolean orEqual) { + Assert.notNull(key, "Key must not be null."); + indexEntry.acquireReadLock(); + try { + var navigableIndexedKeys = indexEntry.indexedKeys(); + var headSetKeys = navigableIndexedKeys.headSet(key, orEqual); + return findIn(headSetKeys); + } finally { + indexEntry.releaseReadLock(); + } + } + + @Override + public NavigableSet greaterThan(String key, boolean orEqual) { + Assert.notNull(key, "Key must not be null."); + indexEntry.acquireReadLock(); + try { + var navigableIndexedKeys = indexEntry.indexedKeys(); + var tailSetKeys = navigableIndexedKeys.tailSet(key, orEqual); + return findIn(tailSetKeys); + } finally { + indexEntry.releaseReadLock(); + } + } + + @Override + public NavigableSet range(String start, String end, boolean startInclusive, + boolean endInclusive) { + Assert.notNull(start, "The start must not be null."); + Assert.notNull(end, "The end must not be null."); + indexEntry.acquireReadLock(); + try { + var navigableIndexedKeys = indexEntry.indexedKeys(); + var tailSetKeys = navigableIndexedKeys.subSet(start, startInclusive, end, endInclusive); + return findIn(tailSetKeys); + } finally { + indexEntry.releaseReadLock(); + } + } + + @Override + public NavigableSet find(String key) { + Assert.notNull(key, "The key must not be null."); + indexEntry.acquireReadLock(); + try { + var resultSet = createNavigableSet(); + var result = indexEntry.getObjectNamesBy(key); + if (result != null) { + resultSet.addAll(result); + } + return resultSet; + } finally { + indexEntry.releaseReadLock(); + } + } + + @Override + public NavigableSet findIn(Collection keys) { + if (keys == null || keys.isEmpty()) { + return createNavigableSet(); + } + indexEntry.acquireReadLock(); + try { + var keysToSearch = new HashSet<>(keys); + var resultSet = createNavigableSet(); + for (var entry : indexEntry.entries()) { + if (keysToSearch.contains(entry.getKey())) { + resultSet.add(entry.getValue()); + } + } + return resultSet; + } finally { + indexEntry.releaseReadLock(); + } + } + + @Override + public Set getValues() { + indexEntry.acquireReadLock(); + try { + Set uniqueValues = new HashSet<>(); + for (Map.Entry entry : indexEntry.entries()) { + uniqueValues.add(entry.getValue()); + } + return uniqueValues; + } finally { + indexEntry.releaseReadLock(); + } + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/IndexSpec.java b/api/src/main/java/run/halo/app/extension/index/IndexSpec.java new file mode 100644 index 0000000..e351d8e --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/IndexSpec.java @@ -0,0 +1,39 @@ +package run.halo.app.extension.index; + +import com.google.common.base.Objects; +import lombok.Data; +import lombok.experimental.Accessors; + +@Data +@Accessors(chain = true) +public class IndexSpec { + private String name; + + private IndexAttribute indexFunc; + + private OrderType order; + + private boolean unique; + + public enum OrderType { + ASC, + DESC + } + + @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); + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/IndexSpecRegistry.java b/api/src/main/java/run/halo/app/extension/index/IndexSpecRegistry.java new file mode 100644 index 0000000..451ca42 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/IndexSpecRegistry.java @@ -0,0 +1,54 @@ +package run.halo.app.extension.index; + +import org.springframework.lang.NonNull; +import run.halo.app.extension.Extension; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.Scheme; + +/** + *

{@link IndexSpecRegistry} is a registry for {@link IndexSpecs} to manage {@link IndexSpecs} + * for different {@link Extension}.

+ * + * @author guqing + * @since 2.12.0 + */ +public interface IndexSpecRegistry { + /** + *

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

+ *

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 scheme must not be {@literal null}. + * @return the {@link IndexSpecs} for the given {@link Scheme}. + */ + IndexSpecs indexFor(Scheme scheme); + + /** + * Get {@link IndexSpecs} for the given {@link Scheme} type registered before. + * + * @param scheme must not be {@literal null}. + * @return the {@link IndexSpecs} for the given {@link Scheme}. + * @throws IllegalArgumentException if no {@link IndexSpecs} found for the given + * {@link Scheme}. + */ + IndexSpecs getIndexSpecs(Scheme scheme); + + boolean contains(Scheme scheme); + + void removeIndexSpecs(Scheme scheme); + + /** + * 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); +} diff --git a/api/src/main/java/run/halo/app/extension/index/IndexSpecs.java b/api/src/main/java/run/halo/app/extension/index/IndexSpecs.java new file mode 100644 index 0000000..c84bead --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/IndexSpecs.java @@ -0,0 +1,54 @@ +package run.halo.app.extension.index; + +import java.util.List; +import org.springframework.lang.Nullable; + +/** + * 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 + */ + void add(IndexSpec indexSpec); + + /** + * Get all {@link IndexSpec} in the collection. + * + * @return all index specs + */ + List getIndexSpecs(); + + /** + * Get the {@link IndexSpec} with the given name. + * + * @param indexName the name of the index spec to get. + * @return the index spec with the given name, or {@code null} if not found. + */ + @Nullable + IndexSpec getIndexSpec(String indexName); + + /** + * Check if the collection contains the {@link IndexSpec} with the given name. + * + * @param indexName the name of the index spec to check. + * @return {@code true} if the collection contains the index spec with the given name, + */ + boolean contains(String indexName); + + /** + * Remove the {@link IndexSpec} with the given name. + * + * @param name the name of the index spec to remove. + */ + void remove(String name); +} diff --git a/api/src/main/java/run/halo/app/extension/index/IndexedQueryEngine.java b/api/src/main/java/run/halo/app/extension/index/IndexedQueryEngine.java new file mode 100644 index 0000000..b6c4623 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/IndexedQueryEngine.java @@ -0,0 +1,44 @@ +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; + +/** + *

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 + */ +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); +} diff --git a/api/src/main/java/run/halo/app/extension/index/Indexer.java b/api/src/main/java/run/halo/app/extension/index/Indexer.java new file mode 100644 index 0000000..e92f336 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/Indexer.java @@ -0,0 +1,106 @@ +package run.halo.app.extension.index; + +import java.util.Iterator; +import java.util.function.Function; +import org.springframework.lang.NonNull; +import run.halo.app.extension.Extension; + +/** + *

The {@link Indexer} is owned by the {@link Extension} and is responsible for the lookup and + * lifetimes of the indexes in a {@link Extension} collection. Every {@link Extension} has + * exactly one instance of this class.

+ *

Callers are expected to have acquired the necessary locks while accessing this interface.

+ * To inspect the contents of this {@link Indexer}, callers may obtain an iterator from + * getIndexIterator(). + * + * @author guqing + * @since 2.12.0 + */ +public interface Indexer { + + /** + *

Index the specified {@link Extension} by {@link IndexDescriptor}s.

+ *

First, the {@link Indexer} will index the {@link Extension} by the + * {@link IndexDescriptor}s and record the index entries to {@code IndexerTransaction} and + * commit the transaction, if any error occurs, the transaction will be rollback to keep the + * {@link Indexer} consistent.

+ * + * @param extension the {@link Extension} to be indexed + * @param the type of the {@link Extension} + */ + void indexRecord(E extension); + + /** + *

Update indexes for the specified {@link Extension} by {@link IndexDescriptor}s.

+ *

First, the {@link Indexer} will remove the index entries of the {@link Extension} by + * the old {@link IndexDescriptor}s and reindex the {@link Extension} to generate change logs + * to {@code IndexerTransaction} and commit the transaction, if any error occurs, the + * transaction will be rollback to keep the {@link Indexer} consistent.

+ * + * @param extension the {@link Extension} to be updated + * @param the type of the {@link Extension} + */ + void updateRecord(E extension); + + /** + *

Remove indexes (index entries) for the specified {@link Extension} already indexed by + * {@link IndexDescriptor}s.

+ * + * @param extensionName the {@link Extension} to be removed + */ + void unIndexRecord(String extensionName); + + /** + *

Find index by name.

+ *

The index name uniquely identifies an index.

+ * + * @param name index name + * @return index descriptor if found, null otherwise + */ + IndexDescriptor findIndexByName(String name); + + /** + *

Create an index entry for the specified {@link IndexDescriptor}.

+ * + * @param descriptor the {@link IndexDescriptor} to be recorded + * @return the {@link IndexEntry} created + */ + IndexEntry createIndexEntry(IndexDescriptor descriptor); + + /** + *

Remove all index entries that match the given {@link IndexDescriptor}.

+ * + * @param matchFn the {@link IndexDescriptor} to be matched + */ + void removeIndexRecords(Function matchFn); + + /** + *

Get the {@link IndexEntry} by index name if found and ready.

+ * + * @param name an index name + * @return the {@link IndexEntry} if found + * @throws IllegalArgumentException if the index name is not found or the index is not ready + */ + @NonNull + IndexEntry getIndexEntry(String name); + + /** + *

Gets an iterator over all the ready {@link IndexEntry}s, in no particular order.

+ * + * @return an iterator over all the ready {@link IndexEntry}s + * @see IndexDescriptor#isReady() + */ + Iterator readyIndexesIterator(); + + /** + *

Gets an iterator over all the {@link IndexEntry}s, in no particular order.

+ * + * @return an iterator over all the {@link IndexEntry}s + * @see IndexDescriptor#isReady() + */ + Iterator allIndexesIterator(); + + void acquireReadLock(); + + void releaseReadLock(); +} diff --git a/api/src/main/java/run/halo/app/extension/index/KeyComparator.java b/api/src/main/java/run/halo/app/extension/index/KeyComparator.java new file mode 100644 index 0000000..750bee3 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/KeyComparator.java @@ -0,0 +1,47 @@ +package run.halo.app.extension.index; + +import java.util.Comparator; +import org.springframework.lang.Nullable; + +public 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; + } + + int i = 0; + int j = 0; + while (i < a.length() && j < b.length()) { + if (Character.isDigit(a.charAt(i)) && Character.isDigit(b.charAt(j))) { + // handle number part + int num1 = 0; + int num2 = 0; + while (i < a.length() && Character.isDigit(a.charAt(i))) { + num1 = num1 * 10 + (a.charAt(i++) - '0'); + } + while (j < b.length() && Character.isDigit(b.charAt(j))) { + num2 = num2 * 10 + (b.charAt(j++) - '0'); + } + if (num1 != num2) { + return num1 - num2; + } + } else if (a.charAt(i) != b.charAt(j)) { + // handle non-number part + return a.charAt(i) - b.charAt(j); + } else { + i++; + j++; + } + } + return a.length() - b.length(); + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/All.java b/api/src/main/java/run/halo/app/extension/index/query/All.java new file mode 100644 index 0000000..46b47b4 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/All.java @@ -0,0 +1,20 @@ +package run.halo.app.extension.index.query; + +import java.util.NavigableSet; + +public class All extends SimpleQuery { + + public All(String fieldName) { + super(fieldName, null); + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + return indexView.getIdsForField(fieldName); + } + + @Override + public String toString() { + return fieldName + " != null"; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/And.java b/api/src/main/java/run/halo/app/extension/index/query/And.java new file mode 100644 index 0000000..b47e8f0 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/And.java @@ -0,0 +1,43 @@ +package run.halo.app.extension.index.query; + +import com.google.common.collect.Sets; +import java.util.Collection; +import java.util.NavigableSet; +import java.util.stream.Collectors; + +public class And extends LogicalQuery { + + /** + * Creates a new And query with the given child queries. + * + * @param childQueries The child queries + */ + public And(Collection childQueries) { + super(childQueries); + if (this.size < 2) { + throw new IllegalStateException( + "An 'And' query cannot have fewer than 2 child queries, " + childQueries.size() + + " were supplied"); + } + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + NavigableSet resultSet = null; + for (Query query : childQueries) { + NavigableSet currentResult = query.matches(indexView); + if (resultSet == null) { + resultSet = Sets.newTreeSet(currentResult); + } else { + resultSet.retainAll(currentResult); + } + } + return resultSet == null ? Sets.newTreeSet() : resultSet; + } + + @Override + public String toString() { + return "(" + childQueries.stream().map(Query::toString) + .collect(Collectors.joining(" AND ")) + ")"; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/Between.java b/api/src/main/java/run/halo/app/extension/index/query/Between.java new file mode 100644 index 0000000..e512a3f --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/Between.java @@ -0,0 +1,31 @@ +package run.halo.app.extension.index.query; + +import java.util.NavigableSet; + +public class Between extends SimpleQuery { + private final String lowerValue; + private final boolean lowerInclusive; + private final String upperValue; + private final boolean upperInclusive; + + public Between(String fieldName, String lowerValue, boolean lowerInclusive, + String upperValue, boolean upperInclusive) { + // value and isFieldRef are not used in Between + super(fieldName, null, false); + this.lowerValue = lowerValue; + this.lowerInclusive = lowerInclusive; + this.upperValue = upperValue; + this.upperInclusive = upperInclusive; + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + return indexView.between(fieldName, lowerValue, lowerInclusive, upperValue, upperInclusive); + } + + @Override + public String toString() { + return fieldName + " BETWEEN " + (lowerInclusive ? "[" : "(") + lowerValue + ", " + + upperValue + (upperInclusive ? "]" : ")"); + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/EqualQuery.java b/api/src/main/java/run/halo/app/extension/index/query/EqualQuery.java new file mode 100644 index 0000000..0ce826a --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/EqualQuery.java @@ -0,0 +1,32 @@ +package run.halo.app.extension.index.query; + +import java.util.NavigableSet; +import org.springframework.util.Assert; + +public class EqualQuery extends SimpleQuery { + + public EqualQuery(String fieldName, String value) { + super(fieldName, value); + } + + public EqualQuery(String fieldName, String value, boolean isFieldRef) { + super(fieldName, value, isFieldRef); + Assert.notNull(value, "Value must not be null, use IsNull or IsNotNull instead"); + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + if (isFieldRef) { + return indexView.findMatchingIdsWithEqualValues(fieldName, value); + } + return indexView.findIds(fieldName, value); + } + + @Override + public String toString() { + if (isFieldRef) { + return fieldName + " = " + value; + } + return fieldName + " = '" + value + "'"; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/GreaterThanQuery.java b/api/src/main/java/run/halo/app/extension/index/query/GreaterThanQuery.java new file mode 100644 index 0000000..101286d --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/GreaterThanQuery.java @@ -0,0 +1,31 @@ +package run.halo.app.extension.index.query; + +import java.util.NavigableSet; + +public class GreaterThanQuery extends SimpleQuery { + private final boolean orEqual; + + public GreaterThanQuery(String fieldName, String value, boolean orEqual) { + this(fieldName, value, orEqual, false); + } + + public GreaterThanQuery(String fieldName, String value, boolean orEqual, boolean isFieldRef) { + super(fieldName, value, isFieldRef); + this.orEqual = orEqual; + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + if (isFieldRef) { + return indexView.findMatchingIdsWithGreaterValues(fieldName, value, orEqual); + } + return indexView.findIdsGreaterThan(fieldName, value, orEqual); + } + + @Override + public String toString() { + return fieldName + + (orEqual ? " >= " : " > ") + + (isFieldRef ? value : "'" + value + "'"); + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/InQuery.java b/api/src/main/java/run/halo/app/extension/index/query/InQuery.java new file mode 100644 index 0000000..b9aa026 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/InQuery.java @@ -0,0 +1,29 @@ +package run.halo.app.extension.index.query; + +import java.util.NavigableSet; +import java.util.Set; +import java.util.stream.Collectors; +import run.halo.app.extension.index.IndexEntryOperatorImpl; + +public class InQuery extends SimpleQuery { + private final Set values; + + public InQuery(String columnName, Set values) { + super(columnName, null); + this.values = values; + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + var indexEntry = indexView.getIndexEntry(fieldName); + var operator = new IndexEntryOperatorImpl(indexEntry); + return operator.findIn(values); + } + + @Override + public String toString() { + return fieldName + " IN (" + values.stream() + .map(value -> "'" + value + "'") + .collect(Collectors.joining(", ")) + ")"; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/IsNotNull.java b/api/src/main/java/run/halo/app/extension/index/query/IsNotNull.java new file mode 100644 index 0000000..1c66133 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/IsNotNull.java @@ -0,0 +1,20 @@ +package run.halo.app.extension.index.query; + +import java.util.NavigableSet; + +public class IsNotNull extends SimpleQuery { + + protected IsNotNull(String fieldName) { + super(fieldName, null); + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + return indexView.getIdsForField(fieldName); + } + + @Override + public String toString() { + return fieldName + " IS NOT NULL"; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/IsNull.java b/api/src/main/java/run/halo/app/extension/index/query/IsNull.java new file mode 100644 index 0000000..d74040c --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/IsNull.java @@ -0,0 +1,28 @@ +package run.halo.app.extension.index.query; + +import java.util.NavigableSet; + +public class IsNull extends SimpleQuery { + + protected IsNull(String fieldName) { + super(fieldName, null); + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + indexView.acquireReadLock(); + try { + var allIds = indexView.getAllIds(); + var idsForNonNullValue = indexView.getIdsForField(fieldName); + allIds.removeAll(idsForNonNullValue); + return allIds; + } finally { + indexView.releaseReadLock(); + } + } + + @Override + public String toString() { + return fieldName + " IS NULL"; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/LessThanQuery.java b/api/src/main/java/run/halo/app/extension/index/query/LessThanQuery.java new file mode 100644 index 0000000..d7168e8 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/LessThanQuery.java @@ -0,0 +1,31 @@ +package run.halo.app.extension.index.query; + +import java.util.NavigableSet; + +public class LessThanQuery extends SimpleQuery { + private final boolean orEqual; + + public LessThanQuery(String fieldName, String value, boolean orEqual) { + this(fieldName, value, orEqual, false); + } + + public LessThanQuery(String fieldName, String value, boolean orEqual, boolean isFieldRef) { + super(fieldName, value, isFieldRef); + this.orEqual = orEqual; + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + if (isFieldRef) { + return indexView.findMatchingIdsWithSmallerValues(fieldName, value, orEqual); + } + return indexView.findIdsLessThan(fieldName, value, orEqual); + } + + @Override + public String toString() { + return fieldName + + (orEqual ? " <= " : " < ") + + (isFieldRef ? value : "'" + value + "'"); + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/LogicalQuery.java b/api/src/main/java/run/halo/app/extension/index/query/LogicalQuery.java new file mode 100644 index 0000000..2311109 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/LogicalQuery.java @@ -0,0 +1,33 @@ +package run.halo.app.extension.index.query; + +import java.util.Collection; +import java.util.Objects; +import lombok.Getter; + +@Getter +public abstract class LogicalQuery implements Query { + protected final Collection childQueries; + protected final int size; + + /** + * Creates a new logical query with the given child queries. + * + * @param childQueries with the given child queries. + */ + public LogicalQuery(Collection childQueries) { + Objects.requireNonNull(childQueries, + "The child queries supplied to a logical query cannot be null"); + for (Query query : childQueries) { + if (!isValid(query)) { + throw new IllegalStateException("Unexpected type of query: " + (query == null ? null + : query + ", " + query.getClass())); + } + } + this.size = childQueries.size(); + this.childQueries = childQueries; + } + + boolean isValid(Query query) { + return query instanceof LogicalQuery || query instanceof SimpleQuery; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/Not.java b/api/src/main/java/run/halo/app/extension/index/query/Not.java new file mode 100644 index 0000000..98d0e29 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/Not.java @@ -0,0 +1,32 @@ +package run.halo.app.extension.index.query; + +import static java.util.Objects.requireNonNull; + +import java.util.Collections; +import java.util.NavigableSet; +import lombok.Getter; + +@Getter +public class Not extends LogicalQuery { + + private final Query negatedQuery; + + public Not(Query negatedQuery) { + super(Collections.singleton( + requireNonNull(negatedQuery, "The negated query must not be null."))); + this.negatedQuery = negatedQuery; + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + var negatedResult = negatedQuery.matches(indexView); + var allIds = indexView.getAllIds(); + allIds.removeAll(negatedResult); + return allIds; + } + + @Override + public String toString() { + return "NOT (" + negatedQuery + ")"; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/NotEqual.java b/api/src/main/java/run/halo/app/extension/index/query/NotEqual.java new file mode 100644 index 0000000..af8f694 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/NotEqual.java @@ -0,0 +1,36 @@ +package run.halo.app.extension.index.query; + +import java.util.NavigableSet; +import org.springframework.util.Assert; + +public class NotEqual extends SimpleQuery { + private final EqualQuery equalQuery; + + public NotEqual(String fieldName, String value) { + this(fieldName, value, false); + } + + public NotEqual(String fieldName, String value, boolean isFieldRef) { + super(fieldName, value, isFieldRef); + Assert.notNull(value, "Value must not be null, use IsNull or IsNotNull instead"); + this.equalQuery = new EqualQuery(fieldName, value, isFieldRef); + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + indexView.acquireReadLock(); + try { + NavigableSet equalNames = equalQuery.matches(indexView); + NavigableSet allNames = indexView.getAllIds(); + allNames.removeAll(equalNames); + return allNames; + } finally { + indexView.releaseReadLock(); + } + } + + @Override + public String toString() { + return fieldName + " != " + (isFieldRef ? value : "'" + value + "'"); + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/Or.java b/api/src/main/java/run/halo/app/extension/index/query/Or.java new file mode 100644 index 0000000..c8579c5 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/Or.java @@ -0,0 +1,28 @@ +package run.halo.app.extension.index.query; + +import com.google.common.collect.Sets; +import java.util.Collection; +import java.util.NavigableSet; +import java.util.stream.Collectors; + +public class Or extends LogicalQuery { + + public Or(Collection childQueries) { + super(childQueries); + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + var resultSet = Sets.newTreeSet(); + for (Query query : childQueries) { + resultSet.addAll(query.matches(indexView)); + } + return resultSet; + } + + @Override + public String toString() { + return "(" + childQueries.stream().map(Query::toString) + .collect(Collectors.joining(" OR ")) + ")"; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/Query.java b/api/src/main/java/run/halo/app/extension/index/query/Query.java new file mode 100644 index 0000000..1ad4716 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/Query.java @@ -0,0 +1,22 @@ +package run.halo.app.extension.index.query; + +import java.util.NavigableSet; +import run.halo.app.extension.Metadata; + +/** + * A {@link Query} is used to match {@link QueryIndexView} objects. + * + * @author guqing + * @since 2.12.0 + */ +public interface Query { + + /** + * Matches the given {@link QueryIndexView} and returns the matched object names see + * {@link Metadata#getName()}. + * + * @param indexView the {@link QueryIndexView} to match. + * @return the matched object names ordered by natural order. + */ + NavigableSet matches(QueryIndexView indexView); +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/QueryFactory.java b/api/src/main/java/run/halo/app/extension/index/query/QueryFactory.java new file mode 100644 index 0000000..741d96c --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/QueryFactory.java @@ -0,0 +1,233 @@ +package run.halo.app.extension.index.query; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.experimental.UtilityClass; +import org.springframework.util.Assert; + +@UtilityClass +public class QueryFactory { + + public static Query all() { + return new All("metadata.name"); + } + + public static Query all(String fieldName) { + return new All(fieldName); + } + + public static Query isNull(String fieldName) { + return new IsNull(fieldName); + } + + public static Query isNotNull(String fieldName) { + return new IsNotNull(fieldName); + } + + /** + * Create a {@link NotEqual} for the given {@code fieldName} and {@code attributeValue}. + */ + public static Query notEqual(String fieldName, String attributeValue) { + if (attributeValue == null) { + return new IsNotNull(fieldName); + } + return new NotEqual(fieldName, attributeValue); + } + + public static Query notEqualOtherField(String fieldName, String otherFieldName) { + return new NotEqual(fieldName, otherFieldName, true); + } + + /** + * Create a {@link EqualQuery} for the given {@code fieldName} and {@code attributeValue}. + */ + public static Query equal(String fieldName, String attributeValue) { + if (attributeValue == null) { + return new IsNull(fieldName); + } + return new EqualQuery(fieldName, attributeValue); + } + + public static Query equalOtherField(String fieldName, String otherFieldName) { + return new EqualQuery(fieldName, otherFieldName, true); + } + + public static Query lessThanOtherField(String fieldName, String otherFieldName) { + return new LessThanQuery(fieldName, otherFieldName, false, true); + } + + public static Query lessThanOrEqualOtherField(String fieldName, String otherFieldName) { + return new LessThanQuery(fieldName, otherFieldName, true, true); + } + + public static Query lessThan(String fieldName, String attributeValue) { + return new LessThanQuery(fieldName, attributeValue, false); + } + + public static Query lessThanOrEqual(String fieldName, String attributeValue) { + return new LessThanQuery(fieldName, attributeValue, true); + } + + public static Query greaterThan(String fieldName, String attributeValue) { + return new GreaterThanQuery(fieldName, attributeValue, false); + } + + public static Query greaterThanOrEqual(String fieldName, String attributeValue) { + return new GreaterThanQuery(fieldName, attributeValue, true); + } + + public static Query greaterThanOtherField(String fieldName, String otherFieldName) { + return new GreaterThanQuery(fieldName, otherFieldName, false, true); + } + + public static Query greaterThanOrEqualOtherField(String fieldName, + String otherFieldName) { + return new GreaterThanQuery(fieldName, otherFieldName, true, true); + } + + public static Query in(String fieldName, String... attributeValues) { + return in(fieldName, Set.of(attributeValues)); + } + + /** + * Create an {@link InQuery} for the given {@code fieldName} and {@code values}. + */ + public static Query in(String fieldName, Collection values) { + Assert.notNull(values, "Values must not be null"); + if (values.size() == 1) { + String singleValue = values.iterator().next(); + return equal(fieldName, singleValue); + } + // Copy the values into a Set if necessary... + var valueSet = (values instanceof Set ? (Set) values + : new HashSet<>(values)); + return new InQuery(fieldName, valueSet); + } + + /** + * Create an {@link And} for the given {@link Query}s. + */ + public static Query and(Collection queries) { + Assert.notEmpty(queries, "Queries must not be empty"); + if (queries.size() == 1) { + return queries.iterator().next(); + } + return new And(queries); + } + + public static And and(Query query1, Query query2) { + Collection queries = Arrays.asList(query1, query2); + return new And(queries); + } + + /** + * Create an {@link And} for the given {@link Query}s. + */ + public static Query and(Query query1, Query query2, Query... additionalQueries) { + var queries = new ArrayList(2 + additionalQueries.length); + queries.add(query1); + queries.add(query2); + Collections.addAll(queries, additionalQueries); + return new And(queries); + } + + /** + * Create an {@link And} for the given {@link Query}s. + */ + public static Query and(Query query1, Query query2, Collection additionalQueries) { + var queries = new ArrayList(2 + additionalQueries.size()); + queries.add(query1); + queries.add(query2); + queries.addAll(additionalQueries); + return new And(queries); + } + + public static Query or(Query query1, Query query2) { + Collection queries = Arrays.asList(query1, query2); + return new Or(queries); + } + + /** + * Create an {@link Or} for the given {@link Query}s. + */ + 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 new Or(queries); + } + + /** + * Create an {@link Or} for the given {@link Query}s. + */ + 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 new Or(queries); + } + + public static Query not(Query query) { + return new Not(query); + } + + public static Query betweenLowerExclusive(String fieldName, String lowerValue, + String upperValue) { + return new Between(fieldName, lowerValue, false, upperValue, true); + } + + public static Query betweenUpperExclusive(String fieldName, String lowerValue, + String upperValue) { + return new Between(fieldName, lowerValue, true, upperValue, false); + } + + public static Query betweenExclusive(String fieldName, String lowerValue, + String upperValue) { + return new Between(fieldName, lowerValue, false, upperValue, false); + } + + public static Query between(String fieldName, String lowerValue, String upperValue) { + return new Between(fieldName, lowerValue, true, upperValue, true); + } + + public static Query startsWith(String fieldName, String value) { + return new StringStartsWith(fieldName, value); + } + + public static Query endsWith(String fieldName, String value) { + return new StringEndsWith(fieldName, value); + } + + public static Query contains(String fieldName, String value) { + return new StringContains(fieldName, value); + } + + /** + * Get all the field names used in the given query. + * + * @param query the query + * @return the field names used in the given query + */ + public static List getFieldNamesUsedInQuery(Query query) { + List fieldNames = new ArrayList<>(); + + if (query instanceof SimpleQuery simpleQuery) { + if (simpleQuery.isFieldRef()) { + fieldNames.add(simpleQuery.getValue()); + } + fieldNames.add(simpleQuery.getFieldName()); + } else if (query instanceof LogicalQuery logicalQuery) { + for (Query childQuery : logicalQuery.getChildQueries()) { + fieldNames.addAll(getFieldNamesUsedInQuery(childQuery)); + } + } + return fieldNames; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/QueryIndexView.java b/api/src/main/java/run/halo/app/extension/index/query/QueryIndexView.java new file mode 100644 index 0000000..eb6a915 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/QueryIndexView.java @@ -0,0 +1,143 @@ +package run.halo.app.extension.index.query; + +import java.util.List; +import java.util.NavigableSet; +import org.springframework.data.domain.Sort; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.index.IndexEntry; +import run.halo.app.extension.index.IndexSpec; + +/** + *

A view of an index entries that can be queried.

+ *

Explanation of naming:

+ *
    + *
  • fieldName: a field of an index, usually {@link IndexSpec#getName()}
  • + *
  • fieldValue: a value of a field, e.g. a value of a field "name" could be "foo"
  • + *
  • id: the id of an object pointing to object position, see {@link Metadata#getName()}
  • + *
+ * + * @author guqing + * @since 2.12.0 + */ +public interface QueryIndexView { + + /** + * Gets all object ids for a given field name and field value. + * + * @param fieldName the field name + * @param fieldValue the field value + * @return all indexed object ids associated with the given field name and field value + * @throws IllegalArgumentException if the field name is not indexed + */ + NavigableSet findIds(String fieldName, String fieldValue); + + /** + * Gets all object ids for a given field name without null cells. + * + * @param fieldName the field name + * @return all indexed object ids for the given field name + * @throws IllegalArgumentException if the field name is not indexed + */ + NavigableSet getIdsForField(String fieldName); + + /** + * Gets all object ids in this view. + * + * @return all object ids in this view + */ + NavigableSet getAllIds(); + + /** + *

Finds and returns a set of unique identifiers (metadata.name) for entries that have + * matching values across two fields and where the values are equal.

+ * For example: + *
+     * metadata.name | field1 | field2
+     * ------------- | ------ | ------
+     * foo           | 1      | 1
+     * bar           | 2      | 3
+     * baz           | 3      | 3
+     * 
+ * findMatchingIdsWithEqualValues("field1", "field2") would return ["foo","baz"] + * + * @see #findMatchingIdsWithGreaterValues(String, String, boolean) + * @see #findMatchingIdsWithSmallerValues(String, String, boolean) + */ + NavigableSet findMatchingIdsWithEqualValues(String fieldName1, String fieldName2); + + /** + *

Finds and returns a set of unique identifiers (metadata.name) for entries that have + * matching values across two fields, but where the value associated with fieldName1 is + * greater than the value associated with fieldName2.

+ * For example: + *
+     *     metadata.name | field1 | field2
+     *     ------------- | ------ | ------
+     *     foo           | 1      | 1
+     *     bar           | 2      | 3
+     *     baz           | 3      | 3
+     *     qux           | 4      | 2
+     * 
+ *

findMatchingIdsWithGreaterValues("field1", "field2")would return ["qux"]

+ *

findMatchingIdsWithGreaterValues("field2", "field1")would return ["bar"]

+ *

findMatchingIdsWithGreaterValues("field1", "field2", true)would return + * ["foo","baz","qux"]

+ * + * @param fieldName1 The field name whose values are compared as the larger values. + * @param fieldName2 The field name whose values are compared as the smaller values. + * @param orEqual whether to include equal values + * @return A result set of ids where the entries in fieldName1 have greater values than those + * in fieldName2 for entries that have the same id across both fields + */ + NavigableSet findMatchingIdsWithGreaterValues(String fieldName1, String fieldName2, + boolean orEqual); + + NavigableSet findIdsGreaterThan(String fieldName, String fieldValue, boolean orEqual); + + /** + *

Finds and returns a set of unique identifiers (metadata.name) for entries that have + * matching values across two fields, but where the value associated with fieldName1 is + * less than the value associated with fieldName2.

+ * For example: + *
+     *     metadata.name | field1 | field2
+     *     ------------- | ------ | ------
+     *     foo           | 1      | 1
+     *     bar           | 2      | 3
+     *     baz           | 3      | 3
+     *     qux           | 4      | 2
+     * 
+ *

findMatchingIdsWithSmallerValues("field1", "field2") would return ["bar"]

+ *

findMatchingIdsWithSmallerValues("field2", "field1") would return ["qux"]

+ *

findMatchingIdsWithSmallerValues("field1", "field2", true) would return + * ["foo","bar","baz"]

+ * + * @param fieldName1 The field name whose values are compared as the smaller values. + * @param fieldName2 The field name whose values are compared as the larger values. + * @param orEqual whether to include equal values + * @return A result set of ids where the entries in fieldName1 have smaller values than those + * in fieldName2 for entries that have the same id across both fields + */ + NavigableSet findMatchingIdsWithSmallerValues(String fieldName1, String fieldName2, + boolean orEqual); + + NavigableSet findIdsLessThan(String fieldName, String fieldValue, boolean orEqual); + + NavigableSet between(String fieldName, String lowerValue, boolean lowerInclusive, + String upperValue, boolean upperInclusive); + + List sortBy(NavigableSet resultSet, Sort sort); + + IndexEntry getIndexEntry(String fieldName); + + /** + * Acquire a read lock on the indexer. + * if you need to operate on more than one {@code IndexEntry} at the same time, you need to + * lock first. + * + * @see #getIndexEntry(String) + */ + void acquireReadLock(); + + void releaseReadLock(); +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/QueryIndexViewImpl.java b/api/src/main/java/run/halo/app/extension/index/query/QueryIndexViewImpl.java new file mode 100644 index 0000000..e5456a9 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/QueryIndexViewImpl.java @@ -0,0 +1,230 @@ +package run.halo.app.extension.index.query; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NavigableSet; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.BiPredicate; +import org.springframework.data.domain.Sort; +import run.halo.app.extension.index.IndexEntry; +import run.halo.app.extension.index.IndexEntryOperator; +import run.halo.app.extension.index.IndexEntryOperatorImpl; +import run.halo.app.extension.index.Indexer; +import run.halo.app.extension.index.KeyComparator; + +/** + * A default implementation for {@link run.halo.app.extension.index.query.QueryIndexView}. + * + * @author guqing + * @since 2.17.0 + */ +public class QueryIndexViewImpl implements QueryIndexView { + + public static final String PRIMARY_INDEX_NAME = "metadata.name"; + + private final Indexer indexer; + + /** + * Construct a new {@link QueryIndexViewImpl} with the given {@link Indexer}. + * + * @throws IllegalArgumentException if the primary index does not exist + */ + public QueryIndexViewImpl(Indexer indexer) { + // check if primary index exists + indexer.getIndexEntry(PRIMARY_INDEX_NAME); + this.indexer = indexer; + } + + @Override + public NavigableSet findIds(String fieldName, String fieldValue) { + var operator = getEntryOperator(fieldName); + return operator.find(fieldValue); + } + + @Override + public NavigableSet getIdsForField(String fieldName) { + var operator = getEntryOperator(fieldName); + return new TreeSet<>(operator.getValues()); + } + + @Override + public NavigableSet getAllIds() { + return new TreeSet<>(allIds()); + } + + @Override + public NavigableSet findMatchingIdsWithEqualValues(String fieldName1, + String fieldName2) { + indexer.acquireReadLock(); + try { + return findIdsWithKeyComparator(fieldName1, fieldName2, (k1, k2) -> { + var compare = KeyComparator.INSTANCE.compare(k1, k2); + return compare == 0; + }); + } finally { + indexer.releaseReadLock(); + } + } + + @Override + public NavigableSet findMatchingIdsWithGreaterValues(String fieldName1, + String fieldName2, boolean orEqual) { + indexer.acquireReadLock(); + try { + return findIdsWithKeyComparator(fieldName1, fieldName2, (k1, k2) -> { + var compare = KeyComparator.INSTANCE.compare(k1, k2); + return orEqual ? compare <= 0 : compare < 0; + }); + } finally { + indexer.releaseReadLock(); + } + } + + @Override + public NavigableSet findIdsGreaterThan(String fieldName, String fieldValue, + boolean orEqual) { + var operator = getEntryOperator(fieldName); + return operator.greaterThan(fieldValue, orEqual); + } + + @Override + public NavigableSet findMatchingIdsWithSmallerValues(String fieldName1, + String fieldName2, boolean orEqual) { + indexer.acquireReadLock(); + try { + return findIdsWithKeyComparator(fieldName1, fieldName2, (k1, k2) -> { + var compare = KeyComparator.INSTANCE.compare(k1, k2); + return orEqual ? compare >= 0 : compare > 0; + }); + } finally { + indexer.releaseReadLock(); + } + } + + @Override + public NavigableSet findIdsLessThan(String fieldName, String fieldValue, + boolean orEqual) { + var operator = getEntryOperator(fieldName); + return operator.lessThan(fieldValue, orEqual); + } + + @Override + public NavigableSet between(String fieldName, String lowerValue, boolean lowerInclusive, + String upperValue, boolean upperInclusive) { + var operator = getEntryOperator(fieldName); + return operator.range(lowerValue, upperValue, lowerInclusive, upperInclusive); + } + + @Override + public List sortBy(NavigableSet ids, Sort sort) { + if (sort.isUnsorted()) { + return new ArrayList<>(ids); + } + indexer.acquireReadLock(); + try { + var combinedComparator = sort.stream() + .map(this::comparatorFrom) + .reduce(Comparator::thenComparing) + .orElseThrow(); + return ids.stream() + .sorted(combinedComparator) + .toList(); + } finally { + indexer.releaseReadLock(); + } + } + + Comparator comparatorFrom(Sort.Order order) { + var indexEntry = getIndexEntry(order.getProperty()); + var idPositionMap = indexEntry.getIdPositionMap(); + var isDesc = order.isDescending(); + // This sort algorithm works leveraging on that the idPositionMap is a map of id -> position + // if the id is not in the map, it means that it is not indexed, and it will be placed at + // the end + return (a, b) -> { + var indexOfA = idPositionMap.get(a); + var indexOfB = idPositionMap.get(b); + + var isAIndexed = indexOfA != null; + var isBIndexed = indexOfB != null; + + if (!isAIndexed && !isBIndexed) { + return 0; + } + // un-indexed item are always at the end + if (!isAIndexed) { + return isDesc ? -1 : 1; + } + if (!isBIndexed) { + return isDesc ? 1 : -1; + } + return isDesc ? Integer.compare(indexOfB, indexOfA) + : Integer.compare(indexOfA, indexOfB); + }; + } + + @Override + public IndexEntry getIndexEntry(String fieldName) { + return indexer.getIndexEntry(fieldName); + } + + @Override + public void acquireReadLock() { + indexer.acquireReadLock(); + } + + @Override + public void releaseReadLock() { + indexer.releaseReadLock(); + } + + private IndexEntryOperator getEntryOperator(String fieldName) { + var indexEntry = getIndexEntry(fieldName); + return createIndexEntryOperator(indexEntry); + } + + private IndexEntryOperator createIndexEntryOperator(IndexEntry entry) { + return new IndexEntryOperatorImpl(entry); + } + + private Set allIds() { + var indexEntry = getIndexEntry(PRIMARY_INDEX_NAME); + return createIndexEntryOperator(indexEntry).getValues(); + } + + /** + * Must lock the indexer before calling this method. + */ + private NavigableSet findIdsWithKeyComparator(String fieldName1, String fieldName2, + BiPredicate keyComparator) { + // get entries from indexer for fieldName1 + var entriesA = getIndexEntry(fieldName1).entries(); + + Map> keyMap = new HashMap<>(); + for (Map.Entry entry : entriesA) { + keyMap.computeIfAbsent(entry.getValue(), v -> new ArrayList<>()).add(entry.getKey()); + } + + NavigableSet result = new TreeSet<>(); + + // get entries from indexer for fieldName2 + var entriesB = getIndexEntry(fieldName2).entries(); + for (Map.Entry entry : entriesB) { + List matchedKeys = keyMap.get(entry.getValue()); + if (matchedKeys != null) { + for (String key : matchedKeys) { + if (keyComparator.test(entry.getKey(), key)) { + result.add(entry.getValue()); + // found one match, no need to continue + break; + } + } + } + } + return result; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/SimpleQuery.java b/api/src/main/java/run/halo/app/extension/index/query/SimpleQuery.java new file mode 100644 index 0000000..9310661 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/SimpleQuery.java @@ -0,0 +1,35 @@ +package run.halo.app.extension.index.query; + +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; +import org.springframework.util.Assert; + +@Getter +public abstract class SimpleQuery implements Query { + protected final String fieldName; + protected final String value; + /** + *

Whether the value if a field reference.

+ * For example, {@code fieldName = "salary", value = "cost"} can lead to a query: + *
+     *     salary > cost
+     * 
+ * means that we want to find all the records whose salary is greater than cost. + * + * @see EqualQuery + * @see GreaterThanQuery + * @see LessThanQuery + */ + protected final boolean isFieldRef; + + protected SimpleQuery(String fieldName, String value) { + this(fieldName, value, false); + } + + protected SimpleQuery(String fieldName, String value, boolean isFieldRef) { + Assert.isTrue(StringUtils.isNotBlank(fieldName), "fieldName cannot be blank."); + this.fieldName = fieldName; + this.value = value; + this.isFieldRef = isFieldRef; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/StringContains.java b/api/src/main/java/run/halo/app/extension/index/query/StringContains.java new file mode 100644 index 0000000..f6e7dc5 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/StringContains.java @@ -0,0 +1,36 @@ +package run.halo.app.extension.index.query; + +import com.google.common.collect.Sets; +import java.util.Map; +import java.util.NavigableSet; +import org.apache.commons.lang3.StringUtils; + +public class StringContains extends SimpleQuery { + public StringContains(String fieldName, String value) { + super(fieldName, value); + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + var resultSet = Sets.newTreeSet(); + var indexEntry = indexView.getIndexEntry(fieldName); + + indexEntry.acquireReadLock(); + try { + for (Map.Entry entry : indexEntry.entries()) { + var fieldValue = entry.getKey(); + if (StringUtils.containsIgnoreCase(fieldValue, value)) { + resultSet.add(entry.getValue()); + } + } + return resultSet; + } finally { + indexEntry.releaseReadLock(); + } + } + + @Override + public String toString() { + return "contains(" + fieldName + ", '" + value + "')"; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/StringEndsWith.java b/api/src/main/java/run/halo/app/extension/index/query/StringEndsWith.java new file mode 100644 index 0000000..51853be --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/StringEndsWith.java @@ -0,0 +1,36 @@ +package run.halo.app.extension.index.query; + +import com.google.common.collect.Sets; +import java.util.Map; +import java.util.NavigableSet; +import org.apache.commons.lang3.StringUtils; + +public class StringEndsWith extends SimpleQuery { + public StringEndsWith(String fieldName, String value) { + super(fieldName, value); + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + var resultSet = Sets.newTreeSet(); + var indexEntry = indexView.getIndexEntry(fieldName); + + indexEntry.acquireReadLock(); + try { + for (Map.Entry entry : indexEntry.entries()) { + var fieldValue = entry.getKey(); + if (StringUtils.endsWith(fieldValue, value)) { + resultSet.add(entry.getValue()); + } + } + return resultSet; + } finally { + indexEntry.releaseReadLock(); + } + } + + @Override + public String toString() { + return "endsWith(" + fieldName + ", '" + value + "')"; + } +} diff --git a/api/src/main/java/run/halo/app/extension/index/query/StringStartsWith.java b/api/src/main/java/run/halo/app/extension/index/query/StringStartsWith.java new file mode 100644 index 0000000..bad07d8 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/index/query/StringStartsWith.java @@ -0,0 +1,36 @@ +package run.halo.app.extension.index.query; + +import com.google.common.collect.Sets; +import java.util.Map; +import java.util.NavigableSet; +import org.apache.commons.lang3.StringUtils; + +public class StringStartsWith extends SimpleQuery { + public StringStartsWith(String fieldName, String value) { + super(fieldName, value); + } + + @Override + public NavigableSet matches(QueryIndexView indexView) { + var resultSet = Sets.newTreeSet(); + var indexEntry = indexView.getIndexEntry(fieldName); + + indexEntry.acquireReadLock(); + try { + for (Map.Entry entry : indexEntry.entries()) { + var fieldValue = entry.getKey(); + if (StringUtils.startsWith(fieldValue, value)) { + resultSet.add(entry.getValue()); + } + } + return resultSet; + } finally { + indexEntry.releaseReadLock(); + } + } + + @Override + public String toString() { + return "startsWith(" + fieldName + ", '" + value + "')"; + } +} diff --git a/api/src/main/java/run/halo/app/extension/router/IListRequest.java b/api/src/main/java/run/halo/app/extension/router/IListRequest.java new file mode 100644 index 0000000..ce7f938 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/IListRequest.java @@ -0,0 +1,96 @@ +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; + +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 0; + } + + @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) + ); + } +} diff --git a/api/src/main/java/run/halo/app/extension/router/QueryParamBuildUtil.java b/api/src/main/java/run/halo/app/extension/router/QueryParamBuildUtil.java new file mode 100644 index 0000000..b17f21b --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/QueryParamBuildUtil.java @@ -0,0 +1,36 @@ +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 java.lang.reflect.Type; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.fn.builders.operation.Builder; + +@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"))); + } + + @Deprecated(since = "2.15.0") + public static void buildParametersFromType(Builder operationBuilder, Type queryParamType) { + log.warn( + "Deprecated method QueryParamBuildUtil.buildParametersFromType is called, please use " + + "'org.springdoc.core.fn.builders.operation.Builder#parameter' method instead." + + "This method will be removed in Halo 2.20.0 version."); + } +} diff --git a/api/src/main/java/run/halo/app/extension/router/SortableRequest.java b/api/src/main/java/run/halo/app/extension/router/SortableRequest.java new file mode 100644 index 0000000..6776900 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/SortableRequest.java @@ -0,0 +1,109 @@ +package run.halo.app.extension.router; + +import static org.springframework.data.domain.Sort.Order.asc; +import static org.springframework.data.domain.Sort.Order.desc; +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.router.selector.SelectorUtil.labelAndFieldSelectorToListOptions; +import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate; + +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.function.Predicate; +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(Sort.by(desc("metadata.creationTimestamp"), + asc("metadata.name")) + ); + } + + /** + * Build predicate from query params, default is label and field selector, you can + * override this method to change it. + * + * @return predicate + */ + public Predicate toPredicate() { + return labelAndFieldSelectorToPredicate(getLabelSelector(), getFieldSelector()); + } + + /** + * 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()); + } +} diff --git a/api/src/main/java/run/halo/app/extension/router/selector/EqualityMatcher.java b/api/src/main/java/run/halo/app/extension/router/selector/EqualityMatcher.java new file mode 100644 index 0000000..a534891 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/selector/EqualityMatcher.java @@ -0,0 +1,73 @@ +package run.halo.app.extension.router.selector; + +import java.util.function.Function; +import java.util.function.Predicate; + +public class EqualityMatcher implements SelectorMatcher { + private final Operator operator; + private final String key; + private final String value; + + EqualityMatcher(String key, Operator operator, String value) { + this.key = key; + this.operator = operator; + this.value = value; + } + + /** + * The "equal" matcher. Matches a label if the label is present and equal. + * + * @param key the matching label key + * @param value the matching label value + * @return the equality matcher + */ + public static EqualityMatcher equal(String key, String value) { + return new EqualityMatcher(key, Operator.EQUAL, value); + } + + /** + * The "not equal" matcher. Matches a label if the label is not present or not equal. + * + * @param key the matching label key + * @param value the matching label value + * @return the equality matcher + */ + public static EqualityMatcher notEqual(String key, String value) { + return new EqualityMatcher(key, Operator.NOT_EQUAL, value); + } + + @Override + public String toString() { + return key + + " " + + operator.name().toLowerCase() + + " " + + value; + } + + @Override + public boolean test(String s) { + return operator.with(value).test(s); + } + + @Override + public String getKey() { + return key; + } + + protected enum Operator { + EQUAL(arg -> arg::equals), + DOUBLE_EQUAL(arg -> arg::equals), + NOT_EQUAL(arg -> v -> !arg.equals(v)); + + private final Function> matcherFunc; + + Operator(Function> matcherFunc) { + this.matcherFunc = matcherFunc; + } + + Predicate with(String value) { + return matcherFunc.apply(value); + } + } +} diff --git a/api/src/main/java/run/halo/app/extension/router/selector/FieldCriteriaPredicateConverter.java b/api/src/main/java/run/halo/app/extension/router/selector/FieldCriteriaPredicateConverter.java new file mode 100644 index 0000000..f3294ea --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/selector/FieldCriteriaPredicateConverter.java @@ -0,0 +1,37 @@ +package run.halo.app.extension.router.selector; + +import java.util.function.Predicate; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.NonNull; +import run.halo.app.extension.Extension; + +@Deprecated(since = "2.12.0") +public class FieldCriteriaPredicateConverter + implements Converter> { + + @Override + @NonNull + public Predicate convert(SelectorCriteria criteria) { + // current we only support name field. + return ext -> { + if ("name".equals(criteria.key())) { + var name = ext.getMetadata().getName(); + if (name == null) { + return false; + } + switch (criteria.operator()) { + case Equals, IN -> { + return criteria.values().contains(name); + } + case NotEquals -> { + return !criteria.values().contains(name); + } + default -> { + return false; + } + } + } + return false; + }; + } +} diff --git a/api/src/main/java/run/halo/app/extension/router/selector/FieldSelector.java b/api/src/main/java/run/halo/app/extension/router/selector/FieldSelector.java new file mode 100644 index 0000000..44d261f --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/selector/FieldSelector.java @@ -0,0 +1,26 @@ +package run.halo.app.extension.router.selector; + +import java.util.Objects; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; +import run.halo.app.extension.index.query.Query; +import run.halo.app.extension.index.query.QueryFactory; + +public record FieldSelector(@NonNull Query query) { + public FieldSelector(Query query) { + this.query = Objects.requireNonNullElseGet(query, QueryFactory::all); + } + + public static FieldSelector of(Query query) { + return new FieldSelector(query); + } + + public static FieldSelector all() { + return new FieldSelector(QueryFactory.all()); + } + + public FieldSelector andQuery(Query other) { + Assert.notNull(other, "Query must not be null"); + return of(QueryFactory.and(query(), other)); + } +} diff --git a/api/src/main/java/run/halo/app/extension/router/selector/FieldSelectorConverter.java b/api/src/main/java/run/halo/app/extension/router/selector/FieldSelectorConverter.java new file mode 100644 index 0000000..8d630b8 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/selector/FieldSelectorConverter.java @@ -0,0 +1,45 @@ +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.Query; +import run.halo.app.extension.index.query.QueryFactory; + +public class FieldSelectorConverter implements Converter { + + @NonNull + @Override + public Query 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 QueryFactory.equal(key, getSingleValue(criteria)); + } + case NotEquals -> { + return QueryFactory.notEqual(key, getSingleValue(criteria)); + } + // compatible with old field selector + case IN -> { + var valueArr = defaultIfNull(criteria.values(), Set.of()); + return QueryFactory.in(key, valueArr); + } + default -> throw new IllegalArgumentException( + "Unsupported operator: " + criteria.operator()); + } + } + + String getSingleValue(SelectorCriteria criteria) { + if (CollectionUtils.isEmpty(criteria.values())) { + return null; + } + return criteria.values().iterator().next(); + } +} diff --git a/api/src/main/java/run/halo/app/extension/router/selector/LabelCriteriaPredicateConverter.java b/api/src/main/java/run/halo/app/extension/router/selector/LabelCriteriaPredicateConverter.java new file mode 100644 index 0000000..5832e7c --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/selector/LabelCriteriaPredicateConverter.java @@ -0,0 +1,42 @@ +package run.halo.app.extension.router.selector; + +import java.util.function.Predicate; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.NonNull; +import run.halo.app.extension.Extension; + +@Deprecated(since = "2.12.0") +public class LabelCriteriaPredicateConverter + implements Converter> { + + @Override + @NonNull + public Predicate convert(SelectorCriteria criteria) { + return ext -> { + var labels = ext.getMetadata().getLabels(); + switch (criteria.operator()) { + case Equals -> { + if (labels == null || !labels.containsKey(criteria.key())) { + return false; + } + return criteria.values().contains(labels.get(criteria.key())); + } + case NotEquals -> { + if (labels == null || !labels.containsKey(criteria.key())) { + return false; + } + return !criteria.values().contains(labels.get(criteria.key())); + } + case NotExist -> { + return labels == null || !labels.containsKey(criteria.key()); + } + case Exist -> { + return labels != null && labels.containsKey(criteria.key()); + } + default -> { + return false; + } + } + }; + } +} diff --git a/api/src/main/java/run/halo/app/extension/router/selector/LabelSelector.java b/api/src/main/java/run/halo/app/extension/router/selector/LabelSelector.java new file mode 100644 index 0000000..1e33e2a --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/selector/LabelSelector.java @@ -0,0 +1,117 @@ +package run.halo.app.extension.router.selector; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import lombok.Data; +import lombok.experimental.Accessors; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; + +@Data +@Accessors(chain = true) +public class LabelSelector implements Predicate> { + private List matchers; + + @Override + public boolean test(@NonNull Map labels) { + Assert.notNull(labels, "Labels must not be null"); + if (matchers == null || matchers.isEmpty()) { + return true; + } + return matchers.stream() + .allMatch(matcher -> matcher.test(labels.get(matcher.getKey()))); + } + + @Override + public String toString() { + if (matchers == null || matchers.isEmpty()) { + return ""; + } + return matchers.stream() + .map(SelectorMatcher::toString) + .collect(Collectors.joining(", ")); + } + + /** + * 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 matchers = new ArrayList(); + matchers.addAll(this.matchers); + matchers.addAll(other.matchers); + labelSelector.setMatchers(matchers); + return labelSelector; + } + + public static LabelSelectorBuilder builder() { + return new LabelSelectorBuilder<>(); + } + + public static class LabelSelectorBuilder> { + private final List matchers = new ArrayList<>(); + + public LabelSelectorBuilder() { + } + + /** + * Create a new label selector builder with the given matchers. + */ + public LabelSelectorBuilder(List givenMatchers) { + if (givenMatchers != null) { + matchers.addAll(givenMatchers); + } + } + + @SuppressWarnings("unchecked") + private T self() { + return (T) this; + } + + public T eq(String key, String value) { + matchers.add(EqualityMatcher.equal(key, value)); + return self(); + } + + public T notEq(String key, String value) { + matchers.add(EqualityMatcher.notEqual(key, value)); + return self(); + } + + public T in(String key, String... values) { + matchers.add(SetMatcher.in(key, values)); + return self(); + } + + public T notIn(String key, String... values) { + matchers.add(SetMatcher.notIn(key, values)); + return self(); + } + + public T exists(String key) { + matchers.add(SetMatcher.exists(key)); + return self(); + } + + public T notExists(String key) { + matchers.add(SetMatcher.notExists(key)); + return self(); + } + + /** + * Build the label selector. + */ + public LabelSelector build() { + var labelSelector = new LabelSelector(); + labelSelector.setMatchers(matchers); + return labelSelector; + } + } +} diff --git a/api/src/main/java/run/halo/app/extension/router/selector/LabelSelectorConverter.java b/api/src/main/java/run/halo/app/extension/router/selector/LabelSelectorConverter.java new file mode 100644 index 0000000..d3e21dd --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/selector/LabelSelectorConverter.java @@ -0,0 +1,44 @@ +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; + +public class LabelSelectorConverter implements Converter { + + @NonNull + @Override + public SelectorMatcher convert(@NonNull SelectorCriteria criteria) { + switch (criteria.operator()) { + case Equals -> { + return EqualityMatcher.equal(criteria.key(), getSingleValue(criteria)); + } + case NotEquals -> { + return EqualityMatcher.notEqual(criteria.key(), getSingleValue(criteria)); + } + case NotExist -> { + return SetMatcher.notExists(criteria.key()); + } + case Exist -> { + return SetMatcher.exists(criteria.key()); + } + case IN -> { + var valueArr = + defaultIfNull(criteria.values(), Set.of()).toArray(new String[0]); + return SetMatcher.in(criteria.key(), valueArr); + } + default -> throw new IllegalArgumentException( + "Unsupported operator: " + criteria.operator()); + } + } + + String getSingleValue(SelectorCriteria criteria) { + if (CollectionUtils.isEmpty(criteria.values())) { + return null; + } + return criteria.values().iterator().next(); + } +} diff --git a/api/src/main/java/run/halo/app/extension/router/selector/Operator.java b/api/src/main/java/run/halo/app/extension/router/selector/Operator.java new file mode 100644 index 0000000..cab3dc1 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/selector/Operator.java @@ -0,0 +1,102 @@ +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() - 1) { + 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; + } + +} diff --git a/api/src/main/java/run/halo/app/extension/router/selector/SelectorConverter.java b/api/src/main/java/run/halo/app/extension/router/selector/SelectorConverter.java new file mode 100644 index 0000000..b3dddc0 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/selector/SelectorConverter.java @@ -0,0 +1,27 @@ +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); + } + +} diff --git a/api/src/main/java/run/halo/app/extension/router/selector/SelectorCriteria.java b/api/src/main/java/run/halo/app/extension/router/selector/SelectorCriteria.java new file mode 100644 index 0000000..13693e5 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/selector/SelectorCriteria.java @@ -0,0 +1,7 @@ +package run.halo.app.extension.router.selector; + +import java.util.Set; + +public record SelectorCriteria(String key, Operator operator, Set values) { + +} diff --git a/api/src/main/java/run/halo/app/extension/router/selector/SelectorMatcher.java b/api/src/main/java/run/halo/app/extension/router/selector/SelectorMatcher.java new file mode 100644 index 0000000..3e4110f --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/selector/SelectorMatcher.java @@ -0,0 +1,14 @@ +package run.halo.app.extension.router.selector; + +public interface SelectorMatcher { + + String getKey(); + + /** + * Returns true if a field value matches. + * + * @param s the field value + * @return the boolean + */ + boolean test(String s); +} diff --git a/api/src/main/java/run/halo/app/extension/router/selector/SelectorUtil.java b/api/src/main/java/run/halo/app/extension/router/selector/SelectorUtil.java new file mode 100644 index 0000000..d506670 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/selector/SelectorUtil.java @@ -0,0 +1,104 @@ +package run.halo.app.extension.router.selector; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Predicate; +import org.springframework.data.util.Predicates; +import org.springframework.web.server.ServerWebInputException; +import run.halo.app.extension.Extension; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.index.query.QueryFactory; + +public final class SelectorUtil { + + private SelectorUtil() { + } + + public static Predicate labelSelectorsToPredicate( + List labelSelectors) { + if (labelSelectors == null) { + labelSelectors = List.of(); + } + + final var labelPredicateConverter = + new SelectorConverter().andThen(new LabelCriteriaPredicateConverter()); + + return labelSelectors.stream() + .map(selector -> { + var predicate = labelPredicateConverter.convert(selector); + if (predicate == null) { + throw new ServerWebInputException("Invalid label selector: " + selector); + } + return predicate; + }) + .reduce(Predicate::and) + .orElse(Predicates.isTrue()); + } + + public static Predicate fieldSelectorToPredicate( + List fieldSelectors) { + if (fieldSelectors == null) { + fieldSelectors = List.of(); + } + + final var fieldPredicateConverter = + new SelectorConverter().andThen(new FieldCriteriaPredicateConverter()); + + return fieldSelectors.stream() + .map(selector -> { + var predicate = fieldPredicateConverter.convert(selector); + if (predicate == null) { + throw new ServerWebInputException("Invalid field selector: " + selector); + } + return predicate; + }) + .reduce(Predicate::and) + .orElse(Predicates.isTrue()); + } + + public static Predicate labelAndFieldSelectorToPredicate( + List labelSelectors, List fieldSelectors) { + return SelectorUtil.labelSelectorsToPredicate(labelSelectors) + .and(fieldSelectorToPredicate(fieldSelectors)); + } + + /** + * 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 labelMatchers = 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 fieldQuery = Optional.ofNullable(fieldSelectorTerms) + .map(selectors -> selectors.stream() + .map(selectorConverter::convert) + .filter(Objects::nonNull) + .map(fieldConverter::convert) + .toList() + ) + .orElse(List.of()); + var listOptions = new ListOptions(); + listOptions.setLabelSelector(new LabelSelector().setMatchers(labelMatchers)); + if (!fieldQuery.isEmpty()) { + listOptions.setFieldSelector(FieldSelector.of(QueryFactory.and(fieldQuery))); + } else { + listOptions.setFieldSelector(FieldSelector.all()); + } + return listOptions; + } +} diff --git a/api/src/main/java/run/halo/app/extension/router/selector/SetMatcher.java b/api/src/main/java/run/halo/app/extension/router/selector/SetMatcher.java new file mode 100644 index 0000000..20d5ff9 --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/router/selector/SetMatcher.java @@ -0,0 +1,77 @@ +package run.halo.app.extension.router.selector; + +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Predicate; + +public class SetMatcher implements SelectorMatcher { + private final SetMatcher.Operator operator; + private final String key; + private final String[] values; + + SetMatcher(String key, SetMatcher.Operator operator) { + this(key, operator, new String[] {}); + } + + SetMatcher(String key, SetMatcher.Operator operator, String[] values) { + this.key = key; + this.operator = operator; + this.values = values; + } + + public static SetMatcher in(String key, String... values) { + return new SetMatcher(key, Operator.IN, values); + } + + public static SetMatcher notIn(String key, String... values) { + return new SetMatcher(key, Operator.NOT_IN, values); + } + + public static SetMatcher exists(String key) { + return new SetMatcher(key, Operator.EXISTS); + } + + public static SetMatcher notExists(String key) { + return new SetMatcher(key, Operator.NOT_EXISTS); + } + + @Override + public String getKey() { + return key; + } + + @Override + public boolean test(String s) { + return operator.with(values).test(s); + } + + @Override + public String toString() { + if (Operator.EXISTS.equals(operator) || Operator.NOT_EXISTS.equals(operator)) { + return key + " " + operator; + } + return key + " " + operator + " (" + String.join(", ", values) + ")"; + } + + private enum Operator { + IN(values -> v -> contains(values, v)), + NOT_IN(values -> v -> !contains(values, v)), + EXISTS(values -> Objects::nonNull), + NOT_EXISTS(values -> Objects::isNull); + + private final Function> matcherFunc; + + Operator(Function> matcherFunc) { + this.matcherFunc = matcherFunc; + } + + private static boolean contains(String[] strArray, String s) { + return Arrays.asList(strArray).contains(s); + } + + Predicate with(String... values) { + return matcherFunc.apply(values); + } + } +} diff --git a/api/src/main/java/run/halo/app/infra/AnonymousUserConst.java b/api/src/main/java/run/halo/app/infra/AnonymousUserConst.java new file mode 100644 index 0000000..5bdced6 --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/AnonymousUserConst.java @@ -0,0 +1,11 @@ +package run.halo.app.infra; + +public interface AnonymousUserConst { + String PRINCIPAL = "anonymousUser"; + + String Role = "anonymous"; + + static boolean isAnonymousUser(String principal) { + return PRINCIPAL.equals(principal); + } +} diff --git a/api/src/main/java/run/halo/app/infra/BackupRootGetter.java b/api/src/main/java/run/halo/app/infra/BackupRootGetter.java new file mode 100644 index 0000000..ecf3c86 --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/BackupRootGetter.java @@ -0,0 +1,14 @@ +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 { + +} diff --git a/api/src/main/java/run/halo/app/infra/Condition.java b/api/src/main/java/run/halo/app/infra/Condition.java new file mode 100644 index 0000000..43bc34f --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/Condition.java @@ -0,0 +1,65 @@ +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 = ""; +} diff --git a/api/src/main/java/run/halo/app/infra/ConditionList.java b/api/src/main/java/run/halo/app/infra/ConditionList.java new file mode 100644 index 0000000..57fb716 --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/ConditionList.java @@ -0,0 +1,156 @@ +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); + } +} diff --git a/api/src/main/java/run/halo/app/infra/ConditionStatus.java b/api/src/main/java/run/halo/app/infra/ConditionStatus.java new file mode 100644 index 0000000..24e0bae --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/ConditionStatus.java @@ -0,0 +1,11 @@ +package run.halo.app.infra; + +/** + * @author guqing + * @since 2.0.0 + */ +public enum ConditionStatus { + TRUE, + FALSE, + UNKNOWN +} diff --git a/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java b/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java new file mode 100644 index 0000000..ea653c7 --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java @@ -0,0 +1,20 @@ +package run.halo.app.infra; + +/** + * {@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); +} diff --git a/api/src/main/java/run/halo/app/infra/ExternalUrlSupplier.java b/api/src/main/java/run/halo/app/infra/ExternalUrlSupplier.java new file mode 100644 index 0000000..0e8e91d --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/ExternalUrlSupplier.java @@ -0,0 +1,41 @@ +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(); +} diff --git a/api/src/main/java/run/halo/app/infra/FileCategoryMatcher.java b/api/src/main/java/run/halo/app/infra/FileCategoryMatcher.java new file mode 100644 index 0000000..705c8f6 --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/FileCategoryMatcher.java @@ -0,0 +1,107 @@ +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); + } +} diff --git a/api/src/main/java/run/halo/app/infra/SystemSetting.java b/api/src/main/java/run/halo/app/infra/SystemSetting.java new file mode 100644 index 0000000..c548486 --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/SystemSetting.java @@ -0,0 +1,140 @@ +package run.halo.app.infra; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Set; +import lombok.Data; +import org.springframework.boot.convert.ApplicationConversionService; +import run.halo.app.extension.ConfigMap; +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"; + + @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 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; + } + + @Data + public static class User { + public static final String GROUP = "user"; + Boolean allowRegistration; + Boolean mustVerifyEmailOnRegistration; + String defaultRole; + String avatarPolicy; + } + + @Data + public static class Post { + public static final String GROUP = "post"; + Integer postPageSize; + Integer archivePageSize; + Integer categoryPageSize; + Integer tagPageSize; + 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 Set enabled; + } + + /** + * 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"; + + } + + public static T get(ConfigMap configMap, String key, Class type) { + var data = configMap.getData(); + 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); + } +} diff --git a/api/src/main/java/run/halo/app/infra/SystemVersionSupplier.java b/api/src/main/java/run/halo/app/infra/SystemVersionSupplier.java new file mode 100644 index 0000000..c00184a --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/SystemVersionSupplier.java @@ -0,0 +1,15 @@ +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 { +} diff --git a/api/src/main/java/run/halo/app/infra/model/License.java b/api/src/main/java/run/halo/app/infra/model/License.java new file mode 100644 index 0000000..2caa949 --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/model/License.java @@ -0,0 +1,12 @@ +package run.halo.app.infra.model; + +import lombok.Data; + +/** + * Common data objects for license. + */ +@Data +public class License { + private String name; + private String url; +} diff --git a/api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java b/api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java new file mode 100644 index 0000000..f932686 --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java @@ -0,0 +1,34 @@ +package run.halo.app.infra.utils; + +import java.io.IOException; +import java.io.InputStream; +import lombok.experimental.UtilityClass; +import org.apache.tika.Tika; +import org.apache.tika.mime.MimeTypeException; +import org.apache.tika.mime.MimeTypes; + +@UtilityClass +public class FileTypeDetectUtils { + + private static final Tika tika = new Tika(); + + /** + * Detect mime type. + * + * @param inputStream input stream will be closed after detection. + */ + public static String detectMimeType(InputStream inputStream) throws IOException { + try { + return tika.detect(inputStream); + } finally { + if (inputStream != null) { + inputStream.close(); + } + } + } + + public static String detectFileExtension(String mimeType) throws MimeTypeException { + MimeTypes mimeTypes = MimeTypes.getDefaultMimeTypes(); + return mimeTypes.forName(mimeType).getExtension(); + } +} diff --git a/api/src/main/java/run/halo/app/infra/utils/GenericClassUtils.java b/api/src/main/java/run/halo/app/infra/utils/GenericClassUtils.java new file mode 100644 index 0000000..3da0330 --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/utils/GenericClassUtils.java @@ -0,0 +1,43 @@ +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(); + } + } +} diff --git a/api/src/main/java/run/halo/app/infra/utils/JsonParseException.java b/api/src/main/java/run/halo/app/infra/utils/JsonParseException.java new file mode 100644 index 0000000..7b0c005 --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/utils/JsonParseException.java @@ -0,0 +1,30 @@ +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); + } +} diff --git a/api/src/main/java/run/halo/app/infra/utils/JsonUtils.java b/api/src/main/java/run/halo/app/infra/utils/JsonUtils.java new file mode 100644 index 0000000..0b33a67 --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/utils/JsonUtils.java @@ -0,0 +1,105 @@ +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 + */ +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); + } + } +} diff --git a/api/src/main/java/run/halo/app/infra/utils/PathUtils.java b/api/src/main/java/run/halo/app/infra/utils/PathUtils.java new file mode 100644 index 0000000..1c08e34 --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/utils/PathUtils.java @@ -0,0 +1,121 @@ +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); + } +} diff --git a/api/src/main/java/run/halo/app/migration/Backup.java b/api/src/main/java/run/halo/app/migration/Backup.java new file mode 100644 index 0000000..8d96e6d --- /dev/null +++ b/api/src/main/java/run/halo/app/migration/Backup.java @@ -0,0 +1,65 @@ +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, + } + +} diff --git a/api/src/main/java/run/halo/app/migration/Constant.java b/api/src/main/java/run/halo/app/migration/Constant.java new file mode 100644 index 0000000..6b7b4c7 --- /dev/null +++ b/api/src/main/java/run/halo/app/migration/Constant.java @@ -0,0 +1,12 @@ +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"; + +} diff --git a/api/src/main/java/run/halo/app/notification/NotificationCenter.java b/api/src/main/java/run/halo/app/notification/NotificationCenter.java new file mode 100644 index 0000000..dbd8de7 --- /dev/null +++ b/api/src/main/java/run/halo/app/notification/NotificationCenter.java @@ -0,0 +1,46 @@ +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); +} diff --git a/api/src/main/java/run/halo/app/notification/NotificationContext.java b/api/src/main/java/run/halo/app/notification/NotificationContext.java new file mode 100644 index 0000000..5550dfd --- /dev/null +++ b/api/src/main/java/run/halo/app/notification/NotificationContext.java @@ -0,0 +1,48 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/notification/NotificationReasonEmitter.java b/api/src/main/java/run/halo/app/notification/NotificationReasonEmitter.java new file mode 100644 index 0000000..5c3551f --- /dev/null +++ b/api/src/main/java/run/halo/app/notification/NotificationReasonEmitter.java @@ -0,0 +1,22 @@ +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); +} diff --git a/api/src/main/java/run/halo/app/notification/ReactiveNotifier.java b/api/src/main/java/run/halo/app/notification/ReactiveNotifier.java new file mode 100644 index 0000000..9093bb3 --- /dev/null +++ b/api/src/main/java/run/halo/app/notification/ReactiveNotifier.java @@ -0,0 +1,20 @@ +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); +} diff --git a/api/src/main/java/run/halo/app/notification/ReasonAttributes.java b/api/src/main/java/run/halo/app/notification/ReasonAttributes.java new file mode 100644 index 0000000..da42f93 --- /dev/null +++ b/api/src/main/java/run/halo/app/notification/ReasonAttributes.java @@ -0,0 +1,13 @@ +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 { + +} diff --git a/api/src/main/java/run/halo/app/notification/ReasonPayload.java b/api/src/main/java/run/halo/app/notification/ReasonPayload.java new file mode 100644 index 0000000..fcc711b --- /dev/null +++ b/api/src/main/java/run/halo/app/notification/ReasonPayload.java @@ -0,0 +1,60 @@ +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); + } + } +} \ No newline at end of file diff --git a/api/src/main/java/run/halo/app/notification/UserIdentity.java b/api/src/main/java/run/halo/app/notification/UserIdentity.java new file mode 100644 index 0000000..b9c1efb --- /dev/null +++ b/api/src/main/java/run/halo/app/notification/UserIdentity.java @@ -0,0 +1,57 @@ +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(); + } +} diff --git a/api/src/main/java/run/halo/app/plugin/ApiVersion.java b/api/src/main/java/run/halo/app/plugin/ApiVersion.java new file mode 100644 index 0000000..3026cb9 --- /dev/null +++ b/api/src/main/java/run/halo/app/plugin/ApiVersion.java @@ -0,0 +1,26 @@ +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(); +} diff --git a/api/src/main/java/run/halo/app/plugin/BasePlugin.java b/api/src/main/java/run/halo/app/plugin/BasePlugin.java new file mode 100644 index 0000000..88cdc2c --- /dev/null +++ b/api/src/main/java/run/halo/app/plugin/BasePlugin.java @@ -0,0 +1,41 @@ +package run.halo.app.plugin; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.pf4j.Plugin; +import org.pf4j.PluginWrapper; + +/** + * 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; + } + + @Deprecated(since = "2.7.0", forRemoval = true) + public BasePlugin(PluginWrapper wrapper) { + super(wrapper); + log.warn("Deprecated constructor 'BasePlugin(PluginWrapper wrapper)' called, please use " + + "'BasePlugin(PluginContext pluginContext)' instead for plugin '{}',This " + + "constructor will be removed in 2.19.0", + wrapper.getPluginId()); + } + + public BasePlugin() { + } +} diff --git a/api/src/main/java/run/halo/app/plugin/PluginConfigUpdatedEvent.java b/api/src/main/java/run/halo/app/plugin/PluginConfigUpdatedEvent.java new file mode 100644 index 0000000..77877d0 --- /dev/null +++ b/api/src/main/java/run/halo/app/plugin/PluginConfigUpdatedEvent.java @@ -0,0 +1,32 @@ +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 { + private final Map oldConfig; + private final Map newConfig; + + @Builder + public PluginConfigUpdatedEvent(Object source, Map oldConfig, + Map newConfig) { + super(source); + this.oldConfig = oldConfig; + this.newConfig = newConfig; + } +} diff --git a/api/src/main/java/run/halo/app/plugin/PluginContext.java b/api/src/main/java/run/halo/app/plugin/PluginContext.java new file mode 100644 index 0000000..d5fa549 --- /dev/null +++ b/api/src/main/java/run/halo/app/plugin/PluginContext.java @@ -0,0 +1,31 @@ +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; +} diff --git a/api/src/main/java/run/halo/app/plugin/PluginsRootGetter.java b/api/src/main/java/run/halo/app/plugin/PluginsRootGetter.java new file mode 100644 index 0000000..214d286 --- /dev/null +++ b/api/src/main/java/run/halo/app/plugin/PluginsRootGetter.java @@ -0,0 +1,14 @@ +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 { + +} diff --git a/api/src/main/java/run/halo/app/plugin/ReactiveSettingFetcher.java b/api/src/main/java/run/halo/app/plugin/ReactiveSettingFetcher.java new file mode 100644 index 0000000..8272fd6 --- /dev/null +++ b/api/src/main/java/run/halo/app/plugin/ReactiveSettingFetcher.java @@ -0,0 +1,23 @@ +package run.halo.app.plugin; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Map; +import org.springframework.lang.NonNull; +import reactor.core.publisher.Mono; + +/** + * 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); + + @NonNull + Mono get(String group); + + @NonNull + Mono> getValues(); +} diff --git a/api/src/main/java/run/halo/app/plugin/SettingFetcher.java b/api/src/main/java/run/halo/app/plugin/SettingFetcher.java new file mode 100644 index 0000000..79374a4 --- /dev/null +++ b/api/src/main/java/run/halo/app/plugin/SettingFetcher.java @@ -0,0 +1,20 @@ +package run.halo.app.plugin; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Map; +import java.util.Optional; + +/** + * SettingFetcher must be a class instead of an interface due to backward compatibility. + * + * @author johnniang + */ +public abstract class SettingFetcher { + + public abstract Optional fetch(String group, Class clazz); + + public abstract JsonNode get(String group); + + public abstract Map getValues(); + +} diff --git a/api/src/main/java/run/halo/app/plugin/SharedEvent.java b/api/src/main/java/run/halo/app/plugin/SharedEvent.java new file mode 100644 index 0000000..a62026c --- /dev/null +++ b/api/src/main/java/run/halo/app/plugin/SharedEvent.java @@ -0,0 +1,21 @@ +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 { +} diff --git a/api/src/main/java/run/halo/app/plugin/event/PluginStartedEvent.java b/api/src/main/java/run/halo/app/plugin/event/PluginStartedEvent.java new file mode 100644 index 0000000..16c1308 --- /dev/null +++ b/api/src/main/java/run/halo/app/plugin/event/PluginStartedEvent.java @@ -0,0 +1,17 @@ +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); + } + +} diff --git a/api/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionGetter.java b/api/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionGetter.java new file mode 100644 index 0000000..58615ec --- /dev/null +++ b/api/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionGetter.java @@ -0,0 +1,37 @@ +package run.halo.app.plugin.extensionpoint; + +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); +} diff --git a/api/src/main/java/run/halo/app/search/HaloDocument.java b/api/src/main/java/run/halo/app/search/HaloDocument.java new file mode 100644 index 0000000..eedd593 --- /dev/null +++ b/api/src/main/java/run/halo/app/search/HaloDocument.java @@ -0,0 +1,106 @@ +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; + +} diff --git a/api/src/main/java/run/halo/app/search/HaloDocumentsProvider.java b/api/src/main/java/run/halo/app/search/HaloDocumentsProvider.java new file mode 100644 index 0000000..91b5c81 --- /dev/null +++ b/api/src/main/java/run/halo/app/search/HaloDocumentsProvider.java @@ -0,0 +1,27 @@ +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(); + +} diff --git a/api/src/main/java/run/halo/app/search/SearchEngine.java b/api/src/main/java/run/halo/app/search/SearchEngine.java new file mode 100644 index 0000000..8a48558 --- /dev/null +++ b/api/src/main/java/run/halo/app/search/SearchEngine.java @@ -0,0 +1,47 @@ +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); + +} diff --git a/api/src/main/java/run/halo/app/search/SearchOption.java b/api/src/main/java/run/halo/app/search/SearchOption.java new file mode 100644 index 0000000..1c84ce4 --- /dev/null +++ b/api/src/main/java/run/halo/app/search/SearchOption.java @@ -0,0 +1,79 @@ +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; +} diff --git a/api/src/main/java/run/halo/app/search/SearchParam.java b/api/src/main/java/run/halo/app/search/SearchParam.java new file mode 100644 index 0000000..e188517 --- /dev/null +++ b/api/src/main/java/run/halo/app/search/SearchParam.java @@ -0,0 +1,108 @@ +package run.halo.app.search; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; +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 io.swagger.v3.oas.annotations.media.Schema; +import org.springdoc.core.fn.builders.operation.Builder; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebInputException; + +@Deprecated(forRemoval = true, since = "2.17") +public class SearchParam { + + private static final int DEFAULT_LIMIT = 10; + private static final String DEFAULT_HIGHLIGHT_PRE_TAG = ""; + private static final String DEFAULT_HIGHLIGHT_POST_TAG = ""; + + private final MultiValueMap query; + + public SearchParam(MultiValueMap query) { + this.query = query; + } + + @Schema(name = "keyword", requiredMode = REQUIRED) + public String getKeyword() { + var keyword = query.getFirst("keyword"); + if (!StringUtils.hasText(keyword)) { + throw new ServerWebInputException("keyword is required"); + } + return keyword; + } + + @Schema(name = "limit", defaultValue = "100", maximum = "1000") + public int getLimit() { + var limitString = query.getFirst("limit"); + int limit = 0; + if (StringUtils.hasText(limitString)) { + try { + limit = Integer.parseInt(limitString); + } catch (NumberFormatException nfe) { + throw new ServerWebInputException("Failed to get "); + } + } + if (limit <= 0) { + limit = DEFAULT_LIMIT; + } + return limit; + } + + @Schema(name = "highlightPreTag", defaultValue = DEFAULT_HIGHLIGHT_PRE_TAG) + public String getHighlightPreTag() { + var highlightPreTag = query.getFirst("highlightPreTag"); + if (!StringUtils.hasText(highlightPreTag)) { + highlightPreTag = DEFAULT_HIGHLIGHT_PRE_TAG; + } + return highlightPreTag; + } + + @Schema(name = "highlightPostTag", defaultValue = DEFAULT_HIGHLIGHT_POST_TAG) + public String getHighlightPostTag() { + var highlightPostTag = query.getFirst("highlightPostTag"); + if (!StringUtils.hasText(highlightPostTag)) { + highlightPostTag = DEFAULT_HIGHLIGHT_POST_TAG; + } + return highlightPostTag; + } + + public static void buildParameters(Builder builder) { + builder.parameter(parameterBuilder() + .in(ParameterIn.QUERY) + .name("keyword") + .description("Keyword to search") + .implementation(String.class) + .required(true)) + .parameter(parameterBuilder() + .in(ParameterIn.QUERY) + .name("limit") + .description("Limit of search results") + .required(false) + .schema(schemaBuilder() + .implementation(Integer.class) + .maximum("1000") + .defaultValue(String.valueOf(DEFAULT_LIMIT)))) + .parameter(parameterBuilder() + .in(ParameterIn.QUERY) + .name("highlightPreTag") + .description("Highlight pre tag") + .required(false) + .schema(schemaBuilder() + .implementation(String.class) + .defaultValue(DEFAULT_HIGHLIGHT_PRE_TAG) + )) + .parameter(parameterBuilder() + .in(ParameterIn.QUERY) + .name("highlightPostTag") + .description("Highlight post tag") + .required(false) + .schema(schemaBuilder() + .implementation(String.class) + .defaultValue(DEFAULT_HIGHLIGHT_POST_TAG) + ) + ); + + } +} diff --git a/api/src/main/java/run/halo/app/search/SearchResult.java b/api/src/main/java/run/halo/app/search/SearchResult.java new file mode 100644 index 0000000..83d209a --- /dev/null +++ b/api/src/main/java/run/halo/app/search/SearchResult.java @@ -0,0 +1,13 @@ +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; +} diff --git a/api/src/main/java/run/halo/app/search/SearchService.java b/api/src/main/java/run/halo/app/search/SearchService.java new file mode 100644 index 0000000..8d17588 --- /dev/null +++ b/api/src/main/java/run/halo/app/search/SearchService.java @@ -0,0 +1,21 @@ +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); + +} diff --git a/api/src/main/java/run/halo/app/search/event/HaloDocumentAddRequestEvent.java b/api/src/main/java/run/halo/app/search/event/HaloDocumentAddRequestEvent.java new file mode 100644 index 0000000..d0e29ce --- /dev/null +++ b/api/src/main/java/run/halo/app/search/event/HaloDocumentAddRequestEvent.java @@ -0,0 +1,21 @@ +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; + } + +} diff --git a/api/src/main/java/run/halo/app/search/event/HaloDocumentDeleteRequestEvent.java b/api/src/main/java/run/halo/app/search/event/HaloDocumentDeleteRequestEvent.java new file mode 100644 index 0000000..1fd1ce5 --- /dev/null +++ b/api/src/main/java/run/halo/app/search/event/HaloDocumentDeleteRequestEvent.java @@ -0,0 +1,27 @@ +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; + } + +} diff --git a/api/src/main/java/run/halo/app/search/event/HaloDocumentRebuildRequestEvent.java b/api/src/main/java/run/halo/app/search/event/HaloDocumentRebuildRequestEvent.java new file mode 100644 index 0000000..7d32d14 --- /dev/null +++ b/api/src/main/java/run/halo/app/search/event/HaloDocumentRebuildRequestEvent.java @@ -0,0 +1,13 @@ +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); + } + +} diff --git a/api/src/main/java/run/halo/app/search/post/PostDoc.java b/api/src/main/java/run/halo/app/search/post/PostDoc.java new file mode 100644 index 0000000..522ebae --- /dev/null +++ b/api/src/main/java/run/halo/app/search/post/PostDoc.java @@ -0,0 +1,23 @@ +package run.halo.app.search.post; + +import java.time.Instant; +import org.springframework.util.Assert; + +@Deprecated(forRemoval = true, since = "2.17") +public record PostDoc(String name, + String title, + String excerpt, + String content, + Instant publishTimestamp, + String permalink) { + + public static final String ID_FIELD = "name"; + + public PostDoc { + Assert.hasText(name, "Name must not be blank"); + Assert.hasText(title, "Title must not be blank"); + Assert.hasText(permalink, "Permalink must not be blank"); + Assert.notNull(publishTimestamp, "PublishTimestamp must not be null"); + } + +} diff --git a/api/src/main/java/run/halo/app/search/post/PostHit.java b/api/src/main/java/run/halo/app/search/post/PostHit.java new file mode 100644 index 0000000..a479f01 --- /dev/null +++ b/api/src/main/java/run/halo/app/search/post/PostHit.java @@ -0,0 +1,20 @@ +package run.halo.app.search.post; + +import java.time.Instant; +import lombok.Data; + +@Data +@Deprecated(forRemoval = true, since = "2.17") +public class PostHit { + + private String name; + + private String title; + + private String content; + + private Instant publishTimestamp; + + private String permalink; + +} diff --git a/api/src/main/java/run/halo/app/security/AdditionalWebFilter.java b/api/src/main/java/run/halo/app/security/AdditionalWebFilter.java new file mode 100644 index 0000000..f20a39b --- /dev/null +++ b/api/src/main/java/run/halo/app/security/AdditionalWebFilter.java @@ -0,0 +1,25 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/security/AfterSecurityWebFilter.java b/api/src/main/java/run/halo/app/security/AfterSecurityWebFilter.java new file mode 100644 index 0000000..c012110 --- /dev/null +++ b/api/src/main/java/run/halo/app/security/AfterSecurityWebFilter.java @@ -0,0 +1,14 @@ +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 { + +} diff --git a/api/src/main/java/run/halo/app/security/AnonymousAuthenticationSecurityWebFilter.java b/api/src/main/java/run/halo/app/security/AnonymousAuthenticationSecurityWebFilter.java new file mode 100644 index 0000000..ac79873 --- /dev/null +++ b/api/src/main/java/run/halo/app/security/AnonymousAuthenticationSecurityWebFilter.java @@ -0,0 +1,13 @@ +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 { + +} diff --git a/api/src/main/java/run/halo/app/security/AuthenticationSecurityWebFilter.java b/api/src/main/java/run/halo/app/security/AuthenticationSecurityWebFilter.java new file mode 100644 index 0000000..1c99747 --- /dev/null +++ b/api/src/main/java/run/halo/app/security/AuthenticationSecurityWebFilter.java @@ -0,0 +1,13 @@ +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 { + +} diff --git a/api/src/main/java/run/halo/app/security/BeforeSecurityWebFilter.java b/api/src/main/java/run/halo/app/security/BeforeSecurityWebFilter.java new file mode 100644 index 0000000..0bedc3d --- /dev/null +++ b/api/src/main/java/run/halo/app/security/BeforeSecurityWebFilter.java @@ -0,0 +1,14 @@ +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 { + +} diff --git a/api/src/main/java/run/halo/app/security/FormLoginSecurityWebFilter.java b/api/src/main/java/run/halo/app/security/FormLoginSecurityWebFilter.java new file mode 100644 index 0000000..267a6a4 --- /dev/null +++ b/api/src/main/java/run/halo/app/security/FormLoginSecurityWebFilter.java @@ -0,0 +1,13 @@ +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 { + +} diff --git a/api/src/main/java/run/halo/app/security/LoginHandlerEnhancer.java b/api/src/main/java/run/halo/app/security/LoginHandlerEnhancer.java new file mode 100644 index 0000000..33c9a07 --- /dev/null +++ b/api/src/main/java/run/halo/app/security/LoginHandlerEnhancer.java @@ -0,0 +1,34 @@ +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); +} diff --git a/api/src/main/java/run/halo/app/security/PersonalAccessToken.java b/api/src/main/java/run/halo/app/security/PersonalAccessToken.java new file mode 100644 index 0000000..333c225 --- /dev/null +++ b/api/src/main/java/run/halo/app/security/PersonalAccessToken.java @@ -0,0 +1,53 @@ +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"; + + 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; + + } +} diff --git a/api/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordAuthenticationManager.java b/api/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordAuthenticationManager.java new file mode 100644 index 0000000..e76a9af --- /dev/null +++ b/api/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordAuthenticationManager.java @@ -0,0 +1,18 @@ +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 { + +} diff --git a/api/src/main/java/run/halo/app/security/device/DeviceService.java b/api/src/main/java/run/halo/app/security/device/DeviceService.java new file mode 100644 index 0000000..ba52cd2 --- /dev/null +++ b/api/src/main/java/run/halo/app/security/device/DeviceService.java @@ -0,0 +1,14 @@ +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); +} diff --git a/api/src/main/java/run/halo/app/theme/ReactivePostContentHandler.java b/api/src/main/java/run/halo/app/theme/ReactivePostContentHandler.java new file mode 100644 index 0000000..9dba0f2 --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/ReactivePostContentHandler.java @@ -0,0 +1,40 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/theme/ReactiveSinglePageContentHandler.java b/api/src/main/java/run/halo/app/theme/ReactiveSinglePageContentHandler.java new file mode 100644 index 0000000..980ddce --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/ReactiveSinglePageContentHandler.java @@ -0,0 +1,38 @@ +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; + } +} diff --git a/api/src/main/java/run/halo/app/theme/TemplateNameResolver.java b/api/src/main/java/run/halo/app/theme/TemplateNameResolver.java new file mode 100644 index 0000000..96ebf67 --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/TemplateNameResolver.java @@ -0,0 +1,45 @@ +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); +} diff --git a/api/src/main/java/run/halo/app/theme/dialect/CommentWidget.java b/api/src/main/java/run/halo/app/theme/dialect/CommentWidget.java new file mode 100644 index 0000000..25c4ae3 --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/dialect/CommentWidget.java @@ -0,0 +1,20 @@ +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); +} diff --git a/api/src/main/java/run/halo/app/theme/dialect/TemplateFooterProcessor.java b/api/src/main/java/run/halo/app/theme/dialect/TemplateFooterProcessor.java new file mode 100644 index 0000000..78f3215 --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/dialect/TemplateFooterProcessor.java @@ -0,0 +1,20 @@ +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); +} diff --git a/api/src/main/java/run/halo/app/theme/dialect/TemplateHeadProcessor.java b/api/src/main/java/run/halo/app/theme/dialect/TemplateHeadProcessor.java new file mode 100644 index 0000000..bb9798b --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/dialect/TemplateHeadProcessor.java @@ -0,0 +1,23 @@ +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); +} diff --git a/api/src/main/java/run/halo/app/theme/finders/Finder.java b/api/src/main/java/run/halo/app/theme/finders/Finder.java new file mode 100644 index 0000000..6a24311 --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/finders/Finder.java @@ -0,0 +1,26 @@ +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.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 + */ + String value() default ""; +} \ No newline at end of file diff --git a/api/src/main/java/run/halo/app/theme/finders/vo/ExtensionVoOperator.java b/api/src/main/java/run/halo/app/theme/finders/vo/ExtensionVoOperator.java new file mode 100644 index 0000000..8992c99 --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/finders/vo/ExtensionVoOperator.java @@ -0,0 +1,16 @@ +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(); +} diff --git a/api/src/main/java/run/halo/app/theme/router/ModelConst.java b/api/src/main/java/run/halo/app/theme/router/ModelConst.java new file mode 100644 index 0000000..86af7e0 --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/router/ModelConst.java @@ -0,0 +1,14 @@ +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"; + public static final Integer DEFAULT_PAGE_SIZE = 10; +} diff --git a/api/src/main/java/run/halo/app/theme/router/PageUrlUtils.java b/api/src/main/java/run/halo/app/theme/router/PageUrlUtils.java new file mode 100644 index 0000000..d4998d1 --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/router/PageUrlUtils.java @@ -0,0 +1,108 @@ +package run.halo.app.theme.router; + +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 StringUtils.defaultString(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); + } +} diff --git a/api/src/main/java/run/halo/app/theme/router/UrlContextListResult.java b/api/src/main/java/run/halo/app/theme/router/UrlContextListResult.java new file mode 100644 index 0000000..4b7efcc --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/router/UrlContextListResult.java @@ -0,0 +1,84 @@ +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); + } + } +} diff --git a/api/src/test/java/run/halo/app/core/extension/content/PostTest.java b/api/src/test/java/run/halo/app/core/extension/content/PostTest.java new file mode 100644 index 0000000..5928ef5 --- /dev/null +++ b/api/src/test/java/run/halo/app/core/extension/content/PostTest.java @@ -0,0 +1,45 @@ +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) + ); + + } + +} diff --git a/api/src/test/java/run/halo/app/core/extension/notification/SubscriptionTest.java b/api/src/test/java/run/halo/app/core/extension/notification/SubscriptionTest.java new file mode 100644 index 0000000..26a51ea --- /dev/null +++ b/api/src/test/java/run/halo/app/core/extension/notification/SubscriptionTest.java @@ -0,0 +1,28 @@ +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); + } +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/ExtensionUtilTest.java b/api/src/test/java/run/halo/app/extension/ExtensionUtilTest.java new file mode 100644 index 0000000..446cb33 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/ExtensionUtilTest.java @@ -0,0 +1,59 @@ +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.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()); + } + +} diff --git a/api/src/test/java/run/halo/app/extension/FakeExtension.java b/api/src/test/java/run/halo/app/extension/FakeExtension.java new file mode 100644 index 0000000..d0e5cb0 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/FakeExtension.java @@ -0,0 +1,18 @@ +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; + } + +} diff --git a/api/src/test/java/run/halo/app/extension/ListOptionsTest.java b/api/src/test/java/run/halo/app/extension/ListOptionsTest.java new file mode 100644 index 0000000..a5ed75a --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/ListOptionsTest.java @@ -0,0 +1,51 @@ +package run.halo.app.extension; + +import static org.assertj.core.api.Assertions.assertThat; +import static run.halo.app.extension.index.query.QueryFactory.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 buildTest() { + 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(); + System.out.println(listOptions); + assertThat(listOptions.toString()).isEqualTo( + "fieldSelector: (spec.slug = 'fake-slug' OR spec.slug = 'test'), labelSelector: " + + "(key-1 equal value-1, key-2 not_equal value-1, key-3 EXISTS)"); + } + + @Test + void buildTest2() { + var listOptions = ListOptions.builder() + .labelSelector() + .notEq("key-2", "value-1") + .end() + .fieldQuery(equal("spec.slug", "fake-slug")) + .build(); + assertThat(listOptions.toString()) + .isEqualTo( + "fieldSelector: (spec.slug = 'fake-slug'), labelSelector: (key-2 not_equal " + + "value-1)"); + } + } +} diff --git a/api/src/test/java/run/halo/app/extension/PageRequestImplTest.java b/api/src/test/java/run/halo/app/extension/PageRequestImplTest.java new file mode 100644 index 0000000..4149ad1 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/PageRequestImplTest.java @@ -0,0 +1,27 @@ +package run.halo.app.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Random; +import org.junit.jupiter.api.RepeatedTest; +import org.springframework.data.domain.Sort; + +class PageRequestImplTest { + + @RepeatedTest(10) + void shouldBeCompatibleZeroAndNegativePageNumber() { + var randomPageNumber = -(new Random().nextInt(0, Integer.MAX_VALUE)); + var page = new PageRequestImpl(randomPageNumber, 10, Sort.unsorted()); + assertEquals(1, page.getPageNumber()); + assertEquals(10, page.getPageSize()); + } + + @RepeatedTest(10) + void shouldBeCompatibleNegativePageSize() { + var randomPageSize = -(new Random().nextInt(1, Integer.MAX_VALUE)); + var page = new PageRequestImpl(10, randomPageSize, Sort.unsorted()); + assertEquals(10, page.getPageNumber()); + assertEquals(0, page.getPageSize()); + } + +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/SecretTest.java b/api/src/test/java/run/halo/app/extension/SecretTest.java new file mode 100644 index 0000000..af18827 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/SecretTest.java @@ -0,0 +1,84 @@ +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=" + } + } + """; + } +} diff --git a/api/src/test/java/run/halo/app/extension/controller/ControllerBuilderTest.java b/api/src/test/java/run/halo/app/extension/controller/ControllerBuilderTest.java new file mode 100644 index 0000000..63dc39d --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/controller/ControllerBuilderTest.java @@ -0,0 +1,117 @@ +package run.halo.app.extension.controller; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.lenient; + +import java.time.Duration; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.FakeExtension; +import run.halo.app.extension.index.IndexedQueryEngine; + +@ExtendWith(MockitoExtension.class) +class ControllerBuilderTest { + + @Mock + ExtensionClient client; + + @Mock + IndexedQueryEngine indexedQueryEngine; + + @BeforeEach + void setUp() { + lenient().when(client.indexedQueryEngine()).thenReturn(indexedQueryEngine); + } + + @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; + } + } + +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/controller/DefaultControllerTest.java b/api/src/test/java/run/halo/app/extension/controller/DefaultControllerTest.java new file mode 100644 index 0000000..86f6d0d --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/controller/DefaultControllerTest.java @@ -0,0 +1,300 @@ +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.doNothing; +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.time.Duration; +import java.time.Instant; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +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, times(1)).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, times(1)).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, times(1)).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).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).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, times(1)).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, times(1)).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 shouldDisposeCorrectly() 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, times(1)).shutdownNow(); + verify(executor, times(1)).awaitTermination(anyLong(), any()); + } + + @Test + void shouldDisposeCorrectlyEvenIfTimeoutAwaitTermination() throws InterruptedException { + when(executor.awaitTermination(anyLong(), any())).thenThrow(InterruptedException.class); + + controller.dispose(); + + assertTrue(controller.isDisposed()); + assertFalse(controller.isStarted()); + + verify(synchronizer, times(1)).dispose(); + verify(queue, times(1)).dispose(); + verify(executor, times(1)).shutdownNow(); + verify(executor, times(1)).awaitTermination(anyLong(), any()); + } + + @Test + void shouldStartCorrectly() throws InterruptedException { + when(executor.submit(any(Runnable.class))).thenAnswer(invocation -> { + doNothing().when(synchronizer).start(); + when(queue.take()).thenThrow(InterruptedException.class); + + // invoke the task really + ((Runnable) invocation.getArgument(0)).run(); + return mock(Future.class); + }); + controller.start(); + + assertTrue(controller.isStarted()); + assertFalse(controller.isDisposed()); + + verify(executor, times(1)).submit(any(Runnable.class)); + verify(synchronizer, times(1)).start(); + verify(queue, times(1)).take(); + verify(reconciler, times(0)).reconcile(any()); + } + + @Test + void shouldNotStartWhenDisposed() { + controller.dispose(); + controller.start(); + assertFalse(controller.isStarted()); + assertTrue(controller.isDisposed()); + + verify(executor, times(0)).submit(any(Runnable.class)); + } + + @Test + void shouldCreateMultiWorkers() { + controller = createController(5); + controller.start(); + verify(executor, times(5)).submit(any(DefaultController.Worker.class)); + } + + @Test + void shouldFailToCreateControllerDueToInvalidWorkerCount() { + assertThrows(IllegalArgumentException.class, () -> createController(0)); + assertThrows(IllegalArgumentException.class, () -> createController(-1)); + } +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/controller/DefaultDelayQueueTest.java b/api/src/test/java/run/halo/app/extension/controller/DefaultDelayQueueTest.java new file mode 100644 index 0000000..68f14c0 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/controller/DefaultDelayQueueTest.java @@ -0,0 +1,137 @@ +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); + } + +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/controller/DelayedEntryTest.java b/api/src/test/java/run/halo/app/extension/controller/DelayedEntryTest.java new file mode 100644 index 0000000..b222546 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/controller/DelayedEntryTest.java @@ -0,0 +1,60 @@ +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); + } +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/controller/ExtensionWatcherTest.java b/api/src/test/java/run/halo/app/extension/controller/ExtensionWatcherTest.java new file mode 100644 index 0000000..ec5cbec --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/controller/ExtensionWatcherTest.java @@ -0,0 +1,152 @@ +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(); + } +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/controller/RequestSynchronizerTest.java b/api/src/test/java/run/halo/app/extension/controller/RequestSynchronizerTest.java new file mode 100644 index 0000000..787f9fe --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/controller/RequestSynchronizerTest.java @@ -0,0 +1,107 @@ +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.GroupVersionKind; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.Watcher; +import run.halo.app.extension.index.IndexedQueryEngine; + +@ExtendWith(MockitoExtension.class) +class RequestSynchronizerTest { + + @Mock + ExtensionClient client; + + @Mock + IndexedQueryEngine indexedQueryEngine; + + @Mock + Watcher watcher; + + RequestSynchronizer synchronizer; + + @BeforeEach + void setUp() { + when(client.indexedQueryEngine()).thenReturn(indexedQueryEngine); + synchronizer = + new RequestSynchronizer(true, client, new FakeExtension(), watcher, new ListOptions()); + assertFalse(synchronizer.isDisposed()); + assertFalse(synchronizer.isStarted()); + } + + @Test + void shouldStartCorrectlyWhenSyncingAllOnStart() { + var type = GroupVersionKind.fromExtension(FakeExtension.class); + when(indexedQueryEngine.retrieveAll(eq(type), isA(ListOptions.class), any(Sort.class))) + .thenReturn(List.of("fake-01", "fake-02")); + + synchronizer.start(); + + assertTrue(synchronizer.isStarted()); + assertFalse(synchronizer.isDisposed()); + + verify(indexedQueryEngine, times(1)).retrieveAll(eq(type), + isA(ListOptions.class), isA(Sort.class)); + 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()); + } + +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/index/FunctionalMultiValueIndexAttributeTest.java b/api/src/test/java/run/halo/app/extension/index/FunctionalMultiValueIndexAttributeTest.java new file mode 100644 index 0000000..7fd91f6 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/index/FunctionalMultiValueIndexAttributeTest.java @@ -0,0 +1,58 @@ +package run.halo.app.extension.index; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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; +import run.halo.app.extension.Unstructured; + +/** + * Tests for {@link FunctionalMultiValueIndexAttribute}. + * + * @author guqing + * @since 2.12.0 + */ +class FunctionalMultiValueIndexAttributeTest { + + @Test + void create() { + var attribute = new FunctionalMultiValueIndexAttribute<>(FakeExtension.class, + FakeExtension::getCategories); + assertThat(attribute).isNotNull(); + } + + @Test + void getValues() { + var attribute = new FunctionalMultiValueIndexAttribute<>(FakeExtension.class, + FakeExtension::getCategories); + var fake = new FakeExtension(); + fake.setCategories(Set.of("test", "halo")); + assertThat(attribute.getValues(fake)).isEqualTo(fake.getCategories()); + + var unstructured = Unstructured.OBJECT_MAPPER.convertValue(fake, Unstructured.class); + assertThatThrownBy(() -> attribute.getValues(unstructured)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Object type does not match"); + + var demoExt = new DemoExtension(); + assertThatThrownBy(() -> attribute.getValues(demoExt)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Object type does not match"); + } + + @Data + @EqualsAndHashCode(callSuper = true) + @GVK(group = "test.halo.run", version = "v1", kind = "FakeExtension", plural = "fakes", + singular = "fake") + static class FakeExtension extends AbstractExtension { + Set categories; + } + + class DemoExtension extends AbstractExtension { + } +} diff --git a/api/src/test/java/run/halo/app/extension/index/IndexAttributeFactoryTest.java b/api/src/test/java/run/halo/app/extension/index/IndexAttributeFactoryTest.java new file mode 100644 index 0000000..04dab75 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/index/IndexAttributeFactoryTest.java @@ -0,0 +1,41 @@ +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.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 multiValueAttribute() { + 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")); + assertThat(attribute.getValues(extension)).isEqualTo(Set.of("tag1", "tag2")); + } + + @Data + @EqualsAndHashCode(callSuper = true) + @GVK(group = "test", version = "v1", kind = "FakeExtension", plural = "fakes", + singular = "fake") + static class FakeExtension extends AbstractExtension { + Set tags; + } +} diff --git a/api/src/test/java/run/halo/app/extension/index/IndexSpecTest.java b/api/src/test/java/run/halo/app/extension/index/IndexSpecTest.java new file mode 100644 index 0000000..08f240f --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/index/IndexSpecTest.java @@ -0,0 +1,76 @@ +package run.halo.app.extension.index; + +import static org.assertj.core.api.Assertions.assertThat; + +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(); + } + + @Data + @EqualsAndHashCode(callSuper = true) + @GVK(group = "test", version = "v1", kind = "Fake", plural = "fakes", singular = "fake") + static class FakeExtension extends AbstractExtension { + private String slug; + } +} diff --git a/api/src/test/java/run/halo/app/extension/index/KeyComparatorTest.java b/api/src/test/java/run/halo/app/extension/index/KeyComparatorTest.java new file mode 100644 index 0000000..9092520 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/index/KeyComparatorTest.java @@ -0,0 +1,79 @@ +package run.halo.app.extension.index; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.Comparator; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link KeyComparator}. + * + * @author guqing + * @since 2.12.0 + */ +class KeyComparatorTest { + + @Test + void keyComparator() { + var comparator = KeyComparator.INSTANCE; + 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() { + var comparator = KeyComparator.INSTANCE; + 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}); + } +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/index/query/InQueryTest.java b/api/src/test/java/run/halo/app/extension/index/query/InQueryTest.java new file mode 100644 index 0000000..c7e8d96 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/index/query/InQueryTest.java @@ -0,0 +1,24 @@ +package run.halo.app.extension.index.query; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.LinkedHashSet; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link InQuery}. + * + * @author guqing + * @since 2.17.0 + */ +class InQueryTest { + + @Test + void testToString() { + var values = new LinkedHashSet(); + values.add("Alice"); + values.add("Bob"); + var inQuery = new InQuery("name", values); + assertThat(inQuery.toString()).isEqualTo("name IN ('Alice', 'Bob')"); + } +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/index/query/IsNotNullTest.java b/api/src/test/java/run/halo/app/extension/index/query/IsNotNullTest.java new file mode 100644 index 0000000..be6b708 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/index/query/IsNotNullTest.java @@ -0,0 +1,20 @@ +package run.halo.app.extension.index.query; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link IsNotNull}. + * + * @author guqing + * @since 2.17.0 + */ +class IsNotNullTest { + + @Test + void testToString() { + var isNotNull = new IsNotNull("name"); + assertThat(isNotNull.toString()).isEqualTo("name IS NOT NULL"); + } +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/index/query/StringContainsTest.java b/api/src/test/java/run/halo/app/extension/index/query/StringContainsTest.java new file mode 100644 index 0000000..f55cdd6 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/index/query/StringContainsTest.java @@ -0,0 +1,20 @@ +package run.halo.app.extension.index.query; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link StringContains}. + * + * @author guqing + * @since 2.17.0 + */ +class StringContainsTest { + + @Test + void testToString() { + var stringContains = new StringContains("name", "Alice"); + assertThat(stringContains.toString()).isEqualTo("contains(name, 'Alice')"); + } +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/router/selector/FieldCriteriaPredicateConverterTest.java b/api/src/test/java/run/halo/app/extension/router/selector/FieldCriteriaPredicateConverterTest.java new file mode 100644 index 0000000..34eff15 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/router/selector/FieldCriteriaPredicateConverterTest.java @@ -0,0 +1,96 @@ +package run.halo.app.extension.router.selector; + +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.mockito.Mockito.mock; + +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import run.halo.app.extension.Extension; +import run.halo.app.extension.FakeExtension; +import run.halo.app.extension.Metadata; + +class FieldCriteriaPredicateConverterTest { + + FieldCriteriaPredicateConverter converter; + + @BeforeEach + void setUp() { + converter = new FieldCriteriaPredicateConverter<>(); + } + + @Test + void shouldConvertNameEqualsCorrectly() { + var criteria = new SelectorCriteria("name", Operator.Equals, Set.of("value1", "value2")); + var predicate = converter.convert(criteria); + assertNotNull(predicate); + + var fakeExt = new FakeExtension(); + var metadata = new Metadata(); + fakeExt.setMetadata(metadata); + assertFalse(predicate.test(fakeExt)); + + metadata.setName("value1"); + assertTrue(predicate.test(fakeExt)); + + metadata.setName("value2"); + assertTrue(predicate.test(fakeExt)); + + metadata.setName("invalid-value"); + assertFalse(predicate.test(fakeExt)); + } + + @Test + void shouldConvertNameNotEqualsCorrectly() { + var criteria = new SelectorCriteria("name", Operator.NotEquals, Set.of("value1", "value2")); + var predicate = converter.convert(criteria); + assertNotNull(predicate); + + var fake = new FakeExtension(); + var metadata = new Metadata(); + fake.setMetadata(metadata); + assertFalse(predicate.test(fake)); + + metadata.setName("not-contain-value"); + assertTrue(predicate.test(fake)); + + metadata.setName("value1"); + assertFalse(predicate.test(fake)); + + metadata.setName("value2"); + assertFalse(predicate.test(fake)); + } + + @Test + void shouldConvertNameInCorrectly() { + var criteria = new SelectorCriteria("name", Operator.IN, Set.of("value1", "value2")); + var predicate = converter.convert(criteria); + assertNotNull(predicate); + + var fake = new FakeExtension(); + var metadata = new Metadata(); + fake.setMetadata(metadata); + assertFalse(predicate.test(fake)); + + metadata.setName("not-contain-value"); + assertFalse(predicate.test(fake)); + + metadata.setName("value1"); + assertTrue(predicate.test(fake)); + + metadata.setName("value2"); + assertTrue(predicate.test(fake)); + } + + @Test + void shouldReturnAlwaysFalseIfCriteriaKeyNotSupported() { + var criteria = + new SelectorCriteria("unsupported-field", Operator.Equals, Set.of("value1", "value2")); + var predicate = converter.convert(criteria); + assertNotNull(predicate); + + assertFalse(predicate.test(mock(Extension.class))); + } +} diff --git a/api/src/test/java/run/halo/app/extension/router/selector/LabelCriteriaPredicateConverterTest.java b/api/src/test/java/run/halo/app/extension/router/selector/LabelCriteriaPredicateConverterTest.java new file mode 100644 index 0000000..59795a6 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/router/selector/LabelCriteriaPredicateConverterTest.java @@ -0,0 +1,101 @@ +package run.halo.app.extension.router.selector; + +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.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import run.halo.app.extension.Extension; +import run.halo.app.extension.FakeExtension; +import run.halo.app.extension.Metadata; + +class LabelCriteriaPredicateConverterTest { + + LabelCriteriaPredicateConverter convert; + + @BeforeEach + void setUp() { + convert = new LabelCriteriaPredicateConverter<>(); + } + + @Test + void shouldConvertEqualsCorrectly() { + var criteria = new SelectorCriteria("name", Operator.Equals, Set.of("value1", "value2")); + var predicate = convert.convert(criteria); + assertNotNull(predicate); + var fakeExt = new FakeExtension(); + var metadata = new Metadata(); + fakeExt.setMetadata(metadata); + assertFalse(predicate.test(fakeExt)); + + metadata.setLabels(Map.of("name", "value")); + assertFalse(predicate.test(fakeExt)); + + metadata.setLabels(Map.of("name", "value1")); + assertTrue(predicate.test(fakeExt)); + + metadata.setLabels(Map.of("name", "value2")); + assertTrue(predicate.test(fakeExt)); + } + + @Test + void shouldConvertNotEqualsCorrectly() { + var criteria = new SelectorCriteria("name", Operator.NotEquals, Set.of("value1", "value2")); + var predicate = convert.convert(criteria); + assertNotNull(predicate); + + var fakeExt = new FakeExtension(); + var metadata = new Metadata(); + fakeExt.setMetadata(metadata); + assertFalse(predicate.test(fakeExt)); + + metadata.setLabels(Map.of("name", "value")); + assertTrue(predicate.test(fakeExt)); + + metadata.setLabels(Map.of("name", "value1")); + assertFalse(predicate.test(fakeExt)); + + metadata.setLabels(Map.of("name", "value2")); + assertFalse(predicate.test(fakeExt)); + } + + @Test + void shouldConvertNotExistCorrectly() { + var criteria = new SelectorCriteria("name", Operator.NotExist, Set.of()); + var predicate = convert.convert(criteria); + assertNotNull(predicate); + + var fake = new FakeExtension(); + var metadata = new Metadata(); + fake.setMetadata(metadata); + assertTrue(predicate.test(fake)); + + metadata.setLabels(Map.of("not-a-name", "")); + assertTrue(predicate.test(fake)); + + metadata.setLabels(Map.of("name", "")); + assertFalse(predicate.test(fake)); + } + + @Test + void shouldConvertExistCorrectly() { + var criteria = new SelectorCriteria("name", Operator.Exist, Set.of()); + var predicate = convert.convert(criteria); + assertNotNull(predicate); + + var fake = new FakeExtension(); + var metadata = new Metadata(); + fake.setMetadata(metadata); + assertFalse(predicate.test(fake)); + + metadata.setLabels(Map.of("not-a-name", "")); + assertFalse(predicate.test(fake)); + + metadata.setLabels(Map.of("name", "")); + assertTrue(predicate.test(fake)); + } + +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/router/selector/LabelSelectorTest.java b/api/src/test/java/run/halo/app/extension/router/selector/LabelSelectorTest.java new file mode 100644 index 0000000..b91b59e --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/router/selector/LabelSelectorTest.java @@ -0,0 +1,24 @@ +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("a equal v1, b IN (v2, v3)"); + } +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/router/selector/OperatorTest.java b/api/src/test/java/run/halo/app/extension/router/selector/OperatorTest.java new file mode 100644 index 0000000..ae42627 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/router/selector/OperatorTest.java @@ -0,0 +1,64 @@ +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", 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())); + }); + } +} diff --git a/api/src/test/java/run/halo/app/extension/router/selector/SelectorConverterTest.java b/api/src/test/java/run/halo/app/extension/router/selector/SelectorConverterTest.java new file mode 100644 index 0000000..3f73065 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/router/selector/SelectorConverterTest.java @@ -0,0 +1,42 @@ +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)); + }); + } +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/extension/router/selector/SelectorUtilTest.java b/api/src/test/java/run/halo/app/extension/router/selector/SelectorUtilTest.java new file mode 100644 index 0000000..8963e64 --- /dev/null +++ b/api/src/test/java/run/halo/app/extension/router/selector/SelectorUtilTest.java @@ -0,0 +1,44 @@ +package run.halo.app.extension.router.selector; + +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.mockito.Mockito.mock; +import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import run.halo.app.extension.Extension; +import run.halo.app.extension.FakeExtension; +import run.halo.app.extension.Metadata; + +class SelectorUtilTest { + + @Test + void shouldConvertCorrectlyIfSelectorsAreNull() { + var predicate = labelAndFieldSelectorToPredicate(null, null); + assertTrue(predicate.test(mock(Extension.class))); + } + + @Test + void shouldConvertCorrectlyIfSelectorsAreNotNull() { + var predicate = labelAndFieldSelectorToPredicate(List.of("label-name=label-value"), + List.of("name=fake-name")); + assertNotNull(predicate); + + var fake = new FakeExtension(); + var metadata = new Metadata(); + fake.setMetadata(metadata); + assertFalse(predicate.test(fake)); + + metadata.setName("fake-name"); + assertFalse(predicate.test(fake)); + + metadata.setLabels(Map.of("label-name", "label-value")); + assertTrue(predicate.test(fake)); + + metadata.setName("invalid-name"); + assertFalse(predicate.test(fake)); + } +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/infra/utils/GenericClassUtilsTest.java b/api/src/test/java/run/halo/app/infra/utils/GenericClassUtilsTest.java new file mode 100644 index 0000000..4090d8a --- /dev/null +++ b/api/src/test/java/run/halo/app/infra/utils/GenericClassUtilsTest.java @@ -0,0 +1,19 @@ +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()); + } + +} \ No newline at end of file diff --git a/api/src/test/java/run/halo/app/infra/utils/JsonUtilsTest.java b/api/src/test/java/run/halo/app/infra/utils/JsonUtilsTest.java new file mode 100644 index 0000000..605df69 --- /dev/null +++ b/api/src/test/java/run/halo/app/infra/utils/JsonUtilsTest.java @@ -0,0 +1,35 @@ +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")); + } +} diff --git a/api/src/test/java/run/halo/app/infra/utils/PathUtilsTest.java b/api/src/test/java/run/halo/app/infra/utils/PathUtilsTest.java new file mode 100644 index 0000000..da63565 --- /dev/null +++ b/api/src/test/java/run/halo/app/infra/utils/PathUtilsTest.java @@ -0,0 +1,98 @@ +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(); + } + } +} \ No newline at end of file diff --git a/application/build.gradle b/application/build.gradle new file mode 100644 index 0000000..49f4040 --- /dev/null +++ b/application/build.gradle @@ -0,0 +1,166 @@ +import de.undercouch.gradle.tasks.download.Download +import org.gradle.crypto.checksum.Checksum + +plugins { + id 'org.springframework.boot' + id 'io.spring.dependency-management' + id "com.gorylenko.gradle-git-properties" + id "checkstyle" + id 'java' + id 'idea' + id 'jacoco' + id "de.undercouch.download" + id "io.freefair.lombok" + id 'org.gradle.crypto.checksum' + id 'org.springdoc.openapi-gradle-plugin' +} + +group = 'run.halo.app' +compileJava.options.encoding = 'UTF-8' +compileTestJava.options.encoding = 'UTF-8' + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +idea { + module { + resourceDirs += file("../ui/build/dist/") + } +} + +checkstyle { + toolVersion = "9.3" + showViolations = false + ignoreFailures = false +} + +repositories { + mavenCentral() + + maven { url 'https://repo.spring.io/milestone' } + maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots' } +} + + +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' + } +} + +tasks.named('jar') { + enabled = false +} + +dependencies { + implementation project(':api') + + 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.security:spring-security-test' + testImplementation 'io.projectreactor:reactor-test' +} + +tasks.register('createChecksums', Checksum) { + dependsOn tasks.named('bootJar') + inputFiles.setFrom(layout.buildDirectory.files('libs')) + outputDirectory = layout.buildDirectory.dir("libs") + checksumAlgorithm = Checksum.Algorithm.SHA256 +} + +tasks.named('processResources', ProcessResources) { + from project(':ui').layout.buildDirectory.dir('dist') + into layout.buildDirectory.dir('resources/main') + configure { + mustRunAfter project(':ui').tasks.named('build') + } +} + +tasks.named('build') { + dependsOn tasks.named('createChecksums') +} + +tasks.named('test', Test) { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +tasks.named('jacocoTestReport', JacocoReport) { + reports { + xml.required = true + html.required = false + } +} + +ext.presetPluginUrls = [ + 'https://github.com/halo-dev/plugin-comment-widget/releases/download/v2.4.0/plugin-comment-widget-2.4.0.jar' : 'plugin-comment-widget.jar', + 'https://github.com/halo-dev/plugin-search-widget/releases/download/v1.5.0/plugin-search-widget-1.5.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.3.0/plugin-feed-1.3.0.jar' : 'plugin-feed.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 + 'https://www.halo.run/store/apps/app-VYJbF/releases/download/app-release-SRWfv/assets/app-release-SRWfv-VLtMA': 'appstore.jar', +] + +tasks.register('downloadPluginPresets', Download) { + doFirst { + delete 'src/main/resources/presets/plugins' + } + + src presetPluginUrls.keySet() + + dest 'src/main/resources/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 + } +} diff --git a/application/src/main/java/run/halo/app/Application.java b/application/src/main/java/run/halo/app/Application.java new file mode 100644 index 0000000..6871d91 --- /dev/null +++ b/application/src/main/java/run/halo/app/Application.java @@ -0,0 +1,31 @@ +package run.halo.app; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.integration.IntegrationAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; +import run.halo.app.infra.properties.HaloProperties; + +/** + * Halo main class. + * + * @author ryanwang + * @author JohnNiang + * @author guqing + * @date 2017-11-14 + */ +@EnableScheduling +@SpringBootApplication(scanBasePackages = "run.halo.app", exclude = + IntegrationAutoConfiguration.class) +@EnableConfigurationProperties({HaloProperties.class}) +public class Application { + + public static void main(String[] args) { + new SpringApplicationBuilder(Application.class) + .applicationStartup(new BufferingApplicationStartup(1024)) + .run(args); + } + +} diff --git a/application/src/main/java/run/halo/app/actuator/DatabaseInfoContributor.java b/application/src/main/java/run/halo/app/actuator/DatabaseInfoContributor.java new file mode 100644 index 0000000..e0aa0b5 --- /dev/null +++ b/application/src/main/java/run/halo/app/actuator/DatabaseInfoContributor.java @@ -0,0 +1,48 @@ +package run.halo.app.actuator; + +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionMetadata; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import org.springframework.boot.actuate.info.Info; +import org.springframework.boot.actuate.info.InfoContributor; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Component +public class DatabaseInfoContributor implements InfoContributor { + private static final String DATABASE_INFO_KEY = "database"; + + private final ConnectionFactory connectionFactory; + + public DatabaseInfoContributor(ConnectionFactory connectionFactory) { + this.connectionFactory = connectionFactory; + } + + @Override + public void contribute(Info.Builder builder) { + builder.withDetail(DATABASE_INFO_KEY, contributorMap()); + } + + public Map contributorMap() { + var map = new HashMap(); + var connectionMetadata = getConnectionMetadata().block(); + if (Objects.isNull(connectionMetadata)) { + return map; + } + map.put("name", connectionMetadata.getDatabaseProductName()); + map.put("version", connectionMetadata.getDatabaseVersion()); + return map; + } + + private Mono getConnectionMetadata() { + return Mono.usingWhen(this.connectionFactory.create(), + conn -> Mono.just(conn.getMetadata()), + Connection::close, + (conn, t) -> conn.close(), + Connection::close + ); + } +} diff --git a/application/src/main/java/run/halo/app/actuator/GlobalInfoEndpoint.java b/application/src/main/java/run/halo/app/actuator/GlobalInfoEndpoint.java new file mode 100644 index 0000000..36ede07 --- /dev/null +++ b/application/src/main/java/run/halo/app/actuator/GlobalInfoEndpoint.java @@ -0,0 +1,171 @@ +package run.halo.app.actuator; + +import static org.apache.commons.lang3.BooleanUtils.isTrue; + +import java.net.URL; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpoint; +import org.springframework.stereotype.Component; +import run.halo.app.extension.ConfigMap; +import run.halo.app.infra.InitializationStateGetter; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.SystemSetting.Basic; +import run.halo.app.infra.SystemSetting.Comment; +import run.halo.app.infra.SystemSetting.User; +import run.halo.app.infra.properties.HaloProperties; +import run.halo.app.security.AuthProviderService; + +@WebEndpoint(id = "globalinfo") +@Component +@RequiredArgsConstructor +public class GlobalInfoEndpoint { + + private final ObjectProvider systemConfigFetcher; + + private final HaloProperties haloProperties; + + private final AuthProviderService authProviderService; + + private final InitializationStateGetter initializationStateGetter; + + @ReadOperation + public GlobalInfo globalInfo() { + final var info = new GlobalInfo(); + info.setExternalUrl(haloProperties.getExternalUrl()); + info.setUseAbsolutePermalink(haloProperties.isUseAbsolutePermalink()); + info.setLocale(Locale.getDefault()); + info.setTimeZone(TimeZone.getDefault()); + info.setUserInitialized(initializationStateGetter.userInitialized() + .blockOptional().orElse(false)); + info.setDataInitialized(initializationStateGetter.dataInitialized() + .blockOptional().orElse(false)); + handleSocialAuthProvider(info); + systemConfigFetcher.ifAvailable(fetcher -> fetcher.getConfigMapBlocking() + .ifPresent(configMap -> { + handleCommentSetting(info, configMap); + handleUserSetting(info, configMap); + handleBasicSetting(info, configMap); + handlePostSlugGenerationStrategy(info, configMap); + })); + return info; + } + + @Data + public static class GlobalInfo { + private URL externalUrl; + + private boolean useAbsolutePermalink; + + private TimeZone timeZone; + + private Locale locale; + + private boolean allowComments; + + private boolean allowAnonymousComments; + + private boolean allowRegistration; + + private String favicon; + + private boolean userInitialized; + + private boolean dataInitialized; + + private String postSlugGenerationStrategy; + + private List socialAuthProviders; + + private Boolean mustVerifyEmailOnRegistration; + + private String siteTitle; + } + + @Data + public static class SocialAuthProvider { + private String name; + + private String displayName; + + private String description; + + private String logo; + + private String website; + + private String authenticationUrl; + + private String bindingUrl; + } + + private void handleCommentSetting(GlobalInfo info, ConfigMap configMap) { + var comment = SystemSetting.get(configMap, Comment.GROUP, Comment.class); + if (comment == null) { + info.setAllowComments(true); + info.setAllowAnonymousComments(true); + } else { + info.setAllowComments(comment.getEnable() != null && comment.getEnable()); + info.setAllowAnonymousComments( + comment.getSystemUserOnly() == null || !comment.getSystemUserOnly()); + } + } + + private void handleUserSetting(GlobalInfo info, ConfigMap configMap) { + var userSetting = SystemSetting.get(configMap, User.GROUP, User.class); + if (userSetting == null) { + info.setAllowRegistration(false); + info.setMustVerifyEmailOnRegistration(false); + } else { + info.setAllowRegistration( + userSetting.getAllowRegistration() != null && userSetting.getAllowRegistration()); + info.setMustVerifyEmailOnRegistration(userSetting.getMustVerifyEmailOnRegistration()); + } + } + + private void handlePostSlugGenerationStrategy(GlobalInfo info, ConfigMap configMap) { + var post = SystemSetting.get(configMap, SystemSetting.Post.GROUP, SystemSetting.Post.class); + if (post != null) { + info.setPostSlugGenerationStrategy(post.getSlugGenerationStrategy()); + } + } + + private void handleBasicSetting(GlobalInfo info, ConfigMap configMap) { + var basic = SystemSetting.get(configMap, Basic.GROUP, Basic.class); + if (basic != null) { + info.setFavicon(basic.getFavicon()); + info.setSiteTitle(basic.getTitle()); + } + } + + private void handleSocialAuthProvider(GlobalInfo info) { + List providers = authProviderService.listAll() + .map(listedAuthProviders -> listedAuthProviders.stream() + .filter(provider -> isTrue(provider.getEnabled())) + .filter(provider -> StringUtils.isNotBlank(provider.getBindingUrl())) + .map(provider -> { + SocialAuthProvider socialAuthProvider = new SocialAuthProvider(); + socialAuthProvider.setName(provider.getName()); + socialAuthProvider.setDisplayName(provider.getDisplayName()); + socialAuthProvider.setDescription(provider.getDescription()); + socialAuthProvider.setLogo(provider.getLogo()); + socialAuthProvider.setWebsite(provider.getWebsite()); + socialAuthProvider.setAuthenticationUrl(provider.getAuthenticationUrl()); + socialAuthProvider.setBindingUrl(provider.getBindingUrl()); + return socialAuthProvider; + }) + .toList() + ) + .block(); + + info.setSocialAuthProviders(providers); + } + +} diff --git a/application/src/main/java/run/halo/app/actuator/RestartEndpoint.java b/application/src/main/java/run/halo/app/actuator/RestartEndpoint.java new file mode 100644 index 0000000..02289ee --- /dev/null +++ b/application/src/main/java/run/halo/app/actuator/RestartEndpoint.java @@ -0,0 +1,77 @@ +package run.halo.app.actuator; + +import java.io.Closeable; +import java.io.IOException; +import java.util.Collections; +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 run.halo.app.Application; + +@WebEndpoint(id = "restart") +@Component +@Slf4j +public class RestartEndpoint implements ApplicationListener { + + private SpringApplication application; + + private String[] args; + + private ConfigurableApplicationContext context; + + @WriteOperation + public Object restart() { + var threadGroup = new ThreadGroup("RestartGroup"); + var thread = new Thread(threadGroup, this::doRestart, "restartMain"); + thread.setDaemon(false); + thread.setContextClassLoader(Application.class.getClassLoader()); + thread.start(); + return Collections.singletonMap("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(); + } + } +} diff --git a/application/src/main/java/run/halo/app/config/ExtensionConfiguration.java b/application/src/main/java/run/halo/app/config/ExtensionConfiguration.java new file mode 100644 index 0000000..b9133cd --- /dev/null +++ b/application/src/main/java/run/halo/app/config/ExtensionConfiguration.java @@ -0,0 +1,37 @@ +package run.halo.app.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerResponse; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.SchemeManager; +import run.halo.app.extension.SchemeWatcherManager; +import run.halo.app.extension.controller.DefaultControllerManager; +import run.halo.app.extension.router.ExtensionCompositeRouterFunction; + +@Configuration(proxyBeanMethods = false) +public class ExtensionConfiguration { + + @Bean + RouterFunction extensionsRouterFunction(ReactiveExtensionClient client, + SchemeWatcherManager watcherManager, SchemeManager schemeManager) { + return new ExtensionCompositeRouterFunction(client, watcherManager, schemeManager); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(name = "halo.extension.controller.disabled", + havingValue = "false", + matchIfMissing = true) + static class ExtensionControllerConfiguration { + + @Bean + DefaultControllerManager controllerManager(ExtensionClient client) { + return new DefaultControllerManager(client); + } + + } + +} diff --git a/application/src/main/java/run/halo/app/config/HaloConfiguration.java b/application/src/main/java/run/halo/app/config/HaloConfiguration.java new file mode 100644 index 0000000..b3fa4a0 --- /dev/null +++ b/application/src/main/java/run/halo/app/config/HaloConfiguration.java @@ -0,0 +1,37 @@ +package run.halo.app.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.MapperFeature; +import java.io.IOException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +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; + +@EnableCaching +@Configuration(proxyBeanMethods = false) +@EnableAsync +public class HaloConfiguration { + + @Bean + Jackson2ObjectMapperBuilderCustomizer objectMapperCustomizer() { + return builder -> { + builder.serializationInclusion(JsonInclude.Include.NON_NULL); + builder.featuresToEnable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS); + }; + } + + @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")); + } +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/config/SwaggerConfig.java b/application/src/main/java/run/halo/app/config/SwaggerConfig.java new file mode 100644 index 0000000..5cd98b3 --- /dev/null +++ b/application/src/main/java/run/halo/app/config/SwaggerConfig.java @@ -0,0 +1,151 @@ +package run.halo.app.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/**" + ) + .build(); + } + + + @Bean + GroupedOpenApi publicV1alpha1Api() { + return GroupedOpenApi.builder() + .group("apis_public.api_v1alpha1") + .displayName("Public API V1alpha1") + .pathsToMatch( + "/apis/api.halo.run/**" + ) + .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/**" + ) + .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/**" + ) + .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("\\$", "."); + } + } + +} diff --git a/application/src/main/java/run/halo/app/config/WebFluxConfig.java b/application/src/main/java/run/halo/app/config/WebFluxConfig.java new file mode 100644 index 0000000..1a864ca --- /dev/null +++ b/application/src/main/java/run/halo/app/config/WebFluxConfig.java @@ -0,0 +1,239 @@ +package run.halo.app.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.method; +import static org.springframework.web.reactive.function.server.RequestPredicates.path; +import static org.springframework.web.reactive.function.server.RouterFunctions.route; +import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Objects; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.web.WebProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpMethod; +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.Jackson2JsonDecoder; +import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.lang.NonNull; +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.BodyInserters; +import org.springframework.web.reactive.function.server.RouterFunction; +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.view.ViewResolutionResultHandler; +import org.springframework.web.reactive.result.view.ViewResolver; +import reactor.core.publisher.Mono; +import run.halo.app.console.ProxyFilter; +import run.halo.app.console.WebSocketRequestPredicate; +import run.halo.app.core.endpoint.WebSocketHandlerMapping; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.core.extension.endpoint.CustomEndpointsBuilder; +import run.halo.app.infra.properties.AttachmentProperties; +import run.halo.app.infra.properties.HaloProperties; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; +import run.halo.app.webfilter.AdditionalWebFilterChainProxy; + +@Configuration +public class WebFluxConfig implements WebFluxConfigurer { + + private final ObjectMapper objectMapper; + + private final HaloProperties haloProp; + + private final WebProperties.Resources resourceProperties; + + private final ApplicationContext applicationContext; + + public WebFluxConfig(ObjectMapper objectMapper, + HaloProperties haloProp, + WebProperties webProperties, + ApplicationContext applicationContext) { + this.objectMapper = objectMapper; + this.haloProp = haloProp; + this.resourceProperties = webProperties.getResources(); + this.applicationContext = applicationContext; + } + + @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().jackson2JsonDecoder(new Jackson2JsonDecoder(objectMapper)); + configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(objectMapper)); + } + + @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 consoleIndexRedirection() { + var consolePredicate = method(HttpMethod.GET) + .and(path("/console/**").and(path("/console/assets/**").negate())) + .and(accept(MediaType.TEXT_HTML)) + .and(new WebSocketRequestPredicate().negate()); + return route(consolePredicate, + request -> this.serveIndex(haloProp.getConsole().getLocation() + "index.html")); + } + + @Bean + RouterFunction ucIndexRedirect() { + var consolePredicate = method(HttpMethod.GET) + .and(path("/uc/**").and(path("/uc/assets/**").negate())) + .and(accept(MediaType.TEXT_HTML)) + .and(new WebSocketRequestPredicate().negate()); + return route(consolePredicate, + request -> this.serveIndex(haloProp.getUc().getLocation() + "index.html")); + } + + private Mono serveIndex(String indexLocation) { + var indexResource = applicationContext.getResource(indexLocation); + try { + return ServerResponse.ok() + .cacheControl(CacheControl.noStore()) + .body(BodyInserters.fromResource(indexResource)); + } catch (Throwable e) { + return Mono.error(e); + } + } + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + var attachmentsRoot = haloProp.getWorkDir().resolve("attachments"); + 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); + + // For console assets + registry.addResourceHandler("/console/assets/**") + .addResourceLocations(haloProp.getConsole().getLocation() + "assets/") + .setCacheControl(cacheControl) + .setUseLastModified(useLastModified) + .resourceChain(true) + .addResolver(new EncodedResourceResolver()) + .addResolver(new PathResourceResolver()); + + // For uc assets + registry.addResourceHandler("/uc/assets/**") + .addResourceLocations(haloProp.getUc().getLocation() + "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 + "/"); + } + } + + 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()); + } + + + @ConditionalOnProperty(name = "halo.console.proxy.enabled", havingValue = "true") + @Bean + ProxyFilter consoleProxyFilter() { + return new ProxyFilter("/console/**", haloProp.getConsole().getProxy()); + } + + + @ConditionalOnProperty(name = "halo.uc.proxy.enabled", havingValue = "true") + @Bean + ProxyFilter ucProxyFilter() { + return new ProxyFilter("/uc/**", haloProp.getUc().getProxy()); + } + + /** + * 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); + } + +} diff --git a/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java b/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java new file mode 100644 index 0000000..06c1917 --- /dev/null +++ b/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java @@ -0,0 +1,153 @@ +package run.halo.app.config; + +import static org.springframework.security.config.Customizer.withDefaults; +import static org.springframework.security.web.server.authentication.ServerWebExchangeDelegatingReactiveAuthenticationManagerResolver.builder; +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; + +import java.util.concurrent.ConcurrentHashMap; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.session.SessionProperties; +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +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.session.MapSession; +import org.springframework.session.config.annotation.web.server.EnableSpringWebSession; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerResponse; +import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.extension.service.UserService; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.AnonymousUserConst; +import run.halo.app.infra.properties.HaloProperties; +import run.halo.app.security.DefaultUserDetailService; +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.authentication.login.PublicKeyRouteBuilder; +import run.halo.app.security.authentication.pat.PatAuthenticationManager; +import run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher; +import run.halo.app.security.authentication.twofactor.TwoFactorAuthorizationManager; +import run.halo.app.security.authorization.RequestInfoAuthorizationManager; +import run.halo.app.security.session.InMemoryReactiveIndexedSessionRepository; +import run.halo.app.security.session.ReactiveIndexedSessionRepository; + +/** + * Security configuration for WebFlux. + * + * @author johnniang + */ +@Configuration +@EnableSpringWebSession +@EnableWebFluxSecurity +@RequiredArgsConstructor +public class WebServerSecurityConfig { + + @Bean + SecurityWebFilterChain filterChain(ServerHttpSecurity http, + RoleService roleService, + ObjectProvider securityConfigurers, + ServerSecurityContextRepository securityContextRepository, + ReactiveExtensionClient client, + CryptoService cryptoService, + HaloProperties haloProperties) { + + http.securityMatcher(pathMatchers("/**")) + .authorizeExchange(spec -> spec.pathMatchers( + "/api/**", + "/apis/**", + "/oauth2/**", + "/login/**", + "/logout", + "/actuator/**" + ) + .access( + new TwoFactorAuthorizationManager( + new RequestInfoAuthorizationManager(roleService) + ) + ) + .anyExchange().permitAll()) + .anonymous(spec -> { + spec.authorities(AnonymousUserConst.Role); + spec.principal(AnonymousUserConst.PRINCIPAL); + }) + .securityContextRepository(securityContextRepository) + .httpBasic(withDefaults()) + .oauth2ResourceServer(oauth2 -> { + var authManagerResolver = builder().add( + new PatServerWebExchangeMatcher(), + new PatAuthenticationManager(client, cryptoService) + ) + // TODO Add other authentication mangers here. e.g.: JwtAuthenticationManager. + .build(); + oauth2.authenticationManagerResolver(authManagerResolver); + }) + .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)) + ); + + // Integrate with other configurers separately + securityConfigurers.orderedStream() + .forEach(securityConfigurer -> securityConfigurer.configure(http)); + return http.build(); + } + + @Bean + ServerSecurityContextRepository securityContextRepository() { + return new WebSessionServerSecurityContextRepository(); + } + + @Bean + public ReactiveIndexedSessionRepository reactiveSessionRepository( + SessionProperties sessionProperties, + ServerProperties serverProperties) { + var repository = new InMemoryReactiveIndexedSessionRepository(new ConcurrentHashMap<>()); + var timeout = sessionProperties.determineTimeout( + () -> serverProperties.getReactive().getSession().getTimeout()); + repository.setDefaultMaxInactiveInterval(timeout); + return repository; + } + + @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 + PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + @Bean + RouterFunction publicKeyRoute(CryptoService cryptoService) { + return new PublicKeyRouteBuilder(cryptoService).build(); + } + + @Bean + CryptoService cryptoService(HaloProperties haloProperties) { + return new RsaKeyService(haloProperties.getWorkDir().resolve("keys")); + } + +} diff --git a/application/src/main/java/run/halo/app/console/ProxyFilter.java b/application/src/main/java/run/halo/app/console/ProxyFilter.java new file mode 100644 index 0000000..854d810 --- /dev/null +++ b/application/src/main/java/run/halo/app/console/ProxyFilter.java @@ -0,0 +1,71 @@ +package run.halo.app.console; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpMethod; +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 consoleMatcher; + + private final WebClient webClient; + + public ProxyFilter(String pattern, ProxyProperties proxyProperties) { + this.proxyProperties = proxyProperties; + var consoleMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, pattern); + consoleMatcher = new AndServerWebExchangeMatcher(consoleMatcher, + new NegatedServerWebExchangeMatcher(new WebSocketServerWebExchangeMatcher())); + this.consoleMatcher = consoleMatcher; + this.webClient = WebClient.create(proxyProperties.getEndpoint().toString()); + log.debug("Initialized ProxyFilter to proxy {} to endpoint {}", pattern, + proxyProperties.getEndpoint()); + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return consoleMatcher.matches(exchange) + .filter(ServerWebExchangeMatcher.MatchResult::isMatch) + .switchIfEmpty(chain.filter(exchange).then(Mono.empty())) + .map(matchResult -> { + var request = exchange.getRequest(); + return UriComponentsBuilder.fromUriString( + request.getPath().pathWithinApplication().value()) + .queryParams(request.getQueryParams()) + .build() + .toUriString(); + }) + .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(); + // set headers + response.getHeaders().putAll(clientResponse.headers().asHttpHeaders()); + // set cookies + response.getCookies().putAll(clientResponse.cookies()); + // set status code + response.setStatusCode(clientResponse.statusCode()); + var body = clientResponse.bodyToFlux(DataBuffer.class); + return exchange.getResponse().writeWith(body); + })); + } +} diff --git a/application/src/main/java/run/halo/app/console/WebSocketRequestPredicate.java b/application/src/main/java/run/halo/app/console/WebSocketRequestPredicate.java new file mode 100644 index 0000000..647f98e --- /dev/null +++ b/application/src/main/java/run/halo/app/console/WebSocketRequestPredicate.java @@ -0,0 +1,15 @@ +package run.halo.app.console; + +import static run.halo.app.console.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); + } +} diff --git a/application/src/main/java/run/halo/app/console/WebSocketServerWebExchangeMatcher.java b/application/src/main/java/run/halo/app/console/WebSocketServerWebExchangeMatcher.java new file mode 100644 index 0000000..17bdbde --- /dev/null +++ b/application/src/main/java/run/halo/app/console/WebSocketServerWebExchangeMatcher.java @@ -0,0 +1,16 @@ +package run.halo.app.console; + +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.console.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(); + } +} diff --git a/application/src/main/java/run/halo/app/console/WebSocketUtils.java b/application/src/main/java/run/halo/app/console/WebSocketUtils.java new file mode 100644 index 0000000..29ba096 --- /dev/null +++ b/application/src/main/java/run/halo/app/console/WebSocketUtils.java @@ -0,0 +1,20 @@ +package run.halo.app.console; + +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.containsKey(HttpHeaders.UPGRADE) + && upgradeConnection + && "websocket".equalsIgnoreCase(headers.getUpgrade()); + } + +} diff --git a/application/src/main/java/run/halo/app/content/AbstractContentService.java b/application/src/main/java/run/halo/app/content/AbstractContentService.java new file mode 100644 index 0000000..f5c4746 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/AbstractContentService.java @@ -0,0 +1,194 @@ +package run.halo.app.content; + +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.isNull; + +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.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 = and(isNull("metadata.deletionTimestamp"), + 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); + } +} diff --git a/application/src/main/java/run/halo/app/content/AbstractEventReconciler.java b/application/src/main/java/run/halo/app/content/AbstractEventReconciler.java new file mode 100644 index 0000000..1a34719 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/AbstractEventReconciler.java @@ -0,0 +1,62 @@ +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; + +/** + * 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; + } +} diff --git a/application/src/main/java/run/halo/app/content/CategoryPostCountUpdater.java b/application/src/main/java/run/halo/app/content/CategoryPostCountUpdater.java new file mode 100644 index 0000000..0c7c714 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/CategoryPostCountUpdater.java @@ -0,0 +1,163 @@ +package run.halo.app.content; + +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.isNull; + +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.Query; +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 = and(basePostQuery(categoryName), + 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 Query basePostQuery(String categoryName) { + return and(isNull("metadata.deletionTimestamp"), + equal("spec.deleted", BooleanUtils.FALSE), + 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()); + } +} diff --git a/application/src/main/java/run/halo/app/content/CategoryService.java b/application/src/main/java/run/halo/app/content/CategoryService.java new file mode 100644 index 0000000..36f14e5 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/CategoryService.java @@ -0,0 +1,15 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/content/Content.java b/application/src/main/java/run/halo/app/content/Content.java new file mode 100644 index 0000000..b9e3177 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/Content.java @@ -0,0 +1,4 @@ +package run.halo.app.content; + +public record Content(String raw, String content, String rawType) { +} diff --git a/application/src/main/java/run/halo/app/content/ContentRequest.java b/application/src/main/java/run/halo/app/content/ContentRequest.java new file mode 100644 index 0000000..95b7bc2 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/ContentRequest.java @@ -0,0 +1,53 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/content/ContentUpdateParam.java b/application/src/main/java/run/halo/app/content/ContentUpdateParam.java new file mode 100644 index 0000000..0eeb146 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/ContentUpdateParam.java @@ -0,0 +1,9 @@ +package run.halo.app.content; + +public record ContentUpdateParam(Long version, String raw, String content, String rawType) { + + public static ContentUpdateParam from(Content content) { + return new ContentUpdateParam(null, content.raw(), content.content(), + content.rawType()); + } +} diff --git a/application/src/main/java/run/halo/app/content/Contributor.java b/application/src/main/java/run/halo/app/content/Contributor.java new file mode 100644 index 0000000..fb86fb4 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/Contributor.java @@ -0,0 +1,16 @@ +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; +} diff --git a/application/src/main/java/run/halo/app/content/ListedPost.java b/application/src/main/java/run/halo/app/content/ListedPost.java new file mode 100644 index 0000000..e873f33 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/ListedPost.java @@ -0,0 +1,41 @@ +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; +} diff --git a/application/src/main/java/run/halo/app/content/ListedSinglePage.java b/application/src/main/java/run/halo/app/content/ListedSinglePage.java new file mode 100644 index 0000000..4537761 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/ListedSinglePage.java @@ -0,0 +1,33 @@ +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; +} diff --git a/application/src/main/java/run/halo/app/content/ListedSnapshotDto.java b/application/src/main/java/run/halo/app/content/ListedSnapshotDto.java new file mode 100644 index 0000000..c0a2757 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/ListedSnapshotDto.java @@ -0,0 +1,42 @@ +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()) + ); + } +} diff --git a/application/src/main/java/run/halo/app/content/NotificationReasonConst.java b/application/src/main/java/run/halo/app/content/NotificationReasonConst.java new file mode 100644 index 0000000..dbc1f3b --- /dev/null +++ b/application/src/main/java/run/halo/app/content/NotificationReasonConst.java @@ -0,0 +1,14 @@ +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"; +} diff --git a/application/src/main/java/run/halo/app/content/PostContentServiceImpl.java b/application/src/main/java/run/halo/app/content/PostContentServiceImpl.java new file mode 100644 index 0000000..aad11d0 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/PostContentServiceImpl.java @@ -0,0 +1,58 @@ +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()); + } +} diff --git a/application/src/main/java/run/halo/app/content/PostHideFromListStateUpdater.java b/application/src/main/java/run/halo/app/content/PostHideFromListStateUpdater.java new file mode 100644 index 0000000..12bd88f --- /dev/null +++ b/application/src/main/java/run/halo/app/content/PostHideFromListStateUpdater.java @@ -0,0 +1,55 @@ +package run.halo.app.content; + +import static run.halo.app.extension.index.query.QueryFactory.equal; + +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; + +/** + * 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 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(); + return Result.doNotRetry(); + } + + @EventListener(CategoryHiddenStateChangeEvent.class) + public void onApplicationEvent(@NonNull CategoryHiddenStateChangeEvent event) { + this.queue.addImmediately(event); + } +} diff --git a/application/src/main/java/run/halo/app/content/PostQuery.java b/application/src/main/java/run/halo/app/content/PostQuery.java new file mode 100644 index 0000000..c6e73f2 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/PostQuery.java @@ -0,0 +1,157 @@ +package run.halo.app.content; + +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter; +import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToListOptions; + +import com.fasterxml.jackson.annotation.JsonIgnore; +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 org.apache.commons.lang3.BooleanUtils; +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 org.springframework.web.server.ServerWebExchange; +import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.endpoint.SortResolver; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.index.query.QueryFactory; +import run.halo.app.extension.router.IListRequest; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.extension.router.selector.LabelSelector; + +/** + * A query object for {@link Post} list. + * + * @author guqing + * @since 2.0.0 + */ +public class PostQuery extends IListRequest.QueryListRequest { + + private final ServerWebExchange exchange; + + private final String username; + + public PostQuery(ServerRequest request) { + this(request, null); + } + + public PostQuery(ServerRequest request, @Nullable String username) { + super(request.queryParams()); + this.exchange = request.exchange(); + this.username = username; + } + + @Schema(hidden = true) + @JsonIgnore + public String getUsername() { + return username; + } + + @Nullable + public Post.PostPhase getPublishPhase() { + String publishPhase = queryParams.getFirst("publishPhase"); + return Post.PostPhase.from(publishPhase); + } + + @Nullable + public String getCategoryWithChildren() { + var value = queryParams.getFirst("categoryWithChildren"); + return StringUtils.defaultIfBlank(value, null); + } + + @Nullable + @Schema(description = "Posts filtered by keyword.") + public String getKeyword() { + return StringUtils.defaultIfBlank(queryParams.getFirst("keyword"), null); + } + + @ArraySchema(uniqueItems = true, + arraySchema = @Schema(name = "sort", + description = "Sort property and direction of the list result. Supported fields: " + + "creationTimestamp,publishTime"), + schema = @Schema(description = "like field,asc or field,desc", + implementation = String.class, + example = "creationTimestamp,desc")) + public Sort getSort() { + var sort = SortResolver.defaultInstance.resolve(exchange); + sort = sort.and(Sort.by(Sort.Direction.DESC, "metadata.creationTimestamp")); + sort = sort.and(Sort.by(Sort.Direction.DESC, "metadata.name")); + return sort; + } + + /** + * Build a list options from the query object. + * + * @return a list options + */ + public ListOptions toListOptions() { + var listOptions = + labelAndFieldSelectorToListOptions(getLabelSelector(), getFieldSelector()); + if (listOptions.getFieldSelector() == null) { + listOptions.setFieldSelector(FieldSelector.all()); + } + var labelSelectorBuilder = LabelSelector.builder(); + var fieldQuery = QueryFactory.all(); + + String keyword = getKeyword(); + if (keyword != null) { + fieldQuery = QueryFactory.and(fieldQuery, QueryFactory.or( + QueryFactory.contains("status.excerpt", keyword), + QueryFactory.contains("spec.slug", keyword), + QueryFactory.contains("spec.title", keyword) + )); + } + + Post.PostPhase publishPhase = getPublishPhase(); + if (publishPhase != null) { + if (Post.PostPhase.PENDING_APPROVAL.equals(publishPhase)) { + fieldQuery = QueryFactory.and(fieldQuery, QueryFactory.equal( + "status.phase", Post.PostPhase.PENDING_APPROVAL.name()) + ); + labelSelectorBuilder.eq(Post.PUBLISHED_LABEL, BooleanUtils.FALSE); + } else if (Post.PostPhase.PUBLISHED.equals(publishPhase)) { + labelSelectorBuilder.eq(Post.PUBLISHED_LABEL, BooleanUtils.TRUE); + } else { + labelSelectorBuilder.eq(Post.PUBLISHED_LABEL, BooleanUtils.FALSE); + } + } + + if (StringUtils.isNotBlank(username)) { + fieldQuery = QueryFactory.and(fieldQuery, QueryFactory.equal( + "spec.owner", username) + ); + } + + listOptions.setFieldSelector(listOptions.getFieldSelector().andQuery(fieldQuery)); + listOptions.setLabelSelector( + listOptions.getLabelSelector().and(labelSelectorBuilder.build())); + return listOptions; + } + + 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)); + } +} diff --git a/application/src/main/java/run/halo/app/content/PostRequest.java b/application/src/main/java/run/halo/app/content/PostRequest.java new file mode 100644 index 0000000..9da438c --- /dev/null +++ b/application/src/main/java/run/halo/app/content/PostRequest.java @@ -0,0 +1,25 @@ +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, + 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()); + } + +} diff --git a/application/src/main/java/run/halo/app/content/PostService.java b/application/src/main/java/run/halo/app/content/PostService.java new file mode 100644 index 0000000..cf4c0b4 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/PostService.java @@ -0,0 +1,53 @@ +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.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); +} diff --git a/application/src/main/java/run/halo/app/content/PostSorter.java b/application/src/main/java/run/halo/app/content/PostSorter.java new file mode 100644 index 0000000..a290b32 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/PostSorter.java @@ -0,0 +1,78 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/content/SinglePageQuery.java b/application/src/main/java/run/halo/app/content/SinglePageQuery.java new file mode 100644 index 0000000..851333b --- /dev/null +++ b/application/src/main/java/run/halo/app/content/SinglePageQuery.java @@ -0,0 +1,208 @@ +package run.halo.app.content; + +import static java.util.Comparator.comparing; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter; +import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate; + +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.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; +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 org.springframework.web.server.ServerWebExchange; +import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.content.SinglePage; +import run.halo.app.core.extension.endpoint.SortResolver; +import run.halo.app.extension.Comparators; +import run.halo.app.extension.router.IListRequest; + +/** + * Query parameter for {@link SinglePage} list. + * + * @author guqing + * @since 2.0.0 + */ +public class SinglePageQuery extends IListRequest.QueryListRequest { + + private final ServerWebExchange exchange; + + public SinglePageQuery(ServerRequest request) { + super(request.queryParams()); + this.exchange = request.exchange(); + } + + @Nullable + @Schema(name = "contributor") + public Set getContributors() { + List contributorList = queryParams.get("contributor"); + return contributorList == null ? null : Set.copyOf(contributorList); + } + + @Nullable + public Post.PostPhase getPublishPhase() { + String publishPhase = queryParams.getFirst("publishPhase"); + return Post.PostPhase.from(publishPhase); + } + + @Nullable + public Post.VisibleEnum getVisible() { + String visible = queryParams.getFirst("visible"); + return Post.VisibleEnum.from(visible); + } + + @Nullable + @Schema(description = "SinglePages filtered by keyword.") + public String getKeyword() { + return StringUtils.defaultIfBlank(queryParams.getFirst("keyword"), null); + } + + @ArraySchema(uniqueItems = true, + arraySchema = @Schema(name = "sort", + description = "Sort property and direction of the list result. Supported fields: " + + "creationTimestamp,publishTime"), + schema = @Schema(description = "like field,asc or field,desc", + implementation = String.class, + example = "creationTimestamp,desc")) + public Sort getSort() { + return SortResolver.defaultInstance.resolve(exchange); + } + + /** + * Build a comparator for {@link SinglePageQuery}. + * + * @return comparator + */ + public Comparator toComparator() { + var sort = getSort(); + var creationTimestampOrder = sort.getOrderFor("creationTimestamp"); + List> comparators = new ArrayList<>(); + if (creationTimestampOrder != null) { + Comparator comparator = + comparing(page -> page.getMetadata().getCreationTimestamp()); + if (creationTimestampOrder.isDescending()) { + comparator = comparator.reversed(); + } + comparators.add(comparator); + } + + var publishTimeOrder = sort.getOrderFor("publishTime"); + if (publishTimeOrder != null) { + Comparator nullsComparator = publishTimeOrder.isAscending() + ? org.springframework.util.comparator.Comparators.nullsLow() + : org.springframework.util.comparator.Comparators.nullsHigh(); + Comparator comparator = + comparing(page -> page.getSpec().getPublishTime(), nullsComparator); + if (publishTimeOrder.isDescending()) { + comparator = comparator.reversed(); + } + comparators.add(comparator); + } + comparators.add(Comparators.compareCreationTimestamp(false)); + comparators.add(Comparators.compareName(true)); + return comparators.stream() + .reduce(Comparator::thenComparing) + .orElse(null); + } + + /** + * Build a predicate for {@link SinglePageQuery}. + * + * @return predicate + */ + public Predicate toPredicate() { + Predicate paramPredicate = singlePage -> contains(getContributors(), + singlePage.getStatusOrDefault().getContributors()); + + String keyword = getKeyword(); + if (keyword != null) { + paramPredicate = paramPredicate.and(page -> { + String excerpt = page.getStatusOrDefault().getExcerpt(); + return StringUtils.containsIgnoreCase(excerpt, keyword) + || StringUtils.containsIgnoreCase(page.getSpec().getSlug(), keyword) + || StringUtils.containsIgnoreCase(page.getSpec().getTitle(), keyword); + }); + } + + Post.PostPhase publishPhase = getPublishPhase(); + if (publishPhase != null) { + paramPredicate = paramPredicate.and(page -> { + if (Post.PostPhase.PENDING_APPROVAL.equals(publishPhase)) { + return !page.isPublished() + && Post.PostPhase.PENDING_APPROVAL.name() + .equalsIgnoreCase(page.getStatusOrDefault().getPhase()); + } + // published + if (Post.PostPhase.PUBLISHED.equals(publishPhase)) { + return page.isPublished(); + } + // draft + return !page.isPublished(); + }); + } + + Post.VisibleEnum visible = getVisible(); + if (visible != null) { + paramPredicate = + paramPredicate.and(post -> visible.equals(post.getSpec().getVisible())); + } + + Predicate predicate = labelAndFieldSelectorToPredicate(getLabelSelector(), + getFieldSelector()); + return predicate.and(paramPredicate); + } + + boolean contains(Collection left, List right) { + // parameter is null, it means that ignore this condition + if (left == null) { + return true; + } + // else, it means that right is empty + if (left.isEmpty()) { + return right.isEmpty(); + } + if (right == null) { + return false; + } + return right.stream().anyMatch(left::contains); + } + + 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)); + ; + } +} diff --git a/application/src/main/java/run/halo/app/content/SinglePageRequest.java b/application/src/main/java/run/halo/app/content/SinglePageRequest.java new file mode 100644 index 0000000..8e93adf --- /dev/null +++ b/application/src/main/java/run/halo/app/content/SinglePageRequest.java @@ -0,0 +1,24 @@ +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()); + } + +} diff --git a/application/src/main/java/run/halo/app/content/SinglePageService.java b/application/src/main/java/run/halo/app/content/SinglePageService.java new file mode 100644 index 0000000..fbaf0dc --- /dev/null +++ b/application/src/main/java/run/halo/app/content/SinglePageService.java @@ -0,0 +1,33 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/content/SnapshotService.java b/application/src/main/java/run/halo/app/content/SnapshotService.java new file mode 100644 index 0000000..d97e422 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/SnapshotService.java @@ -0,0 +1,22 @@ +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); + +} diff --git a/application/src/main/java/run/halo/app/content/Stats.java b/application/src/main/java/run/halo/app/content/Stats.java new file mode 100644 index 0000000..151f926 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/Stats.java @@ -0,0 +1,42 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/content/TagPostCountUpdater.java b/application/src/main/java/run/halo/app/content/TagPostCountUpdater.java new file mode 100644 index 0000000..481da45 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/TagPostCountUpdater.java @@ -0,0 +1,135 @@ +package run.halo.app.content; + +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.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.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); + }); + } +} diff --git a/application/src/main/java/run/halo/app/content/comment/CommentEmailOwner.java b/application/src/main/java/run/halo/app/content/comment/CommentEmailOwner.java new file mode 100644 index 0000000..5c0e5f9 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/CommentEmailOwner.java @@ -0,0 +1,43 @@ +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, StringUtils.EMPTY)); + + 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; + } +} diff --git a/application/src/main/java/run/halo/app/content/comment/CommentNotificationReasonPublisher.java b/application/src/main/java/run/halo/app/content/comment/CommentNotificationReasonPublisher.java new file mode 100644 index 0000000..3868482 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/CommentNotificationReasonPublisher.java @@ -0,0 +1,343 @@ +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.util.Map; +import java.util.Optional; +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import lombok.experimental.UtilityClass; +import org.apache.commons.lang3.StringUtils; +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.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 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); + } + + @Component + @RequiredArgsConstructor + static class NewCommentOnPostReasonPublisher { + + private final ExtensionClient client; + private final NotificationReasonEmitter notificationReasonEmitter; + private final ExternalLinkProcessor externalLinkProcessor; + + 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(comment.getSpec().getContent()) + .commentName(comment.getMetadata().getName()) + .build(); + builder.attributes(ReasonDataConverter.toAttributeMap(attributes)) + .author(identityFrom(owner)) + .subject(reasonSubject); + }).block(); + } + + 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; + + 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(comment.getSpec().getContent()) + .commentName(comment.getMetadata().getName()) + .build(); + builder.attributes(ReasonDataConverter.toAttributeMap(attributes)) + .author(identityFrom(owner)) + .subject(reasonSubject); + }).block(); + } + + 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; + + 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 -> 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(comment.getSpec().getContent()) + .isQuoteReply(isQuoteReply) + .quoteContent(quoteReplyContent) + .commentName(comment.getMetadata().getName()) + .replier(defaultIfBlank(replyOwner.getDisplayName(), replyOwner.getName())) + .content(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(); + } + + /** + * 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(); + } + + 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) { + } + } +} diff --git a/application/src/main/java/run/halo/app/content/comment/CommentQuery.java b/application/src/main/java/run/halo/app/content/comment/CommentQuery.java new file mode 100644 index 0000000..4caef11 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/CommentQuery.java @@ -0,0 +1,110 @@ +package run.halo.app.content.comment; + +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.contains; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToListOptions; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +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 org.springframework.web.server.ServerWebExchange; +import run.halo.app.core.extension.User; +import run.halo.app.core.extension.content.Comment; +import run.halo.app.core.extension.endpoint.SortResolver; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.PageRequest; +import run.halo.app.extension.PageRequestImpl; +import run.halo.app.extension.router.IListRequest; +import run.halo.app.extension.router.QueryParamBuildUtil; +import run.halo.app.extension.router.selector.FieldSelector; + +/** + * Query criteria for comment list. + * + * @author guqing + * @since 2.0.0 + */ +public class CommentQuery extends IListRequest.QueryListRequest { + + private final ServerWebExchange exchange; + + public CommentQuery(ServerRequest request) { + super(request.queryParams()); + this.exchange = request.exchange(); + } + + public String getKeyword() { + String keyword = queryParams.getFirst("keyword"); + return StringUtils.isBlank(keyword) ? null : keyword; + } + + public String getOwnerKind() { + String ownerKind = queryParams.getFirst("ownerKind"); + return StringUtils.isBlank(ownerKind) ? null : ownerKind; + } + + public String getOwnerName() { + String ownerName = queryParams.getFirst("ownerName"); + return StringUtils.isBlank(ownerName) ? null : ownerName; + } + + public Sort getSort() { + var sort = SortResolver.defaultInstance.resolve(exchange); + return sort.and(Sort.by("status.lastReplyTime", + "spec.creationTime", + "metadata.name" + ).descending()); + } + + public PageRequest toPageRequest() { + return PageRequestImpl.of(getPage(), getSize(), getSort()); + } + + /** + * Convert to list options. + */ + public ListOptions toListOptions() { + var listOptions = + labelAndFieldSelectorToListOptions(getLabelSelector(), getFieldSelector()); + var fieldQuery = listOptions.getFieldSelector().query(); + + String keyword = getKeyword(); + if (StringUtils.isNotBlank(keyword)) { + fieldQuery = and(fieldQuery, contains("spec.raw", keyword)); + } + + String ownerName = getOwnerName(); + if (StringUtils.isNotBlank(ownerName)) { + String ownerKind = StringUtils.defaultIfBlank(getOwnerKind(), User.KIND); + fieldQuery = and(fieldQuery, + equal("spec.owner", Comment.CommentOwner.ownerIdentity(ownerKind, ownerName))); + } + + listOptions.setFieldSelector(FieldSelector.of(fieldQuery)); + return listOptions; + } + + 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)); + } +} diff --git a/application/src/main/java/run/halo/app/content/comment/CommentRequest.java b/application/src/main/java/run/halo/app/content/comment/CommentRequest.java new file mode 100644 index 0000000..599cfca --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/CommentRequest.java @@ -0,0 +1,57 @@ +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; + + /** + * 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); + + if (owner != null) { + spec.setOwner(owner.toCommentOwner()); + } + return comment; + } +} diff --git a/application/src/main/java/run/halo/app/content/comment/CommentService.java b/application/src/main/java/run/halo/app/content/comment/CommentService.java new file mode 100644 index 0000000..c3a9012 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/CommentService.java @@ -0,0 +1,22 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java b/application/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java new file mode 100644 index 0000000..d32931c --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java @@ -0,0 +1,256 @@ +package run.halo.app.content.comment; + +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.isNull; + +import java.time.Duration; +import java.time.Instant; +import java.util.Set; +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.security.core.context.ReactiveSecurityContextHolder; +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.User; +import run.halo.app.core.extension.content.Comment; +import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.extension.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.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.exception.AccessDeniedException; +import run.halo.app.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; +import run.halo.app.security.authorization.AuthorityUtils; + +/** + * Comment service implementation. + * + * @author guqing + * @since 2.0.0 + */ +@Component +public class CommentServiceImpl implements CommentService { + + private final ReactiveExtensionClient client; + private final UserService userService; + private final RoleService roleService; + private final ExtensionGetter extensionGetter; + + private final SystemConfigurableEnvironmentFetcher environmentFetcher; + private final CounterService counterService; + + public CommentServiceImpl(ReactiveExtensionClient client, + UserService userService, + SystemConfigurableEnvironmentFetcher environmentFetcher, + CounterService counterService, RoleService roleService, + ExtensionGetter extensionGetter + ) { + this.client = client; + this.userService = userService; + this.environmentFetcher = environmentFetcher; + this.counterService = counterService; + this.roleService = roleService; + this.extensionGetter = extensionGetter; + } + + @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)) + .concatMap(Function.identity()) + .collectList() + .map(list -> new ListResult<>(comments.getPage(), comments.getSize(), + comments.getTotal(), list) + ) + ); + } + + @Override + public Mono create(Comment comment) { + 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()); + } + + comment.getSpec().setHidden(false); + + // return if the comment owner is not null + if (comment.getSpec().getOwner() != null) { + return Mono.just(comment); + } + // populate owner from current user + return fetchCurrentUser() + .flatMap(currentUser -> 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)) + .doOnNext(result -> { + if (result) { + comment.getSpec().setApproved(true); + comment.getSpec().setApprovedTime(Instant.now()); + } + }) + .thenReturn(toCommentOwner(currentUser)); + })) + .map(owner -> { + comment.getSpec().setOwner(owner); + return comment; + }) + .switchIfEmpty( + Mono.error(new IllegalStateException("The owner must not be null."))); + }) + .flatMap(client::create); + } + + @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 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; + } + + private Mono fetchCurrentUser() { + return ReactiveSecurityContextHolder.getContext() + .map(securityContext -> securityContext.getAuthentication().getName()) + .flatMap(username -> client.fetch(User.class, username)); + } + + private Mono toListedComment(Comment comment) { + var builder = ListedComment.builder().comment(comment); + // not empty + var ownerInfoMono = getCommentOwnerInfo(comment.getSpec().getOwner()) + .doOnNext(builder::owner); + var subjectMono = getCommentSubject(comment.getSpec().getSubjectRef()) + .doOnNext(builder::subject); + var statsMono = fetchStats(comment.getMetadata().getName()) + .doOnNext(builder::stats); + return Mono.when(ownerInfoMono, subjectMono, statsMono) + .then(Mono.fromSupplier(builder::build)); + } + + Mono fetchStats(String commentName) { + Assert.notNull(commentName, "The commentName must not be null."); + return counterService.getByName(MeterUtils.nameOf(Comment.class, commentName)) + .map(counter -> CommentStats.builder() + .upvote(counter.getUpvote()) + .build() + ) + .defaultIfEmpty(CommentStats.empty()); + } + + private Mono getCommentOwnerInfo(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)); + } + throw new IllegalStateException( + "Unsupported owner kind: " + owner.getKind()); + } + + @SuppressWarnings("unchecked") + Mono getCommentSubject(Ref ref) { + return extensionGetter.getExtensions(CommentSubject.class) + .filter(subject -> subject.supports(ref)) + .next() + .flatMap(subject -> subject.get(ref.getName())); + } +} diff --git a/application/src/main/java/run/halo/app/content/comment/CommentStats.java b/application/src/main/java/run/halo/app/content/comment/CommentStats.java new file mode 100644 index 0000000..7de0642 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/CommentStats.java @@ -0,0 +1,23 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/content/comment/ListedComment.java b/application/src/main/java/run/halo/app/content/comment/ListedComment.java new file mode 100644 index 0000000..eb5594d --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/ListedComment.java @@ -0,0 +1,31 @@ +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; +} diff --git a/application/src/main/java/run/halo/app/content/comment/ListedReply.java b/application/src/main/java/run/halo/app/content/comment/ListedReply.java new file mode 100644 index 0000000..01050c3 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/ListedReply.java @@ -0,0 +1,28 @@ +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; +} diff --git a/application/src/main/java/run/halo/app/content/comment/OwnerInfo.java b/application/src/main/java/run/halo/app/content/comment/OwnerInfo.java new file mode 100644 index 0000000..69f98e3 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/OwnerInfo.java @@ -0,0 +1,62 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/content/comment/PostCommentSubject.java b/application/src/main/java/run/halo/app/content/comment/PostCommentSubject.java new file mode 100644 index 0000000..1e96a53 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/PostCommentSubject.java @@ -0,0 +1,48 @@ +package run.halo.app.content.comment; + +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.GroupVersionKind; +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."); + GroupVersionKind groupVersionKind = + new GroupVersionKind(ref.getGroup(), ref.getVersion(), ref.getKind()); + return GroupVersionKind.fromExtension(Post.class).equals(groupVersionKind); + } +} diff --git a/application/src/main/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelper.java b/application/src/main/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelper.java new file mode 100644 index 0000000..eff83af --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelper.java @@ -0,0 +1,73 @@ +package run.halo.app.content.comment; + +import io.micrometer.common.util.StringUtils; +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.notification.NotificationCenter; +import run.halo.app.notification.UserIdentity; + +/** + * Reply notification subscription helper. + * + * @author guqing + * @since 2.9.0 + */ +@Component +@RequiredArgsConstructor +public class ReplyNotificationSubscriptionHelper { + + 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(); + } + + @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()); + } +} diff --git a/application/src/main/java/run/halo/app/content/comment/ReplyQuery.java b/application/src/main/java/run/halo/app/content/comment/ReplyQuery.java new file mode 100644 index 0000000..2dff447 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/ReplyQuery.java @@ -0,0 +1,67 @@ +package run.halo.app.content.comment; + +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static run.halo.app.extension.index.query.QueryFactory.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)); + } +} diff --git a/application/src/main/java/run/halo/app/content/comment/ReplyRequest.java b/application/src/main/java/run/halo/app/content/comment/ReplyRequest.java new file mode 100644 index 0000000..d7ad2c3 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/ReplyRequest.java @@ -0,0 +1,55 @@ +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; + + 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.setQuoteReply(quoteReply); + + if (owner != null) { + spec.setOwner(owner.toCommentOwner()); + } + return reply; + } +} diff --git a/application/src/main/java/run/halo/app/content/comment/ReplyService.java b/application/src/main/java/run/halo/app/content/comment/ReplyService.java new file mode 100644 index 0000000..0c5f7c0 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/ReplyService.java @@ -0,0 +1,20 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java b/application/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java new file mode 100644 index 0000000..5c32c96 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java @@ -0,0 +1,260 @@ +package run.halo.app.content.comment; + +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.isNull; +import static run.halo.app.security.authorization.AuthorityUtils.COMMENT_MANAGEMENT_ROLE_NAME; +import static run.halo.app.security.authorization.AuthorityUtils.authoritiesToRoles; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Set; +import java.util.function.Function; +import java.util.function.UnaryOperator; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.reactivestreams.Publisher; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.domain.Sort; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.stereotype.Service; +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.User; +import run.halo.app.core.extension.content.Comment; +import run.halo.app.core.extension.content.Reply; +import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.extension.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.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; + +/** + * A default implementation of {@link ReplyService}. + * + * @author guqing + * @since 2.0.0 + */ +@Service +@RequiredArgsConstructor +public class ReplyServiceImpl implements ReplyService { + + private final ReactiveExtensionClient client; + private final UserService userService; + private final RoleService roleService; + private final CounterService counterService; + + @Override + public Mono create(String commentName, Reply reply) { + return client.get(Comment.class, commentName) + .flatMap(comment -> prepareReply(commentName, reply) + .flatMap(client::create) + .flatMap(createdReply -> { + var quotedReply = createdReply.getSpec().getQuoteReply(); + if (StringUtils.isBlank(quotedReply)) { + return Mono.just(createdReply); + } + return approveReply(quotedReply) + .thenReturn(createdReply); + }) + .flatMap(createdReply -> approveComment(comment) + .thenReturn(createdReply) + ) + ); + } + + private Mono approveComment(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 Mono.defer(() -> client.fetch(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)) + .then(); + } + + 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(String commentName, Reply reply) { + reply.getSpec().setCommentName(commentName); + 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 (BooleanUtils.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); + } + + Mono hasCommentManagePermission() { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .flatMap(authentication -> { + var roles = authoritiesToRoles(authentication.getAuthorities()); + return roleService.contains(roles, Set.of(COMMENT_MANAGEMENT_ROLE_NAME)); + }); + } + + @Override + public Mono> list(ReplyQuery query) { + return client.listBy(Reply.class, query.toListOptions(), query.toPageRequest()) + .flatMap(list -> Flux.fromStream(list.get() + .map(this::toListedReply)) + .concatMap(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) + .map(ownerInfo -> { + builder.owner(ownerInfo); + return builder; + }) + .map(ListedReply.ListedReplyBuilder::build) + .flatMap(listedReply -> fetchStats(reply) + .doOnNext(listedReply::setStats) + .thenReturn(listedReply)); + } + + Mono fetchStats(Reply reply) { + Assert.notNull(reply, "The reply must not be null."); + String name = reply.getMetadata().getName(); + return counterService.getByName(MeterUtils.nameOf(Reply.class, name)) + .map(counter -> CommentStats.builder() + .upvote(counter.getUpvote()) + .build() + ) + .defaultIfEmpty(CommentStats.empty()); + } + + private Mono getOwnerInfo(Reply reply) { + Comment.CommentOwner owner = reply.getSpec().getOwner(); + 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)); + } + throw new IllegalStateException( + "Unsupported owner kind: " + owner.getKind()); + } + + private 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; + } + + private Mono fetchCurrentUser() { + return ReactiveSecurityContextHolder.getContext() + .map(securityContext -> securityContext.getAuthentication().getName()) + .flatMap(username -> client.fetch(User.class, username)); + } +} diff --git a/application/src/main/java/run/halo/app/content/comment/SinglePageCommentSubject.java b/application/src/main/java/run/halo/app/content/comment/SinglePageCommentSubject.java new file mode 100644 index 0000000..4f7eeb6 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/comment/SinglePageCommentSubject.java @@ -0,0 +1,49 @@ +package run.halo.app.content.comment; + +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.GroupVersionKind; +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."); + GroupVersionKind groupVersionKind = + new GroupVersionKind(ref.getGroup(), ref.getVersion(), ref.getKind()); + return GroupVersionKind.fromExtension(SinglePage.class).equals(groupVersionKind); + } +} diff --git a/application/src/main/java/run/halo/app/content/impl/CategoryServiceImpl.java b/application/src/main/java/run/halo/app/content/impl/CategoryServiceImpl.java new file mode 100644 index 0000000..b7328ab --- /dev/null +++ b/application/src/main/java/run/halo/app/content/impl/CategoryServiceImpl.java @@ -0,0 +1,71 @@ +package run.halo.app.content.impl; + +import static run.halo.app.extension.index.query.QueryFactory.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(); + } +} diff --git a/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java b/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java new file mode 100644 index 0000000..1b2c974 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java @@ -0,0 +1,404 @@ +package run.halo.app.content.impl; + +import static run.halo.app.extension.index.query.QueryFactory.in; + +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.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.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.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.extension.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.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.Condition; +import run.halo.app.infra.ConditionStatus; +import run.halo.app.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; + +/** + * 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, + PageRequestImpl.of(query.getPage(), query.getSize(), query.getSort()) + )) + .flatMap(listResult -> Flux.fromStream(listResult.get()) + .map(this::getListedPost) + .concatMap(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 (tagNames == null) { + 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")); + } + + private Flux listCategories(List categoryNames) { + if (categoryNames == null) { + return Flux.empty(); + } + var listOptions = new ListOptions(); + listOptions.setFieldSelector(FieldSelector.of(in("metadata.name", categoryNames))); + return client.listAll(Category.class, listOptions, Sort.by("metadata.creationTimestamp")); + } + + private Flux listContributors(List usernames) { + if (usernames == null) { + return Flux.empty(); + } + return Flux.fromIterable(usernames) + .concatMap(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)); + }); + } + + 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)) + ); + } +} diff --git a/application/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java b/application/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java new file mode 100644 index 0000000..8fc7609 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java @@ -0,0 +1,321 @@ +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.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.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; +import run.halo.app.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; + +/** + * 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.list(SinglePage.class, query.toPredicate(), + query.toComparator(), query.getPage(), query.getSize()) + .flatMap(listResult -> Flux.fromStream( + listResult.get().map(this::getListedSinglePage) + ) + .concatMap(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; + }); + } +} diff --git a/application/src/main/java/run/halo/app/content/impl/SnapshotServiceImpl.java b/application/src/main/java/run/halo/app/content/impl/SnapshotServiceImpl.java new file mode 100644 index 0000000..d9ff3b3 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/impl/SnapshotServiceImpl.java @@ -0,0 +1,125 @@ +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()); + } +} diff --git a/application/src/main/java/run/halo/app/content/permalinks/CategoryPermalinkPolicy.java b/application/src/main/java/run/halo/app/content/permalinks/CategoryPermalinkPolicy.java new file mode 100644 index 0000000..d69ff60 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/permalinks/CategoryPermalinkPolicy.java @@ -0,0 +1,48 @@ +package run.halo.app.content.permalinks; + +import static org.springframework.web.util.UriUtils.encode; + +import java.nio.charset.StandardCharsets; +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.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.utils.PathUtils; + +/** + * @author guqing + * @since 2.0.0 + */ +@Component +@RequiredArgsConstructor +public class CategoryPermalinkPolicy implements PermalinkPolicy { + public static final String DEFAULT_PERMALINK_PREFIX = + SystemSetting.ThemeRouteRules.empty().getCategories(); + + private final ExternalUrlSupplier externalUrlSupplier; + private final SystemConfigurableEnvironmentFetcher 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) + .blockOptional() + .orElse(DEFAULT_PERMALINK_PREFIX); + } +} diff --git a/application/src/main/java/run/halo/app/content/permalinks/ExtensionLocator.java b/application/src/main/java/run/halo/app/content/permalinks/ExtensionLocator.java new file mode 100644 index 0000000..a9c2567 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/permalinks/ExtensionLocator.java @@ -0,0 +1,44 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/content/permalinks/PermalinkPolicy.java b/application/src/main/java/run/halo/app/content/permalinks/PermalinkPolicy.java new file mode 100644 index 0000000..03ec67a --- /dev/null +++ b/application/src/main/java/run/halo/app/content/permalinks/PermalinkPolicy.java @@ -0,0 +1,16 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/content/permalinks/PostPermalinkPolicy.java b/application/src/main/java/run/halo/app/content/permalinks/PostPermalinkPolicy.java new file mode 100644 index 0000000..f903ddd --- /dev/null +++ b/application/src/main/java/run/halo/app/content/permalinks/PostPermalinkPolicy.java @@ -0,0 +1,73 @@ +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.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.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.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.utils.PathUtils; + +/** + * @author guqing + * @since 2.0.0 + */ +@Component +@RequiredArgsConstructor +public class PostPermalinkPolicy implements PermalinkPolicy { + public static final String DEFAULT_PERMALINK_PATTERN = + SystemSetting.ThemeRouteRules.empty().getPost(); + private static final NumberFormat NUMBER_FORMAT = new DecimalFormat("00"); + + private final SystemConfigurableEnvironmentFetcher environmentFetcher; + private final ExternalUrlSupplier externalUrlSupplier; + + @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(SystemSetting.ThemeRouteRules::getPost) + .blockOptional() + .orElse(DEFAULT_PERMALINK_PATTERN); + } + + 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())); + + String simplifiedPattern = PathUtils.simplifyPathPattern(pattern); + String permalink = + PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders(simplifiedPattern, properties); + return externalUrlSupplier.get() + .resolve(permalink) + .normalize() + .toString(); + } +} diff --git a/application/src/main/java/run/halo/app/content/permalinks/TagPermalinkPolicy.java b/application/src/main/java/run/halo/app/content/permalinks/TagPermalinkPolicy.java new file mode 100644 index 0000000..a22ebf7 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/permalinks/TagPermalinkPolicy.java @@ -0,0 +1,47 @@ +package run.halo.app.content.permalinks; + +import java.nio.charset.StandardCharsets; +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.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.utils.PathUtils; + +/** + * @author guqing + * @since 2.0.0 + */ +@Component +@RequiredArgsConstructor +public class TagPermalinkPolicy implements PermalinkPolicy { + public static final String DEFAULT_PERMALINK_PREFIX = + SystemSetting.ThemeRouteRules.empty().getTags(); + private final ExternalUrlSupplier externalUrlSupplier; + private final SystemConfigurableEnvironmentFetcher 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) + .blockOptional() + .orElse(DEFAULT_PERMALINK_PREFIX); + } +} diff --git a/application/src/main/java/run/halo/app/core/endpoint/WebSocketEndpointManager.java b/application/src/main/java/run/halo/app/core/endpoint/WebSocketEndpointManager.java new file mode 100644 index 0000000..bcdf99e --- /dev/null +++ b/application/src/main/java/run/halo/app/core/endpoint/WebSocketEndpointManager.java @@ -0,0 +1,16 @@ +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); + +} diff --git a/application/src/main/java/run/halo/app/core/endpoint/WebSocketHandlerMapping.java b/application/src/main/java/run/halo/app/core/endpoint/WebSocketHandlerMapping.java new file mode 100644 index 0000000..0105c03 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/endpoint/WebSocketHandlerMapping.java @@ -0,0 +1,140 @@ +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.console.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; + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpoint.java b/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpoint.java new file mode 100644 index 0000000..599f546 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpoint.java @@ -0,0 +1,316 @@ +package run.halo.app.core.extension.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.arrayschema.Builder.arraySchemaBuilder; +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.web.reactive.function.server.RequestPredicates.contentType; +import static run.halo.app.extension.ListResult.generateGenericClass; +import static run.halo.app.extension.index.query.QueryFactory.all; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.contains; +import static run.halo.app.extension.index.query.QueryFactory.in; +import static run.halo.app.extension.index.query.QueryFactory.isNull; +import static run.halo.app.extension.index.query.QueryFactory.not; +import static run.halo.app.extension.index.query.QueryFactory.startsWith; +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.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +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.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.CollectionUtils; +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.ServerWebExchange; +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.attachment.Group; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.core.extension.endpoint.SortResolver; +import run.halo.app.core.extension.service.AttachmentService; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.PageRequestImpl; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.index.query.QueryFactory; +import run.halo.app.extension.router.IListRequest; +import run.halo.app.extension.router.IListRequest.QueryListRequest; +import run.halo.app.extension.router.QueryParamBuildUtil; +import run.halo.app.extension.router.selector.LabelSelector; + +@Slf4j +@Component +public class AttachmentEndpoint implements CustomEndpoint { + + private final AttachmentService attachmentService; + + private final ReactiveExtensionClient client; + + public AttachmentEndpoint(AttachmentService attachmentService, + ReactiveExtensionClient client) { + this.attachmentService = attachmentService; + this.client = client; + } + + @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()) + .GET("/attachments", this::search, + builder -> { + builder + .operationId("SearchAttachments") + .tag(tag) + .response( + responseBuilder().implementation(generateGenericClass(Attachment.class)) + ); + ISearchRequest.buildParameters(builder); + } + ) + .build(); + } + + Mono search(ServerRequest request) { + var searchRequest = new SearchRequest(request); + var groupListOptions = new ListOptions(); + groupListOptions.setLabelSelector(LabelSelector.builder() + .exists(Group.HIDDEN_LABEL) + .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), + PageRequestImpl.of(searchRequest.getPage(), searchRequest.getSize(), + searchRequest.getSort()) + ) + .flatMap(listResult -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(listResult) + ) + ); + } + + public interface ISearchRequest extends IListRequest { + + @Schema(description = "Keyword for searching.") + Optional getKeyword(); + + @Schema(description = "Filter attachments without group. This parameter will ignore group" + + " parameter.") + Optional getUngrouped(); + + @ArraySchema(uniqueItems = true, + arraySchema = @Schema(name = "accepts", + description = "Acceptable media types."), + schema = @Schema(description = "like image/*, video/mp4, text/*", + implementation = String.class, + example = "image/*")) + List getAccepts(); + + @ArraySchema(uniqueItems = true, + arraySchema = @Schema(name = "sort", + description = "Sort property and direction of the list result. Supported fields: " + + "creationTimestamp, size"), + schema = @Schema(description = "like field,asc or field,desc", + implementation = String.class, + example = "creationTimestamp,desc")) + Sort getSort(); + + 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) + ); + } + } + + public static class SearchRequest extends QueryListRequest implements ISearchRequest { + + private final ServerWebExchange exchange; + + public SearchRequest(ServerRequest request) { + super(request.queryParams()); + this.exchange = request.exchange(); + } + + @Override + public Optional getKeyword() { + return Optional.ofNullable(queryParams.getFirst("keyword")) + .filter(StringUtils::hasText); + } + + @Override + public Optional getUngrouped() { + return Optional.ofNullable(queryParams.getFirst("ungrouped")) + .map(ungroupedStr -> getSharedInstance().convert(ungroupedStr, Boolean.class)); + } + + @Override + public List getAccepts() { + return queryParams.getOrDefault("accepts", Collections.emptyList()); + } + + @Override + public Sort getSort() { + var sort = SortResolver.defaultInstance.resolve(exchange); + sort = sort.and(Sort.by( + Sort.Order.desc("metadata.creationTimestamp"), + Sort.Order.asc("metadata.name") + )); + return sort; + } + + public ListOptions toListOptions(List hiddenGroups) { + final var listOptions = + labelAndFieldSelectorToListOptions(getLabelSelector(), getFieldSelector()); + + var fieldQuery = all(); + if (getKeyword().isPresent()) { + fieldQuery = and(fieldQuery, contains("spec.displayName", getKeyword().get())); + } + + if (getUngrouped().isPresent() && BooleanUtils.isTrue(getUngrouped().get())) { + fieldQuery = and(fieldQuery, isNull("spec.groupName")); + } + + if (!hiddenGroups.isEmpty()) { + fieldQuery = and(fieldQuery, not(in("spec.groupName", hiddenGroups))); + } + + if (hasAccepts()) { + var acceptFieldQueryOptional = getAccepts().stream() + .filter(StringUtils::hasText) + .map((accept -> accept.replace("/*", "/").toLowerCase())) + .distinct() + .map(accept -> startsWith("spec.mediaType", accept)) + .reduce(QueryFactory::or); + if (acceptFieldQueryOptional.isPresent()) { + fieldQuery = and(fieldQuery, acceptFieldQueryOptional.get()); + } + } + + listOptions.setFieldSelector(listOptions.getFieldSelector().andQuery(fieldQuery)); + return listOptions; + } + + private boolean hasAccepts() { + return !CollectionUtils.isEmpty(getAccepts()) + && !getAccepts().contains("*") + && !getAccepts().contains("*/*"); + } + } + + @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; + } + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java b/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java new file mode 100644 index 0000000..60ef6c9 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java @@ -0,0 +1,310 @@ +package run.halo.app.core.extension.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.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.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 lombok.Data; +import lombok.extern.slf4j.Slf4j; +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.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 org.springframework.web.util.UriUtils; +import reactor.core.Exceptions; +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.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.extension.ConfigMap; +import run.halo.app.extension.Metadata; +import run.halo.app.infra.ExternalUrlSupplier; +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.properties.HaloProperties; +import run.halo.app.infra.utils.FileTypeDetectUtils; +import run.halo.app.infra.utils.JsonUtils; + +@Slf4j +@Component +class LocalAttachmentUploadHandler implements AttachmentHandler { + + private final HaloProperties haloProp; + + private final ExternalUrlSupplier externalUrl; + + public LocalAttachmentUploadHandler(HaloProperties haloProp, + ExternalUrlSupplier externalUrl) { + this.haloProp = haloProp; + this.externalUrl = externalUrl; + } + + Path getAttachmentsRoot() { + return haloProp.getWorkDir().resolve("attachments"); + } + + @Override + public Mono upload(UploadContext uploadOption) { + return Mono.just(uploadOption) + .filter(option -> this.shouldHandle(option.policy())) + .flatMap(option -> { + var configMap = option.configMap(); + var settingJson = configMap.getData().getOrDefault("default", "{}"); + var setting = JsonUtils.jsonToObject(settingJson, PolicySetting.class); + + final var attachmentsRoot = getAttachmentsRoot(); + final var uploadRoot = attachmentsRoot.resolve("upload"); + final var file = option.file(); + final Path attachmentPath; + if (StringUtils.hasText(setting.getLocation())) { + attachmentPath = + uploadRoot.resolve(setting.getLocation()).resolve(file.filename()); + } else { + attachmentPath = uploadRoot.resolve(file.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 {}", file.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"); + 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); + 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 = "Unknown"; + try { + mimeType = FileTypeDetectUtils.detectMimeType(dataBuffer.asInputStream()); + var isAllow = setting.getAllowedFileTypes() + .stream() + .map(FileCategoryMatcher::of) + .anyMatch(matcher -> matcher.match(file.filename())); + if (isAllow) { + sink.next(dataBuffer); + return; + } + } catch (IOException e) { + log.warn("Failed to detect file type", e); + } + sink.error(new FileTypeNotAllowedException("File type is not allowed", + "problemDetail.attachment.upload.fileTypeNotSupported", + new Object[] {mimeType}) + ); + }); + validations.add(typeValidator); + } + return Mono.when(validations); + } + + @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 = getAttachmentsRoot(); + var attachmentPath = attachmentsRoot.resolve(localRelativePath); + checkDirectoryTraversal(attachmentsRoot, attachmentPath); + + // delete it permanently + try { + log.info("{} is being deleted", attachmentPath); + boolean deleted = Files.deleteIfExists(attachmentPath); + if (deleted) { + log.info("{} was deleted successfully", attachment); + } else { + log.info("{} was not exist", attachment); + } + } catch (IOException e) { + throw Exceptions.propagate(e); + } + } + } + }) + .map(DeleteContext::attachment); + } + + @Override + public Mono getPermalink(Attachment attachment, Policy policy, ConfigMap configMap) { + if (!this.shouldHandle(policy)) { + return Mono.empty(); + } + var annotations = attachment.getMetadata().getAnnotations(); + if (annotations == null + || !annotations.containsKey(Constant.URI_ANNO_KEY)) { + return Mono.empty(); + } + var uriStr = annotations.get(Constant.URI_ANNO_KEY); + // the uriStr is encoded before. + uriStr = UriUtils.decode(uriStr, StandardCharsets.UTF_8); + var uri = UriComponentsBuilder.fromUri(externalUrl.get()) + // The URI has been encoded before, so there is no need to encode it again. + .path(uriStr) + .build() + .toUri(); + return Mono.just(uri); + } + + @Override + public Mono getSharedURL(Attachment attachment, + Policy policy, + ConfigMap configMap, + Duration ttl) { + return getPermalink(attachment, policy, configMap); + } + + private boolean shouldHandle(Policy policy) { + if (policy == null + || policy.getSpec() == null + || !StringUtils.hasText(policy.getSpec().getTemplateName())) { + return false; + } + return "local".equals(policy.getSpec().getTemplateName()); + } + + @Data + public static class PolicySetting { + + private String location; + + private DataSize maxFileSize; + + private Set allowedFileTypes; + + public void setMaxFileSize(String maxFileSize) { + if (!StringUtils.hasText(maxFileSize)) { + return; + } + this.maxFileSize = DataSize.parse(maxFileSize); + } + } + + /** + * 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)); + }); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/AuthProviderEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/AuthProviderEndpoint.java new file mode 100644 index 0000000..a58b922 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/AuthProviderEndpoint.java @@ -0,0 +1,86 @@ +package run.halo.app.core.extension.endpoint; + +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.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)); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java new file mode 100644 index 0000000..18c904c --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java @@ -0,0 +1,133 @@ +package run.halo.app.core.extension.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.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.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)); + } + +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/CustomEndpointsBuilder.java b/application/src/main/java/run/halo/app/core/extension/endpoint/CustomEndpointsBuilder.java new file mode 100644 index 0000000..6b8d7cf --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/CustomEndpointsBuilder.java @@ -0,0 +1,46 @@ +package run.halo.app.core.extension.endpoint; + +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.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), + builder -> builder.operationId("CustomEndpoints") + .description("Custom Endpoint") + .tag(gv + "/CustomEndpoint") + ); + }); + if (routerFunctionsMap.isEmpty()) { + // return empty route. + return request -> Mono.empty(); + } + routerFunctionsMap.clear(); + return routeBuilder.build(); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java new file mode 100644 index 0000000..5e3af3a --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java @@ -0,0 +1,742 @@ +package run.halo.app.core.extension.endpoint; + +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 java.util.Comparator.comparing; +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.router.QueryParamBuildUtil.sortParameter; +import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate; +import static run.halo.app.infra.utils.FileUtils.deleteFileSilently; + +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.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.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Predicate; +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.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.ServerWebExchange; +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.service.PluginService; +import run.halo.app.core.extension.theme.SettingUtils; +import run.halo.app.extension.Comparators; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.router.IListRequest; +import run.halo.app.extension.router.IListRequest.QueryListRequest; +import run.halo.app.infra.ReactiveUrlDataBufferFetcher; +import run.halo.app.plugin.PluginNotFoundException; + +@Slf4j +@Component +public class PluginEndpoint implements CustomEndpoint, InitializingBean { + + private final ReactiveExtensionClient client; + + private final PluginService pluginService; + + private final ReactiveUrlDataBufferFetcher reactiveUrlDataBufferFetcher; + + 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, + WebProperties webProperties) { + this.client = client; + this.pluginService = pluginService; + this.reactiveUrlDataBufferFetcher = reactiveUrlDataBufferFetcher; + 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}/config", this::updatePluginConfig, + builder -> builder.operationId("updatePluginConfig") + .description("Update the configMap 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(ConfigMap.class)))) + .response(responseBuilder() + .implementation(ConfigMap.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}/config", this::fetchPluginConfig, + builder -> builder.operationId("fetchPluginConfig") + .description("Fetch configMap of plugin by configured configMapName.") + .tag(tag) + .parameter(parameterBuilder() + .name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class) + ) + .response(responseBuilder() + .implementation(ConfigMap.class)) + ) + .GET("plugin-presets", this::listPresets, + builder -> builder.operationId("ListPluginPresets") + .description("List all plugin presets in the system.") + .tag(tag) + .response(responseBuilder().implementationArray(Plugin.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(); + } + + 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("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("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 listPresets(ServerRequest request) { + return ServerResponse.ok().body(pluginService.getPresets(), 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); + } + if (InstallSource.PRESET.equals(source)) { + return installFromPreset(installRequest.getPresetName(), + 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)); + } + if (InstallSource.PRESET.equals(source)) { + return installFromPreset(installRequest.getPresetName(), + 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); + } + + private Mono installFromPreset(Mono presetNameMono, + Function> resourceClosure) { + return presetNameMono.flatMap(pluginService::getPreset) + .switchIfEmpty( + Mono.error(() -> new PluginNotFoundException("Plugin preset was not found."))) + .map(pluginPreset -> pluginPreset.getStatus().getLoadLocation()) + .map(Path::of) + .flatMap(resourceClosure); + } + + public static class ListRequest extends QueryListRequest { + + private final ServerWebExchange exchange; + + public ListRequest(ServerRequest request) { + super(request.queryParams()); + this.exchange = 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); + } + + @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); + } + + public Predicate toPredicate() { + Predicate displayNamePredicate = plugin -> { + var keyword = getKeyword(); + if (!StringUtils.hasText(keyword)) { + return true; + } + var displayName = plugin.getSpec().getDisplayName(); + if (!StringUtils.hasText(displayName)) { + return false; + } + return displayName.toLowerCase().contains(keyword.trim().toLowerCase()); + }; + Predicate descriptionPredicate = plugin -> { + var keyword = getKeyword(); + if (!StringUtils.hasText(keyword)) { + return true; + } + var description = plugin.getSpec().getDescription(); + if (!StringUtils.hasText(description)) { + return false; + } + return description.toLowerCase().contains(keyword.trim().toLowerCase()); + }; + Predicate enablePredicate = plugin -> { + var enabled = getEnabled(); + if (enabled == null) { + return true; + } + return Objects.equals(enabled, plugin.getSpec().getEnabled()); + }; + return displayNamePredicate.or(descriptionPredicate) + .and(enablePredicate) + .and(labelAndFieldSelectorToPredicate(getLabelSelector(), getFieldSelector())); + } + + public Comparator toComparator() { + var sort = getSort(); + var ctOrder = sort.getOrderFor("creationTimestamp"); + List> comparators = new ArrayList<>(); + if (ctOrder != null) { + Comparator comparator = + comparing(plugin -> plugin.getMetadata().getCreationTimestamp()); + if (ctOrder.isDescending()) { + comparator = comparator.reversed(); + } + comparators.add(comparator); + } + comparators.add(Comparators.compareCreationTimestamp(false)); + comparators.add(Comparators.compareName(true)); + return comparators.stream() + .reduce(Comparator::thenComparing) + .orElse(null); + } + + 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 -> { + var predicate = listRequest.toPredicate(); + var comparator = listRequest.toComparator(); + return client.list(Plugin.class, + predicate, + comparator, + listRequest.getPage(), + listRequest.getSize()); + }) + .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); + } + +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java new file mode 100644 index 0000000..5720ff6 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java @@ -0,0 +1,409 @@ +package run.halo.app.core.extension.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 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.retry.RetryException; +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.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("Publish 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 RetryException("Retry to check post publish status")))) + .retryWhen(Retry.backoff(maxAttemptsWaitForPublish, Duration.ofMillis(100)) + .filter(t -> t instanceof RetryException)); + } + + 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. + */ + public void setMaxAttemptsWaitForPublish(int maxAttempts) { + this.maxAttemptsWaitForPublish = maxAttempts; + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/ReplyEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/ReplyEndpoint.java new file mode 100644 index 0000000..61e2fb5 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/ReplyEndpoint.java @@ -0,0 +1,54 @@ +package run.halo.app.core.extension.endpoint; + +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.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)); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java new file mode 100644 index 0000000..0c86c01 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java @@ -0,0 +1,342 @@ +package run.halo.app.core.extension.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 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.retry.RetryException; +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.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) + .map(latest -> { + String latestReleasedSnapshotName = + MetadataUtil.nullSafeAnnotations(latest) + .get(Post.LAST_RELEASED_SNAPSHOT_ANNO); + if (StringUtils.equals(latestReleasedSnapshotName, + latest.getSpec().getReleaseSnapshot())) { + return latest; + } + throw new RetryException("SinglePage publishing status is not as expected"); + }) + .retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100)) + .filter(t -> t instanceof RetryException)) + .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)); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/StatsEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/StatsEndpoint.java new file mode 100644 index 0000000..84881b7 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/StatsEndpoint.java @@ -0,0 +1,113 @@ +package run.halo.app.core.extension.endpoint; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.isNull; + +import lombok.Data; +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.Counter; +import run.halo.app.core.extension.User; +import run.halo.app.core.extension.content.Post; +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; + +/** + * 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) { + return client.list(Counter.class, null, null) + .reduce(DashboardStats.emptyStats(), (stats, counter) -> { + stats.setVisits(stats.getVisits() + counter.getVisit()); + stats.setComments(stats.getComments() + counter.getTotalComment()); + stats.setApprovedComments( + stats.getApprovedComments() + counter.getApprovedComment()); + stats.setUpvotes(stats.getUpvotes() + counter.getUpvote()); + return stats; + }) + .flatMap(stats -> { + 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)) + .doOnNext(result -> stats.setUsers((int) result.getTotal())) + .thenReturn(stats); + }) + .flatMap(stats -> { + var listOptions = new ListOptions(); + listOptions.setFieldSelector(FieldSelector.of( + and(isNull("metadata.deletionTimestamp"), + equal("spec.deleted", "false"))) + ); + return client.listBy(Post.class, listOptions, PageRequestImpl.ofSize(1)) + .doOnNext(list -> stats.setPosts((int) list.getTotal())) + .thenReturn(stats); + }) + .flatMap(stats -> ServerResponse.ok().bodyValue(stats)); + } + + @Data + public static class DashboardStats { + private Integer visits; + private Integer comments; + private Integer approvedComments; + private Integer upvotes; + private Integer users; + private Integer posts; + + /** + * Creates an empty stats that populated initialize value. + * + * @return stats with initialize value. + */ + public static DashboardStats emptyStats() { + DashboardStats stats = new DashboardStats(); + stats.setVisits(0); + stats.setComments(0); + stats.setApprovedComments(0); + stats.setUpvotes(0); + stats.setUsers(0); + stats.setPosts(0); + return stats; + } + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/SystemInitializationEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/SystemInitializationEndpoint.java new file mode 100644 index 0000000..37477cc --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/SystemInitializationEndpoint.java @@ -0,0 +1,146 @@ +package run.halo.app.core.extension.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.header.Builder.headerBuilder; +import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.net.URI; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.http.HttpHeaders; +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.ResponseStatusException; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.InitializationStateGetter; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.ValidationUtils; +import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; +import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.security.SuperAdminInitializer; + +/** + * System initialization endpoint. + * + * @author guqing + * @since 2.9.0 + */ +@Component +@RequiredArgsConstructor +public class SystemInitializationEndpoint implements CustomEndpoint { + + private final ReactiveExtensionClient client; + private final SuperAdminInitializer superAdminInitializer; + private final InitializationStateGetter initializationStateSupplier; + + @Override + public RouterFunction endpoint() { + var tag = "SystemV1alpha1Console"; + // define a non-resource api + return SpringdocRouteBuilder.route() + .POST("/system/initialize", this::initialize, + builder -> builder.operationId("initialize") + .description("Initialize system") + .tag(tag) + .requestBody(requestBodyBuilder() + .implementation(SystemInitializationRequest.class)) + .response(responseBuilder() + .responseCode(HttpStatus.CREATED.value() + "") + .description("System initialization successfully.") + .header(headerBuilder() + .name(HttpHeaders.LOCATION) + .description("Redirect URL.") + ) + ) + ) + .build(); + } + + private Mono initialize(ServerRequest request) { + return request.bodyToMono(SystemInitializationRequest.class) + .switchIfEmpty( + Mono.error(new ServerWebInputException("Request body must not be empty")) + ) + .doOnNext(requestBody -> { + if (!ValidationUtils.validateName(requestBody.getUsername())) { + throw new UnsatisfiedAttributeValueException( + "The username does not meet the specifications", + "problemDetail.user.username.unsatisfied", null); + } + if (StringUtils.isBlank(requestBody.getPassword())) { + throw new UnsatisfiedAttributeValueException( + "The password does not meet the specifications", + "problemDetail.user.password.unsatisfied", null); + } + }) + .flatMap(requestBody -> initializationStateSupplier.userInitialized() + .flatMap(result -> { + if (result) { + return Mono.error(new ResponseStatusException(HttpStatus.CONFLICT, + "System has been initialized")); + } + return initializeSystem(requestBody); + }) + ) + .then(ServerResponse.created(URI.create("/console")).build()); + } + + private Mono initializeSystem(SystemInitializationRequest requestBody) { + Mono initializeAdminUser = superAdminInitializer.initialize( + SuperAdminInitializer.InitializationParam.builder() + .username(requestBody.getUsername()) + .password(requestBody.getPassword()) + .email(requestBody.getEmail()) + .build()); + + Mono siteSetting = + Mono.defer(() -> client.get(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) + .flatMap(config -> { + Map data = config.getData(); + if (data == null) { + data = new LinkedHashMap<>(); + config.setData(data); + } + String basic = data.getOrDefault(SystemSetting.Basic.GROUP, "{}"); + SystemSetting.Basic basicSetting = + JsonUtils.jsonToObject(basic, SystemSetting.Basic.class); + basicSetting.setTitle(requestBody.getSiteTitle()); + data.put(SystemSetting.Basic.GROUP, JsonUtils.objectToJson(basicSetting)); + return client.update(config); + })) + .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) + .filter(t -> t instanceof OptimisticLockingFailureException) + ) + .then(); + return Mono.when(initializeAdminUser, siteSetting); + } + + @Data + public static class SystemInitializationRequest { + + @Schema(requiredMode = REQUIRED, minLength = 1) + private String username; + + @Schema(requiredMode = REQUIRED, minLength = 3) + private String password; + + private String email; + + private String siteTitle; + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/TagEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/TagEndpoint.java new file mode 100644 index 0000000..47a90b7 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/TagEndpoint.java @@ -0,0 +1,139 @@ +package run.halo.app.core.extension.endpoint; + +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.QueryFactory.all; +import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter; +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.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +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.data.domain.Sort; +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.ServerWebExchange; +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.index.query.QueryFactory; +import run.halo.app.extension.router.IListRequest; + +/** + * 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 interface ITagQuery extends IListRequest { + + @Schema(description = "Keyword for searching.") + Optional getKeyword(); + + @ArraySchema(uniqueItems = true, + arraySchema = @Schema(name = "sort", + description = "Sort property and direction of the list result. Supported fields: " + + "creationTimestamp, name"), + schema = @Schema(description = "like field,asc or field,desc", + implementation = String.class, + example = "creationTimestamp,desc")) + Sort getSort(); + } + + public static class TagQuery extends IListRequest.QueryListRequest + implements ITagQuery { + + private final ServerWebExchange exchange; + + public TagQuery(ServerRequest request) { + super(request.queryParams()); + this.exchange = request.exchange(); + } + + @Override + public Optional getKeyword() { + return Optional.ofNullable(queryParams.getFirst("keyword")) + .filter(StringUtils::hasText); + } + + @Override + public Sort getSort() { + var sort = SortResolver.defaultInstance.resolve(exchange); + sort = sort.and(Sort.by( + Sort.Order.desc("metadata.creationTimestamp"), + Sort.Order.asc("metadata.name") + )); + return sort; + } + + public ListOptions toListOptions() { + final var listOptions = + labelAndFieldSelectorToListOptions(getLabelSelector(), getFieldSelector()); + + var fieldQuery = all(); + if (getKeyword().isPresent()) { + fieldQuery = QueryFactory.and(fieldQuery, QueryFactory.or( + QueryFactory.contains("spec.displayName", getKeyword().get()), + QueryFactory.contains("spec.slug", getKeyword().get()) + )); + } + + listOptions.setFieldSelector(listOptions.getFieldSelector().andQuery(fieldQuery)); + return listOptions; + } + + 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)); + } + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/TrackerEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/TrackerEndpoint.java new file mode 100644 index 0000000..8ada772 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/TrackerEndpoint.java @@ -0,0 +1,138 @@ +package run.halo.app.core.extension.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 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.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"); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java new file mode 100644 index 0000000..932a392 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java @@ -0,0 +1,812 @@ +package run.halo.app.core.extension.endpoint; + +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.QueryFactory.contains; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.in; +import static run.halo.app.extension.index.query.QueryFactory.or; +import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter; +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.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import java.security.Principal; +import java.time.Duration; +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.data.domain.Sort; +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.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.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.function.Tuples; +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.service.AttachmentService; +import run.halo.app.core.extension.service.EmailVerificationService; +import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.extension.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.IListRequest; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.ValidationUtils; +import run.halo.app.infra.exception.RateLimitExceededException; +import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; +import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; + +@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 SystemConfigurableEnvironmentFetcher environmentFetcher; + + @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) String email) { + } + + public record VerifyCodeRequest( + @Schema(requiredMode = REQUIRED) String password, + @Schema(requiredMode = REQUIRED, minLength = 1) String code) { + } + + private Mono sendEmailVerificationCode(ServerRequest request) { + return request.bodyToMono(EmailVerifyRequest.class) + .switchIfEmpty(Mono.error( + () -> new ServerWebInputException("Request body is required.")) + ) + .doOnNext(emailRequest -> { + if (!ValidationUtils.isValidEmail(emailRequest.email())) { + throw new ServerWebInputException("Invalid email address."); + } + }) + .flatMap(emailRequest -> { + var email = emailRequest.email(); + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Principal::getName) + .map(username -> Tuples.of(username, email)); + }) + .flatMap(tuple -> { + var username = tuple.getT1(); + var email = 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 ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Authentication::getName) + .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) { + return environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class) + .switchIfEmpty( + Mono.error(new IllegalStateException("User setting is not configured")) + ) + .flatMap(userSetting -> Mono.defer( + () -> { + String avatarPolicy = userSetting.getAvatarPolicy(); + if (StringUtils.isBlank(avatarPolicy)) { + avatarPolicy = DEFAULT_USER_AVATAR_ATTACHMENT_POLICY_NAME; + } + 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 ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Authentication::getName) + .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."))) + .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)); + } + + 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 = 6) + String password) { + } + + record ChangePasswordRequest( + @Schema(description = "New password.", requiredMode = REQUIRED, minLength = 6) + String password) { + } + + @NonNull + Mono me(ServerRequest request) { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .filter(auth -> !(auth instanceof TwoFactorAuthentication)) + .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 IListRequest.QueryListRequest { + + private final ServerWebExchange exchange; + + public ListRequest(ServerRequest request) { + super(request.queryParams()); + this.exchange = request.exchange(); + } + + @Schema(name = "keyword") + public String getKeyword() { + return queryParams.getFirst("keyword"); + } + + @Schema(name = "role") + public String getRole() { + return queryParams.getFirst("role"); + } + + @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() { + var sort = SortResolver.defaultInstance.resolve(exchange); + sort = sort.and(Sort.by("metadata.creationTimestamp", "metadata.name").descending()); + return sort; + } + + /** + * 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( + 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) { + IListRequest.buildParameters(builder); + builder.parameter(sortParameter()) + .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).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); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/AnnotationSettingReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/AnnotationSettingReconciler.java new file mode 100644 index 0000000..8b6fce8 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/AnnotationSettingReconciler.java @@ -0,0 +1,54 @@ +package run.halo.app.core.extension.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(); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/AuthProviderReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/AuthProviderReconciler.java new file mode 100644 index 0000000..5176f8a --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/AuthProviderReconciler.java @@ -0,0 +1,50 @@ +package run.halo.app.core.extension.reconciler; + +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.security.AuthProviderService; + +/** + * Reconciler for {@link AuthProvider}. + * + * @author guqing + * @since 2.4.0 + */ +@Component +@RequiredArgsConstructor +public class AuthProviderReconciler implements Reconciler { + 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(); + } + } + + private boolean privileged(AuthProvider authProvider) { + return BooleanUtils.TRUE.equals(MetadataUtil.nullSafeLabels(authProvider) + .get(AuthProvider.PRIVILEGED_LABEL)); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java new file mode 100644 index 0000000..d4abf48 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java @@ -0,0 +1,121 @@ +package run.halo.app.core.extension.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.util.Map; +import java.util.Set; +import lombok.AllArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +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.event.post.CategoryHiddenStateChangeEvent; +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; + +/** + * Reconciler for {@link Category}. + * + * @author guqing + * @since 2.0.0 + */ +@Component +@AllArgsConstructor +public class CategoryReconciler implements Reconciler { + 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); + 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() + .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) { + Map annotations = nullSafeAnnotations(category); + String newPattern = categoryPermalinkPolicy.pattern(); + annotations.put(Constant.PERMALINK_PATTERN_ANNO, newPattern); + } + + void populatePermalink(Category category) { + category.getStatusOrDefault() + .setPermalink(categoryPermalinkPolicy.permalink(category)); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/CommentReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/CommentReconciler.java new file mode 100644 index 0000000..4e9ce52 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/CommentReconciler.java @@ -0,0 +1,193 @@ +package run.halo.app.core.extension.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.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.isNull; + +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import run.halo.app.content.comment.ReplyNotificationSubscriptionHelper; +import run.halo.app.content.comment.ReplyService; +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.DefaultExtensionMatcher; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.GroupVersionKind; +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.Query; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.metrics.MeterUtils; + +/** + * Reconciler for {@link Comment}. + * + * @author guqing + * @since 2.0.0 + */ +@Component +@RequiredArgsConstructor +public class CommentReconciler implements Reconciler { + 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) + .onAddMatcher(DefaultExtensionMatcher.builder(client, extension.groupVersionKind()) + .fieldSelector(FieldSelector.of( + equal(Comment.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, BooleanUtils.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(); + GroupVersionKind groupVersionKind = groupVersionKind(commentSubjectRef); + + var totalCount = countTotalComments(commentSubjectRef); + var approvedTotalCount = countApprovedComments(commentSubjectRef); + schemeManager.fetch(groupVersionKind).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 Query 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(); + + // decrement total comment count + updateSameSubjectRefCommentCounter(comment); + } + + @NonNull + private GroupVersionKind groupVersionKind(@NonNull Ref ref) { + return new GroupVersionKind(ref.getGroup(), ref.getVersion(), ref.getKind()); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/MenuItemReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/MenuItemReconciler.java new file mode 100644 index 0000000..9d27680 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/MenuItemReconciler.java @@ -0,0 +1,135 @@ +package run.halo.app.core.extension.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); + }); + } + +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/PluginReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/PluginReconciler.java new file mode 100644 index 0000000..145c7df --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/PluginReconciler.java @@ -0,0 +1,846 @@ +package run.halo.app.core.extension.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.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +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.function.Predicate; +import java.util.function.Supplier; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.pf4j.PluginDependency; +import org.pf4j.PluginState; +import org.pf4j.PluginWrapper; +import org.pf4j.RuntimeMode; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ResourceUtils; +import org.springframework.web.util.UriComponentsBuilder; +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.core.extension.theme.SettingUtils; +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.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.YamlUnstructuredLoader; +import run.halo.app.plugin.PluginConst; +import run.halo.app.plugin.PluginProperties; +import run.halo.app.plugin.SpringPluginManager; + +/** + * Plugin reconciler. + * + * @author guqing + * @author johnniang + * @since 2.0.0 + */ +@Slf4j +@Component +public class PluginReconciler implements Reconciler { + 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 Clock clock; + + public PluginReconciler(ExtensionClient client, SpringPluginManager pluginManager, + PluginProperties pluginProperties) { + this.client = client; + this.pluginManager = pluginManager; + this.pluginProperties = pluginProperties; + this.clock = Clock.systemUTC(); + } + + /** + * Only for testing. + * + * @param clock new clock. + */ + void setClock(Clock clock) { + this.clock = clock; + } + + @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); + } + // 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(); + status.setPhase(Plugin.Phase.STARTING); + + // check if the parent plugin is started + var pw = pluginManager.getPlugin(pluginName); + var unstartedDependencies = pw.getDescriptor().getDependencies() + .stream() + .filter(pd -> { + if (pd.isOptional()) { + return false; + } + var parent = pluginManager.getPlugin(pd.getPluginId()); + return parent == null || !PluginState.STARTED.equals(parent.getPluginState()); + }) + .map(PluginDependency::getPluginId) + .toList(); + 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(1)); + } + + try { + var pluginState = pluginManager.startPlugin(pluginName); + if (!PluginState.STARTED.equals(pluginState)) { + throw new IllegalStateException(""" + Failed to start plugin %s(%s).\ + """.formatted(pluginName, pluginState)); + } + } catch (Throwable e) { + conditions.addAndEvictFIFO(Condition.builder() + .type(ConditionType.READY) + .status(ConditionStatus.FALSE) + .reason(ConditionReason.START_ERROR) + .message(e.getMessage()) + .lastTransitionTime(clock.instant()) + .build()); + status.setPhase(Plugin.Phase.FAILED); + return Result.doNotRetry(); + } + + 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); + + log.info("Started plugin {}", pluginName); + return null; + } + + 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 { + pluginManager.disablePlugin(pluginName); + } catch (Throwable 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 = plugin.getSpec().getPluginDependencies().keySet() + .stream() + .filter(dependency -> { + var parentPlugin = pluginManager.getPlugin(dependency); + return parentPlugin == null + || pluginManager.getUnresolvedPlugins().contains(parentPlugin); + }) + .sorted() + .toList(); + 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)); + log.info("Loaded plugin {} from {}", pluginName, loadLocation); + } + + conditions.addAndEvictFIFO(Condition.builder() + .type(ConditionType.INITIALIZED) + .status(ConditionStatus.TRUE) + .reason(ConditionReason.LOADED) + .lastTransitionTime(clock.instant()) + .build()); + plugin.getStatus().setPhase(Plugin.Phase.RESOLVED); + 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(); + status.setLoadLocation(loadLocation); + } 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 + pluginPathAnno = generateFileName(plugin); + annotations.put(PLUGIN_PATH, pluginPathAnno); + var pluginPath = Paths.get(pluginPathAnno); + var pluginsRoot = getPluginsRoot(); + if (pluginPath.isAbsolute()) { + if (pluginPath.startsWith(pluginsRoot)) { + // ensure the plugin path is a relative path. + annotations.put(PLUGIN_PATH, pluginsRoot.relativize(pluginPath).toString()); + } + } else { + pluginPath = pluginsRoot.resolve(pluginPath); + } + + // 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); + } + } + status.setLoadLocation(newLoadLocation); + } + + status.getConditions().addAndEvictFIFO(Condition.builder() + .type(ConditionType.INITIALIZED) + .status(ConditionStatus.TRUE) + .reason(ConditionReason.LOAD_LOCATION_RESOLVED) + .lastTransitionTime(clock.instant()) + .build()); + status.setPhase(Plugin.Phase.RESOLVED); + log.debug("Populated load location {} for plugin {}", status.getLoadLocation(), pluginName); + return null; + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return builder + .extension(new Plugin()) + .build(); + } + + 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 Path getPluginsRoot() { + return pluginManager.getPluginsRoots().stream() + .findFirst() + .orElseThrow( + () -> new IllegalStateException("pluginsRoots have not been initialized, yet.")); + } + + 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 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"; + } + +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/PostCounterReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/PostCounterReconciler.java new file mode 100644 index 0000000..a6986a5 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/PostCounterReconciler.java @@ -0,0 +1,53 @@ +package run.halo.app.core.extension.reconciler; + +import static run.halo.app.extension.index.query.QueryFactory.startsWith; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +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.DefaultExtensionMatcher; +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.router.selector.FieldSelector; +import run.halo.app.metrics.MeterUtils; + +@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) + .onAddMatcher(DefaultExtensionMatcher.builder(client, extension.groupVersionKind()) + .fieldSelector(FieldSelector.of( + startsWith("metadata.name", MeterUtils.nameOf(Post.class, ""))) + ) + .build()) + .build(); + } + + static boolean isSameAsPost(String name) { + return name.startsWith(MeterUtils.nameOf(Post.class, "")); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java new file mode 100644 index 0000000..29d5469 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java @@ -0,0 +1,439 @@ +package run.halo.app.core.extension.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.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.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.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.DefaultExtensionMatcher; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.ExtensionOperator; +import run.halo.app.extension.ListOptions; +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.QueryFactory; +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.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; +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 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); + } + + 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().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()) + .onAddMatcher(DefaultExtensionMatcher.builder(client, Post.GVK) + .fieldSelector(FieldSelector.of( + 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(); + } + + 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(); + + // delete counter + counterService.deleteByName(MeterUtils.nameOf(Post.class, post.getMetadata().getName())) + .block(); + } + + private String getExcerpt(Post post) { + Optional contentWrapper = + postService.getContent(post.getSpec().getReleaseSnapshot(), + post.getSpec().getBaseSnapshot()) + .blockOptional(); + if (contentWrapper.isEmpty()) { + return StringUtils.EMPTY; + } + var content = contentWrapper.get(); + 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() + .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( + QueryFactory.equal("spec.subjectRef", Snapshot.toSubjectRefKey(ref)))); + return client.listAll(Snapshot.class, snapshotListOptions, Sort.unsorted()); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/ReplyReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/ReplyReconciler.java new file mode 100644 index 0000000..3dc0b89 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/ReplyReconciler.java @@ -0,0 +1,98 @@ +package run.halo.app.core.extension.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.QueryFactory.equal; + +import java.util.Set; +import lombok.AllArgsConstructor; +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.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.DefaultExtensionMatcher; +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.router.selector.FieldSelector; + +/** + * 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) + .onAddMatcher(DefaultExtensionMatcher.builder(client, extension.groupVersionKind()) + .fieldSelector(FieldSelector.of( + equal(Reply.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, BooleanUtils.TRUE)) + ) + .build() + ) + .build(); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/ReverseProxyReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/ReverseProxyReconciler.java new file mode 100644 index 0000000..e0a34ca --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/ReverseProxyReconciler.java @@ -0,0 +1,105 @@ +package run.halo.app.core.extension.reconciler; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +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 StringUtils.defaultString(labels.get(PluginConst.PLUGIN_NAME_LABEL_NAME), + PluginConst.SYSTEM_PLUGIN_NAME); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/RoleReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/RoleReconciler.java new file mode 100644 index 0000000..e3d6311 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/RoleReconciler.java @@ -0,0 +1,65 @@ +package run.halo.app.core.extension.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); + }); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java new file mode 100644 index 0000000..b803b6d --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java @@ -0,0 +1,413 @@ +package run.halo.app.core.extension.reconciler; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.springframework.web.util.UriUtils.encodePath; + +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.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.QueryFactory; +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.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; +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 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(); + } + + 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(); + + // delete counter for single page + counterService.deleteByName( + MeterUtils.nameOf(SinglePage.class, singlePage.getMetadata().getName())) + .block(); + } + + 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(); + if (contentWrapper.isEmpty()) { + return StringUtils.EMPTY; + } + var content = contentWrapper.get(); + 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() + .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( + QueryFactory.equal("spec.subjectRef", Snapshot.toSubjectRefKey(ref)))); + return client.listAll(Snapshot.class, snapshotListOptions, Sort.unsorted()); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/SystemSettingReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/SystemSettingReconciler.java new file mode 100644 index 0000000..f3f7638 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/SystemSettingReconciler.java @@ -0,0 +1,300 @@ +package run.halo.app.core.extension.reconciler; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.Metadata; +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.SystemConfigurableEnvironmentFetcher; +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.router.PermalinkRuleChangedEvent; + +/** + * Reconciler for system settings. + * + * @author guqing + * @since 2.0.0 + */ +@Slf4j +@Component +public class SystemSettingReconciler implements Reconciler { + public static final String OLD_THEME_ROUTE_RULES = "halo.run/old-theme-route-rules"; + public static final String FINALIZER_NAME = "system-setting-protection"; + + private final ExtensionClient client; + private final SystemConfigurableEnvironmentFetcher environmentFetcher; + private final ApplicationContext applicationContext; + + private final RouteRuleReconciler routeRuleReconciler = new RouteRuleReconciler(); + + public SystemSettingReconciler(ExtensionClient client, + SystemConfigurableEnvironmentFetcher environmentFetcher, + ApplicationContext applicationContext) { + this.client = client; + this.environmentFetcher = environmentFetcher; + this.applicationContext = applicationContext; + } + + @Override + public Result reconcile(Request request) { + String name = request.name(); + if (!isSystemSetting(name)) { + return new Result(false, null); + } + client.fetch(ConfigMap.class, name) + .ifPresent(configMap -> { + addFinalizerIfNecessary(configMap); + routeRuleReconciler.reconcile(name); + customizeSystem(name); + }); + return new Result(false, null); + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return builder + .extension(new ConfigMap()) + .build(); + } + + private void customizeSystem(String name) { + if (!SystemSetting.SYSTEM_CONFIG_DEFAULT.equals(name)) { + return; + } + // configMap named system not found then create it by system-default + Optional systemOpt = client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG); + if (systemOpt.isPresent()) { + return; + } + ConfigMap system = client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG_DEFAULT) + .map(configMap -> { + // create a new configMap named system by system-default + ConfigMap systemConfigMap = new ConfigMap(); + systemConfigMap.setMetadata(new Metadata()); + systemConfigMap.getMetadata().setName(SystemSetting.SYSTEM_CONFIG); + systemConfigMap.setData(configMap.getData()); + return systemConfigMap; + }) + .orElseGet(() -> { + // empty configMap named system + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setName(SystemSetting.SYSTEM_CONFIG); + configMap.setData(new HashMap<>()); + return configMap; + }); + client.create(system); + } + + private void addFinalizerIfNecessary(ConfigMap oldConfigMap) { + if (SystemSetting.SYSTEM_CONFIG.equals(oldConfigMap.getMetadata().getName())) { + return; + } + Set finalizers = oldConfigMap.getMetadata().getFinalizers(); + if (finalizers != null && finalizers.contains(FINALIZER_NAME)) { + return; + } + client.fetch(ConfigMap.class, oldConfigMap.getMetadata().getName()) + .ifPresent(configMap -> { + Set newFinalizers = configMap.getMetadata().getFinalizers(); + if (newFinalizers == null) { + newFinalizers = new HashSet<>(); + configMap.getMetadata().setFinalizers(newFinalizers); + } + newFinalizers.add(FINALIZER_NAME); + client.update(configMap); + }); + } + + class RouteRuleReconciler { + + public void reconcile(String name) { + reconcileArchivesRule(name); + reconcileTagsRule(name); + reconcileCategoriesRule(name); + reconcilePostRule(name); + } + + private void reconcileArchivesRule(String name) { + getConfigMap(name).ifPresent(configMap -> { + SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap); + SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap); + + final String oldArchivesPrefix = oldRules.getArchives(); + final String oldPostPattern = oldRules.getPost(); + + // dispatch event + final boolean archivesPrefixChanged = + !StringUtils.equals(oldRules.getArchives(), newRules.getArchives()); + + final boolean postPatternChanged = + changePostPatternPrefixIfNecessary(oldArchivesPrefix, newRules); + + if (archivesPrefixChanged || postPatternChanged) { + oldRules.setPost(newRules.getPost()); + oldRules.setArchives(newRules.getArchives()); + updateNewRuleToConfigMap(configMap, oldRules, newRules); + } + + // archives rule changed + if (archivesPrefixChanged) { + log.debug("Archives prefix changed from [{}] to [{}].", oldArchivesPrefix, + newRules.getArchives()); + applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, + DefaultTemplateEnum.ARCHIVES, + oldArchivesPrefix, newRules.getArchives())); + } + + if (postPatternChanged) { + log.debug("Post pattern changed from [{}] to [{}].", oldPostPattern, + newRules.getPost()); + // post rule changed + applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, + DefaultTemplateEnum.POST, oldPostPattern, newRules.getPost())); + } + }); + } + + private void updateNewRuleToConfigMap(ConfigMap configMap, + SystemSetting.ThemeRouteRules oldRules, + SystemSetting.ThemeRouteRules newRules) { + Map annotations = getAnnotationsSafe(configMap); + annotations.put(OLD_THEME_ROUTE_RULES, JsonUtils.objectToJson(oldRules)); + configMap.getData().put(SystemSetting.ThemeRouteRules.GROUP, + JsonUtils.objectToJson(newRules)); + client.update(configMap); + } + + private void reconcileTagsRule(String name) { + getConfigMap(name).ifPresent(configMap -> { + SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap); + SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap); + final String oldTagsPrefix = oldRules.getTags(); + if (!StringUtils.equals(oldTagsPrefix, newRules.getTags())) { + oldRules.setTags(newRules.getTags()); + updateNewRuleToConfigMap(configMap, oldRules, newRules); + + log.debug("Tags prefix changed from [{}] to [{}].", oldTagsPrefix, + newRules.getTags()); + // then publish event + applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, + DefaultTemplateEnum.TAGS, + oldTagsPrefix, newRules.getTags())); + } + }); + } + + private void reconcileCategoriesRule(String name) { + getConfigMap(name).ifPresent(configMap -> { + SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap); + SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap); + final String oldCategoriesPrefix = oldRules.getCategories(); + if (!StringUtils.equals(oldCategoriesPrefix, newRules.getCategories())) { + oldRules.setCategories(newRules.getCategories()); + updateNewRuleToConfigMap(configMap, oldRules, newRules); + + log.debug("Categories prefix changed from [{}] to [{}].", oldCategoriesPrefix, + newRules.getCategories()); + // categories rule changed + applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, + DefaultTemplateEnum.CATEGORIES, + oldCategoriesPrefix, newRules.getCategories())); + } + }); + } + + private void reconcilePostRule(String name) { + getConfigMap(name).ifPresent(configMap -> { + SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap); + SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap); + + final String oldPostPattern = oldRules.getPost(); + if (!StringUtils.equals(oldPostPattern, newRules.getPost())) { + oldRules.setPost(newRules.getPost()); + updateNewRuleToConfigMap(configMap, oldRules, newRules); + + log.debug("Post pattern changed from [{}] to [{}].", oldPostPattern, + newRules.getPost()); + // post rule changed + applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, + DefaultTemplateEnum.POST, + oldPostPattern, newRules.getPost())); + } + }); + } + + static boolean changePostPatternPrefixIfNecessary(String oldArchivePrefix, + SystemSetting.ThemeRouteRules newRules) { + if (StringUtils.isBlank(oldArchivePrefix) + || StringUtils.isBlank(newRules.getPost())) { + return false; + } + String newArchivesPrefix = newRules.getArchives(); + if (StringUtils.equals(oldArchivePrefix, newArchivesPrefix)) { + return false; + } + + String oldPrefix = StringUtils.removeStart(oldArchivePrefix, "/"); + String postPattern = StringUtils.removeStart(newRules.getPost(), "/"); + + if (postPattern.startsWith(oldPrefix)) { + String postPatternToUpdate = PathUtils.combinePath(newArchivesPrefix, + StringUtils.removeStart(postPattern, oldPrefix)); + newRules.setPost(postPatternToUpdate); + return true; + } + return false; + } + + private SystemSetting.ThemeRouteRules getOldRouteRulesFromAnno(ConfigMap configMap) { + Map annotations = getAnnotationsSafe(configMap); + String oldRulesJson = annotations.get(OLD_THEME_ROUTE_RULES); + + // old rules is empty, means this is the first time to update theme route rules + if (oldRulesJson == null) { + oldRulesJson = "{}"; + } + + // diff old rules and new rules + return JsonUtils.jsonToObject(oldRulesJson, SystemSetting.ThemeRouteRules.class); + } + + private SystemSetting.ThemeRouteRules getRouteRules(ConfigMap configMap) { + Map data = configMap.getData(); + // get new rules and replace old rules to new rules + return JsonUtils.jsonToObject(data.get(SystemSetting.ThemeRouteRules.GROUP), + SystemSetting.ThemeRouteRules.class); + } + + private Map getAnnotationsSafe(ConfigMap configMap) { + Map annotations = configMap.getMetadata().getAnnotations(); + if (annotations == null) { + annotations = new HashMap<>(); + configMap.getMetadata().setAnnotations(annotations); + } + return annotations; + } + } + + public boolean isSystemSetting(String name) { + return SystemSetting.SYSTEM_CONFIG.equals(name) + || SystemSetting.SYSTEM_CONFIG_DEFAULT.equals(name); + } + + private Optional getConfigMap(String name) { + return environmentFetcher.getConfigMapBlocking(); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/TagReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/TagReconciler.java new file mode 100644 index 0000000..1318e6c --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/TagReconciler.java @@ -0,0 +1,79 @@ +package run.halo.app.core.extension.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.QueryFactory.equal; + +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.BooleanUtils; +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.DefaultExtensionMatcher; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.ExtensionUtil; +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.router.selector.FieldSelector; + +/** + * 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); + + String newPattern = tagPermalinkPolicy.pattern(); + annotations.put(Constant.PERMALINK_PATTERN_ANNO, newPattern); + + String permalink = tagPermalinkPolicy.permalink(tag); + var status = tag.getStatusOrDefault(); + status.setPermalink(permalink); + + // 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()) + .onAddMatcher(DefaultExtensionMatcher.builder(client, Tag.GVK) + .fieldSelector(FieldSelector.of( + equal(Tag.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, BooleanUtils.TRUE)) + ) + .build() + ) + .build(); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java new file mode 100644 index 0000000..0899e0a --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java @@ -0,0 +1,229 @@ +package run.halo.app.core.extension.reconciler; + +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; + +import java.io.IOException; +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import org.apache.commons.lang3.StringUtils; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.FileSystemUtils; +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.theme.SettingUtils; +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.JsonUtils; +import run.halo.app.infra.utils.VersionUtils; + +/** + * Reconciler for theme. + * + * @author guqing + * @since 2.0.0 + */ +@Component +public class ThemeReconciler implements Reconciler { + private static final String FINALIZER_NAME = "theme-protection"; + + private final ExtensionClient client; + + private final ThemeRootGetter themeRoot; + private final SystemVersionSupplier systemVersionSupplier; + + private final RetryTemplate retryTemplate = RetryTemplate.builder() + .maxAttempts(20) + .fixedBackoff(300) + .retryOn(IllegalStateException.class) + .build(); + + public ThemeReconciler(ExtensionClient client, ThemeRootGetter themeRoot, + SystemVersionSupplier systemVersionSupplier) { + this.client = client; + this.themeRoot = themeRoot; + this.systemVersionSupplier = systemVersionSupplier; + } + + @Override + public Result reconcile(Request request) { + client.fetch(Theme.class, request.name()) + .ifPresent(theme -> { + if (isDeleted(theme)) { + cleanUpResourcesAndRemoveFinalizer(request.name()); + return; + } + addFinalizerIfNecessary(theme); + themeSettingDefaultConfig(theme); + reconcileStatus(request.name()); + }); + return new Result(false, null); + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return builder + .extension(new Theme()) + .build(); + } + + void reconcileStatus(String name) { + client.fetch(Theme.class, name).ifPresent(theme -> { + final Theme.ThemeStatus status = + defaultIfNull(theme.getStatus(), new Theme.ThemeStatus()); + final Theme.ThemeStatus oldStatus = JsonUtils.deepCopy(status); + theme.setStatus(status); + + var themePath = themeRoot.get().resolve(name); + status.setLocation(themePath.toAbsolutePath().toString()); + + status.setPhase(Theme.ThemePhase.READY); + Condition.ConditionBuilder 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. + String normalVersion = systemVersionSupplier.get().getNormalVersion(); + String 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()); + + if (!Objects.equals(oldStatus, status)) { + client.update(theme); + } + }); + } + + private void themeSettingDefaultConfig(Theme theme) { + if (StringUtils.isBlank(theme.getSpec().getSettingName())) { + return; + } + final String userDefinedConfigMapName = theme.getSpec().getConfigMapName(); + + final String newConfigMapName = UUID.randomUUID().toString(); + if (StringUtils.isBlank(userDefinedConfigMapName)) { + client.fetch(Theme.class, theme.getMetadata().getName()) + .ifPresent(themeToUse -> { + Theme oldTheme = JsonUtils.deepCopy(themeToUse); + themeToUse.getSpec().setConfigMapName(newConfigMapName); + if (!oldTheme.equals(themeToUse)) { + client.update(themeToUse); + } + }); + } + + final String configMapNameToUse = + StringUtils.defaultIfBlank(userDefinedConfigMapName, newConfigMapName); + SettingUtils.createOrUpdateConfigMap(client, theme.getSpec().getSettingName(), + configMapNameToUse); + } + + private void addFinalizerIfNecessary(Theme oldTheme) { + Set finalizers = oldTheme.getMetadata().getFinalizers(); + if (finalizers != null && finalizers.contains(FINALIZER_NAME)) { + return; + } + client.fetch(Theme.class, oldTheme.getMetadata().getName()) + .ifPresent(theme -> { + Set newFinalizers = theme.getMetadata().getFinalizers(); + if (newFinalizers == null) { + newFinalizers = new HashSet<>(); + theme.getMetadata().setFinalizers(newFinalizers); + } + newFinalizers.add(FINALIZER_NAME); + client.update(theme); + }); + } + + private void cleanUpResourcesAndRemoveFinalizer(String themeName) { + client.fetch(Theme.class, themeName).ifPresent(theme -> { + reconcileThemeDeletion(theme); + if (theme.getMetadata().getFinalizers() != null) { + theme.getMetadata().getFinalizers().remove(FINALIZER_NAME); + } + client.update(theme); + }); + } + + private void reconcileThemeDeletion(Theme theme) { + deleteThemeFiles(theme); + // delete theme setting form + String settingName = theme.getSpec().getSettingName(); + if (StringUtils.isNotBlank(settingName)) { + client.fetch(Setting.class, settingName) + .ifPresent(client::delete); + retryTemplate.execute(callback -> { + client.fetch(Setting.class, settingName).ifPresent(setting -> { + throw new IllegalStateException("Waiting for setting to be deleted."); + }); + return null; + }); + } + // delete annotation setting + deleteAnnotationSettings(theme.getMetadata().getName()); + } + + private void deleteAnnotationSettings(String themeName) { + List result = listAnnotationSettingsByThemeName(themeName); + + for (AnnotationSetting annotationSetting : result) { + client.delete(annotationSetting); + } + + retryTemplate.execute(callback -> { + List annotationSettings = + listAnnotationSettingsByThemeName(themeName); + if (annotationSettings.isEmpty()) { + return null; + } + throw new IllegalStateException("Waiting for annotation settings to be deleted."); + }); + } + + 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); + } + } + + private boolean isDeleted(Theme theme) { + return theme.getMetadata().getDeletionTimestamp() != null; + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java new file mode 100644 index 0000000..c74d087 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java @@ -0,0 +1,197 @@ +package run.halo.app.core.extension.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.QueryFactory.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.extension.service.RoleService; +import run.halo.app.core.extension.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; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserReconciler implements Reconciler { + 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() + .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(Duration.ofMinutes(1)) + ) + .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(Duration.ofMinutes(1)); + } + + 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(); + } + +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/attachment/AttachmentReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/attachment/AttachmentReconciler.java new file mode 100644 index 0000000..d525e2f --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/attachment/AttachmentReconciler.java @@ -0,0 +1,125 @@ +package run.halo.app.core.extension.reconciler.attachment; + +import java.net.URI; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +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.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.ExternalUrlSupplier; + +@Slf4j +@Component +public class AttachmentReconciler implements Reconciler { + + private final ExtensionClient client; + + private final ExternalUrlSupplier externalUrl; + + private final AttachmentService attachmentService; + + public AttachmentReconciler(ExtensionClient client, + ExternalUrlSupplier externalUrl, AttachmentService attachmentService) { + this.client = client; + this.externalUrl = externalUrl; + this.attachmentService = attachmentService; + } + + @Override + public Result reconcile(Request request) { + client.fetch(Attachment.class, request.name()).ifPresent(attachment -> { + // TODO Handle the finalizer + if (attachment.getMetadata().getDeletionTimestamp() != null) { + removeFinalizer(attachment); + return; + } + // add finalizer + addFinalizerIfNotSet(request.name(), attachment.getMetadata().getFinalizers()); + var annotations = attachment.getMetadata().getAnnotations(); + if (annotations != null) { + attachmentService.getPermalink(attachment) + .map(URI::toString) + .switchIfEmpty(Mono.fromSupplier(() -> { + // Only for back-compatibility + return annotations.get(Constant.EXTERNAL_LINK_ANNO_KEY); + })) + .doOnNext(permalink -> { + log.debug("Set permalink {} for attachment {}", permalink, request.name()); + var status = attachment.getStatus(); + if (status == null) { + status = new AttachmentStatus(); + attachment.setStatus(status); + } + status.setPermalink(permalink); + }) + .blockOptional(); + } + updateStatus(request.name(), attachment.getStatus()); + }); + return null; + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return builder + .extension(new Attachment()) + .build(); + } + + void updateStatus(String attachmentName, AttachmentStatus status) { + client.fetch(Attachment.class, attachmentName) + .filter(attachment -> !Objects.deepEquals(attachment.getStatus(), status)) + .ifPresent(attachment -> { + attachment.setStatus(status); + client.update(attachment); + }); + } + + void removeFinalizer(Attachment oldAttachment) { + if (!hasFinalizer(oldAttachment, Constant.FINALIZER_NAME)) { + return; + } + attachmentService.delete(oldAttachment).block(); + client.fetch(Attachment.class, oldAttachment.getMetadata().getName()) + .ifPresent(attachment -> { + var finalizers = attachment.getMetadata().getFinalizers(); + if (hasFinalizer(attachment, Constant.FINALIZER_NAME) + && finalizers.remove(Constant.FINALIZER_NAME)) { + // update it + client.update(attachment); + } + }); + } + + boolean hasFinalizer(Attachment attachment, String finalizer) { + var finalizers = attachment.getMetadata().getFinalizers(); + return finalizers != null && finalizers.contains(finalizer); + } + + void addFinalizerIfNotSet(String attachmentName, Set existingFinalizers) { + if (existingFinalizers != null && existingFinalizers.contains(Constant.FINALIZER_NAME)) { + return; + } + + client.fetch(Attachment.class, attachmentName).ifPresent(attachment -> { + var finalizers = attachment.getMetadata().getFinalizers(); + if (finalizers == null) { + finalizers = new HashSet<>(); + attachment.getMetadata().setFinalizers(finalizers); + } + finalizers.add(Constant.FINALIZER_NAME); + client.update(attachment); + }); + } + +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java b/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java new file mode 100644 index 0000000..6351643 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java @@ -0,0 +1,252 @@ +package run.halo.app.core.extension.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.QueryFactory; +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(QueryFactory.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.empty(); + } + var subjects = usernames.stream().map(DefaultRoleService::toUserSubject) + .map(Object::toString) + .collect(Collectors.toSet()); + var listOptions = ListOptions.builder() + .andQuery(notDeleting()) + .andQuery(QueryFactory.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(QueryFactory.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 listOptionsBuilder = Optional.ofNullable(additionalListOptions) + .map(ListOptions::builder) + .orElseGet(ListOptions::builder); + roleNames.stream() + .map(roleName -> Role.ROLE_AGGREGATE_LABEL_PREFIX + roleName) + .forEach( + label -> listOptionsBuilder.labelSelector().eq(label, Boolean.TRUE.toString()) + ); + return client.listAll(Role.class, listOptionsBuilder.build(), 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(QueryFactory.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<>() { + }); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/EmailPasswordRecoveryService.java b/application/src/main/java/run/halo/app/core/extension/service/EmailPasswordRecoveryService.java new file mode 100644 index 0000000..3e1a977 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/EmailPasswordRecoveryService.java @@ -0,0 +1,38 @@ +package run.halo.app.core.extension.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); + + /** + *

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 username username to reset password + * @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 username, String newPassword, String token); +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/EmailVerificationService.java b/application/src/main/java/run/halo/app/core/extension/service/EmailVerificationService.java new file mode 100644 index 0000000..762f92a --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/EmailVerificationService.java @@ -0,0 +1,47 @@ +package run.halo.app.core.extension.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); +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/PluginService.java b/application/src/main/java/run/halo/app/core/extension/service/PluginService.java new file mode 100644 index 0000000..7c86444 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/PluginService.java @@ -0,0 +1,113 @@ +package run.halo.app.core.extension.service; + +import java.nio.file.Path; +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 { + + Flux getPresets(); + + /** + * Gets a plugin information by preset name from plugin presets. + * + * @param presetName is preset name of plugin. + * @return plugin preset information. + */ + Mono getPreset(String presetName); + + /** + * 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); + +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/RoleService.java b/application/src/main/java/run/halo/app/core/extension/service/RoleService.java new file mode 100644 index 0000000..d22beff --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/RoleService.java @@ -0,0 +1,53 @@ +package run.halo.app.core.extension.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); +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/UserService.java b/application/src/main/java/run/halo/app/core/extension/service/UserService.java new file mode 100644 index 0000000..f252d15 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/UserService.java @@ -0,0 +1,29 @@ +package run.halo.app.core.extension.service; + +import java.util.Set; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.User; + +public interface UserService { + + Mono getUser(String username); + + Mono getUserOrGhost(String username); + + Mono updatePassword(String username, String newPassword); + + Mono updateWithRawPassword(String username, String rawPassword); + + Mono grantRoles(String username, Set roles); + + Mono signUp(User user, String password); + + Mono createUser(User user, Set roles); + + Mono confirmPassword(String username, String rawPassword); + + Flux listByEmail(String email); + + String encryptPassword(String rawPassword); +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java b/application/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java new file mode 100644 index 0000000..eb55946 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java @@ -0,0 +1,240 @@ +package run.halo.app.core.extension.service; + +import static org.springframework.data.domain.Sort.Order.asc; +import static org.springframework.data.domain.Sort.Order.desc; +import static run.halo.app.extension.index.query.QueryFactory.equal; + +import java.time.Clock; +import java.time.Duration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.domain.Sort; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +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.event.user.PasswordChangedEvent; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.exception.ExtensionNotFoundException; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.exception.AccessDeniedException; +import run.halo.app.infra.exception.DuplicateNameException; +import run.halo.app.infra.exception.UserNotFoundException; + +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + public static final String GHOST_USER_NAME = "ghost"; + + private final ReactiveExtensionClient client; + + private final PasswordEncoder passwordEncoder; + + private final SystemConfigurableEnvironmentFetcher environmentFetcher; + + private final ApplicationEventPublisher eventPublisher; + + private final RoleService roleService; + + 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 getUserOrGhost(String username) { + return client.fetch(User.class, username) + .switchIfEmpty(Mono.defer(() -> client.get(User.class, GHOST_USER_NAME))); + } + + @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) { + 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) { + return client.get(User.class, username) + .flatMap(user -> { + 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); + return roleService.listRoleBindings(subject) + .doOnNext(binding -> { + var roleName = binding.getRoleRef().getName(); + if (roles.contains(roleName)) { + existingRoles.add(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); + } + }) + .thenMany(Flux.fromIterable(bindingsToUpdate).flatMap(client::update)) + .thenMany(Flux.fromIterable(bindingsToDelete).flatMap(client::delete)) + .thenMany(Flux.fromStream(() -> { + var mutableRoles = new HashSet<>(roles); + mutableRoles.removeAll(existingRoles); + return mutableRoles.stream() + .map(roleName -> RoleBinding.create(username, roleName)); + }).flatMap(client::create)) + .then(Mono.defer(() -> { + var annotations = Optional.ofNullable(user.getMetadata().getAnnotations()) + .orElseGet(HashMap::new); + user.getMetadata().setAnnotations(annotations); + annotations.put(User.REQUEST_TO_UPDATE, clock.instant().toString()); + return client.update(user); + })); + }); + } + + @Override + public Mono signUp(User user, String password) { + if (!StringUtils.hasText(password)) { + throw new IllegalArgumentException("Password must not be blank"); + } + return environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class) + .switchIfEmpty(Mono.error(new IllegalStateException("User setting is not configured"))) + .flatMap(userSetting -> { + Boolean allowRegistration = userSetting.getAllowRegistration(); + if (BooleanUtils.isFalse(allowRegistration)) { + return Mono.error(new AccessDeniedException("Registration is not allowed", + "problemDetail.user.signUpFailed.disallowed", + null)); + } + String defaultRole = userSetting.getDefaultRole(); + if (!StringUtils.hasText(defaultRole)) { + return Mono.error(new AccessDeniedException( + "Default registration role is not configured by admin", + "problemDetail.user.signUpFailed.disallowed", + null)); + } + String encodedPassword = passwordEncoder.encode(password); + user.getSpec().setPassword(encodedPassword); + 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(Mono.defer(() -> client.create(user) + .flatMap(newUser -> grantRoles(user.getMetadata().getName(), roleNames) + .retryWhen( + Retry.backoff(5, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance) + ) + )) + ); + } + + @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))); + return client.listAll(User.class, listOptions, Sort.by(desc("metadata.creationTimestamp"), + asc("metadata.name")) + ); + } + + @Override + public String encryptPassword(String rawPassword) { + return passwordEncoder.encode(rawPassword); + } + + void publishPasswordChangedEvent(String username) { + eventPublisher.publishEvent(new PasswordChangedEvent(this, username)); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/impl/DefaultAttachmentService.java b/application/src/main/java/run/halo/app/core/extension/service/impl/DefaultAttachmentService.java new file mode 100644 index 0000000..27c885e --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/impl/DefaultAttachmentService.java @@ -0,0 +1,140 @@ +package run.halo.app.core.extension.service.impl; + +import java.net.URI; +import java.time.Duration; +import java.util.function.Consumer; +import java.util.function.Function; +import org.springframework.core.io.buffer.DataBuffer; +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.extension.attachment.Attachment; +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.plugin.extensionpoint.ExtensionGetter; + +@Component +public class DefaultAttachmentService implements AttachmentService { + + private final ReactiveExtensionClient client; + + private final ExtensionGetter extensionGetter; + + public DefaultAttachmentService(ReactiveExtensionClient client, + ExtensionGetter extensionGetter) { + this.client = client; + this.extensionGetter = extensionGetter; + } + + @Override + public Mono upload( + @NonNull String username, + @NonNull String policyName, + @Nullable String groupName, + @NonNull FilePart filePart, + @Nullable Consumer beforeCreating) { + return client.get(Policy.class, policyName) + .flatMap(policy -> { + var configMapName = policy.getSpec().getConfigMapName(); + if (!StringUtils.hasText(configMapName)) { + return Mono.error(new ServerWebInputException( + "ConfigMap name not found in Policy " + policyName)); + } + return client.get(ConfigMap.class, configMapName) + .map(configMap -> new UploadOption(filePart, policy, configMap)); + }) + .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() + ) + ); + } + + private Mono authenticationConsumer(Function> func) { + return ReactiveSecurityContextHolder.getContext() + .switchIfEmpty(Mono.error(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "Authentication required."))) + .map(SecurityContext::getAuthentication) + .flatMap(func); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImpl.java b/application/src/main/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImpl.java new file mode 100644 index 0000000..4fbe8c8 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImpl.java @@ -0,0 +1,208 @@ +package run.halo.app.core.extension.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 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 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.extension.service.EmailPasswordRecoveryService; +import run.halo.app.core.extension.service.UserService; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.ExternalLinkProcessor; +import run.halo.app.infra.exception.AccessDeniedException; +import run.halo.app.infra.exception.RateLimitExceededException; +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; + static final String RESET_PASSWORD_BY_EMAIL_REASON_TYPE = "reset-password-by-email"; + + private final ResetPasswordVerificationManager resetPasswordVerificationManager = + new ResetPasswordVerificationManager(); + private final ExternalLinkProcessor externalLinkProcessor; + private final ReactiveExtensionClient client; + private final NotificationReasonEmitter reasonEmitter; + private final NotificationCenter notificationCenter; + private final UserService userService; + + @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 changePassword(String username, String newPassword, String token) { + Assert.state(StringUtils.isNotBlank(username), "Username must not be blank"); + Assert.state(StringUtils.isNotBlank(newPassword), "NewPassword must not be blank"); + Assert.state(StringUtils.isNotBlank(token), "Token for reset password must not be blank"); + var verified = resetPasswordVerificationManager.verifyToken(username, token); + if (!verified) { + return Mono.error(AccessDeniedException::new); + } + return userService.updateWithRawPassword(username, newPassword) + .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance)) + .flatMap(user -> { + resetPasswordVerificationManager.removeToken(username); + return unSubscribeResetPasswordEmailNotification(user.getSpec().getEmail()); + }) + .then(); + } + + 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)); + } + + Mono sendResetPasswordNotification(String username, String email) { + var token = resetPasswordVerificationManager.generateToken(username); + var link = getResetPasswordLink(username, token); + + var subscribeNotification = autoSubscribeResetPasswordEmailNotification(email); + 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 Mono.when(subscribeNotification).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 String getResetPasswordLink(String username, String token) { + return externalLinkProcessor.processLink( + "/uc/reset-password/" + username + "?reset_password_token=" + token); + } + + static class ResetPasswordVerificationManager { + private final Cache userTokenCache = + CacheBuilder.newBuilder() + .expireAfterWrite(LINK_EXPIRATION_MINUTES, TimeUnit.MINUTES) + .maximumSize(10000) + .build(); + + private final Cache + blackListCache = CacheBuilder.newBuilder() + .expireAfterWrite(Duration.ofHours(2)) + .maximumSize(1000) + .build(); + + public boolean verifyToken(String username, String token) { + var verification = userTokenCache.getIfPresent(username); + if (verification == null) { + // expired or not generated + return false; + } + if (blackListCache.getIfPresent(username) != null) { + // in blacklist + throw new RateLimitExceededException(null); + } + synchronized (verification) { + if (verification.getAttempts().get() >= MAX_ATTEMPTS) { + // add to blacklist to prevent brute force attack + blackListCache.put(username, true); + return false; + } + if (!verification.getToken().equals(token)) { + verification.getAttempts().incrementAndGet(); + return false; + } + } + return true; + } + + public void removeToken(String username) { + userTokenCache.invalidate(username); + } + + public String generateToken(String username) { + Assert.state(StringUtils.isNotBlank(username), "Username must not be blank"); + var verification = new Verification(); + verification.setToken(RandomStringUtils.randomAlphanumeric(20)); + verification.setAttempts(new AtomicInteger(0)); + userTokenCache.put(username, verification); + return verification.getToken(); + } + + /** + * Only for test. + */ + boolean contains(String username) { + return userTokenCache.getIfPresent(username) != null; + } + + @Data + @Accessors(chain = true) + static class Verification { + private String token; + private AtomicInteger attempts; + } + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImpl.java b/application/src/main/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImpl.java new file mode 100644 index 0000000..857b464 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImpl.java @@ -0,0 +1,273 @@ +package run.halo.app.core.extension.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 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.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.extension.service.EmailVerificationService; +import run.halo.app.core.extension.service.UserService; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.MetadataUtil; +import run.halo.app.extension.ReactiveExtensionClient; +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 + */ +@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; + private final UserService userService; + + @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.equals(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) { + return userService.listByEmail(emailToVerify) + .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, 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.just(emailVerificationManager.verifyCode(email, email, code)); + } + + Mono sendVerificationNotification(String username, String email) { + var code = emailVerificationManager.generateCode(username, email); + 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) { + } + + @Data + @Accessors(chain = true) + static class Verification { + private String code; + private AtomicInteger attempts; + } + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/impl/PluginServiceImpl.java b/application/src/main/java/run/halo/app/core/extension/service/impl/PluginServiceImpl.java new file mode 100644 index 0000000..817ea1d --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/impl/PluginServiceImpl.java @@ -0,0 +1,638 @@ +package run.halo.app.core.extension.service.impl; + +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.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.pf4j.DependencyResolver; +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.stereotype.Component; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.FileSystemUtils; +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.core.extension.service.PluginService; +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.PluginConst; +import run.halo.app.plugin.PluginUtils; +import run.halo.app.plugin.PluginsRootGetter; +import run.halo.app.plugin.SpringPluginManager; +import run.halo.app.plugin.YamlPluginDescriptorFinder; +import run.halo.app.plugin.YamlPluginFinder; +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 Flux getPresets() { + // list presets from classpath + return Flux.defer(() -> getPresetJars() + .map(this::toPath) + .map(path -> new YamlPluginFinder().find(path))); + } + + @Override + public Mono getPreset(String presetName) { + return getPresets() + .filter(plugin -> Objects.equals(plugin.getMetadata().getName(), presetName)) + .next(); + } + + @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 = List.copyOf(pluginManager.getStartedPlugins()); + String plugins = """ + this.enabledPlugins = [%s] + """.formatted(startedPlugins.stream() + .map(plugin -> """ + { + "name": "%s", + "version": "%s" + } + """.formatted(plugin.getPluginId(), plugin.getDescriptor().getVersion()) + ) + .collect(Collectors.joining(", "))); + return Flux.fromIterable(startedPlugins) + .mapNotNull(pluginWrapper -> { + var pluginName = pluginWrapper.getPluginId(); + return BundleResourceUtils.getJsBundleResource(pluginManager, pluginName, + BundleResourceUtils.JS_BUNDLE); + }) + .flatMap(resource -> { + try { + // Specifying bufferSize as resource content length is + // to append line breaks at the end of each plugin + return DataBufferUtils.read(resource, DefaultDataBufferFactory.sharedInstance, + (int) resource.contentLength()) + .doOnNext(dataBuffer -> { + // add a new line after each plugin bundle to avoid syntax error + dataBuffer.write("\n".getBytes(StandardCharsets.UTF_8)); + }); + } catch (IOException e) { + log.error("Failed to read plugin bundle resource", e); + return Flux.empty(); + } + }) + .concatWith(Flux.defer(() -> { + var dataBuffer = DefaultDataBufferFactory.sharedInstance + .wrap(plugins.getBytes(StandardCharsets.UTF_8)); + return Flux.just(dataBuffer); + })); + } + + @Override + public Flux uglifyCssBundle() { + return Flux.fromIterable(pluginManager.getStartedPlugins()) + .mapNotNull(pluginWrapper -> { + String pluginName = pluginWrapper.getPluginId(); + return BundleResourceUtils.getJsBundleResource(pluginManager, pluginName, + BundleResourceUtils.CSS_BUNDLE); + }) + .flatMap(resource -> { + try { + return DataBufferUtils.read(resource, DefaultDataBufferFactory.sharedInstance, + (int) resource.contentLength()); + } catch (IOException e) { + log.error("Failed to read plugin css bundle resource", e); + return Flux.empty(); + } + }); + } + + @Override + public Mono generateBundleVersion() { + if (pluginManager.isDevelopment()) { + return Mono.just(String.valueOf(clock.instant().toEpochMilli())); + } + return Flux.fromIterable(new ArrayList<>(pluginManager.getStartedPlugins())) + .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 dependencies = plugin.getSpec().getPluginDependencies().keySet(); + var notStartedDependencies = dependencies.stream() + .filter(dependency -> { + var pluginWrapper = pluginManager.getPlugin(dependency); + return pluginWrapper == null + || !Objects.equals(STARTED, pluginWrapper.getPluginState()); + }) + .toList(); + 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; + } + + 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.getNormalVersion(); + 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); + } catch (IOException e) { + return Flux.error(e); + } + } + + private Path toPath(Resource resource) { + try { + return Path.of(resource.getURI()); + } catch (IOException e) { + throw Exceptions.propagate(e); + } + } + + 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 { + } + +} diff --git a/application/src/main/java/run/halo/app/core/extension/theme/SettingUtils.java b/application/src/main/java/run/halo/app/core/extension/theme/SettingUtils.java new file mode 100644 index 0000000..0a3b33b --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/theme/SettingUtils.java @@ -0,0 +1,175 @@ +package run.halo.app.core.extension.theme; + +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.fge.jsonpatch.JsonPatchException; +import com.github.fge.jsonpatch.mergepatch.JsonMergePatch; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import lombok.experimental.UtilityClass; +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.infra.utils.JsonParseException; +import run.halo.app.infra.utils.JsonUtils; + +@UtilityClass +public class SettingUtils { + 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 -> JsonUtils.DEFAULT_JSON_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).asText(); + JsonNode value = jsonNode.get(VALUE_FIELD); + return Map.entry(name, value); + }) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + data.put(group, JsonUtils.objectToJson(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) + .ifPresent(setting -> { + final var source = SettingUtils.settingDefinedDefaultValueMap(setting); + client.fetch(ConfigMap.class, configMapName) + .ifPresentOrElse(configMap -> { + Map modified = defaultIfNull(configMap.getData(), Map.of()); + final var oldData = JsonUtils.deepCopy(modified); + + Map merged = SettingUtils.mergePatch(modified, source); + configMap.setData(merged); + + if (!Objects.equals(oldData, configMap.getData())) { + client.update(configMap); + } + }, () -> { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setName(configMapName); + configMap.setData(source); + client.create(configMap); + }); + }); + } + + 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) { + JsonNode modifiedJson = mapToJsonNode(modified); + // original + JsonNode sourceJson = mapToJsonNode(source); + try { + // patch + JsonMergePatch jsonMergePatch = JsonMergePatch.fromJson(modifiedJson); + // apply patch to original + JsonNode patchedNode = jsonMergePatch.apply(sourceJson); + return jsonNodeToStringMap(patchedNode); + } catch (JsonPatchException e) { + throw new JsonParseException(e); + } + } + + JsonNode mapToJsonNode(Map map) { + ObjectNode objectNode = JsonNodeFactory.instance.objectNode(); + map.forEach((k, v) -> { + if (isJson(v)) { + JsonNode value = JsonUtils.jsonToObject(v, JsonNode.class); + objectNode.set(k, value); + return; + } + objectNode.put(k, v); + }); + return objectNode; + } + + Map jsonNodeToStringMap(JsonNode node) { + Map stringMap = new LinkedHashMap<>(); + node.fields().forEachRemaining(entry -> { + String k = entry.getKey(); + JsonNode v = entry.getValue(); + 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, JsonUtils.objectToJson(v)); + return; + } + stringMap.put(k, v.asText()); + }); + return stringMap; + } + + boolean isJson(String jsonString) { + try { + JsonUtils.DEFAULT_JSON_MAPPER.readTree(jsonString); + return true; + } catch (JacksonException e) { + return false; + } + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java b/application/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java new file mode 100644 index 0000000..9cc4210 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java @@ -0,0 +1,537 @@ +package run.halo.app.core.extension.theme; + +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.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.SystemConfigurableEnvironmentFetcher; +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; + +/** + * 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 SystemConfigurableEnvironmentFetcher systemEnvironmentFetcher; + + private final ReactiveUrlDataBufferFetcher urlDataBufferFetcher; + + @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}/config", this::updateThemeConfig, + builder -> builder.operationId("updateThemeConfig") + .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(ConfigMap.class)))) + .response(responseBuilder() + .implementation(ConfigMap.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}/config", this::fetchThemeConfig, + builder -> builder.operationId("fetchThemeConfig") + .description("Fetch configMap of theme by configured configMapName.") + .tag(tag) + .parameter(parameterBuilder() + .name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class) + ) + .response(responseBuilder() + .implementation(ConfigMap.class)) + ) + .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 -> systemEnvironmentFetcher.fetch(SystemSetting.Theme.GROUP, + SystemSetting.Theme.class) + .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 updateThemeConfig(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(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 theme " + + "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 fetchThemeConfig(ServerRequest request) { + return themeNameInPathVariableOrActivated(request) + .flatMap(themeName -> client.fetch(Theme.class, themeName)) + .mapNotNull(theme -> theme.getSpec().getConfigMapName()) + .flatMap(configMapName -> client.fetch(ConfigMap.class, configMapName)) + .flatMap(configMap -> ServerResponse.ok().bodyValue(configMap)); + } + + private Mono fetchActivatedTheme(ServerRequest request) { + return systemEnvironmentFetcher.fetch(SystemSetting.Theme.GROUP, SystemSetting.Theme.class) + .map(SystemSetting.Theme::getActive) + .flatMap(activatedName -> client.fetch(Theme.class, activatedName)) + .flatMap(theme -> ServerResponse.ok().bodyValue(theme)); + } + + 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."); + return Mono.fromSupplier(() -> request.pathVariable("name")) + .flatMap(name -> { + if ("-".equals(name)) { + return systemEnvironmentFetcher.fetch(SystemSetting.Theme.GROUP, + SystemSetting.Theme.class) + .mapNotNull(SystemSetting.Theme::getActive) + .defaultIfEmpty(name); + } + return Mono.just(name); + }); + } + + 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)); + } + +} diff --git a/application/src/main/java/run/halo/app/core/extension/theme/ThemeService.java b/application/src/main/java/run/halo/app/core/extension/theme/ThemeService.java new file mode 100644 index 0000000..a3954a3 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/theme/ThemeService.java @@ -0,0 +1,20 @@ +package run.halo.app.core.extension.theme; + +import org.reactivestreams.Publisher; +import org.springframework.core.io.buffer.DataBuffer; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Theme; +import run.halo.app.extension.ConfigMap; + +public interface ThemeService { + + Mono install(Publisher content); + + Mono upgrade(String themeName, Publisher content); + + Mono reloadTheme(String name); + + Mono resetSettingConfig(String name); + // TODO Migrate other useful methods in ThemeEndpoint in the future. + +} diff --git a/application/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java b/application/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java new file mode 100644 index 0000000..a3abfed --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java @@ -0,0 +1,322 @@ +package run.halo.app.core.extension.theme; + +import static org.springframework.util.FileSystemUtils.copyRecursively; +import static run.halo.app.core.extension.theme.ThemeUtils.loadThemeManifest; +import static run.halo.app.core.extension.theme.ThemeUtils.locateThemeManifest; +import static run.halo.app.core.extension.theme.ThemeUtils.unzipThemeTo; +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.nio.file.Path; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.reactivestreams.Publisher; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.retry.RetryException; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; +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.extension.ConfigMap; +import run.halo.app.extension.MetadataUtil; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.Unstructured; +import run.halo.app.infra.SystemVersionSupplier; +import run.halo.app.infra.ThemeRootGetter; +import run.halo.app.infra.exception.ThemeUpgradeException; +import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; +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 SystemVersionSupplier systemVersionSupplier; + + private final Scheduler scheduler = Schedulers.boundedElastic(); + + @Override + public Mono install(Publisher content) { + var themeRoot = this.themeRoot.get(); + return unzipThemeTo(content, themeRoot, scheduler) + .flatMap(this::persistent); + } + + @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.usingWhen( + createTempDir("halo-theme-", scheduler), + tempDir -> { + var locateThemeManifest = Mono.fromCallable(() -> locateThemeManifest(tempDir) + .orElseThrow(() -> new ThemeUpgradeException( + "Missing theme manifest file: theme.yaml or theme.yml", + "problemDetail.theme.upgrade.missingManifest", null))); + return unzip(content, tempDir, scheduler) + .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); + copyRecursively(themeManifest.getParent(), themePath); + return themePath; + }); + + return deleteThemeAndWaitForComplete(themeName) + .then(copyTheme) + .then(this.persistent(newTheme)); + }); + }, + tempDir -> deleteRecursivelyAndSilently(tempDir, scheduler) + ); + + return checkTheme.then(upgradeTheme); + } + + /** + * 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 + */ + public Mono persistent(Unstructured themeManifest) { + Assert.state(StringUtils.equals(Theme.KIND, themeManifest.getKind()), + "Theme manifest kind must be Theme."); + return client.create(themeManifest) + .map(theme -> Unstructured.OBJECT_MAPPER.convertValue(theme, Theme.class)) + .doOnNext(theme -> { + String systemVersion = systemVersionSupplier.get().getNormalVersion(); + 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.")); + } + var spec = theme.getSpec(); + return Flux.fromIterable(unstructureds) + .filter(unstructured -> { + String name = unstructured.getMetadata().getName(); + boolean isThemeSetting = unstructured.getKind().equals(Setting.KIND) + && StringUtils.equals(spec.getSettingName(), name); + + boolean isThemeConfig = unstructured.getKind().equals(ConfigMap.KIND) + && StringUtils.equals(spec.getConfigMapName(), name); + + boolean isAnnotationSetting = unstructured.getKind() + .equals(AnnotationSetting.KIND); + return isThemeSetting || isThemeConfig || isAnnotationSetting; + }) + .doOnNext(unstructured -> + populateThemeNameLabel(unstructured, theme.getMetadata().getName())) + .flatMap(this::createOrUpdate) + .then(Mono.just(theme)); + }); + } + + Mono createOrUpdate(Unstructured unstructured) { + return Mono.defer(() -> client.fetch(unstructured.groupVersionKind(), + unstructured.getMetadata().getName()) + .flatMap(existUnstructured -> { + existUnstructured.getMetadata() + .setVersion(unstructured.getMetadata().getVersion()); + return client.update(existUnstructured); + }) + .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 -> { + String settingName = theme.getSpec().getSettingName(); + return Flux.fromIterable(ThemeUtils.loadThemeResources(getThemePath(theme))) + .filter(unstructured -> (Setting.KIND.equals(unstructured.getKind()) + && unstructured.getMetadata().getName().equals(settingName)) + || AnnotationSetting.KIND.equals(unstructured.getKind()) + ) + .doOnNext(unstructured -> populateThemeNameLabel(unstructured, name)) + .flatMap(client::create) + .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)); + }); + } + + 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 RetryException("Re-check if the setting is deleted."))) + .retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100)) + .filter(t -> t instanceof RetryException)) + ) + ) + .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()) + .doOnNext(latest -> { + throw new RetryException("AnnotationSetting is not deleted yet."); + }) + .retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100)) + .filter(t -> t instanceof RetryException)) + ) + ) + .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) + .doOnNext(theme -> { + throw new RetryException("Re-check if the theme is deleted successfully"); + }) + .retryWhen(Retry.fixedDelay(20, Duration.ofMillis(100)) + .filter(t -> t instanceof RetryException) + .onRetryExhaustedThrow((spec, signal) -> + new ServerErrorException("Wait timeout for theme deleted", null))) + .then(); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java b/application/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java new file mode 100644 index 0000000..a0276a3 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java @@ -0,0 +1,205 @@ +package run.halo.app.core.extension.theme; + +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.extern.slf4j.Slf4j; +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.lang.Nullable; +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 +class ThemeUtils { + private static final String THEME_TMP_PREFIX = "halo-theme-"; + private static final String[] THEME_MANIFESTS = {"theme.yaml", "theme.yml"}; + + 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, + 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, Scheduler scheduler) { + return Mono.usingWhen( + createTempDir(THEME_TMP_PREFIX, scheduler), + tempDir -> { + var locateThemeManifest = Mono.fromCallable(() -> locateThemeManifest(tempDir) + .orElseThrow(() -> new ThemeInstallationException("Missing theme manifest", + "problemDetail.theme.install.missingManifest", null))); + return unzip(content, tempDir, scheduler) + .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); + } + }) + .subscribeOn(scheduler); + }, + tempDir -> FileUtils.deleteRecursivelyAndSilently(tempDir, scheduler) + ); + } + + 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()); + } + +} diff --git a/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostAttachmentEndpoint.java b/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostAttachmentEndpoint.java new file mode 100644 index 0000000..cc32f20 --- /dev/null +++ b/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostAttachmentEndpoint.java @@ -0,0 +1,208 @@ +package run.halo.app.endpoint.uc.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 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 io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.HashMap; +import java.util.function.Consumer; +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.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.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.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.exception.NotFoundException; + +@Component +public class UcPostAttachmentEndpoint 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 PostService postService; + + private final SystemConfigurableEnvironmentFetcher systemSettingFetcher; + + public UcPostAttachmentEndpoint(AttachmentService attachmentService, PostService postService, + SystemConfigurableEnvironmentFetcher systemSettingFetcher) { + this.attachmentService = attachmentService; + this.postService = postService; + this.systemSettingFetcher = systemSettingFetcher; + } + + @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.") + .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)) + ) + .build(); + } + + private Mono createAttachmentForPost(ServerRequest request) { + var postAttachmentRequestMono = request.body(BodyExtractors.toMultipartData()) + .map(PostAttachmentRequest::from) + .cache(); + + var postSettingMono = 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); + }); + + // get settings + var createdAttachment = postSettingMono.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 = 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 ServerResponse.ok().body(createdAttachment, Attachment.class); + } + + 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.content.halo.run/v1alpha1"); + } + + @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); + } + + } +} diff --git a/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostEndpoint.java b/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostEndpoint.java new file mode 100644 index 0000000..0b30aec --- /dev/null +++ b/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostEndpoint.java @@ -0,0 +1,332 @@ +package run.halo.app.endpoint.uc.content; + +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))) + .build(), + builder -> { + }) + .build(); + } + + 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"); + } + +} diff --git a/application/src/main/java/run/halo/app/endpoint/uc/content/UcSnapshotEndpoint.java b/application/src/main/java/run/halo/app/endpoint/uc/content/UcSnapshotEndpoint.java new file mode 100644 index 0000000..1f196bc --- /dev/null +++ b/application/src/main/java/run/halo/app/endpoint/uc/content/UcSnapshotEndpoint.java @@ -0,0 +1,123 @@ +package run.halo.app.endpoint.uc.content; + +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(), + builder -> { + }) + .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"); + } + +} diff --git a/application/src/main/java/run/halo/app/event/post/CategoryHiddenStateChangeEvent.java b/application/src/main/java/run/halo/app/event/post/CategoryHiddenStateChangeEvent.java new file mode 100644 index 0000000..92a2d23 --- /dev/null +++ b/application/src/main/java/run/halo/app/event/post/CategoryHiddenStateChangeEvent.java @@ -0,0 +1,24 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/event/post/CommentCreatedEvent.java b/application/src/main/java/run/halo/app/event/post/CommentCreatedEvent.java new file mode 100644 index 0000000..8483aa4 --- /dev/null +++ b/application/src/main/java/run/halo/app/event/post/CommentCreatedEvent.java @@ -0,0 +1,22 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/event/post/CommentUnreadReplyCountChangedEvent.java b/application/src/main/java/run/halo/app/event/post/CommentUnreadReplyCountChangedEvent.java new file mode 100644 index 0000000..2e32514 --- /dev/null +++ b/application/src/main/java/run/halo/app/event/post/CommentUnreadReplyCountChangedEvent.java @@ -0,0 +1,22 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/event/post/DownvotedEvent.java b/application/src/main/java/run/halo/app/event/post/DownvotedEvent.java new file mode 100644 index 0000000..3b3d6cb --- /dev/null +++ b/application/src/main/java/run/halo/app/event/post/DownvotedEvent.java @@ -0,0 +1,14 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/event/post/PostStatsChangedEvent.java b/application/src/main/java/run/halo/app/event/post/PostStatsChangedEvent.java new file mode 100644 index 0000000..aa936b8 --- /dev/null +++ b/application/src/main/java/run/halo/app/event/post/PostStatsChangedEvent.java @@ -0,0 +1,23 @@ +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.extension.Counter; +import run.halo.app.core.extension.content.Post; +import run.halo.app.metrics.MeterUtils; + +@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, "")); + } +} diff --git a/application/src/main/java/run/halo/app/event/post/ReplyChangedEvent.java b/application/src/main/java/run/halo/app/event/post/ReplyChangedEvent.java new file mode 100644 index 0000000..c615686 --- /dev/null +++ b/application/src/main/java/run/halo/app/event/post/ReplyChangedEvent.java @@ -0,0 +1,14 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/event/post/ReplyCreatedEvent.java b/application/src/main/java/run/halo/app/event/post/ReplyCreatedEvent.java new file mode 100644 index 0000000..e0ebab8 --- /dev/null +++ b/application/src/main/java/run/halo/app/event/post/ReplyCreatedEvent.java @@ -0,0 +1,16 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/event/post/ReplyDeletedEvent.java b/application/src/main/java/run/halo/app/event/post/ReplyDeletedEvent.java new file mode 100644 index 0000000..8382563 --- /dev/null +++ b/application/src/main/java/run/halo/app/event/post/ReplyDeletedEvent.java @@ -0,0 +1,10 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/event/post/ReplyEvent.java b/application/src/main/java/run/halo/app/event/post/ReplyEvent.java new file mode 100644 index 0000000..adf53c0 --- /dev/null +++ b/application/src/main/java/run/halo/app/event/post/ReplyEvent.java @@ -0,0 +1,21 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/event/post/UpvotedEvent.java b/application/src/main/java/run/halo/app/event/post/UpvotedEvent.java new file mode 100644 index 0000000..111319d --- /dev/null +++ b/application/src/main/java/run/halo/app/event/post/UpvotedEvent.java @@ -0,0 +1,14 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/event/post/VisitedEvent.java b/application/src/main/java/run/halo/app/event/post/VisitedEvent.java new file mode 100644 index 0000000..9bb70cc --- /dev/null +++ b/application/src/main/java/run/halo/app/event/post/VisitedEvent.java @@ -0,0 +1,22 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/event/post/VotedEvent.java b/application/src/main/java/run/halo/app/event/post/VotedEvent.java new file mode 100644 index 0000000..75a0553 --- /dev/null +++ b/application/src/main/java/run/halo/app/event/post/VotedEvent.java @@ -0,0 +1,22 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/event/user/PasswordChangedEvent.java b/application/src/main/java/run/halo/app/event/user/PasswordChangedEvent.java new file mode 100644 index 0000000..532fea8 --- /dev/null +++ b/application/src/main/java/run/halo/app/event/user/PasswordChangedEvent.java @@ -0,0 +1,14 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/extension/DefaultSchemeManager.java b/application/src/main/java/run/halo/app/extension/DefaultSchemeManager.java new file mode 100644 index 0000000..488f76b --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/DefaultSchemeManager.java @@ -0,0 +1,75 @@ +package run.halo.app.extension; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import run.halo.app.extension.SchemeWatcherManager.SchemeRegistered; +import run.halo.app.extension.SchemeWatcherManager.SchemeUnregistered; +import run.halo.app.extension.index.IndexSpecRegistry; +import run.halo.app.extension.index.IndexSpecs; + +public class DefaultSchemeManager implements SchemeManager { + + private final List schemes; + + private final IndexSpecRegistry indexSpecRegistry; + + @Nullable + private final SchemeWatcherManager watcherManager; + + public DefaultSchemeManager(IndexSpecRegistry indexSpecRegistry, + @Nullable SchemeWatcherManager watcherManager) { + this.indexSpecRegistry = indexSpecRegistry; + this.watcherManager = watcherManager; + // we have to use CopyOnWriteArrayList at here to prevent concurrent modification between + // registering and listing. + schemes = new CopyOnWriteArrayList<>(); + } + + @Override + public void register(@NonNull Scheme scheme) { + if (!schemes.contains(scheme)) { + indexSpecRegistry.indexFor(scheme); + schemes.add(scheme); + getWatchers().forEach(watcher -> watcher.onChange(new SchemeRegistered(scheme))); + } + } + + @Override + public void register(@NonNull Scheme scheme, Consumer specsConsumer) { + if (schemes.contains(scheme)) { + return; + } + var indexSpecs = indexSpecRegistry.indexFor(scheme); + specsConsumer.accept(indexSpecs); + schemes.add(scheme); + getWatchers().forEach(watcher -> watcher.onChange(new SchemeRegistered(scheme))); + } + + @Override + public void unregister(@NonNull Scheme scheme) { + if (schemes.contains(scheme)) { + indexSpecRegistry.removeIndexSpecs(scheme); + schemes.remove(scheme); + getWatchers().forEach(watcher -> watcher.onChange(new SchemeUnregistered(scheme))); + } + } + + @Override + @NonNull + public List schemes() { + return Collections.unmodifiableList(schemes); + } + + @NonNull + private List getWatchers() { + if (this.watcherManager == null) { + return Collections.emptyList(); + } + return Optional.ofNullable(this.watcherManager.watchers()).orElse(Collections.emptyList()); + } +} diff --git a/application/src/main/java/run/halo/app/extension/DefaultSchemeWatcherManager.java b/application/src/main/java/run/halo/app/extension/DefaultSchemeWatcherManager.java new file mode 100644 index 0000000..4852170 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/DefaultSchemeWatcherManager.java @@ -0,0 +1,34 @@ +package run.halo.app.extension; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; + +public class DefaultSchemeWatcherManager implements SchemeWatcherManager { + + private final List watchers; + + public DefaultSchemeWatcherManager() { + watchers = new CopyOnWriteArrayList<>(); + } + + @Override + public void register(@NonNull SchemeWatcher watcher) { + Assert.notNull(watcher, "Scheme watcher must not be null"); + watchers.add(watcher); + } + + @Override + public void unregister(@NonNull SchemeWatcher watcher) { + Assert.notNull(watcher, "Scheme watcher must not be null"); + watchers.remove(watcher); + } + + @Override + public List watchers() { + // we have to copy the watchers entirely to prevent concurrent modification. + return Collections.unmodifiableList(watchers); + } +} diff --git a/application/src/main/java/run/halo/app/extension/DelegateExtensionClient.java b/application/src/main/java/run/halo/app/extension/DelegateExtensionClient.java new file mode 100644 index 0000000..a9a0bf5 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/DelegateExtensionClient.java @@ -0,0 +1,82 @@ +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 org.springframework.stereotype.Component; +import run.halo.app.extension.index.IndexedQueryEngine; + +/** + * DelegateExtensionClient fully delegates ReactiveExtensionClient. + * + * @author johnniang + */ +@Component +public class DelegateExtensionClient implements ExtensionClient { + + 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(); + } + + @Override + public ListResult list(Class type, Predicate predicate, + Comparator comparator, int page, int size) { + return client.list(type, predicate, comparator, page, size).block(); + } + + @Override + public List listAll(Class type, ListOptions options, Sort sort) { + return client.listAll(type, options, sort).collectList().block(); + } + + @Override + public ListResult listBy(Class type, ListOptions options, + PageRequest page) { + return client.listBy(type, options, page).block(); + } + + @Override + public Optional fetch(Class type, String name) { + return client.fetch(type, name).blockOptional(); + } + + @Override + public Optional fetch(GroupVersionKind gvk, String name) { + return client.fetch(gvk, name).blockOptional(); + } + + @Override + public void create(E extension) { + client.create(extension).block(); + } + + @Override + public void update(E extension) { + client.update(extension).block(); + } + + @Override + public void delete(E extension) { + client.delete(extension).block(); + } + + @Override + public IndexedQueryEngine indexedQueryEngine() { + return client.indexedQueryEngine(); + } + + @Override + public void watch(Watcher watcher) { + client.watch(watcher); + } +} diff --git a/application/src/main/java/run/halo/app/extension/ExtensionConverter.java b/application/src/main/java/run/halo/app/extension/ExtensionConverter.java new file mode 100644 index 0000000..cf91933 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/ExtensionConverter.java @@ -0,0 +1,31 @@ +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); + +} diff --git a/application/src/main/java/run/halo/app/extension/ExtensionStoreUtil.java b/application/src/main/java/run/halo/app/extension/ExtensionStoreUtil.java new file mode 100644 index 0000000..0f73a63 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/ExtensionStoreUtil.java @@ -0,0 +1,42 @@ +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; + } + +} diff --git a/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java b/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java new file mode 100644 index 0000000..8aa2304 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java @@ -0,0 +1,145 @@ +package run.halo.app.extension; + +import static org.openapi4j.core.validation.ValidationSeverity.ERROR; +import static org.springframework.util.StringUtils.arrayToCommaDelimitedString; +import static run.halo.app.extension.ExtensionStoreUtil.buildStoreName; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.core.util.Json; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Optional; +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.core.validation.ValidationResult; +import org.openapi4j.core.validation.ValidationResults; +import org.openapi4j.schema.validator.BaseJsonValidator; +import org.openapi4j.schema.validator.ValidationContext; +import org.openapi4j.schema.validator.ValidationData; +import org.openapi4j.schema.validator.v3.SchemaValidator; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +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 +public class JSONExtensionConverter implements ExtensionConverter { + + public final ObjectMapper objectMapper; + + private final SchemeManager schemeManager; + + public JSONExtensionConverter(SchemeManager schemeManager) { + this.schemeManager = schemeManager; + this.objectMapper = Json.mapper(); + } + + public ObjectMapper getObjectMapper() { + return 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); + } + } + + private SchemaValidator getValidator(Scheme scheme) + throws MalformedURLException, ResolutionException { + var context = new ValidationContext( + new OAI3Context(new URL("file:/"), scheme.openApiSchema())); + context.setFastFail(false); + return new SchemaValidator(context, null, scheme.openApiSchema()); + } + + public static class ExtraValidationValidator extends BaseJsonValidator { + + private String[] fieldNames; + + private static final ValidationResult ERR = + new ValidationResult(ERROR, 1100, "Fields '%s' should not be blank at the same time"); + + private static final ValidationResults.CrumbInfo CRUMB_INFO = + new ValidationResults.CrumbInfo("not-blank-at-least-one", true); + + protected ExtraValidationValidator(ValidationContext context, + JsonNode schemaNode, JsonNode schemaParentNode, + SchemaValidator parentSchema) { + super(context, schemaNode, schemaParentNode, parentSchema); + + var withNode = schemaNode.get("not-blank-at-least-one"); + if (withNode != null && withNode.isTextual()) { + fieldNames = StringUtils.commaDelimitedListToStringArray(withNode.asText()); + withNode.asText(); + } + } + + @Override + public boolean validate(JsonNode valueNode, ValidationData validation) { + if (fieldNames == null) { + return false; + } + for (var fieldName : fieldNames) { + JsonNode value = valueNode.get(fieldName); + if (value != null && value.isTextual() && StringUtils.hasText(value.asText())) { + return false; + } + } + // or all of them are blank string + validation.add(CRUMB_INFO, ERR, arrayToCommaDelimitedString(fieldNames)); + return false; + } + } + +} diff --git a/application/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java b/application/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java new file mode 100644 index 0000000..7187a96 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java @@ -0,0 +1,487 @@ +package run.halo.app.extension; + +import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic; +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.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; +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 reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; +import run.halo.app.extension.exception.ExtensionNotFoundException; +import run.halo.app.extension.index.DefaultExtensionIterator; +import run.halo.app.extension.index.ExtensionIterator; +import run.halo.app.extension.index.IndexedQueryEngine; +import run.halo.app.extension.index.IndexerFactory; +import run.halo.app.extension.store.ReactiveExtensionStoreClient; + +@Slf4j +@Component +public class ReactiveExtensionClientImpl implements ReactiveExtensionClient { + + 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 IndexerFactory indexerFactory; + + private final IndexedQueryEngine indexedQueryEngine; + + private final ConcurrentMap indexBuildingState = + new ConcurrentHashMap<>(); + + private TransactionalOperator transactionalOperator; + + public ReactiveExtensionClientImpl(ReactiveExtensionStoreClient client, + ExtensionConverter converter, SchemeManager schemeManager, ObjectMapper objectMapper, + IndexerFactory indexerFactory, IndexedQueryEngine indexedQueryEngine, + ReactiveTransactionManager reactiveTransactionManager) { + this.client = client; + this.converter = converter; + this.schemeManager = schemeManager; + this.objectMapper = objectMapper; + this.indexerFactory = indexerFactory; + this.indexedQueryEngine = indexedQueryEngine; + this.transactionalOperator = TransactionalOperator.create(reactiveTransactionManager); + } + + /** + * 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.fromSupplier( + () -> indexedQueryEngine.retrieveAll(scheme.groupVersionKind(), options, + nullSafeSort)) + .doOnSuccess(objectKeys -> { + if (log.isDebugEnabled()) { + if (objectKeys.size() > 500) { + log.warn("The number of objects retrieved by listAll is too large ({}) " + + "and it is recommended to use paging query.", + objectKeys.size()); + } + } + }) + .flatMapMany(objectKeys -> { + var storeNames = objectKeys.stream() + .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 all by names from db for {} in {}ms", + scheme.groupVersionKind(), System.currentTimeMillis() - startTimeMs) + ); + }); + } + + @Override + public Mono> listBy(Class type, ListOptions options, + PageRequest page) { + var scheme = schemeManager.get(type); + return Mono.fromSupplier( + () -> indexedQueryEngine.retrieve(scheme.groupVersionKind(), options, page) + ) + .flatMap(objectKeys -> { + var storeNames = objectKeys.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(result -> new ListResult<>(page.getPageNumber(), page.getPageSize(), + objectKeys.getTotal(), result)); + }) + .defaultIfEmpty(ListResult.emptyResult()); + } + + @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) { + checkClientWritable(extension); + return Mono.just(extension) + .doOnNext(ext -> { + 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() + randomAlphabetic(5)); + } + extension.setMetadata(metadata); + }) + .map(converter::convertTo) + .flatMap(extStore -> doCreate(extension, extStore.getName(), extStore.getData()) + .doOnNext(created -> watchers.onAdd(convertToRealExtension(created))) + ) + .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) { + checkClientWritable(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 updated = doUpdate(extension, store.getName(), store.getVersion(), store.getData()); + if (!onlyStatusChanged) { + updated = updated.doOnNext(ext -> watchers.onUpdate(convertToRealExtension(old), + convertToRealExtension(ext)) + ); + } + return updated; + }); + } + + 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) { + checkClientWritable(extension); + // set deletionTimestamp + extension.getMetadata().setDeletionTimestamp(Instant.now()); + var extensionStore = converter.convertTo(extension); + return doUpdate(extension, extensionStore.getName(), + extensionStore.getVersion(), extensionStore.getData() + ).doOnNext(updated -> watchers.onDelete(convertToRealExtension(extension))); + } + + @Override + public IndexedQueryEngine indexedQueryEngine() { + return this.indexedQueryEngine; + } + + /** + *

Note of transactional:

+ *

doSomething does not have a transaction, but methodOne and methodTwo have their own + * transactions.

+ *

If methodTwo fails and throws an exception, the transaction in methodTwo will be rolled + * back, but the transaction in methodOne will not.

+ *
+     * public void doSomething() {
+     *     // with manual transaction
+     *     methodOne();
+     *     // with manual transaction
+     *     methodTwo();
+     * }
+     * 
+ *

If doSomething is annotated with @Transactional, both methodOne and methodTwo will be + * executed within the same transaction context.

+ *

If methodTwo fails and throws an exception, the entire transaction will be rolled back, + * including any changes made by methodOne.

+ *

This ensures that all operations within the doSomething method either succeed or fail + * together.

+ *

This example advises against adding transaction annotations to the outer method that + * invokes {@link #update(Extension)} or {@link #create(Extension)}, as doing so could + * undermine the intended transactional integrity of this method.

+ * + *

Note another point:

+ * After executing the {@code client.create(name, data)} method, an attempt is made to + * indexRecord. However, indexRecord might fail due to duplicate keys in the unique index, + * causing the index creation to fail. In such cases, the data created by {@code client + * .create(name, data)} should be rolled back to maintain consistency between the index and + * the data. + *

Until a better solution is found for this consistency problem, do not remove the + * manual transaction here.

+ *
+     * client.create(name, data)
+     *  .doOnNext(extension -> indexer.indexRecord(convertToRealExtension(extension)))
+     *  .as(transactionalOperator::transactional);
+     * 
+ */ + @SuppressWarnings("unchecked") + Mono doCreate(E oldExtension, String name, byte[] data) { + return Mono.defer(() -> { + var gvk = oldExtension.groupVersionKind(); + var type = (Class) oldExtension.getClass(); + var indexer = indexerFactory.getIndexer(gvk); + return client.create(name, data) + .map(created -> converter.convertFrom(type, created)) + .doOnNext(extension -> indexer.indexRecord(convertToRealExtension(extension))) + .as(transactionalOperator::transactional); + }); + } + + /** + * see also {@link #doCreate(Extension, String, byte[])}. + */ + @SuppressWarnings("unchecked") + Mono doUpdate(E oldExtension, String name, Long version, byte[] data) { + return Mono.defer(() -> { + var type = (Class) oldExtension.getClass(); + var indexer = indexerFactory.getIndexer(oldExtension.groupVersionKind()); + return client.update(name, version, data) + .map(updated -> converter.convertFrom(type, updated)) + .doOnNext(extension -> indexer.updateRecord(convertToRealExtension(extension))) + .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; + } + + /** + * If the extension is being updated, we should the index is not building index for the + * extension, otherwise the {@link IllegalStateException} will be thrown. + */ + private void checkClientWritable(E extension) { + var buildingState = indexBuildingState.get(extension.groupVersionKind().groupKind()); + if (buildingState != null && buildingState.get()) { + throw new IllegalStateException("Index is building for " + extension.groupVersionKind() + + ", please wait for a moment and try again."); + } + } + + void setIndexBuildingStateFor(GroupKind groupKind, boolean building) { + indexBuildingState.computeIfAbsent(groupKind, k -> new AtomicBoolean(building)) + .set(building); + } + + @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; + } + + @Component + @RequiredArgsConstructor + class IndexBuildsManager { + private final SchemeManager schemeManager; + private final IndexerFactory indexerFactory; + private final ExtensionConverter converter; + private final ReactiveExtensionStoreClient client; + private final SchemeWatcherManager schemeWatcherManager; + + @NonNull + private ExtensionIterator createExtensionIterator(Scheme scheme) { + var type = scheme.type(); + var prefix = ExtensionStoreUtil.buildStoreNamePrefix(scheme); + return new DefaultExtensionIterator<>(pageable -> + client.listByNamePrefix(prefix, pageable) + .map(page -> + page.map(store -> (Extension) converter.convertFrom(type, store)) + ) + .block() + ); + } + + @EventListener(ContextRefreshedEvent.class) + public void startBuildingIndex() { + final long startTimeMs = System.currentTimeMillis(); + log.info("Start building index for all extensions, please wait..."); + schemeManager.schemes() + .forEach(this::createIndexerFor); + + schemeWatcherManager.register(event -> { + if (event instanceof SchemeWatcherManager.SchemeRegistered schemeRegistered) { + createIndexerFor(schemeRegistered.getNewScheme()); + return; + } + if (event instanceof SchemeWatcherManager.SchemeUnregistered schemeUnregistered) { + var scheme = schemeUnregistered.getDeletedScheme(); + indexerFactory.removeIndexer(scheme); + } + }); + log.info("Successfully built index in {}ms, Preparing to lunch application...", + System.currentTimeMillis() - startTimeMs); + } + + private void createIndexerFor(Scheme scheme) { + setIndexBuildingStateFor(scheme.groupVersionKind().groupKind(), true); + indexerFactory.createIndexerFor(scheme.type(), createExtensionIterator(scheme)); + setIndexBuildingStateFor(scheme.groupVersionKind().groupKind(), false); + } + } +} diff --git a/application/src/main/java/run/halo/app/extension/SchemeWatcherManager.java b/application/src/main/java/run/halo/app/extension/SchemeWatcherManager.java new file mode 100644 index 0000000..5c69a47 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/SchemeWatcherManager.java @@ -0,0 +1,49 @@ +package run.halo.app.extension; + +import java.util.List; +import org.springframework.lang.NonNull; + +public interface SchemeWatcherManager { + + void register(@NonNull SchemeWatcher watcher); + + void unregister(@NonNull SchemeWatcher watcher); + + List watchers(); + + interface SchemeWatcher { + + void onChange(ChangeEvent event); + + } + + interface ChangeEvent { + + } + + class SchemeRegistered implements ChangeEvent { + private final Scheme newScheme; + + public SchemeRegistered(Scheme newScheme) { + this.newScheme = newScheme; + } + + public Scheme getNewScheme() { + return newScheme; + } + } + + class SchemeUnregistered implements ChangeEvent { + + private final Scheme deletedScheme; + + public SchemeUnregistered(Scheme deletedScheme) { + this.deletedScheme = deletedScheme; + } + + public Scheme getDeletedScheme() { + return deletedScheme; + } + + } +} diff --git a/application/src/main/java/run/halo/app/extension/controller/ControllerManager.java b/application/src/main/java/run/halo/app/extension/controller/ControllerManager.java new file mode 100644 index 0000000..2be487c --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/controller/ControllerManager.java @@ -0,0 +1,19 @@ +package run.halo.app.extension.controller; + +public interface ControllerManager { + + /** + * Register and start a reconciler. + * + * @param reconciler reconciler must not be null. + */ + void start(Reconciler reconciler); + + /** + * Unregister and stop a reconciler. + * + * @param reconciler reconciler must not be null. + */ + void stop(Reconciler reconciler); + +} diff --git a/application/src/main/java/run/halo/app/extension/controller/DefaultControllerManager.java b/application/src/main/java/run/halo/app/extension/controller/DefaultControllerManager.java new file mode 100644 index 0000000..f6313d9 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/controller/DefaultControllerManager.java @@ -0,0 +1,83 @@ +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.beans.factory.DisposableBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ApplicationListener; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.controller.Reconciler.Request; +import run.halo.app.infra.ExtensionInitializedEvent; + +@Slf4j +public class DefaultControllerManager + implements ApplicationListener, + ApplicationContextAware, DisposableBean, ControllerManager { + + private final ExtensionClient client; + + private ApplicationContext applicationContext; + + /** + * Map with key: reconciler class name, value: controller self. + */ + private final ConcurrentHashMap controllers; + + public DefaultControllerManager(ExtensionClient client) { + this.client = client; + controllers = new ConcurrentHashMap<>(); + } + + @Override + public 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(Reconciler reconciler) { + var controller = controllers.remove(reconciler.getClass().getName()); + // destroy it + disposeSilently(controller); + } + + 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 destroy() { + log.info("Shutting down {} controllers...", controllers.size()); + controllers.forEach((name, controller) -> disposeSilently(controller)); + log.info("Shutdown {} controllers.", controllers.size()); + } + + @Override + public void onApplicationEvent(ExtensionInitializedEvent event) { + // register reconcilers in system after scheme initialized + applicationContext.>getBeanProvider( + forClassWithGenerics(Reconciler.class, Request.class)) + .orderedStream() + .forEach(this::start); + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } +} diff --git a/application/src/main/java/run/halo/app/extension/exception/ExtensionConvertException.java b/application/src/main/java/run/halo/app/extension/exception/ExtensionConvertException.java new file mode 100644 index 0000000..109d153 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/exception/ExtensionConvertException.java @@ -0,0 +1,17 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/extension/exception/ExtensionNotFoundException.java b/application/src/main/java/run/halo/app/extension/exception/ExtensionNotFoundException.java new file mode 100644 index 0000000..a298704 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/exception/ExtensionNotFoundException.java @@ -0,0 +1,13 @@ +package run.halo.app.extension.exception; + +import org.springframework.http.HttpStatus; +import run.halo.app.extension.GroupVersionKind; + +public class ExtensionNotFoundException extends ExtensionException { + + public ExtensionNotFoundException(GroupVersionKind gvk, String name) { + super(HttpStatus.NOT_FOUND, "Extension " + gvk + "/" + name + " was not found.", + null, null, new Object[] {gvk, name}); + } + +} diff --git a/application/src/main/java/run/halo/app/extension/exception/SchemaViolationException.java b/application/src/main/java/run/halo/app/extension/exception/SchemaViolationException.java new file mode 100644 index 0000000..b1697a0 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/exception/SchemaViolationException.java @@ -0,0 +1,28 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/extension/gc/GcControllerInitializer.java b/application/src/main/java/run/halo/app/extension/gc/GcControllerInitializer.java new file mode 100644 index 0000000..6b32f5a --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/gc/GcControllerInitializer.java @@ -0,0 +1,28 @@ +package run.halo.app.extension.gc; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; +import run.halo.app.extension.controller.Controller; +import run.halo.app.infra.ExtensionInitializedEvent; + +@Component +public class GcControllerInitializer + implements ApplicationListener, DisposableBean { + + private final Controller gcController; + + public GcControllerInitializer(GcReconciler gcReconciler) { + this.gcController = gcReconciler.setupWith(null); + } + + @Override + public void onApplicationEvent(ExtensionInitializedEvent event) { + gcController.start(); + } + + @Override + public void destroy() throws Exception { + gcController.dispose(); + } +} diff --git a/application/src/main/java/run/halo/app/extension/gc/GcReconciler.java b/application/src/main/java/run/halo/app/extension/gc/GcReconciler.java new file mode 100644 index 0000000..f805fde --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/gc/GcReconciler.java @@ -0,0 +1,88 @@ +package run.halo.app.extension.gc; + +import java.time.Duration; +import java.time.Instant; +import java.util.function.Predicate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; +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.SchemeWatcherManager; +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.index.IndexerFactory; +import run.halo.app.extension.store.ExtensionStoreClient; + +@Slf4j +@Component +class GcReconciler implements Reconciler { + + private final ExtensionClient client; + + private final ExtensionStoreClient storeClient; + + private final ExtensionConverter converter; + + private final SchemeManager schemeManager; + + private final IndexerFactory indexerFactory; + + private final SchemeWatcherManager schemeWatcherManager; + + GcReconciler(ExtensionClient client, + ExtensionStoreClient storeClient, + ExtensionConverter converter, + SchemeManager schemeManager, IndexerFactory indexerFactory, + SchemeWatcherManager schemeWatcherManager) { + this.client = client; + this.storeClient = storeClient; + this.converter = converter; + this.schemeManager = schemeManager; + this.indexerFactory = indexerFactory; + this.schemeWatcherManager = schemeWatcherManager; + } + + @Override + public Result reconcile(GcRequest request) { + log.debug("Extension {} is being deleted", request); + + client.fetch(request.gvk(), request.name()) + .filter(deletable()) + .ifPresent(extension -> { + var extensionStore = converter.convertTo(extension); + storeClient.delete(extensionStore.getName(), extensionStore.getVersion()); + // drop index for this extension + var indexer = indexerFactory.getIndexer(extension.groupVersionKind()); + indexer.unIndexRecord(request.name()); + log.debug("Extension {} was deleted", request); + }); + + return null; + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + var queue = new DefaultQueue(Instant::now, Duration.ofMillis(500)); + var synchronizer = new GcSynchronizer(client, queue, schemeManager, schemeWatcherManager); + return new DefaultController<>( + "garbage-collector-controller", + this, + queue, + synchronizer, + Duration.ofMillis(500), + Duration.ofSeconds(1000), + // TODO Make it configurable + 10); + } + + private Predicate deletable() { + return extension -> CollectionUtils.isEmpty(extension.getMetadata().getFinalizers()) + && extension.getMetadata().getDeletionTimestamp() != null; + } +} diff --git a/application/src/main/java/run/halo/app/extension/gc/GcRequest.java b/application/src/main/java/run/halo/app/extension/gc/GcRequest.java new file mode 100644 index 0000000..27dfca8 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/gc/GcRequest.java @@ -0,0 +1,12 @@ +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"); + } +} diff --git a/application/src/main/java/run/halo/app/extension/gc/GcSynchronizer.java b/application/src/main/java/run/halo/app/extension/gc/GcSynchronizer.java new file mode 100644 index 0000000..7fdeab6 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/gc/GcSynchronizer.java @@ -0,0 +1,79 @@ +package run.halo.app.extension.gc; + +import java.util.List; +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.SchemeWatcherManager; +import run.halo.app.extension.SchemeWatcherManager.SchemeRegistered; +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.index.query.QueryFactory; +import run.halo.app.extension.router.selector.FieldSelector; + +class GcSynchronizer implements Synchronizer { + + private final ExtensionClient client; + + private final SchemeManager schemeManager; + + private final SchemeWatcherManager schemeWatcherManager; + + private boolean disposed = false; + + private boolean started = false; + + private final Watcher watcher; + + GcSynchronizer(ExtensionClient client, RequestQueue queue, + SchemeManager schemeManager, SchemeWatcherManager schemeWatcherManager) { + this.client = client; + this.schemeManager = schemeManager; + this.watcher = new GcWatcher(queue); + this.schemeWatcherManager = schemeWatcherManager; + } + + @Override + public void dispose() { + if (isDisposed()) { + return; + } + this.disposed = true; + this.watcher.dispose(); + } + + @Override + public boolean isDisposed() { + return disposed; + } + + @Override + public void start() { + if (isDisposed() || started) { + return; + } + this.started = true; + this.schemeWatcherManager.register(event -> { + if (event instanceof SchemeRegistered registeredEvent) { + var newScheme = registeredEvent.getNewScheme(); + listDeleted(newScheme.type()).forEach(watcher::onDelete); + } + }); + 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(QueryFactory.isNotNull("metadata.deletionTimestamp")) + ); + return client.listAll(type, options, Sort.by(Sort.Order.asc("metadata.creationTimestamp"))); + } +} diff --git a/application/src/main/java/run/halo/app/extension/gc/GcWatcher.java b/application/src/main/java/run/halo/app/extension/gc/GcWatcher.java new file mode 100644 index 0000000..1cfc058 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/gc/GcWatcher.java @@ -0,0 +1,64 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/extension/index/DefaultExtensionIterator.java b/application/src/main/java/run/halo/app/extension/index/DefaultExtensionIterator.java new file mode 100644 index 0000000..a96b227 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/DefaultExtensionIterator.java @@ -0,0 +1,66 @@ +package run.halo.app.extension.index; + +import java.util.List; +import java.util.NoSuchElementException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import run.halo.app.extension.Extension; + +/** + * Default implementation of {@link ExtensionIterator}. + * + * @param the type of the extension. + * @author guqing + * @since 2.12.0 + */ +public class DefaultExtensionIterator implements ExtensionIterator { + static final int DEFAULT_PAGE_SIZE = 500; + private final ExtensionPaginatedLister lister; + private Pageable currentPageable; + private List currentData; + private int currentIndex; + + public DefaultExtensionIterator(ExtensionPaginatedLister lister) { + this(PageRequest.of(0, DEFAULT_PAGE_SIZE, Sort.by("name")), lister); + } + + /** + * Constructs a new DefaultExtensionIterator with the given lister. + * + * @param lister the lister to use to load data. + */ + public DefaultExtensionIterator(Pageable initPageable, ExtensionPaginatedLister lister) { + this.lister = lister; + this.currentPageable = initPageable; + this.currentData = loadData(); + } + + private List loadData() { + Page page = lister.list(currentPageable); + currentPageable = page.hasNext() ? page.nextPageable() : null; + return page.getContent(); + } + + @Override + public boolean hasNext() { + if (currentIndex < currentData.size()) { + return true; + } + if (currentPageable == null) { + return false; + } + currentData = loadData(); + currentIndex = 0; + return !currentData.isEmpty(); + } + + @Override + public E next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return currentData.get(currentIndex++); + } +} diff --git a/application/src/main/java/run/halo/app/extension/index/DefaultIndexSpecs.java b/application/src/main/java/run/halo/app/extension/index/DefaultIndexSpecs.java new file mode 100644 index 0000000..831eaae --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/DefaultIndexSpecs.java @@ -0,0 +1,64 @@ +package run.halo.app.extension.index; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.apache.commons.lang3.StringUtils; + +/** + * Default implementation of {@link IndexSpecs}. + * + * @author guqing + * @since 2.12.0 + */ +public class DefaultIndexSpecs implements IndexSpecs { + private final ConcurrentMap indexSpecs; + + public DefaultIndexSpecs() { + this.indexSpecs = new ConcurrentHashMap<>(); + } + + @Override + public void add(IndexSpec indexSpec) { + checkIndexSpec(indexSpec); + var indexName = indexSpec.getName(); + var existingSpec = indexSpecs.putIfAbsent(indexName, indexSpec); + if (existingSpec != null) { + throw new IllegalArgumentException( + "IndexSpec with name " + indexName + " already exists"); + } + } + + @Override + public List getIndexSpecs() { + return List.copyOf(this.indexSpecs.values()); + } + + @Override + public IndexSpec getIndexSpec(String indexName) { + return this.indexSpecs.get(indexName); + } + + @Override + public boolean contains(String indexName) { + return this.indexSpecs.containsKey(indexName); + } + + @Override + public void remove(String name) { + this.indexSpecs.remove(name); + } + + private void checkIndexSpec(IndexSpec indexSpec) { + var order = indexSpec.getOrder(); + if (order == null) { + indexSpec.setOrder(IndexSpec.OrderType.ASC); + } + if (StringUtils.isBlank(indexSpec.getName())) { + throw new IllegalArgumentException("IndexSpec name must not be blank"); + } + if (indexSpec.getIndexFunc() == null) { + throw new IllegalArgumentException("IndexSpec indexFunc must not be null"); + } + } +} diff --git a/application/src/main/java/run/halo/app/extension/index/DefaultIndexer.java b/application/src/main/java/run/halo/app/extension/index/DefaultIndexer.java new file mode 100644 index 0000000..10c5709 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/DefaultIndexer.java @@ -0,0 +1,233 @@ +package run.halo.app.extension.index; + +import static run.halo.app.extension.index.IndexerTransaction.ChangeRecord; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Function; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.lang.NonNull; +import run.halo.app.extension.Extension; + +/** + *

A default implementation of {@link Indexer}.

+ *

It uses the {@link IndexEntryContainer} to store the index entries for the specified + * {@link IndexDescriptor}s.

+ * + * @author guqing + * @since 2.12.0 + */ +public class DefaultIndexer implements Indexer { + private final ReadWriteLock rwl = new ReentrantReadWriteLock(); + private final Lock readLock = rwl.readLock(); + private final Lock writeLock = rwl.writeLock(); + + private final List indexDescriptors; + private final IndexEntryContainer indexEntries; + + /** + * Constructs a new {@link DefaultIndexer} with the given {@link IndexDescriptor}s and + * {@link IndexEntryContainer}. + * + * @param indexDescriptors the index descriptors. + * @param oldIndexEntries must have the same size with the given descriptors + */ + public DefaultIndexer(List indexDescriptors, + IndexEntryContainer oldIndexEntries) { + this.indexDescriptors = new ArrayList<>(indexDescriptors); + this.indexEntries = new IndexEntryContainer(); + for (IndexEntry entry : oldIndexEntries) { + this.indexEntries.add(entry); + } + for (IndexDescriptor indexDescriptor : indexDescriptors) { + if (!indexDescriptor.isReady()) { + throw new IllegalArgumentException( + "Index descriptor is not ready for: " + indexDescriptor.getSpec().getName()); + } + if (!this.indexEntries.contains(indexDescriptor)) { + throw new IllegalArgumentException( + "Index entry not found for: " + indexDescriptor.getSpec().getName()); + } + } + } + + static String getObjectKey(Extension extension) { + return PrimaryKeySpecUtils.getObjectPrimaryKey(extension); + } + + @Override + public void indexRecord(E extension) { + writeLock.lock(); + var transaction = new IndexerTransactionImpl(); + try { + transaction.begin(); + doIndexRecord(extension).forEach(transaction::add); + transaction.commit(); + } catch (Throwable e) { + transaction.rollback(); + throw e; + } finally { + writeLock.unlock(); + } + } + + @Override + public void updateRecord(E extension) { + writeLock.lock(); + var transaction = new IndexerTransactionImpl(); + try { + transaction.begin(); + unIndexRecord(getObjectKey(extension)); + indexRecord(extension); + transaction.commit(); + } catch (Throwable e) { + transaction.rollback(); + throw e; + } finally { + writeLock.unlock(); + } + } + + @Override + public void unIndexRecord(String extensionName) { + writeLock.lock(); + var transaction = new IndexerTransactionImpl(); + try { + transaction.begin(); + doUnIndexRecord(extensionName).forEach(transaction::add); + transaction.commit(); + } catch (Throwable e) { + transaction.rollback(); + throw e; + } finally { + writeLock.unlock(); + } + } + + private List doUnIndexRecord(String extensionName) { + List changeRecords = new ArrayList<>(); + for (IndexEntry indexEntry : indexEntries) { + indexEntry.entries().forEach(records -> { + var indexKey = records.getKey(); + var objectKey = records.getValue(); + if (objectKey.equals(extensionName)) { + changeRecords.add(ChangeRecord.onRemove(indexEntry, indexKey, objectKey)); + } + }); + } + return changeRecords; + } + + private List doIndexRecord(E extension) { + List changeRecords = new ArrayList<>(); + for (IndexDescriptor indexDescriptor : indexDescriptors) { + var indexEntry = indexEntries.get(indexDescriptor); + var indexFunc = indexDescriptor.getSpec().getIndexFunc(); + Set indexKeys = indexFunc.getValues(extension); + var objectKey = PrimaryKeySpecUtils.getObjectPrimaryKey(extension); + for (String indexKey : indexKeys) { + changeRecords.add(ChangeRecord.onAdd(indexEntry, indexKey, objectKey)); + } + } + return changeRecords; + } + + @Override + public IndexDescriptor findIndexByName(String name) { + readLock.lock(); + try { + return indexDescriptors.stream() + .filter(descriptor -> descriptor.getSpec().getName().equals(name)) + .findFirst() + .orElse(null); + } finally { + readLock.unlock(); + } + } + + @Override + public IndexEntry createIndexEntry(IndexDescriptor descriptor) { + return new IndexEntryImpl(descriptor); + } + + @Override + public void removeIndexRecords(Function matchFn) { + writeLock.lock(); + try { + var iterator = indexEntries.iterator(); + while (iterator.hasNext()) { + var entry = iterator.next(); + if (BooleanUtils.isTrue(matchFn.apply(entry.getIndexDescriptor()))) { + iterator.remove(); + entry.clear(); + indexEntries.add(createIndexEntry(entry.getIndexDescriptor())); + } + } + } finally { + writeLock.unlock(); + } + } + + @Override + @NonNull + public IndexEntry getIndexEntry(String name) { + readLock.lock(); + try { + var indexDescriptor = findIndexByName(name); + if (indexDescriptor == null) { + throw new IllegalArgumentException( + "No index found for fieldPath [" + name + "], " + + "make sure you have created an index for this field."); + } + if (!indexDescriptor.isReady()) { + throw new IllegalStateException( + "Index [" + name + "] is not ready, " + + "Please wait for more time or check the index status."); + } + return indexEntries.get(indexDescriptor); + } finally { + readLock.unlock(); + } + } + + @Override + public Iterator readyIndexesIterator() { + readLock.lock(); + try { + var readyIndexes = new ArrayList(); + for (IndexEntry entry : indexEntries) { + if (entry.getIndexDescriptor().isReady()) { + readyIndexes.add(entry); + } + } + return readyIndexes.iterator(); + } finally { + readLock.unlock(); + } + } + + @Override + public Iterator allIndexesIterator() { + readLock.lock(); + try { + return indexEntries.iterator(); + } finally { + readLock.unlock(); + } + } + + @Override + public void acquireReadLock() { + readLock.lock(); + } + + @Override + public void releaseReadLock() { + readLock.unlock(); + } +} diff --git a/application/src/main/java/run/halo/app/extension/index/ExtensionIterator.java b/application/src/main/java/run/halo/app/extension/index/ExtensionIterator.java new file mode 100644 index 0000000..faf693c --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/ExtensionIterator.java @@ -0,0 +1,17 @@ +package run.halo.app.extension.index; + +import java.util.Iterator; +import run.halo.app.extension.Extension; + +/** + * An iterator over a collection of extensions, it is used to iterate extensions in a paginated + * way to avoid loading all extensions into memory at once. + * + * @param the type of the extension. + * @author guqing + * @see DefaultExtensionIterator + * @since 2.12.0 + */ +public interface ExtensionIterator extends Iterator { + +} diff --git a/application/src/main/java/run/halo/app/extension/index/ExtensionPaginatedLister.java b/application/src/main/java/run/halo/app/extension/index/ExtensionPaginatedLister.java new file mode 100644 index 0000000..155f954 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/ExtensionPaginatedLister.java @@ -0,0 +1,23 @@ +package run.halo.app.extension.index; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import run.halo.app.extension.Extension; + +/** + * List extensions with pagination, used for {@link ExtensionIterator}. + * + * @author guqing + * @since 2.12.0 + */ +@FunctionalInterface +public interface ExtensionPaginatedLister { + + /** + * List extensions with pagination. + * + * @param pageable pageable + * @return page of extensions + */ + Page list(Pageable pageable); +} diff --git a/application/src/main/java/run/halo/app/extension/index/IndexBuilder.java b/application/src/main/java/run/halo/app/extension/index/IndexBuilder.java new file mode 100644 index 0000000..4d0f98f --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/IndexBuilder.java @@ -0,0 +1,26 @@ +package run.halo.app.extension.index; + +import org.springframework.lang.NonNull; + +/** + * {@link IndexBuilder} is used to build index for a specific + * {@link run.halo.app.extension.Extension} type on startup. + * + * @author guqing + * @since 2.12.0 + */ +public interface IndexBuilder { + /** + * Start building index for a specific {@link run.halo.app.extension.Extension} type. + */ + void startBuildingIndex(); + + /** + * Gets final index entries after building index. + * + * @return index entries must not be null. + * @throws IllegalStateException if any index entries are not ready yet. + */ + @NonNull + IndexEntryContainer getIndexEntries(); +} diff --git a/application/src/main/java/run/halo/app/extension/index/IndexBuilderImpl.java b/application/src/main/java/run/halo/app/extension/index/IndexBuilderImpl.java new file mode 100644 index 0000000..a165e71 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/IndexBuilderImpl.java @@ -0,0 +1,65 @@ +package run.halo.app.extension.index; + +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import org.springframework.lang.NonNull; +import run.halo.app.extension.Extension; + +public class IndexBuilderImpl implements IndexBuilder { + private final List indexDescriptors; + private final ExtensionIterator extensionIterator; + + private final IndexEntryContainer indexEntries = new IndexEntryContainer(); + + public static IndexBuilder of(List indexDescriptors, + ExtensionIterator extensionIterator) { + return new IndexBuilderImpl(indexDescriptors, extensionIterator); + } + + IndexBuilderImpl(List indexDescriptors, + ExtensionIterator extensionIterator) { + this.indexDescriptors = indexDescriptors; + this.extensionIterator = extensionIterator; + indexDescriptors.forEach(indexDescriptor -> { + var indexEntry = new IndexEntryImpl(indexDescriptor); + indexEntries.add(indexEntry); + }); + } + + @Override + public void startBuildingIndex() { + while (extensionIterator.hasNext()) { + var extensionRecord = extensionIterator.next(); + + indexRecords(extensionRecord); + } + + for (IndexDescriptor indexDescriptor : indexDescriptors) { + indexDescriptor.setReady(true); + } + } + + @Override + @NonNull + public IndexEntryContainer getIndexEntries() { + for (IndexEntry indexEntry : indexEntries) { + if (!indexEntry.getIndexDescriptor().isReady()) { + throw new IllegalStateException( + "IndexEntry are not ready yet for index named " + + indexEntry.getIndexDescriptor().getSpec().getName()); + } + } + return indexEntries; + } + + private void indexRecords(E extension) { + for (IndexDescriptor indexDescriptor : indexDescriptors) { + var indexEntry = indexEntries.get(indexDescriptor); + var indexFunc = indexDescriptor.getSpec().getIndexFunc(); + Set indexKeys = indexFunc.getValues(extension); + indexEntry.addEntry(new LinkedList<>(indexKeys), + PrimaryKeySpecUtils.getObjectPrimaryKey(extension)); + } + } +} diff --git a/application/src/main/java/run/halo/app/extension/index/IndexEntryContainer.java b/application/src/main/java/run/halo/app/extension/index/IndexEntryContainer.java new file mode 100644 index 0000000..dac484a --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/IndexEntryContainer.java @@ -0,0 +1,71 @@ +package run.halo.app.extension.index; + +import java.util.Iterator; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Consumer; +import org.springframework.lang.NonNull; + +/** + *

A container for {@link IndexEntry}s, it is used to store all {@link IndexEntry}s according + * to the {@link IndexDescriptor}.

+ *

This class is thread-safe.

+ * + * @author guqing + * @see DefaultIndexer + * @since 2.12.0 + */ +public class IndexEntryContainer implements Iterable { + private final ConcurrentMap indexEntryMap; + + public IndexEntryContainer() { + this.indexEntryMap = new ConcurrentHashMap<>(); + } + + /** + * Add an {@link IndexEntry} to this container. + * + * @param entry the entry to add + * @throws IllegalArgumentException if the entry already exists + */ + public void add(IndexEntry entry) { + IndexEntry existing = indexEntryMap.putIfAbsent(entry.getIndexDescriptor(), entry); + if (existing != null) { + throw new IllegalArgumentException( + "Index entry already exists for " + entry.getIndexDescriptor()); + } + } + + /** + * Get the {@link IndexEntry} for the given {@link IndexDescriptor}. + * + * @param indexDescriptor the index descriptor + * @return the index entry + */ + public IndexEntry get(IndexDescriptor indexDescriptor) { + return indexEntryMap.get(indexDescriptor); + } + + public boolean contains(IndexDescriptor indexDescriptor) { + return indexEntryMap.containsKey(indexDescriptor); + } + + public void remove(IndexDescriptor indexDescriptor) { + indexEntryMap.remove(indexDescriptor); + } + + public int size() { + return indexEntryMap.size(); + } + + @Override + @NonNull + public Iterator iterator() { + return indexEntryMap.values().iterator(); + } + + @Override + public void forEach(Consumer action) { + indexEntryMap.values().forEach(action); + } +} diff --git a/application/src/main/java/run/halo/app/extension/index/IndexEntryImpl.java b/application/src/main/java/run/halo/app/extension/index/IndexEntryImpl.java new file mode 100644 index 0000000..b677d6e --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/IndexEntryImpl.java @@ -0,0 +1,165 @@ +package run.halo.app.extension.index; + +import com.google.common.collect.ListMultimap; +import com.google.common.collect.MultimapBuilder; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NavigableSet; +import java.util.TreeSet; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import lombok.Data; +import run.halo.app.infra.exception.DuplicateNameException; + +@Data +public class IndexEntryImpl implements IndexEntry { + private final ReadWriteLock rwl = new ReentrantReadWriteLock(); + private final Lock readLock = rwl.readLock(); + private final Lock writeLock = rwl.writeLock(); + + private final IndexDescriptor indexDescriptor; + private final ListMultimap indexKeyObjectNamesMap; + + /** + * Creates a new {@link IndexEntryImpl} for the given {@link IndexDescriptor}. + * + * @param indexDescriptor for which the {@link IndexEntryImpl} is created. + */ + public IndexEntryImpl(IndexDescriptor indexDescriptor) { + this.indexDescriptor = indexDescriptor; + this.indexKeyObjectNamesMap = MultimapBuilder.treeKeys(keyComparator()) + .linkedListValues().build(); + } + + Comparator keyComparator() { + var order = indexDescriptor.getSpec().getOrder(); + if (IndexSpec.OrderType.ASC.equals(order)) { + return KeyComparator.INSTANCE; + } + return KeyComparator.INSTANCE.reversed(); + } + + @Override + public void acquireReadLock() { + this.rwl.readLock().lock(); + } + + @Override + public void releaseReadLock() { + this.rwl.readLock().unlock(); + } + + @Override + public void addEntry(List keys, String objectName) { + var isUnique = indexDescriptor.getSpec().isUnique(); + for (String key : keys) { + writeLock.lock(); + try { + if (isUnique && indexKeyObjectNamesMap.containsKey(key)) { + throw new DuplicateNameException( + "The value [%s] is already exists for unique index [%s].".formatted( + key, + indexDescriptor.getSpec().getName()), + null, + "problemDetail.index.duplicateKey", + new Object[] {key, indexDescriptor.getSpec().getName()}); + } + this.indexKeyObjectNamesMap.put(key, objectName); + } finally { + writeLock.unlock(); + } + } + } + + @Override + public void removeEntry(String indexedKey, String objectKey) { + writeLock.lock(); + try { + indexKeyObjectNamesMap.remove(indexedKey, objectKey); + } finally { + writeLock.unlock(); + } + } + + @Override + public void remove(String objectName) { + writeLock.lock(); + try { + indexKeyObjectNamesMap.values().removeIf(objectName::equals); + } finally { + writeLock.unlock(); + } + } + + @Override + public NavigableSet indexedKeys() { + readLock.lock(); + try { + var keys = indexKeyObjectNamesMap.keySet(); + var resultSet = new TreeSet<>(keyComparator()); + resultSet.addAll(keys); + return resultSet; + } finally { + readLock.unlock(); + } + } + + @Override + public Collection> entries() { + readLock.lock(); + try { + return indexKeyObjectNamesMap.entries(); + } finally { + readLock.unlock(); + } + } + + @Override + public Map getIdPositionMap() { + readLock.lock(); + try { + // asMap is sorted by key + var keyObjectMap = getKeyObjectMap(); + int i = 0; + var idPositionMap = new HashMap(); + for (var valueIdsEntry : keyObjectMap.entrySet()) { + var ids = valueIdsEntry.getValue(); + for (String id : ids) { + idPositionMap.put(id, i); + } + i++; + } + return idPositionMap; + } finally { + readLock.unlock(); + } + } + + protected Map> getKeyObjectMap() { + return indexKeyObjectNamesMap.asMap(); + } + + @Override + public List getObjectNamesBy(String indexKey) { + readLock.lock(); + try { + return indexKeyObjectNamesMap.get(indexKey); + } finally { + readLock.unlock(); + } + } + + @Override + public void clear() { + writeLock.lock(); + try { + indexKeyObjectNamesMap.clear(); + } finally { + writeLock.unlock(); + } + } +} diff --git a/application/src/main/java/run/halo/app/extension/index/IndexSpecRegistryImpl.java b/application/src/main/java/run/halo/app/extension/index/IndexSpecRegistryImpl.java new file mode 100644 index 0000000..1831ff8 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/IndexSpecRegistryImpl.java @@ -0,0 +1,85 @@ +package run.halo.app.extension.index; + +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.springframework.lang.NonNull; +import run.halo.app.extension.Extension; +import run.halo.app.extension.ExtensionStoreUtil; +import run.halo.app.extension.Scheme; + +/** + *

A default implementation of {@link IndexSpecRegistry}.

+ * + * @author guqing + * @since 2.12.0 + */ +public class IndexSpecRegistryImpl implements IndexSpecRegistry { + private final ConcurrentMap extensionIndexSpecs = new ConcurrentHashMap<>(); + + @Override + public IndexSpecs indexFor(Scheme scheme) { + var keySpace = getKeySpace(scheme); + var indexSpecs = new DefaultIndexSpecs(); + useDefaultIndexSpec(scheme.type(), indexSpecs); + extensionIndexSpecs.put(keySpace, indexSpecs); + return indexSpecs; + } + + @Override + public IndexSpecs getIndexSpecs(Scheme scheme) { + var keySpace = getKeySpace(scheme); + var result = extensionIndexSpecs.get(keySpace); + if (result == null) { + throw new IllegalArgumentException( + "No index specs found for extension type: " + scheme.groupVersionKind() + + ", make sure you have called indexFor() before calling getIndexSpecs()"); + + } + return result; + } + + @Override + public boolean contains(Scheme scheme) { + var keySpace = getKeySpace(scheme); + return extensionIndexSpecs.containsKey(keySpace); + } + + @Override + public void removeIndexSpecs(Scheme scheme) { + var keySpace = getKeySpace(scheme); + extensionIndexSpecs.remove(keySpace); + } + + @Override + @NonNull + public String getKeySpace(Scheme scheme) { + return ExtensionStoreUtil.buildStoreNamePrefix(scheme); + } + + void useDefaultIndexSpec(Class extensionType, + IndexSpecs indexSpecs) { + var nameIndexSpec = PrimaryKeySpecUtils.primaryKeyIndexSpec(extensionType); + indexSpecs.add(nameIndexSpec); + + var creationTimestampIndexSpec = new IndexSpec() + .setName("metadata.creationTimestamp") + .setOrder(IndexSpec.OrderType.ASC) + .setUnique(false) + .setIndexFunc(IndexAttributeFactory.simpleAttribute(extensionType, + e -> e.getMetadata().getCreationTimestamp().toString()) + ); + indexSpecs.add(creationTimestampIndexSpec); + + var deletionTimestampIndexSpec = new IndexSpec() + .setName("metadata.deletionTimestamp") + .setOrder(IndexSpec.OrderType.ASC) + .setUnique(false) + .setIndexFunc(IndexAttributeFactory.simpleAttribute(extensionType, + e -> Objects.toString(e.getMetadata().getDeletionTimestamp(), null)) + ); + indexSpecs.add(deletionTimestampIndexSpec); + + indexSpecs.add(LabelIndexSpecUtils.labelIndexSpec(extensionType)); + } +} diff --git a/application/src/main/java/run/halo/app/extension/index/IndexedQueryEngineImpl.java b/application/src/main/java/run/halo/app/extension/index/IndexedQueryEngineImpl.java new file mode 100644 index 0000000..809cea4 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/IndexedQueryEngineImpl.java @@ -0,0 +1,211 @@ +package run.halo.app.extension.index; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.NavigableSet; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StopWatch; +import run.halo.app.extension.GroupVersionKind; +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.QueryFactory; +import run.halo.app.extension.index.query.QueryIndexView; +import run.halo.app.extension.index.query.QueryIndexViewImpl; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.extension.router.selector.LabelSelector; +import run.halo.app.extension.router.selector.SelectorMatcher; + +/** + * A default implementation of {@link IndexedQueryEngine}. + * + * @author guqing + * @since 2.12.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class IndexedQueryEngineImpl implements IndexedQueryEngine { + + private final IndexerFactory indexerFactory; + + @Override + public ListResult retrieve(GroupVersionKind type, ListOptions options, + PageRequest page) { + var allMatchedResult = doRetrieve(type, options, page.getSort()); + var list = ListResult.subList(allMatchedResult, page.getPageNumber(), page.getPageSize()); + return new ListResult<>(page.getPageNumber(), page.getPageSize(), + allMatchedResult.size(), list); + } + + @Override + public List retrieveAll(GroupVersionKind type, ListOptions options, Sort sort) { + return doRetrieve(type, options, sort); + } + + NavigableSet retrieveForLabelMatchers(Indexer indexer, + List labelMatchers) { + var objectLabelMap = ObjectLabelMap.buildFrom(indexer, labelMatchers); + // O(k×m) time complexity, k is the number of keys, m is the number of labelMatchers + return objectLabelMap.objectIdLabelsMap() + .entrySet() + .stream() + .filter(entry -> { + var labels = entry.getValue(); + // object match all labels will be returned + return labelMatchers.stream() + .allMatch(matcher -> matcher.test(labels.get(matcher.getKey()))); + }) + .map(Map.Entry::getKey) + .collect(Collectors.toCollection(TreeSet::new)); + } + + NavigableSet evaluateSelectorsForIndex(Indexer indexer, QueryIndexView indexView, + ListOptions options) { + final var hasLabelSelector = hasLabelSelector(options.getLabelSelector()); + final var hasFieldSelector = hasFieldSelector(options.getFieldSelector()); + + if (!hasLabelSelector && !hasFieldSelector) { + return QueryFactory.all().matches(indexView); + } + + // only label selector + if (hasLabelSelector && !hasFieldSelector) { + return retrieveForLabelMatchers(indexer, options.getLabelSelector().getMatchers()); + } + + // only field selector + if (!hasLabelSelector) { + var fieldSelector = options.getFieldSelector(); + return fieldSelector.query().matches(indexView); + } + + // both label and field selector + var fieldSelector = options.getFieldSelector(); + var forField = fieldSelector.query().matches(indexView); + var forLabel = + retrieveForLabelMatchers(indexer, options.getLabelSelector().getMatchers()); + + // determine the optimal retainAll direction based on the size of the collection + var resultSet = (forField.size() <= forLabel.size()) ? forField : forLabel; + resultSet.retainAll((resultSet == forField) ? forLabel : forField); + return resultSet; + } + + List doRetrieve(GroupVersionKind type, ListOptions options, Sort sort) { + var indexer = indexerFactory.getIndexer(type); + + StopWatch stopWatch = new StopWatch(type.toString()); + + stopWatch.start("Check index status to ensure all indexes are ready"); + var fieldNamesUsedInQuery = getFieldNamesUsedInListOptions(options, sort); + checkIndexForNames(indexer, fieldNamesUsedInQuery); + stopWatch.stop(); + + var indexView = new QueryIndexViewImpl(indexer); + + stopWatch.start("Evaluate selectors for index"); + var resultSet = evaluateSelectorsForIndex(indexer, indexView, options); + stopWatch.stop(); + + stopWatch.start("Sort result set by sort order"); + var result = indexView.sortBy(resultSet, sort); + stopWatch.stop(); + + if (log.isTraceEnabled()) { + log.trace("Retrieve result from indexer by query [{}],\n {}", options, + stopWatch.prettyPrint(TimeUnit.MILLISECONDS)); + } + return result; + } + + void checkIndexForNames(Indexer indexer, Set indexNames) { + indexer.acquireReadLock(); + try { + for (String indexName : indexNames) { + // get index entry will throw exception if index not found + indexer.getIndexEntry(indexName); + } + } finally { + indexer.releaseReadLock(); + } + } + + @NonNull + private Set getFieldNamesUsedInListOptions(ListOptions options, Sort sort) { + var fieldNamesUsedInQuery = new HashSet(); + fieldNamesUsedInQuery.add(PrimaryKeySpecUtils.PRIMARY_INDEX_NAME); + for (Sort.Order order : sort) { + fieldNamesUsedInQuery.add(order.getProperty()); + } + var hasFieldSelector = hasFieldSelector(options.getFieldSelector()); + if (hasFieldSelector) { + var fieldQuery = options.getFieldSelector().query(); + var fieldNames = QueryFactory.getFieldNamesUsedInQuery(fieldQuery); + fieldNamesUsedInQuery.addAll(fieldNames); + } + return fieldNamesUsedInQuery; + } + + boolean hasLabelSelector(LabelSelector labelSelector) { + return labelSelector != null && !CollectionUtils.isEmpty(labelSelector.getMatchers()); + } + + boolean hasFieldSelector(FieldSelector fieldSelector) { + return fieldSelector != null; + } + + record ObjectLabelMap(Map> objectIdLabelsMap) { + + public static ObjectLabelMap buildFrom(Indexer indexer, + List labelMatchers) { + indexer.acquireReadLock(); + try { + final var objectNameLabelsMap = new HashMap>(); + final var labelIndexEntry = indexer.getIndexEntry(LabelIndexSpecUtils.LABEL_PATH); + // O(m) time complexity, m is the number of labelMatchers + final var labelKeysToQuery = labelMatchers.stream() + .sorted(Comparator.comparing(SelectorMatcher::getKey)) + .map(SelectorMatcher::getKey) + .collect(Collectors.toSet()); + + labelIndexEntry.entries().forEach(entry -> { + // key is labelKey=labelValue, value is objectName + var labelPair = LabelIndexSpecUtils.labelKeyValuePair(entry.getKey()); + if (!labelKeysToQuery.contains(labelPair.getFirst())) { + return; + } + objectNameLabelsMap.computeIfAbsent(entry.getValue(), k -> new HashMap<>()) + .put(labelPair.getFirst(), labelPair.getSecond()); + }); + + var nameIndexOperator = new IndexEntryOperatorImpl( + indexer.getIndexEntry(PrimaryKeySpecUtils.PRIMARY_INDEX_NAME) + ); + var allIndexedObjectNames = nameIndexOperator.getValues(); + + // remove all object names that exist labels,O(n) time complexity + allIndexedObjectNames.removeAll(objectNameLabelsMap.keySet()); + // add absent object names to object labels map + for (String name : allIndexedObjectNames) { + objectNameLabelsMap.put(name, new HashMap<>()); + } + return new ObjectLabelMap(objectNameLabelsMap); + } finally { + indexer.releaseReadLock(); + } + } + } +} diff --git a/application/src/main/java/run/halo/app/extension/index/IndexerFactory.java b/application/src/main/java/run/halo/app/extension/index/IndexerFactory.java new file mode 100644 index 0000000..1128928 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/IndexerFactory.java @@ -0,0 +1,56 @@ +package run.halo.app.extension.index; + +import org.springframework.lang.NonNull; +import run.halo.app.extension.Extension; +import run.halo.app.extension.ExtensionStoreUtil; +import run.halo.app.extension.GroupVersionKind; +import run.halo.app.extension.Scheme; +import run.halo.app.extension.SchemeManager; + +/** + *

{@link IndexerFactory} is used to create {@link Indexer} for {@link Extension} type.

+ *

It's stored {@link Indexer} by key space, the key space is generated by {@link Scheme} like + * {@link ExtensionStoreUtil#buildStoreNamePrefix(Scheme)}.

+ *

E.g. create {@link Indexer} for Post extension, the mapping relationship is:

+ *
+ *    /registry/content.halo.run/posts -> Indexer
+ * 
+ * + * @author guqing + * @since 2.12.0 + */ +public interface IndexerFactory { + + /** + * Create {@link Indexer} for {@link Extension} type. + * + * @param extensionType the extension type must exist in {@link SchemeManager}. + * @param extensionIterator the extension iterator to iterate all records for the extension type + * @return created {@link Indexer} + */ + @NonNull + Indexer createIndexerFor(Class extensionType, + ExtensionIterator extensionIterator); + + /** + * Get {@link Indexer} for {@link GroupVersionKind}. + * + * @param gvk the group version kind must exist in {@link SchemeManager} + * @return the indexer + * @throws IllegalArgumentException if the {@link GroupVersionKind} represents a special + * {@link Extension} not exists in {@link SchemeManager} + */ + @NonNull + Indexer getIndexer(GroupVersionKind gvk); + + boolean contains(GroupVersionKind gvk); + + /** + *

Remove a specific {@link Indexer} by {@link Scheme} that represents a {@link Extension} + * .

+ *

Usually, the specified {@link Scheme} is not in {@link SchemeManager} at this time.

+ * + * @param scheme the scheme represents a {@link Extension} + */ + void removeIndexer(Scheme scheme); +} diff --git a/application/src/main/java/run/halo/app/extension/index/IndexerFactoryImpl.java b/application/src/main/java/run/halo/app/extension/index/IndexerFactoryImpl.java new file mode 100644 index 0000000..f0932ab --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/IndexerFactoryImpl.java @@ -0,0 +1,84 @@ +package run.halo.app.extension.index; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import run.halo.app.extension.Extension; +import run.halo.app.extension.ExtensionStoreUtil; +import run.halo.app.extension.GroupVersionKind; +import run.halo.app.extension.Scheme; +import run.halo.app.extension.SchemeManager; + +/** + *

A default implementation of {@link IndexerFactory}.

+ * + * @author guqing + * @since 2.12.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class IndexerFactoryImpl implements IndexerFactory { + private final ConcurrentMap keySpaceIndexer = new ConcurrentHashMap<>(); + + private final IndexSpecRegistry indexSpecRegistry; + private final SchemeManager schemeManager; + + @Override + @NonNull + public Indexer createIndexerFor(Class extensionType, + ExtensionIterator extensionIterator) { + var scheme = schemeManager.get(extensionType); + var keySpace = indexSpecRegistry.getKeySpace(scheme); + if (keySpaceIndexer.containsKey(keySpace)) { + throw new IllegalArgumentException("Indexer already exists for type: " + keySpace); + } + if (!indexSpecRegistry.contains(scheme)) { + indexSpecRegistry.indexFor(scheme); + } + var specs = indexSpecRegistry.getIndexSpecs(scheme); + var indexDescriptors = specs.getIndexSpecs() + .stream() + .map(IndexDescriptor::new) + .toList(); + + final long startTimeMs = System.currentTimeMillis(); + log.info("Start building index for type: {}, please wait...", keySpace); + var indexBuilder = IndexBuilderImpl.of(indexDescriptors, extensionIterator); + indexBuilder.startBuildingIndex(); + var indexer = + new DefaultIndexer(indexDescriptors, indexBuilder.getIndexEntries()); + keySpaceIndexer.put(keySpace, indexer); + log.info("Index for type: {} built successfully, cost {} ms", keySpace, + System.currentTimeMillis() - startTimeMs); + return indexer; + } + + @Override + @NonNull + public Indexer getIndexer(GroupVersionKind gvk) { + var scheme = schemeManager.get(gvk); + var indexer = keySpaceIndexer.get(indexSpecRegistry.getKeySpace(scheme)); + if (indexer == null) { + throw new IllegalArgumentException("No indexer found for type: " + gvk); + } + return indexer; + } + + @Override + public boolean contains(GroupVersionKind gvk) { + var schemeOpt = schemeManager.fetch(gvk); + return schemeOpt.isPresent() + && keySpaceIndexer.containsKey(indexSpecRegistry.getKeySpace(schemeOpt.get())); + } + + @Override + public void removeIndexer(Scheme scheme) { + var keySpace = ExtensionStoreUtil.buildStoreNamePrefix(scheme); + keySpaceIndexer.remove(keySpace); + indexSpecRegistry.removeIndexSpecs(scheme); + } +} diff --git a/application/src/main/java/run/halo/app/extension/index/IndexerTransaction.java b/application/src/main/java/run/halo/app/extension/index/IndexerTransaction.java new file mode 100644 index 0000000..cbb090d --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/IndexerTransaction.java @@ -0,0 +1,40 @@ +package run.halo.app.extension.index; + +import org.springframework.util.Assert; + +/** + *

{@link IndexerTransaction} is a transactional interface for {@link Indexer} to ensure + * consistency when {@link Indexer} indexes objects.

+ *

It is not supported to call {@link #begin()} twice without calling {@link #commit()} or + * {@link #rollback()} in between and it is not supported to call one of {@link #commit()} or + * {@link #rollback()} in different thread than {@link #begin()} was called.

+ * + * @author guqing + * @since 2.12.0 + */ +public interface IndexerTransaction { + void begin(); + + void commit(); + + void rollback(); + + void add(ChangeRecord changeRecord); + + record ChangeRecord(IndexEntry indexEntry, String key, String value, boolean isAdd) { + + public ChangeRecord { + Assert.notNull(indexEntry, "IndexEntry must not be null"); + Assert.notNull(key, "Key must not be null"); + Assert.notNull(value, "Value must not be null"); + } + + public static ChangeRecord onAdd(IndexEntry indexEntry, String key, String value) { + return new ChangeRecord(indexEntry, key, value, true); + } + + public static ChangeRecord onRemove(IndexEntry indexEntry, String key, String value) { + return new ChangeRecord(indexEntry, key, value, false); + } + } +} diff --git a/application/src/main/java/run/halo/app/extension/index/IndexerTransactionImpl.java b/application/src/main/java/run/halo/app/extension/index/IndexerTransactionImpl.java new file mode 100644 index 0000000..d90bf92 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/IndexerTransactionImpl.java @@ -0,0 +1,105 @@ +package run.halo.app.extension.index; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; + +/** + * Implementation of {@link IndexerTransaction}. + * + * @author guqing + * @since 2.12.0 + */ +public class IndexerTransactionImpl implements IndexerTransaction { + private Deque changeRecords; + private boolean inTransaction = false; + private Long threadId; + + @Override + public synchronized void begin() { + if (inTransaction) { + throw new IllegalStateException("Transaction already active"); + } + threadId = Thread.currentThread().getId(); + this.changeRecords = new ArrayDeque<>(); + inTransaction = true; + } + + @Override + public synchronized void commit() { + checkThread(); + if (!inTransaction) { + throw new IllegalStateException("Transaction not started"); + } + Deque committedRecords = new ArrayDeque<>(); + try { + while (!changeRecords.isEmpty()) { + var changeRecord = changeRecords.pop(); + applyChange(changeRecord); + committedRecords.push(changeRecord); + } + // Reset threadId after transaction ends + inTransaction = false; + // Reset threadId after transaction ends + threadId = null; + } catch (Exception e) { + // Rollback the changes that were committed before the error occurred + while (!committedRecords.isEmpty()) { + var changeRecord = committedRecords.pop(); + revertChange(changeRecord); + } + throw e; + } + } + + @Override + public synchronized void rollback() { + checkThread(); + if (!inTransaction) { + throw new IllegalStateException("Transaction not started"); + } + changeRecords.clear(); + inTransaction = false; + // Reset threadId after transaction ends + threadId = null; + } + + @Override + public synchronized void add(ChangeRecord changeRecord) { + if (inTransaction) { + changeRecords.push(changeRecord); + } else { + throw new IllegalStateException("No active transaction to add changes"); + } + } + + private void applyChange(ChangeRecord changeRecord) { + var indexEntry = changeRecord.indexEntry(); + var key = changeRecord.key(); + var value = changeRecord.value(); + var isAdd = changeRecord.isAdd(); + if (isAdd) { + indexEntry.addEntry(List.of(key), value); + } else { + indexEntry.removeEntry(key, value); + } + } + + private void revertChange(ChangeRecord changeRecord) { + var indexEntry = changeRecord.indexEntry(); + var key = changeRecord.key(); + var value = changeRecord.value(); + var isAdd = changeRecord.isAdd(); + if (isAdd) { + indexEntry.removeEntry(key, value); + } else { + indexEntry.addEntry(List.of(key), value); + } + } + + private void checkThread() { + if (threadId != null && !threadId.equals(Thread.currentThread().getId())) { + throw new IllegalStateException("Transaction cannot span multiple threads!"); + } + } +} diff --git a/application/src/main/java/run/halo/app/extension/index/LabelIndexSpecUtils.java b/application/src/main/java/run/halo/app/extension/index/LabelIndexSpecUtils.java new file mode 100644 index 0000000..4117245 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/LabelIndexSpecUtils.java @@ -0,0 +1,55 @@ +package run.halo.app.extension.index; + +import java.util.Set; +import java.util.stream.Collectors; +import lombok.experimental.UtilityClass; +import org.springframework.data.util.Pair; +import run.halo.app.extension.Extension; + +@UtilityClass +public class LabelIndexSpecUtils { + + public static final String LABEL_PATH = "metadata.labels"; + + /** + * Creates a label index spec. + * + * @param extensionType extension type + * @param extension type + * @return label index spec + */ + public static IndexSpec labelIndexSpec(Class extensionType) { + return new IndexSpec() + .setName(LABEL_PATH) + .setOrder(IndexSpec.OrderType.ASC) + .setUnique(false) + .setIndexFunc(IndexAttributeFactory.multiValueAttribute(extensionType, + LabelIndexSpecUtils::labelIndexValueFunc) + ); + } + + /** + * Label key-value pair from indexed label key string, e.g. "key=value". + * + * @param indexedLabelKey indexed label key + * @return label key-value pair + */ + public static Pair labelKeyValuePair(String indexedLabelKey) { + var idx = indexedLabelKey.indexOf('='); + if (idx != -1) { + return Pair.of(indexedLabelKey.substring(0, idx), indexedLabelKey.substring(idx + 1)); + } + throw new IllegalArgumentException("Invalid label key-value pair: " + indexedLabelKey); + } + + static Set labelIndexValueFunc(E obj) { + var labels = obj.getMetadata().getLabels(); + if (labels == null) { + return Set.of(); + } + return labels.entrySet() + .stream() + .map(entry -> entry.getKey() + "=" + entry.getValue()) + .collect(Collectors.toSet()); + } +} diff --git a/application/src/main/java/run/halo/app/extension/index/PrimaryKeySpecUtils.java b/application/src/main/java/run/halo/app/extension/index/PrimaryKeySpecUtils.java new file mode 100644 index 0000000..6fb11bf --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/index/PrimaryKeySpecUtils.java @@ -0,0 +1,30 @@ +package run.halo.app.extension.index; + +import lombok.experimental.UtilityClass; +import run.halo.app.extension.Extension; + +@UtilityClass +public class PrimaryKeySpecUtils { + public static final String PRIMARY_INDEX_NAME = "metadata.name"; + + /** + * Primary key index spec. + * + * @param type the type + * @param the type parameter of {@link Extension} + * @return the index spec + */ + public static IndexSpec primaryKeyIndexSpec(Class type) { + return new IndexSpec() + .setName(PRIMARY_INDEX_NAME) + .setOrder(IndexSpec.OrderType.ASC) + .setUnique(true) + .setIndexFunc(IndexAttributeFactory.simpleAttribute(type, + e -> e.getMetadata().getName()) + ); + } + + public static String getObjectPrimaryKey(Extension obj) { + return obj.getMetadata().getName(); + } +} diff --git a/application/src/main/java/run/halo/app/extension/router/ExtensionCompositeRouterFunction.java b/application/src/main/java/run/halo/app/extension/router/ExtensionCompositeRouterFunction.java new file mode 100644 index 0000000..bf5e981 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/router/ExtensionCompositeRouterFunction.java @@ -0,0 +1,87 @@ +package run.halo.app.extension.router; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.lang.NonNull; +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.SchemeManager; +import run.halo.app.extension.SchemeWatcherManager; +import run.halo.app.extension.SchemeWatcherManager.SchemeWatcher; + +public class ExtensionCompositeRouterFunction implements + RouterFunction, + SchemeWatcher, + InitializingBean, + ApplicationListener { + + private final Map> schemeRouterFuncMapper; + + private final ReactiveExtensionClient client; + + private final SchemeManager schemeManager; + + private final SchemeWatcherManager watcherManager; + + public ExtensionCompositeRouterFunction(ReactiveExtensionClient client, + SchemeWatcherManager watcherManager, + SchemeManager schemeManager) { + this.client = client; + this.schemeManager = schemeManager; + this.watcherManager = watcherManager; + 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()); + } + + @Override + public void onChange(SchemeWatcherManager.ChangeEvent event) { + if (event instanceof SchemeWatcherManager.SchemeRegistered registeredEvent) { + var scheme = registeredEvent.getNewScheme(); + var factory = new ExtensionRouterFunctionFactory(scheme, client); + this.schemeRouterFuncMapper.put(scheme, factory.create()); + } else if (event instanceof SchemeWatcherManager.SchemeUnregistered unregisteredEvent) { + this.schemeRouterFuncMapper.remove(unregisteredEvent.getDeletedScheme()); + } + } + + @Override + public void onApplicationEvent(ApplicationStartedEvent event) { + schemeManager.schemes().forEach(scheme -> { + var factory = new ExtensionRouterFunctionFactory(scheme, client); + this.schemeRouterFuncMapper.put(scheme, factory.create()); + }); + } + + @Override + public void afterPropertiesSet() { + watcherManager.register(this); + } +} diff --git a/application/src/main/java/run/halo/app/extension/router/ExtensionCreateHandler.java b/application/src/main/java/run/halo/app/extension/router/ExtensionCreateHandler.java new file mode 100644 index 0000000..e90c9bd --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/router/ExtensionCreateHandler.java @@ -0,0 +1,45 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/extension/router/ExtensionDeleteHandler.java b/application/src/main/java/run/halo/app/extension/router/ExtensionDeleteHandler.java new file mode 100644 index 0000000..ca31560 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/router/ExtensionDeleteHandler.java @@ -0,0 +1,40 @@ +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}"; + } + +} diff --git a/application/src/main/java/run/halo/app/extension/router/ExtensionGetHandler.java b/application/src/main/java/run/halo/app/extension/router/ExtensionGetHandler.java new file mode 100644 index 0000000..ebd0064 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/router/ExtensionGetHandler.java @@ -0,0 +1,39 @@ +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)); + } +} diff --git a/application/src/main/java/run/halo/app/extension/router/ExtensionListHandler.java b/application/src/main/java/run/halo/app/extension/router/ExtensionListHandler.java new file mode 100644 index 0000000..f11e979 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/router/ExtensionListHandler.java @@ -0,0 +1,42 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/extension/router/ExtensionPatchHandler.java b/application/src/main/java/run/halo/app/extension/router/ExtensionPatchHandler.java new file mode 100644 index 0000000..6f5f1fd --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/router/ExtensionPatchHandler.java @@ -0,0 +1,80 @@ +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 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 + */ +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}"; + } + +} diff --git a/application/src/main/java/run/halo/app/extension/router/ExtensionRouterFunctionFactory.java b/application/src/main/java/run/halo/app/extension/router/ExtensionRouterFunctionFactory.java new file mode 100644 index 0000000..976e1ad --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/router/ExtensionRouterFunctionFactory.java @@ -0,0 +1,161 @@ +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 { + + } + +} diff --git a/application/src/main/java/run/halo/app/extension/router/ExtensionUpdateHandler.java b/application/src/main/java/run/halo/app/extension/router/ExtensionUpdateHandler.java new file mode 100644 index 0000000..c13b1b8 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/router/ExtensionUpdateHandler.java @@ -0,0 +1,48 @@ +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}"; + } +} diff --git a/application/src/main/java/run/halo/app/extension/router/JsonPatch.java b/application/src/main/java/run/halo/app/extension/router/JsonPatch.java new file mode 100644 index 0000000..55b3bf5 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/router/JsonPatch.java @@ -0,0 +1,104 @@ +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) + ); + } + +} diff --git a/application/src/main/java/run/halo/app/extension/store/ExtensionStore.java b/application/src/main/java/run/halo/app/extension/store/ExtensionStore.java new file mode 100644 index 0000000..ad21ed7 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/store/ExtensionStore.java @@ -0,0 +1,55 @@ +package run.halo.app.extension.store; + +import jakarta.persistence.Lob; +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. + */ + @Lob + 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; + } +} diff --git a/application/src/main/java/run/halo/app/extension/store/ExtensionStoreClient.java b/application/src/main/java/run/halo/app/extension/store/ExtensionStoreClient.java new file mode 100644 index 0000000..45e3adf --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/store/ExtensionStoreClient.java @@ -0,0 +1,64 @@ +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); + + 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. +} diff --git a/application/src/main/java/run/halo/app/extension/store/ExtensionStoreClientJPAImpl.java b/application/src/main/java/run/halo/app/extension/store/ExtensionStoreClientJPAImpl.java new file mode 100644 index 0000000..e503070 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/store/ExtensionStoreClientJPAImpl.java @@ -0,0 +1,57 @@ +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; +import org.springframework.stereotype.Service; + +/** + * An implementation of ExtensionStoreClient using JPA. + * + * @author johnniang + */ +@Service +public class ExtensionStoreClientJPAImpl implements ExtensionStoreClient { + + private final ReactiveExtensionStoreClient storeClient; + + public ExtensionStoreClientJPAImpl(ReactiveExtensionStoreClient storeClient) { + this.storeClient = storeClient; + } + + @Override + public List listByNamePrefix(String prefix) { + return storeClient.listByNamePrefix(prefix).collectList().block(); + } + + @Override + public Page listByNamePrefix(String prefix, Pageable pageable) { + return storeClient.listByNamePrefix(prefix, pageable).block(); + } + + @Override + public List listByNames(List names) { + return storeClient.listByNames(names).collectList().block(); + } + + @Override + public Optional fetchByName(String name) { + return storeClient.fetchByName(name).blockOptional(); + } + + @Override + public ExtensionStore create(String name, byte[] data) { + return storeClient.create(name, data).block(); + } + + @Override + public ExtensionStore update(String name, Long version, byte[] data) { + return storeClient.update(name, version, data).block(); + } + + @Override + public ExtensionStore delete(String name, Long version) { + return storeClient.delete(name, version).block(); + } +} diff --git a/application/src/main/java/run/halo/app/extension/store/ExtensionStoreRepository.java b/application/src/main/java/run/halo/app/extension/store/ExtensionStoreRepository.java new file mode 100644 index 0000000..ddf4203 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/store/ExtensionStoreRepository.java @@ -0,0 +1,38 @@ +package run.halo.app.extension.store; + +import java.util.List; +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(List names); +} diff --git a/application/src/main/java/run/halo/app/extension/store/ReactiveExtensionStoreClient.java b/application/src/main/java/run/halo/app/extension/store/ReactiveExtensionStoreClient.java new file mode 100644 index 0000000..ab7ba7b --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/store/ReactiveExtensionStoreClient.java @@ -0,0 +1,31 @@ +package run.halo.app.extension.store; + +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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 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); + +} diff --git a/application/src/main/java/run/halo/app/extension/store/ReactiveExtensionStoreClientImpl.java b/application/src/main/java/run/halo/app/extension/store/ReactiveExtensionStoreClientImpl.java new file mode 100644 index 0000000..465245e --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/store/ReactiveExtensionStoreClientImpl.java @@ -0,0 +1,71 @@ +package run.halo.app.extension.store; + +import java.util.Comparator; +import java.util.List; +import java.util.function.ToIntFunction; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.infra.exception.DuplicateNameException; + +@Component +public class ReactiveExtensionStoreClientImpl implements ReactiveExtensionStoreClient { + + private final ExtensionStoreRepository repository; + + public ReactiveExtensionStoreClientImpl(ExtensionStoreRepository repository) { + this.repository = repository; + } + + @Override + public Flux listByNamePrefix(String prefix) { + return repository.findAllByNameStartingWith(prefix); + } + + @Override + public Mono> listByNamePrefix(String prefix, Pageable pageable) { + return this.repository.findAllByNameStartingWith(prefix, pageable) + .collectList() + .zipWith(this.repository.countByNameStartingWith(prefix)) + .map(p -> new PageImpl<>(p.getT1(), pageable, p.getT2())); + } + + @Override + public Flux listByNames(List names) { + ToIntFunction comparator = + store -> names.indexOf(store.getName()); + return repository.findByNameIn(names) + .sort(Comparator.comparingInt(comparator)); + } + + @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); + }); + } +} diff --git a/application/src/main/java/run/halo/app/infra/DefaultBackupRootGetter.java b/application/src/main/java/run/halo/app/infra/DefaultBackupRootGetter.java new file mode 100644 index 0000000..976467a --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/DefaultBackupRootGetter.java @@ -0,0 +1,20 @@ +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"); + } +} diff --git a/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java b/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java new file mode 100644 index 0000000..f3b9091 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java @@ -0,0 +1,54 @@ +package run.halo.app.infra; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +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)) { + return link; + } + if (externalLink == null || !linkInSite(externalLink, link)) { + return link; + } + + return append(externalLink.toString(), link); + } + + String append(String externalLink, String link) { + return StringUtils.removeEnd(externalLink, "/") + + StringUtils.prependIfMissing(link, "/"); + } + + boolean linkInSite(@NonNull URL externalUrl, @NonNull String link) { + if (!PathUtils.isAbsoluteUri(link)) { + // relative uri is always in site + return true; + } + try { + URI requestUri = new URI(link); + return StringUtils.equals(externalUrl.getAuthority(), requestUri.getAuthority()); + } catch (URISyntaxException e) { + // ignore this link + } + return false; + } +} diff --git a/application/src/main/java/run/halo/app/infra/DefaultInitializationStateGetter.java b/application/src/main/java/run/halo/app/infra/DefaultInitializationStateGetter.java new file mode 100644 index 0000000..fafed52 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/DefaultInitializationStateGetter.java @@ -0,0 +1,68 @@ +package run.halo.app.infra; + +import static org.apache.commons.lang3.BooleanUtils.isTrue; +import static run.halo.app.extension.index.query.QueryFactory.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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/DefaultReactiveUrlDataBufferFetcher.java b/application/src/main/java/run/halo/app/infra/DefaultReactiveUrlDataBufferFetcher.java new file mode 100644 index 0000000..822c3c6 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/DefaultReactiveUrlDataBufferFetcher.java @@ -0,0 +1,34 @@ +package run.halo.app.infra; + +import java.net.URI; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +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 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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/DefaultSystemVersionSupplier.java b/application/src/main/java/run/halo/app/infra/DefaultSystemVersionSupplier.java new file mode 100644 index 0000000..f91b48e --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/DefaultSystemVersionSupplier.java @@ -0,0 +1,34 @@ +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.valueOf(DEFAULT_VERSION); + } + var projectVersion = Objects.toString(properties.getVersion(), DEFAULT_VERSION); + return Version.valueOf(projectVersion); + } +} diff --git a/application/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java b/application/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java new file mode 100644 index 0000000..b186923 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java @@ -0,0 +1,65 @@ +package run.halo.app.infra; + +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.core.io.UrlResource; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.stereotype.Component; +import org.springframework.util.ResourceUtils; +import org.springframework.util.StreamUtils; +import run.halo.app.core.extension.theme.ThemeService; +import run.halo.app.infra.properties.HaloProperties; +import run.halo.app.infra.properties.ThemeProperties; +import run.halo.app.infra.utils.FileUtils; + +@Slf4j +@Component +public class DefaultThemeInitializer implements ApplicationListener { + + private final ThemeService themeService; + + private final ThemeRootGetter themeRoot; + + private final ThemeProperties themeProps; + + public DefaultThemeInitializer(ThemeService themeService, ThemeRootGetter themeRoot, + HaloProperties haloProps) { + this.themeService = themeService; + this.themeRoot = themeRoot; + this.themeProps = haloProps.getTheme(); + } + + @Override + public void onApplicationEvent(ApplicationStartedEvent event) { + if (themeProps.getInitializer().isDisabled()) { + log.debug("Skipped initializing default theme due to disabled"); + return; + } + var themeRoot = this.themeRoot.get(); + var location = themeProps.getInitializer().getLocation(); + try { + // TODO Checking if any themes are installed here in the future might be better? + if (!FileUtils.isEmpty(themeRoot)) { + log.debug("Skipped initializing default theme because there are themes " + + "inside theme root"); + return; + } + log.info("Initializing default theme from {}", location); + var themeUrl = ResourceUtils.getURL(location); + var content = DataBufferUtils.read(new UrlResource(themeUrl), + DefaultDataBufferFactory.sharedInstance, + StreamUtils.BUFFER_SIZE); + var theme = themeService.install(content).block(); + log.info("Initialized default theme: {}", theme); + // Because default active theme is default, we don't need to enabled it manually. + } catch (IOException e) { + // we should skip the initialization error at here + log.warn("Failed to initialize theme from " + location, e); + } + } + + +} diff --git a/application/src/main/java/run/halo/app/infra/DefaultThemeRootGetter.java b/application/src/main/java/run/halo/app/infra/DefaultThemeRootGetter.java new file mode 100644 index 0000000..2df9380 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/DefaultThemeRootGetter.java @@ -0,0 +1,21 @@ +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"); + } + +} diff --git a/application/src/main/java/run/halo/app/infra/ExtensionInitializedEvent.java b/application/src/main/java/run/halo/app/infra/ExtensionInitializedEvent.java new file mode 100644 index 0000000..428b1a8 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/ExtensionInitializedEvent.java @@ -0,0 +1,16 @@ +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); + } + +} diff --git a/application/src/main/java/run/halo/app/infra/ExtensionResourceInitializer.java b/application/src/main/java/run/halo/app/infra/ExtensionResourceInitializer.java new file mode 100644 index 0000000..74fb91c --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/ExtensionResourceInitializer.java @@ -0,0 +1,118 @@ +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.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationListener; +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 ApplicationListener { + + 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; + } + + public void onApplicationEvent(ApplicationStartedEvent initializedEvent) { + 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(Mono.fromRunnable( + () -> eventPublisher.publishEvent(new ExtensionInitializedEvent(this)))) + .block(Duration.ofMinutes(1)); + } + + private Mono createOrUpdate(Unstructured extension) { + return Mono.just(extension) + .flatMap(ext -> extensionClient.fetch(extension.groupVersionKind(), + extension.getMetadata().getName())) + .flatMap(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); + } + } + +} diff --git a/application/src/main/java/run/halo/app/infra/HaloPropertiesExternalUrlSupplier.java b/application/src/main/java/run/halo/app/infra/HaloPropertiesExternalUrlSupplier.java new file mode 100644 index 0000000..d17c9e6 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/HaloPropertiesExternalUrlSupplier.java @@ -0,0 +1,71 @@ +package run.halo.app.infra; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxProperties; +import org.springframework.http.HttpRequest; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import run.halo.app.infra.properties.HaloProperties; + +/** + * Default implementation for getting external url from halo properties. + * + * @author johnniang + */ +@Component +public class HaloPropertiesExternalUrlSupplier implements ExternalUrlSupplier { + + private final HaloProperties haloProperties; + + private final WebFluxProperties webFluxProperties; + + public HaloPropertiesExternalUrlSupplier(HaloProperties haloProperties, + WebFluxProperties webFluxProperties) { + this.haloProperties = haloProperties; + this.webFluxProperties = webFluxProperties; + } + + @Override + public URI get() { + if (!haloProperties.isUseAbsolutePermalink()) { + return URI.create(getBasePath()); + } + + try { + return haloProperties.getExternalUrl().toURI(); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + + @Override + public URL getURL(HttpRequest request) { + var externalUrl = haloProperties.getExternalUrl(); + if (externalUrl == null) { + 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 haloProperties.getExternalUrl(); + } + + private String getBasePath() { + var basePath = webFluxProperties.getBasePath(); + if (!StringUtils.hasText(basePath)) { + basePath = "/"; + } + return basePath; + } +} diff --git a/application/src/main/java/run/halo/app/infra/InitializationStateGetter.java b/application/src/main/java/run/halo/app/infra/InitializationStateGetter.java new file mode 100644 index 0000000..87a65fd --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/InitializationStateGetter.java @@ -0,0 +1,26 @@ +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(); +} diff --git a/application/src/main/java/run/halo/app/infra/ReactiveExtensionPaginatedOperator.java b/application/src/main/java/run/halo/app/infra/ReactiveExtensionPaginatedOperator.java new file mode 100644 index 0000000..0feec59 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/ReactiveExtensionPaginatedOperator.java @@ -0,0 +1,37 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/infra/ReactiveExtensionPaginatedOperatorImpl.java b/application/src/main/java/run/halo/app/infra/ReactiveExtensionPaginatedOperatorImpl.java new file mode 100644 index 0000000..f6760af --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/ReactiveExtensionPaginatedOperatorImpl.java @@ -0,0 +1,132 @@ +package run.halo.app.infra; + +import static run.halo.app.extension.index.query.QueryFactory.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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/ReactiveUrlDataBufferFetcher.java b/application/src/main/java/run/halo/app/infra/ReactiveUrlDataBufferFetcher.java new file mode 100644 index 0000000..df37f04 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/ReactiveUrlDataBufferFetcher.java @@ -0,0 +1,23 @@ +package run.halo.app.infra; + +import java.net.URI; +import org.springframework.core.io.buffer.DataBuffer; +import reactor.core.publisher.Flux; + +/** + *

{@link DataBuffer} stream fetcher from uri.

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

Fetch data buffer flux from uri.

+ * + * @param uri uri to fetch + * @return data buffer flux + */ + Flux fetch(URI uri); +} diff --git a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java new file mode 100644 index 0000000..898627b --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -0,0 +1,578 @@ +package run.halo.app.infra; + +import static org.apache.commons.lang3.BooleanUtils.isTrue; +import static org.apache.commons.lang3.BooleanUtils.toStringTrueFalse; +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; +import static run.halo.app.extension.index.IndexAttributeFactory.multiValueAttribute; +import static run.halo.app.extension.index.IndexAttributeFactory.simpleAttribute; + +import com.fasterxml.jackson.core.type.TypeReference; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.context.event.ApplicationContextInitializedEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import run.halo.app.content.Stats; +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.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.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.content.Snapshot; +import run.halo.app.core.extension.content.Tag; +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.ConfigMap; +import run.halo.app.extension.DefaultSchemeManager; +import run.halo.app.extension.DefaultSchemeWatcherManager; +import run.halo.app.extension.MetadataOperator; +import run.halo.app.extension.MetadataUtil; +import run.halo.app.extension.Secret; +import run.halo.app.extension.index.IndexSpec; +import run.halo.app.extension.index.IndexSpecRegistryImpl; +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.search.extension.SearchEngine; +import run.halo.app.security.PersonalAccessToken; + +@Component +public class SchemeInitializer implements ApplicationListener { + + @Override + public void onApplicationEvent(@NonNull ApplicationContextInitializedEvent event) { + var schemeManager = createSchemeManager(event); + + schemeManager.register(Role.class); + + // plugin.halo.run + schemeManager.register(Plugin.class); + schemeManager.register(SearchEngine.class); + schemeManager.register(ExtensionPointDefinition.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.className") + .setIndexFunc(simpleAttribute(ExtensionPointDefinition.class, + definition -> definition.getSpec().getClassName()) + )); + }); + schemeManager.register(ExtensionDefinition.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.extensionPointName") + .setIndexFunc(simpleAttribute(ExtensionDefinition.class, + definition -> definition.getSpec().getExtensionPointName()) + )); + }); + + schemeManager.register(RoleBinding.class, is -> { + is.add(new IndexSpec() + .setName("roleRef.name") + .setIndexFunc(simpleAttribute(RoleBinding.class, + roleBinding -> roleBinding.getRoleRef().getName()) + ) + ); + is.add(new IndexSpec() + .setName("subjects") + .setIndexFunc(multiValueAttribute(RoleBinding.class, + roleBinding -> roleBinding.getSubjects().stream() + .map(RoleBinding.Subject::toString) + .collect(Collectors.toSet())) + ) + ); + }); + schemeManager.register(User.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.displayName") + .setIndexFunc( + simpleAttribute(User.class, user -> user.getSpec().getDisplayName()))); + indexSpecs.add(new IndexSpec() + .setName("spec.email") + .setIndexFunc(simpleAttribute(User.class, user -> { + var email = user.getSpec().getEmail(); + return StringUtils.isBlank(email) ? null : email; + }))); + indexSpecs.add(new IndexSpec() + .setName(User.USER_RELATED_ROLES_INDEX) + .setIndexFunc(multiValueAttribute(User.class, 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)))); + }); + schemeManager.register(ReverseProxy.class); + schemeManager.register(Setting.class); + schemeManager.register(AnnotationSetting.class); + 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(new IndexSpec() + .setName("spec.title") + .setIndexFunc(simpleAttribute(Post.class, post -> post.getSpec().getTitle()))); + indexSpecs.add(new IndexSpec() + .setName("spec.slug") + // Compatible with old data, hoping to set it to true in the future + .setUnique(false) + .setIndexFunc(simpleAttribute(Post.class, post -> post.getSpec().getSlug()))); + indexSpecs.add(new IndexSpec() + .setName("spec.publishTime") + .setIndexFunc(simpleAttribute(Post.class, post -> { + var publishTime = post.getSpec().getPublishTime(); + return publishTime == null ? null : publishTime.toString(); + }))); + indexSpecs.add(new IndexSpec() + .setName("spec.owner") + .setIndexFunc(simpleAttribute(Post.class, post -> post.getSpec().getOwner()))); + indexSpecs.add(new IndexSpec() + .setName("spec.deleted") + .setIndexFunc(simpleAttribute(Post.class, post -> { + var deleted = post.getSpec().getDeleted(); + return deleted == null ? "false" : deleted.toString(); + }))); + indexSpecs.add(new IndexSpec() + .setName("spec.pinned") + .setIndexFunc(simpleAttribute(Post.class, post -> { + var pinned = post.getSpec().getPinned(); + return pinned == null ? "false" : pinned.toString(); + }))); + indexSpecs.add(new IndexSpec() + .setName("spec.priority") + .setIndexFunc(simpleAttribute(Post.class, post -> { + var priority = post.getSpec().getPriority(); + return priority == null ? "0" : priority.toString(); + }))); + indexSpecs.add(new IndexSpec() + .setName("spec.visible") + .setIndexFunc( + simpleAttribute(Post.class, post -> post.getSpec().getVisible().name()))); + indexSpecs.add(new IndexSpec() + .setName("spec.tags") + .setIndexFunc(multiValueAttribute(Post.class, post -> { + var tags = post.getSpec().getTags(); + return tags == null ? Set.of() : Set.copyOf(tags); + }))); + indexSpecs.add(new IndexSpec() + .setName("spec.categories") + .setIndexFunc(multiValueAttribute(Post.class, post -> { + var categories = post.getSpec().getCategories(); + return categories == null ? Set.of() : Set.copyOf(categories); + }))); + indexSpecs.add(new IndexSpec() + .setName("status.contributors") + .setIndexFunc(multiValueAttribute(Post.class, post -> { + var contributors = post.getStatusOrDefault().getContributors(); + return contributors == null ? Set.of() : Set.copyOf(contributors); + }))); + indexSpecs.add(new IndexSpec() + .setName("status.phase") + .setIndexFunc( + simpleAttribute(Post.class, post -> post.getStatusOrDefault().getPhase()))); + indexSpecs.add(new IndexSpec() + .setName("status.excerpt") + .setIndexFunc( + simpleAttribute(Post.class, post -> post.getStatusOrDefault().getExcerpt()))); + indexSpecs.add(new IndexSpec() + .setName("status.lastModifyTime") + .setIndexFunc(simpleAttribute(Post.class, post -> { + var lastModifyTime = post.getStatus().getLastModifyTime(); + return lastModifyTime == null ? null : lastModifyTime.toString(); + }))); + indexSpecs.add(new IndexSpec() + .setName("status.hideFromList") + .setIndexFunc(simpleAttribute(Post.class, post -> { + var hidden = post.getStatus().getHideFromList(); + // only index when hidden is true + return (hidden == null || !hidden) ? null : BooleanUtils.TRUE; + })) + ); + indexSpecs.add(new IndexSpec() + .setName(Post.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME) + .setIndexFunc(simpleAttribute(Post.class, post -> { + var version = post.getMetadata().getVersion(); + var observedVersion = post.getStatusOrDefault().getObservedVersion(); + if (observedVersion == null || observedVersion < version) { + return BooleanUtils.TRUE; + } + // do not care about the false case so return null to avoid indexing + return null; + }))); + + indexSpecs.add(new IndexSpec() + .setName("stats.visit") + .setIndexFunc(simpleAttribute(Post.class, post -> { + var annotations = MetadataUtil.nullSafeAnnotations(post); + var statsStr = annotations.get(Post.STATS_ANNO); + if (StringUtils.isBlank(statsStr)) { + return "0"; + } + var stats = JsonUtils.jsonToObject(statsStr, Stats.class); + return ObjectUtils.defaultIfNull(stats.getVisit(), 0).toString(); + }))); + + indexSpecs.add(new IndexSpec() + .setName("stats.totalComment") + .setIndexFunc(simpleAttribute(Post.class, post -> { + var annotations = MetadataUtil.nullSafeAnnotations(post); + var statsStr = annotations.get(Post.STATS_ANNO); + if (StringUtils.isBlank(statsStr)) { + return "0"; + } + var stats = JsonUtils.jsonToObject(statsStr, Stats.class); + return ObjectUtils.defaultIfNull(stats.getTotalComment(), 0).toString(); + }))); + }); + schemeManager.register(Category.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.slug") + .setIndexFunc( + simpleAttribute(Category.class, category -> category.getSpec().getSlug())) + ); + indexSpecs.add(new IndexSpec() + .setName("spec.priority") + .setIndexFunc(simpleAttribute(Category.class, + category -> defaultIfNull(category.getSpec().getPriority(), 0).toString()))); + indexSpecs.add(new IndexSpec() + .setName("spec.children") + .setIndexFunc(multiValueAttribute(Category.class, category -> { + var children = category.getSpec().getChildren(); + return children == null ? Set.of() : Set.copyOf(children); + })) + ); + indexSpecs.add(new IndexSpec() + .setName("spec.hideFromList") + .setIndexFunc(simpleAttribute(Category.class, + category -> toStringTrueFalse(isTrue(category.getSpec().isHideFromList())) + )) + ); + }); + schemeManager.register(Tag.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.displayName") + .setIndexFunc(simpleAttribute(Tag.class, tag -> tag.getSpec().getDisplayName()))); + indexSpecs.add(new IndexSpec() + .setName("spec.slug") + .setIndexFunc(simpleAttribute(Tag.class, tag -> tag.getSpec().getSlug())) + ); + indexSpecs.add(new IndexSpec() + .setName(Tag.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME) + .setIndexFunc(simpleAttribute(Tag.class, tag -> { + var version = tag.getMetadata().getVersion(); + var observedVersion = tag.getStatusOrDefault().getObservedVersion(); + if (observedVersion == null || observedVersion < version) { + return BooleanUtils.TRUE; + } + // do not care about the false case so return null to avoid indexing + return null; + }))); + }); + schemeManager.register(Snapshot.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.subjectRef") + .setIndexFunc(simpleAttribute(Snapshot.class, + snapshot -> Snapshot.toSubjectRefKey(snapshot.getSpec().getSubjectRef())) + ) + ); + }); + schemeManager.register(Comment.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.creationTime") + .setIndexFunc(simpleAttribute(Comment.class, + comment -> defaultIfNull(comment.getSpec().getCreationTime(), + comment.getMetadata().getCreationTimestamp()).toString()) + )); + indexSpecs.add(new IndexSpec() + .setName("spec.approved") + .setIndexFunc(simpleAttribute(Comment.class, + comment -> toStringTrueFalse(isTrue(comment.getSpec().getApproved()))) + )); + indexSpecs.add(new IndexSpec() + .setName("spec.owner") + .setIndexFunc(simpleAttribute(Comment.class, comment -> { + var owner = comment.getSpec().getOwner(); + return Comment.CommentOwner.ownerIdentity(owner.getKind(), owner.getName()); + }))); + indexSpecs.add(new IndexSpec() + .setName("spec.subjectRef") + .setIndexFunc(simpleAttribute(Comment.class, + comment -> Comment.toSubjectRefKey(comment.getSpec().getSubjectRef())) + )); + indexSpecs.add(new IndexSpec() + .setName("spec.top") + .setIndexFunc(simpleAttribute(Comment.class, + comment -> toStringTrueFalse(isTrue(comment.getSpec().getTop()))) + )); + indexSpecs.add(new IndexSpec() + .setName("spec.hidden") + .setIndexFunc(simpleAttribute(Comment.class, + comment -> toStringTrueFalse(isTrue(comment.getSpec().getHidden()))) + )); + indexSpecs.add(new IndexSpec() + .setName("spec.priority") + .setIndexFunc(simpleAttribute(Comment.class, + comment -> { + var isTop = comment.getSpec().getTop(); + // only top comments have priority + if (!isTop) { + return "0"; + } + return defaultIfNull(comment.getSpec().getPriority(), 0).toString(); + }) + )); + indexSpecs.add(new IndexSpec() + .setName("spec.raw") + .setIndexFunc(simpleAttribute(Comment.class, + comment -> comment.getSpec().getRaw()) + )); + indexSpecs.add(new IndexSpec() + .setName("status.lastReplyTime") + .setIndexFunc(simpleAttribute(Comment.class, comment -> { + var lastReplyTime = comment.getStatusOrDefault().getLastReplyTime(); + return defaultIfNull(lastReplyTime, + comment.getSpec().getCreationTime()).toString(); + }))); + indexSpecs.add(new IndexSpec() + .setName("status.replyCount") + .setIndexFunc(simpleAttribute(Comment.class, comment -> { + var replyCount = comment.getStatusOrDefault().getReplyCount(); + return defaultIfNull(replyCount, 0).toString(); + }))); + indexSpecs.add(new IndexSpec() + .setName(Comment.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME) + .setIndexFunc(simpleAttribute(Comment.class, comment -> { + var version = comment.getMetadata().getVersion(); + var observedVersion = comment.getStatusOrDefault().getObservedVersion(); + if (observedVersion == null || observedVersion < version) { + return BooleanUtils.TRUE; + } + // do not care about the false case so return null to avoid indexing + return null; + }))); + }); + schemeManager.register(Reply.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.creationTime") + .setIndexFunc(simpleAttribute(Reply.class, + reply -> defaultIfNull(reply.getSpec().getCreationTime(), + reply.getMetadata().getCreationTimestamp()).toString()) + )); + indexSpecs.add(new IndexSpec() + .setName("spec.commentName") + .setIndexFunc(simpleAttribute(Reply.class, + reply -> reply.getSpec().getCommentName()) + )); + indexSpecs.add(new IndexSpec() + .setName("spec.hidden") + .setIndexFunc(simpleAttribute(Reply.class, + reply -> toStringTrueFalse(isTrue(reply.getSpec().getHidden()))) + )); + indexSpecs.add(new IndexSpec() + .setName("spec.approved") + .setIndexFunc(simpleAttribute(Reply.class, + reply -> toStringTrueFalse(isTrue(reply.getSpec().getApproved()))) + )); + indexSpecs.add(new IndexSpec() + .setName("spec.owner") + .setIndexFunc(simpleAttribute(Reply.class, reply -> { + var owner = reply.getSpec().getOwner(); + return Comment.CommentOwner.ownerIdentity(owner.getKind(), owner.getName()); + }))); + indexSpecs.add(new IndexSpec() + .setName(Reply.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME) + .setIndexFunc(simpleAttribute(Reply.class, reply -> { + var version = reply.getMetadata().getVersion(); + var observedVersion = reply.getStatus().getObservedVersion(); + if (observedVersion == null || observedVersion < version) { + return BooleanUtils.TRUE; + } + // do not care about the false case so return null to avoid indexing + return null; + }))); + }); + schemeManager.register(SinglePage.class); + // storage.halo.run + schemeManager.register(Group.class); + schemeManager.register(Policy.class); + schemeManager.register(Attachment.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.displayName") + .setIndexFunc(simpleAttribute(Attachment.class, + attachment -> attachment.getSpec().getDisplayName())) + ); + indexSpecs.add(new IndexSpec() + .setName("spec.policyName") + .setIndexFunc(simpleAttribute(Attachment.class, + attachment -> attachment.getSpec().getPolicyName())) + ); + indexSpecs.add(new IndexSpec() + .setName("spec.groupName") + .setIndexFunc(simpleAttribute(Attachment.class, attachment -> { + var group = attachment.getSpec().getGroupName(); + return StringUtils.isBlank(group) ? null : group; + })) + ); + indexSpecs.add(new IndexSpec() + .setName("spec.mediaType") + .setIndexFunc(simpleAttribute(Attachment.class, attachment -> { + var mediaType = attachment.getSpec().getMediaType(); + return StringUtils.isBlank(mediaType) ? null : mediaType; + })) + ); + indexSpecs.add(new IndexSpec() + .setName("spec.ownerName") + .setIndexFunc(simpleAttribute(Attachment.class, + attachment -> attachment.getSpec().getOwnerName())) + ); + indexSpecs.add(new IndexSpec() + .setName("spec.size") + .setIndexFunc(simpleAttribute(Attachment.class, + attachment -> { + var size = attachment.getSpec().getSize(); + return size != null ? size.toString() : null; + })) + ); + }); + schemeManager.register(PolicyTemplate.class); + // metrics.halo.run + schemeManager.register(Counter.class); + // auth.halo.run + schemeManager.register(AuthProvider.class); + schemeManager.register(UserConnection.class, is -> { + is.add(new IndexSpec() + .setName("spec.username") + .setIndexFunc(simpleAttribute(UserConnection.class, + connection -> Optional.ofNullable(connection.getSpec()) + .map(UserConnectionSpec::getUsername) + .orElse(null) + ))); + }); + + // security.halo.run + schemeManager.register(PersonalAccessToken.class); + schemeManager.register(RememberMeToken.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.series") + .setUnique(true) + .setIndexFunc(simpleAttribute(RememberMeToken.class, + token -> token.getSpec().getSeries()) + ) + ); + indexSpecs.add(new IndexSpec() + .setName("spec.username") + .setIndexFunc(simpleAttribute(RememberMeToken.class, + token -> token.getSpec().getUsername()) + ) + ); + }); + schemeManager.register(Device.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.principalName") + .setIndexFunc(simpleAttribute(Device.class, + device -> device.getSpec().getPrincipalName()) + ) + ); + }); + + // 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(new IndexSpec() + .setName("spec.reasonSelector.reasonType") + .setIndexFunc(simpleAttribute(NotificationTemplate.class, + template -> template.getSpec().getReasonSelector().getReasonType())) + ); + }); + schemeManager.register(Subscription.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.reason.reasonType") + .setIndexFunc(simpleAttribute(Subscription.class, + subscription -> subscription.getSpec().getReason().getReasonType())) + ); + indexSpecs.add(new IndexSpec() + .setName("spec.reason.subject") + .setIndexFunc(simpleAttribute(Subscription.class, + subscription -> subscription.getSpec().getReason().getSubject().toString())) + ); + indexSpecs.add(new IndexSpec() + .setName("spec.reason.expression") + .setIndexFunc(simpleAttribute(Subscription.class, + subscription -> subscription.getSpec().getReason().getExpression())) + ); + indexSpecs.add(new IndexSpec() + .setName("spec.subscriber") + .setIndexFunc(simpleAttribute(Subscription.class, + subscription -> subscription.getSpec().getSubscriber().toString())) + ); + }); + schemeManager.register(NotifierDescriptor.class); + schemeManager.register(Notification.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.unread") + .setIndexFunc(simpleAttribute(Notification.class, + notification -> String.valueOf(notification.getSpec().isUnread()))) + ); + indexSpecs.add(new IndexSpec() + .setName("spec.reason") + .setIndexFunc(simpleAttribute(Notification.class, + notification -> notification.getSpec().getReason())) + ); + indexSpecs.add(new IndexSpec() + .setName("spec.recipient") + .setIndexFunc(simpleAttribute(Notification.class, + notification -> notification.getSpec().getRecipient())) + ); + }); + } + + private static DefaultSchemeManager createSchemeManager( + ApplicationContextInitializedEvent event) { + var indexSpecRegistry = new IndexSpecRegistryImpl(); + var watcherManager = new DefaultSchemeWatcherManager(); + var schemeManager = new DefaultSchemeManager(indexSpecRegistry, watcherManager); + + var beanFactory = event.getApplicationContext().getBeanFactory(); + beanFactory.registerSingleton("indexSpecRegistry", indexSpecRegistry); + beanFactory.registerSingleton("schemeWatcherManager", watcherManager); + beanFactory.registerSingleton("schemeManager", schemeManager); + return schemeManager; + } +} diff --git a/application/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java b/application/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java new file mode 100644 index 0000000..2f3f87e --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java @@ -0,0 +1,154 @@ +package run.halo.app.infra; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.github.fge.jsonpatch.JsonPatchException; +import com.github.fge.jsonpatch.mergepatch.JsonMergePatch; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +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.JsonUtils; + +/** + * 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 + */ +@Component +public class SystemConfigurableEnvironmentFetcher { + private final ReactiveExtensionClient extensionClient; + private final ConversionService conversionService; + + public SystemConfigurableEnvironmentFetcher(ReactiveExtensionClient extensionClient, + ConversionService conversionService) { + this.extensionClient = extensionClient; + this.conversionService = conversionService; + } + + 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 JsonUtils.jsonToObject(stringValue, type); + }); + } + + public Mono fetchComment() { + return fetch(SystemSetting.Comment.GROUP, SystemSetting.Comment.class) + .switchIfEmpty(Mono.just(new SystemSetting.Comment())); + } + + public Mono fetchPost() { + return fetch(SystemSetting.Post.GROUP, SystemSetting.Post.class) + .switchIfEmpty(Mono.just(new SystemSetting.Post())); + } + + public Mono fetchRouteRules() { + return fetch(SystemSetting.ThemeRouteRules.GROUP, SystemSetting.ThemeRouteRules.class); + } + + @NonNull + private Mono> getValuesInternal() { + return getConfigMap() + .filter(configMap -> configMap.getData() != null) + .map(ConfigMap::getData) + .defaultIfEmpty(Map.of()); + } + + /** + * Gets config map. + * + * @return a new {@link ConfigMap} named system by json merge patch. + */ + public Mono getConfigMap() { + Mono mapMono = + extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG_DEFAULT); + if (mapMono == null) { + return Mono.empty(); + } + return mapMono.flatMap(systemDefault -> + extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) + .map(system -> { + Map defaultData = systemDefault.getData(); + Map data = system.getData(); + Map mergedData = mergeData(defaultData, data); + system.setData(mergedData); + return system; + }) + .switchIfEmpty(Mono.just(systemDefault))); + } + + public Optional getConfigMapBlocking() { + return getConfigMap().blockOptional(); + } + + private Map mergeData(Map defaultData, + Map data) { + if (defaultData == null) { + return data; + } + if (data == null) { + return defaultData; + } + + Map copiedDefault = new LinkedHashMap<>(defaultData); + // // merge the data map entries into the default map + data.forEach((group, dataValue) -> { + // https://www.rfc-editor.org/rfc/rfc7386 + String defaultV = copiedDefault.get(group); + String newValue; + if (dataValue == null) { + if (copiedDefault.containsKey(group)) { + newValue = null; + } else { + newValue = defaultV; + } + } else { + newValue = mergeRemappingFunction(dataValue, defaultV); + } + + if (newValue == null) { + copiedDefault.remove(group); + } else { + copiedDefault.put(group, newValue); + } + }); + return copiedDefault; + } + + String mergeRemappingFunction(String dataV, String defaultV) { + JsonNode dataJsonValue = nullSafeToJsonNode(dataV); + // original + JsonNode defaultJsonValue = nullSafeToJsonNode(defaultV); + try { + // patch + JsonMergePatch jsonMergePatch = JsonMergePatch.fromJson(dataJsonValue); + // apply patch to original + JsonNode patchedNode = jsonMergePatch.apply(defaultJsonValue); + return JsonUtils.objectToJson(patchedNode); + } catch (JsonPatchException e) { + throw new JsonParseException(e); + } + } + + JsonNode nullSafeToJsonNode(String json) { + return StringUtils.isBlank(json) ? JsonNodeFactory.instance.nullNode() + : JsonUtils.jsonToObject(json, JsonNode.class); + } +} diff --git a/application/src/main/java/run/halo/app/infra/SystemState.java b/application/src/main/java/run/halo/app/infra/SystemState.java new file mode 100644 index 0000000..1403a15 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/SystemState.java @@ -0,0 +1,74 @@ +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.util.LinkedHashMap; +import java.util.Map; +import lombok.Data; +import org.springframework.lang.NonNull; +import run.halo.app.extension.ConfigMap; +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); + } + } + + private static String emptyJsonObject() { + return "{}"; + } +} diff --git a/application/src/main/java/run/halo/app/infra/ThemeRootGetter.java b/application/src/main/java/run/halo/app/infra/ThemeRootGetter.java new file mode 100644 index 0000000..2f2a72a --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/ThemeRootGetter.java @@ -0,0 +1,13 @@ +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 { + +} diff --git a/application/src/main/java/run/halo/app/infra/ValidationUtils.java b/application/src/main/java/run/halo/app/infra/ValidationUtils.java new file mode 100644 index 0000000..bb52132 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/ValidationUtils.java @@ -0,0 +1,40 @@ +package run.halo.app.infra; + +import java.util.regex.Pattern; +import lombok.experimental.UtilityClass; +import org.apache.commons.lang3.StringUtils; + +@UtilityClass +public class ValidationUtils { + public static final Pattern NAME_PATTERN = + Pattern.compile("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$"); + + public static final String EMAIL_REGEX = + "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"; + + public static final String NAME_VALIDATION_MESSAGE = """ + Super administrator username must be a valid subdomain name, the name must: + 1. contain no more than 63 characters + 2. contain only lowercase alphanumeric characters, '-' or '.' + 3. start with an alphanumeric character + 4. end with an alphanumeric character + """; + + /** + * Validates the name. + * + * @param name name for validation + * @return true if the name is valid + */ + public static boolean validateName(String name) { + if (StringUtils.isBlank(name)) { + return false; + } + boolean matches = NAME_PATTERN.matcher(name).matches(); + return matches && name.length() <= 63; + } + + public static boolean isValidEmail(String email) { + return StringUtils.isNotBlank(email) && email.matches(EMAIL_REGEX); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/AccessDeniedException.java b/application/src/main/java/run/halo/app/infra/exception/AccessDeniedException.java new file mode 100644 index 0000000..2b1911d --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/AccessDeniedException.java @@ -0,0 +1,24 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/AttachmentAlreadyExistsException.java b/application/src/main/java/run/halo/app/infra/exception/AttachmentAlreadyExistsException.java new file mode 100644 index 0000000..2bcda1c --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/AttachmentAlreadyExistsException.java @@ -0,0 +1,16 @@ +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}); + } + +} diff --git a/application/src/main/java/run/halo/app/infra/exception/DuplicateNameException.java b/application/src/main/java/run/halo/app/infra/exception/DuplicateNameException.java new file mode 100644 index 0000000..a68889c --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/DuplicateNameException.java @@ -0,0 +1,24 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/EmailVerificationFailed.java b/application/src/main/java/run/halo/app/infra/exception/EmailVerificationFailed.java new file mode 100644 index 0000000..40074ba --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/EmailVerificationFailed.java @@ -0,0 +1,26 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/Exceptions.java b/application/src/main/java/run/halo/app/infra/exception/Exceptions.java new file mode 100644 index 0000000..93f4c3f --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/Exceptions.java @@ -0,0 +1,106 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/FileSizeExceededException.java b/application/src/main/java/run/halo/app/infra/exception/FileSizeExceededException.java new file mode 100644 index 0000000..bc26cb0 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/FileSizeExceededException.java @@ -0,0 +1,18 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/FileTypeNotAllowedException.java b/application/src/main/java/run/halo/app/infra/exception/FileTypeNotAllowedException.java new file mode 100644 index 0000000..6e7abe5 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/FileTypeNotAllowedException.java @@ -0,0 +1,18 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/NotFoundException.java b/application/src/main/java/run/halo/app/infra/exception/NotFoundException.java new file mode 100644 index 0000000..978122b --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/NotFoundException.java @@ -0,0 +1,31 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/PluginAlreadyExistsException.java b/application/src/main/java/run/halo/app/infra/exception/PluginAlreadyExistsException.java new file mode 100644 index 0000000..2a37d1c --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/PluginAlreadyExistsException.java @@ -0,0 +1,21 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/PluginDependenciesNotEnabledException.java b/application/src/main/java/run/halo/app/infra/exception/PluginDependenciesNotEnabledException.java new file mode 100644 index 0000000..3275ac0 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/PluginDependenciesNotEnabledException.java @@ -0,0 +1,32 @@ +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); + } + +} diff --git a/application/src/main/java/run/halo/app/infra/exception/PluginDependencyException.java b/application/src/main/java/run/halo/app/infra/exception/PluginDependencyException.java new file mode 100644 index 0000000..04cc2f3 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/PluginDependencyException.java @@ -0,0 +1,56 @@ +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); + } + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/PluginDependentsNotDisabledException.java b/application/src/main/java/run/halo/app/infra/exception/PluginDependentsNotDisabledException.java new file mode 100644 index 0000000..7cddbb8 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/PluginDependentsNotDisabledException.java @@ -0,0 +1,32 @@ +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); + } + +} diff --git a/application/src/main/java/run/halo/app/infra/exception/PluginInstallationException.java b/application/src/main/java/run/halo/app/infra/exception/PluginInstallationException.java new file mode 100644 index 0000000..e6100f5 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/PluginInstallationException.java @@ -0,0 +1,19 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/RateLimitExceededException.java b/application/src/main/java/run/halo/app/infra/exception/RateLimitExceededException.java new file mode 100644 index 0000000..4ac4eb6 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/RateLimitExceededException.java @@ -0,0 +1,15 @@ +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)); + } + +} diff --git a/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java b/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java new file mode 100644 index 0000000..353f2fb --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java @@ -0,0 +1,63 @@ +package run.halo.app.infra.exception; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +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; + +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(); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/ThemeAlreadyExistsException.java b/application/src/main/java/run/halo/app/infra/exception/ThemeAlreadyExistsException.java new file mode 100644 index 0000000..e3bcd9a --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/ThemeAlreadyExistsException.java @@ -0,0 +1,26 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/ThemeInstallationException.java b/application/src/main/java/run/halo/app/infra/exception/ThemeInstallationException.java new file mode 100644 index 0000000..2be1145 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/ThemeInstallationException.java @@ -0,0 +1,17 @@ +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); + } + +} diff --git a/application/src/main/java/run/halo/app/infra/exception/ThemeUninstallException.java b/application/src/main/java/run/halo/app/infra/exception/ThemeUninstallException.java new file mode 100644 index 0000000..fd337ba --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/ThemeUninstallException.java @@ -0,0 +1,16 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/ThemeUpgradeException.java b/application/src/main/java/run/halo/app/infra/exception/ThemeUpgradeException.java new file mode 100644 index 0000000..c040eb1 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/ThemeUpgradeException.java @@ -0,0 +1,17 @@ +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); + } + +} diff --git a/application/src/main/java/run/halo/app/infra/exception/UnsatisfiedAttributeValueException.java b/application/src/main/java/run/halo/app/infra/exception/UnsatisfiedAttributeValueException.java new file mode 100644 index 0000000..4b74dd9 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/UnsatisfiedAttributeValueException.java @@ -0,0 +1,20 @@ +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, @Nullable String messageDetailCode, + @Null Object[] messageDetailArguments) { + super(reason, null, null, messageDetailCode, messageDetailArguments); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/UserNotFoundException.java b/application/src/main/java/run/halo/app/infra/exception/UserNotFoundException.java new file mode 100644 index 0000000..2142bef --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/UserNotFoundException.java @@ -0,0 +1,13 @@ +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}); + } + +} diff --git a/application/src/main/java/run/halo/app/infra/exception/handlers/HaloErrorConfiguration.java b/application/src/main/java/run/halo/app/infra/exception/handlers/HaloErrorConfiguration.java new file mode 100644 index 0000000..a59c298 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/handlers/HaloErrorConfiguration.java @@ -0,0 +1,64 @@ +package run.halo.app.infra.exception.handlers; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.autoconfigure.web.WebProperties; +import org.springframework.boot.autoconfigure.web.reactive.error.ErrorWebFluxAutoConfiguration; +import org.springframework.boot.web.reactive.error.ErrorAttributes; +import org.springframework.boot.web.reactive.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; + +/** + * Configuration to render errors via a WebFlux + * {@link org.springframework.web.server.WebExceptionHandler}. + *
+ *
+ * See + * {@link org.springframework.boot.autoconfigure.web.reactive.error.ErrorWebFluxAutoConfiguration} + * for more. + * + * @author guqing + * @author johnniang + * @since 2.1.0 + */ +@Configuration +public class HaloErrorConfiguration { + + /** + * This bean will replace ErrorWebExceptionHandler defined at + * {@link ErrorWebFluxAutoConfiguration#errorWebExceptionHandler}. + */ + @Bean + @Order(-1) + ErrorWebExceptionHandler errorWebExceptionHandler(ErrorAttributes errorAttributes, + WebProperties webProperties, + ObjectProvider viewResolvers, + ServerCodecConfigurer serverCodecConfigurer, + ApplicationContext applicationContext, + ServerProperties serverProperties) { + var exceptionHandler = new HaloErrorWebExceptionHandler( + errorAttributes, + webProperties.getResources(), + serverProperties.getError(), + applicationContext); + exceptionHandler.setViewResolvers(viewResolvers.orderedStream().toList()); + exceptionHandler.setMessageWriters(serverCodecConfigurer.getWriters()); + exceptionHandler.setMessageReaders(serverCodecConfigurer.getReaders()); + return exceptionHandler; + } + + /** + * This bean will replace ErrorAttributes defined at + * {@link ErrorWebFluxAutoConfiguration#errorAttributes}. + */ + @Bean + ErrorAttributes errorAttributes(MessageSource messageSource) { + return new ProblemDetailErrorAttributes(messageSource); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/handlers/HaloErrorWebExceptionHandler.java b/application/src/main/java/run/halo/app/infra/exception/handlers/HaloErrorWebExceptionHandler.java new file mode 100644 index 0000000..4331c1c --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/handlers/HaloErrorWebExceptionHandler.java @@ -0,0 +1,80 @@ +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.autoconfigure.web.reactive.error.DefaultErrorWebExceptionHandler; +import org.springframework.boot.web.reactive.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); + }); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/handlers/ProblemDetailErrorAttributes.java b/application/src/main/java/run/halo/app/infra/exception/handlers/ProblemDetailErrorAttributes.java new file mode 100644 index 0000000..e65c36d --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/handlers/ProblemDetailErrorAttributes.java @@ -0,0 +1,54 @@ +package run.halo.app.infra.exception.handlers; + +import static run.halo.app.infra.exception.Exceptions.createErrorResponse; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.boot.web.reactive.error.DefaultErrorAttributes; +import org.springframework.boot.web.reactive.error.ErrorAttributes; +import org.springframework.context.MessageSource; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.server.ServerWebExchange; + +/** + * See {@link DefaultErrorAttributes} for more. + * + * @author johnn + */ +public class ProblemDetailErrorAttributes implements ErrorAttributes { + + private static final String ERROR_INTERNAL_ATTRIBUTE = + ProblemDetailErrorAttributes.class.getName() + ".ERROR"; + + private final MessageSource messageSource; + + public ProblemDetailErrorAttributes(MessageSource messageSource) { + this.messageSource = messageSource; + } + + @Override + public Map getErrorAttributes(ServerRequest request, + ErrorAttributeOptions options) { + final var errAttributes = new LinkedHashMap(); + var error = getError(request); + var errorResponse = createErrorResponse(error, null, request.exchange(), messageSource); + errAttributes.put("error", errorResponse.getBody()); + return errAttributes; + } + + @Override + public Throwable getError(ServerRequest request) { + return (Throwable) request.attribute(ERROR_INTERNAL_ATTRIBUTE).stream() + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Missing exception attribute in ServerWebExchange")); + } + + @Override + public void storeErrorInformation(Throwable error, ServerWebExchange exchange) { + exchange.getAttributes().putIfAbsent(ERROR_INTERNAL_ATTRIBUTE, error); + } + + +} diff --git a/application/src/main/java/run/halo/app/infra/properties/AttachmentProperties.java b/application/src/main/java/run/halo/app/infra/properties/AttachmentProperties.java new file mode 100644 index 0000000..2315c69 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/properties/AttachmentProperties.java @@ -0,0 +1,26 @@ +package run.halo.app.infra.properties; + +import java.util.LinkedList; +import java.util.List; +import lombok.Data; + +@Data +public class AttachmentProperties { + + private List resourceMappings = new LinkedList<>(); + + @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; + + } +} diff --git a/application/src/main/java/run/halo/app/infra/properties/CacheProperties.java b/application/src/main/java/run/halo/app/infra/properties/CacheProperties.java new file mode 100644 index 0000000..9d7daa3 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/properties/CacheProperties.java @@ -0,0 +1,10 @@ +package run.halo.app.infra.properties; + +import lombok.Data; + +@Data +public class CacheProperties { + + private boolean disabled; + +} diff --git a/application/src/main/java/run/halo/app/infra/properties/ConsoleProperties.java b/application/src/main/java/run/halo/app/infra/properties/ConsoleProperties.java new file mode 100644 index 0000000..8bf245a --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/properties/ConsoleProperties.java @@ -0,0 +1,14 @@ +package run.halo.app.infra.properties; + +import jakarta.validation.Valid; +import lombok.Data; + +@Data +public class ConsoleProperties { + + private String location = "classpath:/console/"; + + @Valid + private ProxyProperties proxy = new ProxyProperties(); + +} diff --git a/application/src/main/java/run/halo/app/infra/properties/ExtensionProperties.java b/application/src/main/java/run/halo/app/infra/properties/ExtensionProperties.java new file mode 100644 index 0000000..101f0a4 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/properties/ExtensionProperties.java @@ -0,0 +1,17 @@ +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; + + } + +} diff --git a/application/src/main/java/run/halo/app/infra/properties/HaloProperties.java b/application/src/main/java/run/halo/app/infra/properties/HaloProperties.java new file mode 100644 index 0000000..2a296df --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/properties/HaloProperties.java @@ -0,0 +1,77 @@ +package run.halo.app.infra.properties; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +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.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 + private final ExtensionProperties extension = new ExtensionProperties(); + + @Valid + private final SecurityProperties security = new SecurityProperties(); + + @Valid + private final ConsoleProperties console = new ConsoleProperties(); + + @Valid + private final UcProperties uc = new UcProperties(); + + @Valid + private final ThemeProperties theme = new ThemeProperties(); + + @Valid + 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; + if (props.isUseAbsolutePermalink() && props.getExternalUrl() == null) { + errors.rejectValue("externalUrl", "external-url.required.when-using-absolute-permalink", + "External URL is required when property `use-absolute-permalink` is set to true."); + } + } +} diff --git a/application/src/main/java/run/halo/app/infra/properties/JwtProperties.java b/application/src/main/java/run/halo/app/infra/properties/JwtProperties.java new file mode 100644 index 0000000..7a6c8f3 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/properties/JwtProperties.java @@ -0,0 +1,133 @@ +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())); + } + } +} diff --git a/application/src/main/java/run/halo/app/infra/properties/ProxyProperties.java b/application/src/main/java/run/halo/app/infra/properties/ProxyProperties.java new file mode 100644 index 0000000..275180d --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/properties/ProxyProperties.java @@ -0,0 +1,18 @@ +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; +} diff --git a/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java b/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java new file mode 100644 index 0000000..d67b495 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java @@ -0,0 +1,50 @@ +package run.halo.app.infra.properties; + +import static org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN; + +import java.time.Duration; +import lombok.Data; +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 RememberMeOptions rememberMe = new RememberMeOptions(); + + private final TwoFactorAuthOptions twoFactorAuth = new TwoFactorAuthOptions(); + + @Data + public static class TwoFactorAuthOptions { + + /** + * Whether two-factor authentication is disabled. + */ + private boolean disabled; + + } + + @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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/properties/ThemeProperties.java b/application/src/main/java/run/halo/app/infra/properties/ThemeProperties.java new file mode 100644 index 0000000..7f5f11c --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/properties/ThemeProperties.java @@ -0,0 +1,26 @@ +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"; + + } + +} diff --git a/application/src/main/java/run/halo/app/infra/properties/UcProperties.java b/application/src/main/java/run/halo/app/infra/properties/UcProperties.java new file mode 100644 index 0000000..46fd7c2 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/properties/UcProperties.java @@ -0,0 +1,14 @@ +package run.halo.app.infra.properties; + +import jakarta.validation.Valid; +import lombok.Data; + +@Data +public class UcProperties { + + private String location = "classpath:/uc/"; + + @Valid + private ProxyProperties proxy = new ProxyProperties(); + +} diff --git a/application/src/main/java/run/halo/app/infra/utils/Base62Utils.java b/application/src/main/java/run/halo/app/infra/utils/Base62Utils.java new file mode 100644 index 0000000..9eb1309 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/utils/Base62Utils.java @@ -0,0 +1,58 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/infra/utils/DataBufferUtils.java b/application/src/main/java/run/halo/app/infra/utils/DataBufferUtils.java new file mode 100644 index 0000000..dc27178 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/utils/DataBufferUtils.java @@ -0,0 +1,43 @@ +package run.halo.app.infra.utils; + +import static org.springframework.core.io.buffer.DataBufferUtils.releaseConsumer; +import static org.springframework.core.io.buffer.DataBufferUtils.write; + +import java.io.IOException; +import java.io.InputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import lombok.extern.slf4j.Slf4j; +import org.reactivestreams.Publisher; +import org.springframework.core.io.buffer.DataBuffer; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; +import reactor.util.context.Context; + +@Slf4j +public enum DataBufferUtils { + ; + + public static Mono toInputStream(Publisher content) { + return toInputStream(content, Schedulers.boundedElastic()); + } + + public static Mono toInputStream(Publisher content, + Scheduler scheduler) { + return Mono.create(sink -> { + try { + var pos = new PipedOutputStream(); + var pis = new PipedInputStream(pos); + var disposable = write(content, pos) + .subscribeOn(scheduler) + .subscribe(releaseConsumer(), sink::error, () -> FileUtils.closeQuietly(pos), + Context.of(sink.contextView())); + sink.onDispose(disposable); + sink.success(pis); + } catch (IOException e) { + sink.error(e); + } + }); + } +} diff --git a/application/src/main/java/run/halo/app/infra/utils/FileNameUtils.java b/application/src/main/java/run/halo/app/infra/utils/FileNameUtils.java new file mode 100644 index 0000000..36ef71e --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/utils/FileNameUtils.java @@ -0,0 +1,44 @@ +package run.halo.app.infra.utils; + +import com.google.common.io.Files; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; + +public final class FileNameUtils { + + private FileNameUtils() { + } + + 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) { + var nameWithoutExt = Files.getNameWithoutExtension(filename); + var ext = Files.getFileExtension(filename); + var random = RandomStringUtils.randomAlphabetic(length).toLowerCase(); + if (StringUtils.isBlank(nameWithoutExt)) { + return random + "." + ext; + } + if (StringUtils.isBlank(ext)) { + return nameWithoutExt + "-" + random; + } + return nameWithoutExt + "-" + random + "." + ext; + } +} diff --git a/application/src/main/java/run/halo/app/infra/utils/FileUtils.java b/application/src/main/java/run/halo/app/infra/utils/FileUtils.java new file mode 100644 index 0000000..ca790bf --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/utils/FileUtils.java @@ -0,0 +1,337 @@ +package run.halo.app.infra.utils; + +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; +import static org.springframework.util.FileSystemUtils.deleteRecursively; +import static run.halo.app.infra.utils.DataBufferUtils.toInputStream; + +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.reactivestreams.Publisher; +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() { + } + + public static Mono unzip(Publisher content, @NonNull Path targetPath) { + return unzip(content, targetPath, Schedulers.boundedElastic()); + } + + public static Mono unzip(Publisher content, @NonNull Path targetPath, + Scheduler scheduler) { + return Mono.usingWhen( + toInputStream(content, scheduler), + is -> { + try (var zis = new ZipInputStream(is)) { + unzip(zis, targetPath); + return Mono.empty(); + } catch (IOException e) { + return Mono.error(e); + } + }, + is -> Mono.fromRunnable(() -> closeQuietly(is)) + ); + } + + 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, Scheduler scheduler) { + return Mono.fromSupplier(() -> { + try { + return deleteRecursively(root); + } catch (IOException ignored) { + return false; + } + }).subscribeOn(scheduler); + } + + + 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 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, Scheduler scheduler) { + return Mono.fromCallable(() -> Files.createTempDirectory(prefix)).subscribeOn(scheduler); + } +} diff --git a/application/src/main/java/run/halo/app/infra/utils/HaloUtils.java b/application/src/main/java/run/halo/app/infra/utils/HaloUtils.java new file mode 100644 index 0000000..08d9e2c --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/utils/HaloUtils.java @@ -0,0 +1,73 @@ +package run.halo.app.infra.utils; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneId; +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; + +/** + * @author guqing + * @date 2022-04-12 + */ +@Slf4j +public class HaloUtils { + + /** + *

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.defaultString(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()); + } +} diff --git a/application/src/main/java/run/halo/app/infra/utils/IpAddressUtils.java b/application/src/main/java/run/halo/app/infra/utils/IpAddressUtils.java new file mode 100644 index 0000000..5a9ec87 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/utils/IpAddressUtils.java @@ -0,0 +1,71 @@ +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; + } + } + +} diff --git a/application/src/main/java/run/halo/app/infra/utils/VersionUtils.java b/application/src/main/java/run/halo/app/infra/utils/VersionUtils.java new file mode 100644 index 0000000..09441ac --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/utils/VersionUtils.java @@ -0,0 +1,49 @@ +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.valueOf(version).satisfies(constraint); + } catch (Exception e) { + throw new ServerWebInputException("Illegal requires version expression.", null, e); + } + } +} diff --git a/application/src/main/java/run/halo/app/infra/utils/YamlUnstructuredLoader.java b/application/src/main/java/run/halo/app/infra/utils/YamlUnstructuredLoader.java new file mode 100644 index 0000000..ce57ba9 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/utils/YamlUnstructuredLoader.java @@ -0,0 +1,50 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/metrics/CounterService.java b/application/src/main/java/run/halo/app/metrics/CounterService.java new file mode 100644 index 0000000..8dcf3b9 --- /dev/null +++ b/application/src/main/java/run/halo/app/metrics/CounterService.java @@ -0,0 +1,15 @@ +package run.halo.app.metrics; + +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); + + Mono deleteByName(String counterName); +} diff --git a/application/src/main/java/run/halo/app/metrics/CounterServiceImpl.java b/application/src/main/java/run/halo/app/metrics/CounterServiceImpl.java new file mode 100644 index 0000000..64727b5 --- /dev/null +++ b/application/src/main/java/run/halo/app/metrics/CounterServiceImpl.java @@ -0,0 +1,33 @@ +package run.halo.app.metrics; + +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Counter; +import run.halo.app.extension.ReactiveExtensionClient; + +/** + * 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 Mono deleteByName(String counterName) { + return client.fetch(Counter.class, counterName) + .flatMap(client::delete); + } +} diff --git a/application/src/main/java/run/halo/app/metrics/MeterUtils.java b/application/src/main/java/run/halo/app/metrics/MeterUtils.java new file mode 100644 index 0000000..c61c773 --- /dev/null +++ b/application/src/main/java/run/halo/app/metrics/MeterUtils.java @@ -0,0 +1,124 @@ +package run.halo.app.metrics; + +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; + } +} diff --git a/application/src/main/java/run/halo/app/metrics/PostStatsUpdater.java b/application/src/main/java/run/halo/app/metrics/PostStatsUpdater.java new file mode 100644 index 0000000..f417c6e --- /dev/null +++ b/application/src/main/java/run/halo/app/metrics/PostStatsUpdater.java @@ -0,0 +1,90 @@ +package run.halo.app.metrics; + +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.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; + } + + @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) { + } +} diff --git a/application/src/main/java/run/halo/app/metrics/ReplyEventReconciler.java b/application/src/main/java/run/halo/app/metrics/ReplyEventReconciler.java new file mode 100644 index 0000000..9f0c5f4 --- /dev/null +++ b/application/src/main/java/run/halo/app/metrics/ReplyEventReconciler.java @@ -0,0 +1,163 @@ +package run.halo.app.metrics; + +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.greaterThan; +import static run.halo.app.extension.index.query.QueryFactory.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.Query; +import run.halo.app.extension.router.selector.FieldSelector; + +/** + * 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(Query 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; + } + + @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())); + } +} diff --git a/application/src/main/java/run/halo/app/metrics/VisitedEventReconciler.java b/application/src/main/java/run/halo/app/metrics/VisitedEventReconciler.java new file mode 100644 index 0000000..968777a --- /dev/null +++ b/application/src/main/java/run/halo/app/metrics/VisitedEventReconciler.java @@ -0,0 +1,174 @@ +package run.halo.app.metrics; + +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.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; + +/** + * 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; + } + + 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) { + } + } +} diff --git a/application/src/main/java/run/halo/app/metrics/VotedEventReconciler.java b/application/src/main/java/run/halo/app/metrics/VotedEventReconciler.java new file mode 100644 index 0000000..915d498 --- /dev/null +++ b/application/src/main/java/run/halo/app/metrics/VotedEventReconciler.java @@ -0,0 +1,141 @@ +package run.halo.app.metrics; + +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.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; + +/** + * 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; + } + + @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) { + } + } +} diff --git a/application/src/main/java/run/halo/app/migration/BackupFile.java b/application/src/main/java/run/halo/app/migration/BackupFile.java new file mode 100644 index 0000000..83fdda7 --- /dev/null +++ b/application/src/main/java/run/halo/app/migration/BackupFile.java @@ -0,0 +1,34 @@ +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; + +} diff --git a/application/src/main/java/run/halo/app/migration/BackupReconciler.java b/application/src/main/java/run/halo/app/migration/BackupReconciler.java new file mode 100644 index 0000000..a97d32b --- /dev/null +++ b/application/src/main/java/run/halo/app/migration/BackupReconciler.java @@ -0,0 +1,133 @@ +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.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(); + 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(); + 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(); + } +} diff --git a/application/src/main/java/run/halo/app/migration/MigrationEndpoint.java b/application/src/main/java/run/halo/app/migration/MigrationEndpoint.java new file mode 100644 index 0000000..10abf98 --- /dev/null +++ b/application/src/main/java/run/halo/app/migration/MigrationEndpoint.java @@ -0,0 +1,234 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/migration/MigrationService.java b/application/src/main/java/run/halo/app/migration/MigrationService.java new file mode 100644 index 0000000..4639e31 --- /dev/null +++ b/application/src/main/java/run/halo/app/migration/MigrationService.java @@ -0,0 +1,40 @@ +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); + +} diff --git a/application/src/main/java/run/halo/app/migration/impl/MigrationServiceImpl.java b/application/src/main/java/run/halo/app/migration/impl/MigrationServiceImpl.java new file mode 100644 index 0000000..700bbd0 --- /dev/null +++ b/application/src/main/java/run/halo/app/migration/impl/MigrationServiceImpl.java @@ -0,0 +1,318 @@ +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.stream.BaseStream; +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.stereotype.Service; +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.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 +public class MigrationServiceImpl implements MigrationService, InitializingBean { + + private final ExtensionStoreRepository repository; + + private final HaloProperties haloProperties; + + private final BackupRootGetter backupRoot; + + private final ObjectMapper objectMapper; + + private final Set excludes = Set.of( + "**/.git/**", + "**/node_modules/**", + "backups/**", + "db/**", + "logs/**", + "docker-compose.yaml", + "docker-compose.yml", + "mysql/**", + "mysqlBackup/**", + "**/.idea/**", + "**/.vscode/**" + ); + + private final DateTimeFormatter dateTimeFormatter; + + private final Scheduler scheduler = Schedulers.boundedElastic(); + + public MigrationServiceImpl(ExtensionStoreRepository repository, + HaloProperties haloProperties, BackupRootGetter backupRoot) { + this.repository = repository; + this.haloProperties = haloProperties; + this.backupRoot = backupRoot; + this.objectMapper = JsonMapper.builder() + .defaultPrettyPrinter(new MinimalPrettyPrinter()) + .build(); + this.dateTimeFormatter = DateTimeFormatter + .ofPattern("yyyyMMddHHmmss") + .withLocale(Locale.getDefault()) + .withZone(ZoneId.systemDefault()); + } + + 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) { + return Mono.usingWhen( + createTempDir("halo-restore-", scheduler), + tempDir -> unpackBackup(content, tempDir) + .then(Mono.defer(() -> restoreExtensions(tempDir))) + .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.empty(); + } + var reader = objectMapper.readerFor(ExtensionStore.class); + return Mono.>using( + () -> reader.readValues(extensionsPath.toFile()), + itr -> Flux.create( + sink -> { + while (itr.hasNext()) { + sink.next(itr.next()); + } + sink.complete(); + }) + // reset version + .doOnNext(extensionStore -> extensionStore.setVersion(null)).buffer(100) + // We might encounter OptimisticLockingFailureException when saving extension + // store, + // So we have to delete all extension stores before saving. + .flatMap(extensionStores -> repository.deleteAll(extensionStores) + .thenMany(repository.saveAll(extensionStores)) + ) + .doOnNext(extensionStore -> log.info("Restored extension store: {}", + extensionStore.getName())) + .then(), + 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); + return backupsFolder; + }) + .handle((backupsFolder, sink) -> { + var backupName = backup.getMetadata().getName(); + var startTimestamp = backup.getStatus().getStartTimestamp(); + var timePart = this.dateTimeFormatter.format(startTimestamp); + var backupFile = backupsFolder.resolve(timePart + '-' + backupName + ".zip"); + try { + FileUtils.zip(baseDir, backupFile); + backup.getStatus().setFilename(backupFile.getFileName().toString()); + backup.getStatus().setSize(Files.size(backupFile)); + sink.complete(); + } catch (IOException e) { + sink.error(e); + } + }) + .subscribeOn(scheduler); + } + + private Mono backupWorkDir(Path baseDir) { + return Mono.fromCallable(() -> Files.createDirectory(baseDir.resolve("workdir"))) + .handle((workdirPath, sink) -> { + try { + copyRecursively(haloProperties.getWorkDir(), workdirPath, excludes); + sink.complete(); + } catch (IOException e) { + sink.error(e); + } + }) + .subscribeOn(scheduler); + } + + private Mono backupExtensions(Path baseDir) { + return Mono.fromCallable(() -> Files.createFile(baseDir.resolve("extensions.data"))) + .flatMap(extensionsPath -> Mono.using( + () -> objectMapper.writerFor(ExtensionStore.class) + .writeValuesAsArray(extensionsPath.toFile()), + seqWriter -> repository.findAll() + .doOnNext(extensionStore -> { + try { + seqWriter.write(extensionStore); + } catch (IOException e) { + throw Exceptions.propagate(e); + } + }).then(), + FileUtils::closeQuietly)) + .subscribeOn(scheduler); + } + + @Override + public void afterPropertiesSet() throws Exception { + Files.createDirectories(getBackupsRoot()); + } +} diff --git a/application/src/main/java/run/halo/app/notification/DefaultNotificationCenter.java b/application/src/main/java/run/halo/app/notification/DefaultNotificationCenter.java new file mode 100644 index 0000000..79ab9ca --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/DefaultNotificationCenter.java @@ -0,0 +1,292 @@ +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 reactor.core.scheduler.Schedulers; +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.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; + + @Override + public Mono notify(Reason reason) { + return recipientResolver.resolve(reason) + .doOnNext(subscriber -> { + log.debug("Dispatching notification to subscriber [{}] for reason [{}]", + subscriber, reason.getMetadata().getName()); + }) + .publishOn(Schedulers.boundedElastic()) + .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 Mono.just(Locale.getDefault()); + } +} diff --git a/application/src/main/java/run/halo/app/notification/DefaultNotificationReasonEmitter.java b/application/src/main/java/run/halo/app/notification/DefaultNotificationReasonEmitter.java new file mode 100644 index 0000000..2a62cc9 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/DefaultNotificationReasonEmitter.java @@ -0,0 +1,89 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/notification/DefaultNotificationSender.java b/application/src/main/java/run/halo/app/notification/DefaultNotificationSender.java new file mode 100644 index 0000000..b00f032 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/DefaultNotificationSender.java @@ -0,0 +1,150 @@ +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 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 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) + .doOnNext(notifier -> { + var item = new QueueItem(UUID.randomUUID().toString(), + () -> notifier.notify(context).block(), 0); + requestQueue.addImmediately(item); + }) + .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().execute(); + 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 SendNotificationTask} and a {@link #times} field.

+ *

{@link SendNotificationTask} 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 SendNotificationTask task; + + @Setter + private int times; + } + + @FunctionalInterface + interface SendNotificationTask { + void execute(); + } +} + diff --git a/application/src/main/java/run/halo/app/notification/DefaultNotificationService.java b/application/src/main/java/run/halo/app/notification/DefaultNotificationService.java new file mode 100644 index 0000000..d5c2e74 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/DefaultNotificationService.java @@ -0,0 +1,68 @@ +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()); + } +} diff --git a/application/src/main/java/run/halo/app/notification/DefaultNotificationTemplateRender.java b/application/src/main/java/run/halo/app/notification/DefaultNotificationTemplateRender.java new file mode 100644 index 0000000..b15ff73 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/DefaultNotificationTemplateRender.java @@ -0,0 +1,66 @@ +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 lombok.RequiredArgsConstructor; +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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher environmentFetcher; + private final ExternalUrlSupplier externalUrlSupplier; + + @Override + public Mono render(String template, Map model) { + var context = new Context(Locale.getDefault(), model); + 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", externalUrlSupplier.getRaw()); + 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); + } +} diff --git a/application/src/main/java/run/halo/app/notification/DefaultNotifierConfigStore.java b/application/src/main/java/run/halo/app/notification/DefaultNotifierConfigStore.java new file mode 100644 index 0000000..467fa9d --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/DefaultNotifierConfigStore.java @@ -0,0 +1,93 @@ +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"; + } +} diff --git a/application/src/main/java/run/halo/app/notification/DefaultSubscriberEmailResolver.java b/application/src/main/java/run/halo/app/notification/DefaultSubscriberEmailResolver.java new file mode 100644 index 0000000..473e0e7 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/DefaultSubscriberEmailResolver.java @@ -0,0 +1,57 @@ +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")); + } +} diff --git a/application/src/main/java/run/halo/app/notification/EmailNotifier.java b/application/src/main/java/run/halo/app/notification/EmailNotifier.java new file mode 100644 index 0000000..be6320a --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/EmailNotifier.java @@ -0,0 +1,123 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/notification/EmailSenderHelper.java b/application/src/main/java/run/halo/app/notification/EmailSenderHelper.java new file mode 100644 index 0000000..48d0bd3 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/EmailSenderHelper.java @@ -0,0 +1,47 @@ +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); + } + } +} diff --git a/application/src/main/java/run/halo/app/notification/EmailSenderHelperImpl.java b/application/src/main/java/run/halo/app/notification/EmailSenderHelperImpl.java new file mode 100644 index 0000000..d018649 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/EmailSenderHelperImpl.java @@ -0,0 +1,68 @@ +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); + }; + } +} diff --git a/application/src/main/java/run/halo/app/notification/LanguageUtils.java b/application/src/main/java/run/halo/app/notification/LanguageUtils.java new file mode 100644 index 0000000..2a0e281 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/LanguageUtils.java @@ -0,0 +1,48 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/notification/NotificationSender.java b/application/src/main/java/run/halo/app/notification/NotificationSender.java new file mode 100644 index 0000000..1946794 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/NotificationSender.java @@ -0,0 +1,21 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/notification/NotificationTemplateRender.java b/application/src/main/java/run/halo/app/notification/NotificationTemplateRender.java new file mode 100644 index 0000000..6d843ae --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/NotificationTemplateRender.java @@ -0,0 +1,15 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/notification/NotificationTrigger.java b/application/src/main/java/run/halo/app/notification/NotificationTrigger.java new file mode 100644 index 0000000..34f2de0 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/NotificationTrigger.java @@ -0,0 +1,57 @@ +package run.halo.app.notification; + +import java.util.Set; +import lombok.RequiredArgsConstructor; +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 + */ +@Component +@RequiredArgsConstructor +public class NotificationTrigger implements Reconciler { + + public static final String TRIGGERED_FINALIZER = "triggered"; + + 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)) { + return; + } + if (ExtensionUtil.addFinalizers(reason.getMetadata(), Set.of(TRIGGERED_FINALIZER))) { + // notifier + onNewReasonReceived(reason); + client.update(reason); + } + }); + return Result.doNotRetry(); + } + + public void onNewReasonReceived(Reason reason) { + notificationCenter.notify(reason).block(); + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return builder + .extension(new Reason()) + .build(); + } +} diff --git a/application/src/main/java/run/halo/app/notification/NotifierConfigStore.java b/application/src/main/java/run/halo/app/notification/NotifierConfigStore.java new file mode 100644 index 0000000..6382567 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/NotifierConfigStore.java @@ -0,0 +1,22 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/notification/ReasonNotificationTemplateSelector.java b/application/src/main/java/run/halo/app/notification/ReasonNotificationTemplateSelector.java new file mode 100644 index 0000000..e233fe4 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/ReasonNotificationTemplateSelector.java @@ -0,0 +1,31 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/notification/ReasonNotificationTemplateSelectorImpl.java b/application/src/main/java/run/halo/app/notification/ReasonNotificationTemplateSelectorImpl.java new file mode 100644 index 0000000..2df7505 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/ReasonNotificationTemplateSelectorImpl.java @@ -0,0 +1,75 @@ +package run.halo.app.notification; + +import static org.apache.commons.lang3.StringUtils.defaultIfBlank; +import static run.halo.app.extension.index.query.QueryFactory.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"); + } +} diff --git a/application/src/main/java/run/halo/app/notification/RecipientResolver.java b/application/src/main/java/run/halo/app/notification/RecipientResolver.java new file mode 100644 index 0000000..7b059e6 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/RecipientResolver.java @@ -0,0 +1,9 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/notification/RecipientResolverImpl.java b/application/src/main/java/run/halo/app/notification/RecipientResolverImpl.java new file mode 100644 index 0000000..e828da0 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/RecipientResolverImpl.java @@ -0,0 +1,116 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/notification/Subscriber.java b/application/src/main/java/run/halo/app/notification/Subscriber.java new file mode 100644 index 0000000..d388c06 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/Subscriber.java @@ -0,0 +1,28 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/notification/SubscriberEmailResolver.java b/application/src/main/java/run/halo/app/notification/SubscriberEmailResolver.java new file mode 100644 index 0000000..0a02d10 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/SubscriberEmailResolver.java @@ -0,0 +1,24 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/notification/SubscriptionMigration.java b/application/src/main/java/run/halo/app/notification/SubscriptionMigration.java new file mode 100644 index 0000000..c9851c5 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/SubscriptionMigration.java @@ -0,0 +1,155 @@ +package run.halo.app.notification; + +import static run.halo.app.content.NotificationReasonConst.NEW_COMMENT_ON_PAGE; +import static run.halo.app.content.NotificationReasonConst.NEW_COMMENT_ON_POST; +import static run.halo.app.content.NotificationReasonConst.SOMEONE_REPLIED_TO_YOU; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.in; +import static run.halo.app.extension.index.query.QueryFactory.isNull; +import static run.halo.app.extension.index.query.QueryFactory.startsWith; + +import java.util.HashSet; +import java.util.Set; +import java.util.function.Consumer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.lang.NonNull; +import org.springframework.scheduling.annotation.Async; +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.ListOptions; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.infra.AnonymousUserConst; +import run.halo.app.infra.ReactiveExtensionPaginatedOperator; +import run.halo.app.infra.ReactiveExtensionPaginatedOperatorImpl; + +/** + * Subscription migration to adapt to the new expression subscribe mechanism. + * + * @author guqing + * @since 2.15.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SubscriptionMigration implements ApplicationListener { + private final NotificationCenter notificationCenter; + private final ReactiveExtensionClient client; + private final SubscriptionService subscriptionService; + private final ReactiveExtensionPaginatedOperator paginatedOperator; + + @Override + @Async + public void onApplicationEvent(@NonNull ApplicationStartedEvent event) { + handleAnonymousSubscription(); + cleanupUserSubscription(); + } + + private void cleanupUserSubscription() { + var listOptions = new ListOptions(); + var query = isNull("metadata.deletionTimestamp"); + listOptions.setFieldSelector(FieldSelector.of(query)); + var iterator = + new ReactiveExtensionPaginatedOperatorImpl(client); + iterator.list(User.class, listOptions) + .map(user -> user.getMetadata().getName()) + .flatMap(this::removeInternalSubscriptionForUser) + .then() + .doOnSuccess(unused -> log.info("Cleanup user subscription completed")) + .block(); + } + + private void handleAnonymousSubscription() { + log.debug("Start to collating anonymous subscription..."); + Set anonymousSubscribers = new HashSet<>(); + deleteAnonymousSubscription(subscription -> { + var name = subscription.getSpec().getSubscriber().getName(); + anonymousSubscribers.add(name); + }).block(); + if (anonymousSubscribers.isEmpty()) { + return; + } + + // anonymous only subscribe some-one-replied-to-you reason + for (String anonymousSubscriber : anonymousSubscribers) { + createSubscription(anonymousSubscriber, + SOMEONE_REPLIED_TO_YOU, + "props.repliedOwner == '%s'".formatted(anonymousSubscriber)).block(); + } + log.info("Collating anonymous subscription completed."); + } + + private Mono deleteAnonymousSubscription(Consumer consumer) { + var listOptions = new ListOptions(); + var query = and(startsWith("spec.subscriber", AnonymousUserConst.PRINCIPAL), + isNull("spec.reason.expression"), + isNull("metadata.deletionTimestamp"), + in("spec.reason.reasonType", Set.of(NEW_COMMENT_ON_POST, + NEW_COMMENT_ON_PAGE, + SOMEONE_REPLIED_TO_YOU)) + ); + listOptions.setFieldSelector(FieldSelector.of(query)); + return paginatedOperator.deleteInitialBatch(Subscription.class, listOptions) + .doOnNext(consumer) + .doOnNext(subscription -> log.debug("Deleted anonymous subscription: {}", + subscription.getMetadata().getName()) + ) + .then(); + } + + private Mono removeInternalSubscriptionForUser(String username) { + log.debug("Start to collating internal subscription for user: {}", username); + var subscriber = new Subscription.Subscriber(); + subscriber.setName(username); + + var listOptions = new ListOptions(); + var fieldQuery = and(isNull("metadata.deletionTimestamp"), + equal("spec.subscriber", subscriber.toString()), + in("spec.reason.reasonType", Set.of( + NEW_COMMENT_ON_POST, + NEW_COMMENT_ON_PAGE, + SOMEONE_REPLIED_TO_YOU + )) + ); + listOptions.setFieldSelector(FieldSelector.of(fieldQuery)); + + return subscriptionService.removeBy(listOptions) + .map(subscription -> { + var name = subscription.getSpec().getSubscriber().getName(); + var reason = subscription.getSpec().getReason(); + String expression = switch (reason.getReasonType()) { + case NEW_COMMENT_ON_POST -> "props.postOwner == '%s'".formatted(name); + case NEW_COMMENT_ON_PAGE -> "props.pageOwner == '%s'".formatted(name); + case SOMEONE_REPLIED_TO_YOU -> "props.repliedOwner == '%s'".formatted(name); + // never happen + default -> null; + }; + return new SubscriptionSummary(name, reason.getReasonType(), expression); + }) + .distinct() + .flatMap(summary -> createSubscription(summary.subscriber(), summary.reasonType(), + summary.expression())) + .then() + .doOnSuccess(unused -> + log.debug("Collating internal subscription for user: {} completed", username)); + } + + Mono createSubscription(String name, String reasonType, String expression) { + var interestReason = new Subscription.InterestReason(); + interestReason.setReasonType(reasonType); + interestReason.setExpression(expression); + var subscriber = new Subscription.Subscriber(); + subscriber.setName(name); + log.debug("Create subscription for user: {} with reasonType: {}", name, reasonType); + return notificationCenter.subscribe(subscriber, interestReason).then(); + } + + record SubscriptionSummary(String subscriber, String reasonType, String expression) { + } +} diff --git a/application/src/main/java/run/halo/app/notification/SubscriptionService.java b/application/src/main/java/run/halo/app/notification/SubscriptionService.java new file mode 100644 index 0000000..a2f5407 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/SubscriptionService.java @@ -0,0 +1,26 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/notification/SubscriptionServiceImpl.java b/application/src/main/java/run/halo/app/notification/SubscriptionServiceImpl.java new file mode 100644 index 0000000..3430bd0 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/SubscriptionServiceImpl.java @@ -0,0 +1,103 @@ +package run.halo.app.notification; + +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.isNull; +import static run.halo.app.extension.index.query.QueryFactory.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.Query; +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)); + } + + Query 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()); + } +} diff --git a/application/src/main/java/run/halo/app/notification/UserNotificationPreference.java b/application/src/main/java/run/halo/app/notification/UserNotificationPreference.java new file mode 100644 index 0000000..54e259e --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/UserNotificationPreference.java @@ -0,0 +1,44 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/notification/UserNotificationPreferenceService.java b/application/src/main/java/run/halo/app/notification/UserNotificationPreferenceService.java new file mode 100644 index 0000000..69ede65 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/UserNotificationPreferenceService.java @@ -0,0 +1,17 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/notification/UserNotificationPreferenceServiceImpl.java b/application/src/main/java/run/halo/app/notification/UserNotificationPreferenceServiceImpl.java new file mode 100644 index 0000000..88c88bd --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/UserNotificationPreferenceServiceImpl.java @@ -0,0 +1,69 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/notification/UserNotificationQuery.java b/application/src/main/java/run/halo/app/notification/UserNotificationQuery.java new file mode 100644 index 0000000..fe331f5 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/UserNotificationQuery.java @@ -0,0 +1,42 @@ +package run.halo.app.notification; + +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.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; +import run.halo.app.extension.router.selector.FieldSelector; + +/** + * 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 filedQuery = listOptions.getFieldSelector().query(); + if (StringUtils.isNotBlank(username)) { + filedQuery = and(filedQuery, equal("spec.recipient", username)); + } + listOptions.setFieldSelector(FieldSelector.of(filedQuery)); + return listOptions; + } +} diff --git a/application/src/main/java/run/halo/app/notification/UserNotificationService.java b/application/src/main/java/run/halo/app/notification/UserNotificationService.java new file mode 100644 index 0000000..4ff448e --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/UserNotificationService.java @@ -0,0 +1,42 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/notification/endpoint/ConsoleNotifierEndpoint.java b/application/src/main/java/run/halo/app/notification/endpoint/ConsoleNotifierEndpoint.java new file mode 100644 index 0000000..389ba3b --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/endpoint/ConsoleNotifierEndpoint.java @@ -0,0 +1,89 @@ +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(ObjectNode.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(ObjectNode.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()); + } +} diff --git a/application/src/main/java/run/halo/app/notification/endpoint/EmailConfigValidationEndpoint.java b/application/src/main/java/run/halo/app/notification/endpoint/EmailConfigValidationEndpoint.java new file mode 100644 index 0000000..fbcd2c4 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/endpoint/EmailConfigValidationEndpoint.java @@ -0,0 +1,116 @@ +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"); + } +} diff --git a/application/src/main/java/run/halo/app/notification/endpoint/SubscriptionRouter.java b/application/src/main/java/run/halo/app/notification/endpoint/SubscriptionRouter.java new file mode 100644 index 0000000..7344613 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/endpoint/SubscriptionRouter.java @@ -0,0 +1,95 @@ +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() { + return SpringdocRouteBuilder.route() + .GET(UNSUBSCRIBE_PATTERN, this::unsubscribe, builder -> { + builder.operationId("Unsubscribe") + .tag("api.notification.halo.run/v1alpha1/Subscription") + .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(); + } +} diff --git a/application/src/main/java/run/halo/app/notification/endpoint/UserNotificationEndpoint.java b/application/src/main/java/run/halo/app/notification/endpoint/UserNotificationEndpoint.java new file mode 100644 index 0000000..fe3dc77 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/endpoint/UserNotificationEndpoint.java @@ -0,0 +1,165 @@ +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(), + builder -> { + }) + .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)); + } +} diff --git a/application/src/main/java/run/halo/app/notification/endpoint/UserNotificationPreferencesEndpoint.java b/application/src/main/java/run/halo/app/notification/endpoint/UserNotificationPreferencesEndpoint.java new file mode 100644 index 0000000..d3d7be0 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/endpoint/UserNotificationPreferencesEndpoint.java @@ -0,0 +1,230 @@ +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.GroupVersion; +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(), + builder -> { + }) + .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) { + return client.list(ReasonType.class, null, Comparators.defaultComparator()) + .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) { + 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"); + } +} diff --git a/application/src/main/java/run/halo/app/notification/endpoint/UserNotifierEndpoint.java b/application/src/main/java/run/halo/app/notification/endpoint/UserNotifierEndpoint.java new file mode 100644 index 0000000..946b1d6 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/endpoint/UserNotifierEndpoint.java @@ -0,0 +1,95 @@ +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(ObjectNode.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(ObjectNode.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"); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/AggregatedRouterFunction.java b/application/src/main/java/run/halo/app/plugin/AggregatedRouterFunction.java new file mode 100644 index 0000000..396c2d4 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/AggregatedRouterFunction.java @@ -0,0 +1,38 @@ +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.extension.endpoint.CustomEndpoint; +import run.halo.app.core.extension.endpoint.CustomEndpointsBuilder; + +/** + * 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(request); + } + + @Override + public void accept(RouterFunctions.Visitor visitor) { + this.aggregated.accept(visitor); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/DefaultDevelopmentPluginRepository.java b/application/src/main/java/run/halo/app/plugin/DefaultDevelopmentPluginRepository.java new file mode 100644 index 0000000..e3b8858 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/DefaultDevelopmentPluginRepository.java @@ -0,0 +1,52 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/DefaultPluginApplicationContextFactory.java b/application/src/main/java/run/halo/app/plugin/DefaultPluginApplicationContextFactory.java new file mode 100644 index 0000000..8a6ba01 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/DefaultPluginApplicationContextFactory.java @@ -0,0 +1,380 @@ +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.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 context = new PluginApplicationContext(pluginId, pluginManager); + context.setBeanNameGenerator(DefaultBeanNameGenerator.INSTANCE); + context.registerShutdownHook(); + context.setParent(pluginManager.getSharedContext()); + + var pluginWrapper = pluginManager.getPlugin(pluginId); + var classLoader = pluginWrapper.getPluginClassLoader(); + 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"); + var beanFactory = context.getBeanFactory(); + 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"); + context.refresh(); + 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); + } + } +} diff --git a/application/src/main/java/run/halo/app/plugin/DefaultPluginGetter.java b/application/src/main/java/run/halo/app/plugin/DefaultPluginGetter.java new file mode 100644 index 0000000..02d9018 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/DefaultPluginGetter.java @@ -0,0 +1,29 @@ +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")); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/DefaultPluginRouterFunctionRegistry.java b/application/src/main/java/run/halo/app/plugin/DefaultPluginRouterFunctionRegistry.java new file mode 100644 index 0000000..602fd56 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/DefaultPluginRouterFunctionRegistry.java @@ -0,0 +1,62 @@ +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; + +/** + * 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) { + return Flux.fromIterable(this.routerFunctions) + .concatMap(routerFunction -> routerFunction.route(request)) + .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; + } +} diff --git a/application/src/main/java/run/halo/app/plugin/DefaultReactiveSettingFetcher.java b/application/src/main/java/run/halo/app/plugin/DefaultReactiveSettingFetcher.java new file mode 100644 index 0000000..397e3ff --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/DefaultReactiveSettingFetcher.java @@ -0,0 +1,213 @@ +package run.halo.app.plugin; + +import static run.halo.app.extension.index.query.QueryFactory.equal; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import reactor.core.publisher.Mono; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.ExtensionMatcher; +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.JsonParseException; +import run.halo.app.infra.utils.JsonUtils; + +/** + * A default implementation of {@link ReactiveSettingFetcher}. + * + * @author guqing + * @since 2.0.0 + */ +public class DefaultReactiveSettingFetcher + implements ReactiveSettingFetcher, Reconciler, DisposableBean, + ApplicationContextAware { + + private final ReactiveExtensionClient client; + + private final ExtensionClient blockingClient; + + private final CacheManager cacheManager; + + /** + * The application context of the plugin. + */ + private ApplicationContext applicationContext; + + private final String pluginName; + + private final String configMapName; + + private final String cacheName; + + public DefaultReactiveSettingFetcher(PluginContext pluginContext, + ReactiveExtensionClient client, ExtensionClient blockingClient, + CacheManager cacheManager) { + this.client = client; + this.pluginName = pluginContext.getName(); + this.configMapName = pluginContext.getConfigMapName(); + this.blockingClient = blockingClient; + this.cacheManager = cacheManager; + this.cacheName = buildCacheKey(pluginName); + } + + @Override + public Mono fetch(String group, Class clazz) { + return getInternal(group) + .mapNotNull(jsonNode -> convertValue(jsonNode, clazz)); + } + + @Override + @NonNull + public Mono get(String group) { + return getInternal(group) + .switchIfEmpty( + Mono.error(new IllegalArgumentException("Group [" + group + "] does not exist.")) + ); + } + + @Override + @NonNull + public Mono> getValues() { + return getValuesInternal() + .map(Map::copyOf) + .defaultIfEmpty(Map.of()); + } + + private Mono getInternal(String group) { + return getValuesInternal() + .mapNotNull(values -> values.get(group)) + .defaultIfEmpty(JsonNodeFactory.instance.missingNode()); + } + + Mono> getValuesInternal() { + var cache = getCache(); + var cachedValue = getCachedConfigData(cache); + if (cachedValue != null) { + return Mono.justOrEmpty(cachedValue); + } + return Mono.defer(() -> { + // double check + var newCachedValue = getCachedConfigData(cache); + if (newCachedValue != null) { + return Mono.justOrEmpty(newCachedValue); + } + if (StringUtils.isBlank(configMapName)) { + return Mono.empty(); + } + return client.fetch(ConfigMap.class, configMapName) + .mapNotNull(ConfigMap::getData) + .map(data -> { + Map result = new LinkedHashMap<>(); + data.forEach((key, value) -> result.put(key, readTree(value))); + return result; + }) + .defaultIfEmpty(Map.of()) + .doOnNext(values -> cache.put(pluginName, values)); + }); + } + + private JsonNode readTree(String json) { + if (StringUtils.isBlank(json)) { + return JsonNodeFactory.instance.missingNode(); + } + try { + return JsonUtils.DEFAULT_JSON_MAPPER.readTree(json); + } catch (JsonProcessingException e) { + throw new JsonParseException(e); + } + } + + private T convertValue(JsonNode jsonNode, Class clazz) { + return JsonUtils.DEFAULT_JSON_MAPPER.convertValue(jsonNode, clazz); + } + + @NonNull + private Cache getCache() { + var cache = cacheManager.getCache(cacheName); + if (cache == null) { + // should never happen + throw new IllegalStateException("Cache [" + cacheName + "] not found."); + } + return cache; + } + + static String buildCacheKey(String pluginName) { + return "plugin-" + pluginName + "-configmap"; + } + + @Override + public Result reconcile(Request request) { + blockingClient.fetch(ConfigMap.class, configMapName) + .ifPresent(configMap -> { + var cache = getCache(); + var existData = getCachedConfigData(cache); + var configMapData = configMap.getData(); + Map result = new LinkedHashMap<>(); + if (configMapData != null) { + configMapData.forEach((key, value) -> result.put(key, readTree(value))); + } + // update cache + cache.put(pluginName, result); + applicationContext.publishEvent(PluginConfigUpdatedEvent.builder() + .source(this) + .oldConfig(existData) + .newConfig(result) + .build()); + }); + return Result.doNotRetry(); + } + + @Nullable + @SuppressWarnings("unchecked") + private Map getCachedConfigData(@NonNull Cache cache) { + var existData = cache.get(pluginName); + if (existData == null) { + return null; + } + return (Map) existData.get(); + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + 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 destroy() { + getCache().invalidate(); + } + + @Override + public void setApplicationContext(@NonNull ApplicationContext applicationContext) + throws BeansException { + this.applicationContext = applicationContext; + } +} diff --git a/application/src/main/java/run/halo/app/plugin/DefaultSettingFetcher.java b/application/src/main/java/run/halo/app/plugin/DefaultSettingFetcher.java new file mode 100644 index 0000000..4a18763 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/DefaultSettingFetcher.java @@ -0,0 +1,46 @@ +package run.halo.app.plugin; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.springframework.lang.NonNull; +import run.halo.app.extension.ConfigMap; + +/** + *

A value fetcher for plugin form configuration.

+ * + * @author guqing + * @since 2.0.0 + */ +public class DefaultSettingFetcher extends SettingFetcher { + 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(); + } + + @NonNull + @Override + public JsonNode get(String group) { + return Objects.requireNonNull(delegateFetcher.get(group).block()); + } + + /** + * Get values from {@link ConfigMap}. + * + * @return a unmodifiable map of values(non-null). + */ + @NonNull + @Override + public Map getValues() { + return Objects.requireNonNull(delegateFetcher.getValues().block()); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/DevPluginLoader.java b/application/src/main/java/run/halo/app/plugin/DevPluginLoader.java new file mode 100644 index 0000000..9698b71 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/DevPluginLoader.java @@ -0,0 +1,43 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/HaloPluginManager.java b/application/src/main/java/run/halo/app/plugin/HaloPluginManager.java new file mode 100644 index 0000000..4d131d4 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/HaloPluginManager.java @@ -0,0 +1,207 @@ +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.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 +public 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); + } + return super.createPluginStatusProvider(); + } + + @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; + } + + /** + * 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); + } + } + } + } + } +} diff --git a/application/src/main/java/run/halo/app/plugin/HaloSharedEventDelegator.java b/application/src/main/java/run/halo/app/plugin/HaloSharedEventDelegator.java new file mode 100644 index 0000000..189d7eb --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/HaloSharedEventDelegator.java @@ -0,0 +1,40 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginApplicationContext.java b/application/src/main/java/run/halo/app/plugin/PluginApplicationContext.java new file mode 100644 index 0000000..19b01ec --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginApplicationContext.java @@ -0,0 +1,137 @@ +package run.halo.app.plugin; + +import java.util.List; +import java.util.concurrent.locks.StampedLock; +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) { + 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(); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginApplicationContextFactory.java b/application/src/main/java/run/halo/app/plugin/PluginApplicationContextFactory.java new file mode 100644 index 0000000..e8f3ef0 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginApplicationContextFactory.java @@ -0,0 +1,15 @@ +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); + +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java b/application/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java new file mode 100644 index 0000000..d9a9871 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java @@ -0,0 +1,85 @@ +package run.halo.app.plugin; + +import static run.halo.app.plugin.resources.BundleResourceUtils.getJsBundleResource; + +import java.io.IOException; +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) { + 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(); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginBeforeStopSyncListener.java b/application/src/main/java/run/halo/app/plugin/PluginBeforeStopSyncListener.java new file mode 100644 index 0000000..92a5d4c --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginBeforeStopSyncListener.java @@ -0,0 +1,73 @@ +package run.halo.app.plugin; + +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.lang.NonNull; +import org.springframework.retry.RetryException; +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.GroupVersionKind; +import run.halo.app.extension.ReactiveExtensionClient; +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 +public class PluginBeforeStopSyncListener { + + private final ReactiveExtensionClient client; + + public PluginBeforeStopSyncListener(ReactiveExtensionClient client) { + this.client = client; + } + + @EventListener + public 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(Duration.ofMinutes(1)); + } + + 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)) + .flatMap(client::delete) + .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 RetryException("Wait for extension deleted")); + }) + .retryWhen(Retry.backoff(10, Duration.ofMillis(100)) + .filter(RetryException.class::isInstance)) + .then() + .doOnSuccess(v -> { + if (log.isDebugEnabled()) { + log.debug("{}/{} was deleted successfully.", gvk, name); + } + }); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginConst.java b/application/src/main/java/run/halo/app/plugin/PluginConst.java new file mode 100644 index 0000000..a4dfa6a --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginConst.java @@ -0,0 +1,29 @@ +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/"; + } + +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginControllerManager.java b/application/src/main/java/run/halo/app/plugin/PluginControllerManager.java new file mode 100644 index 0000000..cc3a2cb --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginControllerManager.java @@ -0,0 +1,49 @@ +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(); + } + +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginDevelopmentInitializer.java b/application/src/main/java/run/halo/app/plugin/PluginDevelopmentInitializer.java new file mode 100644 index 0000000..20aa4da --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginDevelopmentInitializer.java @@ -0,0 +1,66 @@ +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; + +/** + * @author guqing + * @since 2.0.0 + */ +@Slf4j +@Component +public class PluginDevelopmentInitializer implements ApplicationListener { + + 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(); + } + } +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginExtensionLoaderUtils.java b/application/src/main/java/run/halo/app/plugin/PluginExtensionLoaderUtils.java new file mode 100644 index 0000000..362237c --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginExtensionLoaderUtils.java @@ -0,0 +1,60 @@ +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[] {}; + } + +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginGetter.java b/application/src/main/java/run/halo/app/plugin/PluginGetter.java new file mode 100644 index 0000000..3f5b660 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginGetter.java @@ -0,0 +1,24 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginNotFoundException.java b/application/src/main/java/run/halo/app/plugin/PluginNotFoundException.java new file mode 100644 index 0000000..da7b262 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginNotFoundException.java @@ -0,0 +1,19 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginProperties.java b/application/src/main/java/run/halo/app/plugin/PluginProperties.java new file mode 100644 index 0000000..9f88115 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginProperties.java @@ -0,0 +1,63 @@ +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; + +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginRequestMappingHandlerMapping.java b/application/src/main/java/run/halo/app/plugin/PluginRequestMappingHandlerMapping.java new file mode 100644 index 0000000..b8a273c --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginRequestMappingHandlerMapping.java @@ -0,0 +1,137 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginRouterFunctionRegistry.java b/application/src/main/java/run/halo/app/plugin/PluginRouterFunctionRegistry.java new file mode 100644 index 0000000..070a738 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginRouterFunctionRegistry.java @@ -0,0 +1,12 @@ +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); + +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginSharedEventDelegator.java b/application/src/main/java/run/halo/app/plugin/PluginSharedEventDelegator.java new file mode 100644 index 0000000..6ed66f2 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginSharedEventDelegator.java @@ -0,0 +1,44 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginStartedListener.java b/application/src/main/java/run/halo/app/plugin/PluginStartedListener.java new file mode 100644 index 0000000..9917847 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginStartedListener.java @@ -0,0 +1,85 @@ +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.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.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 +public class PluginStartedListener { + + 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(client::update) + .switchIfEmpty(Mono.defer(() -> client.create(unstructured))); + } + + @EventListener + public Mono onApplicationEvent(HaloPluginStartedEvent event) { + var pluginWrapper = event.getPlugin(); + var p = pluginWrapper.getPlugin(); + if (!(p instanceof SpringPlugin springPlugin)) { + return Mono.empty(); + } + var applicationContext = springPlugin.getApplicationContext(); + if (!(applicationContext instanceof PluginApplicationContext pluginApplicationContext)) { + return Mono.empty(); + } + var pluginName = pluginWrapper.getPluginId(); + + return 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()); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginStartingError.java b/application/src/main/java/run/halo/app/plugin/PluginStartingError.java new file mode 100644 index 0000000..873b0e8 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginStartingError.java @@ -0,0 +1,22 @@ +package run.halo.app.plugin; + +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + *

Use this class to collect error information when the plugin enables an error.

+ * + * @author guqing + * @since 2.0.0 + */ +@Data +@AllArgsConstructor(staticName = "of") +public class PluginStartingError implements Serializable { + + private String pluginId; + + private String message; + + private String devMessage; +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginUtils.java b/application/src/main/java/run/halo/app/plugin/PluginUtils.java new file mode 100644 index 0000000..f9fcb66 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginUtils.java @@ -0,0 +1,35 @@ +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)); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/PluginsRootGetterImpl.java b/application/src/main/java/run/halo/app/plugin/PluginsRootGetterImpl.java new file mode 100644 index 0000000..7c37a32 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PluginsRootGetterImpl.java @@ -0,0 +1,28 @@ +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"); + } + +} diff --git a/application/src/main/java/run/halo/app/plugin/PropertyPluginStatusProvider.java b/application/src/main/java/run/halo/app/plugin/PropertyPluginStatusProvider.java new file mode 100644 index 0000000..635f009 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/PropertyPluginStatusProvider.java @@ -0,0 +1,60 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/SharedApplicationContextFactory.java b/application/src/main/java/run/halo/app/plugin/SharedApplicationContextFactory.java new file mode 100644 index 0000000..90b1461 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/SharedApplicationContextFactory.java @@ -0,0 +1,77 @@ +package run.halo.app.plugin; + +import org.springframework.cache.CacheManager; +import org.springframework.context.ApplicationContext; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import run.halo.app.content.PostContentService; +import run.halo.app.core.extension.service.AttachmentService; +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.notification.NotificationCenter; +import run.halo.app.notification.NotificationReasonEmitter; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; +import run.halo.app.security.LoginHandlerEnhancer; + +/** + * 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)); + // TODO add more shared instance here + + sharedContext.refresh(); + return sharedContext; + } +} diff --git a/application/src/main/java/run/halo/app/plugin/SharedEventDispatcher.java b/application/src/main/java/run/halo/app/plugin/SharedEventDispatcher.java new file mode 100644 index 0000000..5c342f3 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/SharedEventDispatcher.java @@ -0,0 +1,50 @@ +package run.halo.app.plugin; + +import java.util.ArrayList; +import org.pf4j.PluginManager; +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 +public class SharedEventDispatcher { + + private final PluginManager pluginManager; + + private final ApplicationEventPublisher publisher; + + public SharedEventDispatcher(PluginManager pluginManager, ApplicationEventPublisher publisher) { + this.pluginManager = pluginManager; + this.publisher = 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 = new ArrayList<>(pluginManager.getStartedPlugins()); + // 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()); + } + +} diff --git a/application/src/main/java/run/halo/app/plugin/SpringComponentsFinder.java b/application/src/main/java/run/halo/app/plugin/SpringComponentsFinder.java new file mode 100644 index 0000000..ff9f449 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/SpringComponentsFinder.java @@ -0,0 +1,86 @@ +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())); + } + } + +} diff --git a/application/src/main/java/run/halo/app/plugin/SpringExtensionFactory.java b/application/src/main/java/run/halo/app/plugin/SpringExtensionFactory.java new file mode 100644 index 0000000..dc769d8 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/SpringExtensionFactory.java @@ -0,0 +1,133 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/SpringPlugin.java b/application/src/main/java/run/halo/app/plugin/SpringPlugin.java new file mode 100644 index 0000000..0acc144 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/SpringPlugin.java @@ -0,0 +1,103 @@ +package run.halo.app.plugin; + +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; + +public class SpringPlugin extends Plugin { + + private ApplicationContext context; + + private Plugin delegate; + + private final PluginApplicationContextFactory contextFactory; + + private final PluginContext pluginContext; + + public SpringPlugin(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(); + try { + // initialize context + this.context = contextFactory.create(pluginId); + 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; + } + } + + @Override + public void stop() { + try { + if (context != null) { + 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 { + 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; + } + + public ApplicationContext getApplicationContext() { + return context; + } + + public PluginContext getPluginContext() { + return pluginContext; + } +} diff --git a/application/src/main/java/run/halo/app/plugin/SpringPluginFactory.java b/application/src/main/java/run/halo/app/plugin/SpringPluginFactory.java new file mode 100644 index 0000000..8560ad6 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/SpringPluginFactory.java @@ -0,0 +1,42 @@ +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 SpringPlugin( + contextFactory, + pluginContext + ); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/SpringPluginManager.java b/application/src/main/java/run/halo/app/plugin/SpringPluginManager.java new file mode 100644 index 0000000..9070cb8 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/SpringPluginManager.java @@ -0,0 +1,22 @@ +package run.halo.app.plugin; + +import java.util.List; +import org.pf4j.PluginManager; +import org.pf4j.PluginWrapper; +import org.springframework.context.ApplicationContext; + +public interface SpringPluginManager extends PluginManager { + + ApplicationContext getRootContext(); + + 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. + */ + List getDependents(String pluginId); +} diff --git a/application/src/main/java/run/halo/app/plugin/YamlPluginDescriptorFinder.java b/application/src/main/java/run/halo/app/plugin/YamlPluginDescriptorFinder.java new file mode 100644 index 0000000..19c76d5 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/YamlPluginDescriptorFinder.java @@ -0,0 +1,77 @@ +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 +public 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) { + String pluginId = plugin.getMetadata().getName(); + Plugin.PluginSpec spec = plugin.getSpec(); + Plugin.PluginAuthor author = spec.getAuthor(); + String provider = (author == null ? StringUtils.EMPTY : author.getName()); + + DefaultPluginDescriptor 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(",")); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/YamlPluginFinder.java b/application/src/main/java/run/halo/app/plugin/YamlPluginFinder.java new file mode 100644 index 0000000..5cdc8f6 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/YamlPluginFinder.java @@ -0,0 +1,128 @@ +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.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 { + 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; + } + + public Plugin find(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); + } + } + } +} diff --git a/application/src/main/java/run/halo/app/plugin/event/HaloPluginBeforeStopEvent.java b/application/src/main/java/run/halo/app/plugin/event/HaloPluginBeforeStopEvent.java new file mode 100644 index 0000000..71904d0 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/event/HaloPluginBeforeStopEvent.java @@ -0,0 +1,21 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/plugin/event/HaloPluginStartedEvent.java b/application/src/main/java/run/halo/app/plugin/event/HaloPluginStartedEvent.java new file mode 100644 index 0000000..b2768ab --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/event/HaloPluginStartedEvent.java @@ -0,0 +1,23 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/plugin/event/HaloPluginStoppedEvent.java b/application/src/main/java/run/halo/app/plugin/event/HaloPluginStoppedEvent.java new file mode 100644 index 0000000..54a3ed4 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/event/HaloPluginStoppedEvent.java @@ -0,0 +1,29 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/event/SpringPluginStartedEvent.java b/application/src/main/java/run/halo/app/plugin/event/SpringPluginStartedEvent.java new file mode 100644 index 0000000..9906034 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/event/SpringPluginStartedEvent.java @@ -0,0 +1,18 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/plugin/event/SpringPluginStartingEvent.java b/application/src/main/java/run/halo/app/plugin/event/SpringPluginStartingEvent.java new file mode 100644 index 0000000..49f4b73 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/event/SpringPluginStartingEvent.java @@ -0,0 +1,18 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/plugin/event/SpringPluginStoppedEvent.java b/application/src/main/java/run/halo/app/plugin/event/SpringPluginStoppedEvent.java new file mode 100644 index 0000000..4de3d53 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/event/SpringPluginStoppedEvent.java @@ -0,0 +1,18 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/plugin/event/SpringPluginStoppingEvent.java b/application/src/main/java/run/halo/app/plugin/event/SpringPluginStoppingEvent.java new file mode 100644 index 0000000..fd43131 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/event/SpringPluginStoppingEvent.java @@ -0,0 +1,18 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetter.java b/application/src/main/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetter.java new file mode 100644 index 0000000..9f5404a --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetter.java @@ -0,0 +1,102 @@ +package run.halo.app.plugin.extensionpoint; + +import static run.halo.app.extension.index.query.QueryFactory.equal; + +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.pf4j.ExtensionPoint; +import org.pf4j.PluginManager; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +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.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting.ExtensionPointEnabled; + +@Component +@RequiredArgsConstructor +public class DefaultExtensionGetter implements ExtensionGetter { + + private final SystemConfigurableEnvironmentFetcher systemConfigFetcher; + + private final PluginManager pluginManager; + + private final BeanFactory beanFactory; + + private final ReactiveExtensionClient client; + + @Override + public Flux getExtensions(Class extensionPoint) { + return Flux.fromIterable(pluginManager.getExtensions(extensionPoint)) + .concatWith( + Flux.fromStream(() -> beanFactory.getBeanProvider(extensionPoint).orderedStream()) + ) + .sort(new AnnotationAwareOrderComparator()); + } + + @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) + .concatMap(extensionDefName -> + client.fetch(ExtensionDefinition.class, extensionDefName) + ) + .concatMap(extensionDef -> { + var className = extensionDef.getSpec().getClassName(); + return extensions.filter( + extension -> Objects.equals(extension.getClass().getName(), + className) + ); + }); + }); + } + + private Mono fetchExtensionPointDefinition( + Class extensionPoint) { + var listOptions = new ListOptions(); + listOptions.setFieldSelector(FieldSelector.of( + equal("spec.className", extensionPoint.getName()) + )); + var sort = Sort.by("metadata.creationTimestamp", "metadata.name").ascending(); + return client.listBy(ExtensionPointDefinition.class, listOptions, + PageRequestImpl.ofSize(1).withSort(sort) + ) + .flatMap(list -> Mono.justOrEmpty(ListResult.first(list))); + } + +} diff --git a/application/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionDefinition.java b/application/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionDefinition.java new file mode 100644 index 0000000..7aecd18 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionDefinition.java @@ -0,0 +1,47 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionPointDefinition.java b/application/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionPointDefinition.java new file mode 100644 index 0000000..5b65032 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionPointDefinition.java @@ -0,0 +1,81 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/plugin/resources/BundleResourceUtils.java b/application/src/main/java/run/halo/app/plugin/resources/BundleResourceUtils.java new file mode 100644 index 0000000..536386e --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/resources/BundleResourceUtils.java @@ -0,0 +1,56 @@ +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()); + } +} diff --git a/application/src/main/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionFactory.java b/application/src/main/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionFactory.java new file mode 100644 index 0000000..6dc0807 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionFactory.java @@ -0,0 +1,183 @@ +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(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(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; + } +} + diff --git a/application/src/main/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionRegistry.java b/application/src/main/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionRegistry.java new file mode 100644 index 0000000..41d87c7 --- /dev/null +++ b/application/src/main/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionRegistry.java @@ -0,0 +1,86 @@ +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); + } + } + +} diff --git a/application/src/main/java/run/halo/app/search/HaloDocumentEventsListener.java b/application/src/main/java/run/halo/app/search/HaloDocumentEventsListener.java new file mode 100644 index 0000000..864595d --- /dev/null +++ b/application/src/main/java/run/halo/app/search/HaloDocumentEventsListener.java @@ -0,0 +1,70 @@ +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)); + } +} diff --git a/application/src/main/java/run/halo/app/search/IndexEndpoint.java b/application/src/main/java/run/halo/app/search/IndexEndpoint.java new file mode 100644 index 0000000..69dc510 --- /dev/null +++ b/application/src/main/java/run/halo/app/search/IndexEndpoint.java @@ -0,0 +1,95 @@ +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 java.util.List; +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; +import run.halo.app.search.post.PostHaloDocumentsProvider; + +@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() + .GET("/indices/post", this::search, + builder -> { + builder.operationId("SearchPost") + .tag(tag) + .description( + "Search posts with fuzzy query. This method is deprecated, please use" + + " POST /indices/-/search instead.") + .deprecated(true) + .response(responseBuilder().implementation(SearchResult.class)); + SearchParam.buildParameters(builder); + } + ) + .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 search(ServerRequest request) { + return Mono.fromSupplier( + () -> new SearchParam(request.queryParams())) + .map(param -> { + var option = new SearchOption(); + option.setIncludeTypes(List.of(PostHaloDocumentsProvider.POST_DOCUMENT_TYPE)); + + option.setKeyword(param.getKeyword()); + option.setLimit(param.getLimit()); + option.setHighlightPreTag(param.getHighlightPreTag()); + option.setHighlightPostTag(param.getHighlightPostTag()); + return option; + }) + .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); + } +} diff --git a/application/src/main/java/run/halo/app/search/IndicesEndpoint.java b/application/src/main/java/run/halo/app/search/IndicesEndpoint.java new file mode 100644 index 0000000..e9eb8c0 --- /dev/null +++ b/application/src/main/java/run/halo/app/search/IndicesEndpoint.java @@ -0,0 +1,59 @@ +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/post", this::rebuildIndices, + builder -> builder.operationId("BuildPostIndices") + .tag(tag) + .deprecated(true) + .description(""" + Build or rebuild post indices for full text search. \ + This method is deprecated, please use POST /indices/-/rebuild instead.\ + """) + ) + .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); + } + +} diff --git a/application/src/main/java/run/halo/app/search/SearchEngineUnavailableException.java b/application/src/main/java/run/halo/app/search/SearchEngineUnavailableException.java new file mode 100644 index 0000000..824654f --- /dev/null +++ b/application/src/main/java/run/halo/app/search/SearchEngineUnavailableException.java @@ -0,0 +1,16 @@ +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."); + } + +} diff --git a/application/src/main/java/run/halo/app/search/SearchServiceImpl.java b/application/src/main/java/run/halo/app/search/SearchServiceImpl.java new file mode 100644 index 0000000..0e229bf --- /dev/null +++ b/application/src/main/java/run/halo/app/search/SearchServiceImpl.java @@ -0,0 +1,36 @@ +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())); + } +} diff --git a/application/src/main/java/run/halo/app/search/extension/SearchEngine.java b/application/src/main/java/run/halo/app/search/extension/SearchEngine.java new file mode 100644 index 0000000..5a64b78 --- /dev/null +++ b/application/src/main/java/run/halo/app/search/extension/SearchEngine.java @@ -0,0 +1,47 @@ +package run.halo.app.search.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.ToString; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; +import run.halo.app.extension.Ref; + +/** + * Search engine extension. + * + * @deprecated This class is deprecated and will be removed in Halo 2.18. + */ +@Data +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) +@GVK(group = "plugin.halo.run", version = "v1alpha1", kind = "SearchEngine", + plural = "searchengines", singular = "searchengine") +@Deprecated(forRemoval = true) +public class SearchEngine extends AbstractExtension { + + @Schema(requiredMode = REQUIRED) + private SearchEngineSpec spec; + + @Data + public static class SearchEngineSpec { + + private String logo; + + private String website; + + @Schema(requiredMode = REQUIRED) + private String displayName; + + private String description; + + private Ref settingRef; + + private String postSearchImpl; + + } + +} diff --git a/application/src/main/java/run/halo/app/search/lucene/LuceneSearchEngine.java b/application/src/main/java/run/halo/app/search/lucene/LuceneSearchEngine.java new file mode 100644 index 0000000..b8e54fd --- /dev/null +++ b/application/src/main/java/run/halo/app/search/lucene/LuceneSearchEngine.java @@ -0,0 +1,440 @@ +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.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 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 IndexWriter indexWriter; + + private SearcherManager searcherManager; + + private Directory directory; + + public LuceneSearchEngine(Path indexRootDir) throws IOException { + 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); + try { + this.indexWriter.updateDocuments(deleteQuery, docs); + this.searcherManager.maybeRefreshBlocking(); + this.indexWriter.commit(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void deleteDocument(Iterable haloDocIds) { + var terms = new LinkedList(); + haloDocIds.forEach(haloDocId -> terms.add(new BytesRef(haloDocId))); + var deleteQuery = new TermInSetQuery("id", terms); + try { + this.indexWriter.deleteDocuments(deleteQuery); + this.searcherManager.maybeRefreshBlocking(); + this.indexWriter.commit(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void deleteAll() { + try { + this.indexWriter.deleteAll(); + this.searcherManager.maybeRefreshBlocking(); + this.indexWriter.commit(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public SearchResult search(SearchOption option) { + IndexSearcher searcher = null; + 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 { + try { + 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); + var writerConfig = new IndexWriterConfig(this.analyzer) + .setOpenMode(CREATE_OR_APPEND); + this.indexWriter = new IndexWriter(this.directory, writerConfig); + this.searcherManager = new SearcherManager(this.indexWriter, null); + log.info("Initialized lucene search engine"); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + void setIndexWriter(IndexWriter indexWriter) { + this.indexWriter = indexWriter; + } + + 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.indexWriter != null) { + closers.add(this.indexWriter); + } + if (this.directory != null) { + closers.add(this.directory); + } + IOUtils.close(closers); + 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); + } + } +} diff --git a/application/src/main/java/run/halo/app/search/post/PostEventsListener.java b/application/src/main/java/run/halo/app/search/post/PostEventsListener.java new file mode 100644 index 0000000..c6cb08f --- /dev/null +++ b/application/src/main/java/run/halo/app/search/post/PostEventsListener.java @@ -0,0 +1,71 @@ +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)) + ); + } + +} diff --git a/application/src/main/java/run/halo/app/search/post/PostHaloDocumentsProvider.java b/application/src/main/java/run/halo/app/search/post/PostHaloDocumentsProvider.java new file mode 100644 index 0000000..d0100c1 --- /dev/null +++ b/application/src/main/java/run/halo/app/search/post/PostHaloDocumentsProvider.java @@ -0,0 +1,81 @@ +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.QueryFactory; +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 = QueryFactory.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; + } +} diff --git a/application/src/main/java/run/halo/app/security/AuthProviderService.java b/application/src/main/java/run/halo/app/security/AuthProviderService.java new file mode 100644 index 0000000..c996db4 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/AuthProviderService.java @@ -0,0 +1,20 @@ +package run.halo.app.security; + +import java.util.List; +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(); +} diff --git a/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java b/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java new file mode 100644 index 0000000..4b318ed --- /dev/null +++ b/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java @@ -0,0 +1,183 @@ +package run.halo.app.security; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +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 run.halo.app.core.extension.AuthProvider; +import run.halo.app.core.extension.UserConnection; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.MetadataUtil; +import run.halo.app.extension.ReactiveExtensionClient; +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; + + @Override + public Mono enable(String name) { + return client.get(AuthProvider.class, name) + .flatMap(authProvider -> updateAuthProviderEnabled(enabled -> enabled.add(name)) + .thenReturn(authProvider) + ); + } + + @Override + public Mono disable(String name) { + // privileged auth provider cannot be disabled + return client.get(AuthProvider.class, name) + .filter(authProvider -> !privileged(authProvider)) + .flatMap(authProvider -> updateAuthProviderEnabled(enabled -> enabled.remove(name)) + .thenReturn(authProvider) + ); + } + + @Override + public Mono> listAll() { + return client.list(AuthProvider.class, provider -> + provider.getMetadata().getDeletionTimestamp() == null, + defaultComparator() + ) + .map(this::convertTo) + .collectList() + .flatMap(providers -> listMyConnections() + .map(connection -> connection.getSpec().getRegistrationId()) + .collectList() + .map(connectedNames -> providers.stream() + .peek(provider -> { + boolean isBound = connectedNames.contains(provider.getName()); + provider.setIsBound(isBound); + }) + .collect(Collectors.toList()) + ) + .defaultIfEmpty(providers) + ) + .flatMap(providers -> fetchEnabledAuthProviders() + .map(names -> providers.stream() + .peek(provider -> { + boolean enabled = names.contains(provider.getName()); + provider.setEnabled(enabled); + }) + .collect(Collectors.toList()) + ) + .defaultIfEmpty(providers) + ); + } + + private Mono> fetchEnabledAuthProviders() { + return client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) + .map(configMap -> { + SystemSetting.AuthProvider authProvider = getAuthProvider(configMap); + return authProvider.getEnabled(); + }); + } + + Flux listMyConnections() { + return ReactiveSecurityContextHolder.getContext() + .map(securityContext -> securityContext.getAuthentication().getName()) + .flatMapMany(username -> client.list(UserConnection.class, + persisted -> persisted.getSpec().getUsername().equals(username), + Comparator.comparing(item -> item.getMetadata() + .getCreationTimestamp()) + ) + ); + } + + private static Comparator defaultComparator() { + return Comparator.comparing((AuthProvider item) -> item.getSpec().getPriority()) + .thenComparing(item -> item.getMetadata().getName()) + .thenComparing(item -> item.getMetadata().getCreationTimestamp()); + } + + private Mono updateAuthProviderEnabled(Consumer> consumer) { + return client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) + .switchIfEmpty(Mono.defer(() -> { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setName(SystemSetting.SYSTEM_CONFIG); + configMap.setData(new HashMap<>()); + return client.create(configMap); + })) + .flatMap(configMap -> { + SystemSetting.AuthProvider authProvider = getAuthProvider(configMap); + consumer.accept(authProvider.getEnabled()); + + final Map data = configMap.getData(); + data.put(SystemSetting.AuthProvider.GROUP, + JsonUtils.objectToJson(authProvider)); + return client.update(configMap); + }); + } + + 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)) + .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 getAuthProvider(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.getEnabled() == null) { + authProvider.setEnabled(new HashSet<>()); + } + return authProvider; + } +} diff --git a/application/src/main/java/run/halo/app/security/CorsConfigurer.java b/application/src/main/java/run/halo/app/security/CorsConfigurer.java new file mode 100644 index 0000000..8584fb2 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/CorsConfigurer.java @@ -0,0 +1,33 @@ +package run.halo.app.security; + +import com.google.common.net.HttpHeaders; +import java.util.List; +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.security.authentication.SecurityConfigurer; + +@Component +public class CorsConfigurer implements SecurityConfigurer { + @Override + public void configure(ServerHttpSecurity http) { + http.cors(spec -> spec.configurationSource(apiCorsConfigSource())); + } + + CorsConfigurationSource apiCorsConfigSource() { + 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")); + + var source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", configuration); + source.registerCorsConfiguration("/apis/**", configuration); + return source; + } +} diff --git a/application/src/main/java/run/halo/app/security/CsrfConfigurer.java b/application/src/main/java/run/halo/app/security/CsrfConfigurer.java new file mode 100644 index 0000000..0dc3b25 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/CsrfConfigurer.java @@ -0,0 +1,31 @@ +package run.halo.app.security; + +import static org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository.withHttpOnlyFalse; +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; + +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.csrf.CsrfWebFilter; +import org.springframework.security.web.server.csrf.ServerCsrfTokenRequestAttributeHandler; +import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher; +import org.springframework.stereotype.Component; +import run.halo.app.security.authentication.SecurityConfigurer; + +@Component +public class CsrfConfigurer implements SecurityConfigurer { + + @Override + public void configure(ServerHttpSecurity http) { + var csrfMatcher = new AndServerWebExchangeMatcher( + CsrfWebFilter.DEFAULT_CSRF_MATCHER, + new NegatedServerWebExchangeMatcher(pathMatchers("/api/**", "/apis/**") + )); + http.csrf(csrfSpec -> csrfSpec + .csrfTokenRepository(withHttpOnlyFalse()) + // TODO Use XorServerCsrfTokenRequestAttributeHandler instead when console implements + // the algorithm + .csrfTokenRequestHandler(new ServerCsrfTokenRequestAttributeHandler()) + .requireCsrfProtectionMatcher(csrfMatcher)); + } + +} diff --git a/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java b/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java new file mode 100644 index 0000000..9e0af48 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java @@ -0,0 +1,31 @@ +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.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * Default authentication entry point. + * See + * https://datatracker.ietf.org/doc/html/rfc7235#section-4.1 + * for more. + * + * @author johnniang + */ +public class DefaultServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint { + + @Override + public Mono commence(ServerWebExchange exchange, AuthenticationException ex) { + return 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(); + }); + } + +} diff --git a/application/src/main/java/run/halo/app/security/DefaultSuperAdminInitializer.java b/application/src/main/java/run/halo/app/security/DefaultSuperAdminInitializer.java new file mode 100644 index 0000000..01ed1ca --- /dev/null +++ b/application/src/main/java/run/halo/app/security/DefaultSuperAdminInitializer.java @@ -0,0 +1,81 @@ +package run.halo.app.security; + +import java.time.Instant; +import java.util.List; +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.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); + + 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; + } +} diff --git a/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java b/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java new file mode 100644 index 0000000..b1bf1e5 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java @@ -0,0 +1,82 @@ +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 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.extension.service.RoleService; +import run.halo.app.core.extension.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; + +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) { + return userService.getUser(username) + .onErrorMap(UserNotFoundException.class, + e -> 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(); + } + +} diff --git a/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java new file mode 100644 index 0000000..bdb5979 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java @@ -0,0 +1,22 @@ +package run.halo.app.security; + +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.oauth2.server.resource.web.access.server.BearerTokenServerAccessDeniedHandler; +import org.springframework.stereotype.Component; +import run.halo.app.security.authentication.SecurityConfigurer; + +@Component +public class ExceptionSecurityConfigurer implements SecurityConfigurer { + + @Override + public void configure(ServerHttpSecurity http) { + http.exceptionHandling(exception -> { + var accessDeniedHandler = new BearerTokenServerAccessDeniedHandler(); + var entryPoint = new DefaultServerAuthenticationEntryPoint(); + exception + .authenticationEntryPoint(entryPoint) + .accessDeniedHandler(accessDeniedHandler); + }); + } + +} diff --git a/application/src/main/java/run/halo/app/security/HaloUserDetails.java b/application/src/main/java/run/halo/app/security/HaloUserDetails.java new file mode 100644 index 0000000..927ce8e --- /dev/null +++ b/application/src/main/java/run/halo/app/security/HaloUserDetails.java @@ -0,0 +1,21 @@ +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(); + +} diff --git a/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java b/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java new file mode 100644 index 0000000..08f2e37 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java @@ -0,0 +1,62 @@ +package run.halo.app.security; + +import java.net.URI; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; +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.PathPatternParserServerWebExchangeMatcher; +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 +@RequiredArgsConstructor +public class InitializeRedirectionWebFilter implements WebFilter { + private final URI location = URI.create("/console"); + private final ServerWebExchangeMatcher redirectMatcher = + new PathPatternParserServerWebExchangeMatcher("/", HttpMethod.GET); + + private final InitializationStateGetter initializationStateGetter; + + @Getter + private ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + + @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; + } +} diff --git a/application/src/main/java/run/halo/app/security/ListedAuthProvider.java b/application/src/main/java/run/halo/app/security/ListedAuthProvider.java new file mode 100644 index 0000000..e0484ff --- /dev/null +++ b/application/src/main/java/run/halo/app/security/ListedAuthProvider.java @@ -0,0 +1,45 @@ +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; + +/** + * 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; + + Boolean isBound; + + Boolean enabled; + + Boolean supportsBinding; + + Boolean privileged; +} diff --git a/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java b/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java new file mode 100644 index 0000000..bb72c7c --- /dev/null +++ b/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java @@ -0,0 +1,39 @@ +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.security.authentication.rememberme.RememberMeServices; +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; + + @Override + public Mono onLoginSuccess(ServerWebExchange exchange, + Authentication successfulAuthentication) { + return rememberMeServices.loginSuccess(exchange, successfulAuthentication) + .then(deviceService.loginSuccess(exchange, successfulAuthentication)); + } + + @Override + public Mono onLoginFailure(ServerWebExchange exchange, + AuthenticationException exception) { + return rememberMeServices.loginFail(exchange); + } +} diff --git a/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java new file mode 100644 index 0000000..79c3974 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java @@ -0,0 +1,77 @@ +package run.halo.app.security; + +import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.LOGOUT_PAGE_GENERATING; +import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll; + +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.logout.DelegatingServerLogoutHandler; +import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler; +import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; +import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler; +import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import run.halo.app.security.authentication.SecurityConfigurer; +import run.halo.app.security.authentication.rememberme.RememberMeServices; + +@Component +@RequiredArgsConstructor +public class LogoutSecurityConfigurer implements SecurityConfigurer { + private final RememberMeServices rememberMeServices; + private final ApplicationContext applicationContext; + + @Override + public void configure(ServerHttpSecurity http) { + var serverLogoutHandlers = getLogoutHandlers(); + http.logout( + logout -> logout.logoutSuccessHandler(new LogoutSuccessHandler(serverLogoutHandlers))); + http.addFilterAt(new LogoutPageGeneratingWebFilter(), LOGOUT_PAGE_GENERATING); + } + + private class LogoutSuccessHandler implements ServerLogoutSuccessHandler { + + private final ServerLogoutSuccessHandler defaultHandler; + private final ServerLogoutHandler logoutHandler; + + public LogoutSuccessHandler(ServerLogoutHandler... logoutHandler) { + var defaultHandler = new RedirectServerLogoutSuccessHandler(); + defaultHandler.setLogoutSuccessUrl(URI.create("/console/?logout")); + this.defaultHandler = defaultHandler; + if (logoutHandler.length == 1) { + this.logoutHandler = logoutHandler[0]; + } else { + this.logoutHandler = new DelegatingServerLogoutHandler(logoutHandler); + } + } + + @Override + public Mono onLogoutSuccess(WebFilterExchange exchange, + Authentication authentication) { + return logoutHandler.logout(exchange, authentication) + .then(rememberMeServices.loginFail(exchange.getExchange())) + .then(ignoringMediaTypeAll(MediaType.APPLICATION_JSON) + .matches(exchange.getExchange()) + .flatMap(matchResult -> { + if (matchResult.isMatch()) { + var response = exchange.getExchange().getResponse(); + response.setStatusCode(HttpStatus.NO_CONTENT); + return response.setComplete(); + } + return defaultHandler.onLogoutSuccess(exchange, authentication); + }) + ); + } + } + + private ServerLogoutHandler[] getLogoutHandlers() { + return applicationContext.getBeansOfType(ServerLogoutHandler.class).values() + .toArray(new ServerLogoutHandler[0]); + } +} diff --git a/application/src/main/java/run/halo/app/security/SecurityWebFiltersConfigurer.java b/application/src/main/java/run/halo/app/security/SecurityWebFiltersConfigurer.java new file mode 100644 index 0000000..daef125 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/SecurityWebFiltersConfigurer.java @@ -0,0 +1,80 @@ +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.LAST; + +import lombok.Setter; +import org.pf4j.ExtensionPoint; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +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 +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(FormLoginSecurityWebFilter.class), + FORM_LOGIN + ) + .addFilterAt( + new SecurityWebFilterChainProxy(AuthenticationSecurityWebFilter.class), + AUTHENTICATION + ) + .addFilterAt( + new SecurityWebFilterChainProxy(AnonymousAuthenticationSecurityWebFilter.class), + ANONYMOUS_AUTHENTICATION + ) + .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)); + } + } + +} diff --git a/application/src/main/java/run/halo/app/security/SuperAdminInitializer.java b/application/src/main/java/run/halo/app/security/SuperAdminInitializer.java new file mode 100644 index 0000000..802d87f --- /dev/null +++ b/application/src/main/java/run/halo/app/security/SuperAdminInitializer.java @@ -0,0 +1,32 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/CryptoService.java b/application/src/main/java/run/halo/app/security/authentication/CryptoService.java new file mode 100644 index 0000000..0084fda --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/CryptoService.java @@ -0,0 +1,37 @@ +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(); + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/SecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/SecurityConfigurer.java new file mode 100644 index 0000000..71afce7 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/SecurityConfigurer.java @@ -0,0 +1,9 @@ +package run.halo.app.security.authentication; + +import org.springframework.security.config.web.server.ServerHttpSecurity; + +public interface SecurityConfigurer { + + void configure(ServerHttpSecurity http); + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/WebExchangeMatchers.java b/application/src/main/java/run/halo/app/security/authentication/WebExchangeMatchers.java new file mode 100644 index 0000000..a947ee9 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/WebExchangeMatchers.java @@ -0,0 +1,16 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/impl/RsaKeyService.java b/application/src/main/java/run/halo/app/security/authentication/impl/RsaKeyService.java new file mode 100644 index 0000000..2b76235 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/impl/RsaKeyService.java @@ -0,0 +1,162 @@ +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); + } + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/jwt/JwtAuthenticationConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/jwt/JwtAuthenticationConfigurer.java new file mode 100644 index 0000000..26c18b5 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/jwt/JwtAuthenticationConfigurer.java @@ -0,0 +1,70 @@ +package run.halo.app.security.authentication.jwt; + +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; + +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerCodecConfigurer; +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.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.web.server.authentication.AuthenticationWebFilter; +import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; +import org.springframework.web.reactive.function.server.ServerResponse; +import run.halo.app.infra.properties.JwtProperties; +import run.halo.app.security.authentication.SecurityConfigurer; + +/** + * TODO: Use It after 2.0.0. + */ +public class JwtAuthenticationConfigurer implements SecurityConfigurer { + + private final ReactiveUserDetailsService userDetailsService; + + private final PasswordEncoder passwordEncoder; + + private final ServerCodecConfigurer codec; + + private final JwtEncoder jwtEncoder; + + private final ServerResponse.Context context; + + private final JwtProperties jwtProp; + + public JwtAuthenticationConfigurer(ReactiveUserDetailsService userDetailsService, + PasswordEncoder passwordEncoder, + ServerCodecConfigurer codec, + JwtEncoder jwtEncoder, + ServerResponse.Context context, + JwtProperties jwtProp) { + this.userDetailsService = userDetailsService; + this.passwordEncoder = passwordEncoder; + this.codec = codec; + this.jwtEncoder = jwtEncoder; + this.context = context; + this.jwtProp = jwtProp; + } + + @Override + public void configure(ServerHttpSecurity http) { + var loginManager = new LoginAuthenticationManager(userDetailsService, passwordEncoder); + + var filter = new AuthenticationWebFilter(loginManager); + var loginMatcher = new AndServerWebExchangeMatcher( + pathMatchers(HttpMethod.POST, "/api/auth/token"), + new MediaTypeServerWebExchangeMatcher(MediaType.APPLICATION_JSON) + ); + + filter.setRequiresAuthenticationMatcher(loginMatcher); + filter.setServerAuthenticationConverter( + new LoginAuthenticationConverter(codec.getReaders())); + filter.setAuthenticationSuccessHandler( + new LoginAuthenticationSuccessHandler(jwtEncoder, jwtProp, context)); + filter.setAuthenticationFailureHandler(new LoginAuthenticationFailureHandler(context)); + + http.addFilterAt(filter, SecurityWebFiltersOrder.FORM_LOGIN); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationConverter.java b/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationConverter.java new file mode 100644 index 0000000..05d1d00 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationConverter.java @@ -0,0 +1,37 @@ +package run.halo.app.security.authentication.jwt; + +import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.unauthenticated; + +import java.util.List; +import lombok.Data; +import org.springframework.http.codec.HttpMessageReader; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +public class LoginAuthenticationConverter implements ServerAuthenticationConverter { + + private final List> reader; + + public LoginAuthenticationConverter(List> reader) { + this.reader = reader; + } + + @Override + public Mono convert(ServerWebExchange exchange) { + return ServerRequest.create(exchange, this.reader) + .bodyToMono(UsernamePasswordRequest.class) + .map(request -> unauthenticated(request.getUsername(), request.getPassword())); + } + + @Data + public static class UsernamePasswordRequest { + + private String username; + + private String password; + + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationFailureHandler.java b/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationFailureHandler.java new file mode 100644 index 0000000..8078a2e --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationFailureHandler.java @@ -0,0 +1,31 @@ +package run.halo.app.security.authentication.jwt; + +import java.util.Map; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; + +public class LoginAuthenticationFailureHandler implements ServerAuthenticationFailureHandler { + + private final ServerResponse.Context context; + + public LoginAuthenticationFailureHandler(ServerResponse.Context context) { + this.context = context; + } + + @Override + public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, + AuthenticationException exception) { + return ServerResponse.badRequest() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue( + Map.of("error", exception.getLocalizedMessage()) + ) + .flatMap(serverResponse -> + serverResponse.writeTo(webFilterExchange.getExchange(), context)); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationManager.java new file mode 100644 index 0000000..a5b3ce3 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationManager.java @@ -0,0 +1,19 @@ +package run.halo.app.security.authentication.jwt; + +import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager; +import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; + +public final class LoginAuthenticationManager + extends UserDetailsRepositoryReactiveAuthenticationManager { + + public LoginAuthenticationManager(ReactiveUserDetailsService userDetailsService, + PasswordEncoder passwordEncoder) { + super(userDetailsService); + super.setPasswordEncoder(passwordEncoder); + if (userDetailsService instanceof ReactiveUserDetailsPasswordService passwordService) { + super.setUserDetailsPasswordService(passwordService); + } + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationSuccessHandler.java b/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationSuccessHandler.java new file mode 100644 index 0000000..6ec9161 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationSuccessHandler.java @@ -0,0 +1,62 @@ +package run.halo.app.security.authentication.jwt; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +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.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; +import run.halo.app.infra.properties.JwtProperties; + +public class LoginAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler { + + private final JwtEncoder jwtEncoder; + + private final JwtProperties jwtProp; + + private final ServerResponse.Context context; + + public LoginAuthenticationSuccessHandler(JwtEncoder jwtEncoder, JwtProperties jwtProp, + ServerResponse.Context context) { + this.jwtEncoder = jwtEncoder; + this.jwtProp = jwtProp; + this.context = context; + } + + @Override + public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, + Authentication authentication) { + var issuedAt = Instant.now(); + // TODO Make the expiresAt configurable + var expiresAt = issuedAt.plus(24, ChronoUnit.HOURS); + var headers = JwsHeader.with(jwtProp.getJwsAlgorithm()).build(); + var claims = JwtClaimsSet.builder() + .issuer("Halo Owner") + .issuedAt(issuedAt) + .expiresAt(expiresAt) + // the principal is the username + .subject(authentication.getName()) + .claim("scope", authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList())) + .build(); + + var jwt = jwtEncoder.encode(JwtEncoderParameters.from(headers, claims)); + + return ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("token", jwt.getTokenValue())) + .flatMap(serverResponse -> serverResponse.writeTo(webFilterExchange.getExchange(), + this.context)); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/login/HaloUser.java b/application/src/main/java/run/halo/app/security/authentication/login/HaloUser.java new file mode 100644 index 0000000..508dc84 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/login/HaloUser.java @@ -0,0 +1,120 @@ +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); + } + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/login/InvalidEncryptedMessageException.java b/application/src/main/java/run/halo/app/security/authentication/login/InvalidEncryptedMessageException.java new file mode 100644 index 0000000..3e7cb4d --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/login/InvalidEncryptedMessageException.java @@ -0,0 +1,17 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java b/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java new file mode 100644 index 0000000..9a3bfb7 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java @@ -0,0 +1,71 @@ +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.exception.RateLimitExceededException; +import run.halo.app.infra.utils.IpAddressUtils; +import run.halo.app.security.authentication.CryptoService; + +@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 -> { + 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)) + .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::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); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java new file mode 100644 index 0000000..8fff496 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java @@ -0,0 +1,95 @@ +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.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.userdetails.ReactiveUserDetailsPasswordService; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +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 run.halo.app.plugin.extensionpoint.ExtensionGetter; +import run.halo.app.security.LoginHandlerEnhancer; +import run.halo.app.security.authentication.CryptoService; +import run.halo.app.security.authentication.SecurityConfigurer; + +@Component +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()); + 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; + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/login/PublicKeyRouteBuilder.java b/application/src/main/java/run/halo/app/security/authentication/login/PublicKeyRouteBuilder.java new file mode 100644 index 0000000..14f2250 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/login/PublicKeyRouteBuilder.java @@ -0,0 +1,47 @@ +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; + + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java new file mode 100644 index 0000000..912d9a5 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java @@ -0,0 +1,53 @@ +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; +import run.halo.app.security.HaloUserDetails; +import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; + +@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)) + ) + // check if MFA is enabled after authenticated + .map(a -> { + if (a.getPrincipal() instanceof HaloUserDetails user + && user.isTwoFactorAuthEnabled()) { + a = new TwoFactorAuthentication(a); + } + return a; + }); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java new file mode 100644 index 0000000..33ce5b0 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java @@ -0,0 +1,114 @@ +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 lombok.extern.slf4j.Slf4j; +import org.springframework.context.MessageSource; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.CredentialsContainer; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler; +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.twofactor.TwoFactorAuthentication; + +@Slf4j +public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandler, + ServerAuthenticationFailureHandler { + + private final ServerResponse.Context context; + + private final MessageSource messageSource; + + private final LoginHandlerEnhancer loginHandlerEnhancer; + + private final ServerAuthenticationFailureHandler defaultFailureHandler = + new RedirectServerAuthenticationFailureHandler("/console?error#/login"); + + private final ServerAuthenticationSuccessHandler defaultSuccessHandler = + new RedirectServerAuthenticationSuccessHandler("/console/"); + + 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( + defaultFailureHandler.onAuthenticationFailure(webFilterExchange, exception) + // Skip the handleAuthenticationException. + .then(Mono.empty()) + ) + .flatMap(matchResult -> handleAuthenticationException(exception, exchange))); + } + + @Override + public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, + Authentication authentication) { + if (authentication instanceof TwoFactorAuthentication) { + // continue filtering for authorization + return loginHandlerEnhancer.onLoginSuccess(webFilterExchange.getExchange(), + authentication) + .then(webFilterExchange.getChain().filter(webFilterExchange.getExchange())); + } + + 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)); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationManager.java new file mode 100644 index 0000000..9cd8860 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationManager.java @@ -0,0 +1,152 @@ +package run.halo.app.security.authentication.pat; + +import static org.apache.commons.lang3.StringUtils.removeStart; +import static org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder.withJwkSource; +import static run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher.PAT_TOKEN_PREFIX; +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.BearerTokenAuthenticationToken; +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; + +public class PatAuthenticationManager implements ReactiveAuthenticationManager { + + /** + * Minimal duration gap of personal access token update. + */ + private static final Duration MIN_UPDATE_GAP = Duration.ofMinutes(1); + + private final ReactiveAuthenticationManager 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 ReactiveAuthenticationManager getDelegate() { + var jwtDecoder = withJwkSource(signedJWT -> Flux.just(cryptoService.getJwk())) + .build(); + return new JwtReactiveAuthenticationManager(jwtDecoder); + } + + public void setClock(Clock clock) { + this.clock = clock; + } + + @Override + public Mono authenticate(Authentication authentication) { + return Mono.just(authentication) + .map(this::clearPrefix) + .flatMap(delegate::authenticate) + .cast(JwtAuthenticationToken.class) + .flatMap(this::checkAndRebuild); + } + + private Authentication clearPrefix(Authentication authentication) { + if (authentication instanceof BearerTokenAuthenticationToken bearerToken) { + var newToken = removeStart(bearerToken.getToken(), PAT_TOKEN_PREFIX); + return new BearerTokenAuthenticationToken(newToken); + } + return authentication; + } + + 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(); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/PatEndpoint.java b/application/src/main/java/run/halo/app/security/authentication/pat/PatEndpoint.java new file mode 100644 index 0000000..62d1c0a --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/pat/PatEndpoint.java @@ -0,0 +1,100 @@ +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 +public 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(), + builder -> builder.description("User-scoped PersonalAccessToken endpoint")) + .build(); + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("uc.api.security.halo.run/v1alpha1"); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/PatServerWebExchangeMatcher.java b/application/src/main/java/run/halo/app/security/authentication/pat/PatServerWebExchangeMatcher.java new file mode 100644 index 0000000..4d8a7e1 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/pat/PatServerWebExchangeMatcher.java @@ -0,0 +1,30 @@ +package run.halo.app.security.authentication.pat; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.web.server.authentication.ServerBearerTokenAuthenticationConverter; +import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +public class PatServerWebExchangeMatcher implements ServerWebExchangeMatcher { + + public static final String PAT_TOKEN_PREFIX = "pat_"; + + private final ServerAuthenticationConverter authConverter = + new ServerBearerTokenAuthenticationConverter(); + + @Override + public Mono matches(ServerWebExchange exchange) { + return authConverter.convert(exchange) + .filter(a -> a instanceof BearerTokenAuthenticationToken) + .cast(BearerTokenAuthenticationToken.class) + .map(BearerTokenAuthenticationToken::getToken) + .filter(tokenString -> StringUtils.startsWith(tokenString, PAT_TOKEN_PREFIX)) + .flatMap(t -> MatchResult.match()) + .onErrorResume(AuthenticationException.class, t -> MatchResult.notMatch()) + .switchIfEmpty(Mono.defer(MatchResult::notMatch)); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandler.java b/application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandler.java new file mode 100644 index 0000000..f552a52 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandler.java @@ -0,0 +1,20 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/impl/UserScopedPatHandlerImpl.java b/application/src/main/java/run/halo/app/security/authentication/pat/impl/UserScopedPatHandlerImpl.java new file mode 100644 index 0000000..a7dfd8b --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/pat/impl/UserScopedPatHandlerImpl.java @@ -0,0 +1,259 @@ +package run.halo.app.security.authentication.pat.impl; + +import static run.halo.app.extension.Comparators.compareCreationTimestamp; +import static run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher.PAT_TOKEN_PREFIX; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import java.time.Clock; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +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.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.service.RoleService; +import run.halo.app.extension.ExtensionUtil; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.exception.ExtensionNotFoundException; +import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.app.infra.exception.AccessDeniedException; +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.authentication.pat.UserScopedPatHandler; +import run.halo.app.security.authorization.AuthorityUtils; + +@Service +public class UserScopedPatHandlerImpl implements UserScopedPatHandler { + + private static final String ACCESS_TOKEN_ANNO_NAME = "security.halo.run/access-token"; + + private static final NotFoundException PAT_NOT_FOUND_EX = + new NotFoundException("The personal access token was not found or deleted."); + + private final ReactiveExtensionClient client; + + private final JwtEncoder patEncoder; + + private final ExternalUrlSupplier externalUrl; + + private final RoleService roleService; + + private final IdGenerator idGenerator; + + private final String keyId; + + private Clock clock; + + public UserScopedPatHandlerImpl(ReactiveExtensionClient client, + CryptoService cryptoService, + ExternalUrlSupplier externalUrl, + RoleService roleService) { + this.client = client; + this.externalUrl = externalUrl; + this.roleService = roleService; + + var patJwk = cryptoService.getJwk(); + var jwkSet = new ImmutableJWKSet<>(new JWKSet(patJwk)); + this.patEncoder = new NimbusJwtEncoder(jwkSet); + this.keyId = patJwk.getKeyID(); + this.idGenerator = new AlternativeJdkIdGenerator(); + this.clock = Clock.systemDefaultZone(); + } + + public void setClock(Clock clock) { + this.clock = clock; + } + + private static Mono mustBeRealUser(Mono authentication) { + return authentication.filter(AuthorityUtils::isRealUser) + // 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(UserScopedPatHandlerImpl::mustBeRealUser) + .flatMap(auth -> request.bodyToMono(PersonalAccessToken.class) + .switchIfEmpty( + Mono.error(() -> new ServerWebInputException("Missing request body."))) + .flatMap(patRequest -> { + var patSpec = patRequest.getSpec(); + var roles = patSpec.getRoles(); + var rolesCheck = hasSufficientRoles(auth.getAuthorities(), roles) + .filter(has -> has) + .switchIfEmpty( + Mono.error(() -> new ServerWebInputException("Insufficient roles."))) + .then(); + + var expiresCheck = Mono.fromRunnable(() -> { + var expiresAt = patSpec.getExpiresAt(); + var now = clock.instant(); + if (expiresAt != null && (now.isAfter(expiresAt))) { + throw new ServerWebInputException("Invalid expiresAt."); + } + }).then(); + + var createPat = Mono.defer(() -> { + var pat = new PersonalAccessToken(); + var spec = pat.getSpec(); + spec.setUsername(auth.getName()); + spec.setName(patSpec.getName()); + spec.setDescription(patSpec.getDescription()); + spec.setRoles(patSpec.getRoles()); + spec.setScopes(patSpec.getScopes()); + spec.setExpiresAt(patSpec.getExpiresAt()); + var tokenId = idGenerator.generateId().toString(); + spec.setTokenId(tokenId); + var metadata = new Metadata(); + metadata.setGenerateName("pat-" + auth.getName() + "-"); + pat.setMetadata(metadata); + return client.create(pat) + .doOnNext(createdPat -> { + var claimsBuilder = JwtClaimsSet.builder() + .issuer(externalUrl.getURL(request.exchange().getRequest()) + .toString()) + .id(tokenId) + .subject(auth.getName()) + .issuedAt(clock.instant()) + .claim("pat_name", createdPat.getMetadata().getName()); + var expiresAt = createdPat.getSpec().getExpiresAt(); + if (expiresAt != null) { + claimsBuilder.expiresAt(expiresAt); + } + var headerBuilder = JwsHeader.with(SignatureAlgorithm.RS256) + .keyId(this.keyId); + var jwt = patEncoder.encode(JwtEncoderParameters.from( + headerBuilder.build(), + claimsBuilder.build())); + var annotations = + createdPat.getMetadata().getAnnotations(); + if (annotations == null) { + annotations = new HashMap<>(); + createdPat.getMetadata().setAnnotations(annotations); + } + annotations.put(ACCESS_TOKEN_ANNO_NAME, + PAT_TOKEN_PREFIX + jwt.getTokenValue()); + }); + }); + return rolesCheck.and(expiresCheck).then(createPat) + .flatMap(createdPat -> ServerResponse.ok().bodyValue(createdPat)); + })); + } + + @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"); + var pat = getPat(name, auth.getName()); + return ServerResponse.ok().body(pat, PersonalAccessToken.class); + }); + } + + @Override + public Mono revoke(ServerRequest request) { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .flatMap(auth -> { + var name = request.pathVariable("name"); + var revokedPat = getPat(name, auth.getName()) + .filter(pat -> !pat.getSpec().isRevoked()) + .switchIfEmpty(Mono.error( + () -> new ServerWebInputException("The token has been revoked before."))) + .doOnNext(pat -> { + var spec = pat.getSpec(); + spec.setRevoked(true); + spec.setRevokesAt(clock.instant()); + }) + .flatMap(client::update); + return ServerResponse.ok().body(revokedPat, PersonalAccessToken.class); + }); + } + + @Override + public Mono delete(ServerRequest request) { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .flatMap(auth -> { + var name = request.pathVariable("name"); + var deletedPat = getPat(name, auth.getName()) + .flatMap(client::delete); + return ServerResponse.ok().body(deletedPat, PersonalAccessToken.class); + }); + } + + @Override + public Mono restore(ServerRequest request) { + var restoredPat = ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .transform(UserScopedPatHandlerImpl::mustBeRealUser) + .flatMap(auth -> { + var name = request.pathVariable("name"); + return getPat(name, auth.getName()); + }) + .filter(pat -> pat.getSpec().isRevoked()) + .switchIfEmpty(Mono.error( + () -> new ServerWebInputException( + "The token has not been revoked before."))) + .doOnNext(pat -> { + var spec = pat.getSpec(); + spec.setRevoked(false); + spec.setRevokesAt(null); + }) + .flatMap(client::update); + return ServerResponse.ok().body(restoredPat, PersonalAccessToken.class); + } + + 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); + } + + private Mono getPat(String name, String username) { + return client.get(PersonalAccessToken.class, name) + .filter(pat -> Objects.equals(pat.getSpec().getUsername(), username) + && !ExtensionUtil.isDeleted(pat)) + .onErrorMap(ExtensionNotFoundException.class, t -> PAT_NOT_FOUND_EX) + .switchIfEmpty(Mono.error(() -> PAT_NOT_FOUND_EX)); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/CookieSignatureKeyResolver.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/CookieSignatureKeyResolver.java new file mode 100644 index 0000000..1de525a --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/CookieSignatureKeyResolver.java @@ -0,0 +1,8 @@ +package run.halo.app.security.authentication.rememberme; + +import reactor.core.publisher.Mono; + +@FunctionalInterface +public interface CookieSignatureKeyResolver { + Mono resolveSigningKey(); +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/DefaultCookieSignatureKeyResolver.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/DefaultCookieSignatureKeyResolver.java new file mode 100644 index 0000000..5b0bfa7 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/DefaultCookieSignatureKeyResolver.java @@ -0,0 +1,17 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/PersistentRememberMeTokenRepository.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/PersistentRememberMeTokenRepository.java new file mode 100644 index 0000000..e99bcef --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/PersistentRememberMeTokenRepository.java @@ -0,0 +1,18 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/PersistentRememberMeTokenRepositoryImpl.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/PersistentRememberMeTokenRepositoryImpl.java new file mode 100644 index 0000000..4b13b2f --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/PersistentRememberMeTokenRepositoryImpl.java @@ -0,0 +1,103 @@ +package run.halo.app.security.authentication.rememberme; + +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.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))); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/PersistentTokenBasedRememberMeServices.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/PersistentTokenBasedRememberMeServices.java new file mode 100644 index 0000000..0f8adfc --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/PersistentTokenBasedRememberMeServices.java @@ -0,0 +1,190 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeAuthenticationManager.java new file mode 100644 index 0000000..d1df38b --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeAuthenticationManager.java @@ -0,0 +1,56 @@ +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"); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeConfigurer.java new file mode 100644 index 0000000..97cbc68 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeConfigurer.java @@ -0,0 +1,41 @@ +package run.halo.app.security.authentication.rememberme; + +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; + +import lombok.RequiredArgsConstructor; +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 +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); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeCookieResolver.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeCookieResolver.java new file mode 100644 index 0000000..4161513 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeCookieResolver.java @@ -0,0 +1,20 @@ +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(); +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeCookieResolverImpl.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeCookieResolverImpl.java new file mode 100644 index 0000000..eeb3c99 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeCookieResolverImpl.java @@ -0,0 +1,54 @@ +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"); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeServices.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeServices.java new file mode 100644 index 0000000..5b2fb89 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeServices.java @@ -0,0 +1,14 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeTokenRevoker.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeTokenRevoker.java new file mode 100644 index 0000000..7bbf971 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeTokenRevoker.java @@ -0,0 +1,30 @@ +package run.halo.app.security.authentication.rememberme; + +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; + +/** + * 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 final PersistentRememberMeTokenRepository tokenRepository; + + @Async + @EventListener(PasswordChangedEvent.class) + public void onPasswordChanged(PasswordChangedEvent event) { + tokenRepository.removeUserTokens(event.getUsername()) + .block(); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberTokenCleaner.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberTokenCleaner.java new file mode 100644 index 0000000..39a3057 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberTokenCleaner.java @@ -0,0 +1,58 @@ +package run.halo.app.security.authentication.rememberme; + +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.isNull; +import static run.halo.app.extension.index.query.QueryFactory.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; + +/** + * 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 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(); + 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(); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java new file mode 100644 index 0000000..17e6730 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java @@ -0,0 +1,395 @@ +package run.halo.app.security.authentication.rememberme; + +import static org.apache.commons.lang3.BooleanUtils.isTrue; +import static org.apache.commons.lang3.BooleanUtils.toBoolean; + +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 int TWO_WEEKS_S = 1209600; + + public static final String DEFAULT_PARAMETER = "remember-me"; + + public static final String DEFAULT_ALGORITHM = "SHA-256"; + + private static final String DELIMITER = ":"; + + protected final CookieSignatureKeyResolver cookieSignatureKeyResolver; + + protected final ReactiveUserDetailsService userDetailsService; + + protected final RememberMeCookieResolver rememberMeCookieResolver; + + private UserDetailsChecker userDetailsChecker = new AccountStatusUserDetailsChecker(); + + private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); + + 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 Mono.empty(); + } + + @Override + public Mono loginSuccess(ServerWebExchange exchange, + Authentication successfulAuthentication) { + if (!rememberMeRequested(exchange)) { + log.debug("Remember-me login not requested."); + return Mono.empty(); + } + return 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 boolean rememberMeRequested(ServerWebExchange exchange) { + String rememberMe = exchange.getRequest().getQueryParams().getFirst(DEFAULT_PARAMETER); + if (isTrue(toBoolean(rememberMe))) { + return true; + } + if (log.isDebugEnabled()) { + log.debug("Did not send remember-me cookie (principal did not set parameter '{}')", + DEFAULT_PARAMETER); + } + return false; + } + + 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 onLogout(exchange, authentication); + } + + protected Mono onLogout(WebFilterExchange exchange, Authentication authentication) { + return Mono.empty(); + } + + record UsernamePassword(String username, String password) { + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java new file mode 100644 index 0000000..9de2be0 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java @@ -0,0 +1,56 @@ +package run.halo.app.security.authentication.twofactor; + +import java.net.URI; +import org.springframework.context.MessageSource; +import org.springframework.security.web.server.DefaultServerRedirectStrategy; +import org.springframework.security.web.server.ServerRedirectStrategy; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.stereotype.Component; +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; + +@Component +public class DefaultTwoFactorAuthResponseHandler implements TwoFactorAuthResponseHandler { + + private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + + private static final String REDIRECT_LOCATION = "/console/login/mfa"; + + 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 DefaultTwoFactorAuthResponseHandler(MessageSource messageSource, + ServerResponse.Context context) { + this.messageSource = messageSource; + this.context = context; + } + + @Override + public Mono handle(ServerWebExchange exchange) { + return XHR_MATCHER.matches(exchange) + .filter(ServerWebExchangeMatcher.MatchResult::isMatch) + .switchIfEmpty(Mono.defer( + () -> redirectStrategy.sendRedirect(exchange, URI.create(REDIRECT_LOCATION)) + .then(Mono.empty()))) + .flatMap(isXhr -> { + var errorResponse = Exceptions.createErrorResponse( + new TwoFactorAuthRequiredException(URI.create(REDIRECT_LOCATION)), + null, exchange, messageSource); + return ServerResponse.status(errorResponse.getStatusCode()) + .bodyValue(errorResponse.getBody()) + .flatMap(response -> response.writeTo(exchange, context)); + }); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthEndpoint.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthEndpoint.java new file mode 100644 index 0000000..991faad --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthEndpoint.java @@ -0,0 +1,283 @@ +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.BeanPropertyBindingResult; +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.extension.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.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(), + builder -> builder.description("Two-factor authentication endpoint(User-scoped)") + ).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")); + + 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")) + .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")); + + 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) { + var errors = new BeanPropertyBindingResult(target, name); + validator.validate(target, errors); + if (errors.hasErrors()) { + throw new RequestBodyValidationException(errors); + } + } + + @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"); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthRequiredException.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthRequiredException.java new file mode 100644 index 0000000..bc686b7 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthRequiredException.java @@ -0,0 +1,17 @@ +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); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java new file mode 100644 index 0000000..a4216a4 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java @@ -0,0 +1,10 @@ +package run.halo.app.security.authentication.twofactor; + +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +public interface TwoFactorAuthResponseHandler { + + Mono handle(ServerWebExchange exchange); + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java new file mode 100644 index 0000000..d8dd6a7 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java @@ -0,0 +1,47 @@ +package run.halo.app.security.authentication.twofactor; + +import org.springframework.context.MessageSource; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.ServerResponse; +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.TotpAuthenticationFilter; + +@Component +public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer { + + private final ServerSecurityContextRepository securityContextRepository; + + private final TotpAuthService totpAuthService; + + private final ServerResponse.Context context; + + private final MessageSource messageSource; + + private final LoginHandlerEnhancer loginHandlerEnhancer; + + public TwoFactorAuthSecurityConfigurer( + ServerSecurityContextRepository securityContextRepository, + TotpAuthService totpAuthService, + ServerResponse.Context context, + MessageSource messageSource, + LoginHandlerEnhancer loginHandlerEnhancer + ) { + this.securityContextRepository = securityContextRepository; + this.totpAuthService = totpAuthService; + this.context = context; + this.messageSource = messageSource; + this.loginHandlerEnhancer = loginHandlerEnhancer; + } + + @Override + public void configure(ServerHttpSecurity http) { + var filter = new TotpAuthenticationFilter(securityContextRepository, totpAuthService, + context, messageSource, loginHandlerEnhancer); + http.addFilterAfter(filter, SecurityWebFiltersOrder.AUTHENTICATION); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSettings.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSettings.java new file mode 100644 index 0000000..010d8be --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSettings.java @@ -0,0 +1,22 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java new file mode 100644 index 0000000..45a27e6 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java @@ -0,0 +1,49 @@ +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() { + // return true for accessing anonymous resources + return true; + } + + public Authentication getPrevious() { + return previous; + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java new file mode 100644 index 0000000..f716717 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java @@ -0,0 +1,36 @@ +package run.halo.app.security.authentication.twofactor; + +import java.net.URI; +import org.springframework.security.authorization.AuthorizationDecision; +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; + +public class TwoFactorAuthorizationManager + implements ReactiveAuthorizationManager { + + private final ReactiveAuthorizationManager delegate; + + private static final URI REDIRECT_LOCATION = URI.create("/console/login?2fa=totp"); + + public TwoFactorAuthorizationManager( + ReactiveAuthorizationManager delegate) { + this.delegate = delegate; + } + + @Override + public Mono check(Mono authentication, + AuthorizationContext context) { + return authentication.flatMap(a -> { + Mono checked = delegate.check(Mono.just(a), context); + if (a instanceof TwoFactorAuthentication) { + checked = checked.filter(AuthorizationDecision::isGranted) + .switchIfEmpty( + Mono.error(() -> new TwoFactorAuthRequiredException(REDIRECT_LOCATION))); + } + return checked; + }); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorUtils.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorUtils.java new file mode 100644 index 0000000..8c5c7bc --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorUtils.java @@ -0,0 +1,23 @@ +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; + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/DefaultTotpAuthService.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/DefaultTotpAuthService.java new file mode 100644 index 0000000..b722cf7 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/DefaultTotpAuthService.java @@ -0,0 +1,107 @@ +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))); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthService.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthService.java new file mode 100644 index 0000000..3805243 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthService.java @@ -0,0 +1,13 @@ +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); + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java new file mode 100644 index 0000000..b214000 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java @@ -0,0 +1,137 @@ +package run.halo.app.security.authentication.twofactor.totp; + +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.MessageSource; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.CredentialsContainer; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.web.server.authentication.AuthenticationWebFilter; +import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import run.halo.app.security.HaloUserDetails; +import run.halo.app.security.LoginHandlerEnhancer; +import run.halo.app.security.authentication.login.UsernamePasswordHandler; +import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; + +@Slf4j +public class TotpAuthenticationFilter extends AuthenticationWebFilter { + + public TotpAuthenticationFilter( + ServerSecurityContextRepository securityContextRepository, + TotpAuthService totpAuthService, + ServerResponse.Context context, + MessageSource messageSource, + LoginHandlerEnhancer loginHandlerEnhancer + ) { + super(new TwoFactorAuthManager(totpAuthService)); + + setSecurityContextRepository(securityContextRepository); + setRequiresAuthenticationMatcher(pathMatchers(HttpMethod.POST, "/login/2fa/totp")); + setServerAuthenticationConverter(new TotpCodeAuthenticationConverter()); + + var handler = new UsernamePasswordHandler(context, messageSource, loginHandlerEnhancer); + setAuthenticationSuccessHandler(handler); + setAuthenticationFailureHandler(handler); + } + + private static 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 + '.')); + } + }); + } + } + + private static class TwoFactorAuthException extends AuthenticationException { + + public TwoFactorAuthException(String msg, Throwable cause) { + super(msg, cause); + } + + public TwoFactorAuthException(String msg) { + super(msg); + } + + } + + private static class TwoFactorAuthManager implements ReactiveAuthenticationManager { + + private final TotpAuthService totpAuthService; + + private TwoFactorAuthManager(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); + }); + } + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationToken.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationToken.java new file mode 100644 index 0000000..af8df5a --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationToken.java @@ -0,0 +1,33 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/security/authorization/Attributes.java b/application/src/main/java/run/halo/app/security/authorization/Attributes.java new file mode 100644 index 0000000..a68317b --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authorization/Attributes.java @@ -0,0 +1,72 @@ +package run.halo.app.security.authorization; + +import java.security.Principal; + +/** + * 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 UserDetails object to authorize + */ + Principal getPrincipal(); + + /** + * @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(); +} diff --git a/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java b/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java new file mode 100644 index 0000000..3fb6833 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java @@ -0,0 +1,80 @@ +package run.halo.app.security.authorization; + +import java.security.Principal; + +/** + * @author guqing + * @since 2.0.0 + */ +public class AttributesRecord implements Attributes { + private final RequestInfo requestInfo; + private final Principal principal; + + public AttributesRecord(Principal principal, RequestInfo requestInfo) { + this.requestInfo = requestInfo; + this.principal = principal; + } + + @Override + public Principal getPrincipal() { + return this.principal; + } + + @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(); + } +} diff --git a/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java b/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java new file mode 100644 index 0000000..64460ea --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java @@ -0,0 +1,64 @@ +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.authentication.RememberMeAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +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"; + + /** + * 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) + .map(authority -> { + authority = StringUtils.removeStart(authority, SCOPE_PREFIX); + authority = StringUtils.removeStart(authority, ROLE_PREFIX); + return authority; + }) + .collect(Collectors.toSet()); + } + + public static boolean containsSuperRole(Collection roles) { + return roles.contains(SUPER_ROLE_NAME); + } + + /** + * Check if the authentication is a real user. + * + * @param authentication current authentication + * @return true if the authentication is a real user; false otherwise + */ + public static boolean isRealUser(Authentication authentication) { + return authentication instanceof UsernamePasswordAuthenticationToken + || authentication instanceof RememberMeAuthenticationToken; + } +} diff --git a/application/src/main/java/run/halo/app/security/authorization/AuthorizationRuleResolver.java b/application/src/main/java/run/halo/app/security/authorization/AuthorizationRuleResolver.java new file mode 100644 index 0000000..cec9a5e --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authorization/AuthorizationRuleResolver.java @@ -0,0 +1,13 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/security/authorization/AuthorizingVisitor.java b/application/src/main/java/run/halo/app/security/authorization/AuthorizingVisitor.java new file mode 100644 index 0000000..5ff786a --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authorization/AuthorizingVisitor.java @@ -0,0 +1,52 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java b/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java new file mode 100644 index 0000000..00cfdc1 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java @@ -0,0 +1,74 @@ +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.extension.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(authentication, 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); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authorization/PolicyRuleList.java b/application/src/main/java/run/halo/app/security/authorization/PolicyRuleList.java new file mode 100644 index 0000000..6ce0b40 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authorization/PolicyRuleList.java @@ -0,0 +1,35 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/security/authorization/RbacRequestEvaluation.java b/application/src/main/java/run/halo/app/security/authorization/RbacRequestEvaluation.java new file mode 100644 index 0000000..3cf832c --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authorization/RbacRequestEvaluation.java @@ -0,0 +1,161 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/security/authorization/RequestInfo.java b/application/src/main/java/run/halo/app/security/authorization/RequestInfo.java new file mode 100644 index 0000000..8295f3a --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authorization/RequestInfo.java @@ -0,0 +1,61 @@ +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[] {}); + } +} diff --git a/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java b/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java new file mode 100644 index 0000000..f450af7 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java @@ -0,0 +1,46 @@ +package run.halo.app.security.authorization; + +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.security.authorization.AuthorizationDecision; +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.extension.service.RoleService; + +@Slf4j +public class RequestInfoAuthorizationManager + implements ReactiveAuthorizationManager { + + private final AuthorizationRuleResolver ruleResolver; + + public RequestInfoAuthorizationManager(RoleService roleService) { + this.ruleResolver = new DefaultRuleResolver(roleService); + } + + @Override + public Mono check(Mono authentication, + AuthorizationContext context) { + ServerHttpRequest request = context.getExchange().getRequest(); + RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); + + return authentication.flatMap(auth -> this.ruleResolver.visitRules(auth, requestInfo) + .doOnNext(visitor -> showErrorMessage(visitor.getErrors())) + .filter(AuthorizingVisitor::isAllowed) + .map(visitor -> new AuthorizationDecision(isGranted(auth))) + .switchIfEmpty(Mono.fromSupplier(() -> new AuthorizationDecision(false)))); + } + + private boolean isGranted(Authentication authentication) { + return authentication != null && authentication.isAuthenticated(); + } + + private void showErrorMessage(List errors) { + if (errors != null) { + errors.forEach(error -> log.error("Access decision error", error)); + } + } + +} diff --git a/application/src/main/java/run/halo/app/security/authorization/RequestInfoFactory.java b/application/src/main/java/run/halo/app/security/authorization/RequestInfoFactory.java new file mode 100644 index 0000000..4f629f6 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authorization/RequestInfoFactory.java @@ -0,0 +1,233 @@ +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.console.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(request.getPath().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, "/"); + } +} diff --git a/application/src/main/java/run/halo/app/security/authorization/RuleAccumulator.java b/application/src/main/java/run/halo/app/security/authorization/RuleAccumulator.java new file mode 100644 index 0000000..1e7efc3 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authorization/RuleAccumulator.java @@ -0,0 +1,11 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/security/device/DeviceCookieResolver.java b/application/src/main/java/run/halo/app/security/device/DeviceCookieResolver.java new file mode 100644 index 0000000..c4a6ac8 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/device/DeviceCookieResolver.java @@ -0,0 +1,19 @@ +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(); +} diff --git a/application/src/main/java/run/halo/app/security/device/DeviceCookieResolverImpl.java b/application/src/main/java/run/halo/app/security/device/DeviceCookieResolverImpl.java new file mode 100644 index 0000000..bd8590a --- /dev/null +++ b/application/src/main/java/run/halo/app/security/device/DeviceCookieResolverImpl.java @@ -0,0 +1,47 @@ +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"); + } +} diff --git a/application/src/main/java/run/halo/app/security/device/DeviceEndpoint.java b/application/src/main/java/run/halo/app/security/device/DeviceEndpoint.java new file mode 100644 index 0000000..c4373a2 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/device/DeviceEndpoint.java @@ -0,0 +1,163 @@ +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.QueryFactory.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"); + } +} diff --git a/application/src/main/java/run/halo/app/security/device/DeviceReconciler.java b/application/src/main/java/run/halo/app/security/device/DeviceReconciler.java new file mode 100644 index 0000000..a8aefde --- /dev/null +++ b/application/src/main/java/run/halo/app/security/device/DeviceReconciler.java @@ -0,0 +1,72 @@ +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.QueryFactory.equal; + +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; + +@Component +@RequiredArgsConstructor +public class DeviceReconciler implements Reconciler { + 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(); + 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() + .isEmpty() + ) + .forEach(client::delete); + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return builder + .extension(new Device()) + .syncAllOnStart(false) + .build(); + } +} diff --git a/application/src/main/java/run/halo/app/security/device/DeviceServiceImpl.java b/application/src/main/java/run/halo/app/security/device/DeviceServiceImpl.java new file mode 100644 index 0000000..001265a --- /dev/null +++ b/application/src/main/java/run/halo/app/security/device/DeviceServiceImpl.java @@ -0,0 +1,255 @@ +package run.halo.app.security.device; + +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.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.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +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.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +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; + + @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(context -> context.getAuthentication().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())); + } + + 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)"); + 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+)?)") + }; + + 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; + } + } +} diff --git a/application/src/main/java/run/halo/app/security/device/DeviceSessionFilter.java b/application/src/main/java/run/halo/app/security/device/DeviceSessionFilter.java new file mode 100644 index 0000000..ff1975d --- /dev/null +++ b/application/src/main/java/run/halo/app/security/device/DeviceSessionFilter.java @@ -0,0 +1,23 @@ +package run.halo.app.security.device; + +import lombok.RequiredArgsConstructor; +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; + +@Component +@RequiredArgsConstructor +public class DeviceSessionFilter implements WebFilter { + private final DeviceService deviceService; + + @Override + @NonNull + public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { + return exchange.getSession() + .flatMap(session -> deviceService.changeSessionId(exchange)) + .then(chain.filter(exchange)); + } +} diff --git a/application/src/main/java/run/halo/app/security/device/NewDeviceLoginEvent.java b/application/src/main/java/run/halo/app/security/device/NewDeviceLoginEvent.java new file mode 100644 index 0000000..e87b3a9 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/device/NewDeviceLoginEvent.java @@ -0,0 +1,15 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/security/device/NewDeviceLoginListener.java b/application/src/main/java/run/halo/app/security/device/NewDeviceLoginListener.java new file mode 100644 index 0000000..1a9c23f --- /dev/null +++ b/application/src/main/java/run/halo/app/security/device/NewDeviceLoginListener.java @@ -0,0 +1,74 @@ +package run.halo.app.security.device; + +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationListener; +import org.springframework.lang.NonNull; +import org.springframework.scheduling.annotation.Async; +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 implements ApplicationListener { + 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; + + @Async + @Override + public void onApplicationEvent(@NonNull NewDeviceLoginEvent event) { + subscribeForNewDeviceLoginReason(event.getDevice()) + .then(sendNewDeviceNotification(event.getDevice())) + .block(); + } + + 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(); + } +} diff --git a/application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java b/application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java new file mode 100644 index 0000000..ff3687f --- /dev/null +++ b/application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java @@ -0,0 +1,28 @@ +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 run.halo.app.security.authentication.login.HaloUser; +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); + } + +} diff --git a/application/src/main/java/run/halo/app/security/jackson2/HaloUserMixin.java b/application/src/main/java/run/halo/app/security/jackson2/HaloUserMixin.java new file mode 100644 index 0000000..8f84ed2 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/jackson2/HaloUserMixin.java @@ -0,0 +1,20 @@ +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) { + } + +} diff --git a/application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java b/application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java new file mode 100644 index 0000000..71c1f73 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java @@ -0,0 +1,25 @@ +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) { + } +} diff --git a/application/src/main/java/run/halo/app/security/session/InMemoryReactiveIndexedSessionRepository.java b/application/src/main/java/run/halo/app/security/session/InMemoryReactiveIndexedSessionRepository.java new file mode 100644 index 0000000..8939d5e --- /dev/null +++ b/application/src/main/java/run/halo/app/security/session/InMemoryReactiveIndexedSessionRepository.java @@ -0,0 +1,144 @@ +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) { + } +} diff --git a/application/src/main/java/run/halo/app/security/session/ReactiveIndexedSessionRepository.java b/application/src/main/java/run/halo/app/security/session/ReactiveIndexedSessionRepository.java new file mode 100644 index 0000000..cc41a45 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/session/ReactiveIndexedSessionRepository.java @@ -0,0 +1,9 @@ +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 { +} diff --git a/application/src/main/java/run/halo/app/security/session/SessionInvalidationListener.java b/application/src/main/java/run/halo/app/security/session/SessionInvalidationListener.java new file mode 100644 index 0000000..225feff --- /dev/null +++ b/application/src/main/java/run/halo/app/security/session/SessionInvalidationListener.java @@ -0,0 +1,38 @@ +package run.halo.app.security.session; + +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; + +@Component +@RequiredArgsConstructor +public class SessionInvalidationListener { + + 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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/DefaultTemplateEnum.java b/application/src/main/java/run/halo/app/theme/DefaultTemplateEnum.java new file mode 100644 index 0000000..9738d94 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/DefaultTemplateEnum.java @@ -0,0 +1,44 @@ +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; + } + + public static DefaultTemplateEnum convertFrom(String template) { + for (DefaultTemplateEnum e : values()) { + if (e.getValue().equals(template)) { + return e; + } + } + return null; + } +} diff --git a/application/src/main/java/run/halo/app/theme/DefaultTemplateNameResolver.java b/application/src/main/java/run/halo/app/theme/DefaultTemplateNameResolver.java new file mode 100644 index 0000000..5ecd1a4 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/DefaultTemplateNameResolver.java @@ -0,0 +1,56 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/theme/DefaultViewNameResolver.java b/application/src/main/java/run/halo/app/theme/DefaultViewNameResolver.java new file mode 100644 index 0000000..9c6e56a --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/DefaultViewNameResolver.java @@ -0,0 +1,58 @@ +package run.halo.app.theme; + +import java.nio.file.Files; +import lombok.AllArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.autoconfigure.thymeleaf.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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/HaloViewResolver.java b/application/src/main/java/run/halo/app/theme/HaloViewResolver.java new file mode 100644 index 0000000..4c0ce90 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/HaloViewResolver.java @@ -0,0 +1,104 @@ +package run.halo.app.theme; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.http.MediaType; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.result.view.View; +import org.springframework.web.server.ServerWebExchange; +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 { + + private final FinderRegistry finderRegistry; + + public HaloViewResolver(FinderRegistry finderRegistry) { + setViewClass(HaloView.class); + this.finderRegistry = finderRegistry; + } + + @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; + }); + } + + public static class HaloView extends ThymeleafReactiveView { + + @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)); + exchange.getAttributes().put(ModelConst.POWERED_BY_HALO_TEMPLATE_ENGINE, true); + return super.render(model, contentType, exchange); + }); + } + + @Override + @NonNull + protected Mono> getModelAttributes(Map model, + @NonNull ServerWebExchange exchange) { + Mono> contextBasedStaticVariables = + getContextBasedStaticVariables(exchange); + Mono> modelAttributes = super.getModelAttributes(model, exchange); + return Flux.merge(modelAttributes, contextBasedStaticVariables) + .collectList() + .map(modelMapList -> { + Map result = new HashMap<>(); + modelMapList.forEach(result::putAll); + return result; + }); + } + + @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; + }); + }); + } + } +} diff --git a/application/src/main/java/run/halo/app/theme/ReactivePropertyAccessor.java b/application/src/main/java/run/halo/app/theme/ReactivePropertyAccessor.java new file mode 100644 index 0000000..db847d2 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/ReactivePropertyAccessor.java @@ -0,0 +1,118 @@ +package run.halo.app.theme; + +import java.util.List; +import org.springframework.expression.AccessException; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.PropertyAccessor; +import org.springframework.expression.TypedValue; +import org.springframework.expression.spel.ast.AstUtils; +import org.springframework.integration.json.JsonPropertyAccessor; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * A SpEL PropertyAccessor that knows how to read properties from {@link Mono} or {@link Flux} + * object. It first converts the target to the actual value and then calls other + * {@link PropertyAccessor}s to parse the result, If it still cannot be resolved, + * {@link JsonPropertyAccessor} will be used to resolve finally. + * + * @author guqing + * @since 2.0.0 + */ +public class ReactivePropertyAccessor implements PropertyAccessor { + + @Override + public Class[] getSpecificTargetClasses() { + return null; + } + + @Override + public boolean canRead(@NonNull EvaluationContext context, Object target, @NonNull String name) + throws AccessException { + if (isReactiveType(target)) { + return true; + } + var propertyAccessors = + getPropertyAccessorsToTry(target.getClass(), context.getPropertyAccessors()); + for (PropertyAccessor propertyAccessor : propertyAccessors) { + if (propertyAccessor.canRead(context, target, name)) { + return true; + } + } + return false; + } + + @Override + @NonNull + public TypedValue read(@NonNull EvaluationContext context, Object target, @NonNull String name) + throws AccessException { + if (target == null) { + return TypedValue.NULL; + } + Object value = blockingGetForReactive(target); + + List propertyAccessorsToTry = + getPropertyAccessorsToTry(value, context.getPropertyAccessors()); + for (PropertyAccessor propertyAccessor : propertyAccessorsToTry) { + try { + TypedValue result = propertyAccessor.read(context, value, name); + return new TypedValue(blockingGetForReactive(result.getValue())); + } catch (AccessException e) { + // ignore this + } + } + + throw new AccessException("Cannot read property '" + name + "' from [" + value + "]"); + } + + @Nullable + private static Object blockingGetForReactive(@Nullable Object target) { + if (target == null) { + return null; + } + Class clazz = target.getClass(); + Object value = target; + if (Mono.class.isAssignableFrom(clazz)) { + value = ((Mono) target).block(); + } else if (Flux.class.isAssignableFrom(clazz)) { + value = ((Flux) target).collectList().block(); + } + return value; + } + + private boolean isReactiveType(Object target) { + if (target == null) { + return true; + } + Class clazz = target.getClass(); + return Mono.class.isAssignableFrom(clazz) + || Flux.class.isAssignableFrom(clazz); + } + + private List getPropertyAccessorsToTry( + @Nullable Object contextObject, List propertyAccessors) { + + Class targetType = (contextObject != null ? contextObject.getClass() : null); + + List resolvers = + AstUtils.getPropertyAccessorsToTry(targetType, propertyAccessors); + // remove this resolver to avoid infinite loop + resolvers.remove(this); + return resolvers; + } + + @Override + public boolean canWrite(@NonNull EvaluationContext context, Object target, @NonNull String name) + throws AccessException { + return false; + } + + @Override + public void write(@NonNull EvaluationContext context, Object target, @NonNull String name, + Object newValue) + throws AccessException { + throw new UnsupportedOperationException("Write is not supported"); + } +} diff --git a/application/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java b/application/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java new file mode 100644 index 0000000..1794497 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java @@ -0,0 +1,44 @@ +package run.halo.app.theme; + +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.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Reactive SPEL variable expression evaluator. + * + * @author guqing + * @since 2.0.0 + */ +public class ReactiveSpelVariableExpressionEvaluator + implements IStandardVariableExpressionEvaluator { + + private final SPELVariableExpressionEvaluator delegate = + SPELVariableExpressionEvaluator.INSTANCE; + + public static final ReactiveSpelVariableExpressionEvaluator INSTANCE = + new ReactiveSpelVariableExpressionEvaluator(); + + @Override + public Object evaluate(IExpressionContext context, IStandardVariableExpression expression, + StandardExpressionExecutionContext expContext) { + Object returnValue = delegate.evaluate(context, expression, expContext); + if (returnValue == null) { + return null; + } + + Class clazz = returnValue.getClass(); + // Note that: 3 instanceof Foo -> syntax error + if (Mono.class.isAssignableFrom(clazz)) { + return ((Mono) returnValue).block(); + } + if (Flux.class.isAssignableFrom(clazz)) { + return ((Flux) returnValue).collectList().block(); + } + return returnValue; + } +} diff --git a/application/src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java b/application/src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java new file mode 100644 index 0000000..d5145ae --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java @@ -0,0 +1,35 @@ +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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher environmentFetcher; + private final ExternalUrlSupplier externalUrlSupplier; + + @Override + public Mono> acquire(ServerWebExchange exchange) { + return environmentFetcher.getConfigMap() + .filter(configMap -> configMap.getData() != null) + .map(configMap -> { + SiteSettingVo siteSettingVo = SiteSettingVo.from(configMap) + .withUrl(externalUrlSupplier.getURL(exchange.getRequest())); + return Map.of("site", siteSettingVo); + }); + } +} diff --git a/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java b/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java new file mode 100644 index 0000000..c8d4922 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java @@ -0,0 +1,170 @@ +package run.halo.app.theme; + +import java.io.FileNotFoundException; +import java.nio.file.Path; +import lombok.NonNull; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties; +import org.springframework.stereotype.Component; +import org.springframework.util.ConcurrentLruCache; +import org.springframework.util.ResourceUtils; +import org.thymeleaf.TemplateEngine; +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.infra.exception.NotFoundException; +import run.halo.app.plugin.HaloPluginManager; +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 HaloPluginManager haloPluginManager; + + private final ObjectProvider templateResolvers; + + private final ObjectProvider dialects; + + private final ThemeResolver themeResolver; + + public TemplateEngineManager(ThymeleafProperties thymeleafProperties, + ExternalUrlSupplier externalUrlSupplier, + HaloPluginManager haloPluginManager, ObjectProvider templateResolvers, + ObjectProvider dialects, ThemeResolver themeResolver) { + this.thymeleafProperties = thymeleafProperties; + this.externalUrlSupplier = externalUrlSupplier; + this.haloPluginManager = haloPluginManager; + 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); + // cache not exists, will create new engine + if (!engineCache.contains(cacheKey)) { + // before this, check if theme exists + if (!fileExists(theme.getPath())) { + throw new NotFoundException("Theme not found."); + } + } + return engineCache.get(cacheKey); + } + + private boolean fileExists(Path path) { + try { + return ResourceUtils.getFile(path.toUri()).exists(); + } catch (FileNotFoundException e) { + return false; + } + } + + public Mono clearCache(String themeName) { + return themeResolver.getThemeContext(themeName) + .doOnNext(themeContext -> { + CacheKey cacheKey = buildCacheKey(themeContext); + TemplateEngine templateEngine = + (TemplateEngine) engineCache.get(cacheKey); + templateEngine.clearTemplateCache(); + }) + .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); + dialects.orderedStream().forEach(engine::addDialect); + + return engine; + } + + @NonNull + private PluginClassloaderTemplateResolver createPluginClassloaderTemplateResolver() { + var pluginTemplateResolver = new PluginClassloaderTemplateResolver(haloPluginManager); + 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; + } +} diff --git a/application/src/main/java/run/halo/app/theme/ThemeContext.java b/application/src/main/java/run/halo/app/theme/ThemeContext.java new file mode 100644 index 0000000..f931208 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/ThemeContext.java @@ -0,0 +1,24 @@ +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; +} diff --git a/application/src/main/java/run/halo/app/theme/ThemeContextBasedVariablesAcquirer.java b/application/src/main/java/run/halo/app/theme/ThemeContextBasedVariablesAcquirer.java new file mode 100644 index 0000000..346f410 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/ThemeContextBasedVariablesAcquirer.java @@ -0,0 +1,36 @@ +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()); + } +} diff --git a/application/src/main/java/run/halo/app/theme/ThemeLinkBuilder.java b/application/src/main/java/run/halo/app/theme/ThemeLinkBuilder.java new file mode 100644 index 0000000..29407dc --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/ThemeLinkBuilder.java @@ -0,0 +1,71 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java b/application/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java new file mode 100644 index 0000000..cf82601 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java @@ -0,0 +1,92 @@ +package run.halo.app.theme; + +import java.util.Locale; +import java.util.TimeZone; +import java.util.function.Function; +import lombok.extern.slf4j.Slf4j; +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.lang.NonNull; +import org.springframework.lang.Nullable; +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 TIME_ZONE_REQUEST_ATTRIBUTE_NAME = + ThemeLocaleContextResolver.class.getName() + ".TIME_ZONE"; + public static final String LOCALE_REQUEST_ATTRIBUTE_NAME = + ThemeLocaleContextResolver.class.getName() + ".LOCALE"; + + public static final String DEFAULT_PARAMETER_NAME = "language"; + public static final String TIME_ZONE_COOKIE_NAME = "time_zone"; + + private final Function defaultTimeZoneFunction = + exchange -> getDefaultTimeZone(); + + @Override + @NonNull + public LocaleContext resolveLocaleContext(@NonNull ServerWebExchange exchange) { + parseLocaleCookieIfNecessary(exchange); + + Locale locale = getLocale(exchange); + + return new SimpleTimeZoneAwareLocaleContext(locale, + exchange.getAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME)); + } + + @Nullable + private Locale getLocale(ServerWebExchange exchange) { + String language = exchange.getRequest().getQueryParams() + .getFirst(DEFAULT_PARAMETER_NAME); + + Locale locale; + if (StringUtils.isNotBlank(language)) { + locale = new Locale(language); + } else if (exchange.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME) != null) { + locale = exchange.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME); + } else { + locale = super.resolveLocaleContext(exchange).getLocale(); + } + return locale; + } + + private TimeZone getDefaultTimeZone() { + return TimeZone.getDefault(); + } + + private void parseLocaleCookieIfNecessary(ServerWebExchange exchange) { + if (exchange.getAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME) == null) { + TimeZone timeZone = null; + HttpCookie cookie = exchange.getRequest() + .getCookies() + .getFirst(TIME_ZONE_COOKIE_NAME); + if (cookie != null) { + String value = cookie.getValue(); + timeZone = TimeZone.getTimeZone(value); + } + exchange.getAttributes().put(TIME_ZONE_REQUEST_ATTRIBUTE_NAME, + (timeZone != null ? timeZone : this.defaultTimeZoneFunction.apply(exchange))); + } + + if (exchange.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME) == null) { + HttpCookie cookie = exchange.getRequest() + .getCookies() + .getFirst(DEFAULT_PARAMETER_NAME); + if (cookie != null) { + String value = cookie.getValue(); + exchange.getAttributes() + .put(LOCALE_REQUEST_ATTRIBUTE_NAME, new Locale(value)); + } + } + } +} diff --git a/application/src/main/java/run/halo/app/theme/ThemeResolver.java b/application/src/main/java/run/halo/app/theme/ThemeResolver.java new file mode 100644 index 0000000..dd9b6c4 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/ThemeResolver.java @@ -0,0 +1,73 @@ +package run.halo.app.theme; + +import lombok.AllArgsConstructor; +import org.apache.commons.lang3.StringUtils; +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.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting.Theme; +import run.halo.app.infra.ThemeRootGetter; + +/** + * @author johnniang + * @since 2.0.0 + */ +@Component +@AllArgsConstructor +public class ThemeResolver { + + private final SystemConfigurableEnvironmentFetcher environmentFetcher; + + private final ThemeRootGetter themeRoot; + + 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(() -> environmentFetcher.fetch(Theme.GROUP, Theme.class) + .map(Theme::getActive) + .switchIfEmpty( + Mono.error(() -> new IllegalArgumentException("No theme activated"))) + .map(activatedTheme -> { + var builder = ThemeContext.builder(); + var themeName = exchange.getRequest().getQueryParams() + .getFirst(ThemeContext.THEME_PREVIEW_PARAM_NAME); + if (StringUtils.isBlank(themeName)) { + 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); + } + +} diff --git a/application/src/main/java/run/halo/app/theme/ViewContextBasedVariablesAcquirer.java b/application/src/main/java/run/halo/app/theme/ViewContextBasedVariablesAcquirer.java new file mode 100644 index 0000000..003c307 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/ViewContextBasedVariablesAcquirer.java @@ -0,0 +1,11 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/theme/ViewNameResolver.java b/application/src/main/java/run/halo/app/theme/ViewNameResolver.java new file mode 100644 index 0000000..873b4a4 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/ViewNameResolver.java @@ -0,0 +1,20 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/theme/config/ThemeConfiguration.java b/application/src/main/java/run/halo/app/theme/config/ThemeConfiguration.java new file mode 100644 index 0000000..d7a7c78 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/config/ThemeConfiguration.java @@ -0,0 +1,40 @@ +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.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) { + return new HaloSpringSecurityDialect(securityContextRepository); + } + + @Bean + @ConditionalOnProperty(name = "halo.theme.generator-meta-disabled", + havingValue = "false", + matchIfMissing = true) + TemplateHeadProcessor generatorMetaProcessor(ObjectProvider buildProperties) { + return new GeneratorMetaProcessor(buildProperties); + } +} diff --git a/application/src/main/java/run/halo/app/theme/config/ThemeWebFluxConfigurer.java b/application/src/main/java/run/halo/app/theme/config/ThemeWebFluxConfigurer.java new file mode 100644 index 0000000..bcdb9ea --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/config/ThemeWebFluxConfigurer.java @@ -0,0 +1,96 @@ +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(); + } + + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java new file mode 100644 index 0000000..96b9935 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java @@ -0,0 +1,50 @@ +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(context, tag, structureHandler); + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/CommentEnabledVariableProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/CommentEnabledVariableProcessor.java new file mode 100644 index 0000000..43bd9be --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/CommentEnabledVariableProcessor.java @@ -0,0 +1,91 @@ +package run.halo.app.theme.dialect; + +import static org.apache.commons.lang3.BooleanUtils.isFalse; +import static org.apache.commons.lang3.BooleanUtils.isTrue; + +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.SystemConfigurableEnvironmentFetcher; +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 { + + 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); + SystemConfigurableEnvironmentFetcher environmentFetcher = + appCtx.getBean(SystemConfigurableEnvironmentFetcher.class); + var commentSetting = environmentFetcher.fetchComment() + .blockOptional() + .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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessor.java new file mode 100644 index 0000000..801270d --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessor.java @@ -0,0 +1,118 @@ +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)); + } +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/theme/dialect/DefaultFaviconHeadProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/DefaultFaviconHeadProcessor.java new file mode 100644 index 0000000..d8986ea --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/DefaultFaviconHeadProcessor.java @@ -0,0 +1,46 @@ +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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher 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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/DefaultLinkExpressionFactory.java b/application/src/main/java/run/halo/app/theme/dialect/DefaultLinkExpressionFactory.java new file mode 100644 index 0000000..23b8480 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/DefaultLinkExpressionFactory.java @@ -0,0 +1,66 @@ +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); + } + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/DuplicateMetaTagProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/DuplicateMetaTagProcessor.java new file mode 100644 index 0000000..4554f66 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/DuplicateMetaTagProcessor.java @@ -0,0 +1,90 @@ +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) { + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/GeneratorMetaProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/GeneratorMetaProcessor.java new file mode 100644 index 0000000..ca2ee56 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/GeneratorMetaProcessor.java @@ -0,0 +1,43 @@ +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); + }); + } + +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java new file mode 100644 index 0000000..6be5c52 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java @@ -0,0 +1,93 @@ +package run.halo.app.theme.dialect; + +import static org.thymeleaf.spring6.context.SpringContextUtils.getApplicationContext; + +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 { + /** + * 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) { + + // 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(context, modelToInsert, structureHandler)) + .then() + .block(); + + // 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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/GlobalSeoProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/GlobalSeoProcessor.java new file mode 100644 index 0000000..1ca3c66 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/GlobalSeoProcessor.java @@ -0,0 +1,60 @@ +package run.halo.app.theme.dialect; + +import lombok.AllArgsConstructor; +import org.apache.commons.lang3.BooleanUtils; +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.SystemConfigurableEnvironmentFetcher; +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 +public class GlobalSeoProcessor implements TemplateHeadProcessor { + + private final SystemConfigurableEnvironmentFetcher environmentFetcher; + + @Override + public Mono process(ITemplateContext context, IModel model, + IElementModelStructureHandler structureHandler) { + return environmentFetcher.fetch(SystemSetting.Seo.GROUP, SystemSetting.Seo.class) + .map(seo -> { + boolean blockSpiders = BooleanUtils.isTrue(seo.getBlockSpiders()); + IModelFactory modelFactory = context.getModelFactory(); + if (blockSpiders) { + String noIndexMeta = "\n"; + model.add(modelFactory.createText(noIndexMeta)); + return model; + } + + 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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/HaloExpressionObjectFactory.java b/application/src/main/java/run/halo/app/theme/dialect/HaloExpressionObjectFactory.java new file mode 100644 index 0000000..ba4f2f4 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/HaloExpressionObjectFactory.java @@ -0,0 +1,40 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java b/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java new file mode 100644 index 0000000..6d61b3c --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java @@ -0,0 +1,46 @@ +package run.halo.app.theme.dialect; + +import java.util.HashSet; +import java.util.Set; +import org.thymeleaf.dialect.AbstractProcessorDialect; +import org.thymeleaf.dialect.IExpressionObjectDialect; +import org.thymeleaf.expression.IExpressionObjectFactory; +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 { + 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 JsonNodePropertyAccessorBoundariesProcessor()); + processors.add(new CommentElementTagProcessor(dialectPrefix)); + processors.add(new CommentEnabledVariableProcessor()); + return processors; + } + + @Override + public IExpressionObjectFactory getExpressionObjectFactory() { + return HALO_EXPRESSION_OBJECTS_FACTORY; + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java b/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java new file mode 100644 index 0000000..31f2b62 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java @@ -0,0 +1,53 @@ +package run.halo.app.theme.dialect; + +import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList; +import static run.halo.app.infra.AnonymousUserConst.PRINCIPAL; +import static run.halo.app.infra.AnonymousUserConst.Role; + +import java.util.function.Function; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import org.springframework.web.server.ServerWebExchange; +import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect; +import org.thymeleaf.extras.springsecurity6.util.SpringSecurityContextUtils; +import org.thymeleaf.extras.springsecurity6.util.SpringVersionUtils; + +/** + * 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; + + public HaloSpringSecurityDialect(ServerSecurityContextRepository securityContextRepository) { + this.securityContextRepository = securityContextRepository; + } + + @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(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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/HaloTrackerProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/HaloTrackerProcessor.java new file mode 100644 index 0000000..beb7d62 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/HaloTrackerProcessor.java @@ -0,0 +1,63 @@ +package run.halo.app.theme.dialect; + +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 { + + private final ExternalUrlSupplier externalUrlGetter; + + public HaloTrackerProcessor(ExternalUrlSupplier externalUrlGetter) { + this.externalUrlGetter = externalUrlGetter; + } + + @Override + public Mono process(ITemplateContext context, IModel model, + IElementModelStructureHandler structureHandler) { + 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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java new file mode 100644 index 0000000..0ab86ff --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java @@ -0,0 +1,49 @@ +package run.halo.app.theme.dialect; + +import org.springframework.integration.json.JsonPropertyAccessor; +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 run.halo.app.theme.ReactivePropertyAccessor; + +/** + * A template boundaries processor for add {@link JsonPropertyAccessor} to + * {@link ThymeleafEvaluationContext}. + * + * @author guqing + * @since 2.0.0 + */ +public class JsonNodePropertyAccessorBoundariesProcessor + extends AbstractTemplateBoundariesProcessor { + private static final int PRECEDENCE = StandardDialect.PROCESSOR_PRECEDENCE; + private static final JsonPropertyAccessor JSON_PROPERTY_ACCESSOR = new JsonPropertyAccessor(); + private static final ReactivePropertyAccessor REACTIVE_PROPERTY_ACCESSOR = + new ReactivePropertyAccessor(); + + public JsonNodePropertyAccessorBoundariesProcessor() { + super(TemplateMode.HTML, PRECEDENCE); + } + + @Override + public void doProcessTemplateStart(ITemplateContext context, ITemplateStart templateStart, + ITemplateBoundariesStructureHandler structureHandler) { + ThymeleafEvaluationContext evaluationContext = + (ThymeleafEvaluationContext) context.getVariable( + ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME); + if (evaluationContext != null) { + evaluationContext.addPropertyAccessor(JSON_PROPERTY_ACCESSOR); + evaluationContext.addPropertyAccessor(REACTIVE_PROPERTY_ACCESSOR); + } + } + + @Override + public void doProcessTemplateEnd(ITemplateContext context, ITemplateEnd templateEnd, + ITemplateBoundariesStructureHandler structureHandler) { + // nothing to do + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/LinkExpressionObjectDialect.java b/application/src/main/java/run/halo/app/theme/dialect/LinkExpressionObjectDialect.java new file mode 100644 index 0000000..839c382 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/LinkExpressionObjectDialect.java @@ -0,0 +1,27 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java new file mode 100644 index 0000000..74239b1 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java @@ -0,0 +1,85 @@ +package run.halo.app.theme.dialect; + +import static org.thymeleaf.spring6.context.SpringContextUtils.getApplicationContext; + +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.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +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 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) { + + 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(context, tag, + structureHandler, modelToInsert) + ) + .then() + .block(); + structureHandler.replaceWith(modelToInsert, false); + } + + private String getGlobalFooterText(ApplicationContext appCtx) { + SystemConfigurableEnvironmentFetcher fetcher = + appCtx.getBean(SystemConfigurableEnvironmentFetcher.class); + return fetcher.fetch(SystemSetting.CodeInjection.GROUP, SystemSetting.CodeInjection.class) + .map(SystemSetting.CodeInjection::getFooter) + .block(); + } + + private Flux getTemplateFooterProcessors(ITemplateContext context) { + var extensionGetter = getApplicationContext(context).getBeanProvider(ExtensionGetter.class) + .getIfUnique(); + if (extensionGetter == null) { + return Flux.empty(); + } + return extensionGetter.getExtensions(TemplateFooterProcessor.class); + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/TemplateGlobalHeadProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/TemplateGlobalHeadProcessor.java new file mode 100644 index 0000000..030ac63 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/TemplateGlobalHeadProcessor.java @@ -0,0 +1,63 @@ +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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher fetcher; + + public TemplateGlobalHeadProcessor(SystemConfigurableEnvironmentFetcher 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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/expression/Annotations.java b/application/src/main/java/run/halo/app/theme/dialect/expression/Annotations.java new file mode 100644 index 0000000..f78b36b --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/expression/Annotations.java @@ -0,0 +1,65 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/CategoryQueryEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/CategoryQueryEndpoint.java new file mode 100644 index 0000000..1183211 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/CategoryQueryEndpoint.java @@ -0,0 +1,139 @@ +package run.halo.app.theme.endpoint; + +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.theme.endpoint.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.QueryFactory; +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(QueryFactory.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()); + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java new file mode 100644 index 0000000..edfd4c4 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java @@ -0,0 +1,408 @@ +package run.halo.app.theme.endpoint; + +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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher environmentFetcher; + private final RateLimiterRegistry rateLimiterRegistry; + + @Override + public RouterFunction endpoint() { + final var tag = "CommentV1alpha1Public"; + return SpringdocRouteBuilder.route() + .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(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(schemaBuilder() + .implementation(ReplyRequest.class)) + )) + .response(responseBuilder() + .implementation(Reply.class)) + ) + .GET("comments", this::listComments, builder -> { + builder.operationId("ListComments") + .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)); + // fix gh-2951 + reply.getSpec().setHidden(false); + 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())); + 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.")); + } + + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/MenuQueryEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/MenuQueryEndpoint.java new file mode 100644 index 0000000..b43eff0 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/MenuQueryEndpoint.java @@ -0,0 +1,92 @@ +package run.halo.app.theme.endpoint; + +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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher 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()); + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PluginQueryEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/PluginQueryEndpoint.java new file mode 100644 index 0000000..2de3024 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/PluginQueryEndpoint.java @@ -0,0 +1,62 @@ +package run.halo.app.theme.endpoint; + +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()); + } +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PostPublicQuery.java b/application/src/main/java/run/halo/app/theme/endpoint/PostPublicQuery.java new file mode 100644 index 0000000..c880186 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/PostPublicQuery.java @@ -0,0 +1,22 @@ +package run.halo.app.theme.endpoint; + +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); + } +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PostQueryEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/PostQueryEndpoint.java new file mode 100644 index 0000000..9136eea --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/PostQueryEndpoint.java @@ -0,0 +1,119 @@ +package run.halo.app.theme.endpoint; + +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) + .doOnNext(result -> { + if (result.getCurrent() == null) { + throw new NotFoundException("Post not found"); + } + }) + .flatMap(result -> ServerResponse.ok().contentType(MediaType.APPLICATION_JSON) + .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()); + } +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PublicApiUtils.java b/application/src/main/java/run/halo/app/theme/endpoint/PublicApiUtils.java new file mode 100644 index 0000000..142b720 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/PublicApiUtils.java @@ -0,0 +1,70 @@ +package run.halo.app.theme.endpoint; + +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; + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PublicUserEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/PublicUserEndpoint.java new file mode 100644 index 0000000..9ebd7b2 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/PublicUserEndpoint.java @@ -0,0 +1,285 @@ +package run.halo.app.theme.endpoint; + +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.parameter.Builder.parameterBuilder; +import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; + +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 lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; +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 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.extension.service.EmailPasswordRecoveryService; +import run.halo.app.core.extension.service.EmailVerificationService; +import run.halo.app.core.extension.service.UserService; +import run.halo.app.extension.GroupVersion; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.ValidationUtils; +import run.halo.app.infra.exception.AccessDeniedException; +import run.halo.app.infra.exception.EmailVerificationFailed; +import run.halo.app.infra.exception.RateLimitExceededException; +import run.halo.app.infra.utils.IpAddressUtils; + +/** + * User endpoint for unauthenticated user. + * + * @author guqing + * @since 2.4.0 + */ +@Component +@RequiredArgsConstructor +public class PublicUserEndpoint implements CustomEndpoint { + private final UserService userService; + private final ServerSecurityContextRepository securityContextRepository; + private final ReactiveUserDetailsService reactiveUserDetailsService; + private final EmailPasswordRecoveryService emailPasswordRecoveryService; + private final RateLimiterRegistry rateLimiterRegistry; + private final SystemConfigurableEnvironmentFetcher environmentFetcher; + private final EmailVerificationService emailVerificationService; + + @Override + public RouterFunction endpoint() { + var tag = "UserV1alpha1Public"; + return SpringdocRouteBuilder.route() + .POST("/users/-/signup", this::signUp, + builder -> builder.operationId("SignUp") + .description("Sign up a new user") + .tag(tag) + .requestBody(requestBodyBuilder().required(true) + .implementation(SignUpRequest.class) + ) + .response(responseBuilder().implementation(User.class)) + ) + .POST("/users/-/send-register-verify-email", this::sendRegisterVerifyEmail, + builder -> builder.operationId("SendRegisterVerifyEmail") + .description( + "Send registration verification email, which can be called when " + + "mustVerifyEmailOnRegistration in user settings is true" + ) + .tag(tag) + .requestBody(requestBodyBuilder() + .required(true) + .implementation(RegisterVerifyEmailRequest.class) + ) + .response(responseBuilder() + .responseCode(HttpStatus.NO_CONTENT.toString()) + .implementation(Void.class) + ) + ) + .POST("/users/-/send-password-reset-email", this::sendPasswordResetEmail, + builder -> builder.operationId("SendPasswordResetEmail") + .description("Send password reset email when forgot password") + .tag(tag) + .requestBody(requestBodyBuilder() + .required(true) + .implementation(PasswordResetEmailRequest.class) + ) + .response(responseBuilder() + .responseCode(HttpStatus.NO_CONTENT.toString()) + .implementation(Void.class)) + ) + .PUT("/users/{name}/reset-password", this::resetPasswordByToken, + builder -> builder.operationId("ResetPasswordByToken") + .description("Reset password by token") + .tag(tag) + .parameter(parameterBuilder() + .name("name") + .description("The name of the user") + .required(true) + .in(ParameterIn.PATH) + ) + .requestBody(requestBodyBuilder() + .required(true) + .implementation(ResetPasswordRequest.class) + ) + .response(responseBuilder() + .responseCode(HttpStatus.NO_CONTENT.toString()) + .implementation(Void.class) + ) + ) + .build(); + } + + private Mono resetPasswordByToken(ServerRequest request) { + var username = request.pathVariable("name"); + return request.bodyToMono(ResetPasswordRequest.class) + .doOnNext(resetReq -> { + if (StringUtils.isBlank(resetReq.token())) { + throw new ServerWebInputException("Token must not be blank"); + } + if (StringUtils.isBlank(resetReq.newPassword())) { + throw new ServerWebInputException("New password must not be blank"); + } + }) + .switchIfEmpty( + Mono.error(() -> new ServerWebInputException("Request body must not be empty")) + ) + .flatMap(resetReq -> { + var token = resetReq.token(); + var newPassword = resetReq.newPassword(); + return emailPasswordRecoveryService.changePassword(username, newPassword, token); + }) + .then(ServerResponse.noContent().build()); + } + + record PasswordResetEmailRequest(@Schema(requiredMode = REQUIRED) String username, + @Schema(requiredMode = REQUIRED) String email) { + } + + record ResetPasswordRequest(@Schema(requiredMode = REQUIRED, minLength = 6) String newPassword, + @Schema(requiredMode = REQUIRED) String token) { + } + + record RegisterVerifyEmailRequest(@Schema(requiredMode = REQUIRED) String email) { + } + + private Mono sendPasswordResetEmail(ServerRequest request) { + return request.bodyToMono(PasswordResetEmailRequest.class) + .flatMap(passwordResetRequest -> { + var username = passwordResetRequest.username(); + var email = passwordResetRequest.email(); + return Mono.just(passwordResetRequest) + .transformDeferred(sendResetPasswordEmailRateLimiter(username, email)) + .flatMap( + r -> emailPasswordRecoveryService.sendPasswordResetEmail(username, email)) + .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); + }) + .then(ServerResponse.noContent().build()); + } + + RateLimiterOperator sendResetPasswordEmailRateLimiter(String username, String email) { + String rateLimiterKey = "send-reset-password-email-" + username + ":" + email; + var rateLimiter = + rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-reset-password-email"); + return RateLimiterOperator.of(rateLimiter); + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("api.halo.run/v1alpha1"); + } + + private Mono signUp(ServerRequest request) { + return request.bodyToMono(SignUpRequest.class) + .doOnNext(signUpRequest -> signUpRequest.user().getSpec().setEmailVerified(false)) + .flatMap(signUpRequest -> environmentFetcher.fetch(SystemSetting.User.GROUP, + SystemSetting.User.class) + .map(user -> BooleanUtils.isTrue(user.getMustVerifyEmailOnRegistration())) + .defaultIfEmpty(false) + .flatMap(mustVerifyEmailOnRegistration -> { + if (!mustVerifyEmailOnRegistration) { + return Mono.just(signUpRequest); + } + if (!StringUtils.isNumeric(signUpRequest.verifyCode)) { + return Mono.error(new EmailVerificationFailed()); + } + return emailVerificationService.verifyRegisterVerificationCode( + signUpRequest.user().getSpec().getEmail(), + signUpRequest.verifyCode) + .flatMap(verified -> { + if (BooleanUtils.isNotTrue(verified)) { + return Mono.error(new EmailVerificationFailed()); + } + signUpRequest.user().getSpec().setEmailVerified(true); + return Mono.just(signUpRequest); + }); + }) + ) + .flatMap(signUpRequest -> + userService.signUp(signUpRequest.user(), signUpRequest.password()) + ) + .flatMap(user -> authenticate(user.getMetadata().getName(), request.exchange()) + .thenReturn(user) + ) + .flatMap(user -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(user) + ) + .transformDeferred(getRateLimiterForSignUp(request.exchange())) + .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); + } + + private Mono sendRegisterVerifyEmail(ServerRequest request) { + return request.bodyToMono(RegisterVerifyEmailRequest.class) + .switchIfEmpty(Mono.error( + () -> new ServerWebInputException("Required request body is missing.")) + ) + .map(emailReq -> { + var email = emailReq.email(); + if (!ValidationUtils.isValidEmail(email)) { + throw new ServerWebInputException("Invalid email address."); + } + return email; + }) + .flatMap(email -> environmentFetcher.fetch(SystemSetting.User.GROUP, + SystemSetting.User.class) + .map(config -> BooleanUtils.isTrue(config.getMustVerifyEmailOnRegistration())) + .defaultIfEmpty(false) + .doOnNext(mustVerifyEmailOnRegistration -> { + if (!mustVerifyEmailOnRegistration) { + throw new AccessDeniedException("Email verification is not required."); + } + }) + .transformDeferred(sendRegisterEmailVerificationCodeRateLimiter(email)) + .flatMap(s -> emailVerificationService.sendRegisterVerificationCode(email) + .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new)) + .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new) + ) + .then(ServerResponse.ok().build()); + } + + private RateLimiterOperator getRateLimiterForSignUp(ServerWebExchange exchange) { + var clientIp = IpAddressUtils.getClientIp(exchange.getRequest()); + var rateLimiter = rateLimiterRegistry.rateLimiter("signup-from-ip-" + clientIp, + "signup"); + return RateLimiterOperator.of(rateLimiter); + } + + private Mono authenticate(String username, ServerWebExchange exchange) { + return reactiveUserDetailsService.findByUsername(username) + .flatMap(userDetails -> { + SecurityContextImpl securityContext = new SecurityContextImpl(); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails.getUsername(), + userDetails.getPassword(), userDetails.getAuthorities()); + securityContext.setAuthentication(authentication); + return securityContextRepository.save(exchange, securityContext); + }); + } + + private RateLimiterOperator sendRegisterEmailVerificationCodeRateLimiter(String email) { + String rateLimiterKey = "send-register-verify-email:" + email; + var rateLimiter = + rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-email-verification-code"); + return RateLimiterOperator.of(rateLimiter); + } + + record SignUpRequest(@Schema(requiredMode = REQUIRED) User user, + @Schema(requiredMode = REQUIRED, minLength = 6) String password, + @Schema(requiredMode = NOT_REQUIRED, minLength = 6, maxLength = 6) + String verifyCode + ) { + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/SinglePageQueryEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/SinglePageQueryEndpoint.java new file mode 100644 index 0000000..e66ace4 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/SinglePageQueryEndpoint.java @@ -0,0 +1,108 @@ +package run.halo.app.theme.endpoint; + +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.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; + + @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 singlePageFinder.list(query.getPage(), + query.getSize(), + query.toPredicate(), + query.toComparator() + ) + .flatMap(result -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .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()); + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/SiteStatsQueryEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/SiteStatsQueryEndpoint.java new file mode 100644 index 0000000..40792d0 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/SiteStatsQueryEndpoint.java @@ -0,0 +1,57 @@ +package run.halo.app.theme.endpoint; + +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"); + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/TagQueryEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/TagQueryEndpoint.java new file mode 100644 index 0000000..a54120d --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/TagQueryEndpoint.java @@ -0,0 +1,144 @@ +package run.halo.app.theme.endpoint; + +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.QueryFactory; +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(QueryFactory.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()); + } +} diff --git a/application/src/main/java/run/halo/app/theme/engine/DefaultThemeTemplateAvailabilityProvider.java b/application/src/main/java/run/halo/app/theme/engine/DefaultThemeTemplateAvailabilityProvider.java new file mode 100644 index 0000000..5f1ce78 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/engine/DefaultThemeTemplateAvailabilityProvider.java @@ -0,0 +1,25 @@ +package run.halo.app.theme.engine; + +import java.nio.file.Files; +import org.springframework.boot.autoconfigure.thymeleaf.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); + } + +} diff --git a/application/src/main/java/run/halo/app/theme/engine/HaloTemplateEngine.java b/application/src/main/java/run/halo/app/theme/engine/HaloTemplateEngine.java new file mode 100644 index 0000000..fcbd3c3 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/engine/HaloTemplateEngine.java @@ -0,0 +1,53 @@ +package run.halo.app.theme.engine; + +import java.nio.charset.Charset; +import java.util.Set; +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.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Default template engine implementation to be used in Halo. + * + * @author johnniang + */ +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()); + } + if (publisher instanceof Flux flux) { + return flux.subscribeOn(Schedulers.boundedElastic()); + } + return publisher; + } + +} diff --git a/application/src/main/java/run/halo/app/theme/engine/PluginClassloaderTemplateResolver.java b/application/src/main/java/run/halo/app/theme/engine/PluginClassloaderTemplateResolver.java new file mode 100644 index 0000000..d77dd5c --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/engine/PluginClassloaderTemplateResolver.java @@ -0,0 +1,105 @@ +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.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; +import run.halo.app.plugin.HaloPluginManager; + +/** + * Plugin classloader template resolver to resolve template by plugin classloader. + * + * @author guqing + * @since 2.11.0 + */ +public class PluginClassloaderTemplateResolver extends AbstractConfigurableTemplateResolver { + + private final HaloPluginManager haloPluginManager; + static final Pattern PLUGIN_TEMPLATE_PATTERN = + Pattern.compile("plugin:([A-Za-z0-9\\-.]+):(.+)"); + + /** + * Create a new plugin classloader template resolver, not cacheable. + * + * @param haloPluginManager plugin manager must not be null + */ + public PluginClassloaderTemplateResolver(HaloPluginManager haloPluginManager) { + super(); + this.haloPluginManager = haloPluginManager; + 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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/engine/ThemeTemplateAvailabilityProvider.java b/application/src/main/java/run/halo/app/theme/engine/ThemeTemplateAvailabilityProvider.java new file mode 100644 index 0000000..dbc60b7 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/engine/ThemeTemplateAvailabilityProvider.java @@ -0,0 +1,9 @@ +package run.halo.app.theme.engine; + +import run.halo.app.theme.ThemeContext; + +public interface ThemeTemplateAvailabilityProvider { + + boolean isTemplateAvailable(ThemeContext themeContext, String viewName); + +} diff --git a/application/src/main/java/run/halo/app/theme/finders/CategoryFinder.java b/application/src/main/java/run/halo/app/theme/finders/CategoryFinder.java new file mode 100644 index 0000000..80bd684 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/CategoryFinder.java @@ -0,0 +1,35 @@ +package run.halo.app.theme.finders; + +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.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(List 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); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/CommentFinder.java b/application/src/main/java/run/halo/app/theme/finders/CommentFinder.java new file mode 100644 index 0000000..88a22a3 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/CommentFinder.java @@ -0,0 +1,26 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/CommentPublicQueryService.java b/application/src/main/java/run/halo/app/theme/finders/CommentPublicQueryService.java new file mode 100644 index 0000000..b11d92f --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/CommentPublicQueryService.java @@ -0,0 +1,32 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/ContributorFinder.java b/application/src/main/java/run/halo/app/theme/finders/ContributorFinder.java new file mode 100644 index 0000000..904bf57 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/ContributorFinder.java @@ -0,0 +1,17 @@ +package run.halo.app.theme.finders; + +import java.util.List; +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(List names); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/DefaultFinderRegistry.java b/application/src/main/java/run/halo/app/theme/finders/DefaultFinderRegistry.java new file mode 100644 index 0000000..fd1a949 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/DefaultFinderRegistry.java @@ -0,0 +1,110 @@ +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); + } + } + +} diff --git a/application/src/main/java/run/halo/app/theme/finders/FinderRegistry.java b/application/src/main/java/run/halo/app/theme/finders/FinderRegistry.java new file mode 100644 index 0000000..7dfe034 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/FinderRegistry.java @@ -0,0 +1,20 @@ +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); + +} diff --git a/application/src/main/java/run/halo/app/theme/finders/MenuFinder.java b/application/src/main/java/run/halo/app/theme/finders/MenuFinder.java new file mode 100644 index 0000000..af45f29 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/MenuFinder.java @@ -0,0 +1,17 @@ +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(); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/PluginFinder.java b/application/src/main/java/run/halo/app/theme/finders/PluginFinder.java new file mode 100644 index 0000000..c113d39 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/PluginFinder.java @@ -0,0 +1,14 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/PostFinder.java b/application/src/main/java/run/halo/app/theme/finders/PostFinder.java new file mode 100644 index 0000000..8347976 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/PostFinder.java @@ -0,0 +1,54 @@ +package run.halo.app.theme.finders; + +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.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(); + + 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); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/PostPublicQueryService.java b/application/src/main/java/run/halo/app/theme/finders/PostPublicQueryService.java new file mode 100644 index 0000000..e1ef8e6 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/PostPublicQueryService.java @@ -0,0 +1,54 @@ +package run.halo.app.theme.finders; + +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); + + /** + * 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); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/SinglePageConversionService.java b/application/src/main/java/run/halo/app/theme/finders/SinglePageConversionService.java new file mode 100644 index 0000000..15f6a85 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/SinglePageConversionService.java @@ -0,0 +1,55 @@ +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.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); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/SinglePageFinder.java b/application/src/main/java/run/halo/app/theme/finders/SinglePageFinder.java new file mode 100644 index 0000000..d577b88 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/SinglePageFinder.java @@ -0,0 +1,29 @@ +package run.halo.app.theme.finders; + +import java.util.Comparator; +import java.util.function.Predicate; +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); + + Mono> list(@Nullable Integer page, @Nullable Integer size, + @Nullable Predicate predicate, @Nullable Comparator comparator); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/SiteStatsFinder.java b/application/src/main/java/run/halo/app/theme/finders/SiteStatsFinder.java new file mode 100644 index 0000000..06b2e38 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/SiteStatsFinder.java @@ -0,0 +1,15 @@ +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(); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/TagFinder.java b/application/src/main/java/run/halo/app/theme/finders/TagFinder.java new file mode 100644 index 0000000..4590f02 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/TagFinder.java @@ -0,0 +1,34 @@ +package run.halo.app.theme.finders; + +import java.util.Comparator; +import java.util.List; +import java.util.function.Predicate; +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(List names); + + Mono> list(@Nullable Integer page, @Nullable Integer size); + + @Deprecated(since = "2.12.0") + Mono> list(@Nullable Integer page, @Nullable Integer size, + @Nullable Predicate predicate, @Nullable Comparator comparator); + + List convertToVo(List tags); + + Flux listAll(); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/ThemeFinder.java b/application/src/main/java/run/halo/app/theme/finders/ThemeFinder.java new file mode 100644 index 0000000..239226f --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/ThemeFinder.java @@ -0,0 +1,17 @@ +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); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java new file mode 100644 index 0000000..2bc65c0 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java @@ -0,0 +1,291 @@ +package run.halo.app.theme.finders.impl; + +import static run.halo.app.extension.index.query.QueryFactory.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 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.PageRequestImpl; +import run.halo.app.extension.ReactiveExtensionClient; +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(List names) { + if (names == null) { + return Flux.empty(); + } + return Flux.fromIterable(names) + .flatMap(this::getByName); + } + + 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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java new file mode 100644 index 0000000..08c6e4c --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java @@ -0,0 +1,48 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImpl.java new file mode 100644 index 0000000..3a39a80 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImpl.java @@ -0,0 +1,318 @@ +package run.halo.app.theme.finders.impl; + + +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.isNull; +import static run.halo.app.extension.index.query.QueryFactory.or; + +import java.security.Principal; +import java.util.HashMap; +import java.util.Optional; +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.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import org.springframework.util.DigestUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.content.comment.OwnerInfo; +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.extension.service.UserService; +import run.halo.app.extension.AbstractExtension; +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.index.query.Query; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.infra.AnonymousUserConst; +import run.halo.app.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; +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 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()))) + .orElse(PageRequestImpl.ofSize(0)); + return fixedCommentFieldSelector(ref) + .flatMap(fieldSelector -> { + var listOptions = new ListOptions(); + listOptions.setFieldSelector(fieldSelector); + return client.listBy(Comment.class, listOptions, pageRequest) + .flatMap(listResult -> Flux.fromStream(listResult.get()) + .map(this::toCommentVo) + .concatMap(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()) + .concatMap(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) { + return fixedReplyFieldSelector(commentName) + .flatMap(fieldSelector -> { + var listOptions = new ListOptions(); + listOptions.setFieldSelector(fieldSelector); + 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)) + .concatMap(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 = DigestUtils.md5DigestAsHex(email.getBytes()); + 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 = DigestUtils.md5DigestAsHex(email.getBytes()); + 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 fixedCommentFieldSelector(@Nullable Ref ref) { + return Mono.fromSupplier( + () -> { + var baseQuery = isNull("metadata.deletionTimestamp"); + if (ref != null) { + baseQuery = + and(baseQuery, + equal("spec.subjectRef", Comment.toSubjectRefKey(ref))); + } + return baseQuery; + }) + .flatMap(this::concatVisibleQuery) + .map(FieldSelector::of); + } + + private Mono concatVisibleQuery(Query query) { + Assert.notNull(query, "The query must not be null"); + var approvedQuery = and( + equal("spec.approved", BooleanUtils.TRUE), + equal("spec.hidden", BooleanUtils.FALSE) + ); + // we should list all comments that the user owns + return getCurrentUserWithoutAnonymous() + .map(username -> or(approvedQuery, equal("spec.owner", + Comment.CommentOwner.ownerIdentity(User.KIND, username))) + ) + .defaultIfEmpty(approvedQuery) + .map(compositeQuery -> and(query, compositeQuery)); + } + + private Mono fixedReplyFieldSelector(String commentName) { + Assert.notNull(commentName, "The commentName must not be null"); + // The comment name must be equal to the comment name of the reply + // is approved and not hidden + return Mono.fromSupplier(() -> and( + equal("spec.commentName", commentName), + isNull("metadata.deletionTimestamp") + )) + .flatMap(this::concatVisibleQuery) + .map(FieldSelector::of); + } + + Mono getCurrentUserWithoutAnonymous() { + return ReactiveSecurityContextHolder.getContext() + .mapNotNull(SecurityContext::getAuthentication) + .map(Principal::getName) + .filter(username -> !AnonymousUserConst.PRINCIPAL.equals(username)); + } + + 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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/ContributorFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/ContributorFinderImpl.java new file mode 100644 index 0000000..3d05eef --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/ContributorFinderImpl.java @@ -0,0 +1,38 @@ +package run.halo.app.theme.finders.impl; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.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(List names) { + if (names == null) { + return Flux.empty(); + } + return Flux.fromIterable(names) + .concatMap(this::getContributor); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/MenuFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/MenuFinderImpl.java new file mode 100644 index 0000000..d40378c --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/MenuFinderImpl.java @@ -0,0 +1,156 @@ +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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher 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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/PluginFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/PluginFinderImpl.java new file mode 100644 index 0000000..0426b40 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/PluginFinderImpl.java @@ -0,0 +1,46 @@ +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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java new file mode 100644 index 0000000..e53b5e0 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java @@ -0,0 +1,283 @@ +package run.halo.app.theme.finders.impl; + +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.in; +import static run.halo.app.extension.index.query.QueryFactory.notEqual; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.AllArgsConstructor; +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.NonNull; +import org.springframework.util.Assert; +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.PageRequestImpl; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.exception.ExtensionNotFoundException; +import run.halo.app.extension.index.query.Query; +import run.halo.app.extension.index.query.QueryFactory; +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.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.desc("metadata.name") + ); + } + + @NonNull + static LinkNavigation findPostNavigation(List postNames, String target) { + Assert.notNull(target, "Target post name must not be null"); + for (int i = 0; i < postNames.size(); i++) { + var item = postNames.get(i); + if (target.equals(item)) { + var prevLink = (i > 0) ? postNames.get(i - 1) : null; + var nextLink = (i < postNames.size() - 1) ? postNames.get(i + 1) : null; + return new LinkNavigation(prevLink, target, nextLink); + } + } + return new LinkNavigation(null, target, null); + } + + static Sort archiveSort() { + return Sort.by(Sort.Order.desc("spec.publishTime"), + Sort.Order.desc("metadata.name") + ); + } + + private Mono fetchByName(String name) { + if (StringUtils.isBlank(name)) { + return Mono.empty(); + } + return getByName(name) + .onErrorResume(ExtensionNotFoundException.class::isInstance, (error) -> Mono.empty()); + } + + @Override + public Mono cursor(String currentName) { + return postPredicateResolver.getListOptions() + .map(listOptions -> ListOptions.builder(listOptions) + // Exclude hidden posts + .andQuery(notHiddenPostQuery()) + .build() + ) + .flatMap(postListOption -> { + var postNames = client.indexedQueryEngine() + .retrieve(Post.GVK, postListOption, + PageRequestImpl.ofSize(0).withSort(defaultSort()) + ) + .getItems(); + var previousNextPair = findPostNavigation(postNames, currentName); + String previousPostName = previousNextPair.prev(); + String nextPostName = previousNextPair.next(); + var builder = NavigationPostVo.builder(); + var currentMono = getByName(currentName) + .doOnNext(builder::current); + var prevMono = fetchByName(previousPostName) + .doOnNext(builder::previous); + var nextMono = fetchByName(nextPostName) + .doOnNext(builder::next); + return Mono.when(currentMono, prevMono, nextMono) + .then(Mono.fromSupplier(builder::build)); + }) + .defaultIfEmpty(NavigationPostVo.empty()); + } + + private static Query notHiddenPostQuery() { + return notEqual("status.hideFromList", BooleanUtils.TRUE); + } + + @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 = QueryFactory.all(); + if (StringUtils.isNotBlank(tag)) { + fieldQuery = and(fieldQuery, 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 = QueryFactory.all(); + if (StringUtils.isNotBlank(owner)) { + fieldQuery = and(fieldQuery, 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())) + .concatMap(postPublicQueryService::convertToListedVo); + } + + int pageNullSafe(Integer page) { + return ObjectUtils.defaultIfNull(page, 1); + } + + int sizeNullSafe(Integer size) { + return ObjectUtils.defaultIfNull(size, 10); + } + + record LinkNavigation(String prev, String current, String next) { + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java new file mode 100644 index 0000000..201444d --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java @@ -0,0 +1,191 @@ +package run.halo.app.theme.finders.impl; + +import java.util.List; +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.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.ListResult; +import run.halo.app.extension.PageRequest; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; +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.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 -> Flux.fromStream(list.get()) + .concatMap(post -> convertToListedVo(post) + .flatMap(postVo -> populateStats(postVo) + .doOnNext(postVo::setStats).thenReturn(postVo) + ) + ) + .collectList() + .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 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(MeterUtils.nameOf(Post.class, postVo.getMetadata() + .getName()) + ) + .map(counter -> StatsVo.builder() + .visit(counter.getVisit()) + .upvote(counter.getUpvote()) + .comment(counter.getApprovedComment()) + .build() + ) + .defaultIfEmpty(StatsVo.empty()); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImpl.java new file mode 100644 index 0000000..655165d --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImpl.java @@ -0,0 +1,153 @@ +package run.halo.app.theme.finders.impl; + +import java.util.List; +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.SinglePageService; +import run.halo.app.core.extension.content.SinglePage; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; +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); + } + + 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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java new file mode 100644 index 0000000..256e7ef --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java @@ -0,0 +1,126 @@ +package run.halo.app.theme.finders.impl; + +import java.security.Principal; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; +import lombok.AllArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.lang.Nullable; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import reactor.core.publisher.Flux; +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.ListResult; +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 { + + public static final Predicate FIXED_PREDICATE = page -> page.isPublished() + && Objects.equals(false, page.getSpec().getDeleted()) + && Post.VisibleEnum.PUBLIC.equals(page.getSpec().getVisible()); + + 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 list(page, size, null, null); + } + + @Override + public Mono> list(@Nullable Integer page, @Nullable Integer size, + @Nullable Predicate predicate, @Nullable Comparator comparator) { + var predicateToUse = Optional.ofNullable(predicate) + .map(p -> p.and(FIXED_PREDICATE)) + .orElse(FIXED_PREDICATE); + var comparatorToUse = Optional.ofNullable(comparator) + .orElse(defaultComparator()); + return client.list(SinglePage.class, predicateToUse, + comparatorToUse, pageNullSafe(page), sizeNullSafe(size)) + .flatMap(list -> Flux.fromStream(list.get()) + .concatMap(singlePagePublicQueryService::convertToListedVo) + .collectList() + .map(pageVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), + pageVos) + ) + ) + .defaultIfEmpty(new ListResult<>(0, 0, 0, List.of())); + } + + 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)); + } + + static Comparator defaultComparator() { + Function pinned = + page -> Objects.requireNonNullElse(page.getSpec().getPinned(), false); + Function priority = + page -> Objects.requireNonNullElse(page.getSpec().getPriority(), 0); + Function creationTimestamp = + page -> page.getMetadata().getCreationTimestamp(); + Function name = page -> page.getMetadata().getName(); + return Comparator.comparing(pinned) + .thenComparing(priority) + .thenComparing(creationTimestamp) + .thenComparing(name) + .reversed(); + } + + int pageNullSafe(Integer page) { + return ObjectUtils.defaultIfNull(page, 1); + } + + int sizeNullSafe(Integer size) { + return ObjectUtils.defaultIfNull(size, 10); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/SiteStatsFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/SiteStatsFinderImpl.java new file mode 100644 index 0000000..3003853 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/SiteStatsFinderImpl.java @@ -0,0 +1,71 @@ +package run.halo.app.theme.finders.impl; + +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.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); + } + +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java new file mode 100644 index 0000000..0f6a135 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java @@ -0,0 +1,112 @@ +package run.halo.app.theme.finders.impl; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.data.domain.Sort; +import org.springframework.lang.Nullable; +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.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.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(List names) { + return Flux.fromIterable(names) + .concatMap(this::getByName); + } + + @Override + public Mono> list(Integer page, Integer size) { + return listBy(new ListOptions(), + PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size))); + } + + @Override + public Mono> list(@Nullable Integer page, @Nullable Integer size, + @Nullable Predicate predicate, @Nullable Comparator comparator) { + Comparator comparatorToUse = Optional.ofNullable(comparator) + .orElse(DEFAULT_COMPARATOR.reversed()); + return client.list(Tag.class, predicate, + comparatorToUse, pageNullSafe(page), sizeNullSafe(size)) + .map(list -> { + List tagVos = list.get() + .map(TagVo::from) + .collect(Collectors.toList()); + return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), tagVos); + }) + .defaultIfEmpty( + new ListResult<>(pageNullSafe(page), sizeNullSafe(size), 0L, List.of())); + } + + @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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/ThemeFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/ThemeFinderImpl.java new file mode 100644 index 0000000..5c463fd --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/ThemeFinderImpl.java @@ -0,0 +1,66 @@ +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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher environmentFetcher; + + public ThemeFinderImpl(ReactiveExtensionClient client, + SystemConfigurableEnvironmentFetcher 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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/CategoryTreeVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/CategoryTreeVo.java new file mode 100644 index 0000000..b429af6 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/CategoryTreeVo.java @@ -0,0 +1,72 @@ +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)" : ""); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/CategoryVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/CategoryVo.java new file mode 100644 index 0000000..9a83cdd --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/CategoryVo.java @@ -0,0 +1,42 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/CommentStatsVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/CommentStatsVo.java new file mode 100644 index 0000000..c8ece19 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/CommentStatsVo.java @@ -0,0 +1,22 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java new file mode 100644 index 0000000..42b2337 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java @@ -0,0 +1,50 @@ +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()); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/CommentWithReplyVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/CommentWithReplyVo.java new file mode 100644 index 0000000..2e9019a --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/CommentWithReplyVo.java @@ -0,0 +1,31 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/ContentVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/ContentVo.java new file mode 100644 index 0000000..addf263 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/ContentVo.java @@ -0,0 +1,32 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/ContributorVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/ContributorVo.java new file mode 100644 index 0000000..06396bc --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/ContributorVo.java @@ -0,0 +1,49 @@ +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.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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/ListedPostVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/ListedPostVo.java new file mode 100644 index 0000000..bf1d576 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/ListedPostVo.java @@ -0,0 +1,59 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/ListedSinglePageVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/ListedSinglePageVo.java new file mode 100644 index 0000000..499a6b1 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/ListedSinglePageVo.java @@ -0,0 +1,53 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/MenuItemVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/MenuItemVo.java new file mode 100644 index 0000000..b2dece1 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/MenuItemVo.java @@ -0,0 +1,62 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/MenuVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/MenuVo.java new file mode 100644 index 0000000..857e5a3 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/MenuVo.java @@ -0,0 +1,59 @@ +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, "└── ", " "); + } + } + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/NavigationPostVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/NavigationPostVo.java new file mode 100644 index 0000000..be927d2 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/NavigationPostVo.java @@ -0,0 +1,38 @@ +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 lombok.Value; + +/** + * Post navigation vo to hold previous and next item. + * + * @author guqing + * @since 2.0.0 + */ +@Value +@Builder +public class NavigationPostVo { + + @Schema(requiredMode = NOT_REQUIRED) + PostVo previous; + + PostVo current; + + @Schema(requiredMode = NOT_REQUIRED) + PostVo next; + + public boolean hasNext() { + return next != null; + } + + public boolean hasPrevious() { + return previous != null; + } + + public static NavigationPostVo empty() { + return NavigationPostVo.builder().build(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/PostArchiveVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/PostArchiveVo.java new file mode 100644 index 0000000..45f953e --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/PostArchiveVo.java @@ -0,0 +1,20 @@ +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; +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/PostArchiveYearMonthVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/PostArchiveYearMonthVo.java new file mode 100644 index 0000000..a145bc0 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/PostArchiveYearMonthVo.java @@ -0,0 +1,20 @@ +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; +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/PostVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/PostVo.java new file mode 100644 index 0000000..40560d4 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/PostVo.java @@ -0,0 +1,62 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/ReplyVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/ReplyVo.java new file mode 100644 index 0000000..4f59d9d --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/ReplyVo.java @@ -0,0 +1,51 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/SinglePageVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/SinglePageVo.java new file mode 100644 index 0000000..b1ed938 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/SinglePageVo.java @@ -0,0 +1,43 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java new file mode 100644 index 0000000..485ee1a --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java @@ -0,0 +1,132 @@ +package run.halo.app.theme.finders.vo; + +import java.net.URL; +import java.util.Map; +import lombok.Builder; +import lombok.Value; +import lombok.With; +import org.springframework.util.Assert; +import run.halo.app.extension.ConfigMap; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.utils.JsonUtils; + +/** + * Site setting value object for theme. + * + * @author guqing + * @since 2.0.0 + */ +@Value +@Builder +public class SiteSettingVo { + + String title; + + @With + URL url; + + String subtitle; + + String logo; + + String favicon; + + Boolean allowRegistration; + + PostSetting post; + + SeoSetting seo; + + CommentSetting comment; + + /** + * Convert to system {@link ConfigMap} to {@link SiteSettingVo}. + * + * @param configMap config map named system + * @return site setting value object + */ + public static SiteSettingVo from(ConfigMap configMap) { + Assert.notNull(configMap, "The configMap must not be null."); + Map data = configMap.getData(); + if (data == null) { + return builder().build(); + } + 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); + + 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.getAllowRegistration()) + .post(PostSetting.builder() + .postPageSize(postSetting.getPostPageSize()) + .archivePageSize(postSetting.getArchivePageSize()) + .categoryPageSize(postSetting.getCategoryPageSize()) + .tagPageSize(postSetting.getTagPageSize()) + .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()) + .build(); + } + + private static T toObject(String json, Class type) { + if (json == null) { + // empty object + json = "{}"; + } + return JsonUtils.jsonToObject(json, type); + } + + @Value + @Builder + public static class PostSetting { + Integer postPageSize; + + Integer archivePageSize; + + Integer categoryPageSize; + + Integer tagPageSize; + } + + @Value + @Builder + public static class SeoSetting { + Boolean blockSpiders; + + String keywords; + + String description; + } + + @Value + @Builder + public static class CommentSetting { + Boolean enable; + + Boolean systemUserOnly; + + Boolean requireReviewForNew; + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/SiteStatsVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/SiteStatsVo.java new file mode 100644 index 0000000..89ddc40 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/SiteStatsVo.java @@ -0,0 +1,35 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/StatsVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/StatsVo.java new file mode 100644 index 0000000..a4a6590 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/StatsVo.java @@ -0,0 +1,29 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/TagVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/TagVo.java new file mode 100644 index 0000000..fc1e4c9 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/TagVo.java @@ -0,0 +1,39 @@ +package run.halo.app.theme.finders.vo; + +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(tag.getStatusOrDefault().getVisiblePostCount()) + .build(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/ThemeVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/ThemeVo.java new file mode 100644 index 0000000..550b920 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/ThemeVo.java @@ -0,0 +1,42 @@ +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(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/UserVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/UserVo.java new file mode 100644 index 0000000..fa60d07 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/UserVo.java @@ -0,0 +1,40 @@ +package run.halo.app.theme.finders.vo; + +import java.util.List; +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())); + statusCopy.setLoginHistories(List.of()); + statusCopy.setLastLoginAt(null); + + User.UserSpec userSpecCopy = JsonUtils.deepCopy(user.getSpec()); + userSpecCopy.setPassword("[PROTECTED]"); + return UserVo.builder() + .metadata(user.getMetadata()) + .spec(userSpecCopy) + .status(statusCopy) + .build(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/VisualizableTreeNode.java b/application/src/main/java/run/halo/app/theme/finders/vo/VisualizableTreeNode.java new file mode 100644 index 0000000..5f9b0eb --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/vo/VisualizableTreeNode.java @@ -0,0 +1,37 @@ +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(); +} diff --git a/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java new file mode 100644 index 0000000..d6943df --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java @@ -0,0 +1,261 @@ +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.text.MessageFormat; +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"; + private static final Object[] EMPTY_MESSAGE_PARAMETERS = new Object[0]; + + @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); + } + + public static Map resolveMessagesForOrigin(final Class origin, + final Locale locale) { + + final Map combinedMessages = new HashMap<>(20); + + Class currentClass = origin; + combinedMessages.putAll(resolveMessagesForSpecificClass(currentClass, locale)); + + while (!currentClass.getSuperclass().equals(Object.class)) { + + currentClass = currentClass.getSuperclass(); + final Map messagesForCurrentClass = + resolveMessagesForSpecificClass(currentClass, locale); + for (final String messageKey : messagesForCurrentClass.keySet()) { + if (!combinedMessages.containsKey(messageKey)) { + combinedMessages.put(messageKey, messagesForCurrentClass.get(messageKey)); + } + } + } + + return Collections.unmodifiableMap(combinedMessages); + + } + + + private static Map resolveMessagesForSpecificClass( + final Class originClass, final Locale locale) { + + + final ClassLoader originClassLoader = originClass.getClassLoader(); + + // 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) { + + final InputStream inputStream = + originClassLoader.getResourceAsStream(messageResourceName); + if (inputStream != null) { + + // At this point we cannot be specified a character encoding (that's only for + // template resolution), + // so we will use the standard character encoding for .properties files, + // which is ISO-8859-1 + // (see Properties#load(InputStream) javadoc). + final InputStreamReader messageResourceReader = + new InputStreamReader(inputStream); + + 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()); + } + + } + + } + + } + + 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; + } + + public static String formatMessage(final Locale locale, final String message, + final Object[] messageParameters) { + if (message == null) { + return null; + } + if (!isFormatCandidate(message)) { + // trying to avoid creating MessageFormat if not needed + return message; + } + final MessageFormat messageFormat = new MessageFormat(message, locale); + return messageFormat.format( + (messageParameters != null ? messageParameters : EMPTY_MESSAGE_PARAMETERS)); + } + + /* + * This will allow us to determine whether a message might actually contain parameter + * placeholders. + */ + private static boolean isFormatCandidate(final String message) { + char c; + int n = message.length(); + while (n-- != 0) { + c = message.charAt(n); + if (c == '}' || c == '\'') { + return true; + } + } + return false; + } +} diff --git a/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java new file mode 100644 index 0000000..6737700 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java @@ -0,0 +1,37 @@ +package run.halo.app.theme.message; + +import java.util.Locale; +import java.util.Map; +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) { + return ThemeMessageResolutionUtils.resolveMessagesForTemplate(locale, theme); + } + + @Override + protected Map resolveMessagesForOrigin(Class origin, Locale locale) { + return ThemeMessageResolutionUtils.resolveMessagesForOrigin(origin, locale); + } + + @Override + protected String formatMessage(Locale locale, String message, Object[] messageParameters) { + return ThemeMessageResolutionUtils.formatMessage(locale, message, messageParameters); + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/DefaultQueryPostPredicateResolver.java b/application/src/main/java/run/halo/app/theme/router/DefaultQueryPostPredicateResolver.java new file mode 100644 index 0000000..d86a393 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/DefaultQueryPostPredicateResolver.java @@ -0,0 +1,73 @@ +package run.halo.app.theme.router; + +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.isNull; +import static run.halo.app.extension.index.query.QueryFactory.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)); + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/ExtensionPermalinkPatternUpdater.java b/application/src/main/java/run/halo/app/theme/router/ExtensionPermalinkPatternUpdater.java new file mode 100644 index 0000000..45d673b --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/ExtensionPermalinkPatternUpdater.java @@ -0,0 +1,84 @@ +package run.halo.app.theme.router; + +import java.util.Map; +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.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.theme.DefaultTemplateEnum; + +/** + * {@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 +public class ExtensionPermalinkPatternUpdater + implements ApplicationListener { + private final ExtensionClient client; + + @Override + public void onApplicationEvent(@NonNull PermalinkRuleChangedEvent event) { + DefaultTemplateEnum template = event.getTemplate(); + log.debug("Refresh permalink for template [{}]", template.getValue()); + String pattern = event.getRule(); + switch (template) { + case POST -> updatePostPermalink(pattern); + case CATEGORY -> updateCategoryPermalink(pattern); + case TAG -> updateTagPermalink(pattern); + default -> { + } + } + } + + private void updatePostPermalink(String pattern) { + log.debug("Update post permalink by new policy [{}]", pattern); + 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)); + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/ModelMapUtils.java b/application/src/main/java/run/halo/app/theme/router/ModelMapUtils.java new file mode 100644 index 0000000..892006c --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/ModelMapUtils.java @@ -0,0 +1,56 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/PermalinkRuleChangedEvent.java b/application/src/main/java/run/halo/app/theme/router/PermalinkRuleChangedEvent.java new file mode 100644 index 0000000..ec6c3e9 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/PermalinkRuleChangedEvent.java @@ -0,0 +1,30 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/PermalinkWatch.java b/application/src/main/java/run/halo/app/theme/router/PermalinkWatch.java new file mode 100644 index 0000000..d1f95ae --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/PermalinkWatch.java @@ -0,0 +1,19 @@ +package run.halo.app.theme.router; + +import run.halo.app.extension.AbstractExtension; + +/** + * Accept permalink change event. + * + * @param extension type + * @author guqing + * @since 2.0.0 + */ +public interface PermalinkWatch { + + void onPermalinkAdd(T extension); + + void onPermalinkUpdate(T extension); + + void onPermalinkDelete(T extension); +} diff --git a/application/src/main/java/run/halo/app/theme/router/PreviewRouterFunction.java b/application/src/main/java/run/halo/app/theme/router/PreviewRouterFunction.java new file mode 100644 index 0000000..eb87d91 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/PreviewRouterFunction.java @@ -0,0 +1,174 @@ +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.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); + 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); + 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)); + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/ReactiveQueryPostPredicateResolver.java b/application/src/main/java/run/halo/app/theme/router/ReactiveQueryPostPredicateResolver.java new file mode 100644 index 0000000..1d571ce --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/ReactiveQueryPostPredicateResolver.java @@ -0,0 +1,19 @@ +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(); +} diff --git a/application/src/main/java/run/halo/app/theme/router/SinglePageRoute.java b/application/src/main/java/run/halo/app/theme/router/SinglePageRoute.java new file mode 100644 index 0000000..d0fc787 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/SinglePageRoute.java @@ -0,0 +1,172 @@ +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")) + ); + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/ThemeCompositeRouterFunction.java b/application/src/main/java/run/halo/app/theme/router/ThemeCompositeRouterFunction.java new file mode 100644 index 0000000..f1b39af --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/ThemeCompositeRouterFunction.java @@ -0,0 +1,137 @@ +package run.halo.app.theme.router; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.event.ApplicationStartedEvent; +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.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +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 + */ +@Component +@RequiredArgsConstructor +public class ThemeCompositeRouterFunction implements RouterFunction { + private final SystemConfigurableEnvironmentFetcher 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(); + + @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)); + } + + List> routerFunctions() { + return transformedPatterns() + .stream() + .map(this::createRouterFunction) + .collect(Collectors.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()); + }; + } + + /** + * Refresh the {@link #cachedRouters} when the permalink rule is changed. + * + * @param event {@link PermalinkRuleChangedEvent} + */ + @EventListener + public void onPermalinkRuleChanged(PermalinkRuleChangedEvent event) { + this.cachedRouters = routerFunctions(); + } + + @EventListener + public void onApplicationStarted(ApplicationStartedEvent event) { + this.cachedRouters = routerFunctions(); + } + + record RoutePattern(DefaultTemplateEnum identifier, String pattern) { + } + + private List transformedPatterns() { + List routePatterns = new ArrayList<>(); + + SystemSetting.ThemeRouteRules rules = + environmentFetcher.fetch(SystemSetting.ThemeRouteRules.GROUP, + SystemSetting.ThemeRouteRules.class) + .blockOptional() + .orElse(SystemSetting.ThemeRouteRules.empty()); + String post = rules.getPost(); + routePatterns.add(new RoutePattern(DefaultTemplateEnum.POST, post)); + + String archives = rules.getArchives(); + routePatterns.add( + new RoutePattern(DefaultTemplateEnum.ARCHIVES, archives)); + + String categories = rules.getCategories(); + routePatterns.add( + new RoutePattern(DefaultTemplateEnum.CATEGORIES, categories)); + routePatterns.add( + new RoutePattern(DefaultTemplateEnum.CATEGORY, categories)); + + String tags = rules.getTags(); + routePatterns.add(new RoutePattern(DefaultTemplateEnum.TAGS, tags)); + routePatterns.add(new RoutePattern(DefaultTemplateEnum.TAG, tags)); + + // Add the index route to the end to prevent conflict with the queryParam rule of the post + routePatterns.add(new RoutePattern(DefaultTemplateEnum.INDEX, "/")); + routePatterns.add(new RoutePattern(DefaultTemplateEnum.AUTHOR, "")); + return routePatterns; + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/TitleVisibilityIdentifyCalculator.java b/application/src/main/java/run/halo/app/theme/router/TitleVisibilityIdentifyCalculator.java new file mode 100644 index 0000000..8124445 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/TitleVisibilityIdentifyCalculator.java @@ -0,0 +1,34 @@ +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; + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/factories/ArchiveRouteFactory.java b/application/src/main/java/run/halo/app/theme/router/factories/ArchiveRouteFactory.java new file mode 100644 index 0000000..a931524 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/factories/ArchiveRouteFactory.java @@ -0,0 +1,123 @@ +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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher 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); + } + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/factories/AuthorPostsRouteFactory.java b/application/src/main/java/run/halo/app/theme/router/factories/AuthorPostsRouteFactory.java new file mode 100644 index 0000000..31fe720 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/factories/AuthorPostsRouteFactory.java @@ -0,0 +1,99 @@ +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 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.extension.ReactiveExtensionClient; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.exception.NotFoundException; +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 SystemConfigurableEnvironmentFetcher 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}")) + .and(accept(MediaType.TEXT_HTML)), handlerFunction()); + } + + HandlerFunction handlerFunction() { + return request -> { + String name = request.pathVariable("name"); + return ServerResponse.ok() + .render(DefaultTemplateEnum.AUTHOR.getValue(), + Map.of("author", getByName(name), + "posts", postList(request, name), + ModelConst.TEMPLATE_ID, DefaultTemplateEnum.AUTHOR.getValue() + ) + ); + }; + } + + private Mono> postList(ServerRequest request, String name) { + String path = request.path(); + int pageNum = pageNumInPathVariable(request); + return configuredPageSize(environmentFetcher, SystemSetting.Post::getPostPageSize) + .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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/factories/CategoriesRouteFactory.java b/application/src/main/java/run/halo/app/theme/router/factories/CategoriesRouteFactory.java new file mode 100644 index 0000000..712f6ca --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/factories/CategoriesRouteFactory.java @@ -0,0 +1,43 @@ +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())); + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/factories/CategoryPostRouteFactory.java b/application/src/main/java/run/halo/app/theme/router/factories/CategoryPostRouteFactory.java new file mode 100644 index 0000000..3bc378c --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/factories/CategoryPostRouteFactory.java @@ -0,0 +1,125 @@ +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.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.index.query.QueryFactory; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +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.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 SystemConfigurableEnvironmentFetcher 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); + 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( + QueryFactory.and( + QueryFactory.equal("spec.slug", slug), + QueryFactory.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() + ); + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/factories/IndexRouteFactory.java b/application/src/main/java/run/halo/app/theme/router/factories/IndexRouteFactory.java new file mode 100644 index 0000000..eecaedb --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/factories/IndexRouteFactory.java @@ -0,0 +1,81 @@ +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 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.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +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 final PostFinder postFinder; + private final SystemConfigurableEnvironmentFetcher environmentFetcher; + private final TitleVisibilityIdentifyCalculator titleVisibilityIdentifyCalculator; + private final LocaleContextResolver localeContextResolver; + + @Override + public RouterFunction create(String pattern) { + return RouterFunctions + .route(GET("/").or(GET("/page/{page}") + .or(GET("/index")).or(GET("/index/page/{page}")) + .and(accept(MediaType.TEXT_HTML))), handlerFunction()); + } + + HandlerFunction handlerFunction() { + return request -> ServerResponse.ok() + .render(DefaultTemplateEnum.INDEX.getValue(), + Map.of("posts", postList(request), + 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() + ); + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/factories/PostRouteFactory.java b/application/src/main/java/run/halo/app/theme/router/factories/PostRouteFactory.java new file mode 100644 index 0000000..fd7b4c2 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/factories/PostRouteFactory.java @@ -0,0 +1,258 @@ +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 com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.regex.Matcher; +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.core.extension.content.Post; +import run.halo.app.extension.MetadataUtil; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.index.query.QueryFactory; +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; + + @Override + public RouterFunction create(String pattern) { + PatternParser 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); + }; + } + + @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)); + }) + .next() + .flatMap(post -> postFinder.getByName(post.getMetadata().getName())) + .switchIfEmpty(Mono.error(new NotFoundException("Post not found"))); + } + + 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 = QueryFactory.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; + + 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 static final Cache MATCHER_CACHE = CacheBuilder.newBuilder() + .maximumSize(5) + .build(); + + private final String pattern; + private String paramName; + private String placeholderName; + private final boolean isQueryParamPattern; + + PatternParser(String pattern) { + this.pattern = pattern; + Matcher matcher = patternToMatcher(pattern); + if (matcher.find()) { + this.paramName = matcher.group(1); + this.placeholderName = matcher.group(2); + this.isQueryParamPattern = true; + } else { + this.isQueryParamPattern = false; + } + } + + Matcher patternToMatcher(String pattern) { + try { + return MATCHER_CACHE.get(pattern, () -> PATTERN_COMPILE.matcher(pattern)); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + } + + RequestPredicate toRequestPredicate() { + if (!this.isQueryParamPattern) { + throw new IllegalStateException("Not a query param pattern: " + pattern); + } + + return RequestPredicates.queryParam(paramName, value -> true); + } + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/factories/RouteFactory.java b/application/src/main/java/run/halo/app/theme/router/factories/RouteFactory.java new file mode 100644 index 0000000..3c89535 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/factories/RouteFactory.java @@ -0,0 +1,33 @@ +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.SystemConfigurableEnvironmentFetcher; +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( + SystemConfigurableEnvironmentFetcher 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); + } +} diff --git a/application/src/main/java/run/halo/app/theme/router/factories/TagPostRouteFactory.java b/application/src/main/java/run/halo/app/theme/router/factories/TagPostRouteFactory.java new file mode 100644 index 0000000..7c92eb0 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/factories/TagPostRouteFactory.java @@ -0,0 +1,116 @@ +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 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.index.query.QueryFactory; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +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.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 SystemConfigurableEnvironmentFetcher 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() + ) + ) + )); + return ServerResponse.ok() + .render(DefaultTemplateEnum.TAG.getValue(), + Map.of("name", tagVo.getMetadata().getName(), + "posts", postList, + "tag", tagVo) + ); + }); + } + + 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( + QueryFactory.and(QueryFactory.equal("spec.slug", slug), + QueryFactory.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))); + } + +} diff --git a/application/src/main/java/run/halo/app/theme/router/factories/TagsRouteFactory.java b/application/src/main/java/run/halo/app/theme/router/factories/TagsRouteFactory.java new file mode 100644 index 0000000..bdd4015 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/router/factories/TagsRouteFactory.java @@ -0,0 +1,47 @@ +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() + ) + ); + } +} diff --git a/application/src/main/java/run/halo/app/webfilter/AdditionalWebFilterChainProxy.java b/application/src/main/java/run/halo/app/webfilter/AdditionalWebFilterChainProxy.java new file mode 100644 index 0000000..7a8ce77 --- /dev/null +++ b/application/src/main/java/run/halo/app/webfilter/AdditionalWebFilterChainProxy.java @@ -0,0 +1,35 @@ +package run.halo.app.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)); + } + +} diff --git a/application/src/main/resources/META-INF/spring-devtools.properties b/application/src/main/resources/META-INF/spring-devtools.properties new file mode 100644 index 0000000..53afa99 --- /dev/null +++ b/application/src/main/resources/META-INF/spring-devtools.properties @@ -0,0 +1,2 @@ +# 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 diff --git a/application/src/main/resources/META-INF/spring.factories b/application/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..e63b880 --- /dev/null +++ b/application/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.context.ApplicationListener=run.halo.app.infra.SchemeInitializer diff --git a/application/src/main/resources/application-dev.yaml b/application/src/main/resources/application-dev.yaml new file mode 100644 index 0000000..4475d96 --- /dev/null +++ b/application/src/main/resources/application-dev.yaml @@ -0,0 +1,47 @@ +server: + port: 8090 + +spring: + output: + ansi: + enabled: always + thymeleaf: + cache: false + web: + resources: + cache: + cachecontrol: + no-cache: true + use-last-modified: false + +halo: + console: + proxy: + endpoint: http://localhost:3000/ + enabled: true + uc: + proxy: + endpoint: http://localhost:4000/ + enabled: true + plugin: + runtime-mode: development # development, deployment + work-dir: ${user.home}/halo2-dev +logging: + level: + run.halo.app: 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: "*" diff --git a/application/src/main/resources/application-doc.yaml b/application/src/main/resources/application-doc.yaml new file mode 100644 index 0000000..af0af75 --- /dev/null +++ b/application/src/main/resources/application-doc.yaml @@ -0,0 +1,17 @@ +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 diff --git a/application/src/main/resources/application-mariadb.yaml b/application/src/main/resources/application-mariadb.yaml new file mode 100644 index 0000000..e29c94d --- /dev/null +++ b/application/src/main/resources/application-mariadb.yaml @@ -0,0 +1,9 @@ +spring: + r2dbc: + url: r2dbc:pool:mariadb://localhost:3306/halo + username: root + password: mariadb + sql: + init: + mode: always + platform: mysql diff --git a/application/src/main/resources/application-mysql.yaml b/application/src/main/resources/application-mysql.yaml new file mode 100644 index 0000000..fe3846c --- /dev/null +++ b/application/src/main/resources/application-mysql.yaml @@ -0,0 +1,9 @@ +spring: + r2dbc: + url: r2dbc:pool:mysql://localhost:3306/halo + username: root + password: openmysql + sql: + init: + mode: always + platform: mysql diff --git a/application/src/main/resources/application-postgresql.yaml b/application/src/main/resources/application-postgresql.yaml new file mode 100644 index 0000000..d519c62 --- /dev/null +++ b/application/src/main/resources/application-postgresql.yaml @@ -0,0 +1,9 @@ +spring: + r2dbc: + url: r2dbc:pool:postgresql://localhost:5432/halo + username: postgres + password: openpostgresql + sql: + init: + mode: always + platform: postgresql diff --git a/application/src/main/resources/application-win.yaml b/application/src/main/resources/application-win.yaml new file mode 100644 index 0000000..5c8b123 --- /dev/null +++ b/application/src/main/resources/application-win.yaml @@ -0,0 +1,5 @@ +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 \ No newline at end of file diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml new file mode 100644 index 0000000..a24b43d --- /dev/null +++ b/application/src/main/resources/application.yaml @@ -0,0 +1,99 @@ +server: + port: 8090 + forward-headers-strategy: framework + compression: + enabled: true + error: + whitelabel: + enabled: false +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 + codec: + max-in-memory-size: 10MB + messages: + basename: config.i18n.messages + web: + resources: + cache: + cachecontrol: + max-age: 365d + cache: + type: caffeine + caffeine: + spec: expireAfterAccess=1h, maximumSize=10000 + +halo: + work-dir: ${user.home}/.halo2 + attachment: + resource-mappings: + - pathPattern: /upload/** + locations: + - migrate-from-1.x + +springdoc: + api-docs: + enabled: false + writer-with-order-by-keys: true + +logging: + 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: + enabled: true + health: + 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-reset-password-email: + limitForPeriod: 2 + limitRefreshPeriod: 1m + timeoutDuration: 0s diff --git a/application/src/main/resources/banner.txt b/application/src/main/resources/banner.txt new file mode 100644 index 0000000..be58cc7 --- /dev/null +++ b/application/src/main/resources/banner.txt @@ -0,0 +1,8 @@ +${AnsiColor.BLUE} + __ __ __ + / / / /___ _/ /___ + / /_/ / __ `/ / __ \ + / __ / /_/ / / /_/ / +/_/ /_/\__,_/_/\____/ +${AnsiColor.BRIGHT_YELLOW} +Version: ${application.version}${AnsiColor.DEFAULT} \ No newline at end of file diff --git a/application/src/main/resources/config/i18n/messages.properties b/application/src/main/resources/config/i18n/messages.properties new file mode 100644 index 0000000..8d1bf19 --- /dev/null +++ b/application/src/main/resources/config/i18n/messages.properties @@ -0,0 +1,83 @@ +# 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.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.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.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.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. + +title.visibility.identification.private=(Private) \ No newline at end of file diff --git a/application/src/main/resources/config/i18n/messages_zh.properties b/application/src/main/resources/config/i18n/messages_zh.properties new file mode 100644 index 0000000..818f7e3 --- /dev/null +++ b/application/src/main/resources/config/i18n/messages_zh.properties @@ -0,0 +1,55 @@ +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.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.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.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.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} 类型的文件。 + +title.visibility.identification.private=(私有) \ No newline at end of file diff --git a/application/src/main/resources/extensions/attachment-local-policy.yaml b/application/src/main/resources/extensions/attachment-local-policy.yaml new file mode 100644 index 0000000..c4a331c --- /dev/null +++ b/application/src/main/resources/extensions/attachment-local-policy.yaml @@ -0,0 +1,75 @@ +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 +spec: + displayName: 本地存储 + templateName: local + configMapName: default-policy-config +--- +apiVersion: v1alpha1 +kind: ConfigMap +metadata: + name: default-policy-config +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 +--- +apiVersion: storage.halo.run/v1alpha1 +kind: Group +metadata: + name: user-avatar-group + labels: + halo.run/hidden: "true" + finalizers: + - system-protection +spec: + displayName: UserAvatar \ No newline at end of file diff --git a/application/src/main/resources/extensions/authproviders.yaml b/application/src/main/resources/extensions/authproviders.yaml new file mode 100644 index 0000000..b8b4178 --- /dev/null +++ b/application/src/main/resources/extensions/authproviders.yaml @@ -0,0 +1,16 @@ +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 + enabled: true + description: Built-in authentication for Halo. + logo: https://halo.run/logo + website: https://halo.run + authenticationUrl: /login diff --git a/application/src/main/resources/extensions/extension-definitions.yaml b/application/src/main/resources/extensions/extension-definitions.yaml new file mode 100644 index 0000000..b654fd1 --- /dev/null +++ b/application/src/main/resources/extensions/extension-definitions.yaml @@ -0,0 +1,49 @@ +## 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 diff --git a/application/src/main/resources/extensions/extensionpoint-definitions.yaml b/application/src/main/resources/extensions/extensionpoint-definitions.yaml new file mode 100644 index 0000000..2e09811 --- /dev/null +++ b/application/src/main/resources/extensions/extensionpoint-definitions.yaml @@ -0,0 +1,99 @@ +## 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 生成。" diff --git a/application/src/main/resources/extensions/notification-templates.yaml b/application/src/main/resources/extensions/notification-templates.yaml new file mode 100644 index 0000000..6290a22 --- /dev/null +++ b/application/src/main/resources/extensions/notification-templates.yaml @@ -0,0 +1,189 @@ +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: | +
+
+

+
+
+

+
+

+

+
+

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

+

+
+
diff --git a/application/src/main/resources/extensions/notification.yaml b/application/src/main/resources/extensions/notification.yaml new file mode 100644 index 0000000..f955c40 --- /dev/null +++ b/application/src/main/resources/extensions/notification.yaml @@ -0,0 +1,231 @@ +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/hide: "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/hide: "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." \ No newline at end of file diff --git a/application/src/main/resources/extensions/role-template-actuator.yaml b/application/src/main/resources/extensions/role-template-actuator.yaml new file mode 100644 index 0000000..7d3ecb4 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-actuator.yaml @@ -0,0 +1,14 @@ +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" ] diff --git a/application/src/main/resources/extensions/role-template-anonymous.yaml b/application/src/main/resources/extensions/role-template-anonymous.yaml new file mode 100644 index 0000000..06e23a7 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-anonymous.yaml @@ -0,0 +1,48 @@ +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" ] + - nonResourceURLs: [ "/apis/api.console.halo.run/v1alpha1/system/initialize" ] + verbs: [ "create" ] +--- +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" ] \ No newline at end of file diff --git a/application/src/main/resources/extensions/role-template-attachment.yaml b/application/src/main/resources/extensions/role-template-attachment.yaml new file mode 100644 index 0000000..8bc9967 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-attachment.yaml @@ -0,0 +1,43 @@ +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: [ "" ] + resources: [ "settings" ] + verbs: [ "get" ] + - nonResourceURLs: [ "/apis/api.console.halo.run/v1alpha1/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" ] \ No newline at end of file diff --git a/application/src/main/resources/extensions/role-template-authenticated.yaml b/application/src/main/resources/extensions/role-template-authenticated.yaml new file mode 100644 index 0000000..a5d9200 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-authenticated.yaml @@ -0,0 +1,155 @@ +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" ] +--- +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" ] diff --git a/application/src/main/resources/extensions/role-template-cache.yaml b/application/src/main/resources/extensions/role-template-cache.yaml new file mode 100644 index 0000000..d2faf04 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-cache.yaml @@ -0,0 +1,16 @@ +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"] diff --git a/application/src/main/resources/extensions/role-template-category.yaml b/application/src/main/resources/extensions/role-template-category.yaml new file mode 100644 index 0000000..d40cef5 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-category.yaml @@ -0,0 +1,30 @@ +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" ] diff --git a/application/src/main/resources/extensions/role-template-comment.yaml b/application/src/main/resources/extensions/role-template-comment.yaml new file mode 100644 index 0000000..36f335b --- /dev/null +++ b/application/src/main/resources/extensions/role-template-comment.yaml @@ -0,0 +1,38 @@ +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" ] diff --git a/application/src/main/resources/extensions/role-template-configmap.yaml b/application/src/main/resources/extensions/role-template-configmap.yaml new file mode 100644 index 0000000..889f40b --- /dev/null +++ b/application/src/main/resources/extensions/role-template-configmap.yaml @@ -0,0 +1,32 @@ +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" ] diff --git a/application/src/main/resources/extensions/role-template-menu.yaml b/application/src/main/resources/extensions/role-template-menu.yaml new file mode 100644 index 0000000..24a4d71 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-menu.yaml @@ -0,0 +1,32 @@ +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: [ "*" ] +--- +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" ] diff --git a/application/src/main/resources/extensions/role-template-migration.yaml b/application/src/main/resources/extensions/role-template-migration.yaml new file mode 100644 index 0000000..6edc1c8 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-migration.yaml @@ -0,0 +1,24 @@ +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" ] diff --git a/application/src/main/resources/extensions/role-template-notification.yaml b/application/src/main/resources/extensions/role-template-notification.yaml new file mode 100644 index 0000000..fdce8c3 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-notification.yaml @@ -0,0 +1,21 @@ +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" ] diff --git a/application/src/main/resources/extensions/role-template-permissions.yaml b/application/src/main/resources/extensions/role-template-permissions.yaml new file mode 100644 index 0000000..9fb86e3 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-permissions.yaml @@ -0,0 +1,32 @@ +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" ] diff --git a/application/src/main/resources/extensions/role-template-plugin.yaml b/application/src/main/resources/extensions/role-template-plugin.yaml new file mode 100644 index 0000000..82ff040 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-plugin.yaml @@ -0,0 +1,45 @@ +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/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" ] + verbs: [ "get", "list" ] diff --git a/application/src/main/resources/extensions/role-template-post.yaml b/application/src/main/resources/extensions/role-template-post.yaml new file mode 100644 index 0000000..d48246b --- /dev/null +++ b/application/src/main/resources/extensions/role-template-post.yaml @@ -0,0 +1,41 @@ +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" ] diff --git a/application/src/main/resources/extensions/role-template-role.yaml b/application/src/main/resources/extensions/role-template-role.yaml new file mode 100644 index 0000000..c05dad2 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-role.yaml @@ -0,0 +1,33 @@ +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" ] diff --git a/application/src/main/resources/extensions/role-template-setting.yaml b/application/src/main/resources/extensions/role-template-setting.yaml new file mode 100644 index 0000000..7507ee6 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-setting.yaml @@ -0,0 +1,38 @@ +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" ] \ No newline at end of file diff --git a/application/src/main/resources/extensions/role-template-singlepage.yaml b/application/src/main/resources/extensions/role-template-singlepage.yaml new file mode 100644 index 0000000..8dd5b1c --- /dev/null +++ b/application/src/main/resources/extensions/role-template-singlepage.yaml @@ -0,0 +1,39 @@ +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" ] diff --git a/application/src/main/resources/extensions/role-template-snapshot.yaml b/application/src/main/resources/extensions/role-template-snapshot.yaml new file mode 100644 index 0000000..bbc4939 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-snapshot.yaml @@ -0,0 +1,25 @@ +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" ] diff --git a/application/src/main/resources/extensions/role-template-tag.yaml b/application/src/main/resources/extensions/role-template-tag.yaml new file mode 100644 index 0000000..868812e --- /dev/null +++ b/application/src/main/resources/extensions/role-template-tag.yaml @@ -0,0 +1,28 @@ +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" ] diff --git a/application/src/main/resources/extensions/role-template-theme.yaml b/application/src/main/resources/extensions/role-template-theme.yaml new file mode 100644 index 0000000..6b6c375 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-theme.yaml @@ -0,0 +1,42 @@ +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/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" ] + verbs: [ "get", "list" ] + diff --git a/application/src/main/resources/extensions/role-template-uc-content.yaml b/application/src/main/resources/extensions/role-template-uc-content.yaml new file mode 100644 index 0000000..a8f6072 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-uc-content.yaml @@ -0,0 +1,116 @@ +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-post-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" ] + +--- +apiVersion: v1alpha1 +kind: Role +metadata: + name: role-template-post-attachment-manager + labels: + halo.run/role-template: "true" + annotations: + rbac.authorization.halo.run/module: "Posts Management" + rbac.authorization.halo.run/display-name: "Post Attachment Manager" + rbac.authorization.halo.run/ui-permissions: | + [ "uc:attachments:manage" ] +rules: + - apiGroups: [ "uc.api.content.halo.run" ] + resources: [ "attachments" ] + verbs: [ "create", "update", "delete" ] diff --git a/application/src/main/resources/extensions/role-template-user.yaml b/application/src/main/resources/extensions/role-template-user.yaml new file mode 100644 index 0000000..ebd69d1 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-user.yaml @@ -0,0 +1,54 @@ +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" ] diff --git a/application/src/main/resources/extensions/searchengine-lucene.yaml b/application/src/main/resources/extensions/searchengine-lucene.yaml new file mode 100644 index 0000000..f1f729c --- /dev/null +++ b/application/src/main/resources/extensions/searchengine-lucene.yaml @@ -0,0 +1,11 @@ +apiVersion: plugin.halo.run/v1alpha1 +kind: SearchEngine +metadata: + name: lucene + deletionTimestamp: 2024-06-25T00:00:00Z +spec: + logo: https://lucene.apache.org/theme/images/lucene/lucene_logo_green_300.png + website: https://lucene.apache.org/ + displayName: Lucene + description: Apache Lucene is a high-performance, full-featured search engine library written entirely in Java. It is a technology suitable for nearly any application that requires structured search, full-text search, faceting, nearest-neighbor search across high-dimensionality vectors, spell correction or query suggestions. + postSearchImpl: run.halo.app.search.post.LucenePostSearchService diff --git a/application/src/main/resources/extensions/system-configurable-configmap.yaml b/application/src/main/resources/extensions/system-configurable-configmap.yaml new file mode 100644 index 0000000..5d22168 --- /dev/null +++ b/application/src/main/resources/extensions/system-configurable-configmap.yaml @@ -0,0 +1,52 @@ +apiVersion: v1alpha1 +kind: "ConfigMap" +metadata: + name: system-default +data: + user: | + { + "allowRegistration": false, + "mustVerifyEmailOnRegistration": false, + "defaultRole": "guest", + "avatarPolicy": "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, + "slugGenerationStrategy": "generateByTitle", + "attachmentPolicyName": "default-policy" + } + comment: | + { + "enable": true, + "requireReviewForNew": true, + "systemUserOnly": true + } + menu: | + { + "primary": "primary" + } + extensionPointEnabled: | + { + "search-engine": ["search-engine-lucene"] + } diff --git a/application/src/main/resources/extensions/system-default-role.yaml b/application/src/main/resources/extensions/system-default-role.yaml new file mode 100644 index 0000000..dee595d --- /dev/null +++ b/application/src/main/resources/extensions/system-default-role.yaml @@ -0,0 +1,28 @@ +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: ["*"] diff --git a/application/src/main/resources/extensions/system-setting.yaml b/application/src/main/resources/extensions/system-setting.yaml new file mode 100644 index 0000000..eaf4414 --- /dev/null +++ b/application/src/main/resources/extensions/system-setting.yaml @@ -0,0 +1,195 @@ +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 + accepts: + - 'image/*' + - $formkit: attachment + label: Favicon + name: favicon + accepts: + - 'image/*' + - 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: 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 + - $formkit: textarea + name: keywords + label: "站点关键词" + - $formkit: textarea + name: description + label: "站点描述" + - 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: roleSelect + name: defaultRole + label: "默认角色" + validation: 'required' + if: "$get(allowRegistration).value === true" + help: 用户注册之后默认为用户分配的角色 + - $formkit: attachmentPolicySelect + name: avatarPolicy + label: "头像存储位置" + value: "default-policy" + help: 指定用户上传头像的存储策略 + - 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: 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}' + 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: "注入代码到所有页面的页脚部分" diff --git a/application/src/main/resources/extensions/user.yaml b/application/src/main/resources/extensions/user.yaml new file mode 100644 index 0000000..8658a99 --- /dev/null +++ b/application/src/main/resources/extensions/user.yaml @@ -0,0 +1,28 @@ +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: 该用户已被删除。 + diff --git a/application/src/main/resources/schema-h2.sql b/application/src/main/resources/schema-h2.sql new file mode 100644 index 0000000..d16ef18 --- /dev/null +++ b/application/src/main/resources/schema-h2.sql @@ -0,0 +1,7 @@ +create table if not exists extensions +( + name varchar(255) not null, + data blob, + version bigint, + primary key (name) +); diff --git a/application/src/main/resources/schema-mariadb.sql b/application/src/main/resources/schema-mariadb.sql new file mode 100644 index 0000000..370cb7c --- /dev/null +++ b/application/src/main/resources/schema-mariadb.sql @@ -0,0 +1,7 @@ +create table if not exists extensions +( + name varchar(255) not null, + data longblob, + version bigint, + primary key (name) +); diff --git a/application/src/main/resources/schema-mysql.sql b/application/src/main/resources/schema-mysql.sql new file mode 100644 index 0000000..370cb7c --- /dev/null +++ b/application/src/main/resources/schema-mysql.sql @@ -0,0 +1,7 @@ +create table if not exists extensions +( + name varchar(255) not null, + data longblob, + version bigint, + primary key (name) +); diff --git a/application/src/main/resources/schema-postgresql.sql b/application/src/main/resources/schema-postgresql.sql new file mode 100644 index 0000000..96c46af --- /dev/null +++ b/application/src/main/resources/schema-postgresql.sql @@ -0,0 +1,7 @@ +create table if not exists extensions +( + name varchar(255) not null, + data bytea, + version bigint, + primary key (name) +); diff --git a/application/src/main/resources/static/halo-tracker.js b/application/src/main/resources/static/halo-tracker.js new file mode 100644 index 0000000..c919bb0 --- /dev/null +++ b/application/src/main/resources/static/halo-tracker.js @@ -0,0 +1 @@ +!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)}(); diff --git a/application/src/main/resources/static/images/extension-points/lucene.png b/application/src/main/resources/static/images/extension-points/lucene.png new file mode 100644 index 0000000..718236e Binary files /dev/null and b/application/src/main/resources/static/images/extension-points/lucene.png differ diff --git a/application/src/main/resources/templates/error/error.html b/application/src/main/resources/templates/error/error.html new file mode 100644 index 0000000..e02aee9 --- /dev/null +++ b/application/src/main/resources/templates/error/error.html @@ -0,0 +1,130 @@ + + + + + + + + + + +
+

+

+

+
+ +
+
+ + \ No newline at end of file diff --git a/application/src/main/resources/themes/theme-earth.zip b/application/src/main/resources/themes/theme-earth.zip new file mode 100644 index 0000000..8a1cd4f Binary files /dev/null and b/application/src/main/resources/themes/theme-earth.zip differ diff --git a/application/src/test/java/run/halo/app/ApplicationTests.java b/application/src/test/java/run/halo/app/ApplicationTests.java new file mode 100644 index 0000000..5de0711 --- /dev/null +++ b/application/src/test/java/run/halo/app/ApplicationTests.java @@ -0,0 +1,13 @@ +package run.halo.app; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/application/src/test/java/run/halo/app/PathPrefixPredicateTest.java b/application/src/test/java/run/halo/app/PathPrefixPredicateTest.java new file mode 100644 index 0000000..1447b4d --- /dev/null +++ b/application/src/test/java/run/halo/app/PathPrefixPredicateTest.java @@ -0,0 +1,37 @@ +package run.halo.app; + +import static org.assertj.core.api.Assertions.assertThat; + +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 { + + } + +} diff --git a/application/src/test/java/run/halo/app/XForwardHeaderTest.java b/application/src/test/java/run/halo/app/XForwardHeaderTest.java new file mode 100644 index 0000000..e0e15e9 --- /dev/null +++ b/application/src/test/java/run/halo/app/XForwardHeaderTest.java @@ -0,0 +1,55 @@ +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=framework") +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); + }); + } + } +} diff --git a/application/src/test/java/run/halo/app/config/CorsTest.java b/application/src/test/java/run/halo/app/config/CorsTest.java new file mode 100644 index 0000000..efc6701 --- /dev/null +++ b/application/src/test/java/run/halo/app/config/CorsTest.java @@ -0,0 +1,93 @@ +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.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +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); + } + } + +} diff --git a/application/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java b/application/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java new file mode 100644 index 0000000..ee0ad61 --- /dev/null +++ b/application/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java @@ -0,0 +1,268 @@ +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.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.annotation.DirtiesContext; +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.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.index.IndexerFactory; +import run.halo.app.extension.store.ExtensionStoreRepository; + +@DirtiesContext +@SpringBootTest +@AutoConfigureWebTestClient +class ExtensionConfigurationTest { + + @Autowired + WebTestClient webClient; + + @Autowired + SchemeManager schemeManager; + + @MockBean + 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, + @Autowired IndexerFactory indexerFactory) { + var gvk = Scheme.buildFromType(FakeExtension.class).groupVersionKind(); + if (indexerFactory.contains(gvk)) { + indexerFactory.getIndexer(gvk).removeIndexRecords(descriptor -> true); + } + 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(); + } + + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/config/HaloConfigurationTest.java b/application/src/test/java/run/halo/app/config/HaloConfigurationTest.java new file mode 100644 index 0000000..56818a0 --- /dev/null +++ b/application/src/test/java/run/halo/app/config/HaloConfigurationTest.java @@ -0,0 +1,44 @@ +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); + } + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/config/SecurityConfigTest.java b/application/src/test/java/run/halo/app/config/SecurityConfigTest.java new file mode 100644 index 0000000..2c7257c --- /dev/null +++ b/application/src/test/java/run/halo/app/config/SecurityConfigTest.java @@ -0,0 +1,39 @@ +package run.halo.app.config; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.springframework.security.web.server.header.StrictTransportSecurityServerHttpHeadersWriter.STRICT_TRANSPORT_SECURITY; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +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"))); + } + +} diff --git a/application/src/test/java/run/halo/app/config/ServerCodecTest.java b/application/src/test/java/run/halo/app/config/ServerCodecTest.java new file mode 100644 index 0000000..202fe6b --- /dev/null +++ b/application/src/test/java/run/halo/app/config/ServerCodecTest.java @@ -0,0 +1,94 @@ +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.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +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(); + } + } +} diff --git a/application/src/test/java/run/halo/app/config/WebFluxConfigTest.java b/application/src/test/java/run/halo/app/config/WebFluxConfigTest.java new file mode 100644 index 0000000..e60e287 --- /dev/null +++ b/application/src/test/java/run/halo/app/config/WebFluxConfigTest.java @@ -0,0 +1,157 @@ +package run.halo.app.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.reactive.server.WebTestClient; +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.test.StepVerifier; +import run.halo.app.core.endpoint.WebSocketEndpoint; +import run.halo.app.core.extension.Role; +import run.halo.app.core.extension.service.RoleService; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.Metadata; + +@SpringBootTest(properties = "halo.console.location=classpath:/console/", webEnvironment = + SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(WebFluxConfigTest.WebSocketSupportTest.TestWebSocketConfiguration.class) +@AutoConfigureWebTestClient +class WebFluxConfigTest { + + @Autowired + WebTestClient webClient; + + @SpyBean + 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 ConsoleRequest { + + @Test + void shouldRequestConsoleIndex() { + List.of( + "/console", + "/console/index", + "/console/index.html", + "/console/dashboard", + "/console/fake" + ) + .forEach(uri -> webClient.get().uri(uri) + .exchange() + .expectStatus().isOk() + .expectBody(String.class).value(StringStartsWith.startsWith("console index")) + ); + } + + @Test + void shouldRequestConsoleAssetsCorrectly() { + webClient.get().uri("/console/assets/fake.txt") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).value(StringStartsWith.startsWith("fake.")); + } + + @Test + void shouldResponseNotFoundWhenAssetsNotExist() { + webClient.get().uri("/console/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(); + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/console/WebSocketServerWebExchangeMatcherTest.java b/application/src/test/java/run/halo/app/console/WebSocketServerWebExchangeMatcherTest.java new file mode 100644 index 0000000..cf2944b --- /dev/null +++ b/application/src/test/java/run/halo/app/console/WebSocketServerWebExchangeMatcherTest.java @@ -0,0 +1,39 @@ +package run.halo.app.console; + +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; + +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(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/console/WebSocketUtilsTest.java b/application/src/test/java/run/halo/app/console/WebSocketUtilsTest.java new file mode 100644 index 0000000..aac0fd1 --- /dev/null +++ b/application/src/test/java/run/halo/app/console/WebSocketUtilsTest.java @@ -0,0 +1,51 @@ +package run.halo.app.console; + +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; + +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)); + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/content/CategoryPostCountUpdaterTest.java b/application/src/test/java/run/halo/app/content/CategoryPostCountUpdaterTest.java new file mode 100644 index 0000000..8b0fab5 --- /dev/null +++ b/application/src/test/java/run/halo/app/content/CategoryPostCountUpdaterTest.java @@ -0,0 +1,227 @@ +package run.halo.app.content; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +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.Nested; +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.boot.test.mock.mockito.SpyBean; +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.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.ExtensionStoreUtil; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.SchemeManager; +import run.halo.app.extension.index.IndexerFactory; +import run.halo.app.extension.store.ReactiveExtensionStoreClient; + +/** + * Tests for {@link CategoryPostCountUpdater}. + * + * @author guqing + * @since 2.15.0 + */ +class CategoryPostCountUpdaterTest { + + @Nested + @DirtiesContext + @SpringBootTest + class CategoryPostCountServiceIntegrationTest { + private final List storedPosts = posts(); + private final List storedCategories = categories(); + + @Autowired + private SchemeManager schemeManager; + + @SpyBean + private ExtensionClient client; + + @Autowired + private ReactiveExtensionClient reactiveClient; + + @Autowired + private ReactiveExtensionStoreClient storeClient; + + @Autowired + private IndexerFactory indexerFactory; + + private CategoryPostCountUpdater.CategoryPostCountService categoryPostCountService; + + Mono deleteImmediately(Extension extension) { + var name = extension.getMetadata().getName(); + var scheme = schemeManager.get(extension.getClass()); + // un-index + var indexer = indexerFactory.getIndexer(extension.groupVersionKind()); + indexer.unIndexRecord(extension.getMetadata().getName()); + + // delete from db + var storeName = ExtensionStoreUtil.buildStoreName(scheme, name); + return storeClient.delete(storeName, extension.getMetadata().getVersion()) + .thenReturn(extension); + } + + @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; + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/content/ContentRequestTest.java b/application/src/test/java/run/halo/app/content/ContentRequestTest.java new file mode 100644 index 0000000..3130735 --- /dev/null +++ b/application/src/test/java/run/halo/app/content/ContentRequestTest.java @@ -0,0 +1,127 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/content/PostIntegrationTests.java b/application/src/test/java/run/halo/app/content/PostIntegrationTests.java new file mode 100644 index 0000000..629f3bb --- /dev/null +++ b/application/src/test/java/run/halo/app/content/PostIntegrationTests.java @@ -0,0 +1,144 @@ +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.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +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.extension.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 +@AutoConfigureTestDatabase +@WithMockUser(username = "fake-user", password = "fake-password", roles = "fake-super-role") +public class PostIntegrationTests { + + @Autowired + private WebTestClient webTestClient; + + @MockBean + 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); + } +} diff --git a/application/src/test/java/run/halo/app/content/TestPost.java b/application/src/test/java/run/halo/app/content/TestPost.java new file mode 100644 index 0000000..0dfeadc --- /dev/null +++ b/application/src/test/java/run/halo/app/content/TestPost.java @@ -0,0 +1,92 @@ +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(); + } +} diff --git a/application/src/test/java/run/halo/app/content/comment/CommentEmailOwnerTest.java b/application/src/test/java/run/halo/app/content/comment/CommentEmailOwnerTest.java new file mode 100644 index 0000000..5d61364 --- /dev/null +++ b/application/src/test/java/run/halo/app/content/comment/CommentEmailOwnerTest.java @@ -0,0 +1,52 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/content/comment/CommentNotificationReasonPublisherTest.java b/application/src/test/java/run/halo/app/content/comment/CommentNotificationReasonPublisherTest.java new file mode 100644 index 0000000..44ae342 --- /dev/null +++ b/application/src/test/java/run/halo/app/content/comment/CommentNotificationReasonPublisherTest.java @@ -0,0 +1,470 @@ +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.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 + class NewCommentOnPostReasonPublisherTest { + @Mock + ExtensionClient client; + + @Mock + NotificationReasonEmitter emitter; + + @Mock + ExtensionGetter extensionGetter; + + @Mock + ExternalLinkProcessor externalLinkProcessor; + + @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(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 + class NewCommentOnPageReasonPublisherTest { + @Mock + ExtensionClient client; + + @Mock + NotificationReasonEmitter emitter; + + @Mock + ExternalLinkProcessor externalLinkProcessor; + + @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(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 + class NewReplyReasonPublisherTest { + + @Mock + ExtensionClient client; + + @Mock + NotificationReasonEmitter notificationReasonEmitter; + + @Mock + ExtensionGetter extensionGetter; + + @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()); + 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; + } +} diff --git a/application/src/test/java/run/halo/app/content/comment/CommentRequestTest.java b/application/src/test/java/run/halo/app/content/comment/CommentRequestTest.java new file mode 100644 index 0000000..5312b6f --- /dev/null +++ b/application/src/test/java/run/halo/app/content/comment/CommentRequestTest.java @@ -0,0 +1,86 @@ +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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/content/comment/CommentServiceImplIntegrationTest.java b/application/src/test/java/run/halo/app/content/comment/CommentServiceImplIntegrationTest.java new file mode 100644 index 0000000..0b16005 --- /dev/null +++ b/application/src/test/java/run/halo/app/content/comment/CommentServiceImplIntegrationTest.java @@ -0,0 +1,159 @@ +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.boot.test.mock.mockito.SpyBean; +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.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.index.IndexerFactory; +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; + + @SpyBean + private ReactiveExtensionClient reactiveClient; + + @Autowired + private ReactiveExtensionStoreClient storeClient; + + @Autowired + private IndexerFactory indexerFactory; + + @SpyBean + private CommentServiceImpl commentService; + + Mono deleteImmediately(Extension extension) { + var name = extension.getMetadata().getName(); + var scheme = schemeManager.get(extension.getClass()); + // un-index + var indexer = indexerFactory.getIndexer(extension.groupVersionKind()); + indexer.unIndexRecord(extension.getMetadata().getName()); + + // 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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java b/application/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java new file mode 100644 index 0000000..ac23f1e --- /dev/null +++ b/application/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java @@ -0,0 +1,434 @@ +package run.halo.app.content.comment; + +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.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.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.Mockito; +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.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.extension.service.RoleService; +import run.halo.app.core.extension.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.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; +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) +class CommentServiceImplTest { + + @Mock + private SystemConfigurableEnvironmentFetcher environmentFetcher; + + @Mock + private ReactiveExtensionClient client; + + @Mock + private UserService userService; + + @Mock + private RoleService roleService; + + @Mock + private ExtensionGetter extensionGetter; + + @InjectMocks + private CommentServiceImpl commentService; + + @Mock + private CounterService counterService; + + @BeforeEach + void setUp() { + SystemSetting.Comment commentSetting = getCommentSetting(); + lenient().when(environmentFetcher.fetchComment()).thenReturn(Mono.just(commentSetting)); + + ListResult comments = new ListResult<>(1, 10, 3, comments()); + when(client.listBy(eq(Comment.class), any(ListOptions.class), any(PageRequest.class))) + .thenReturn(Mono.just(comments)); + + when(userService.getUserOrGhost(eq("A-owner"))) + .thenReturn(Mono.just(createUser("A-owner"))); + when(userService.getUserOrGhost(eq("B-owner"))) + .thenReturn(Mono.just(createUser("B-owner"))); + when(client.fetch(eq(User.class), eq("C-owner"))) + .thenReturn(Mono.empty()); + + when(roleService.contains(Set.of("USER"), + Set.of(AuthorityUtils.COMMENT_MANAGEMENT_ROLE_NAME))) + .thenReturn(Mono.just(false)); + + 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())); + } + + 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() { + 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 { + 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 + } + """; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/content/comment/PostCommentSubjectTest.java b/application/src/test/java/run/halo/app/content/comment/PostCommentSubjectTest.java new file mode 100644 index 0000000..f3744ea --- /dev/null +++ b/application/src/test/java/run/halo/app/content/comment/PostCommentSubjectTest.java @@ -0,0 +1,67 @@ +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.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.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(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelperTest.java b/application/src/test/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelperTest.java new file mode 100644 index 0000000..0173015 --- /dev/null +++ b/application/src/test/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelperTest.java @@ -0,0 +1,136 @@ +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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/content/comment/ReplyServiceImplIntegrationTest.java b/application/src/test/java/run/halo/app/content/comment/ReplyServiceImplIntegrationTest.java new file mode 100644 index 0000000..c2365bd --- /dev/null +++ b/application/src/test/java/run/halo/app/content/comment/ReplyServiceImplIntegrationTest.java @@ -0,0 +1,152 @@ +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.boot.test.mock.mockito.SpyBean; +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.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.index.IndexerFactory; +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; + + @SpyBean + private ReactiveExtensionClient reactiveClient; + + @Autowired + private ReactiveExtensionStoreClient storeClient; + + @Autowired + private IndexerFactory indexerFactory; + + @SpyBean + private ReplyServiceImpl replyService; + + Mono deleteImmediately(Extension extension) { + var name = extension.getMetadata().getName(); + var scheme = schemeManager.get(extension.getClass()); + // un-index + var indexer = indexerFactory.getIndexer(extension.groupVersionKind()); + indexer.unIndexRecord(extension.getMetadata().getName()); + + // 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 + } + } + """; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/content/comment/SinglePageCommentSubjectTest.java b/application/src/test/java/run/halo/app/content/comment/SinglePageCommentSubjectTest.java new file mode 100644 index 0000000..ffe796b --- /dev/null +++ b/application/src/test/java/run/halo/app/content/comment/SinglePageCommentSubjectTest.java @@ -0,0 +1,75 @@ +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 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.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(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/content/permalinks/CategoryPermalinkPolicyTest.java b/application/src/test/java/run/halo/app/content/permalinks/CategoryPermalinkPolicyTest.java new file mode 100644 index 0000000..fbd1e1a --- /dev/null +++ b/application/src/test/java/run/halo/app/content/permalinks/CategoryPermalinkPolicyTest.java @@ -0,0 +1,64 @@ +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.SystemConfigurableEnvironmentFetcher; + +/** + * Tests for {@link CategoryPermalinkPolicy}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class CategoryPermalinkPolicyTest { + + @Mock + private ExternalUrlSupplier externalUrlSupplier; + + @Mock + private SystemConfigurableEnvironmentFetcher 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"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/content/permalinks/PostPermalinkPolicyTest.java b/application/src/test/java/run/halo/app/content/permalinks/PostPermalinkPolicyTest.java new file mode 100644 index 0000000..f1655c6 --- /dev/null +++ b/application/src/test/java/run/halo/app/content/permalinks/PostPermalinkPolicyTest.java @@ -0,0 +1,115 @@ +package run.halo.app.content.permalinks; + +import static org.assertj.core.api.Assertions.assertThat; +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.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 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.MetadataUtil; +import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher environmentFetcher; + + private PostPermalinkPolicy postPermalinkPolicy; + + @BeforeEach + void setUp() { + lenient().when(externalUrlSupplier.get()).thenReturn(URI.create("")); + postPermalinkPolicy = new PostPermalinkPolicy(environmentFetcher, externalUrlSupplier); + } + + @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 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"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/content/permalinks/TagPermalinkPolicyTest.java b/application/src/test/java/run/halo/app/content/permalinks/TagPermalinkPolicyTest.java new file mode 100644 index 0000000..cd40a75 --- /dev/null +++ b/application/src/test/java/run/halo/app/content/permalinks/TagPermalinkPolicyTest.java @@ -0,0 +1,67 @@ +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.SystemConfigurableEnvironmentFetcher; + +/** + * 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 SystemConfigurableEnvironmentFetcher 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"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/endpoint/WebSocketHandlerMappingTest.java b/application/src/test/java/run/halo/app/core/endpoint/WebSocketHandlerMappingTest.java new file mode 100644 index 0000000..3fb4e43 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/endpoint/WebSocketHandlerMappingTest.java @@ -0,0 +1,55 @@ +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; + } + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/PostTest.java b/application/src/test/java/run/halo/app/core/extension/PostTest.java new file mode 100644 index 0000000..f721de7 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/PostTest.java @@ -0,0 +1,32 @@ +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"))); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/RoleBindingTest.java b/application/src/test/java/run/halo/app/core/extension/RoleBindingTest.java new file mode 100644 index 0000000..cacf7ba --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/RoleBindingTest.java @@ -0,0 +1,60 @@ +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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/SettingTest.java b/application/src/test/java/run/halo/app/core/extension/SettingTest.java new file mode 100644 index 0000000..1d3e830 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/SettingTest.java @@ -0,0 +1,103 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/TestRole.java b/application/src/test/java/run/halo/app/core/extension/TestRole.java new file mode 100644 index 0000000..d5f631f --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/TestRole.java @@ -0,0 +1,60 @@ +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); + } +} diff --git a/application/src/test/java/run/halo/app/core/extension/ThemeTest.java b/application/src/test/java/run/halo/app/core/extension/ThemeTest.java new file mode 100644 index 0000000..3492d9e --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/ThemeTest.java @@ -0,0 +1,152 @@ +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"); + + themeSpec.setVersion(null); + themeSpec.setRequires(null); + 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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpointTest.java new file mode 100644 index 0000000..e3c7fa4 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpointTest.java @@ -0,0 +1,266 @@ +package run.halo.app.core.extension.attachment.endpoint; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +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 java.util.function.Consumer; +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.data.domain.Sort; +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.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.extension.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.PageRequest; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; + +@ExtendWith(MockitoExtension.class) +class AttachmentEndpointTest { + + @Mock + ReactiveExtensionClient client; + + @Mock + ExtensionGetter extensionGetter; + + AttachmentEndpoint endpoint; + + WebTestClient webClient; + + @BeforeEach + void setUp() { + var attachmentService = new DefaultAttachmentService(client, extensionGetter); + endpoint = new AttachmentEndpoint(attachmentService, client); + 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(); + } + + void prepareForUploading(Consumer consumer) { + 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); + + 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"); + + consumer.accept(builder); + } + + @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()); + + 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); + + 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).create(attachment); + verify(handler).upload(any()); + } + } + + @Nested + class SearchTest { + + @Test + void shouldListUngroupedAttachments() { + when(client.listAll(eq(Group.class), any(), any(Sort.class))) + .thenReturn(Flux.empty()); + + when(client.listBy(same(Attachment.class), any(), any(PageRequest.class))) + .thenReturn(Mono.just(ListResult.emptyResult())); + + webClient + .get() + .uri("/attachments") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("items.length()").isEqualTo(0); + } + + @Test + void searchAttachmentWhenGroupIsEmpty() { + when(client.listAll(eq(Group.class), any(), any(Sort.class))) + .thenReturn(Flux.empty()); + + when(client.listBy(eq(Attachment.class), any(), any(PageRequest.class))) + .thenReturn(Mono.empty()); + + webClient + .get() + .uri("/attachments") + .exchange() + .expectStatus().isOk(); + + verify(client).listBy(eq(Attachment.class), any(), any(PageRequest.class)); + } + } +} diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/EmailVerificationCodeTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/EmailVerificationCodeTest.java new file mode 100644 index 0000000..ee3838c --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/endpoint/EmailVerificationCodeTest.java @@ -0,0 +1,122 @@ +package run.halo.app.core.extension.endpoint; + +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 static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; + +import io.github.resilience4j.ratelimiter.RateLimiterConfig; +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; +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.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 reactor.core.publisher.Mono; +import run.halo.app.core.extension.User; +import run.halo.app.core.extension.service.EmailVerificationService; +import run.halo.app.core.extension.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) +@WithMockUser(username = "fake-user", password = "fake-password") +class EmailVerificationCodeTest { + WebTestClient webClient; + @Mock + ReactiveExtensionClient client; + @Mock + EmailVerificationService emailVerificationService; + + @Mock + UserService userService; + + @InjectMocks + UserEndpoint endpoint; + + @BeforeEach + void setUp() { + var spyUserEndpoint = spy(endpoint); + 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"); + doReturn(RateLimiterOperator.of(sendCodeRateLimiter)).when(spyUserEndpoint) + .sendEmailVerificationCodeRateLimiter(eq("fake-user"), eq("hi@halo.run")); + + var verifyEmailRateLimiter = RateLimiterRegistry.of(config) + .rateLimiter("verify-email-fake-user"); + doReturn(RateLimiterOperator.of(verifyEmailRateLimiter)).when(spyUserEndpoint) + .verificationEmailRateLimiter(eq("fake-user")); + + webClient = WebTestClient.bindToRouterFunction(spyUserEndpoint.endpoint()).build() + .mutateWith(csrf()); + } + + @Test + void sendEmailVerificationCode() { + 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(client.get(eq(User.class), eq("fake-user"))).thenReturn(Mono.just(user)); + 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() { + 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); + } +} diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java new file mode 100644 index 0000000..75ec310 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java @@ -0,0 +1,506 @@ +package run.halo.app.core.extension.endpoint; + +import static java.util.Objects.requireNonNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +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.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.ArrayList; +import java.util.List; +import java.util.Objects; +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.extension.service.PluginService; +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.SystemVersionSupplier; +import run.halo.app.infra.utils.FileUtils; + +@Slf4j +@ExtendWith(MockitoExtension.class) +class PluginEndpointTest { + + @Mock + private ReactiveExtensionClient client; + + @Mock + SystemVersionSupplier systemVersionSupplier; + + @Mock + PluginService pluginService; + + @Spy + WebProperties webProperties = new WebProperties(); + + @InjectMocks + PluginEndpoint endpoint; + + @Nested + class PluginListTest { + + @Test + void shouldListEmptyPluginsWhenNoPlugins() { + when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) + .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.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) + .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.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(expectResult)); + + bindToRouterFunction(endpoint.endpoint()) + .build() + .get().uri("/plugins?keyword=Expected") + .exchange() + .expectStatus().isOk(); + + verify(client).list(same(Plugin.class), argThat( + predicate -> predicate.test(expectPlugin) + && !predicate.test(unexpectedPlugin1) + && !predicate.test(unexpectedPlugin2)), + any(), anyInt(), anyInt()); + } + + @Test + void shouldFilterPluginsWhenEnabledProvided() { + var expectPlugin = + createPlugin("fake-plugin-2", "expected display name", "", true); + 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.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(expectResult)); + + bindToRouterFunction(endpoint.endpoint()) + .build() + .get().uri("/plugins?enabled=true") + .exchange() + .expectStatus().isOk(); + + verify(client).list(same(Plugin.class), argThat( + predicate -> predicate.test(expectPlugin) + && !predicate.test(unexpectedPlugin1) + && !predicate.test(unexpectedPlugin2)), + any(), anyInt(), anyInt()); + } + + @Test + void shouldSortPluginsWhenCreationTimestampSet() { + var expectPlugin = + createPlugin("fake-plugin-2", "expected display name", "", true); + var expectResult = new ListResult<>(List.of(expectPlugin)); + when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(expectResult)); + + bindToRouterFunction(endpoint.endpoint()) + .build() + .get().uri("/plugins?sort=creationTimestamp,desc") + .exchange() + .expectStatus().isOk(); + + verify(client).list(same(Plugin.class), any(), argThat(comparator -> { + var now = Instant.now(); + var plugins = new ArrayList<>(List.of( + createPlugin("fake-plugin-a", now), + createPlugin("fake-plugin-b", now.plusSeconds(1)), + createPlugin("fake-plugin-c", now.plusSeconds(2)) + )); + plugins.sort(comparator); + return Objects.deepEquals(plugins, List.of( + createPlugin("fake-plugin-c", now.plusSeconds(2)), + createPlugin("fake-plugin-b", now.plusSeconds(1)), + createPlugin("fake-plugin-a", now) + )); + }), anyInt(), anyInt()); + } + } + + @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.valueOf("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/config") + .exchange() + .expectStatus().isBadRequest(); + } + + @Test + void updateWhenConfigMapNameNotMatch() { + Plugin plugin = createPlugin("fake-plugin"); + plugin.getSpec().setConfigMapName("fake-config-map"); + + when(client.fetch(eq(Plugin.class), eq("fake-plugin"))).thenReturn(Mono.just(plugin)); + webClient.put() + .uri("/plugins/fake-plugin/config") + .body(Mono.fromSupplier(() -> { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setName("not-match"); + return configMap; + }), ConfigMap.class) + .exchange() + .expectStatus().isBadRequest(); + } + + @Test + void updateWhenConfigMapNameMatch() { + 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(client.fetch(eq(ConfigMap.class), eq("fake-config-map"))).thenReturn(Mono.empty()); + when(client.create(any(ConfigMap.class))).thenReturn(Mono.empty()); + + webClient.put() + .uri("/plugins/fake-plugin/config") + .body(Mono.fromSupplier(() -> { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setName("fake-config-map"); + return configMap; + }), ConfigMap.class) + .exchange() + .expectStatus().isOk(); + } + } + + @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 fetchConfig() { + Plugin plugin = createPlugin("fake"); + plugin.getSpec().setConfigMapName("fake-config"); + + when(client.fetch(eq(ConfigMap.class), eq("fake-config"))) + .thenReturn(Mono.just(new ConfigMap())); + + when(client.fetch(eq(Plugin.class), eq("fake"))).thenReturn(Mono.just(plugin)); + webClient.get() + .uri("/plugins/fake/config") + .exchange() + .expectStatus().isOk(); + + verify(client).fetch(eq(ConfigMap.class), 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; + } + } + +} diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java new file mode 100644 index 0000000..7933535 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java @@ -0,0 +1,182 @@ +package run.halo.app.core.extension.endpoint; + +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")); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/SinglePageEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/SinglePageEndpointTest.java new file mode 100644 index 0000000..9e3fcd8 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/endpoint/SinglePageEndpointTest.java @@ -0,0 +1,92 @@ +package run.halo.app.core.extension.endpoint; + +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)); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/SystemInitializationEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/SystemInitializationEndpointTest.java new file mode 100644 index 0000000..82cf8a2 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/endpoint/SystemInitializationEndpointTest.java @@ -0,0 +1,89 @@ +package run.halo.app.core.extension.endpoint; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction; + +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.extension.endpoint.SystemInitializationEndpoint.SystemInitializationRequest; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.InitializationStateGetter; +import run.halo.app.infra.SystemSetting; +import run.halo.app.security.SuperAdminInitializer; +import run.halo.app.security.SuperAdminInitializer.InitializationParam; + +/** + * Tests for {@link SystemInitializationEndpoint}. + * + * @author guqing + * @since 2.9.0 + */ +@ExtendWith(MockitoExtension.class) +class SystemInitializationEndpointTest { + + @Mock + InitializationStateGetter initializationStateGetter; + + @Mock + SuperAdminInitializer superAdminInitializer; + + @Mock + ReactiveExtensionClient client; + + @InjectMocks + SystemInitializationEndpoint initializationEndpoint; + + WebTestClient webTestClient; + + @BeforeEach + void setUp() { + webTestClient = bindToRouterFunction(initializationEndpoint.endpoint()).build(); + } + + @Test + void initializeWithoutRequestBody() { + webTestClient.post() + .uri("/system/initialize") + .exchange() + .expectStatus() + .isBadRequest(); + } + + @Test + void initializeWithRequestBody() { + var initialization = new SystemInitializationRequest(); + initialization.setUsername("faker"); + initialization.setPassword("openfaker"); + initialization.setEmail("faker@halo.run"); + initialization.setSiteTitle("Fake Site"); + + when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(false)); + when(superAdminInitializer.initialize(any(InitializationParam.class))) + .thenReturn(Mono.empty()); + + var configMap = new ConfigMap(); + when(client.get(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)) + .thenReturn(Mono.just(configMap)); + when(client.update(configMap)).thenReturn(Mono.just(configMap)); + + webTestClient.post().uri("/system/initialize") + .bodyValue(initialization) + .exchange() + .expectStatus().isCreated() + .expectHeader().location("/console"); + + verify(initializationStateGetter).userInitialized(); + verify(superAdminInitializer).initialize(any()); + verify(client).get(ConfigMap.class, SystemSetting.SYSTEM_CONFIG); + verify(client).update(configMap); + } +} diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/TagEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/TagEndpointTest.java new file mode 100644 index 0000000..1eb3214 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/endpoint/TagEndpointTest.java @@ -0,0 +1,104 @@ +package run.halo.app.core.extension.endpoint; + +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; + } + + } +} diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointIntegrationTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointIntegrationTest.java new file mode 100644 index 0000000..b83f2f6 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointIntegrationTest.java @@ -0,0 +1,125 @@ +package run.halo.app.core.extension.endpoint; + +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.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.annotation.DirtiesContext; +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.extension.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; + + @MockBean + 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; + } +} diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java new file mode 100644 index 0000000..9396cfe --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java @@ -0,0 +1,514 @@ +package run.halo.app.core.extension.endpoint; + +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.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.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; +import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction; +import static run.halo.app.extension.GroupVersionKind.fromExtension; + +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.security.test.context.support.WithMockUser; +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.RoleBinding; +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.extension.service.RoleService; +import run.halo.app.core.extension.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.extension.exception.ExtensionNotFoundException; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.utils.JsonUtils; + +@SpringBootTest +@AutoConfigureWebTestClient +@WithMockUser(username = "fake-user", password = "fake-password", roles = "fake-super-role") +class UserEndpointTest { + + WebTestClient webClient; + + @Mock + RoleService roleService; + + @Mock + AttachmentService attachmentService; + + @Mock + SystemConfigurableEnvironmentFetcher environmentFetcher; + + @Mock + ReactiveExtensionClient client; + + @Mock + UserService userService; + + @InjectMocks + UserEndpoint endpoint; + + @BeforeEach + void setUp() { + // disable authorization + webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()).build() + .mutateWith(csrf()); + } + + @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())); + + bindToRouterFunction(endpoint.endpoint()) + .build() + .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)); + + bindToRouterFunction(endpoint.endpoint()) + .build() + .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()); + + bindToRouterFunction(endpoint.endpoint()) + .build() + .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()); + + bindToRouterFunction(endpoint.endpoint()) + .build() + .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; + } + + User createUser(String name, Instant creationTimestamp) { + var metadata = new Metadata(); + metadata.setName(name); + metadata.setCreationTimestamp(creationTimestamp); + var spec = new User.UserSpec(); + 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 ExtensionNotFoundException(fromExtension(User.class), "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))); + // verify(roleService).list(eq(Set.of("role-A"))); + } + } + + @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 updatedUser = createUser("fake-user"); + var requestUser = createUser("another-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().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.confirmPassword("another-fake-user", "old-password")) + .thenReturn(Mono.just(true)); + 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 { + + @BeforeEach + void setUp() { + when(client.list(same(RoleBinding.class), any(), any())).thenReturn(Flux.empty()); + when(client.get(User.class, "fake-user")) + .thenReturn(Mono.error( + new ExtensionNotFoundException(fromExtension(User.class), "fake-user"))); + } + + @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.listDependenciesFlux(anySet())).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())); + when(userService.updateWithRawPassword(anyString(), anyString())) + .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"); + + SystemSetting.User user = mock(SystemSetting.User.class); + when(environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class)) + .thenReturn(Mono.just(user)); + + 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"); + + SystemSetting.User user = mock(SystemSetting.User.class); + when(environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class)) + .thenReturn(Mono.just(user)); + when(client.get(User.class, "fake-user")).thenReturn(Mono.just(currentUser)); + when(attachmentService.upload(anyString(), 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; + } + } +} diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/CommentReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/CommentReconcilerTest.java new file mode 100644 index 0000000..1f6af9e --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/CommentReconcilerTest.java @@ -0,0 +1,114 @@ +package run.halo.app.core.extension.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.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; + } +} diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/MenuItemReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/MenuItemReconcilerTest.java new file mode 100644 index 0000000..aec1993 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/MenuItemReconcilerTest.java @@ -0,0 +1,218 @@ +package run.halo.app.core.extension.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.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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/PluginReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/PluginReconcilerTest.java new file mode 100644 index 0000000..92652fb --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/PluginReconcilerTest.java @@ -0,0 +1,565 @@ +package run.halo.app.core.extension.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.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.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 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.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; + + @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); + } + + @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; + + @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)); + assertFalse(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)); + assertFalse(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()); + when(pluginManager.startPlugin(name)).thenReturn(PluginState.FAILED); + + 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(Condition.builder() + .type(PluginReconciler.ConditionType.READY) + .status(ConditionStatus.FALSE) + .reason(PluginReconciler.ConditionReason.START_ERROR) + .message("Failed to start plugin fake-plugin(FAILED).") + .build(), condition); + } + + @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.STOPPED)) + // sync plugin state + .thenReturn(mockPluginWrapper(PluginState.STARTED)); + when(pluginManager.startPlugin(name)).thenReturn(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).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) { + var pluginWrapper = mock(PluginWrapper.class); + lenient().when(pluginWrapper.getPluginState()).thenReturn(state); + lenient().when(pluginWrapper.getDescriptor()).thenReturn(new DefaultPluginDescriptor()); + 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; + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java new file mode 100644 index 0000000..b701523 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java @@ -0,0 +1,236 @@ +package run.halo.app.core.extension.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.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 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); + })); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/ReverseProxyReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/ReverseProxyReconcilerTest.java new file mode 100644 index 0000000..0f88e23 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/ReverseProxyReconcilerTest.java @@ -0,0 +1,68 @@ +package run.halo.app.core.extension.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.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")); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java new file mode 100644 index 0000000..486efad --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java @@ -0,0 +1,254 @@ +package run.halo.app.core.extension.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.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.metrics.CounterService; +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); + })); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/SystemSettingReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/SystemSettingReconcilerTest.java new file mode 100644 index 0000000..61edc6a --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/SystemSettingReconcilerTest.java @@ -0,0 +1,187 @@ +package run.halo.app.core.extension.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.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationContext; +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.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.utils.JsonUtils; + +/** + * Tests for {@link SystemSettingReconciler}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class SystemSettingReconcilerTest { + + @Mock + private ExtensionClient client; + + @Mock + private ApplicationContext applicationContext; + + @Mock + private SystemConfigurableEnvironmentFetcher environmentFetcher; + + private SystemSettingReconciler systemSettingReconciler; + + @BeforeEach + void setUp() { + systemSettingReconciler = new SystemSettingReconciler(client, environmentFetcher, + applicationContext); + } + + @Test + void reconcileArchivesRouteRule() { + ConfigMap configMap = systemConfigMapForRouteRule(rules -> { + rules.setArchives("archives-new"); + return rules; + }); + when(environmentFetcher.getConfigMapBlocking()).thenReturn(Optional.of(configMap)); + when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) + .thenReturn(Optional.of(configMap)); + systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG)); + ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigMap.class); + verify(client, times(1)).update(captor.capture()); + + ConfigMap updatedConfigMap = captor.getValue(); + assertThat(rulesFrom(updatedConfigMap).getArchives()).isEqualTo("archives-new"); + assertThat(rulesFrom(updatedConfigMap).getPost()).isEqualTo("/archives-new/{slug}"); + + assertThat(oldRulesFromAnno(updatedConfigMap).getArchives()).isEqualTo("archives-new"); + assertThat(oldRulesFromAnno(updatedConfigMap).getPost()).isEqualTo("/archives-new/{slug}"); + + // archives and post + verify(applicationContext, times(2)).publishEvent(any()); + } + + @Test + void reconcileTagsRule() { + ConfigMap configMap = systemConfigMapForRouteRule(rules -> { + rules.setTags("tags-new"); + return rules; + }); + when(environmentFetcher.getConfigMapBlocking()).thenReturn(Optional.of(configMap)); + when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) + .thenReturn(Optional.of(configMap)); + systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG)); + ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigMap.class); + verify(client, times(1)).update(captor.capture()); + + ConfigMap updatedConfigMap = captor.getValue(); + assertThat(rulesFrom(updatedConfigMap).getTags()).isEqualTo("tags-new"); + + assertThat(oldRulesFromAnno(updatedConfigMap).getTags()).isEqualTo("tags-new"); + + verify(applicationContext, times(1)).publishEvent(any()); + } + + @Test + void reconcileCategoriesRule() { + ConfigMap configMap = systemConfigMapForRouteRule(rules -> { + rules.setCategories("categories-new"); + return rules; + }); + when(environmentFetcher.getConfigMapBlocking()).thenReturn(Optional.of(configMap)); + when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) + .thenReturn(Optional.of(configMap)); + systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG)); + ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigMap.class); + verify(client, times(1)).update(captor.capture()); + + ConfigMap updatedConfigMap = captor.getValue(); + assertThat(rulesFrom(updatedConfigMap).getCategories()).isEqualTo("categories-new"); + + assertThat(oldRulesFromAnno(updatedConfigMap).getCategories()).isEqualTo("categories-new"); + + verify(applicationContext, times(1)).publishEvent(any()); + } + + @Test + void reconcilePostRule() { + ConfigMap configMap = systemConfigMapForRouteRule(rules -> { + rules.setPost("/post-new/{slug}"); + return rules; + }); + when(environmentFetcher.getConfigMapBlocking()).thenReturn(Optional.of(configMap)); + when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) + .thenReturn(Optional.of(configMap)); + systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG)); + ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigMap.class); + verify(client, times(1)).update(captor.capture()); + + ConfigMap updatedConfigMap = captor.getValue(); + assertThat(rulesFrom(updatedConfigMap).getPost()).isEqualTo("/post-new/{slug}"); + + assertThat(oldRulesFromAnno(updatedConfigMap).getPost()).isEqualTo("/post-new/{slug}"); + + verify(applicationContext, times(1)).publishEvent(any()); + } + + private SystemSetting.ThemeRouteRules rulesFrom(ConfigMap configMap) { + String s = configMap.getData().get(SystemSetting.ThemeRouteRules.GROUP); + return JsonUtils.jsonToObject(s, SystemSetting.ThemeRouteRules.class); + } + + private SystemSetting.ThemeRouteRules oldRulesFromAnno(ConfigMap configMap) { + Map annotations = configMap.getMetadata().getAnnotations(); + String s = annotations.get(SystemSettingReconciler.OLD_THEME_ROUTE_RULES); + return JsonUtils.jsonToObject(s, SystemSetting.ThemeRouteRules.class); + } + + private ConfigMap systemConfigMapForRouteRule( + Function function) { + ConfigMap configMap = new ConfigMap(); + Metadata metadata = new Metadata(); + metadata.setName(SystemSetting.SYSTEM_CONFIG); + configMap.setMetadata(metadata); + + SystemSetting.ThemeRouteRules themeRouteRules = new SystemSetting.ThemeRouteRules(); + themeRouteRules.setArchives("archives"); + themeRouteRules.setTags("tags"); + themeRouteRules.setCategories("categories"); + themeRouteRules.setPost("/archives/{slug}"); + Map annotations = new HashMap<>(); + annotations.put(SystemSettingReconciler.OLD_THEME_ROUTE_RULES, + JsonUtils.objectToJson(themeRouteRules)); + metadata.setAnnotations(annotations); + + SystemSetting.ThemeRouteRules newRules = function.apply(themeRouteRules); + configMap.putDataItem(SystemSetting.ThemeRouteRules.GROUP, + JsonUtils.objectToJson(newRules)); + return configMap; + } + + @Test + void changePostPatternPrefixIfNecessary() { + SystemSetting.ThemeRouteRules newRouteRules = new SystemSetting.ThemeRouteRules(); + newRouteRules.setPost("/archives/{slug}"); + newRouteRules.setArchives("new"); + boolean result = SystemSettingReconciler.RouteRuleReconciler + .changePostPatternPrefixIfNecessary("archives", newRouteRules); + assertThat(result).isTrue(); + + assertThat(newRouteRules.getPost()).isEqualTo("/new/{slug}"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/TagReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/TagReconcilerTest.java new file mode 100644 index 0000000..d88fe3c --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/TagReconcilerTest.java @@ -0,0 +1,89 @@ +package run.halo.app.core.extension.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.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; + } +} diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java new file mode 100644 index 0000000..b18f928 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java @@ -0,0 +1,335 @@ +package run.halo.app.core.extension.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.Instant; +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.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +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.retry.RetryException; +import org.springframework.util.FileSystemUtils; +import org.springframework.util.ResourceUtils; +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.MetadataOperator; +import run.halo.app.extension.controller.Reconciler; +import run.halo.app.infra.SystemVersionSupplier; +import run.halo.app.infra.ThemeRootGetter; +import run.halo.app.infra.utils.JsonUtils; + +/** + * 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; + + @InjectMocks + ThemeReconciler themeReconciler; + + @TempDir + private Path tempDirectory; + + @BeforeEach + void setUp() throws IOException { + defaultTheme = ResourceUtils.getFile("classpath:themes/default"); + lenient().when(systemVersionSupplier.get()).thenReturn(Version.valueOf("0.0.0")); + } + + @Test + void reconcileDelete() throws IOException { + 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.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(2)).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() { + Theme theme = fakeTheme(); + final MetadataOperator metadata = theme.getMetadata(); + + Path testWorkDir = tempDirectory.resolve("reconcile-delete"); + when(themeRoot.get()).thenReturn(testWorkDir); + + final ThemeReconciler themeReconciler = + new ThemeReconciler(extensionClient, themeRoot, systemVersionSupplier); + + 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(2)).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)); + } + + @Test + void reconcileDeleteRetryWhenThrowException() { + Theme theme = fakeTheme(); + + Path testWorkDir = tempDirectory.resolve("reconcile-delete"); + when(themeRoot.get()).thenReturn(testWorkDir); + + final ThemeReconciler themeReconciler = + new ThemeReconciler(extensionClient, themeRoot, systemVersionSupplier); + + final int[] retryFlags = {0}; + when(extensionClient.fetch(eq(Setting.class), eq("theme-test-setting"))) + .thenAnswer((Answer>) invocation -> { + retryFlags[0]++; + // retry 2 times + if (retryFlags[0] < 2) { + return Optional.of(new Setting()); + } + throw new RetryException("retry exception."); + }); + + String settingName = theme.getSpec().getSettingName(); + assertThatThrownBy( + () -> themeReconciler.reconcile(new Reconciler.Request(theme.getMetadata().getName()))) + .isInstanceOf(RetryException.class) + .hasMessage("retry exception."); + + verify(extensionClient, times(2)).fetch(eq(Setting.class), eq(settingName)); + } + + @Test + void reconcileStatus() { + when(systemVersionSupplier.get()).thenReturn(Version.valueOf("2.3.0")); + Path testWorkDir = tempDirectory.resolve("reconcile-delete"); + when(themeRoot.get()).thenReturn(testWorkDir); + + final ThemeReconciler themeReconciler = + new ThemeReconciler(extensionClient, themeRoot, systemVersionSupplier); + Theme theme = fakeTheme(); + theme.setStatus(null); + theme.getSpec().setRequires(">2.3.0"); + when(extensionClient.fetch(eq(Theme.class), eq("fake-theme"))) + .thenReturn(Optional.of(theme)); + themeReconciler.reconcileStatus("fake-theme"); + ArgumentCaptor 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); + + theme.getSpec().setRequires(">=2.3.0"); + when(extensionClient.fetch(eq(Theme.class), eq("fake-theme"))) + .thenReturn(Optional.of(theme)); + themeReconciler.reconcileStatus("fake-theme"); + verify(extensionClient, times(2)).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"); + 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); + lenient().when(extensionClient.fetch(eq(Theme.class), eq(metadata.getName()))) + .thenReturn(Optional.of(theme)); + 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)); + Reconciler.Result reconcile = + themeReconciler.reconcile(new Reconciler.Request(metadata.getName())); + assertThat(reconcile.reEnqueue()).isFalse(); + verify(extensionClient, times(3)).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); + themeReconciler.reconcile(new Reconciler.Request(metadata.getName())); + verify(extensionClient, times(6)) + .fetch(eq(Theme.class), eq(metadata.getName())); + verify(extensionClient, times(3)) + .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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/UserReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/UserReconcilerTest.java new file mode 100644 index 0000000..f54997d --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/UserReconcilerTest.java @@ -0,0 +1,113 @@ +package run.halo.app.core.extension.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.extension.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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/service/DefaultRoleServiceTest.java b/application/src/test/java/run/halo/app/core/extension/service/DefaultRoleServiceTest.java new file mode 100644 index 0000000..b2ca0f1 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/service/DefaultRoleServiceTest.java @@ -0,0 +1,331 @@ +package run.halo.app.core.extension.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.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.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.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; + + @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; + } + } +} diff --git a/application/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java b/application/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java new file mode 100644 index 0000000..8b63e04 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java @@ -0,0 +1,405 @@ +package run.halo.app.core.extension.service; + +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.anySet; +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.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.Set; +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.security.crypto.password.PasswordEncoder; +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.event.user.PasswordChangedEvent; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.exception.ExtensionNotFoundException; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.exception.AccessDeniedException; +import run.halo.app.infra.exception.DuplicateNameException; +import run.halo.app.infra.exception.UserNotFoundException; + +@ExtendWith(MockitoExtension.class) +class UserServiceImplTest { + + @Mock + ReactiveExtensionClient client; + + @Mock + SystemConfigurableEnvironmentFetcher environmentFetcher; + + @Mock + PasswordEncoder passwordEncoder; + + @Mock + ApplicationEventPublisher eventPublisher; + + @Mock + RoleService roleService; + + @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 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"); + } + + } + + User createUser(String password) { + var user = new User(); + Metadata metadata = new Metadata(); + metadata.setName("fake-user"); + user.setMetadata(metadata); + user.setSpec(new User.UserSpec()); + user.getSpec().setPassword(password); + return user; + } + + @Nested + class GrantRolesTest { + + @Test + void shouldGetNotFoundIfUserNotFound() { + when(client.get(User.class, "invalid-user")) + .thenReturn(Mono.error( + new ExtensionNotFoundException(fromExtension(User.class), "invalid-user"))); + + 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 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 user = createUser("fake-password"); + when(client.get(User.class, "fake-user")).thenReturn(Mono.just(user)); + 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(client.update(user)).thenReturn(Mono.just(user)); + + StepVerifier.create(userService.grantRoles("fake-user", Set.of("fake-role"))) + .expectNextCount(1) + .verifyComplete(); + + verify(client).delete(notProvidedRoleBinding); + } + + @Test + void shouldUpdateRoleBindingIfExists() { + var user = createUser("fake-password"); + when(client.get(User.class, "fake-user")).thenReturn(Mono.just(user)); + // 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(client.update(user)).thenReturn(Mono.just(user)); + + StepVerifier.create(userService.grantRoles("fake-user", Set.of("fake-role"))) + .expectNextCount(1) + .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)); + + User fakeUser = fakeSignUpUser("fake-user", "fake-password"); + + userService.signUp(fakeUser, "fake-password") + .as(StepVerifier::create) + .expectError(AccessDeniedException.class) + .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)); + + User fakeUser = fakeSignUpUser("fake-user", "fake-password"); + + userService.signUp(fakeUser, "fake-password") + .as(StepVerifier::create) + .expectError(AccessDeniedException.class) + .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(fakeSignUpUser("test", "test"))); + + User fakeUser = fakeSignUpUser("fake-user", "fake-password"); + + userService.signUp(fakeUser, "fake-password") + .as(StepVerifier::create) + .expectError(DuplicateNameException.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 = fakeSignUpUser("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()); + + spyUserService.signUp(fakeUser, "fake-password") + .as(StepVerifier::create) + .consumeNextWith(user -> { + assertThat(user.getMetadata().getName()).isEqualTo("fake-user"); + assertThat(user.getSpec().getPassword()).isEqualTo("fake-password"); + }) + .verifyComplete(); + + verify(client).create(any(User.class)); + verify(spyUserService).grantRoles(eq("fake-user"), anySet()); + } + + User fakeSignUpUser(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; + } + } + + @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(); + } +} diff --git a/application/src/test/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImplTest.java b/application/src/test/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImplTest.java new file mode 100644 index 0000000..1d95fce --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImplTest.java @@ -0,0 +1,83 @@ +package run.halo.app.core.extension.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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.infra.exception.RateLimitExceededException; + +/** + * Tests for {@link EmailPasswordRecoveryServiceImpl}. + * + * @author guqing + * @since 2.11.0 + */ +@ExtendWith(MockitoExtension.class) +class EmailPasswordRecoveryServiceImplTest { + + @Nested + class ResetPasswordVerificationManagerTest { + @Test + public void generateTokenTest() { + var verificationManager = + new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); + verificationManager.generateToken("fake-user"); + var result = verificationManager.contains("fake-user"); + assertThat(result).isTrue(); + + verificationManager.generateToken("guqing"); + result = verificationManager.contains("guqing"); + assertThat(result).isTrue(); + + result = verificationManager.contains("123"); + assertThat(result).isFalse(); + } + } + + @Test + public void removeTest() { + var verificationManager = + new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); + verificationManager.generateToken("fake-user"); + var result = verificationManager.contains("fake-user"); + + verificationManager.removeToken("fake-user"); + result = verificationManager.contains("fake-user"); + assertThat(result).isFalse(); + } + + @Test + void verifyTokenTestNormal() { + String username = "guqing"; + var verificationManager = + new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); + var result = verificationManager.verifyToken(username, "fake-code"); + assertThat(result).isFalse(); + + var token = verificationManager.generateToken(username); + result = verificationManager.verifyToken(username, "fake-code"); + assertThat(result).isFalse(); + + result = verificationManager.verifyToken(username, token); + assertThat(result).isTrue(); + } + + @Test + void verifyTokenFailedAfterMaxAttempts() { + String username = "guqing"; + var verificationManager = + new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); + var token = verificationManager.generateToken(username); + for (int i = 0; i <= EmailPasswordRecoveryServiceImpl.MAX_ATTEMPTS; i++) { + var result = verificationManager.verifyToken(username, "fake-code"); + assertThat(result).isFalse(); + } + + assertThatThrownBy(() -> verificationManager.verifyToken(username, token)) + .isInstanceOf(RateLimitExceededException.class) + .hasMessage("429 TOO_MANY_REQUESTS \"You have exceeded your quota\""); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImplTest.java b/application/src/test/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImplTest.java new file mode 100644 index 0000000..81a99bf --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImplTest.java @@ -0,0 +1,86 @@ +package run.halo.app.core.extension.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static run.halo.app.core.extension.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.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.\""); + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java b/application/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java new file mode 100644 index 0000000..2e79ce0 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java @@ -0,0 +1,509 @@ +package run.halo.app.core.extension.service.impl; + +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; +import run.halo.app.plugin.PluginConst; +import run.halo.app.plugin.PluginsRootGetter; +import run.halo.app.plugin.SpringPluginManager; +import run.halo.app.plugin.YamlPluginFinder; + +@ExtendWith(MockitoExtension.class) +class PluginServiceImplTest { + + @Mock + SystemVersionSupplier systemVersionSupplier; + + @Mock + ReactiveExtensionClient client; + + @Mock + PluginsRootGetter pluginsRootGetter; + + @Mock + SpringPluginManager pluginManager; + + @Spy + @InjectMocks + PluginServiceImpl pluginService; + + @Test + void getPresetsTest() { + var presets = pluginService.getPresets(); + StepVerifier.create(presets) + .assertNext(plugin -> { + assertEquals("fake-plugin", plugin.getMetadata().getName()); + assertEquals("0.0.2", plugin.getSpec().getVersion()); + assertEquals(Plugin.Phase.PENDING, plugin.getStatus().getPhase()); + }) + .verifyComplete(); + } + + @Test + void getPresetIfNotFound() { + var plugin = pluginService.getPreset("not-found-plugin"); + StepVerifier.create(plugin) + .verifyComplete(); + } + + @Test + void getPresetIfFound() { + var plugin = pluginService.getPreset("fake-plugin"); + StepVerifier.create(plugin) + .expectNextCount(1) + .verifyComplete(); + } + + @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.valueOf("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.getStartedPlugins()).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.getStartedPlugins()).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()).getStartedPlugins(); + } + + @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; + } +} diff --git a/application/src/test/java/run/halo/app/core/extension/theme/SettingUtilsTest.java b/application/src/test/java/run/halo/app/core/extension/theme/SettingUtilsTest.java new file mode 100644 index 0000000..bbed171 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/theme/SettingUtilsTest.java @@ -0,0 +1,137 @@ +package run.halo.app.core.extension.theme; + +import static org.assertj.core.api.Assertions.assertThat; + +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; +import run.halo.app.infra.utils.JsonUtils; + +/** + * 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); + } + + @Test + void isJson() { + assertThat(SettingUtils.isJson("[1,2,3]")).isTrue(); + assertThat(SettingUtils.isJson("[\"hello\"]")).isTrue(); + assertThat(SettingUtils.isJson("{\"name\":\"guqing\",\"age\":18}")).isTrue(); + assertThat(SettingUtils.isJson("{ \"flag\":true }")).isTrue(); + assertThat(SettingUtils.isJson(""" + [ + { "K1": "value-1", "K2":"value1-2" } + ] + """)).isTrue(); + assertThat(SettingUtils.isJson(""" + { + "sites": [{ "name":"halo" , "url":"halo.run" }] + } + """)).isTrue(); + assertThat(SettingUtils.isJson("{\"name\":\"guqing\"")).isFalse(); + assertThat(SettingUtils.isJson("hello")).isFalse(); + } + + 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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java new file mode 100644 index 0000000..4fc3194 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java @@ -0,0 +1,363 @@ +package run.halo.app.core.extension.theme; + +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 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.extension.ConfigMap; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.ReactiveUrlDataBufferFetcher; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.ThemeRootGetter; +import run.halo.app.theme.TemplateEngineManager; + +/** + * 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 SystemConfigurableEnvironmentFetcher environmentFetcher; + + @Mock + private ReactiveUrlDataBufferFetcher urlDataBufferFetcher; + + @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 updateWhenConfigMapNameIsNull() { + Theme theme = new Theme(); + theme.setMetadata(new Metadata()); + theme.setSpec(new Theme.ThemeSpec()); + theme.getSpec().setConfigMapName(null); + + when(client.fetch(eq(Theme.class), eq("fake-theme"))).thenReturn(Mono.just(theme)); + webTestClient.put() + .uri("/themes/fake-theme/config") + .exchange() + .expectStatus().isBadRequest(); + } + + @Test + void updateWhenConfigMapNameNotMatch() { + 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)); + webTestClient.put() + .uri("/themes/fake-theme/config") + .body(Mono.fromSupplier(() -> { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setName("not-match"); + return configMap; + }), ConfigMap.class) + .exchange() + .expectStatus().isBadRequest(); + } + + @Test + void updateWhenConfigMapNameMatch() { + 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(client.fetch(eq(ConfigMap.class), eq("fake-config-map"))).thenReturn(Mono.empty()); + when(client.create(any(ConfigMap.class))).thenReturn(Mono.empty()); + + webTestClient.put() + .uri("/themes/fake-theme/config") + .body(Mono.fromSupplier(() -> { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setName("fake-config-map"); + return configMap; + }), ConfigMap.class) + .exchange() + .expectStatus().isOk(); + } + } + + + @Test + void fetchActivatedTheme() { + when(environmentFetcher.fetch(eq(SystemSetting.Theme.GROUP), eq(SystemSetting.Theme.class))) + .thenReturn(Mono.fromSupplier(() -> { + SystemSetting.Theme theme = new SystemSetting.Theme(); + theme.setActive("fake-activated"); + return theme; + })); + + when(client.fetch(eq(Theme.class), eq("fake-activated"))).thenReturn(Mono.empty()); + webTestClient.get() + .uri("/themes/-/activation") + .exchange() + .expectStatus().isOk(); + + verify(client).fetch(eq(Theme.class), eq("fake-activated")); + } + + @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 fetchThemeConfig() { + Theme theme = new Theme(); + theme.setMetadata(new Metadata()); + theme.getMetadata().setName("fake"); + theme.setSpec(new Theme.ThemeSpec()); + theme.getSpec().setConfigMapName("fake-config"); + + when(client.fetch(eq(ConfigMap.class), eq("fake-config"))) + .thenReturn(Mono.just(new ConfigMap())); + + when(client.fetch(eq(Theme.class), eq("fake"))).thenReturn(Mono.just(theme)); + webTestClient.get() + .uri("/themes/fake/config") + .exchange() + .expectStatus().isOk(); + + verify(client).fetch(eq(ConfigMap.class), eq("fake-config")); + verify(client).fetch(eq(Theme.class), eq("fake")); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java b/application/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java new file mode 100644 index 0000000..045b5f1 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java @@ -0,0 +1,463 @@ +package run.halo.app.core.extension.theme; + +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.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.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 + private SystemVersionSupplier systemVersionSupplier; + + @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.valueOf("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.delete(oldTheme)).thenReturn(Mono.just(oldTheme)); + when(client.create(isA(Unstructured.class))).thenReturn( + Mono.just(convert(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, times(3)).fetch(Theme.class, "default"); + verify(client).delete(oldTheme); + verify(client).create(isA(Unstructured.class)); + } + } + + @Nested + class InstallTest { + + + @Test + void shouldInstallSuccessfully() throws IOException, URISyntaxException { + var defaultThemeZipPath = prepareTheme("default"); + when(client.create(isA(Unstructured.class))).thenReturn( + Mono.just(convert(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(Unstructured.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()); + + 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)); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/AbstractExtensionTest.java b/application/src/test/java/run/halo/app/extension/AbstractExtensionTest.java new file mode 100644 index 0000000..6d42d2e --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/AbstractExtensionTest.java @@ -0,0 +1,52 @@ +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()); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/ComparatorsTest.java b/application/src/test/java/run/halo/app/extension/ComparatorsTest.java new file mode 100644 index 0000000..8265721 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/ComparatorsTest.java @@ -0,0 +1,97 @@ +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); + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/ConfigMapTest.java b/application/src/test/java/run/halo/app/extension/ConfigMapTest.java new file mode 100644 index 0000000..2da4250 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/ConfigMapTest.java @@ -0,0 +1,103 @@ +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"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/DefaultSchemeManagerTest.java b/application/src/test/java/run/halo/app/extension/DefaultSchemeManagerTest.java new file mode 100644 index 0000000..8b44ebc --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/DefaultSchemeManagerTest.java @@ -0,0 +1,140 @@ +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.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 run.halo.app.extension.SchemeWatcherManager.SchemeRegistered; +import run.halo.app.extension.SchemeWatcherManager.SchemeUnregistered; +import run.halo.app.extension.SchemeWatcherManager.SchemeWatcher; +import run.halo.app.extension.exception.SchemeNotFoundException; +import run.halo.app.extension.index.IndexSpecRegistry; + +@ExtendWith(MockitoExtension.class) +class DefaultSchemeManagerTest { + + @Mock + private IndexSpecRegistry indexSpecRegistry; + + @Mock + SchemeWatcherManager watcherManager; + + @InjectMocks + DefaultSchemeManager schemeManager; + + @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() { + final var watcher = mock(SchemeWatcher.class); + when(watcherManager.watchers()).thenReturn(List.of(watcher)); + + schemeManager.register(FakeExtension.class); + verify(watcherManager, times(1)).watchers(); + verify(watcher, times(1)).onChange(isA(SchemeRegistered.class)); + + schemeManager.register(FakeExtension.class); + verify(watcherManager, times(1)).watchers(); + verify(watcher, times(1)).onChange(isA(SchemeRegistered.class)); + verify(indexSpecRegistry).indexFor(any(Scheme.class)); + } + + @Test + void shouldTriggerOnChangeOnlyOnceWhenUnregisterTwice() { + + final var watcher = mock(SchemeWatcher.class); + when(watcherManager.watchers()).thenReturn(List.of(watcher)); + + schemeManager.register(FakeExtension.class); + + var scheme = schemeManager.get(FakeExtension.class); + + schemeManager.unregister(scheme); + verify(watcherManager, times(2)).watchers(); + verify(watcher, times(1)).onChange(isA(SchemeUnregistered.class)); + + schemeManager.unregister(scheme); + verify(watcherManager, times(2)).watchers(); + verify(watcher, times(1)).onChange(isA(SchemeUnregistered.class)); + verify(indexSpecRegistry).indexFor(any(Scheme.class)); + } + + @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 { + } +} + diff --git a/application/src/test/java/run/halo/app/extension/DefaultSchemeWatcherManagerTest.java b/application/src/test/java/run/halo/app/extension/DefaultSchemeWatcherManagerTest.java new file mode 100644 index 0000000..8e3ad08 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/DefaultSchemeWatcherManagerTest.java @@ -0,0 +1,59 @@ +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.mock; + +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import run.halo.app.extension.SchemeWatcherManager.SchemeWatcher; + +class DefaultSchemeWatcherManagerTest { + + DefaultSchemeWatcherManager watcherManager; + + @BeforeEach + void setUp() { + watcherManager = new DefaultSchemeWatcherManager(); + } + + @Test + void shouldThrowExceptionWhenRegisterNullWatcher() { + assertThrows(IllegalArgumentException.class, () -> watcherManager.register(null)); + } + + @Test + void shouldThrowExceptionWhenUnregisterNullWatcher() { + assertThrows(IllegalArgumentException.class, () -> watcherManager.unregister(null)); + } + + @Test + void shouldRegisterSuccessfully() { + var watcher = mock(SchemeWatcher.class); + watcherManager.register(watcher); + + assertEquals(watcherManager.watchers(), List.of(watcher)); + } + + @Test + void shouldUnregisterSuccessfully() { + var watcher = mock(SchemeWatcher.class); + watcherManager.register(watcher); + assertEquals(List.of(watcher), watcherManager.watchers()); + + watcherManager.unregister(watcher); + assertEquals(Collections.emptyList(), watcherManager.watchers()); + } + + @Test + void shouldReturnCopyOfWatchers() { + var firstWatcher = mock(SchemeWatcher.class); + var secondWatcher = mock(SchemeWatcher.class); + watcherManager.register(firstWatcher); + + var watchers = watcherManager.watchers(); + watchers.forEach(watcher -> watcherManager.register(secondWatcher)); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/ExtensionOperatorTest.java b/application/src/test/java/run/halo/app/extension/ExtensionOperatorTest.java new file mode 100644 index 0000000..bdb20f1 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/ExtensionOperatorTest.java @@ -0,0 +1,41 @@ +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)); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/ExtensionStoreUtilTest.java b/application/src/test/java/run/halo/app/extension/ExtensionStoreUtilTest.java new file mode 100644 index 0000000..1f70b3f --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/ExtensionStoreUtilTest.java @@ -0,0 +1,47 @@ +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); + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/FakeExtension.java b/application/src/test/java/run/halo/app/extension/FakeExtension.java new file mode 100644 index 0000000..5933c37 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/FakeExtension.java @@ -0,0 +1,31 @@ +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; + } +} diff --git a/application/src/test/java/run/halo/app/extension/GroupVersionKindTest.java b/application/src/test/java/run/halo/app/extension/GroupVersionKindTest.java new file mode 100644 index 0000000..53e17b6 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/GroupVersionKindTest.java @@ -0,0 +1,87 @@ +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()); + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/GroupVersionTest.java b/application/src/test/java/run/halo/app/extension/GroupVersionTest.java new file mode 100644 index 0000000..91edfec --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/GroupVersionTest.java @@ -0,0 +1,29 @@ +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"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/JsonExtensionConverterTest.java b/application/src/test/java/run/halo/app/extension/JsonExtensionConverterTest.java new file mode 100644 index 0000000..9f7e3ee --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/JsonExtensionConverterTest.java @@ -0,0 +1,104 @@ +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.mock; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.Locale; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import run.halo.app.extension.exception.ExtensionConvertException; +import run.halo.app.extension.exception.SchemaViolationException; +import run.halo.app.extension.index.IndexSpecRegistry; +import run.halo.app.extension.store.ExtensionStore; + +class JsonExtensionConverterTest { + + JSONExtensionConverter converter; + + ObjectMapper objectMapper; + + Locale localeDefault; + + @BeforeEach + void setUp() { + localeDefault = Locale.getDefault(); + Locale.setDefault(Locale.ENGLISH); + var indexSpecRegistry = mock(IndexSpecRegistry.class); + + DefaultSchemeManager schemeManager = new DefaultSchemeManager(indexSpecRegistry, null); + converter = new JSONExtensionConverter(schemeManager); + objectMapper = converter.getObjectMapper(); + + schemeManager.register(FakeExtension.class); + } + + @AfterEach + void cleanUp() { + Locale.setDefault(localeDefault); + } + + @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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/JsonExtensionTest.java b/application/src/test/java/run/halo/app/extension/JsonExtensionTest.java new file mode 100644 index 0000000..62c5596 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/JsonExtensionTest.java @@ -0,0 +1,80 @@ +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()); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/ListResultTest.java b/application/src/test/java/run/halo/app/extension/ListResultTest.java new file mode 100644 index 0000000..d4455dc --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/ListResultTest.java @@ -0,0 +1,78 @@ +package run.halo.app.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.ParameterizedType; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +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)); + } + + 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)); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/MetadataOperatorTest.java b/application/src/test/java/run/halo/app/extension/MetadataOperatorTest.java new file mode 100644 index 0000000..c5e016d --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/MetadataOperatorTest.java @@ -0,0 +1,91 @@ +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 static run.halo.app.extension.MetadataOperator.metadataDeepEquals; + +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 testMetadataDeepEqualsWithSameType() { + assertTrue(metadataDeepEquals(null, null)); + + var left = createFullMetadata(); + var right = createFullMetadata(); + assertFalse(metadataDeepEquals(left, null)); + assertFalse(metadataDeepEquals(null, right)); + assertTrue(metadataDeepEquals(left, right)); + + left.setDeletionTimestamp(null); + assertFalse(metadataDeepEquals(left, right)); + right.setDeletionTimestamp(null); + assertTrue(metadataDeepEquals(left, right)); + + left.setCreationTimestamp(null); + assertFalse(metadataDeepEquals(left, right)); + right.setCreationTimestamp(null); + assertTrue(metadataDeepEquals(left, right)); + + left.setVersion(null); + assertFalse(metadataDeepEquals(left, right)); + right.setVersion(null); + assertTrue(metadataDeepEquals(left, right)); + + left.setAnnotations(null); + assertFalse(metadataDeepEquals(left, right)); + right.setAnnotations(null); + assertTrue(metadataDeepEquals(left, right)); + + left.setLabels(null); + assertFalse(metadataDeepEquals(left, right)); + right.setLabels(null); + assertTrue(metadataDeepEquals(left, right)); + + left.setName(null); + assertFalse(metadataDeepEquals(left, right)); + right.setName(null); + assertTrue(metadataDeepEquals(left, right)); + + left.setFinalizers(null); + assertFalse(metadataDeepEquals(left, right)); + right.setFinalizers(null); + assertTrue(metadataDeepEquals(left, right)); + } + + @Test + void testMetadataDeepEqualsWithDifferentType() { + 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(metadataDeepEquals(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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/ReactiveExtensionClientTest.java b/application/src/test/java/run/halo/app/extension/ReactiveExtensionClientTest.java new file mode 100644 index 0000000..3363dae --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/ReactiveExtensionClientTest.java @@ -0,0 +1,734 @@ +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.assertEquals; +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.mockito.ArgumentMatchers.any; +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.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.Indexer; +import run.halo.app.extension.index.IndexerFactory; +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 + IndexerFactory indexerFactory; + + @Mock + ReactiveTransactionManager reactiveTransactionManager; + + @Spy + ObjectMapper objectMapper = JsonMapper.builder() + .addModule(new JavaTimeModule()) + .build(); + + @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); + 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); + + var indexer = mock(Indexer.class); + when(indexerFactory.getIndexer(eq(fake.groupVersionKind()))).thenReturn(indexer); + + 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()); + verify(indexer).indexRecord(eq(fake)); + } + + @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); + + var indexer = mock(Indexer.class); + when(indexerFactory.getIndexer(eq(fake.groupVersionKind()))).thenReturn(indexer); + + 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()); + verify(indexer).indexRecord(eq(fake)); + } + + @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); + + var indexer = mock(Indexer.class); + when(indexerFactory.getIndexer(eq(fake.groupVersionKind()))).thenReturn(indexer); + + 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()); + + verify(indexer).indexRecord(assertArg(ext -> { + assertInstanceOf(FakeExtension.class, ext); + assertEquals("fake", ext.getMetadata().getName()); + })); + } + + @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))); + + 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); + + var indexer = mock(Indexer.class); + when(indexerFactory.getIndexer(eq(fake.groupVersionKind()))).thenReturn(indexer); + + 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()); + verify(indexer).updateRecord(eq(updatedFake)); + } + + @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))); + + 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); + + var indexer = mock(Indexer.class); + when(indexerFactory.getIndexer(eq(fake.groupVersionKind()))).thenReturn(indexer); + + 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()); + verify(indexer).updateRecord(eq(updatedFake)); + } + + @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))); + + 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); + + var indexer = mock(Indexer.class); + when(indexerFactory.getIndexer(eq(fake.groupVersionKind()))).thenReturn(indexer); + + 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()); + verify(indexer).updateRecord(assertArg(ext -> { + assertInstanceOf(FakeExtension.class, ext); + assertEquals("fake", ext.getMetadata().getName()); + })); + } + + @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); + + var indexer = mock(Indexer.class); + when(indexerFactory.getIndexer(eq(fake.groupVersionKind()))).thenReturn(indexer); + + 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()); + verify(indexer).updateRecord(eq(fake)); + } + + @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 shouldWatchRealType() { + var extensionStore = createExtensionStore("/registry/fake.halo.run/fakes/fake"); + var fake = createFakeExtension("fake", 1L); + var unstructured = Unstructured.OBJECT_MAPPER.convertValue(fake, Unstructured.class); + + when(storeClient.fetchByName(extensionStore.getName())) + .thenReturn(Mono.just(extensionStore)); + when(converter.convertTo(any())).thenReturn(extensionStore); + when(converter.convertFrom(same(Unstructured.class), any())).thenReturn(unstructured); + + var indexer = mock(Indexer.class); + when(indexerFactory.getIndexer(eq(fake.groupVersionKind()))).thenReturn(indexer); + + // on add + when(storeClient.create(any(), any())).thenReturn(Mono.just(extensionStore)); + doNothing().when(watcher).onAdd(any(Extension.class)); + StepVerifier.create(client.create(unstructured)) + .expectNext(unstructured) + .verifyComplete(); + verify(watcher, times(1)).onAdd(isA(FakeExtension.class)); + + // on update + when(storeClient.update(any(), any(), any())).thenReturn(Mono.just(extensionStore)); + + doNothing().when(watcher).onUpdate(any(), any()); + StepVerifier.create(client.update(unstructured)) + .expectNext(unstructured) + .verifyComplete(); + verify(watcher, times(1)) + .onUpdate(isA(FakeExtension.class), isA(FakeExtension.class)); + + // on delete + doNothing().when(watcher).onDelete(any()); + StepVerifier.create(client.delete(unstructured)) + .expectNext(unstructured) + .verifyComplete(); + verify(watcher, times(1)).onDelete(isA(FakeExtension.class)); + + } + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/RefTest.java b/application/src/test/java/run/halo/app/extension/RefTest.java new file mode 100644 index 0000000..70a913d --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/RefTest.java @@ -0,0 +1,25 @@ +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"))); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/SchemeTest.java b/application/src/test/java/run/halo/app/extension/SchemeTest.java new file mode 100644 index 0000000..83e8332 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/SchemeTest.java @@ -0,0 +1,114 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/UnstructuredTest.java b/application/src/test/java/run/halo/app/extension/UnstructuredTest.java new file mode 100644 index 0000000..8db9b04 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/UnstructuredTest.java @@ -0,0 +1,152 @@ +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 static run.halo.app.extension.MetadataOperator.metadataDeepEquals; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +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()); + metadataDeepEquals(createMetadata(), extension.getMetadata()); + } + + @Test + void shouldSetExtensionCorrectly() { + var extension = createUnstructured(); + + assertEquals("fake.halo.run/v1alpha1", extension.getApiVersion()); + assertEquals("Fake", extension.getKind()); + assertTrue(metadataDeepEquals(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()); + } + + 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; + } + +} diff --git a/application/src/test/java/run/halo/app/extension/gc/GcReconcilerTest.java b/application/src/test/java/run/halo/app/extension/gc/GcReconcilerTest.java new file mode 100644 index 0000000..455b5b8 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/gc/GcReconcilerTest.java @@ -0,0 +1,127 @@ +package run.halo.app.extension.gc; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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.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.ExtensionConverter; +import run.halo.app.extension.FakeExtension; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.Unstructured; +import run.halo.app.extension.index.Indexer; +import run.halo.app.extension.index.IndexerFactory; +import run.halo.app.extension.store.ExtensionStore; +import run.halo.app.extension.store.ExtensionStoreClient; + +@ExtendWith(MockitoExtension.class) +class GcReconcilerTest { + + @Mock + ExtensionClient client; + + @Mock + ExtensionStoreClient storeClient; + + @Mock + ExtensionConverter converter; + + @Mock + IndexerFactory indexerFactory; + + @InjectMocks + GcReconciler reconciler; + + @Test + void shouldDoNothingIfExtensionNotFound() { + var fake = createExtension(); + when(client.fetch(fake.groupVersionKind(), 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(fake.groupVersionKind(), fake.getMetadata().getName())) + .thenReturn(Optional.of(convertTo(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(fake.groupVersionKind(), fake.getMetadata().getName())) + .thenReturn(Optional.of(convertTo(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(fake.groupVersionKind(), fake.getMetadata().getName())) + .thenReturn(Optional.of(convertTo(fake))); + + ExtensionStore store = new ExtensionStore(); + store.setName("fake-store-name"); + store.setVersion(1L); + + when(converter.convertTo(any())).thenReturn(store); + + var indexer = mock(Indexer.class); + when(indexerFactory.getIndexer(any())).thenReturn(indexer); + + var result = reconciler.reconcile(createGcRequest()); + assertNull(result); + verify(converter).convertTo(any()); + verify(storeClient).delete("fake-store-name", 1L); + verify(indexer).unIndexRecord(eq(fake.getMetadata().getName())); + } + + GcRequest createGcRequest() { + var fake = createExtension(); + return new GcRequest(fake.groupVersionKind(), fake.getMetadata().getName()); + } + + Unstructured convertTo(FakeExtension fake) { + return Unstructured.OBJECT_MAPPER.convertValue(fake, Unstructured.class); + } + + FakeExtension createExtension() { + var fake = new FakeExtension(); + var metadata = new Metadata(); + metadata.setName("fake"); + fake.setMetadata(metadata); + return fake; + } +} diff --git a/application/src/test/java/run/halo/app/extension/gc/GcSynchronizerTest.java b/application/src/test/java/run/halo/app/extension/gc/GcSynchronizerTest.java new file mode 100644 index 0000000..48c270d --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/gc/GcSynchronizerTest.java @@ -0,0 +1,52 @@ +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.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; +import run.halo.app.extension.SchemeWatcherManager; +import run.halo.app.extension.SchemeWatcherManager.SchemeWatcher; + +@ExtendWith(MockitoExtension.class) +class GcSynchronizerTest { + + @Mock + ExtensionClient client; + + @Mock + SchemeManager schemeManager; + + @Mock + SchemeWatcherManager schemeWatcherManager; + + @InjectMocks + GcSynchronizer synchronizer; + + @Test + void shouldStartNormally() { + synchronizer.start(); + + assertFalse(synchronizer.isDisposed()); + verify(schemeWatcherManager).register(any(SchemeWatcher.class)); + verify(client).watch(isA(GcWatcher.class)); + verify(schemeManager).schemes(); + } + + @Test + void shouldDisposeSuccessfully() { + assertFalse(synchronizer.isDisposed()); + + synchronizer.dispose(); + + assertTrue(synchronizer.isDisposed()); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/gc/GcWatcherTest.java b/application/src/test/java/run/halo/app/extension/gc/GcWatcherTest.java new file mode 100644 index 0000000..e36f85d --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/gc/GcWatcherTest.java @@ -0,0 +1,92 @@ +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; + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/DefaultExtensionIteratorTest.java b/application/src/test/java/run/halo/app/extension/index/DefaultExtensionIteratorTest.java new file mode 100644 index 0000000..d71d580 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/DefaultExtensionIteratorTest.java @@ -0,0 +1,85 @@ +package run.halo.app.extension.index; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.NoSuchElementException; +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.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import run.halo.app.extension.Extension; + +/** + * Tests for {@link DefaultExtensionIterator}. + * + * @author guqing + * @since 2.12.0 + */ +@ExtendWith(MockitoExtension.class) +class DefaultExtensionIteratorTest { + + @Mock + private ExtensionPaginatedLister lister; + + @Test + @SuppressWarnings("unchecked") + void testConstructor_loadsData() { + Page page = mock(Page.class); + when(page.getContent()).thenReturn(List.of(mock(Extension.class))); + when(page.hasNext()).thenReturn(true); + when(page.nextPageable()).thenReturn( + PageRequest.of(1, DefaultExtensionIterator.DEFAULT_PAGE_SIZE, Sort.by("name"))); + when(lister.list(any())).thenReturn(page); + + var iterator = new DefaultExtensionIterator<>(lister); + + assertThat(iterator.hasNext()).isTrue(); + } + + @Test + @SuppressWarnings("unchecked") + void hasNext_whenNextPageExists_loadsNextPage() { + Page page1 = mock(Page.class); + when(page1.getContent()).thenReturn(List.of(mock(Extension.class))); + when(page1.hasNext()).thenReturn(true); + when(page1.nextPageable()).thenReturn( + PageRequest.of(1, DefaultExtensionIterator.DEFAULT_PAGE_SIZE, Sort.by("name"))); + + Page page2 = mock(Page.class); + when(page2.getContent()).thenReturn(List.of(mock(Extension.class))); + when(page2.hasNext()).thenReturn(false); + + when(lister.list(any(Pageable.class))).thenReturn(page1, page2); + + var iterator = new DefaultExtensionIterator<>(lister); + // consume first page + iterator.next(); + + // should load the next page + assertThat(iterator.hasNext()).isTrue(); + } + + @Test + @SuppressWarnings("unchecked") + void next_whenNoNextElement_throwsException() { + Page page = mock(Page.class); + when(page.getContent()).thenReturn(List.of(mock(Extension.class))); + when(page.hasNext()).thenReturn(false); + when(lister.list(any())).thenReturn(page); + + var iterator = new DefaultExtensionIterator<>(lister); + // consume only element + iterator.next(); + + assertThatThrownBy(iterator::next).isInstanceOf(NoSuchElementException.class); + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/DefaultIndexSpecsTest.java b/application/src/test/java/run/halo/app/extension/index/DefaultIndexSpecsTest.java new file mode 100644 index 0000000..1b12a81 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/DefaultIndexSpecsTest.java @@ -0,0 +1,75 @@ +package run.halo.app.extension.index; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static run.halo.app.extension.index.PrimaryKeySpecUtils.primaryKeyIndexSpec; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.junit.jupiter.api.Test; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +/** + * Test for {@link DefaultIndexSpecs}. + * + * @author guqing + * @since 2.12.0 + */ +class DefaultIndexSpecsTest { + + @Test + void add() { + var specs = new DefaultIndexSpecs(); + specs.add(primaryKeyIndexSpec(FakeExtension.class)); + assertThat(specs.contains(PrimaryKeySpecUtils.PRIMARY_INDEX_NAME)).isTrue(); + } + + @Test + void addWithException() { + var specs = new DefaultIndexSpecs(); + var nameSpec = new IndexSpec().setName("test"); + assertThatThrownBy(() -> specs.add(nameSpec)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("IndexSpec indexFunc must not be null"); + nameSpec.setIndexFunc(IndexAttributeFactory.simpleAttribute(FakeExtension.class, + e -> e.getMetadata().getName())); + specs.add(nameSpec); + assertThatThrownBy(() -> specs.add(nameSpec)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("IndexSpec with name test already exists"); + } + + @Test + void getIndexSpecs() { + var specs = new DefaultIndexSpecs(); + specs.add(primaryKeyIndexSpec(FakeExtension.class)); + assertThat(specs.getIndexSpecs()).hasSize(1); + } + + @Test + void getIndexSpec() { + var specs = new DefaultIndexSpecs(); + var nameSpec = primaryKeyIndexSpec(FakeExtension.class); + specs.add(nameSpec); + assertThat(specs.getIndexSpec(PrimaryKeySpecUtils.PRIMARY_INDEX_NAME)).isEqualTo(nameSpec); + } + + @Test + void remove() { + var specs = new DefaultIndexSpecs(); + var nameSpec = primaryKeyIndexSpec(FakeExtension.class); + specs.add(nameSpec); + assertThat(specs.contains(PrimaryKeySpecUtils.PRIMARY_INDEX_NAME)).isTrue(); + specs.remove(PrimaryKeySpecUtils.PRIMARY_INDEX_NAME); + assertThat(specs.contains(PrimaryKeySpecUtils.PRIMARY_INDEX_NAME)).isFalse(); + } + + @Data + @EqualsAndHashCode(callSuper = true) + @GVK(group = "test", version = "v1", kind = "FakeExtension", plural = "fakeextensions", + singular = "fakeextension") + static class FakeExtension extends AbstractExtension { + private String email; + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/DefaultIndexerTest.java b/application/src/test/java/run/halo/app/extension/index/DefaultIndexerTest.java new file mode 100644 index 0000000..a1d6010 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/DefaultIndexerTest.java @@ -0,0 +1,277 @@ +package run.halo.app.extension.index; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Map; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; +import run.halo.app.extension.Metadata; +import run.halo.app.infra.exception.DuplicateNameException; + +/** + * Tests for {@link DefaultIndexer}. + * + * @author guqing + * @since 2.12.0 + */ +@ExtendWith(MockitoExtension.class) +class DefaultIndexerTest { + + private static FakeExtension createFakeExtension() { + var fake = new FakeExtension(); + fake.setMetadata(new Metadata()); + fake.getMetadata().setName("fake-extension"); + fake.setEmail("fake-email"); + return fake; + } + + private static IndexSpec getNameIndexSpec() { + return getIndexSpec("metadata.name", true, + IndexAttributeFactory.simpleAttribute(FakeExtension.class, + e -> e.getMetadata().getName())); + } + + private static IndexSpec getIndexSpec(String name, boolean unique, IndexAttribute attribute) { + return new IndexSpec() + .setName(name) + .setOrder(IndexSpec.OrderType.ASC) + .setUnique(unique) + .setIndexFunc(attribute); + } + + @Test + void constructor() { + var spec = getNameIndexSpec(); + var descriptor = new IndexDescriptor(spec); + descriptor.setReady(true); + var indexContainer = new IndexEntryContainer(); + indexContainer.add(new IndexEntryImpl(descriptor)); + new DefaultIndexer(List.of(descriptor), indexContainer); + } + + @Test + void constructorWithException() { + var spec = getNameIndexSpec(); + var descriptor = new IndexDescriptor(spec); + var indexContainer = new IndexEntryContainer(); + assertThatThrownBy(() -> new DefaultIndexer(List.of(descriptor), indexContainer)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Index descriptor is not ready for: metadata.name"); + descriptor.setReady(true); + assertThatThrownBy(() -> new DefaultIndexer(List.of(descriptor), indexContainer)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Index entry not found for: metadata.name"); + } + + @Test + void getIndexEntryTest() { + var spec = getNameIndexSpec(); + var descriptor = new IndexDescriptor(spec); + descriptor.setReady(true); + var indexContainer = new IndexEntryContainer(); + indexContainer.add(new IndexEntryImpl(descriptor)); + + var defaultIndexer = new DefaultIndexer(List.of(descriptor), indexContainer); + assertThatThrownBy(() -> defaultIndexer.getIndexEntry("not-exist")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No index found for fieldPath [not-exist], " + + "make sure you have created an index for this field."); + + assertThat(defaultIndexer.getIndexEntry("metadata.name")).isNotNull(); + } + + @Test + void getObjectKey() { + var fake = createFakeExtension(); + assertThat(DefaultIndexer.getObjectKey(fake)).isEqualTo("fake-extension"); + } + + @Test + void indexRecord() { + var nameIndex = getNameIndexSpec(); + var indexContainer = new IndexEntryContainer(); + var descriptor = new IndexDescriptor(nameIndex); + descriptor.setReady(true); + indexContainer.add(new IndexEntryImpl(descriptor)); + + var indexer = new DefaultIndexer(List.of(descriptor), indexContainer); + indexer.indexRecord(createFakeExtension()); + + var iterator = indexer.allIndexesIterator(); + assertThat(iterator.hasNext()).isTrue(); + var indexEntry = iterator.next(); + var entries = indexEntry.entries(); + assertThat(entries).hasSize(1); + assertThat(entries).contains(Map.entry("fake-extension", "fake-extension")); + } + + @Test + void indexRecordWithExceptionShouldRollback() { + var indexContainer = new IndexEntryContainer(); + // add email before name + var emailDescriptor = new IndexDescriptor(getIndexSpec("email", false, + IndexAttributeFactory.simpleAttribute(FakeExtension.class, FakeExtension::getEmail))); + emailDescriptor.setReady(true); + var emailIndexEntry = new IndexEntryImpl(emailDescriptor); + indexContainer.add(emailIndexEntry); + + var descriptor = new IndexDescriptor(getNameIndexSpec()); + descriptor.setReady(true); + var nameIndexEntry = new IndexEntryImpl(descriptor); + indexContainer.add(nameIndexEntry); + + var indexer = new DefaultIndexer(List.of(descriptor, emailDescriptor), indexContainer); + + indexer.indexRecord(createFakeExtension()); + assertThat(emailIndexEntry.entries()).hasSize(1); + assertThat(nameIndexEntry.entries()).hasSize(1); + + var fake2 = createFakeExtension(); + fake2.setEmail("email-2"); + + // email applied to entry then name duplicate + assertThatThrownBy(() -> indexer.indexRecord(fake2)) + .isInstanceOf(DuplicateNameException.class) + .hasMessage( + "400 BAD_REQUEST \"The value [fake-extension] is already exists for unique index " + + "[metadata.name].\""); + + // should be rollback email-2 key + assertThat(emailIndexEntry.entries()).hasSize(1); + assertThat(nameIndexEntry.entries()).hasSize(1); + } + + @Test + void updateRecordWithExceptionShouldRollback() { + var indexContainer = new IndexEntryContainer(); + // add email before name + var emailDescriptor = new IndexDescriptor(getIndexSpec("email", false, + IndexAttributeFactory.simpleAttribute(FakeExtension.class, FakeExtension::getEmail))); + emailDescriptor.setReady(true); + var emailIndexEntry = new IndexEntryImpl(emailDescriptor); + indexContainer.add(emailIndexEntry); + + var descriptor = new IndexDescriptor(getNameIndexSpec()); + descriptor.setReady(true); + var nameIndexEntry = new IndexEntryImpl(descriptor); + indexContainer.add(nameIndexEntry); + + var indexer = new DefaultIndexer(List.of(descriptor, emailDescriptor), indexContainer); + + var fakeExtension = createFakeExtension(); + indexer.indexRecord(fakeExtension); + + assertThat(emailIndexEntry.entries()).hasSize(1); + assertThat(emailIndexEntry.entries()).contains(Map.entry("fake-email", "fake-extension")); + assertThat(nameIndexEntry.entries()).hasSize(1); + assertThat(nameIndexEntry.entries()).contains( + Map.entry("fake-extension", "fake-extension")); + + fakeExtension.setEmail("email-2"); + indexer.updateRecord(fakeExtension); + assertThat(emailIndexEntry.entries()).hasSize(1); + assertThat(emailIndexEntry.entries()).contains(Map.entry("email-2", "fake-extension")); + assertThat(nameIndexEntry.entries()).hasSize(1); + assertThat(nameIndexEntry.entries()).contains( + Map.entry("fake-extension", "fake-extension")); + + fakeExtension.getMetadata().setName("fake-extension-2"); + indexer.updateRecord(fakeExtension); + assertThat(emailIndexEntry.entries()) + .containsExactly(Map.entry("email-2", "fake-extension"), + Map.entry("email-2", "fake-extension-2")); + assertThat(nameIndexEntry.entries()) + .containsExactly(Map.entry("fake-extension", "fake-extension"), + Map.entry("fake-extension-2", "fake-extension-2")); + } + + @Test + void findIndexByName() { + var indexContainer = new IndexEntryContainer(); + // add email before name + var emailDescriptor = new IndexDescriptor(getIndexSpec("email", false, + IndexAttributeFactory.simpleAttribute(FakeExtension.class, FakeExtension::getEmail))); + emailDescriptor.setReady(true); + var emailIndexEntry = new IndexEntryImpl(emailDescriptor); + indexContainer.add(emailIndexEntry); + + var descriptor = new IndexDescriptor(getNameIndexSpec()); + descriptor.setReady(true); + var nameIndexEntry = new IndexEntryImpl(descriptor); + indexContainer.add(nameIndexEntry); + + var indexer = new DefaultIndexer(List.of(descriptor, emailDescriptor), indexContainer); + + var foundNameDescriptor = indexer.findIndexByName("metadata.name"); + assertThat(foundNameDescriptor).isNotNull(); + assertThat(foundNameDescriptor).isEqualTo(descriptor); + + var foundEmailDescriptor = indexer.findIndexByName("email"); + assertThat(foundEmailDescriptor).isNotNull(); + assertThat(foundEmailDescriptor).isEqualTo(emailDescriptor); + } + + @Test + void createIndexEntry() { + var nameSpec = getNameIndexSpec(); + var descriptor = new IndexDescriptor(nameSpec); + descriptor.setReady(true); + var indexContainer = new IndexEntryContainer(); + indexContainer.add(new IndexEntryImpl(descriptor)); + var indexer = new DefaultIndexer(List.of(descriptor), indexContainer); + var indexEntry = indexer.createIndexEntry(descriptor); + assertThat(indexEntry).isNotNull(); + } + + @Test + void removeIndexRecord() { + var nameIndex = getNameIndexSpec(); + var indexContainer = new IndexEntryContainer(); + var descriptor = new IndexDescriptor(nameIndex); + descriptor.setReady(true); + var nameIndexEntry = new IndexEntryImpl(descriptor); + indexContainer.add(nameIndexEntry); + + var indexer = new DefaultIndexer(List.of(descriptor), indexContainer); + indexer.indexRecord(createFakeExtension()); + + assertThat(nameIndexEntry.entries()) + .containsExactly(Map.entry("fake-extension", "fake-extension")); + + indexer.removeIndexRecords(d -> true); + assertThat(nameIndexEntry.entries()).isEmpty(); + } + + @Test + void readyIndexesIterator() { + var indexContainer = new IndexEntryContainer(); + var descriptor = new IndexDescriptor(getNameIndexSpec()); + descriptor.setReady(true); + var nameIndexEntry = new IndexEntryImpl(descriptor); + indexContainer.add(nameIndexEntry); + + var indexer = new DefaultIndexer(List.of(descriptor), indexContainer); + + var iterator = indexer.readyIndexesIterator(); + assertThat(iterator.hasNext()).isTrue(); + + descriptor.setReady(false); + iterator = indexer.readyIndexesIterator(); + assertThat(iterator.hasNext()).isFalse(); + } + + @Data + @EqualsAndHashCode(callSuper = true) + @GVK(group = "test", version = "v1", kind = "FakeExtension", plural = "fakeextensions", + singular = "fakeextension") + static class FakeExtension extends AbstractExtension { + private String email; + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/IndexDescriptorTest.java b/application/src/test/java/run/halo/app/extension/index/IndexDescriptorTest.java new file mode 100644 index 0000000..08c3e44 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/IndexDescriptorTest.java @@ -0,0 +1,40 @@ +package run.halo.app.extension.index; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import run.halo.app.extension.FakeExtension; + +/** + * Tests for {@link IndexDescriptor}. + * + * @author guqing + * @since 2.12.0 + */ +class IndexDescriptorTest { + + @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 descriptor = new IndexDescriptor(spec1); + var descriptor2 = new IndexDescriptor(spec1); + assertThat(descriptor).isEqualTo(descriptor2); + + var spec2 = new IndexSpec() + .setName("metadata.name") + .setOrder(IndexSpec.OrderType.DESC) + .setIndexFunc(IndexAttributeFactory.simpleAttribute(FakeExtension.class, + e -> e.getMetadata().getName()) + ) + .setUnique(false); + var descriptor3 = new IndexDescriptor(spec2); + assertThat(descriptor).isEqualTo(descriptor3); + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/IndexEntryContainerTest.java b/application/src/test/java/run/halo/app/extension/index/IndexEntryContainerTest.java new file mode 100644 index 0000000..05eb534 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/IndexEntryContainerTest.java @@ -0,0 +1,56 @@ +package run.halo.app.extension.index; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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 IndexEntryContainer}. + * + * @author guqing + * @since 2.12.0 + */ +class IndexEntryContainerTest { + + @Test + void add() { + IndexEntryContainer indexEntry = new IndexEntryContainer(); + var spec = PrimaryKeySpecUtils.primaryKeyIndexSpec(FakeExtension.class); + var descriptor = new IndexDescriptor(spec); + var entry = new IndexEntryImpl(descriptor); + indexEntry.add(entry); + assertThat(indexEntry.contains(descriptor)).isTrue(); + + assertThatThrownBy(() -> indexEntry.add(entry)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Index entry already exists for " + descriptor); + } + + @Test + void remove() { + IndexEntryContainer indexEntry = new IndexEntryContainer(); + var spec = PrimaryKeySpecUtils.primaryKeyIndexSpec(FakeExtension.class); + var descriptor = new IndexDescriptor(spec); + var entry = new IndexEntryImpl(descriptor); + indexEntry.add(entry); + assertThat(indexEntry.contains(descriptor)).isTrue(); + assertThat(indexEntry.size()).isEqualTo(1); + + indexEntry.remove(descriptor); + assertThat(indexEntry.contains(descriptor)).isFalse(); + assertThat(indexEntry.size()).isEqualTo(0); + } + + @Data + @EqualsAndHashCode(callSuper = true) + @GVK(group = "test", version = "v1", kind = "FakeExtension", plural = "fakes", + singular = "fake") + static class FakeExtension extends AbstractExtension { + + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/IndexEntryImplTest.java b/application/src/test/java/run/halo/app/extension/index/IndexEntryImplTest.java new file mode 100644 index 0000000..fbadfed --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/IndexEntryImplTest.java @@ -0,0 +1,150 @@ +package run.halo.app.extension.index; + +import static org.assertj.core.api.Assertions.assertThat; +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.extension.index.query.IndexViewDataSet.createCommentIndexView; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import run.halo.app.extension.index.query.IndexViewDataSet; +import run.halo.app.extension.index.query.QueryIndexView; + +/** + * Tests for {@link IndexEntryImpl}. + * + * @author guqing + * @since 2.12.0 + */ +@ExtendWith(MockitoExtension.class) +class IndexEntryImplTest { + + @Test + void add() { + var spec = + PrimaryKeySpecUtils.primaryKeyIndexSpec(IndexEntryContainerTest.FakeExtension.class); + var descriptor = new IndexDescriptor(spec); + var entry = new IndexEntryImpl(descriptor); + entry.addEntry(List.of("slug-1"), "fake-name-1"); + assertThat(entry.indexedKeys()).containsExactly("slug-1"); + } + + @Test + void remove() { + var spec = + PrimaryKeySpecUtils.primaryKeyIndexSpec(IndexEntryContainerTest.FakeExtension.class); + var descriptor = new IndexDescriptor(spec); + var entry = new IndexEntryImpl(descriptor); + entry.addEntry(List.of("slug-1"), "fake-name-1"); + assertThat(entry.indexedKeys()).containsExactly("slug-1"); + assertThat(entry.entries()).hasSize(1); + + entry.removeEntry("slug-1", "fake-name-1"); + assertThat(entry.indexedKeys()).isEmpty(); + assertThat(entry.entries()).isEmpty(); + } + + @Test + void removeByIndex() { + var spec = + PrimaryKeySpecUtils.primaryKeyIndexSpec(IndexEntryContainerTest.FakeExtension.class); + var descriptor = new IndexDescriptor(spec); + var entry = new IndexEntryImpl(descriptor); + entry.addEntry(List.of("slug-1", "slug-2"), "fake-name-1"); + assertThat(entry.indexedKeys()).containsExactly("slug-1", "slug-2"); + assertThat(entry.entries()).hasSize(2); + + entry.remove("fake-name-1"); + assertThat(entry.indexedKeys()).isEmpty(); + assertThat(entry.entries()).isEmpty(); + } + + @Test + void getObjectIdsTest() { + var spec = + PrimaryKeySpecUtils.primaryKeyIndexSpec(IndexEntryContainerTest.FakeExtension.class); + var descriptor = new IndexDescriptor(spec); + var entry = new IndexEntryImpl(descriptor); + entry.addEntry(List.of("slug-1", "slug-2"), "fake-name-1"); + assertThat(entry.indexedKeys()).containsExactly("slug-1", "slug-2"); + assertThat(entry.entries()).hasSize(2); + + assertThat(entry.getObjectNamesBy("slug-1")).isEqualTo(List.of("fake-name-1")); + } + + @Test + void keyOrder() { + var spec = + PrimaryKeySpecUtils.primaryKeyIndexSpec(IndexEntryContainerTest.FakeExtension.class); + spec.setOrder(IndexSpec.OrderType.DESC); + var descriptor = new IndexDescriptor(spec); + var entry = new IndexEntryImpl(descriptor); + entry.addEntry(List.of("slug-1", "slug-2"), "fake-name-1"); + entry.addEntry(List.of("slug-3"), "fake-name-2"); + entry.addEntry(List.of("slug-4"), "fake-name-3"); + entry.addEntry(List.of("slug-5"), "fake-name-1"); + assertThat(entry.entries()) + .containsSequence( + Map.entry("slug-5", "fake-name-1"), + Map.entry("slug-4", "fake-name-3"), + Map.entry("slug-3", "fake-name-2"), + Map.entry("slug-2", "fake-name-1"), + Map.entry("slug-1", "fake-name-1")); + + assertThat(entry.indexedKeys()).containsSequence("slug-4", "slug-3", "slug-2", "slug-1"); + + spec.setOrder(IndexSpec.OrderType.ASC); + var descriptor2 = new IndexDescriptor(spec); + var entry2 = new IndexEntryImpl(descriptor2); + entry2.addEntry(List.of("slug-1", "slug-2"), "fake-name-1"); + entry2.addEntry(List.of("slug-3"), "fake-name-2"); + entry2.addEntry(List.of("slug-4"), "fake-name-3"); + assertThat(entry2.entries()) + .containsSequence(Map.entry("slug-1", "fake-name-1"), + Map.entry("slug-2", "fake-name-1"), + Map.entry("slug-3", "fake-name-2"), + Map.entry("slug-4", "fake-name-3")); + assertThat(entry2.indexedKeys()).containsSequence("slug-1", "slug-2", "slug-3", "slug-4"); + } + + @Test + void getIdPositionMapTest() { + var indexView = createCommentIndexView(); + var topIndexEntry = prepareForPositionMapTest(indexView, "spec.top"); + var topIndexEntryFromView = indexView.getIndexEntry("spec.top"); + assertThat(topIndexEntry.getIdPositionMap()) + .isEqualTo(topIndexEntryFromView.getIdPositionMap()); + + var creationTimeIndexEntry = prepareForPositionMapTest(indexView, "spec.creationTime"); + var creationTimeIndexEntryFromView = indexView.getIndexEntry("spec.creationTime"); + assertThat(creationTimeIndexEntry.getIdPositionMap()) + .isEqualTo(creationTimeIndexEntryFromView.getIdPositionMap()); + + var priorityIndexEntry = prepareForPositionMapTest(indexView, "spec.priority"); + var priorityIndexEntryFromView = indexView.getIndexEntry("spec.priority"); + assertThat(priorityIndexEntry.getIdPositionMap()) + .isEqualTo(priorityIndexEntryFromView.getIdPositionMap()); + } + + IndexEntry prepareForPositionMapTest(QueryIndexView indexView, String property) { + var indexSpec = mock(IndexSpec.class); + var descriptor = mock(IndexDescriptor.class); + when(descriptor.getSpec()).thenReturn(indexSpec); + var indexEntry = new IndexEntryImpl(descriptor); + + var indexEntryFromView = indexView.getIndexEntry(property); + var sortedEntries = IndexViewDataSet.sortEntries(indexEntryFromView.entries()); + + var spyIndexEntry = spy(indexEntry); + + doReturn(IndexViewDataSet.toKeyObjectMap(sortedEntries)) + .when(spyIndexEntry).getKeyObjectMap(); + + return spyIndexEntry; + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/IndexEntryOperatorImplTest.java b/application/src/test/java/run/halo/app/extension/index/IndexEntryOperatorImplTest.java new file mode 100644 index 0000000..15d0f15 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/IndexEntryOperatorImplTest.java @@ -0,0 +1,177 @@ +package run.halo.app.extension.index; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +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.index.query.IndexViewDataSet; + +/** + * Tests for {@link IndexEntryOperatorImpl}. + * + * @author guqing + * @since 2.17.0 + */ +@ExtendWith(MockitoExtension.class) +class IndexEntryOperatorImplTest { + + @Mock + private IndexEntry indexEntry; + + @InjectMocks + private IndexEntryOperatorImpl indexEntryOperator; + + private LinkedHashMap> createIndexedMapAndPile() { + var entries = new ArrayList>(); + entries.add(Map.entry("apple", "A")); + entries.add(Map.entry("banana", "B")); + entries.add(Map.entry("cherry", "C")); + entries.add(Map.entry("date", "D")); + entries.add(Map.entry("egg", "E")); + entries.add(Map.entry("f", "F")); + + var indexedMap = IndexViewDataSet.toKeyObjectMap(entries); + lenient().when(indexEntry.indexedKeys()).thenReturn(new TreeSet<>(indexedMap.keySet())); + lenient().when(indexEntry.getObjectNamesBy(anyString())).thenAnswer(invocation -> { + var key = (String) invocation.getArgument(0); + return indexedMap.get(key); + }); + lenient().when(indexEntry.entries()).thenReturn(entries); + return indexedMap; + } + + @Test + void lessThan() { + final var indexedMap = createIndexedMapAndPile(); + + var result = indexEntryOperator.lessThan("banana", false); + assertThat(result).containsExactly("A"); + + result = indexEntryOperator.lessThan("banana", true); + assertThat(result).containsExactly("A", "B"); + + result = indexEntryOperator.lessThan("cherry", true); + assertThat(result).containsExactly("A", "B", "C"); + + // does not exist key + result = indexEntryOperator.lessThan("z", false); + var objectIds = indexedMap.values().stream() + .flatMap(Collection::stream) + .toArray(String[]::new); + assertThat(result).contains(objectIds); + + result = indexEntryOperator.lessThan("a", false); + assertThat(result).isEmpty(); + } + + @Test + void greaterThan() { + createIndexedMapAndPile(); + + var result = indexEntryOperator.greaterThan("banana", false); + assertThat(result).containsExactly("C", "D", "E", "F"); + + result = indexEntryOperator.greaterThan("banana", true); + assertThat(result).containsExactly("B", "C", "D", "E", "F"); + + result = indexEntryOperator.greaterThan("cherry", true); + assertThat(result).containsExactly("C", "D", "E", "F"); + + result = indexEntryOperator.greaterThan("cherry", false); + assertThat(result).containsExactly("D", "E", "F"); + + // does not exist key + result = indexEntryOperator.greaterThan("z", false); + assertThat(result).isEmpty(); + } + + @Test + void greaterThanForNumberString() { + var entries = List.of( + Map.entry("100", "1"), + Map.entry("101", "2"), + Map.entry("102", "3"), + Map.entry("103", "4"), + Map.entry("110", "5"), + Map.entry("111", "6"), + Map.entry("112", "7"), + Map.entry("120", "8") + ); + var indexedMap = IndexViewDataSet.toKeyObjectMap(entries); + when(indexEntry.indexedKeys()).thenReturn(new TreeSet<>(indexedMap.keySet())); + lenient().when(indexEntry.getObjectNamesBy(anyString())).thenAnswer(invocation -> { + var key = (String) invocation.getArgument(0); + return indexedMap.get(key); + }); + when(indexEntry.entries()).thenReturn(entries); + + var result = indexEntryOperator.greaterThan("102", false); + assertThat(result).containsExactly("4", "5", "6", "7", "8"); + + result = indexEntryOperator.greaterThan("110", false); + assertThat(result).containsExactly("6", "7", "8"); + } + + @Test + void range() { + createIndexedMapAndPile(); + + var result = indexEntryOperator.range("banana", "date", true, false); + assertThat(result).containsExactly("B", "C"); + + result = indexEntryOperator.range("banana", "date", false, false); + assertThat(result).containsExactly("C"); + + result = indexEntryOperator.range("banana", "date", true, true); + assertThat(result).containsExactly("B", "C", "D"); + + result = indexEntryOperator.range("apple", "egg", false, true); + assertThat(result).containsExactly("B", "C", "D", "E"); + + // end not exist + result = indexEntryOperator.range("d", "z", false, false); + assertThat(result).containsExactly("D", "E", "F"); + + // start key > end key + assertThatThrownBy(() -> indexEntryOperator.range("z", "f", false, false)) + .isInstanceOf(IllegalArgumentException.class); + + // both not exist + result = indexEntryOperator.range("z", "zz", false, false); + assertThat(result).isEmpty(); + } + + @Test + void findTest() { + createIndexedMapAndPile(); + + var result = indexEntryOperator.find("banana"); + assertThat(result).containsExactly("B"); + + result = indexEntryOperator.find("date"); + assertThat(result).containsExactly("D"); + + result = indexEntryOperator.find("z"); + assertThat(result).isEmpty(); + } + + @Test + void findInTest() { + createIndexedMapAndPile(); + var result = indexEntryOperator.findIn(List.of("banana", "date")); + assertThat(result).containsExactly("B", "D"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/index/IndexSpecRegistryImplTest.java b/application/src/test/java/run/halo/app/extension/index/IndexSpecRegistryImplTest.java new file mode 100644 index 0000000..1b8bd17 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/IndexSpecRegistryImplTest.java @@ -0,0 +1,73 @@ +package run.halo.app.extension.index; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Set; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.junit.jupiter.api.AfterEach; +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.Scheme; + +/** + * Tests for {@link IndexSpecRegistryImpl}. + * + * @author guqing + * @since 2.12.0 + */ +@ExtendWith(MockitoExtension.class) +class IndexSpecRegistryImplTest { + @InjectMocks + private IndexSpecRegistryImpl indexSpecRegistry; + + private final Scheme scheme = Scheme.buildFromType(FakeExtension.class); + + @AfterEach + void tearDown() { + indexSpecRegistry.removeIndexSpecs(scheme); + } + + @Test + void indexFor() { + var specs = indexSpecRegistry.indexFor(scheme); + assertThat(specs).isNotNull(); + assertThat(specs.getIndexSpecs()).hasSize(4); + } + + @Test + void contains() { + indexSpecRegistry.indexFor(scheme); + assertThat(indexSpecRegistry.contains(scheme)).isTrue(); + } + + @Test + void getKeySpace() { + var keySpace = indexSpecRegistry.getKeySpace(scheme); + assertThat(keySpace).isEqualTo("/registry/test.halo.run/fakes"); + } + + @Test + void getIndexSpecs() { + assertThatThrownBy(() -> indexSpecRegistry.getIndexSpecs(scheme)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("No index specs found for extension type: "); + + indexSpecRegistry.indexFor(scheme); + var specs = indexSpecRegistry.getIndexSpecs(scheme); + assertThat(specs).isNotNull(); + } + + @Data + @EqualsAndHashCode(callSuper = true) + @GVK(group = "test.halo.run", version = "v1", kind = "FakeExtension", plural = "fakes", + singular = "fake") + static class FakeExtension extends AbstractExtension { + Set tags; + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/IndexedQueryEngineImplTest.java b/application/src/test/java/run/halo/app/extension/index/IndexedQueryEngineImplTest.java new file mode 100644 index 0000000..ce7d19d --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/IndexedQueryEngineImplTest.java @@ -0,0 +1,194 @@ +package run.halo.app.extension.index; + +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.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 static run.halo.app.extension.index.query.IndexViewDataSet.pileForIndexer; +import static run.halo.app.extension.index.query.QueryFactory.equal; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import lombok.Data; +import lombok.EqualsAndHashCode; +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 run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; +import run.halo.app.extension.GroupVersionKind; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.PageRequest; +import run.halo.app.extension.router.selector.EqualityMatcher; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.extension.router.selector.LabelSelector; +import run.halo.app.extension.router.selector.SelectorMatcher; + +/** + * Tests for {@link IndexedQueryEngineImpl}. + * + * @author guqing + * @since 2.12.0 + */ +@ExtendWith(MockitoExtension.class) +class IndexedQueryEngineImplTest { + + @Mock + private IndexerFactory indexerFactory; + + @InjectMocks + private IndexedQueryEngineImpl indexedQueryEngine; + + @Test + void retrieve() { + var spyIndexedQueryEngine = spy(indexedQueryEngine); + doReturn(List.of("object1", "object2", "object3")).when(spyIndexedQueryEngine) + .doRetrieve(any(), any(), eq(Sort.unsorted())); + + var gvk = GroupVersionKind.fromExtension(DemoExtension.class); + + var pageRequest = mock(PageRequest.class); + when(pageRequest.getPageNumber()).thenReturn(1); + when(pageRequest.getPageSize()).thenReturn(2); + when(pageRequest.getSort()).thenReturn(Sort.unsorted()); + + var result = spyIndexedQueryEngine.retrieve(gvk, new ListOptions(), pageRequest); + assertThat(result.getItems()).containsExactly("object1", "object2"); + assertThat(result.getTotal()).isEqualTo(3); + + verify(spyIndexedQueryEngine).doRetrieve(eq(gvk), any(), eq(Sort.unsorted())); + verify(pageRequest, times(2)).getPageNumber(); + verify(pageRequest, times(2)).getPageSize(); + verify(pageRequest).getSort(); + } + + @Test + void retrieveAll() { + var spyIndexedQueryEngine = spy(indexedQueryEngine); + doReturn(List.of()).when(spyIndexedQueryEngine) + .doRetrieve(any(), any(), eq(Sort.unsorted())); + + var gvk = GroupVersionKind.fromExtension(DemoExtension.class); + + var result = spyIndexedQueryEngine.retrieveAll(gvk, new ListOptions(), Sort.unsorted()); + assertThat(result).isEmpty(); + + verify(spyIndexedQueryEngine).doRetrieve(eq(gvk), any(), eq(Sort.unsorted())); + } + + @Test + void doRetrieve() { + var indexer = mock(Indexer.class); + + var gvk = GroupVersionKind.fromExtension(DemoExtension.class); + + when(indexerFactory.getIndexer(eq(gvk))).thenReturn(indexer); + + pileForIndexer(indexer, PrimaryKeySpecUtils.PRIMARY_INDEX_NAME, List.of( + Map.entry("object1", "object1"), + Map.entry("object2", "object2"), + Map.entry("object3", "object3") + )); + + pileForIndexer(indexer, LabelIndexSpecUtils.LABEL_PATH, List.of( + Map.entry("key1=value1", "object1"), + Map.entry("key2=value2", "object1"), + Map.entry("key1=value1", "object2"), + Map.entry("key2=value2", "object2"), + Map.entry("key1=value1", "object3") + )); + + pileForIndexer(indexer, "slug", List.of( + Map.entry("slug1", "object1"), + Map.entry("slug2", "object2") + )); + + var listOptions = new ListOptions(); + listOptions.setLabelSelector(LabelSelector.builder() + .eq("key1", "value1").build()); + listOptions.setFieldSelector(FieldSelector.of(equal("slug", "slug1"))); + + var result = indexedQueryEngine.doRetrieve(gvk, listOptions, Sort.unsorted()); + assertThat(result).containsExactly("object1"); + } + + @Data + @EqualsAndHashCode(callSuper = true) + @GVK(group = "test", version = "v1", kind = "demo", plural = "demos", singular = "demo") + static class DemoExtension extends AbstractExtension { + + } + + @Nested + @ExtendWith(MockitoExtension.class) + class LabelMatcherTest { + @InjectMocks + private IndexedQueryEngineImpl indexedQueryEngine; + + @Test + void testRetrieveForLabelMatchers() { + // Setup mocks + IndexEntry indexEntryMock = mock(IndexEntry.class); + // Setup mock behavior + when(indexEntryMock.entries()) + .thenReturn(List.of(Map.entry("key1=value1", "object1"), + Map.entry("key2=value2", "object1"), + Map.entry("key1=value1", "object2"), + Map.entry("key2=value2", "object2"), + Map.entry("key1=value1", "object3"))); + + var matcher1 = EqualityMatcher.equal("key1", "value1"); + var matcher2 = EqualityMatcher.equal("key2", "value2"); + + List labelMatchers = Arrays.asList(matcher1, matcher2); + + var indexer = mock(Indexer.class); + when(indexer.getIndexEntry(eq(LabelIndexSpecUtils.LABEL_PATH))) + .thenReturn(indexEntryMock); + var nameIndexEntry = mock(IndexEntry.class); + when(indexer.getIndexEntry(eq(PrimaryKeySpecUtils.PRIMARY_INDEX_NAME))) + .thenReturn(nameIndexEntry); + when(nameIndexEntry.entries()).thenReturn(List.of(Map.entry("object1", "object1"), + Map.entry("object2", "object2"), Map.entry("object3", "object3"))); + // Test + assertThat(indexedQueryEngine.retrieveForLabelMatchers(indexer, labelMatchers)) + .containsSequence("object1", "object2"); + } + + @Test + void testRetrieveForLabelMatchersNoMatch() { + IndexEntry indexEntryMock = mock(IndexEntry.class); + // Setup mock behavior + when(indexEntryMock.entries()) + .thenReturn(List.of(Map.entry("key1=value1", "object1"), + Map.entry("key2=value2", "object2"), + Map.entry("key1=value3", "object3")) + ); + + var matcher1 = EqualityMatcher.equal("key3", "value3"); + List labelMatchers = List.of(matcher1); + + var indexer = mock(Indexer.class); + when(indexer.getIndexEntry(eq(LabelIndexSpecUtils.LABEL_PATH))) + .thenReturn(indexEntryMock); + var nameIndexEntry = mock(IndexEntry.class); + when(indexer.getIndexEntry(eq(PrimaryKeySpecUtils.PRIMARY_INDEX_NAME))) + .thenReturn(nameIndexEntry); + when(nameIndexEntry.entries()).thenReturn(List.of(Map.entry("object1", "object1"), + Map.entry("object2", "object2"), Map.entry("object3", "object3"))); + // Test + assertThat( + indexedQueryEngine.retrieveForLabelMatchers(indexer, labelMatchers)).isEmpty(); + } + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/IndexerFactoryImplTest.java b/application/src/test/java/run/halo/app/extension/index/IndexerFactoryImplTest.java new file mode 100644 index 0000000..2239d38 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/IndexerFactoryImplTest.java @@ -0,0 +1,93 @@ +package run.halo.app.extension.index; + +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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import lombok.Data; +import lombok.EqualsAndHashCode; +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.AbstractExtension; +import run.halo.app.extension.GVK; +import run.halo.app.extension.GroupVersionKind; +import run.halo.app.extension.Scheme; +import run.halo.app.extension.SchemeManager; + +/** + * Tests for {@link IndexerFactoryImpl}. + * + * @author guqing + * @since 2.12.0 + */ +@ExtendWith(MockitoExtension.class) +class IndexerFactoryImplTest { + @Mock + private SchemeManager schemeManager; + @Mock + private IndexSpecRegistry indexSpecRegistry; + + @InjectMocks + IndexerFactoryImpl indexerFactory; + + @Test + @SuppressWarnings("unchecked") + void indexFactory() { + var scheme = Scheme.buildFromType(DemoExtension.class); + when(schemeManager.get(eq(DemoExtension.class))) + .thenReturn(scheme); + when(indexSpecRegistry.getKeySpace(scheme)) + .thenReturn("/registry/test/demoextensions"); + when(indexSpecRegistry.contains(eq(scheme))) + .thenReturn(false); + var specs = mock(IndexSpecs.class); + when(indexSpecRegistry.getIndexSpecs(eq(scheme))) + .thenReturn(specs); + when(specs.getIndexSpecs()) + .thenReturn(List.of(PrimaryKeySpecUtils.primaryKeyIndexSpec(DemoExtension.class))); + ExtensionIterator iterator = mock(ExtensionIterator.class); + when(iterator.hasNext()).thenReturn(false); + // create indexer + var indexer = indexerFactory.createIndexerFor(DemoExtension.class, iterator); + assertThat(indexer).isNotNull(); + + when(schemeManager.fetch(eq(scheme.groupVersionKind()))).thenReturn(Optional.of(scheme)); + when(schemeManager.get(eq(scheme.groupVersionKind()))).thenReturn(scheme); + // contains indexer + var hasIndexer = indexerFactory.contains(scheme.groupVersionKind()); + assertThat(hasIndexer).isTrue(); + + assertThat(indexerFactory.contains( + new GroupVersionKind("test", "v1", "Post"))).isFalse(); + + // get indexer + var foundIndexer = indexerFactory.getIndexer(scheme.groupVersionKind()); + assertThat(foundIndexer).isEqualTo(indexer); + + // remove indexer + indexerFactory.removeIndexer(scheme); + assertThat(indexerFactory.contains(scheme.groupVersionKind())).isFalse(); + + // verify + verify(indexSpecRegistry).indexFor(eq(scheme)); + verify(schemeManager).get(eq(DemoExtension.class)); + verify(indexSpecRegistry, times(4)).getKeySpace(eq(scheme)); + verify(indexSpecRegistry).contains(eq(scheme)); + } + + @Data + @EqualsAndHashCode(callSuper = true) + @GVK(group = "test", version = "v1", kind = "DemoExtension", plural = "demoextensions", + singular = "demoextension") + static class DemoExtension extends AbstractExtension { + private String email; + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/LabelIndexSpecUtilsTest.java b/application/src/test/java/run/halo/app/extension/index/LabelIndexSpecUtilsTest.java new file mode 100644 index 0000000..529858b --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/LabelIndexSpecUtilsTest.java @@ -0,0 +1,48 @@ +package run.halo.app.extension.index; + +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.Test; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.Metadata; + +/** + * Tests for {@link LabelIndexSpecUtils}. + * + * @author guqing + * @since 2.12.0 + */ +class LabelIndexSpecUtilsTest { + + @Test + void labelKeyValuePair() { + var pair = LabelIndexSpecUtils.labelKeyValuePair("key=value"); + assertThat(pair.getFirst()).isEqualTo("key"); + assertThat(pair.getSecond()).isEqualTo("value"); + + pair = LabelIndexSpecUtils.labelKeyValuePair("key=value=1"); + assertThat(pair.getFirst()).isEqualTo("key"); + assertThat(pair.getSecond()).isEqualTo("value=1"); + + assertThatThrownBy(() -> LabelIndexSpecUtils.labelKeyValuePair("key")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid label key-value pair: key"); + } + + @Test + void labelIndexValueFunc() { + var ext = new TestExtension(); + ext.setMetadata(new Metadata()); + assertThat(LabelIndexSpecUtils.labelIndexValueFunc(ext)).isEmpty(); + + ext.getMetadata().setLabels(Map.of("key", "value", "key1", "value1")); + assertThat(LabelIndexSpecUtils.labelIndexValueFunc(ext)).containsExactlyInAnyOrder( + "key=value", "key1=value1"); + } + + static class TestExtension extends AbstractExtension { + + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/PrimaryKeySpecUtilsTest.java b/application/src/test/java/run/halo/app/extension/index/PrimaryKeySpecUtilsTest.java new file mode 100644 index 0000000..d182b3a --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/PrimaryKeySpecUtilsTest.java @@ -0,0 +1,46 @@ +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.Test; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; +import run.halo.app.extension.Metadata; + +/** + * Tests for {@link PrimaryKeySpecUtils}. + * + * @author guqing + * @since 2.12.0 + */ +class PrimaryKeySpecUtilsTest { + + @Test + void primaryKeyIndexSpec() { + var spec = + PrimaryKeySpecUtils.primaryKeyIndexSpec(FakeExtension.class); + assertThat(spec.getName()).isEqualTo("metadata.name"); + assertThat(spec.getOrder()).isEqualTo(IndexSpec.OrderType.ASC); + assertThat(spec.isUnique()).isTrue(); + assertThat(spec.getIndexFunc()).isNotNull(); + assertThat(spec.getIndexFunc().getObjectType()).isEqualTo(FakeExtension.class); + + var extension = new FakeExtension(); + extension.setMetadata(new Metadata()); + extension.getMetadata().setName("fake-name-1"); + + assertThat(spec.getIndexFunc().getValues(extension)) + .isEqualTo(Set.of("fake-name-1")); + } + + @Data + @EqualsAndHashCode(callSuper = true) + @GVK(group = "test", version = "v1", kind = "FakeExtension", plural = "fakes", + singular = "fake") + static class FakeExtension extends AbstractExtension { + + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/query/AndTest.java b/application/src/test/java/run/halo/app/extension/index/query/AndTest.java new file mode 100644 index 0000000..0c29a85 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/query/AndTest.java @@ -0,0 +1,128 @@ +package run.halo.app.extension.index.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static run.halo.app.extension.index.query.IndexViewDataSet.pileForIndexer; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.greaterThan; +import static run.halo.app.extension.index.query.QueryFactory.or; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import run.halo.app.extension.index.Indexer; + +/** + * Tests for the {@link And} query. + * + * @author guqing + * @since 2.12.0 + */ +public class AndTest { + + @Test + void testMatches() { + var deptEntry = List.of(Map.entry("A", "guqing"), + Map.entry("A", "halo"), + Map.entry("B", "lisi"), + Map.entry("B", "zhangsan"), + Map.entry("C", "ryanwang"), + Map.entry("C", "johnniang") + ); + var ageEntry = List.of(Map.entry("19", "halo"), + Map.entry("19", "guqing"), + Map.entry("18", "zhangsan"), + Map.entry("17", "lisi"), + Map.entry("17", "ryanwang"), + Map.entry("17", "johnniang") + ); + + var indexer = mock(Indexer.class); + + pileForIndexer(indexer, QueryIndexViewImpl.PRIMARY_INDEX_NAME, List.of( + Map.entry("guqing", "guqing"), + Map.entry("halo", "halo"), + Map.entry("lisi", "lisi"), + Map.entry("zhangsan", "zhangsan"), + Map.entry("ryanwang", "ryanwang"), + Map.entry("johnniang", "johnniang") + )); + + pileForIndexer(indexer, "dept", deptEntry); + + pileForIndexer(indexer, "age", ageEntry); + + var indexView = new QueryIndexViewImpl(indexer); + var query = and(equal("dept", "B"), equal("age", "18")); + var resultSet = query.matches(indexView); + assertThat(resultSet).containsExactly("zhangsan"); + + query = and(equal("dept", "C"), equal("age", "18")); + resultSet = query.matches(indexView); + assertThat(resultSet).isEmpty(); + + query = and( + // guqing, halo, lisi, zhangsan + or(equal("dept", "A"), equal("dept", "B")), + // guqing, halo, zhangsan + or(equal("age", "19"), equal("age", "18")) + ); + resultSet = query.matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder("guqing", "halo", "zhangsan"); + + query = and( + // guqing, halo, lisi, zhangsan + or(equal("dept", "A"), equal("dept", "B")), + // guqing, halo, zhangsan + or(equal("age", "19"), equal("age", "18")) + ); + resultSet = query.matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder("guqing", "halo", "zhangsan"); + + query = and( + // guqing, halo, lisi, zhangsan + or(equal("dept", "A"), equal("dept", "C")), + // guqing, halo, zhangsan + and(equal("age", "17"), equal("age", "17")) + ); + resultSet = query.matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder("ryanwang", "johnniang"); + } + + @Test + void andMatch2() { + var indexView = IndexViewDataSet.createEmployeeIndexView(); + var query = and(equal("lastName", "Fay"), + and( + equal("hireDate", "17"), + and(greaterThan("salary", "1000"), + and(equal("managerId", "101"), + equal("departmentId", "50") + ) + ) + ) + ); + var resultSet = query.matches(indexView); + assertThat(resultSet).containsExactly("100"); + } + + @Test + void orAndMatch() { + var indexView = IndexViewDataSet.createEmployeeIndexView(); + // test the case when the data matched by the query does not intersect with the data + // matched by the and query + // or(query, and(otherQuery1, otherQuery2)) + var query = or( + // matched with id 101 + and(equal("lastName", "Day"), equal("managerId", "102")), + // matched with id 100, 103 + and( + equal("hireDate", "17"), + greaterThan("salary", "1800") + ) + ); + var resultSet = query.matches(indexView); + assertThat(resultSet).containsExactly("100", "101", "103"); + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/query/IndexViewDataSet.java b/application/src/test/java/run/halo/app/extension/index/query/IndexViewDataSet.java new file mode 100644 index 0000000..5768ea8 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/query/IndexViewDataSet.java @@ -0,0 +1,340 @@ +package run.halo.app.extension.index.query; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import java.util.stream.Collectors; +import run.halo.app.extension.index.IndexEntry; +import run.halo.app.extension.index.Indexer; +import run.halo.app.extension.index.KeyComparator; + +public class IndexViewDataSet { + + /** + * Create a {@link QueryIndexView} for employee to test. + *
+     * | id | firstName | lastName | email | hireDate | salary | managerId | departmentId |
+     * |----|-----------|----------|-------|----------|--------|-----------|--------------|
+     * | 100| Pat       | Fay      | p     | 17       | 2600   | 101       | 50           |
+     * | 101| Lee       | Day      | l     | 17       | 2400   | 102       | 40           |
+     * | 102| William   | Jay      | w     | 19       | 2200   | 102       | 50           |
+     * | 103| Mary      | Day      | p     | 17       | 2000   | 103       | 50           |
+     * | 104| John      | Fay      | j     | 17       | 1800   | 103       | 50           |
+     * | 105| Gon       | Fay      | p     | 18       | 1900   | 101       | 40           |
+     * 
+ * + * @return a {@link QueryIndexView} for employee to test + */ + public static QueryIndexView createEmployeeIndexView() { + final var idEntry = List.of( + Map.entry("100", "100"), + Map.entry("101", "101"), + Map.entry("102", "102"), + Map.entry("103", "103"), + Map.entry("104", "104"), + Map.entry("105", "105") + ); + final var firstNameEntry = List.of( + Map.entry("Pat", "100"), + Map.entry("Lee", "101"), + Map.entry("William", "102"), + Map.entry("Mary", "103"), + Map.entry("John", "104"), + Map.entry("Gon", "105") + ); + final var lastNameEntry = List.of( + Map.entry("Fay", "100"), + Map.entry("Day", "101"), + Map.entry("Jay", "102"), + Map.entry("Day", "103"), + Map.entry("Fay", "104"), + Map.entry("Fay", "105") + ); + final var emailEntry = List.of( + Map.entry("p", "100"), + Map.entry("l", "101"), + Map.entry("w", "102"), + Map.entry("p", "103"), + Map.entry("j", "104"), + Map.entry("p", "105") + ); + final var hireDateEntry = List.of( + Map.entry("17", "100"), + Map.entry("17", "101"), + Map.entry("19", "102"), + Map.entry("17", "103"), + Map.entry("17", "104"), + Map.entry("18", "105") + ); + final var salaryEntry = List.of( + Map.entry("2600", "100"), + Map.entry("2400", "101"), + Map.entry("2200", "102"), + Map.entry("2000", "103"), + Map.entry("1800", "104"), + Map.entry("1900", "105") + ); + final var managerIdEntry = List.of( + Map.entry("101", "100"), + Map.entry("102", "101"), + Map.entry("102", "102"), + Map.entry("103", "103"), + Map.entry("103", "104"), + Map.entry("101", "105") + ); + final var departmentIdEntry = List.of( + Map.entry("50", "100"), + Map.entry("40", "101"), + Map.entry("50", "102"), + Map.entry("50", "103"), + Map.entry("50", "104"), + Map.entry("40", "105") + ); + + var indexer = mock(Indexer.class); + + pileForIndexer(indexer, QueryIndexViewImpl.PRIMARY_INDEX_NAME, idEntry); + pileForIndexer(indexer, "firstName", firstNameEntry); + pileForIndexer(indexer, "lastName", lastNameEntry); + pileForIndexer(indexer, "email", emailEntry); + pileForIndexer(indexer, "hireDate", hireDateEntry); + pileForIndexer(indexer, "salary", salaryEntry); + pileForIndexer(indexer, "managerId", managerIdEntry); + pileForIndexer(indexer, "departmentId", departmentIdEntry); + + return new QueryIndexViewImpl(indexer); + } + + /** + * Create a {@link QueryIndexView} for post to test. + *
+     * | id  | title  | published | publishTime         | owner |
+     * |-----|--------|-----------|---------------------|-------|
+     * | 100 | title1 | true      | 2024-01-01T00:00:00 | jack  |
+     * | 101 | title2 | true      | 2024-01-02T00:00:00 | rose  |
+     * | 102 | title3 | false     | null                | smith |
+     * | 103 | title4 | false     | null                | peter |
+     * | 104 | title5 | false     | null                | john  |
+     * | 105 | title6 | true      | 2024-01-05 00:00:00 | tom   |
+     * | 106 | title7 | true      | 2024-01-05 13:00:00 | jerry |
+     * | 107 | title8 | true      | 2024-01-05 12:00:00 | jerry |
+     * | 108 | title9 | false     | null                | jerry |
+     * 
+ * + * @return a {@link QueryIndexView} for post to test + */ + public static QueryIndexView createPostIndexViewWithNullCell() { + final var idEntry = List.of( + Map.entry("100", "100"), + Map.entry("101", "101"), + Map.entry("102", "102"), + Map.entry("103", "103"), + Map.entry("104", "104"), + Map.entry("105", "105"), + Map.entry("106", "106"), + Map.entry("107", "107"), + Map.entry("108", "108") + ); + final var titleEntry = List.of( + Map.entry("title1", "100"), + Map.entry("title2", "101"), + Map.entry("title3", "102"), + Map.entry("title4", "103"), + Map.entry("title5", "104"), + Map.entry("title6", "105"), + Map.entry("title7", "106"), + Map.entry("title8", "107"), + Map.entry("title9", "108") + ); + final var publishedEntry = List.of( + Map.entry("true", "100"), + Map.entry("true", "101"), + Map.entry("false", "102"), + Map.entry("false", "103"), + Map.entry("false", "104"), + Map.entry("true", "105"), + Map.entry("true", "106"), + Map.entry("true", "107"), + Map.entry("false", "108") + ); + final var publishTimeEntry = List.of( + Map.entry("2024-01-01T00:00:00", "100"), + Map.entry("2024-01-02T00:00:00", "101"), + Map.entry("2024-01-05 00:00:00", "105"), + Map.entry("2024-01-05 13:00:00", "106"), + Map.entry("2024-01-05 12:00:00", "107") + ); + + final var ownerEntry = List.of( + Map.entry("jack", "100"), + Map.entry("rose", "101"), + Map.entry("smith", "102"), + Map.entry("peter", "103"), + Map.entry("john", "104"), + Map.entry("tom", "105"), + Map.entry("jerry", "106"), + Map.entry("jerry", "107"), + Map.entry("jerry", "108") + ); + + var indexer = mock(Indexer.class); + pileForIndexer(indexer, QueryIndexViewImpl.PRIMARY_INDEX_NAME, idEntry); + pileForIndexer(indexer, "title", titleEntry); + pileForIndexer(indexer, "published", publishedEntry); + pileForIndexer(indexer, "publishTime", publishTimeEntry); + pileForIndexer(indexer, "owner", ownerEntry); + + return new QueryIndexViewImpl(indexer); + } + + /** + * Creates a fake comment index view for below data. + *
+     * | Name | Top   | Priority | Creation Time                    |
+     * | ---- | ----- | -------- | -------------------------------- |
+     * | 1    | True  | 0        | 2024-06-05 02:58:15.633165+00:00 |
+     * | 2    | True  | 1        | 2024-06-05 02:58:16.633165+00:00 |
+     * | 4    | True  | 2        | 2024-06-05 02:58:18.633165+00:00 |
+     * | 3    | True  | 2        | 2024-06-05 02:58:17.633165+00:00 |
+     * | 5    | True  | 3        | 2024-06-05 02:58:18.633165+00:00 |
+     * | 6    | True  | 3        | 2024-06-05 02:58:18.633165+00:00 |
+     * | 10   | False | 0        | 2024-06-05 02:58:17.633165+00:00 |
+     * | 9    | False | 0        | 2024-06-05 02:58:17.633165+00:00 |
+     * | 8    | False | 0        | 2024-06-05 02:58:16.633165+00:00 |
+     * | 7    | False | 0        | 2024-06-05 02:58:15.633165+00:00 |
+     * | 11   | False | 0        | 2024-06-05 02:58:14.633165+00:00 |
+     * | 12   | False | 1        | 2024-06-05 02:58:14.633165+00:00 |
+     * | 14   | False | 3        | 2024-06-05 02:58:17.633165+00:00 |
+     * | 13   | False | 3        | 2024-06-05 02:58:14.633165+00:00 |
+     * 
+ */ + public static QueryIndexView createCommentIndexView() { + final var idEntry = List.of( + Map.entry("1", "1"), + Map.entry("2", "2"), + Map.entry("3", "3"), + Map.entry("4", "4"), + Map.entry("5", "5"), + Map.entry("6", "6"), + Map.entry("7", "7"), + Map.entry("8", "8"), + Map.entry("9", "9"), + Map.entry("10", "10"), + Map.entry("11", "11"), + Map.entry("12", "12"), + Map.entry("13", "13"), + Map.entry("14", "14") + ); + final var creationTimeEntry = List.of( + Map.entry("2024-06-05 02:58:15.633165", "1"), + Map.entry("2024-06-05 02:58:16.633165", "2"), + Map.entry("2024-06-05 02:58:17.633165", "3"), + Map.entry("2024-06-05 02:58:18.633165", "4"), + Map.entry("2024-06-05 02:58:18.633165", "5"), + Map.entry("2024-06-05 02:58:18.633165", "6"), + Map.entry("2024-06-05 02:58:15.633165", "7"), + Map.entry("2024-06-05 02:58:16.633165", "8"), + Map.entry("2024-06-05 02:58:17.633165", "9"), + Map.entry("2024-06-05 02:58:17.633165", "10"), + Map.entry("2024-06-05 02:58:14.633165", "11"), + Map.entry("2024-06-05 02:58:14.633165", "12"), + Map.entry("2024-06-05 02:58:14.633165", "13"), + Map.entry("2024-06-05 02:58:17.633165", "14") + ); + final var topEntry = List.of( + Map.entry("true", "1"), + Map.entry("true", "2"), + Map.entry("true", "3"), + Map.entry("true", "4"), + Map.entry("true", "5"), + Map.entry("true", "6"), + Map.entry("false", "7"), + Map.entry("false", "8"), + Map.entry("false", "9"), + Map.entry("false", "10"), + Map.entry("false", "11"), + Map.entry("false", "12"), + Map.entry("false", "13"), + Map.entry("false", "14") + ); + final var priorityEntry = List.of( + Map.entry("0", "1"), + Map.entry("1", "2"), + Map.entry("2", "3"), + Map.entry("2", "4"), + Map.entry("3", "5"), + Map.entry("3", "6"), + Map.entry("0", "7"), + Map.entry("0", "8"), + Map.entry("0", "9"), + Map.entry("0", "10"), + Map.entry("0", "11"), + Map.entry("1", "12"), + Map.entry("3", "13"), + Map.entry("3", "14") + ); + + var indexer = mock(Indexer.class); + pileForIndexer(indexer, QueryIndexViewImpl.PRIMARY_INDEX_NAME, idEntry); + pileForIndexer(indexer, "spec.creationTime", creationTimeEntry); + pileForIndexer(indexer, "spec.top", topEntry); + pileForIndexer(indexer, "spec.priority", priorityEntry); + + return new QueryIndexViewImpl(indexer); + } + + public static void pileForIndexer(Indexer indexer, String propertyName, + Collection> entries) { + var indexEntry = mock(IndexEntry.class); + lenient().when(indexer.getIndexEntry(propertyName)).thenReturn(indexEntry); + var sortedEntries = sortEntries(entries); + + lenient().when(indexEntry.entries()).thenReturn(sortedEntries); + lenient().when(indexEntry.getIdPositionMap()).thenReturn(idPositionMap(sortedEntries)); + + var indexedMap = toKeyObjectMap(sortedEntries); + lenient().when(indexEntry.indexedKeys()).thenReturn(new TreeSet<>(indexedMap.keySet())); + lenient().when(indexEntry.getObjectNamesBy(anyString())).thenAnswer(invocation -> { + var key = (String) invocation.getArgument(0); + return indexedMap.get(key); + }); + lenient().when(indexEntry.entries()).thenReturn(entries); + } + + public static List> sortEntries( + Collection> entries) { + return entries.stream() + .sorted((a, b) -> KeyComparator.INSTANCE.compare(a.getKey(), b.getKey())) + .toList(); + } + + public static Map idPositionMap( + Collection> sortedEntries) { + var asMap = toKeyObjectMap(sortedEntries); + int i = 0; + var idPositionMap = new HashMap(); + for (var valueIdsEntry : asMap.entrySet()) { + var ids = valueIdsEntry.getValue(); + for (String id : ids) { + idPositionMap.put(id, i); + } + i++; + } + return idPositionMap; + } + + public static LinkedHashMap> toKeyObjectMap( + Collection> sortedEntries) { + return sortedEntries.stream() + .collect(Collectors.groupingBy(Map.Entry::getKey, + LinkedHashMap::new, + Collectors.mapping(Map.Entry::getValue, Collectors.toList()))); + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/query/QueryFactoryTest.java b/application/src/test/java/run/halo/app/extension/index/query/QueryFactoryTest.java new file mode 100644 index 0000000..8b10ce0 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/query/QueryFactoryTest.java @@ -0,0 +1,310 @@ +package run.halo.app.extension.index.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static run.halo.app.extension.index.query.IndexViewDataSet.createEmployeeIndexView; +import static run.halo.app.extension.index.query.QueryFactory.all; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.between; +import static run.halo.app.extension.index.query.QueryFactory.contains; +import static run.halo.app.extension.index.query.QueryFactory.endsWith; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.equalOtherField; +import static run.halo.app.extension.index.query.QueryFactory.getFieldNamesUsedInQuery; +import static run.halo.app.extension.index.query.QueryFactory.greaterThan; +import static run.halo.app.extension.index.query.QueryFactory.greaterThanOrEqual; +import static run.halo.app.extension.index.query.QueryFactory.greaterThanOrEqualOtherField; +import static run.halo.app.extension.index.query.QueryFactory.greaterThanOtherField; +import static run.halo.app.extension.index.query.QueryFactory.in; +import static run.halo.app.extension.index.query.QueryFactory.isNotNull; +import static run.halo.app.extension.index.query.QueryFactory.isNull; +import static run.halo.app.extension.index.query.QueryFactory.lessThan; +import static run.halo.app.extension.index.query.QueryFactory.lessThanOrEqual; +import static run.halo.app.extension.index.query.QueryFactory.lessThanOrEqualOtherField; +import static run.halo.app.extension.index.query.QueryFactory.lessThanOtherField; +import static run.halo.app.extension.index.query.QueryFactory.notEqual; +import static run.halo.app.extension.index.query.QueryFactory.notEqualOtherField; +import static run.halo.app.extension.index.query.QueryFactory.or; +import static run.halo.app.extension.index.query.QueryFactory.startsWith; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link QueryFactory}. + * + * @author guqing + * @since 2.12.0 + */ +class QueryFactoryTest { + + private final String id = QueryIndexViewImpl.PRIMARY_INDEX_NAME; + + @Test + void allTest() { + var indexView = createEmployeeIndexView(); + var resultSet = all("firstName").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "101", "102", "103", "104", "105" + ); + } + + @Test + void isNullTest() { + var indexView = IndexViewDataSet.createPostIndexViewWithNullCell(); + var resultSet = isNull("publishTime").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "102", "103", "104", "108" + ); + } + + @Test + void isNotNullTest() { + var indexView = IndexViewDataSet.createPostIndexViewWithNullCell(); + var resultSet = isNotNull("publishTime").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "101", "105", "106", "107" + ); + } + + @Test + void equalTest() { + var indexView = createEmployeeIndexView(); + var resultSet = equal("lastName", "Fay").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "104", "105" + ); + } + + @Test + void equalOtherFieldTest() { + var indexView = createEmployeeIndexView(); + var resultSet = equalOtherField("managerId", id).matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "102", "103" + ); + } + + @Test + void notEqualTest() { + var indexView = createEmployeeIndexView(); + var resultSet = notEqual("lastName", "Fay").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "101", "102", "103" + ); + } + + @Test + void notEqualOtherFieldTest() { + var indexView = createEmployeeIndexView(); + var resultSet = notEqualOtherField("managerId", id).matches(indexView); + // 103 102 is equal + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "101", "104", "105" + ); + } + + @Test + void lessThanTest() { + var indexView = createEmployeeIndexView(); + var resultSet = lessThan(id, "103").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "101", "102" + ); + } + + @Test + void lessThanOtherFieldTest() { + var indexView = createEmployeeIndexView(); + var resultSet = lessThanOtherField(id, "managerId").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "101" + ); + } + + @Test + void lessThanOrEqualTest() { + var indexView = createEmployeeIndexView(); + var resultSet = lessThanOrEqual(id, "103").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "101", "102", "103" + ); + } + + @Test + void lessThanOrEqualOtherFieldTest() { + var indexView = createEmployeeIndexView(); + var resultSet = + lessThanOrEqualOtherField(id, "managerId").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "101", "102", "103" + ); + } + + @Test + void greaterThanTest() { + var indexView = createEmployeeIndexView(); + var resultSet = greaterThan(id, "103").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "104", "105" + ); + } + + @Test + void greaterThanOtherFieldTest() { + var indexView = createEmployeeIndexView(); + var resultSet = greaterThanOtherField(id, "managerId").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "104", "105" + ); + } + + @Test + void greaterThanOrEqualTest() { + var indexView = createEmployeeIndexView(); + var resultSet = greaterThanOrEqual(id, "103").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "103", "104", "105" + ); + } + + @Test + void greaterThanOrEqualOtherFieldTest() { + var indexView = createEmployeeIndexView(); + var resultSet = + greaterThanOrEqualOtherField(id, "managerId").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "102", "103", "104", "105" + ); + } + + @Test + void inTest() { + var indexView = createEmployeeIndexView(); + var resultSet = in(id, "103", "104").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "103", "104" + ); + } + + @Test + void inTest2() { + var indexView = createEmployeeIndexView(); + var resultSet = in("lastName", "Fay").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "104", "105" + ); + } + + @Test + void betweenTest() { + var indexView = createEmployeeIndexView(); + var resultSet = between(id, "103", "105").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "103", "104", "105" + ); + + indexView = createEmployeeIndexView(); + resultSet = between("salary", "2000", "2400").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "101", "102", "103" + ); + } + + @Test + void betweenLowerExclusive() { + var indexView = createEmployeeIndexView(); + var resultSet = + QueryFactory.betweenLowerExclusive("salary", "2000", "2400").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "101", "102" + ); + } + + @Test + void betweenUpperExclusive() { + var indexView = createEmployeeIndexView(); + var resultSet = + QueryFactory.betweenUpperExclusive("salary", "2000", "2400").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "102", "103" + ); + } + + @Test + void betweenExclusive() { + var indexView = createEmployeeIndexView(); + var resultSet = QueryFactory.betweenExclusive("salary", "2000", "2400").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "102" + ); + } + + @Test + void startsWithTest() { + var indexView = createEmployeeIndexView(); + var resultSet = startsWith("firstName", "W").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "102" + ); + } + + @Test + void endsWithTest() { + var indexView = createEmployeeIndexView(); + var resultSet = endsWith("firstName", "y").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "103" + ); + } + + @Test + void containsTest() { + var indexView = createEmployeeIndexView(); + var resultSet = contains("firstName", "i").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "102" + ); + resultSet = contains("firstName", "N").matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "104", "105" + ); + } + + @Test + void notTest() { + var indexView = createEmployeeIndexView(); + var resultSet = + QueryFactory.not(QueryFactory.contains("firstName", "i")).matches(indexView); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "101", "103", "104", "105" + ); + } + + @Test + void getUsedFieldNamesTest() { + // single query + var query = equal("firstName", "W"); + var fieldNames = getFieldNamesUsedInQuery(query); + assertThat(fieldNames).containsExactlyInAnyOrder("firstName"); + + // and composite query + query = and( + and(equal("firstName", "W"), equal("lastName", "Fay")), + or(equalOtherField(id, "userId"), lessThan("age", "123")) + ); + fieldNames = getFieldNamesUsedInQuery(query); + assertThat(fieldNames).containsExactlyInAnyOrder("firstName", "lastName", id, "userId", + "age"); + + // or composite query + var complexQuery = or( + equal("field1", "value1"), + and( + equal("field2", "value2"), + equal("field3", "value3") + ), + equal("field4", "value4") + ); + fieldNames = getFieldNamesUsedInQuery(complexQuery); + assertThat(fieldNames).containsExactlyInAnyOrder("field1", "field2", "field3", "field4"); + } +} diff --git a/application/src/test/java/run/halo/app/extension/index/query/QueryIndexViewImplTest.java b/application/src/test/java/run/halo/app/extension/index/query/QueryIndexViewImplTest.java new file mode 100644 index 0000000..b746295 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/index/query/QueryIndexViewImplTest.java @@ -0,0 +1,301 @@ +package run.halo.app.extension.index.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static run.halo.app.extension.index.query.IndexViewDataSet.createCommentIndexView; +import static run.halo.app.extension.index.query.IndexViewDataSet.createEmployeeIndexView; +import static run.halo.app.extension.index.query.IndexViewDataSet.createPostIndexViewWithNullCell; +import static run.halo.app.extension.index.query.IndexViewDataSet.pileForIndexer; +import static run.halo.app.extension.index.query.QueryIndexViewImpl.PRIMARY_INDEX_NAME; + +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +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.data.domain.Sort; +import run.halo.app.extension.index.IndexEntry; +import run.halo.app.extension.index.Indexer; + +/** + * Tests for {@link QueryIndexViewImpl}. + * + * @author guqing + * @since 2.17.0 + */ +class QueryIndexViewImplTest { + final String id = PRIMARY_INDEX_NAME; + + @Test + void getAllIdsForFieldTest() { + var indexView = createPostIndexViewWithNullCell(); + var resultSet = indexView.getIdsForField("title"); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "101", "102", "103", "104", "105", "106", "107", "108" + ); + + resultSet = indexView.getIdsForField("publishTime"); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "101", "105", "106", "107" + ); + } + + @Test + void findIdsForValueEqualTest() { + var indexView = createEmployeeIndexView(); + var resultSet = indexView.findMatchingIdsWithEqualValues("managerId", id); + assertThat(resultSet).containsExactlyInAnyOrder( + "102", "103" + ); + } + + @Test + void findIdsForFieldValueGreaterThanTest() { + var indexView = createEmployeeIndexView(); + var resultSet = indexView.findMatchingIdsWithGreaterValues(id, "managerId", false); + assertThat(resultSet).containsExactlyInAnyOrder( + "104", "105" + ); + + indexView = createEmployeeIndexView(); + resultSet = indexView.findMatchingIdsWithGreaterValues(id, "managerId", true); + assertThat(resultSet).containsExactlyInAnyOrder( + "103", "102", "104", "105" + ); + } + + @Test + void findIdsForFieldValueGreaterThanTest2() { + var indexView = createEmployeeIndexView(); + var resultSet = indexView.findMatchingIdsWithGreaterValues("managerId", id, false); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "101" + ); + + indexView = createEmployeeIndexView(); + resultSet = indexView.findMatchingIdsWithGreaterValues("managerId", id, true); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "101", "102", "103" + ); + } + + @Test + void findIdsForFieldValueLessThanTest() { + var indexView = createEmployeeIndexView(); + var resultSet = indexView.findMatchingIdsWithSmallerValues(id, "managerId", false); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "101" + ); + + indexView = createEmployeeIndexView(); + resultSet = indexView.findMatchingIdsWithSmallerValues(id, "managerId", true); + assertThat(resultSet).containsExactlyInAnyOrder( + "100", "101", "102", "103" + ); + } + + @Test + void findIdsForFieldValueLessThanTest2() { + var indexView = createEmployeeIndexView(); + var resultSet = indexView.findMatchingIdsWithSmallerValues("managerId", id, false); + assertThat(resultSet).containsExactlyInAnyOrder( + "104", "105" + ); + + indexView = createEmployeeIndexView(); + resultSet = indexView.findMatchingIdsWithSmallerValues("managerId", id, true); + assertThat(resultSet).containsExactlyInAnyOrder( + "103", "102", "104", "105" + ); + } + + @Nested + @ExtendWith(MockitoExtension.class) + class SortTest { + @Mock + private Indexer indexer; + + @Test + void testSortByUnsorted() { + var idEntry = mock(IndexEntry.class); + when(indexer.getIndexEntry(PRIMARY_INDEX_NAME)) + .thenReturn(idEntry); + var indexView = new QueryIndexViewImpl(indexer); + + var sort = Sort.unsorted(); + + var resultSet = new TreeSet<>(List.of("Item1", "Item2")); + List sortedList = indexView.sortBy(resultSet, sort); + assertThat(sortedList).isEqualTo(List.of("Item1", "Item2")); + } + + @Test + void testSortBySortedAscending() { + pileForIndexer(indexer, "field1", + List.of(Map.entry("key2", "Item2"), Map.entry("key1", "Item1"))); + + pileForIndexer(indexer, PRIMARY_INDEX_NAME, + List.of(Map.entry("Item1", "Item1"), Map.entry("Item2", "Item2"))); + + var indexView = new QueryIndexViewImpl(indexer); + + var sort = Sort.by(Sort.Order.asc("field1")); + + List sortedList = indexView.sortBy(indexView.getAllIds(), sort); + + assertThat(sortedList).containsSequence("Item1", "Item2"); + } + + @Test + void testSortBySortedDescending() { + pileForIndexer(indexer, "field1", + List.of(Map.entry("key1", "Item1"), Map.entry("key2", "Item2"))); + + pileForIndexer(indexer, PRIMARY_INDEX_NAME, + List.of(Map.entry("Item1", "Item1"), Map.entry("Item2", "Item2"))); + + var indexView = new QueryIndexViewImpl(indexer); + + var sort = Sort.by(Sort.Order.desc("field1")); + + var resultSet = new TreeSet<>(List.of("Item1", "Item2")); + List sortedList = indexView.sortBy(resultSet, sort); + + assertThat(sortedList).containsExactly("Item2", "Item1"); + } + + @Test + void testSortByMultipleFields() { + pileForIndexer(indexer, "field1", + List.of(Map.entry("k3", "Item3"), Map.entry("k2", "Item2"))); + + pileForIndexer(indexer, "field2", + List.of(Map.entry("k1", "Item1"), Map.entry("k3", "Item3"))); + + pileForIndexer(indexer, id, + List.of(Map.entry("Item1", "Item1"), Map.entry("Item2", "Item2"), + Map.entry("Item3", "Item3"))); + + var indexView = new QueryIndexViewImpl(indexer); + + var sort = Sort.by(Sort.Order.asc("field1"), Sort.Order.desc("field2")); + + var resultSet = new TreeSet<>(List.of("Item1", "Item2", "Item3")); + List sortedList = indexView.sortBy(resultSet, sort); + + assertThat(sortedList).containsExactly("Item2", "Item3", "Item1"); + } + + @Test + void testSortByMultipleFields2() { + pileForIndexer(indexer, id, List.of()); + + pileForIndexer(indexer, "field1", List.of(Map.entry("John", "John"), + Map.entry("Bob", "Bob"), + Map.entry("Alice", "Alice") + )); + pileForIndexer(indexer, "field2", List.of(Map.entry("David", "David"), + Map.entry("Eva", "Eva"), + Map.entry("Frank", "Frank") + )); + pileForIndexer(indexer, "field3", List.of(Map.entry("George", "George"), + Map.entry("Helen", "Helen"), + Map.entry("Ivy", "Ivy") + )); + + /* + *
+             * Row Key | field1 | field2 | field3
+             * -------|-------|-------|-------
+             * John   | John  |       |
+             * Bob    | Bob   |       |
+             * Alice  | Alice |       |
+             * David  |       | David |
+             * Eva    |       | Eva   |
+             * Frank  |       | Frank |
+             * George |       |       | George
+             * Helen  |       |       | Helen
+             * Ivy    |       |       | Ivy
+             * 
+ */ + var indexView = new QueryIndexViewImpl(indexer); + var sort = Sort.by(Sort.Order.desc("field1"), Sort.Order.asc("field2"), + Sort.Order.asc("field3")); + + var resultSet = new TreeSet<>( + List.of("Bob", "John", "Eva", "Alice", "Ivy", "David", "Frank", "Helen", "George")); + List sortedList = indexView.sortBy(resultSet, sort); + + assertThat(sortedList).containsSequence("David", "Eva", "Frank", "George", "Helen", + "Ivy", "John", "Bob", "Alice"); + } + + /** + *

Result for the following data.

+ *
+         *  | id | firstName | lastName | email | hireDate | salary | managerId | departmentId |
+         * |----|-----------|----------|-------|----------|--------|-----------|--------------|
+         * | 100| Pat       | Fay      | p     | 17       | 2600   | 101       | 50           |
+         * | 101| Lee       | Day      | l     | 17       | 2400   | 102       | 40           |
+         * | 103| Mary      | Day      | p     | 17       | 2000   | 103       | 50           |
+         * | 104| John      | Fay      | j     | 17       | 1800   | 103       | 50           |
+         * | 105| Gon       | Fay      | p     | 18       | 1900   | 101       | 40           |
+         * | 102| William   | Jay      | w     | 19       | 2200   | 102       | 50           |
+         * 
+ */ + @Test + void sortByMultipleFieldsWithFirstSame() { + var indexView = createEmployeeIndexView(); + var ids = indexView.getAllIds(); + var result = indexView.sortBy(ids, Sort.by(Sort.Order.asc("hireDate"), + Sort.Order.asc("lastName")) + ); + assertThat(result).containsSequence("101", "103", "100", "104", "105", "102"); + } + + /** + *

Result for the following data.

+ *
+         * | id  | title  | published | publishTime         | owner |
+         * |-----|--------|-----------|---------------------|-------|
+         * | 100 | title1 | true      | 2024-01-01T00:00:00 | jack  |
+         * | 101 | title2 | true      | 2024-01-02T00:00:00 | rose  |
+         * | 105 | title6 | true      | 2024-01-05 00:00:00 | tom   |
+         * | 107 | title8 | true      | 2024-01-05 12:00:00 | jerry |
+         * | 106 | title7 | true      | 2024-01-05 13:00:00 | jerry |
+         * | 108 | title9 | false     | null                | jerry |
+         * | 104 | title5 | false     | null                | john  |
+         * | 103 | title4 | false     | null                | peter |
+         * | 102 | title3 | false     | null                | smith |
+         * 
+ */ + @Test + void sortByMultipleFieldsForPostDataSet() { + var indexView = createPostIndexViewWithNullCell(); + var ids = indexView.getAllIds(); + var result = indexView.sortBy(ids, Sort.by(Sort.Order.asc("publishTime"), + Sort.Order.desc("title")) + ); + assertThat(result).containsSequence("100", "101", "105", "107", "106", "108", "104", + "103", "102"); + } + + @Test + void sortByMultipleFieldsForCommentDataSet() { + var indexView = createCommentIndexView(); + var ids = indexView.getAllIds(); + var sort = Sort.by(Sort.Order.desc("spec.top"), + Sort.Order.asc("spec.priority"), + Sort.Order.desc("spec.creationTime"), + Sort.Order.asc("metadata.name") + ); + var result = indexView.sortBy(ids, sort); + assertThat(result).containsSequence("1", "2", "4", "3", "5", "6", "9", "10", "8", "7", + "11", "12", "14", "13"); + } + } +} diff --git a/application/src/test/java/run/halo/app/extension/router/ExtensionCompositeRouterFunctionTest.java b/application/src/test/java/run/halo/app/extension/router/ExtensionCompositeRouterFunctionTest.java new file mode 100644 index 0000000..d5f9884 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/router/ExtensionCompositeRouterFunctionTest.java @@ -0,0 +1,96 @@ +package run.halo.app.extension.router; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +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 org.springframework.boot.context.event.ApplicationStartedEvent; +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.SchemeManager; +import run.halo.app.extension.SchemeWatcherManager; +import run.halo.app.extension.SchemeWatcherManager.SchemeRegistered; +import run.halo.app.extension.SchemeWatcherManager.SchemeUnregistered; + +@ExtendWith(MockitoExtension.class) +class ExtensionCompositeRouterFunctionTest { + + @Mock + ReactiveExtensionClient client; + + @Mock + SchemeManager schemeManager; + + @Mock + SchemeWatcherManager watcherManager; + + @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.onChange( + new SchemeRegistered(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.onChange( + new SchemeRegistered(Scheme.buildFromType(FakeExtension.class))); + + ServerRequest request = ServerRequest.create(exchange, messageReaders); + var handlerFunc = extensionRouterFunc.route(request).block(); + assertNotNull(handlerFunc); + + // trigger registering scheme + extensionRouterFunc.onChange( + new SchemeUnregistered(Scheme.buildFromType(FakeExtension.class))); + handlerFunc = extensionRouterFunc.route(request).block(); + assertNull(handlerFunc); + } + + @Test + void shouldRegisterWatcherAfterPropertiesSet() { + extensionRouterFunc.afterPropertiesSet(); + verify(watcherManager).register(eq(extensionRouterFunc)); + } + + @Test + void shouldBuildRouterFunctionsOnApplicationStarted() { + var applicationStartedEvent = mock(ApplicationStartedEvent.class); + extensionRouterFunc.onApplicationEvent(applicationStartedEvent); + verify(schemeManager).schemes(); + } + +} diff --git a/application/src/test/java/run/halo/app/extension/router/ExtensionCreateHandlerTest.java b/application/src/test/java/run/halo/app/extension/router/ExtensionCreateHandlerTest.java new file mode 100644 index 0000000..b6d44d7 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/router/ExtensionCreateHandlerTest.java @@ -0,0 +1,115 @@ +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()); + } +} diff --git a/application/src/test/java/run/halo/app/extension/router/ExtensionDeleteHandlerTest.java b/application/src/test/java/run/halo/app/extension/router/ExtensionDeleteHandlerTest.java new file mode 100644 index 0000000..52fe0bb --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/router/ExtensionDeleteHandlerTest.java @@ -0,0 +1,110 @@ +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()); + } +} diff --git a/application/src/test/java/run/halo/app/extension/router/ExtensionGetHandlerTest.java b/application/src/test/java/run/halo/app/extension/router/ExtensionGetHandlerTest.java new file mode 100644 index 0000000..8cc4e43 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/router/ExtensionGetHandlerTest.java @@ -0,0 +1,76 @@ +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(); + } +} diff --git a/application/src/test/java/run/halo/app/extension/router/ExtensionListHandlerTest.java b/application/src/test/java/run/halo/app/extension/router/ExtensionListHandlerTest.java new file mode 100644 index 0000000..12014a3 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/router/ExtensionListHandlerTest.java @@ -0,0 +1,69 @@ +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()); + } + +} diff --git a/application/src/test/java/run/halo/app/extension/router/ExtensionRouterFunctionFactoryTest.java b/application/src/test/java/run/halo/app/extension/router/ExtensionRouterFunctionFactoryTest.java new file mode 100644 index 0000000..78a43b5 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/router/ExtensionRouterFunctionFactoryTest.java @@ -0,0 +1,181 @@ +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.converter.json.Jackson2ObjectMapperBuilder; +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; + +@ExtendWith(MockitoExtension.class) +class ExtensionRouterFunctionFactoryTest { + + @Mock + ReactiveExtensionClient client; + + @Spy + Scheme scheme = Scheme.buildFromType(FakeExtension.class); + + @InjectMocks + ExtensionRouterFunctionFactory factory; + + WebTestClient webClient; + + @BeforeEach + void setUp() { + webClient = WebTestClient.bindToRouterFunction(factory.create()).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 = Jackson2ObjectMapperBuilder.json().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) { + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/router/ExtensionUpdateHandlerTest.java b/application/src/test/java/run/halo/app/extension/router/ExtensionUpdateHandlerTest.java new file mode 100644 index 0000000..b2e6752 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/router/ExtensionUpdateHandlerTest.java @@ -0,0 +1,129 @@ +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()); + } +} diff --git a/application/src/test/java/run/halo/app/extension/router/PathPatternGeneratorTest.java b/application/src/test/java/run/halo/app/extension/router/PathPatternGeneratorTest.java new file mode 100644 index 0000000..d2974a0 --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/router/PathPatternGeneratorTest.java @@ -0,0 +1,36 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/store/ReactiveExtensionStoreClientImplTest.java b/application/src/test/java/run/halo/app/extension/store/ReactiveExtensionStoreClientImplTest.java new file mode 100644 index 0000000..5e7f13c --- /dev/null +++ b/application/src/test/java/run/halo/app/extension/store/ReactiveExtensionStoreClientImplTest.java @@ -0,0 +1,102 @@ +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.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; + +@ExtendWith(MockitoExtension.class) +class ReactiveExtensionStoreClientImplTest { + + @Mock + ExtensionStoreRepository repository; + + @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) + ); + + when(repository.findAllByNameStartingWith("/registry/posts")) + .thenReturn(Flux.fromIterable(expectedExtensions)); + + var gotExtensions = client.listByNamePrefix("/registry/posts").collectList().block(); + assertEquals(expectedExtensions, gotExtensions); + } + + @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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/ConditionListTest.java b/application/src/test/java/run/halo/app/infra/ConditionListTest.java new file mode 100644 index 0000000..51717fd --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/ConditionListTest.java @@ -0,0 +1,237 @@ +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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/DefaultBackupRootGetterTest.java b/application/src/test/java/run/halo/app/infra/DefaultBackupRootGetterTest.java new file mode 100644 index 0000000..bfba9f4 --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/DefaultBackupRootGetterTest.java @@ -0,0 +1,33 @@ +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(); + } + + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java b/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java new file mode 100644 index 0000000..257dbb2 --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java @@ -0,0 +1,49 @@ +package run.halo.app.infra; + +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; + +/** + * 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(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"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/DefaultSystemVersionSupplierTest.java b/application/src/test/java/run/halo/app/infra/DefaultSystemVersionSupplierTest.java new file mode 100644 index 0000000..6be7f0a --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/DefaultSystemVersionSupplierTest.java @@ -0,0 +1,65 @@ +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.getPreReleaseVersion()).isEqualTo("SNAPSHOT"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/ExtensionResourceInitializerTest.java b/application/src/test/java/run/halo/app/infra/ExtensionResourceInitializerTest.java new file mode 100644 index 0000000..b082a18 --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/ExtensionResourceInitializerTest.java @@ -0,0 +1,173 @@ +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.json.JSONException; +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 onApplicationEvent() throws JSONException { + 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.onApplicationEvent(applicationStartedEvent); + + 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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/HaloPropertiesExternalUrlSupplierTest.java b/application/src/test/java/run/halo/app/infra/HaloPropertiesExternalUrlSupplierTest.java new file mode 100644 index 0000000..4fe085e --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/HaloPropertiesExternalUrlSupplierTest.java @@ -0,0 +1,119 @@ +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.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.autoconfigure.web.reactive.WebFluxProperties; +import org.springframework.http.HttpRequest; +import run.halo.app.infra.properties.HaloProperties; + +@ExtendWith(MockitoExtension.class) +class HaloPropertiesExternalUrlSupplierTest { + + @Mock + HaloProperties haloProperties; + + @Mock + WebFluxProperties webFluxProperties; + + @InjectMocks + HaloPropertiesExternalUrlSupplier externalUrl; + + @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() throws MalformedURLException { + 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()); + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/InitializationStateGetterTest.java b/application/src/test/java/run/halo/app/infra/InitializationStateGetterTest.java new file mode 100644 index 0000000..ab7bada --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/InitializationStateGetterTest.java @@ -0,0 +1,84 @@ +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)); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/ReactiveExtensionPaginatedOperatorImplTest.java b/application/src/test/java/run/halo/app/infra/ReactiveExtensionPaginatedOperatorImplTest.java new file mode 100644 index 0000000..8f1dce7 --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/ReactiveExtensionPaginatedOperatorImplTest.java @@ -0,0 +1,107 @@ +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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcherTest.java b/application/src/test/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcherTest.java new file mode 100644 index 0000000..c50784a --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcherTest.java @@ -0,0 +1,168 @@ +package run.halo.app.infra; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; + +import java.util.LinkedHashMap; +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.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.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.utils.JsonUtils; + +/** + * Tests for {@link SystemConfigurableEnvironmentFetcher}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class SystemConfigurableEnvironmentFetcherTest { + + @Mock + private ReactiveExtensionClient client; + + @InjectMocks + private SystemConfigurableEnvironmentFetcher environmentFetcher; + + @BeforeEach + void setUp() { + lenient().when(client.fetch(eq(ConfigMap.class), eq("system-default"))) + .thenReturn(Mono.just(systemDefault())); + lenient().when(client.fetch(eq(ConfigMap.class), eq("system"))) + .thenReturn(Mono.just(system())); + } + + @Test + void getConfigMap() { + environmentFetcher.getConfigMap() + .as(StepVerifier::create) + .consumeNextWith(configMap -> { + assertThat(configMap.getMetadata().getName()) + .isEqualTo(SystemSetting.SYSTEM_CONFIG); + try { + JSONAssert.assertEquals(expectedJson(), + JsonUtils.objectToJson(configMap), + true); + } catch (JSONException e) { + throw new RuntimeException(e); + } + }) + .verifyComplete(); + } + + String expectedJson() { + String routeRules = + "{\\\"categories\\\":\\\"topics\\\",\\\"archives\\\":\\\"archives-new\\\"," + + "\\\"post\\\":\\\"/archives-new/{slug}\\\"}"; + String fakeArray = "{\\\"select\\\":[{\\\"label\\\":\\\"Hello\\\"," + + "\\\"value\\\":\\\"hello\\\"},{\\\"label\\\":\\\"Awesome\\\"," + + "\\\"value\\\":\\\"awesome\\\"}]}"; + return """ + { + "data": { + "routeRules": "%s", + "seo": "{\\"blockSpiders\\":\\"true\\",\\"keywords\\":\\"Hello,Test,Fake\\"}", + "fakeArray": "%s" + }, + "apiVersion": "v1alpha1", + "kind": "ConfigMap", + "metadata": { + "name": "system" + } + } + """.formatted(routeRules, fakeArray); + } + + ConfigMap systemDefault() { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setName("system-default"); + configMap.setData(new LinkedHashMap<>()); + configMap.getData().put("routeRules", """ + { + "categories": "categories", + "archives": "archives", + "post": "/archives/{slug}", + "tags": "tags" + } + """ + ); + configMap.getData().put("seo", """ + { + "blockSpiders": "false", + "keywords": "Hello,Test,Fake" + } + """ + ); + configMap.getData().put("post", """ + { + "pageSize": "10" + } + """ + ); + configMap.getData().put("fakeArray", """ + { + "select": [{ + "label": "Hello", + "value": "hello" + }, { + "label": "Test", + "value": "test" + }] + } + """ + ); + return configMap; + } + + ConfigMap system() { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setName("system"); + configMap.setData(new LinkedHashMap<>()); + // will delete the tags key and replace some values + configMap.getData().put("routeRules", """ + { + "categories": "topics", + "archives": "archives-new", + "post": "/archives-new/{slug}", + "tags": null + } + """ + ); + configMap.getData().put("seo", """ + { + "blockSpiders": "true" + } + """ + ); + + // deleted post group here + configMap.getData().put("post", null); + + configMap.getData().put("fakeArray", """ + { + "select": [{ + "label": "Hello", + "value": "hello" + }, { + "label": "Awesome", + "value": "awesome" + }] + } + """ + ); + return configMap; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/SystemSettingTest.java b/application/src/test/java/run/halo/app/infra/SystemSettingTest.java new file mode 100644 index 0000000..46f163f --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/SystemSettingTest.java @@ -0,0 +1,60 @@ +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, Comment.GROUP, Comment.class); + assertTrue(comment.getEnable()); + } + + @Test + void shouldGetNullIfKeyNotExist() { + var configMap = new ConfigMap(); + configMap.setData(new HashMap<>()); + String fake = SystemSetting.get(configMap, "fake-key", String.class); + assertNull(fake); + } + + @Test + void shouldGetConfigViaConversionService() { + var configMap = new ConfigMap(); + configMap.putDataItem("int", "100"); + var integer = SystemSetting.get(configMap, "int", Integer.class); + assertEquals(100, integer); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/SystemStateTest.java b/application/src/test/java/run/halo/app/infra/SystemStateTest.java new file mode 100644 index 0000000..cafce5c --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/SystemStateTest.java @@ -0,0 +1,51 @@ +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\"}"); + } +} diff --git a/application/src/test/java/run/halo/app/infra/ValidationUtilsTest.java b/application/src/test/java/run/halo/app/infra/ValidationUtilsTest.java new file mode 100644 index 0000000..6fa0991 --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/ValidationUtilsTest.java @@ -0,0 +1,92 @@ +package run.halo.app.infra; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +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(ValidationUtils.validateName(null)).isFalse(); + } + + @Test + void emptyUsername() { + assertThat(ValidationUtils.validateName("")).isFalse(); + } + + @Test + void startWithIllegalCharacter() { + assertThat(ValidationUtils.validateName("-abc")).isFalse(); + } + + @Test + void endWithIllegalCharacter() { + assertThat(ValidationUtils.validateName("abc-")).isFalse(); + assertThat(ValidationUtils.validateName("abcD")).isFalse(); + } + + @Test + void middleWithIllegalCharacter() { + assertThat(ValidationUtils.validateName("ab?c")).isFalse(); + } + + @Test + void moreThan63Characters() { + assertThat(ValidationUtils.validateName(StringUtils.repeat('a', 64))).isFalse(); + } + + @Test + void correctUsername() { + assertThat(ValidationUtils.validateName("abc")).isTrue(); + assertThat(ValidationUtils.validateName("ab-c")).isTrue(); + assertThat(ValidationUtils.validateName("1st")).isTrue(); + assertThat(ValidationUtils.validateName("ast1")).isTrue(); + assertThat(ValidationUtils.validateName("ast-1")).isTrue(); + } + } + + @Test + void validateEmailTest() { + var cases = new HashMap(); + // Valid cases + cases.put("simple@example.com", true); + cases.put("very.common@example.com", true); + cases.put("disposable.style.email.with+symbol@example.com", true); + cases.put("other.email-with-hyphen@example.com", true); + cases.put("fully-qualified-domain@example.com", true); + cases.put("user.name+tag+sorting@example.com", true); + cases.put("x@example.com", true); + cases.put("example-indeed@strange-example.com", true); + cases.put("example@s.example", true); + cases.put("john.doe@example.com", true); + cases.put("a.little.lengthy.but.fine@dept.example.com", true); + cases.put("123ada@halo.co", true); + cases.put("23ad@halo.top", true); + + // Invalid cases + cases.put("Abc.example.com", false); + cases.put("admin@mailserver1", false); + cases.put("\" \"@example.org", false); + cases.put("A@b@c@example.com", false); + cases.put("a\"b(c)d,e:f;gi[j\\k]l@example.com", false); + cases.put("just\"not\"right@example.com", false); + cases.put("this is\"not\\allowed@example.com", false); + cases.put("this\\ still\\\"not\\\\allowed@example.com", false); + cases.put("123456789012345678901234567890123456789012345", false); + cases.forEach((email, expected) -> assertThat(ValidationUtils.isValidEmail(email)) + .isEqualTo(expected)); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/exception/handlers/I18nExceptionTest.java b/application/src/test/java/run/halo/app/infra/exception/handlers/I18nExceptionTest.java new file mode 100644 index 0000000..e217f3c --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/exception/handlers/I18nExceptionTest.java @@ -0,0 +1,209 @@ +package run.halo.app.infra.exception.handlers; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +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.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +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.put().uri("/response-entity/conflict-error") + .header("X-XSRF-TOKEN", "fake-token") + .cookie("XSRF-TOKEN", "fake-token") + .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); + } + } +} diff --git a/application/src/test/java/run/halo/app/infra/utils/Base62UtilsTest.java b/application/src/test/java/run/halo/app/infra/utils/Base62UtilsTest.java new file mode 100644 index 0000000..77619a9 --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/utils/Base62UtilsTest.java @@ -0,0 +1,47 @@ +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; + } +} diff --git a/application/src/test/java/run/halo/app/infra/utils/FileNameUtilsTest.java b/application/src/test/java/run/halo/app/infra/utils/FileNameUtilsTest.java new file mode 100644 index 0000000..98b9c7f --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/utils/FileNameUtilsTest.java @@ -0,0 +1,78 @@ +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-")); + } + } +} diff --git a/application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java b/application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java new file mode 100644 index 0000000..a69ee0c --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java @@ -0,0 +1,45 @@ +package run.halo.app.infra.utils; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +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 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"); + } +} diff --git a/application/src/test/java/run/halo/app/infra/utils/FileUtilsTest.java b/application/src/test/java/run/halo/app/infra/utils/FileUtilsTest.java new file mode 100644 index 0000000..3365edc --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/utils/FileUtilsTest.java @@ -0,0 +1,118 @@ +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(); + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/utils/IpAddressUtilsTest.java b/application/src/test/java/run/halo/app/infra/utils/IpAddressUtilsTest.java new file mode 100644 index 0000000..6975b77 --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/utils/IpAddressUtilsTest.java @@ -0,0 +1,64 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/utils/VersionUtilsTest.java b/application/src/test/java/run/halo/app/infra/utils/VersionUtilsTest.java new file mode 100644 index 0000000..fa02a8a --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/utils/VersionUtilsTest.java @@ -0,0 +1,51 @@ +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(); + } +} diff --git a/application/src/test/java/run/halo/app/infra/utils/YamlUnstructuredLoaderTest.java b/application/src/test/java/run/halo/app/infra/utils/YamlUnstructuredLoaderTest.java new file mode 100644 index 0000000..5f09bf5 --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/utils/YamlUnstructuredLoaderTest.java @@ -0,0 +1,116 @@ +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(); + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/metrics/MeterUtilsTest.java b/application/src/test/java/run/halo/app/metrics/MeterUtilsTest.java new file mode 100644 index 0000000..8c83c68 --- /dev/null +++ b/application/src/test/java/run/halo/app/metrics/MeterUtilsTest.java @@ -0,0 +1,128 @@ +package run.halo.app.metrics; + +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(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/migration/BackupReconcilerTest.java b/application/src/test/java/run/halo/app/migration/BackupReconcilerTest.java new file mode 100644 index 0000000..1a18604 --- /dev/null +++ b/application/src/test/java/run/halo/app/migration/BackupReconcilerTest.java @@ -0,0 +1,250 @@ +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; + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/migration/impl/MigrationServiceImplTest.java b/application/src/test/java/run/halo/app/migration/impl/MigrationServiceImplTest.java new file mode 100644 index 0000000..e515e02 --- /dev/null +++ b/application/src/test/java/run/halo/app/migration/impl/MigrationServiceImplTest.java @@ -0,0 +1,294 @@ +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.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.junit.jupiter.MockitoExtension; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +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; + + @InjectMocks + MigrationServiceImpl migrationService; + + @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") + ); + when(repository.findAll()).thenReturn(Flux.fromIterable(extensionStores)); + 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(); + + verify(repository).findAll(); + // 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(List.of(expectStore))).thenReturn(Mono.empty()); + when(repository.saveAll(List.of(expectStore))).thenReturn(Flux.empty()); + + var content = DataBufferUtils.read(backupFile, + DefaultDataBufferFactory.sharedInstance, + 2048, + StandardOpenOption.READ); + StepVerifier.create(migrationService.restore(content)) + .verifyComplete(); + + + verify(haloProperties).getWorkDir(); + verify(repository).deleteAll(List.of(expectStore)); + 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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/notification/DefaultNotificationCenterTest.java b/application/src/test/java/run/halo/app/notification/DefaultNotificationCenterTest.java new file mode 100644 index 0000000..fdbb4cc --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/DefaultNotificationCenterTest.java @@ -0,0 +1,325 @@ +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; + +/** + * 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; + + @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); + + notificationCenter.getLocaleFromSubscriber(subscription) + .as(StepVerifier::create) + .expectNext(Locale.getDefault()) + .verifyComplete(); + } +} diff --git a/application/src/test/java/run/halo/app/notification/DefaultNotificationReasonEmitterTest.java b/application/src/test/java/run/halo/app/notification/DefaultNotificationReasonEmitterTest.java new file mode 100644 index 0000000..6bad6cb --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/DefaultNotificationReasonEmitterTest.java @@ -0,0 +1,200 @@ +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); + } +} diff --git a/application/src/test/java/run/halo/app/notification/DefaultNotificationSenderTest.java b/application/src/test/java/run/halo/app/notification/DefaultNotificationSenderTest.java new file mode 100644 index 0000000..3a26b83 --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/DefaultNotificationSenderTest.java @@ -0,0 +1,32 @@ +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(DefaultNotificationSender.SendNotificationTask.class), 0); + var item2 = + new DefaultNotificationSender.QueueItem("1", + mock(DefaultNotificationSender.SendNotificationTask.class), 1); + + assertThat(item1).isEqualTo(item2); + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/notification/DefaultNotificationTemplateRenderTest.java b/application/src/test/java/run/halo/app/notification/DefaultNotificationTemplateRenderTest.java new file mode 100644 index 0000000..e4d1033 --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/DefaultNotificationTemplateRenderTest.java @@ -0,0 +1,104 @@ +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.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.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; + +/** + * Tests for {@link DefaultNotificationTemplateRender}. + * + * @author guqing + * @since 2.9.0 + */ +@ExtendWith(MockitoExtension.class) +class DefaultNotificationTemplateRenderTest { + + @Mock + private SystemConfigurableEnvironmentFetcher 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(); + } +} diff --git a/application/src/test/java/run/halo/app/notification/DefaultNotifierConfigStoreTest.java b/application/src/test/java/run/halo/app/notification/DefaultNotifierConfigStoreTest.java new file mode 100644 index 0000000..eded2ec --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/DefaultNotifierConfigStoreTest.java @@ -0,0 +1,185 @@ +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"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/notification/DefaultSubscriberEmailResolverTest.java b/application/src/test/java/run/halo/app/notification/DefaultSubscriberEmailResolverTest.java new file mode 100644 index 0000000..a222860 --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/DefaultSubscriberEmailResolverTest.java @@ -0,0 +1,70 @@ +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(); + } +} diff --git a/application/src/test/java/run/halo/app/notification/LanguageUtilsTest.java b/application/src/test/java/run/halo/app/notification/LanguageUtilsTest.java new file mode 100644 index 0000000..1f52e38 --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/LanguageUtilsTest.java @@ -0,0 +1,47 @@ +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."); + } +} diff --git a/application/src/test/java/run/halo/app/notification/NotificationContextTest.java b/application/src/test/java/run/halo/app/notification/NotificationContextTest.java new file mode 100644 index 0000000..347cd5f --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/NotificationContextTest.java @@ -0,0 +1,66 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/notification/NotificationTriggerTest.java b/application/src/test/java/run/halo/app/notification/NotificationTriggerTest.java new file mode 100644 index 0000000..e9faaa2 --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/NotificationTriggerTest.java @@ -0,0 +1,58 @@ +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.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 + private ExtensionClient client; + + @Mock + private NotificationCenter notificationCenter; + + @InjectMocks + private NotificationTrigger notificationTrigger; + + @Test + void reconcile() { + 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).update(any(Reason.class)); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/notification/ReasonNotificationTemplateSelectorImplTest.java b/application/src/test/java/run/halo/app/notification/ReasonNotificationTemplateSelectorImplTest.java new file mode 100644 index 0000000..7332ab4 --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/ReasonNotificationTemplateSelectorImplTest.java @@ -0,0 +1,178 @@ +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(); + } +} diff --git a/application/src/test/java/run/halo/app/notification/ReasonPayloadTest.java b/application/src/test/java/run/halo/app/notification/ReasonPayloadTest.java new file mode 100644 index 0000000..16590f7 --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/ReasonPayloadTest.java @@ -0,0 +1,45 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/notification/RecipientResolverImplTest.java b/application/src/test/java/run/halo/app/notification/RecipientResolverImplTest.java new file mode 100644 index 0000000..276019d --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/RecipientResolverImplTest.java @@ -0,0 +1,180 @@ +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; + } +} diff --git a/application/src/test/java/run/halo/app/notification/SubscriptionServiceImplTest.java b/application/src/test/java/run/halo/app/notification/SubscriptionServiceImplTest.java new file mode 100644 index 0000000..07ffbc8 --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/SubscriptionServiceImplTest.java @@ -0,0 +1,74 @@ +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")); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/notification/SubscriptionServiceIntegrationTest.java b/application/src/test/java/run/halo/app/notification/SubscriptionServiceIntegrationTest.java new file mode 100644 index 0000000..6a55506 --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/SubscriptionServiceIntegrationTest.java @@ -0,0 +1,171 @@ +package run.halo.app.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.verify; +import static run.halo.app.extension.index.query.QueryFactory.isNull; + +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.boot.test.mock.mockito.SpyBean; +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.notification.Subscription; +import run.halo.app.extension.Extension; +import run.halo.app.extension.ExtensionStoreUtil; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.PageRequestImpl; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.SchemeManager; +import run.halo.app.extension.index.IndexerFactory; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.extension.store.ReactiveExtensionStoreClient; +import run.halo.app.infra.utils.JsonUtils; + +/** + * Integration tests for {@link SubscriptionService}. + * + * @author guqing + * @since 2.15.0 + */ +@DirtiesContext +@SpringBootTest +class SubscriptionServiceIntegrationTest { + + @Autowired + private SchemeManager schemeManager; + + @SpyBean + private ReactiveExtensionClient client; + + @Autowired + private ReactiveExtensionStoreClient storeClient; + + @Autowired + private IndexerFactory indexerFactory; + + Mono deleteImmediately(Extension extension) { + var name = extension.getMetadata().getName(); + var scheme = schemeManager.get(extension.getClass()); + // un-index + var indexer = indexerFactory.getIndexer(extension.groupVersionKind()); + indexer.unIndexRecord(extension.getMetadata().getName()); + + // delete from db + var storeName = ExtensionStoreUtil.buildStoreName(scheme, name); + return storeClient.delete(storeName, extension.getMetadata().getVersion()) + .thenReturn(extension); + } + + @Nested + class RemoveInitialBatchTest { + static int size = 310; + private final List storedSubscriptions = subscriptionsForStore(); + + @Autowired + private SubscriptionService subscriptionService; + + @BeforeEach + void setUp() { + Flux.fromIterable(storedSubscriptions) + .flatMap(comment -> client.create(comment)) + .as(StepVerifier::create) + .expectNextCount(storedSubscriptions.size()) + .verifyComplete(); + } + + @AfterEach + void tearDown() { + Flux.fromIterable(storedSubscriptions) + .flatMap(SubscriptionServiceIntegrationTest.this::deleteImmediately) + .as(StepVerifier::create) + .expectNextCount(storedSubscriptions.size()) + .verifyComplete(); + } + + private List subscriptionsForStore() { + List subscriptions = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + var subscription = createSubscription(); + subscription.getMetadata().setName("subscription-" + i); + subscriptions.add(subscription); + } + return subscriptions; + } + + @Test + void removeTest() { + var subscriber = new Subscription.Subscriber(); + subscriber.setName("admin"); + var interestReason = new Subscription.InterestReason(); + interestReason.setReasonType("new-comment-on-post"); + var subject = new Subscription.ReasonSubject(); + subject.setApiVersion("content.halo.run/v1alpha1"); + subject.setKind("Post"); + interestReason.setSubject(subject); + + subscriptionService.remove(subscriber, interestReason).block(); + + verify(client, atLeast(size)).delete(any(Subscription.class)); + assertCleanedUp(); + } + + @Test + void removeBySubscriberTest() { + var subscriber = new Subscription.Subscriber(); + subscriber.setName("admin"); + + subscriptionService.remove(subscriber).block(); + verify(client, atLeast(size)).delete(any(Subscription.class)); + assertCleanedUp(); + } + + private void assertCleanedUp() { + var listOptions = new ListOptions(); + listOptions.setFieldSelector(FieldSelector.of(isNull("metadata.deletionTimestamp"))); + client.listBy(Subscription.class, listOptions, PageRequestImpl.ofSize(1)) + .as(StepVerifier::create) + .consumeNextWith(result -> { + assertThat(result.getTotal()).isEqualTo(0); + assertThat(result.getItems()).isEmpty(); + }) + .verifyComplete(); + } + } + + Subscription createSubscription() { + return JsonUtils.jsonToObject(""" + { + "spec": { + "subscriber": { + "name": "admin" + }, + "unsubscribeToken": "423530c9-bec7-446e-b73b-dd98ac00ba2b", + "reason": { + "reasonType": "new-comment-on-post", + "subject": { + "name": "5152aea5-c2e8-4717-8bba-2263d46e19d5", + "apiVersion": "content.halo.run/v1alpha1", + "kind": "Post" + } + }, + "disabled": false + }, + "apiVersion": "notification.halo.run/v1alpha1", + "kind": "Subscription", + "metadata": { + "generateName": "subscription-" + } + } + """, Subscription.class); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/notification/UserIdentityTest.java b/application/src/test/java/run/halo/app/notification/UserIdentityTest.java new file mode 100644 index 0000000..8df7d90 --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/UserIdentityTest.java @@ -0,0 +1,21 @@ +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"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/notification/UserNotificationPreferenceServiceImplTest.java b/application/src/test/java/run/halo/app/notification/UserNotificationPreferenceServiceImplTest.java new file mode 100644 index 0000000..62be0f3 --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/UserNotificationPreferenceServiceImplTest.java @@ -0,0 +1,139 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/notification/UserNotificationPreferenceTest.java b/application/src/test/java/run/halo/app/notification/UserNotificationPreferenceTest.java new file mode 100644 index 0000000..d8179ff --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/UserNotificationPreferenceTest.java @@ -0,0 +1,43 @@ +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"); + } +} diff --git a/application/src/test/java/run/halo/app/notification/endpoint/SubscriptionRouterTest.java b/application/src/test/java/run/halo/app/notification/endpoint/SubscriptionRouterTest.java new file mode 100644 index 0000000..8bff1c1 --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/endpoint/SubscriptionRouterTest.java @@ -0,0 +1,46 @@ +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"); + } +} diff --git a/application/src/test/java/run/halo/app/notification/endpoint/UserNotificationPreferencesEndpointTest.java b/application/src/test/java/run/halo/app/notification/endpoint/UserNotificationPreferencesEndpointTest.java new file mode 100644 index 0000000..f5aa335 --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/endpoint/UserNotificationPreferencesEndpointTest.java @@ -0,0 +1,71 @@ +package run.halo.app.notification.endpoint; + +import static org.mockito.ArgumentMatchers.any; +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.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(); + } + + @Test + void listNotificationPreferences() { + when(client.list(eq(ReasonType.class), eq(null), any())).thenReturn(Flux.empty()); + 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(ReasonType.class), eq(null), any())).thenReturn(Flux.empty()); + 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(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/plugin/DefaultDevelopmentPluginRepositoryTest.java b/application/src/test/java/run/halo/app/plugin/DefaultDevelopmentPluginRepositoryTest.java new file mode 100644 index 0000000..1e04d81 --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/DefaultDevelopmentPluginRepositoryTest.java @@ -0,0 +1,43 @@ +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(); + } +} diff --git a/application/src/test/java/run/halo/app/plugin/DefaultPluginApplicationContextFactoryTest.java b/application/src/test/java/run/halo/app/plugin/DefaultPluginApplicationContextFactoryTest.java new file mode 100644 index 0000000..0f37a59 --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/DefaultPluginApplicationContextFactoryTest.java @@ -0,0 +1,48 @@ +package run.halo.app.plugin; + +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.pf4j.PluginWrapper; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import run.halo.app.search.SearchService; + +@SpringBootTest +class DefaultPluginApplicationContextFactoryTest { + + @SpyBean + 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 sp = mock(SpringPlugin.class); + var pluginContext = new PluginContext.PluginContextBuilder() + .name("fake-plugin") + .version("1.0.0") + .build(); + when(sp.getPluginContext()).thenReturn(pluginContext); + when(pw.getPlugin()).thenReturn(sp); + when(pluginManager.getPlugin("fake-plugin")).thenReturn(pw); + var context = factory.create("fake-plugin"); + + assertInstanceOf(PluginApplicationContext.class, context); + assertNotNull(context.getBeanProvider(SearchService.class).getIfUnique()); + assertNotNull(context.getBeanProvider(PluginsRootGetter.class).getIfUnique()); + // TODO Add more assertions here. + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/plugin/DefaultPluginRouterFunctionRegistryTest.java b/application/src/test/java/run/halo/app/plugin/DefaultPluginRouterFunctionRegistryTest.java new file mode 100644 index 0000000..578c524 --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/DefaultPluginRouterFunctionRegistryTest.java @@ -0,0 +1,34 @@ +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()); + } + +} diff --git a/application/src/test/java/run/halo/app/plugin/DefaultSettingFetcherTest.java b/application/src/test/java/run/halo/app/plugin/DefaultSettingFetcherTest.java new file mode 100644 index 0000000..5e7f0d2 --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/DefaultSettingFetcherTest.java @@ -0,0 +1,218 @@ +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.inOrder; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static run.halo.app.plugin.DefaultReactiveSettingFetcher.buildCacheKey; + +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.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCache; +import org.springframework.context.ApplicationContext; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Plugin; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ExtensionClient; +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; + + @Mock + private ExtensionClient blockingClient; + + @Mock + private CacheManager cacheManager; + + @MockBean + private final PluginContext pluginContext = PluginContext.builder() + .name("fake") + .configMapName("fake-config") + .build(); + + @Mock + private ApplicationContext applicationContext; + + private DefaultReactiveSettingFetcher reactiveSettingFetcher; + private DefaultSettingFetcher settingFetcher; + + @Spy + Cache cache = new ConcurrentMapCache(buildCacheKey(pluginContext.getName())); + + @BeforeEach + void setUp() { + cache.invalidate(); + + this.reactiveSettingFetcher = new DefaultReactiveSettingFetcher(pluginContext, client, + blockingClient, cacheManager); + reactiveSettingFetcher.setApplicationContext(applicationContext); + + settingFetcher = new DefaultSettingFetcher(reactiveSettingFetcher); + + when(cacheManager.getCache(eq(cache.getName()))).thenReturn(cache); + + 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(blockingClient.fetch(eq(ConfigMap.class), eq(pluginContext.getConfigMapName()))) + .thenReturn(Optional.of(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 + var inOrder = inOrder(cache, applicationContext); + inOrder.verify(cache).put(eq("fake"), any()); + inOrder.verify(applicationContext).publishEvent(isA(PluginConfigUpdatedEvent.class)); + + Map updatedValues = settingFetcher.getValues(); + verify(client, times(1)).fetch(eq(ConfigMap.class), any()); + assertThat(updatedValues).hasSize(2); + JSONAssert.assertEquals(configMap.getData().get("sns"), + JsonUtils.objectToJson(updatedValues.get("sns")), true); + + // cleanup cache + reactiveSettingFetcher.destroy(); + + updatedValues = settingFetcher.getValues(); + assertThat(updatedValues).hasSize(2); + verify(client, times(2)).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; + } + + private Plugin buildPlugin() { + Plugin plugin = new Plugin(); + plugin.setKind("Plugin"); + plugin.setApiVersion("plugin.halo.run/v1alpha1"); + + Metadata pluginMetadata = new Metadata(); + pluginMetadata.setName("fakePlugin"); + plugin.setMetadata(pluginMetadata); + + Plugin.PluginSpec pluginSpec = new Plugin.PluginSpec(); + pluginSpec.setConfigMapName("fakeConfigMap"); + pluginSpec.setSettingName("fakeSetting"); + plugin.setSpec(pluginSpec); + return plugin; + } + + 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) { + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/plugin/HaloPluginManagerTest.java b/application/src/test/java/run/halo/app/plugin/HaloPluginManagerTest.java new file mode 100644 index 0000000..d6711e1 --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/HaloPluginManagerTest.java @@ -0,0 +1,63 @@ +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()); + } + + +} diff --git a/application/src/test/java/run/halo/app/plugin/PluginExtensionLoaderUtilsTest.java b/application/src/test/java/run/halo/app/plugin/PluginExtensionLoaderUtilsTest.java new file mode 100644 index 0000000..416f849 --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/PluginExtensionLoaderUtilsTest.java @@ -0,0 +1,40 @@ +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))); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/plugin/PluginRequestMappingHandlerMappingTest.java b/application/src/test/java/run/halo/app/plugin/PluginRequestMappingHandlerMappingTest.java new file mode 100644 index 0000000..524bda5 --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/PluginRequestMappingHandlerMappingTest.java @@ -0,0 +1,309 @@ +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() { + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/plugin/PluginsRootGetterImplTest.java b/application/src/test/java/run/halo/app/plugin/PluginsRootGetterImplTest.java new file mode 100644 index 0000000..68744cb --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/PluginsRootGetterImplTest.java @@ -0,0 +1,30 @@ +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()); + } + +} diff --git a/application/src/test/java/run/halo/app/plugin/SharedApplicationContextFactoryTest.java b/application/src/test/java/run/halo/app/plugin/SharedApplicationContextFactoryTest.java new file mode 100644 index 0000000..80518da --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/SharedApplicationContextFactoryTest.java @@ -0,0 +1,29 @@ +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.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; + +/** + * Tests for {@link SharedApplicationContextFactory}. + * + * @author guqing + * @since 2.0.0 + */ +@SpringBootTest +@AutoConfigureTestDatabase +class SharedApplicationContextFactoryTest { + + @Autowired + ApplicationContext applicationContext; + + @Test + void createSharedApplicationContext() { + var sharedContext = SharedApplicationContextFactory.create(applicationContext); + assertNotNull(sharedContext); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/plugin/SharedEventDispatcherTest.java b/application/src/test/java/run/halo/app/plugin/SharedEventDispatcherTest.java new file mode 100644 index 0000000..53cc238 --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/SharedEventDispatcherTest.java @@ -0,0 +1,108 @@ +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.PluginManager; +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 + PluginManager 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(SpringPlugin.class); + var context = + mock(ApplicationContext.class, withSettings().extraInterfaces(Lifecycle.class)); + when(((Lifecycle) context).isRunning()).thenReturn(true); + when(plugin.getApplicationContext()).thenReturn(context); + when(pw.getPlugin()).thenReturn(plugin); + when(pluginManager.getStartedPlugins()).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(SpringPlugin.class); + var context = + mock(ApplicationContext.class, withSettings().extraInterfaces(Lifecycle.class)); + when(((Lifecycle) context).isRunning()).thenReturn(false); + when(plugin.getApplicationContext()).thenReturn(context); + when(pw.getPlugin()).thenReturn(plugin); + when(pluginManager.getStartedPlugins()).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(SpringPlugin.class); + var context = mock(ApplicationContext.class); + when(plugin.getApplicationContext()).thenReturn(context); + when(pw.getPlugin()).thenReturn(plugin); + when(pluginManager.getStartedPlugins()).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); + } + + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/plugin/SpringComponentsFinderTest.java b/application/src/test/java/run/halo/app/plugin/SpringComponentsFinderTest.java new file mode 100644 index 0000000..11c86ad --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/SpringComponentsFinderTest.java @@ -0,0 +1,95 @@ +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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/plugin/YamlPluginDescriptorFinderTest.java b/application/src/test/java/run/halo/app/plugin/YamlPluginDescriptorFinderTest.java new file mode 100644 index 0000000..e47eabe --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/YamlPluginDescriptorFinderTest.java @@ -0,0 +1,100 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java b/application/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java new file mode 100644 index 0000000..a858f83 --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java @@ -0,0 +1,174 @@ +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); + } +} diff --git a/application/src/test/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetterTest.java b/application/src/test/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetterTest.java new file mode 100644 index 0000000..bab1b6e --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetterTest.java @@ -0,0 +1,250 @@ +package run.halo.app.plugin.extensionpoint; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +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.ListOptions; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting.ExtensionPointEnabled; +import run.halo.app.plugin.extensionpoint.ExtensionPointDefinition.ExtensionPointType; + +@ExtendWith(MockitoExtension.class) +class DefaultExtensionGetterTest { + + @Mock + ReactiveExtensionClient client; + + @Mock + PluginManager pluginManager; + + @Mock + SystemConfigurableEnvironmentFetcher configFetcher; + + @Mock + BeanFactory beanFactory; + + @InjectMocks + DefaultExtensionGetter getter; + + @Test + void shouldGetExtensionBySingletonDefinitionWhenExtensionPointEnabledSet() { + // prepare extension point definition + when(client.listBy(same(ExtensionPointDefinition.class), any(ListOptions.class), any())) + .thenReturn(Mono.fromSupplier(() -> { + var epd = createExtensionPointDefinition("fake-extension-point", + FakeExtensionPoint.class, + ExtensionPointType.SINGLETON); + return new ListResult<>(List.of(epd)); + })); + + when(client.fetch(ExtensionDefinition.class, "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(); + when(pluginManager.getExtensions(FakeExtensionPoint.class)) + .thenReturn(List.of(extensionImpl)); + + getter.getEnabledExtensions(FakeExtensionPoint.class) + .as(StepVerifier::create) + .expectNext(extensionImpl) + .verifyComplete(); + } + + @Test + void shouldGetDefaultSingletonDefinitionWhileExtensionPointEnabledNotSet() { + when(client.listBy(same(ExtensionPointDefinition.class), any(ListOptions.class), any())) + .thenReturn(Mono.fromSupplier(() -> { + var epd = createExtensionPointDefinition("fake-extension-point", + FakeExtensionPoint.class, + ExtensionPointType.SINGLETON); + return new ListResult<>(List.of(epd)); + })); + + 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); + + when(pluginManager.getExtensions(FakeExtensionPoint.class)) + .thenReturn(List.of()); + + getter.getEnabledExtensions(FakeExtensionPoint.class) + .as(StepVerifier::create) + .expectNext(extensionDefaultImpl) + .verifyComplete(); + } + + @Test + void shouldGetMultiInstanceExtensionWhileExtensionPointEnabledSet() { + // prepare extension point definition + when(client.listBy(same(ExtensionPointDefinition.class), any(ListOptions.class), any())) + .thenReturn(Mono.fromSupplier(() -> { + var epd = createExtensionPointDefinition("fake-extension-point", + FakeExtensionPoint.class, + ExtensionPointType.MULTI_INSTANCE); + return new ListResult<>(List.of(epd)); + })); + + when(client.fetch(ExtensionDefinition.class, "fake-extension")) + .thenReturn(Mono.fromSupplier(() -> createExtensionDefinition( + "fake-extension", + FakeExtensionPointImpl.class, + "fake-extension-point"))); + + when(client.fetch(ExtensionDefinition.class, "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() { + }; + when(pluginManager.getExtensions(FakeExtensionPoint.class)) + .thenReturn(List.of(extensionImpl, anotherExtensionImpl)); + + getter.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(client.listBy(same(ExtensionPointDefinition.class), any(ListOptions.class), any())) + .thenReturn(Mono.fromSupplier(() -> { + var epd = createExtensionPointDefinition("fake-extension-point", + FakeExtensionPoint.class, + ExtensionPointType.MULTI_INSTANCE); + return new ListResult<>(List.of(epd)); + })); + + 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() { + }; + when(pluginManager.getExtensions(FakeExtensionPoint.class)) + .thenReturn(List.of(extensionImpl, anotherExtensionImpl)); + + getter.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(); + } + + 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; + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/plugin/resources/BundleResourceUtilsTest.java b/application/src/test/java/run/halo/app/plugin/resources/BundleResourceUtilsTest.java new file mode 100644 index 0000000..a69801c --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/resources/BundleResourceUtilsTest.java @@ -0,0 +1,67 @@ +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.PluginWrapper; +import org.springframework.core.io.Resource; +import run.halo.app.infra.exception.AccessDeniedException; +import run.halo.app.plugin.HaloPluginManager; + +/** + * Tests for {@link BundleResourceUtils}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class BundleResourceUtilsTest { + + @Mock + private HaloPluginManager 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); + } +} diff --git a/application/src/test/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionFactoryTest.java b/application/src/test/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionFactoryTest.java new file mode 100644 index 0000000..8072c04 --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionFactoryTest.java @@ -0,0 +1,132 @@ +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; + } +} diff --git a/application/src/test/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionRegistryTest.java b/application/src/test/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionRegistryTest.java new file mode 100644 index 0000000..4d9d007 --- /dev/null +++ b/application/src/test/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionRegistryTest.java @@ -0,0 +1,76 @@ +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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/search/HaloDocumentEventsListenerTest.java b/application/src/test/java/run/halo/app/search/HaloDocumentEventsListenerTest.java new file mode 100644 index 0000000..8721fc8 --- /dev/null +++ b/application/src/test/java/run/halo/app/search/HaloDocumentEventsListenerTest.java @@ -0,0 +1,84 @@ +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)) + ); + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/search/IndexEndpointTest.java b/application/src/test/java/run/halo/app/search/IndexEndpointTest.java new file mode 100644 index 0000000..e77e190 --- /dev/null +++ b/application/src/test/java/run/halo/app/search/IndexEndpointTest.java @@ -0,0 +1,126 @@ +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 shouldBeCompatibleWithOldSearchApi() { + var searchResult = new SearchResult(); + when(searchService.search(any(SearchOption.class))) + .thenReturn(Mono.just(searchResult)); + + client.get().uri(uriBuilder -> uriBuilder.path("/indices/post") + .queryParam("keyword", "halo") + .build()) + .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()); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/search/IndicesEndpointTest.java b/application/src/test/java/run/halo/app/search/IndicesEndpointTest.java new file mode 100644 index 0000000..48f1ea2 --- /dev/null +++ b/application/src/test/java/run/halo/app/search/IndicesEndpointTest.java @@ -0,0 +1,52 @@ +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", "/indices/post"}) + 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()); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/search/SearchServiceImplTest.java b/application/src/test/java/run/halo/app/search/SearchServiceImplTest.java new file mode 100644 index 0000000..53325fa --- /dev/null +++ b/application/src/test/java/run/halo/app/search/SearchServiceImplTest.java @@ -0,0 +1,106 @@ +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(); + } +} diff --git a/application/src/test/java/run/halo/app/search/lucene/LuceneSearchEngineIntegrationTest.java b/application/src/test/java/run/halo/app/search/lucene/LuceneSearchEngineIntegrationTest.java new file mode 100644 index 0000000..9437af0 --- /dev/null +++ b/application/src/test/java/run/halo/app/search/lucene/LuceneSearchEngineIntegrationTest.java @@ -0,0 +1,245 @@ +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.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.retry.support.RetryTemplateBuilder; +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() { + 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) { + var retryTemplate = new RetryTemplateBuilder() + .exponentialBackoff(Duration.ofMillis(200), 2.0, Duration.ofSeconds(10)) + .maxAttempts(maxAttempts) + .retryOn(AssertionFailedError.class) + .build(); + var option = new SearchOption(); + option.setKeyword("halo"); + option.setHighlightPreTag(""); + option.setHighlightPostTag(""); + retryTemplate.execute(context -> { + 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) { + var retryTemplate = new RetryTemplateBuilder() + .exponentialBackoff(Duration.ofMillis(200), 2.0, Duration.ofSeconds(10)) + .maxAttempts(maxAttempts) + .retryOn(AssertionFailedError.class) + .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(context -> { + 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); + } + +} diff --git a/application/src/test/java/run/halo/app/search/lucene/LuceneSearchEngineTest.java b/application/src/test/java/run/halo/app/search/lucene/LuceneSearchEngineTest.java new file mode 100644 index 0000000..4e73d00 --- /dev/null +++ b/application/src/test/java/run/halo/app/search/lucene/LuceneSearchEngineTest.java @@ -0,0 +1,185 @@ +package run.halo.app.search.lucene; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.assertArg; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.analysis.standard.StandardAnalyzer; +import org.apache.lucene.document.Document; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.StoredFields; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.ScoreDoc; +import org.apache.lucene.search.SearcherManager; +import org.apache.lucene.search.Sort; +import org.apache.lucene.search.TopFieldDocs; +import org.apache.lucene.search.TotalHits; +import org.apache.lucene.store.Directory; +import org.assertj.core.util.Streams; +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 run.halo.app.search.HaloDocument; +import run.halo.app.search.SearchOption; + +@ExtendWith(MockitoExtension.class) +class LuceneSearchEngineTest { + + @Mock + IndexWriter indexWriter; + + @Mock + SearcherManager searcherManager; + + @Mock + Directory directory; + + @Mock + Analyzer analyzer; + + LuceneSearchEngine searchEngine; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() throws Exception { + var searchEngine = new LuceneSearchEngine(tempDir); + searchEngine.setIndexWriter(indexWriter); + searchEngine.setDirectory(directory); + searchEngine.setSearcherManager(searcherManager); + searchEngine.setAnalyzer(analyzer); + this.searchEngine = searchEngine; + } + + + @Test + void shouldAddOrUpdateDocument() throws IOException { + var haloDoc = createFakeHaloDoc(); + searchEngine.addOrUpdate(List.of(haloDoc)); + verify(this.indexWriter).updateDocuments(any(Query.class), assertArg(docs -> { + var docList = Streams.stream(docs).toList(); + assertEquals(1, docList.size()); + var doc = docList.get(0); + assertInstanceOf(Document.class, doc); + var document = (Document) doc; + assertEquals("fake-id", document.get("id")); + })); + verify(this.searcherManager).maybeRefreshBlocking(); + verify(this.indexWriter).commit(); + } + + @Test + void shouldDeleteDocument() throws IOException { + this.searchEngine.deleteDocument(List.of("fake-id")); + verify(this.indexWriter).deleteDocuments(any(Query.class)); + verify(this.searcherManager).maybeRefreshBlocking(); + verify(this.indexWriter).commit(); + } + + @Test + void shouldDeleteAll() throws IOException { + this.searchEngine.deleteAll(); + + verify(this.indexWriter).deleteAll(); + verify(this.searcherManager).maybeRefreshBlocking(); + verify(this.indexWriter).commit(); + } + + @Test + void shouldDestroy() throws Exception { + this.searchEngine.destroy(); + verify(this.analyzer).close(); + verify(this.searcherManager).close(); + verify(this.indexWriter).close(); + verify(this.directory).close(); + } + + @Test + void shouldAlwaysDestroyAllEvenErrorOccurred() throws Exception { + var analyzerCloseError = new IOException("analyzer close error"); + doThrow(analyzerCloseError).when(this.analyzer).close(); + + var directoryCloseError = new IOException("directory close error"); + doThrow(directoryCloseError).when(this.directory).close(); + var e = assertThrows(IOException.class, () -> this.searchEngine.destroy()); + assertEquals(analyzerCloseError, e); + assertEquals(directoryCloseError, e.getSuppressed()[0]); + verify(this.analyzer).close(); + verify(this.searcherManager).close(); + verify(this.indexWriter).close(); + verify(this.directory).close(); + } + + @Test + void shouldSearch() throws IOException { + var searcher = mock(IndexSearcher.class); + when(this.searcherManager.acquire()).thenReturn(searcher); + this.searchEngine.setAnalyzer(new StandardAnalyzer()); + + var totalHits = new TotalHits(1234, TotalHits.Relation.EQUAL_TO); + var scoreDoc = new ScoreDoc(1, 1.0f); + + var topFieldDocs = new TopFieldDocs(totalHits, new ScoreDoc[] {scoreDoc}, null); + when(searcher.search(any(Query.class), eq(123), any(Sort.class))) + .thenReturn(topFieldDocs); + var storedFields = mock(StoredFields.class); + + var haloDoc = createFakeHaloDoc(); + var doc = this.searchEngine.getHaloDocumentConverter().convert(haloDoc); + when(storedFields.document(1)).thenReturn(doc); + when(searcher.storedFields()).thenReturn(storedFields); + + var option = new SearchOption(); + option.setKeyword("fake"); + option.setLimit(123); + option.setHighlightPreTag(""); + option.setHighlightPostTag(""); + var result = this.searchEngine.search(option); + assertEquals(1234, 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()); + } + + 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; + } + +} diff --git a/application/src/test/java/run/halo/app/search/post/PostEventsListenerTest.java b/application/src/test/java/run/halo/app/search/post/PostEventsListenerTest.java new file mode 100644 index 0000000..7e8968b --- /dev/null +++ b/application/src/test/java/run/halo/app/search/post/PostEventsListenerTest.java @@ -0,0 +1,124 @@ +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)) + ); + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/search/post/PostHaloDocumentsProviderTest.java b/application/src/test/java/run/halo/app/search/post/PostHaloDocumentsProviderTest.java new file mode 100644 index 0000000..1f2771a --- /dev/null +++ b/application/src/test/java/run/halo/app/search/post/PostHaloDocumentsProviderTest.java @@ -0,0 +1,89 @@ +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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java b/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java new file mode 100644 index 0000000..6487ee3 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java @@ -0,0 +1,185 @@ +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.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +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.skyscreamer.jsonassert.JSONAssert; +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.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +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) +class AuthProviderServiceImplTest { + @Mock + private ReactiveExtensionClient client; + + @InjectMocks + private AuthProviderServiceImpl authProviderService; + + @Test + void testEnable() { + // 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()); + + ConfigMap configMap = new ConfigMap(); + configMap.setData(new HashMap<>()); + when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) + .thenReturn(Mono.just(configMap)); + + AuthProvider local = createAuthProvider("local"); + local.getMetadata().getLabels().put(AuthProvider.PRIVILEGED_LABEL, "true"); + when(client.list(eq(AuthProvider.class), any(), any())).thenReturn(Flux.just(local)); + + // Call the method being tested + Mono result = authProviderService.enable("github"); + + assertEquals(authProvider, result.block()); + ConfigMap value = captor.getValue(); + String providerSettingStr = value.getData().get(SystemSetting.AuthProvider.GROUP); + Set enabled = + JsonUtils.jsonToObject(providerSettingStr, SystemSetting.AuthProvider.class) + .getEnabled(); + assertThat(enabled).containsExactly("github"); + // Verify the result + verify(client).get(AuthProvider.class, "github"); + verify(client).fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG)); + } + + @Test + void testDisable() { + // 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"); + when(client.list(eq(AuthProvider.class), any(), any())).thenReturn(Flux.just(local)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigMap.class); + when(client.update(captor.capture())).thenReturn(Mono.empty()); + + ConfigMap configMap = new ConfigMap(); + configMap.setData(new HashMap<>()); + configMap.getData().put(SystemSetting.AuthProvider.GROUP, "{\"enabled\":[\"github\"]}"); + when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) + .thenReturn(Mono.just(configMap)); + + // Call the method being tested + Mono result = authProviderService.disable("github"); + + assertEquals(authProvider, result.block()); + ConfigMap value = captor.getValue(); + String providerSettingStr = value.getData().get(SystemSetting.AuthProvider.GROUP); + Set enabled = + JsonUtils.jsonToObject(providerSettingStr, SystemSetting.AuthProvider.class) + .getEnabled(); + assertThat(enabled).isEmpty(); + // Verify the result + verify(client).get(AuthProvider.class, "github"); + verify(client).fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG)); + } + + + @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.list(eq(AuthProvider.class), any(), any())) + .thenReturn(Flux.just(github, gitlab, gitee)); + when(client.list(eq(UserConnection.class), any(), any())).thenReturn(Flux.empty()); + + ConfigMap configMap = new ConfigMap(); + configMap.setData(new HashMap<>()); + configMap.getData().put(SystemSetting.AuthProvider.GROUP, "{\"enabled\":[\"github\"]}"); + when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) + .thenReturn(Mono.just(configMap)); + + authProviderService.listAll() + .as(StepVerifier::create) + .consumeNextWith(result -> { + assertThat(result).hasSize(3); + try { + JSONAssert.assertEquals(""" + [{ + "name": "github", + "displayName": "github", + "bindingUrl": "fake-binding-url", + "enabled": true, + "isBound": false, + "supportsBinding": false, + "privileged": false + }, { + "name": "gitlab", + "displayName": "gitlab", + "bindingUrl": "fake-binding-url", + "enabled": false, + "isBound": false, + "supportsBinding": false, + "privileged": false + },{ + + "name": "gitee", + "displayName": "gitee", + "enabled": false, + "isBound": false, + "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); + return authProvider; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java b/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java new file mode 100644 index 0000000..f473bfc --- /dev/null +++ b/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java @@ -0,0 +1,35 @@ +package run.halo.app.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.http.HttpHeaders.WWW_AUTHENTICATE; + +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.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import reactor.test.StepVerifier; + +@ExtendWith(MockitoExtension.class) +class DefaultServerAuthenticationEntryPointTest { + + @InjectMocks + DefaultServerAuthenticationEntryPoint entryPoint; + + @Test + void commence() { + var mockReq = MockServerHttpRequest.get("/protected") + .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)); + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java b/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java new file mode 100644 index 0000000..123f436 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java @@ -0,0 +1,223 @@ +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.eq; +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.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.extension.Role; +import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.extension.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(); + } + + Role createRole(String roleName) { + var role = new Role(); + role.setMetadata(new Metadata()); + role.getMetadata().setName(roleName); + return role; + } + + 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; + + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java b/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java new file mode 100644 index 0000000..9d7a1d8 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java @@ -0,0 +1,109 @@ +package run.halo.app.security; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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.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); + + MockServerHttpRequest request = MockServerHttpRequest.get("/").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("/console"))); + verify(chain, never()).filter(eq(exchange)); + } + + @Test + void shouldNotRedirectWhenSystemInitialized() { + when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true)); + + WebFilterChain chain = mock(WebFilterChain.class); + + MockServerHttpRequest request = MockServerHttpRequest.get("/").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), + eq(URI.create("/console"))); + verify(chain).filter(eq(exchange)); + } + + @Test + void shouldNotRedirectWhenNotHomePage() { + WebFilterChain chain = mock(WebFilterChain.class); + + MockServerHttpRequest request = MockServerHttpRequest.get("/test").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), + eq(URI.create("/console"))); + verify(chain).filter(eq(exchange)); + } +} diff --git a/application/src/test/java/run/halo/app/security/ResponseMap.java b/application/src/test/java/run/halo/app/security/ResponseMap.java new file mode 100644 index 0000000..91c5b0b --- /dev/null +++ b/application/src/test/java/run/halo/app/security/ResponseMap.java @@ -0,0 +1,7 @@ +package run.halo.app.security; + +import java.util.HashMap; + +public class ResponseMap extends HashMap { + +} diff --git a/application/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java b/application/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java new file mode 100644 index 0000000..909bee4 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java @@ -0,0 +1,62 @@ +package run.halo.app.security; + +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.reactive.server.WebTestClient; +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.extension.ReactiveExtensionClient; + +@Disabled +@SpringBootTest(properties = {"halo.security.initializer.disabled=false", + "halo.security.initializer.super-admin-username=fake-admin", + "halo.security.initializer.super-admin-password=fake-password", + "halo.required-extension-disabled=true", + "halo.theme.initializer.disabled=true"}) +@AutoConfigureWebTestClient +@AutoConfigureTestDatabase +class SuperAdminInitializerTest { + + @SpyBean + ReactiveExtensionClient client; + + @Autowired + WebTestClient webClient; + + @Autowired + PasswordEncoder encoder; + + @Test + void checkSuperAdminInitialization() { + verify(client, times(1)).create(argThat(extension -> { + if (extension instanceof User user) { + return "fake-admin".equals(user.getMetadata().getName()) + && encoder.matches("fake-password", user.getSpec().getPassword()); + } + return false; + })); + verify(client, times(1)).create(argThat(extension -> { + if (extension instanceof Role role) { + return "super-role".equals(role.getMetadata().getName()); + } + return false; + })); + verify(client, times(1)).create(argThat(extension -> { + if (extension instanceof RoleBinding roleBinding) { + return "fake-admin-super-role-binding".equals(roleBinding.getMetadata().getName()); + } + return false; + })); + } +} diff --git a/application/src/test/java/run/halo/app/security/authentication/WebExchangeMatchersTest.java b/application/src/test/java/run/halo/app/security/authentication/WebExchangeMatchersTest.java new file mode 100644 index 0000000..5a74be4 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authentication/WebExchangeMatchersTest.java @@ -0,0 +1,38 @@ +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(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/authentication/impl/RsaKeyServiceTest.java b/application/src/test/java/run/halo/app/security/authentication/impl/RsaKeyServiceTest.java new file mode 100644 index 0000000..8d79920 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authentication/impl/RsaKeyServiceTest.java @@ -0,0 +1,122 @@ +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()); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java b/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java new file mode 100644 index 0000000..a40207b --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java @@ -0,0 +1,131 @@ +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.infra.exception.RateLimitExceededException; +import run.halo.app.security.authentication.CryptoService; + +@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(RateLimitExceededException.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()); + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/authentication/login/PublicKeyRouteBuilderTest.java b/application/src/test/java/run/halo/app/security/authentication/login/PublicKeyRouteBuilderTest.java new file mode 100644 index 0000000..bc7c861 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authentication/login/PublicKeyRouteBuilderTest.java @@ -0,0 +1,52 @@ +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(); + } + +} diff --git a/application/src/test/java/run/halo/app/security/authentication/pat/PatTest.java b/application/src/test/java/run/halo/app/security/authentication/pat/PatTest.java new file mode 100644 index 0000000..42c5e83 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authentication/pat/PatTest.java @@ -0,0 +1,40 @@ +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.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +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")); + }); + } + +} diff --git a/application/src/test/java/run/halo/app/security/authentication/rememberme/PersistentTokenBasedRememberMeServicesTest.java b/application/src/test/java/run/halo/app/security/authentication/rememberme/PersistentTokenBasedRememberMeServicesTest.java new file mode 100644 index 0000000..c016dd2 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authentication/rememberme/PersistentTokenBasedRememberMeServicesTest.java @@ -0,0 +1,163 @@ +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(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/authentication/rememberme/RememberTokenCleanerTest.java b/application/src/test/java/run/halo/app/security/authentication/rememberme/RememberTokenCleanerTest.java new file mode 100644 index 0000000..cf9d0a6 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authentication/rememberme/RememberTokenCleanerTest.java @@ -0,0 +1,39 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServicesTest.java b/application/src/test/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServicesTest.java new file mode 100644 index 0000000..c179b85 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServicesTest.java @@ -0,0 +1,85 @@ +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.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) +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"); + } +} diff --git a/application/src/test/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSettingsTest.java b/application/src/test/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSettingsTest.java new file mode 100644 index 0000000..a46381e --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSettingsTest.java @@ -0,0 +1,41 @@ +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; + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java b/application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java new file mode 100644 index 0000000..f926643 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java @@ -0,0 +1,47 @@ +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 org.mockito.Mockito.mock; +import static run.halo.app.security.authorization.AuthorityUtils.authoritiesToRoles; +import static run.halo.app.security.authorization.AuthorityUtils.containsSuperRole; +import static run.halo.app.security.authorization.AuthorityUtils.isRealUser; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.RememberMeAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +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", "faker", "system:read"), roles); + } + + @Test + void containsSuperRoleTest() { + assertTrue(containsSuperRole(Set.of("super-role"))); + assertTrue(containsSuperRole(Set.of("super-role", "admin"))); + assertFalse(containsSuperRole(Set.of("admin"))); + } + + @Test + void shouldReturnTrueWhenAuthenticationIsRealUser() { + assertTrue(isRealUser(mock(UsernamePasswordAuthenticationToken.class))); + assertTrue(isRealUser(mock(RememberMeAuthenticationToken.class))); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java b/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java new file mode 100644 index 0000000..9ae0ef2 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java @@ -0,0 +1,151 @@ +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 org.springframework.web.reactive.function.server.RequestPredicates.GET; +import static org.springframework.web.reactive.function.server.RequestPredicates.PUT; +import static org.springframework.web.reactive.function.server.RequestPredicates.accept; +import static org.springframework.web.reactive.function.server.RouterFunctions.route; + +import java.util.ArrayList; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +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.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 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.extension.service.RoleService; +import run.halo.app.extension.Metadata; +import run.halo.app.infra.AnonymousUserConst; + +@SpringBootTest +@AutoConfigureWebTestClient +@Import(AuthorizationTest.TestConfig.class) +class AuthorizationTest { + + @Autowired + WebTestClient webClient; + + @MockBean + ReactiveUserDetailsService userDetailsService; + + @MockBean + ReactiveUserDetailsPasswordService userDetailsPasswordService; + + @MockBean + RoleService roleService; + + @BeforeEach + void setUp() { + webClient = webClient.mutateWith(csrf()); + } + + @Test + void anonymousUserAccessProtectedApi() { + when(userDetailsService.findByUsername(eq(AnonymousUserConst.PRINCIPAL))) + .thenReturn(Mono.empty()); + when(roleService.listDependenciesFlux(anySet())).thenReturn(Flux.empty()); + + webClient.get().uri("/apis/fake.halo.run/v1/posts").exchange().expectStatus() + .isUnauthorized(); + + verify(roleService).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"); + + verify(roleService).listDependenciesFlux(anySet()); + + webClient.get().uri("/apis/fake.halo.run/v1/posts/hello-halo").exchange() + .expectStatus() + .isUnauthorized(); + + verify(roleService, times(2)).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()); + } + + @TestConfiguration + static class TestConfig { + + @Bean + public RouterFunction postRoute() { + return route( + GET("/apis/fake.halo.run/v1/posts").and(accept(MediaType.APPLICATION_JSON)), + this::queryPosts).andRoute( + PUT("/apis/fake.halo.run/v1/posts/{name}").and(accept(MediaType.APPLICATION_JSON)), + this::updatePost); + } + + @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); + } + + } +} diff --git a/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java b/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java new file mode 100644 index 0000000..a311bac --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java @@ -0,0 +1,144 @@ +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.extension.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("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("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) { + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/authorization/PolicyRuleTest.java b/application/src/test/java/run/halo/app/security/authorization/PolicyRuleTest.java new file mode 100644 index 0000000..1f11328 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authorization/PolicyRuleTest.java @@ -0,0 +1,89 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/authorization/RbacRequestEvaluationTest.java b/application/src/test/java/run/halo/app/security/authorization/RbacRequestEvaluationTest.java new file mode 100644 index 0000000..efbfe06 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authorization/RbacRequestEvaluationTest.java @@ -0,0 +1,47 @@ +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); + } +} diff --git a/application/src/test/java/run/halo/app/security/authorization/RequestInfoResolverTest.java b/application/src/test/java/run/halo/app/security/authorization/RequestInfoResolverTest.java new file mode 100644 index 0000000..3e67d75 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authorization/RequestInfoResolverTest.java @@ -0,0 +1,304 @@ +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"})); + } + +} diff --git a/application/src/test/java/run/halo/app/security/device/DeviceServiceImplTest.java b/application/src/test/java/run/halo/app/security/device/DeviceServiceImplTest.java new file mode 100644 index 0000000..58d82ca --- /dev/null +++ b/application/src/test/java/run/halo/app/security/device/DeviceServiceImplTest.java @@ -0,0 +1,24 @@ +package run.halo.app.security.device; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link DeviceServiceImpl}. + * + * @author guqing + * @since 2.17.0 + */ +class DeviceServiceImplTest { + + @Test + void deviceInfoParseTest() { + var info = DeviceServiceImpl.DeviceInfo.parse( + "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"); + assertThat(info.os()).isEqualTo("Mac OS X 10.15.7"); + assertThat(info.browser()).isEqualTo("Chrome 126.0"); + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java b/application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java new file mode 100644 index 0000000..55804bb --- /dev/null +++ b/application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java @@ -0,0 +1,66 @@ +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.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 run.halo.app.security.authentication.login.HaloUser; +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 -> new TwoFactorAuthentication( + UsernamePasswordAuthenticationToken.authenticated(haloUser, + haloUser.getPassword(), + haloUser.getAuthorities()))); + } + + 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); + } +} diff --git a/application/src/test/java/run/halo/app/security/session/InMemoryReactiveIndexedSessionRepositoryTest.java b/application/src/test/java/run/halo/app/security/session/InMemoryReactiveIndexedSessionRepositoryTest.java new file mode 100644 index 0000000..d944af1 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/session/InMemoryReactiveIndexedSessionRepositoryTest.java @@ -0,0 +1,107 @@ +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; + } +} diff --git a/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java b/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java new file mode 100644 index 0000000..cd6f5bd --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java @@ -0,0 +1,162 @@ +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 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.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.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.theme.dialect.HaloProcessorDialect; + +/** + * Tests expression parser for reactive return value. + * + * @author guqing + * @see ReactivePropertyAccessor + * @see ReactiveSpelVariableExpressionEvaluator + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +public class ReactiveFinderExpressionParserTests { + @Mock + private ApplicationContext applicationContext; + + @Mock + private SystemConfigurableEnvironmentFetcher 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(SystemConfigurableEnvironmentFetcher.class))) + .thenReturn(environmentFetcher); + 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(""" +

+

+

+

+ + """); + } + + } +} diff --git a/application/src/test/java/run/halo/app/theme/SiteSettingVariablesAcquirerTest.java b/application/src/test/java/run/halo/app/theme/SiteSettingVariablesAcquirerTest.java new file mode 100644 index 0000000..8fd8066 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/SiteSettingVariablesAcquirerTest.java @@ -0,0 +1,62 @@ +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 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.extension.ConfigMap; +import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher environmentFetcher; + + @InjectMocks + private SiteSettingVariablesAcquirer siteSettingVariablesAcquirer; + + @Test + void acquireWhenExternalUrlSet() throws MalformedURLException { + var configMap = new ConfigMap(); + configMap.setData(Map.of()); + + var url = new URL("https://halo.run"); + when(externalUrlSupplier.getURL(any())).thenReturn(url); + when(environmentFetcher.getConfigMap()).thenReturn(Mono.just(configMap)); + + siteSettingVariablesAcquirer.acquire(mock(ServerWebExchange.class)) + .as(StepVerifier::create) + .consumeNextWith(result -> { + assertThat(result).containsKey("site"); + assertThat(result.get("site")).isInstanceOf(SiteSettingVo.class); + assertThat((SiteSettingVo) result.get("site")) + .extracting(SiteSettingVo::getUrl) + .isEqualTo(url); + }) + .verifyComplete(); + verify(externalUrlSupplier).getURL(any()); + } +} diff --git a/application/src/test/java/run/halo/app/theme/ThemeContextTest.java b/application/src/test/java/run/halo/app/theme/ThemeContextTest.java new file mode 100644 index 0000000..1a0733d --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/ThemeContextTest.java @@ -0,0 +1,35 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/ThemeLinkBuilderTest.java b/application/src/test/java/run/halo/app/theme/ThemeLinkBuilderTest.java new file mode 100644 index 0000000..0b0a375 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/ThemeLinkBuilderTest.java @@ -0,0 +1,133 @@ +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(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/ThemeLocaleContextResolverTest.java b/application/src/test/java/run/halo/app/theme/ThemeLocaleContextResolverTest.java new file mode 100644 index 0000000..8ab62e8 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/ThemeLocaleContextResolverTest.java @@ -0,0 +1,193 @@ +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.DEFAULT_PARAMETER_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); + } + + @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); + } + + + 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(DEFAULT_PARAMETER_NAME, "en"))); + } + + private ServerWebExchange exchangeForParam(String language) { + return MockServerWebExchange.from( + MockServerHttpRequest.get("/index?language=" + language)); + } +} diff --git a/application/src/test/java/run/halo/app/theme/ViewNameResolverTest.java b/application/src/test/java/run/halo/app/theme/ViewNameResolverTest.java new file mode 100644 index 0000000..2c8c602 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/ViewNameResolverTest.java @@ -0,0 +1,110 @@ +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.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +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.springframework.boot.autoconfigure.thymeleaf.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) +class ViewNameResolverTest { + + @Mock + private ThemeResolver themeResolver; + + @Mock + private ThymeleafProperties thymeleafProperties; + + @InjectMocks + private DefaultViewNameResolver viewNameResolver; + + @TempDir + private File themePath; + + @BeforeEach + void setUp() throws IOException { + when(thymeleafProperties.getSuffix()).thenReturn(ThymeleafProperties.DEFAULT_SUFFIX); + + var templatesPath = themePath.toPath().resolve("templates"); + if (!Files.exists(templatesPath)) { + Files.createDirectory(templatesPath); + } + Files.createFile(templatesPath.resolve("post_news.html")); + Files.createFile(templatesPath.resolve("post_docs.html")); + + when(themeResolver.getTheme(any())) + .thenReturn(Mono.fromSupplier(() -> ThemeContext.builder() + .name("fake-theme") + .path(themePath.toPath()) + .active(true) + .build()) + ); + } + + @Test + void resolveViewNameOrDefault() throws URISyntaxException { + ServerWebExchange exchange = Mockito.mock(ServerWebExchange.class); + 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"); + } +} diff --git a/application/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java b/application/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java new file mode 100644 index 0000000..2a0726b --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java @@ -0,0 +1,144 @@ +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.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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher 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); + } + + @Test + void doProcess() { + Context context = getContext(); + + when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.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:

+ + + + """; + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/dialect/CommentEnabledVariableProcessorTest.java b/application/src/test/java/run/halo/app/theme/dialect/CommentEnabledVariableProcessorTest.java new file mode 100644 index 0000000..507b7d5 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/CommentEnabledVariableProcessorTest.java @@ -0,0 +1,121 @@ +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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher environmentFetcher; + + @BeforeEach + void setUp() { + lenient().when(applicationContext.getBean(eq(ExtensionGetter.class))) + .thenReturn(extensionGetter); + } + + @Test + void getCommentWidget() { + when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.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)); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorIntegrationTest.java b/application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorIntegrationTest.java new file mode 100644 index 0000000..2ab16d4 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorIntegrationTest.java @@ -0,0 +1,205 @@ +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.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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher 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(SystemConfigurableEnvironmentFetcher.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 + */ + assertThat(Jsoup.parse(result).html()).isEqualTo(""" + + + + + Post detail + + + + + + this is body + + """); + } + + 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 + + + """; + } + } +} diff --git a/application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorTest.java b/application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorTest.java new file mode 100644 index 0000000..837e19b --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorTest.java @@ -0,0 +1,88 @@ +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; + } + } +} diff --git a/application/src/test/java/run/halo/app/theme/dialect/DuplicateMetaTagProcessorTest.java b/application/src/test/java/run/halo/app/theme/dialect/DuplicateMetaTagProcessorTest.java new file mode 100644 index 0000000..d48e658 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/DuplicateMetaTagProcessorTest.java @@ -0,0 +1,51 @@ +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"); + } +} diff --git a/application/src/test/java/run/halo/app/theme/dialect/GeneratorMetaProcessorTest.java b/application/src/test/java/run/halo/app/theme/dialect/GeneratorMetaProcessorTest.java new file mode 100644 index 0000000..a732d7a --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/GeneratorMetaProcessorTest.java @@ -0,0 +1,59 @@ +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.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +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; + + @MockBean + InitializationStateGetter initializationStateGetter; + + @MockBean + 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(); + } + +} diff --git a/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java b/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java new file mode 100644 index 0000000..c4c18af --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java @@ -0,0 +1,442 @@ +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 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.SystemConfigurableEnvironmentFetcher; +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.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 SystemConfigurableEnvironmentFetcher 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)); + + 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(SystemConfigurableEnvironmentFetcher.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 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 seoWithKeywordsAndDescription() { + final Context context = getContext(); + 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 """ +

+ """; + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/dialect/LinkExpressionObjectDialectTest.java b/application/src/test/java/run/halo/app/theme/dialect/LinkExpressionObjectDialectTest.java new file mode 100644 index 0000000..9abf682 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/LinkExpressionObjectDialectTest.java @@ -0,0 +1,25 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessorTest.java b/application/src/test/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessorTest.java new file mode 100644 index 0000000..9fec46b --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessorTest.java @@ -0,0 +1,136 @@ +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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher 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(SystemConfigurableEnvironmentFetcher.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(); + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/CategoryQueryEndpointTest.java b/application/src/test/java/run/halo/app/theme/endpoint/CategoryQueryEndpointTest.java new file mode 100644 index 0000000..9ba60c6 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/endpoint/CategoryQueryEndpointTest.java @@ -0,0 +1,106 @@ +package run.halo.app.theme.endpoint; + +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"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/CommentFinderEndpointTest.java b/application/src/test/java/run/halo/app/theme/endpoint/CommentFinderEndpointTest.java new file mode 100644 index 0000000..997b4d3 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/endpoint/CommentFinderEndpointTest.java @@ -0,0 +1,208 @@ +package run.halo.app.theme.endpoint; + +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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher 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"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/MenuQueryEndpointTest.java b/application/src/test/java/run/halo/app/theme/endpoint/MenuQueryEndpointTest.java new file mode 100644 index 0000000..a6dbb50 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/endpoint/MenuQueryEndpointTest.java @@ -0,0 +1,122 @@ +package run.halo.app.theme.endpoint; + +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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher 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"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/PluginQueryEndpointTest.java b/application/src/test/java/run/halo/app/theme/endpoint/PluginQueryEndpointTest.java new file mode 100644 index 0000000..739b92c --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/endpoint/PluginQueryEndpointTest.java @@ -0,0 +1,55 @@ +package run.halo.app.theme.endpoint; + +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"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/PostQueryEndpointTest.java b/application/src/test/java/run/halo/app/theme/endpoint/PostQueryEndpointTest.java new file mode 100644 index 0000000..f39f139 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/endpoint/PostQueryEndpointTest.java @@ -0,0 +1,115 @@ +package run.halo.app.theme.endpoint; + +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() + .current(PostVo.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("$.current.metadata.name").isEqualTo("test"); + + verify(postFinder).cursor(anyString()); + } + + @Test + void groupVersion() { + GroupVersion groupVersion = endpoint.groupVersion(); + assertThat(groupVersion.toString()).isEqualTo("api.content.halo.run/v1alpha1"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/PublicApiUtilsTest.java b/application/src/test/java/run/halo/app/theme/endpoint/PublicApiUtilsTest.java new file mode 100644 index 0000000..56979ed --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/endpoint/PublicApiUtilsTest.java @@ -0,0 +1,48 @@ +package run.halo.app.theme.endpoint; + +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 { + + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/PublicUserEndpointTest.java b/application/src/test/java/run/halo/app/theme/endpoint/PublicUserEndpointTest.java new file mode 100644 index 0000000..8acdefc --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/endpoint/PublicUserEndpointTest.java @@ -0,0 +1,92 @@ +package run.halo.app.theme.endpoint; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +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 io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +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.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.User; +import run.halo.app.core.extension.service.UserService; +import run.halo.app.extension.Metadata; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; + +/** + * Tests for {@link PublicUserEndpoint}. + * + * @author guqing + * @since 2.4.0 + */ +@ExtendWith(MockitoExtension.class) +class PublicUserEndpointTest { + @Mock + private UserService userService; + @Mock + private ServerSecurityContextRepository securityContextRepository; + @Mock + private ReactiveUserDetailsService reactiveUserDetailsService; + @Mock + SystemConfigurableEnvironmentFetcher environmentFetcher; + @Mock + RateLimiterRegistry rateLimiterRegistry; + + @InjectMocks + private PublicUserEndpoint publicUserEndpoint; + + private WebTestClient webClient; + + @BeforeEach + void setUp() { + webClient = WebTestClient.bindToRouterFunction(publicUserEndpoint.endpoint()) + .build(); + } + + @Test + void signUp() { + User user = new User(); + user.setMetadata(new Metadata()); + user.getMetadata().setName("fake-user"); + user.setSpec(new User.UserSpec()); + user.getSpec().setDisplayName("hello"); + user.getSpec().setBio("bio"); + + when(userService.signUp(any(User.class), anyString())).thenReturn(Mono.just(user)); + when(securityContextRepository.save(any(), any())).thenReturn(Mono.empty()); + when(reactiveUserDetailsService.findByUsername(anyString())).thenReturn(Mono.just( + org.springframework.security.core.userdetails.User.withUsername("fake-user") + .password("123456") + .authorities("test-role") + .build())); + SystemSetting.User userSetting = mock(SystemSetting.User.class); + when(environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class)) + .thenReturn(Mono.just(userSetting)); + + when(rateLimiterRegistry.rateLimiter("signup-from-ip-127.0.0.1", "signup")) + .thenReturn(RateLimiter.ofDefaults("signup")); + + webClient.post() + .uri("/users/-/signup") + .header("X-Forwarded-For", "127.0.0.1") + .bodyValue(new PublicUserEndpoint.SignUpRequest(user, "fake-password", "")) + .exchange() + .expectStatus().isOk(); + + verify(userService).signUp(any(User.class), anyString()); + verify(securityContextRepository).save(any(), any()); + verify(reactiveUserDetailsService).findByUsername(eq("fake-user")); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/SinglePageQueryEndpointTest.java b/application/src/test/java/run/halo/app/theme/endpoint/SinglePageQueryEndpointTest.java new file mode 100644 index 0000000..d9d804a --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/endpoint/SinglePageQueryEndpointTest.java @@ -0,0 +1,106 @@ +package run.halo.app.theme.endpoint; + +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.eq; +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.core.extension.content.SinglePage; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.Metadata; +import run.halo.app.theme.finders.SinglePageFinder; +import run.halo.app.theme.finders.vo.ListedSinglePageVo; +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 listSinglePages() { + ListedSinglePageVo test = ListedSinglePageVo.builder() + .metadata(metadata("test")) + .spec(new SinglePage.SinglePageSpec()) + .build(); + + ListResult pageResult = new ListResult<>(List.of(test)); + + when(singlePageFinder.list(anyInt(), anyInt(), any(), any())) + .thenReturn(Mono.just(pageResult)); + + webTestClient.get() + .uri("/singlepages?page=0&size=10") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.total").isEqualTo(1) + .jsonPath("$.items[0].metadata.name").isEqualTo("test"); + + verify(singlePageFinder).list(eq(0), eq(10), any(), any()); + } + + @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"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/engine/DefaultThemeTemplateAvailabilityProviderTest.java b/application/src/test/java/run/halo/app/theme/engine/DefaultThemeTemplateAvailabilityProviderTest.java new file mode 100644 index 0000000..83ff3ff --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/engine/DefaultThemeTemplateAvailabilityProviderTest.java @@ -0,0 +1,47 @@ +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.autoconfigure.thymeleaf.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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/engine/PluginClassloaderTemplateResolverTest.java b/application/src/test/java/run/halo/app/theme/engine/PluginClassloaderTemplateResolverTest.java new file mode 100644 index 0000000..afc1c92 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/engine/PluginClassloaderTemplateResolverTest.java @@ -0,0 +1,53 @@ +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 run.halo.app.plugin.HaloPluginManager; + +/** + * Tests for {@link PluginClassloaderTemplateResolver}. + * + * @author guqing + * @since 2.11.0 + */ +@ExtendWith(MockitoExtension.class) +class PluginClassloaderTemplateResolverTest { + + @Mock + private HaloPluginManager 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"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/finders/FinderRegistryTest.java b/application/src/test/java/run/halo/app/theme/finders/FinderRegistryTest.java new file mode 100644 index 0000000..9dc9e8c --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/FinderRegistryTest.java @@ -0,0 +1,68 @@ +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 { + + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/CategoryFinderImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/CategoryFinderImplTest.java new file mode 100644 index 0000000..6f1bbb2 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/impl/CategoryFinderImplTest.java @@ -0,0 +1,570 @@ +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<>() { + }); + } +} diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImplTest.java new file mode 100644 index 0000000..f1c8833 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImplTest.java @@ -0,0 +1,233 @@ +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.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.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.extension.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; +import run.halo.app.metrics.CounterService; + +/** + * Tests for {@link CommentFinderImpl}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(SpringExtension.class) +class CommentPublicQueryServiceImplTest { + + @Mock + private ReactiveExtensionClient client; + @Mock + private UserService userService; + + @Mock + private CounterService counterService; + + @InjectMocks + private CommentPublicQueryServiceImpl commentPublicQueryService; + + @BeforeEach + void setUp() { + 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())); + } + + @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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceIntegrationTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceIntegrationTest.java new file mode 100644 index 0000000..463e5b0 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceIntegrationTest.java @@ -0,0 +1,510 @@ +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.index.IndexerFactory; +import run.halo.app.extension.store.ReactiveExtensionStoreClient; +import run.halo.app.infra.AnonymousUserConst; +import run.halo.app.infra.utils.JsonUtils; + +@DirtiesContext +@SpringBootTest +class CommentPublicQueryServiceIntegrationTest { + + @Autowired + private SchemeManager schemeManager; + + @Autowired + private ReactiveExtensionClient client; + + @Autowired + private ReactiveExtensionStoreClient storeClient; + + @Autowired + private IndexerFactory indexerFactory; + + Mono deleteImmediately(Extension extension) { + var name = extension.getMetadata().getName(); + var scheme = schemeManager.get(extension.getClass()); + // un-index + var indexer = indexerFactory.getIndexer(extension.groupVersionKind()); + indexer.unIndexRecord(extension.getMetadata().getName()); + + // 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, 9, 10, 14, 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 + 1s, no, 0 + // 7, now + 2s, no, 0 + // 8, now + 3s, 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() { + 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": "4249f4df72b475e7894fabed1c5888cf" + } + }, + "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); + } +} diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/MenuFinderImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/MenuFinderImplTest.java new file mode 100644 index 0000000..58f8a37 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/impl/MenuFinderImplTest.java @@ -0,0 +1,135 @@ +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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/PluginFinderImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/PluginFinderImplTest.java new file mode 100644 index 0000000..4ae4cec --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/impl/PluginFinderImplTest.java @@ -0,0 +1,89 @@ +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.PluginState; +import org.pf4j.PluginWrapper; +import run.halo.app.plugin.HaloPluginManager; + +/** + * Tests for {@link PluginFinderImpl}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class PluginFinderImplTest { + @Mock + private HaloPluginManager 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(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java new file mode 100644 index 0000000..405d5b6 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java @@ -0,0 +1,217 @@ +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.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +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.PostService; +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.metrics.CounterService; +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"); + } + + @Test + void postPreviousNextPair() { + List postNames = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + postNames.add("post-" + i); + } + + // post-0, post-1, post-2 + var previousNextPair = PostFinderImpl.findPostNavigation(postNames, "post-0"); + assertThat(previousNextPair.prev()).isNull(); + assertThat(previousNextPair.next()).isEqualTo("post-1"); + + previousNextPair = PostFinderImpl.findPostNavigation(postNames, "post-1"); + assertThat(previousNextPair.prev()).isEqualTo("post-0"); + assertThat(previousNextPair.next()).isEqualTo("post-2"); + + // post-1, post-2, post-3 + previousNextPair = PostFinderImpl.findPostNavigation(postNames, "post-2"); + assertThat(previousNextPair.prev()).isEqualTo("post-1"); + assertThat(previousNextPair.next()).isEqualTo("post-3"); + + // post-7, post-8, post-9 + previousNextPair = PostFinderImpl.findPostNavigation(postNames, "post-8"); + assertThat(previousNextPair.prev()).isEqualTo("post-7"); + assertThat(previousNextPair.next()).isEqualTo("post-9"); + + previousNextPair = PostFinderImpl.findPostNavigation(postNames, "post-9"); + assertThat(previousNextPair.prev()).isEqualTo("post-8"); + assertThat(previousNextPair.next()).isNull(); + } + + 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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImplTest.java new file mode 100644 index 0000000..17cd0c4 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImplTest.java @@ -0,0 +1,79 @@ +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); + } + } +} diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImplTest.java new file mode 100644 index 0000000..435ea1e --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImplTest.java @@ -0,0 +1,91 @@ +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); + } + } +} diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/SinglePageFinderImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/SinglePageFinderImplTest.java new file mode 100644 index 0000000..1751686 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/impl/SinglePageFinderImplTest.java @@ -0,0 +1,70 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/TagFinderImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/TagFinderImplTest.java new file mode 100644 index 0000000..8008174 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/impl/TagFinderImplTest.java @@ -0,0 +1,129 @@ +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; + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/finders/vo/UserVoTest.java b/application/src/test/java/run/halo/app/theme/finders/vo/UserVoTest.java new file mode 100644 index 0000000..95016f0 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/vo/UserVoTest.java @@ -0,0 +1,85 @@ +package run.halo.app.theme.finders.vo; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.List; +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()); + user.getStatus().setLastLoginAt(Instant.parse("2022-01-02T00:00:00.00Z")); + User.LoginHistory loginHistory = new User.LoginHistory(); + loginHistory.setLoginAt(Instant.parse("2022-01-02T00:00:00.00Z")); + loginHistory.setReason("login reason"); + loginHistory.setUserAgent("user agent"); + user.getStatus().setLoginHistories(List.of(loginHistory)); + + 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": { + "loginHistories": [] + } + } + """, + 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(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java new file mode 100644 index 0000000..924f1ed --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java @@ -0,0 +1,59 @@ +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).hasSize(1); + assertThat(properties).containsEntry("index.welcome", "欢迎来到首页"); + } + + @Test + void resolveMessagesForTemplateForEnglish() throws URISyntaxException { + Map properties = + ThemeMessageResolutionUtils.resolveMessagesForTemplate(Locale.ENGLISH, getTheme()); + assertThat(properties).hasSize(1); + assertThat(properties).containsEntry("index.welcome", "Welcome to the index"); + } + + @Test + void messageFormat() { + String s = + ThemeMessageResolutionUtils.formatMessage(Locale.ENGLISH, "Welcome {0} to the index", + new Object[] {"Halo"}); + assertThat(s).isEqualTo("Welcome Halo to the index"); + } + + ThemeContext getTheme() throws URISyntaxException { + return ThemeContext.builder() + .name("default") + .path(Path.of(defaultThemeUrl.toURI())) + .active(true) + .build(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java new file mode 100644 index 0000000..6948c4e --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java @@ -0,0 +1,157 @@ +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.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.context.annotation.Bean; +import org.springframework.http.MediaType; +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 { + + @SpyBean + private ThemeResolver themeResolver; + + private URL defaultThemeUrl; + + private URL otherThemeUrl; + + @SpyBean + 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() + .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("Title") + .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")); + } + } +} diff --git a/application/src/test/java/run/halo/app/theme/router/EmptyView.java b/application/src/test/java/run/halo/app/theme/router/EmptyView.java new file mode 100644 index 0000000..ba4a0bf --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/router/EmptyView.java @@ -0,0 +1,24 @@ +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(); + } +} diff --git a/application/src/test/java/run/halo/app/theme/router/PageUrlUtilsTest.java b/application/src/test/java/run/halo/app/theme/router/PageUrlUtilsTest.java new file mode 100644 index 0000000..281a320 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/router/PageUrlUtilsTest.java @@ -0,0 +1,63 @@ +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("/"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/router/PreviewRouterFunctionTest.java b/application/src/test/java/run/halo/app/theme/router/PreviewRouterFunctionTest.java new file mode 100644 index 0000000..683401a --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/router/PreviewRouterFunctionTest.java @@ -0,0 +1,175 @@ +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.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) +class PreviewRouterFunctionTest { + @Mock + private ReactiveExtensionClient client; + + @Mock + private PostPublicQueryService postPublicQueryService; + + @Mock + private ViewNameResolver viewNameResolver; + + @Mock + private ViewResolver viewResolver; + + @Mock + private PostService postService; + + @Mock + private SinglePageConversionService singlePageConversionService; + + @InjectMocks + private PreviewRouterFunction previewRouterFunction; + + private WebTestClient webTestClient; + + @BeforeEach + public void setUp() { + webTestClient = WebTestClient.bindToRouterFunction(previewRouterFunction.previewRouter()) + .handlerStrategies(HandlerStrategies.builder() + .viewResolver(viewResolver) + .build()) + .build(); + + 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); + } + })); + } + + @Test + @WithMockUser(username = "testuser") + public void previewPost() { + 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() { + 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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/router/ReactiveQueryPostPredicateResolverTest.java b/application/src/test/java/run/halo/app/theme/router/ReactiveQueryPostPredicateResolverTest.java new file mode 100644 index 0000000..6e675f7 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/router/ReactiveQueryPostPredicateResolverTest.java @@ -0,0 +1,87 @@ +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(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/router/SinglePageRouteTest.java b/application/src/test/java/run/halo/app/theme/router/SinglePageRouteTest.java new file mode 100644 index 0000000..db8e55e --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/router/SinglePageRouteTest.java @@ -0,0 +1,257 @@ +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; + } + } +} diff --git a/application/src/test/java/run/halo/app/theme/router/factories/ArchiveRouteFactoryTest.java b/application/src/test/java/run/halo/app/theme/router/factories/ArchiveRouteFactoryTest.java new file mode 100644 index 0000000..81d99e1 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/router/factories/ArchiveRouteFactoryTest.java @@ -0,0 +1,66 @@ +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); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/router/factories/AuthorPostsRouteFactoryTest.java b/application/src/test/java/run/halo/app/theme/router/factories/AuthorPostsRouteFactoryTest.java new file mode 100644 index 0000000..c308ef8 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/router/factories/AuthorPostsRouteFactoryTest.java @@ -0,0 +1,43 @@ +package run.halo.app.theme.router.factories; + +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.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() { + RouterFunction routerFunction = authorPostsRouteFactory.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(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/router/factories/CategoriesRouteFactoryTest.java b/application/src/test/java/run/halo/app/theme/router/factories/CategoriesRouteFactoryTest.java new file mode 100644 index 0000000..8b2066d --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/router/factories/CategoriesRouteFactoryTest.java @@ -0,0 +1,41 @@ +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(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/router/factories/IndexRouteFactoryTest.java b/application/src/test/java/run/halo/app/theme/router/factories/IndexRouteFactoryTest.java new file mode 100644 index 0000000..a7983d0 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/router/factories/IndexRouteFactoryTest.java @@ -0,0 +1,42 @@ +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(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/router/factories/PostRouteFactoryTest.java b/application/src/test/java/run/halo/app/theme/router/factories/PostRouteFactoryTest.java new file mode 100644 index 0000000..21ecdf8 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/router/factories/PostRouteFactoryTest.java @@ -0,0 +1,113 @@ +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.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 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(); + } +} diff --git a/application/src/test/java/run/halo/app/theme/router/factories/RouteFactoryTest.java b/application/src/test/java/run/halo/app/theme/router/factories/RouteFactoryTest.java new file mode 100644 index 0000000..e624ad3 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/router/factories/RouteFactoryTest.java @@ -0,0 +1,60 @@ +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); + 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()); + } + + static class TestRouteFactory implements RouteFactory { + + @Override + public RouterFunction create(String pattern) { + return null; + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/router/factories/RouteFactoryTestSuite.java b/application/src/test/java/run/halo/app/theme/router/factories/RouteFactoryTestSuite.java new file mode 100644 index 0000000..a08dabf --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/router/factories/RouteFactoryTestSuite.java @@ -0,0 +1,66 @@ +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.SystemConfigurableEnvironmentFetcher; +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 SystemConfigurableEnvironmentFetcher 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(); + } +} diff --git a/application/src/test/java/run/halo/app/theme/router/factories/TagPostRouteFactoryTest.java b/application/src/test/java/run/halo/app/theme/router/factories/TagPostRouteFactoryTest.java new file mode 100644 index 0000000..c0b1cd4 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/router/factories/TagPostRouteFactoryTest.java @@ -0,0 +1,72 @@ +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(); + } +} \ No newline at end of file diff --git a/application/src/test/resources/apiToken.salt b/application/src/test/resources/apiToken.salt new file mode 100644 index 0000000..206ee96 --- /dev/null +++ b/application/src/test/resources/apiToken.salt @@ -0,0 +1 @@ +bySwF9ZxJ2JpQYs830+eA3Fw6O7F/ULDvyYGEPaZKwY= \ No newline at end of file diff --git a/application/src/test/resources/app.key b/application/src/test/resources/app.key new file mode 100644 index 0000000..5bbada6 --- /dev/null +++ b/application/src/test/resources/app.key @@ -0,0 +1,28 @@ +-----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----- \ No newline at end of file diff --git a/application/src/test/resources/app.pub b/application/src/test/resources/app.pub new file mode 100644 index 0000000..d1af67b --- /dev/null +++ b/application/src/test/resources/app.pub @@ -0,0 +1,9 @@ +-----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----- \ No newline at end of file diff --git a/application/src/test/resources/application.yaml b/application/src/test/resources/application.yaml new file mode 100644 index 0000000..3598500 --- /dev/null +++ b/application/src/test/resources/application.yaml @@ -0,0 +1,40 @@ +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 diff --git a/application/src/test/resources/backups/backup-for-restoration/extensions.data b/application/src/test/resources/backups/backup-for-restoration/extensions.data new file mode 100644 index 0000000..cf80e9e --- /dev/null +++ b/application/src/test/resources/backups/backup-for-restoration/extensions.data @@ -0,0 +1 @@ +[{"name":"fake-extension-store","data":"ZmFrZS1kYXRh","version":1024}] \ No newline at end of file diff --git a/application/src/test/resources/backups/backup-for-restoration/workdir/fake-file b/application/src/test/resources/backups/backup-for-restoration/workdir/fake-file new file mode 100644 index 0000000..0176184 --- /dev/null +++ b/application/src/test/resources/backups/backup-for-restoration/workdir/fake-file @@ -0,0 +1 @@ +halo \ No newline at end of file diff --git a/application/src/test/resources/categories/independent-post-count.json b/application/src/test/resources/categories/independent-post-count.json new file mode 100644 index 0000000..f4fce09 --- /dev/null +++ b/application/src/test/resources/categories/independent-post-count.json @@ -0,0 +1,422 @@ +[ + { + "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" + } + } +] diff --git a/application/src/test/resources/config/i18n/messages.properties b/application/src/test/resources/config/i18n/messages.properties new file mode 100644 index 0000000..c2ef729 --- /dev/null +++ b/application/src/test/resources/config/i18n/messages.properties @@ -0,0 +1,9 @@ +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. diff --git a/application/src/test/resources/config/i18n/messages_zh.properties b/application/src/test/resources/config/i18n/messages_zh.properties new file mode 100644 index 0000000..c79351d --- /dev/null +++ b/application/src/test/resources/config/i18n/messages_zh.properties @@ -0,0 +1,3 @@ +problemDetail.title.run.halo.app.infra.exception.handlers.I18nExceptionTest$ErrorResponseException=发生错误 +problemDetail.run.halo.app.infra.exception.handlers.I18nExceptionTest$ErrorResponseException=参数:{0}。 +error.somethingWentWrong=发生了一些错误,参数:{0}。 diff --git a/application/src/test/resources/console/assets/fake.txt b/application/src/test/resources/console/assets/fake.txt new file mode 100644 index 0000000..2ae6365 --- /dev/null +++ b/application/src/test/resources/console/assets/fake.txt @@ -0,0 +1 @@ +fake. diff --git a/application/src/test/resources/console/index.html b/application/src/test/resources/console/index.html new file mode 100644 index 0000000..f4d6c3d --- /dev/null +++ b/application/src/test/resources/console/index.html @@ -0,0 +1 @@ +console index diff --git a/application/src/test/resources/folder-to-zip/examplefile b/application/src/test/resources/folder-to-zip/examplefile new file mode 100644 index 0000000..bd82b20 --- /dev/null +++ b/application/src/test/resources/folder-to-zip/examplefile @@ -0,0 +1 @@ +Here is an example file. diff --git a/application/src/test/resources/plugin/plugin-0.0.1/extensions/reverseProxy.yaml b/application/src/test/resources/plugin/plugin-0.0.1/extensions/reverseProxy.yaml new file mode 100644 index 0000000..1114137 --- /dev/null +++ b/application/src/test/resources/plugin/plugin-0.0.1/extensions/reverseProxy.yaml @@ -0,0 +1,13 @@ +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 diff --git a/application/src/test/resources/plugin/plugin-0.0.1/extensions/roles.yaml b/application/src/test/resources/plugin/plugin-0.0.1/extensions/roles.yaml new file mode 100644 index 0000000..bee3de4 --- /dev/null +++ b/application/src/test/resources/plugin/plugin-0.0.1/extensions/roles.yaml @@ -0,0 +1,13 @@ +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" ] diff --git a/application/src/test/resources/plugin/plugin-0.0.1/extensions/setting.yaml b/application/src/test/resources/plugin/plugin-0.0.1/extensions/setting.yaml new file mode 100644 index 0000000..d2bf76b --- /dev/null +++ b/application/src/test/resources/plugin/plugin-0.0.1/extensions/setting.yaml @@ -0,0 +1,6 @@ +apiVersion: v1alpha1 +kind: Setting +metadata: + name: fake-setting +spec: + forms: [ ] \ No newline at end of file diff --git a/application/src/test/resources/plugin/plugin-0.0.1/extensions/test.yml b/application/src/test/resources/plugin/plugin-0.0.1/extensions/test.yml new file mode 100644 index 0000000..1114137 --- /dev/null +++ b/application/src/test/resources/plugin/plugin-0.0.1/extensions/test.yml @@ -0,0 +1,13 @@ +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 diff --git a/application/src/test/resources/plugin/plugin-0.0.1/plugin.yaml b/application/src/test/resources/plugin/plugin-0.0.1/plugin.yaml new file mode 100644 index 0000000..6bed586 --- /dev/null +++ b/application/src/test/resources/plugin/plugin-0.0.1/plugin.yaml @@ -0,0 +1,18 @@ +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 \ No newline at end of file diff --git a/application/src/test/resources/plugin/plugin-0.0.2/plugin.yaml b/application/src/test/resources/plugin/plugin-0.0.2/plugin.yaml new file mode 100644 index 0000000..de1ac60 --- /dev/null +++ b/application/src/test/resources/plugin/plugin-0.0.2/plugin.yaml @@ -0,0 +1,16 @@ +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 diff --git a/application/src/test/resources/plugin/plugin-for-finder/META-INF/plugin-components.idx b/application/src/test/resources/plugin/plugin-for-finder/META-INF/plugin-components.idx new file mode 100644 index 0000000..a40f507 --- /dev/null +++ b/application/src/test/resources/plugin/plugin-for-finder/META-INF/plugin-components.idx @@ -0,0 +1,2 @@ +# Generated by Halo +run.halo.fake.FakePlugin diff --git a/application/src/test/resources/plugin/plugin-for-reverseproxy/static/test.txt b/application/src/test/resources/plugin/plugin-for-reverseproxy/static/test.txt new file mode 100644 index 0000000..24756a8 --- /dev/null +++ b/application/src/test/resources/plugin/plugin-for-reverseproxy/static/test.txt @@ -0,0 +1 @@ +Fake content. \ No newline at end of file diff --git a/application/src/test/resources/plugin/plugin.yaml b/application/src/test/resources/plugin/plugin.yaml new file mode 100644 index 0000000..6bed586 --- /dev/null +++ b/application/src/test/resources/plugin/plugin.yaml @@ -0,0 +1,18 @@ +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 \ No newline at end of file diff --git a/application/src/test/resources/presets/plugins/fake-plugin.jar b/application/src/test/resources/presets/plugins/fake-plugin.jar new file mode 100644 index 0000000..0acfb07 Binary files /dev/null and b/application/src/test/resources/presets/plugins/fake-plugin.jar differ diff --git a/application/src/test/resources/themes/default/i18n/default.properties b/application/src/test/resources/themes/default/i18n/default.properties new file mode 100644 index 0000000..0321c81 --- /dev/null +++ b/application/src/test/resources/themes/default/i18n/default.properties @@ -0,0 +1 @@ +index.welcome=\u6B22\u8FCE\u6765\u5230\u9996\u9875 \ No newline at end of file diff --git a/application/src/test/resources/themes/default/i18n/en.properties b/application/src/test/resources/themes/default/i18n/en.properties new file mode 100644 index 0000000..1e6ec93 --- /dev/null +++ b/application/src/test/resources/themes/default/i18n/en.properties @@ -0,0 +1 @@ +index.welcome=Welcome to the index \ No newline at end of file diff --git a/application/src/test/resources/themes/default/templates/index.html b/application/src/test/resources/themes/default/templates/index.html new file mode 100644 index 0000000..7d38411 --- /dev/null +++ b/application/src/test/resources/themes/default/templates/index.html @@ -0,0 +1,12 @@ + + + + + Title + + +index +
+
+ + diff --git a/application/src/test/resources/themes/default/templates/timezone.html b/application/src/test/resources/themes/default/templates/timezone.html new file mode 100644 index 0000000..d37df4e --- /dev/null +++ b/application/src/test/resources/themes/default/templates/timezone.html @@ -0,0 +1 @@ +

diff --git a/application/src/test/resources/themes/default/theme.yaml b/application/src/test/resources/themes/default/theme.yaml new file mode 100644 index 0000000..00263a8 --- /dev/null +++ b/application/src/test/resources/themes/default/theme.yaml @@ -0,0 +1,15 @@ +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 diff --git a/application/src/test/resources/themes/invalid-missing-manifest/i18n/default.properties b/application/src/test/resources/themes/invalid-missing-manifest/i18n/default.properties new file mode 100644 index 0000000..0321c81 --- /dev/null +++ b/application/src/test/resources/themes/invalid-missing-manifest/i18n/default.properties @@ -0,0 +1 @@ +index.welcome=\u6B22\u8FCE\u6765\u5230\u9996\u9875 \ No newline at end of file diff --git a/application/src/test/resources/themes/invalid-missing-manifest/i18n/en.properties b/application/src/test/resources/themes/invalid-missing-manifest/i18n/en.properties new file mode 100644 index 0000000..1e6ec93 --- /dev/null +++ b/application/src/test/resources/themes/invalid-missing-manifest/i18n/en.properties @@ -0,0 +1 @@ +index.welcome=Welcome to the index \ No newline at end of file diff --git a/application/src/test/resources/themes/invalid-missing-manifest/templates/index.html b/application/src/test/resources/themes/invalid-missing-manifest/templates/index.html new file mode 100644 index 0000000..441ad47 --- /dev/null +++ b/application/src/test/resources/themes/invalid-missing-manifest/templates/index.html @@ -0,0 +1,12 @@ + + + + + Title + + +index +
+
+ + diff --git a/application/src/test/resources/themes/invalid-missing-manifest/templates/timezone.html b/application/src/test/resources/themes/invalid-missing-manifest/templates/timezone.html new file mode 100644 index 0000000..d37df4e --- /dev/null +++ b/application/src/test/resources/themes/invalid-missing-manifest/templates/timezone.html @@ -0,0 +1 @@ +

diff --git a/application/src/test/resources/themes/other/i18n/default.properties b/application/src/test/resources/themes/other/i18n/default.properties new file mode 100644 index 0000000..7faa99e --- /dev/null +++ b/application/src/test/resources/themes/other/i18n/default.properties @@ -0,0 +1 @@ +index.welcome=Other \u9996\u9875 \ No newline at end of file diff --git a/application/src/test/resources/themes/other/i18n/en.properties b/application/src/test/resources/themes/other/i18n/en.properties new file mode 100644 index 0000000..82b9a28 --- /dev/null +++ b/application/src/test/resources/themes/other/i18n/en.properties @@ -0,0 +1 @@ +index.welcome=other index \ No newline at end of file diff --git a/application/src/test/resources/themes/other/templates/index.html b/application/src/test/resources/themes/other/templates/index.html new file mode 100644 index 0000000..2ecf09e --- /dev/null +++ b/application/src/test/resources/themes/other/templates/index.html @@ -0,0 +1,10 @@ + + + + + Other theme title + + +

+ + diff --git a/application/src/test/resources/themes/other/theme.yaml b/application/src/test/resources/themes/other/theme.yaml new file mode 100644 index 0000000..1347631 --- /dev/null +++ b/application/src/test/resources/themes/other/theme.yaml @@ -0,0 +1,15 @@ +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 diff --git a/application/src/test/resources/themes/test-theme.zip b/application/src/test/resources/themes/test-theme.zip new file mode 100644 index 0000000..7fb443d Binary files /dev/null and b/application/src/test/resources/themes/test-theme.zip differ diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..60e0273 --- /dev/null +++ b/build.gradle @@ -0,0 +1,10 @@ +plugins { + id 'org.springframework.boot' version '3.3.1' apply false + id 'io.spring.dependency-management' version '1.1.5' apply false + id "com.gorylenko.gradle-git-properties" version "2.4.1" apply false + id "de.undercouch.download" version "5.6.0" apply false + id "io.freefair.lombok" version "8.6" apply false + id 'org.gradle.crypto.checksum' version '1.4.0' apply false + id "com.github.node-gradle.node" version "7.0.2" apply false + id "org.springdoc.openapi-gradle-plugin" version "1.9.0" apply false +} diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle new file mode 100644 index 0000000..6784052 --- /dev/null +++ b/buildSrc/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'groovy-gradle-plugin' +} diff --git a/buildSrc/src/main/groovy/halo.publish.gradle b/buildSrc/src/main/groovy/halo.publish.gradle new file mode 100644 index 0000000..ce1932f --- /dev/null +++ b/buildSrc/src/main/groovy/halo.publish.gradle @@ -0,0 +1,56 @@ +plugins { + id 'maven-publish' +} + +publishing { + publications { + def pubName = "${archivesBaseName}" + pluginManager.withPlugin('java-platform') { + pubName = pubName + 'Pom' + } + pluginManager.withPlugin('java') { + pubName = pubName + 'Library' + } + "${pubName}"(MavenPublication) { + pluginManager.withPlugin('java-platform') { + from components.javaPlatform + } + pluginManager.withPlugin('java') { + from components.java + } + pom { + 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@foxmil.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 { + mavenLocal() + if (project.hasProperty("release")) { + maven { + name = 'ossrh' + def releasesRepoUrl = 'https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/' + def snapshotsRepoUrl = 'https://s01.oss.sonatype.org/content/repositories/snapshots/' + url = version.endsWith('-SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl + credentials(PasswordCredentials) + } + } + } +} \ No newline at end of file diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..0b01c59 --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,365 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/authentication/README.md b/docs/authentication/README.md new file mode 100644 index 0000000..c4ef7b5 --- /dev/null +++ b/docs/authentication/README.md @@ -0,0 +1,228 @@ +# 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" +``` diff --git a/docs/backup-and-restore.md b/docs/backup-and-restore.md new file mode 100644 index 0000000..782f393 --- /dev/null +++ b/docs/backup-and-restore.md @@ -0,0 +1,240 @@ +# 备份和恢复 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 + +- 数据备份期间可能会存在数据的创建、更新和删除。 + +我们将忽略这些数据变化。 + +- 是否支持在初始化博客后恢复数据? + +支持。不过可能会覆盖掉已有的数据。 diff --git a/docs/cache/page.md b/docs/cache/page.md new file mode 100644 index 0000000..4bd8697 --- /dev/null +++ b/docs/cache/page.md @@ -0,0 +1,33 @@ +# 缓存 + +缓存在各个领域用得非常广泛,例如 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` 个 | diff --git a/docs/developer-guide/custom-endpoint.md b/docs/developer-guide/custom-endpoint.md new file mode 100644 index 0000000..80a0a8b --- /dev/null +++ b/docs/developer-guide/custom-endpoint.md @@ -0,0 +1,50 @@ +# 系统自定义 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 文档地址,并进行测试。 diff --git a/docs/developer-guide/plugin-configuration-properties.md b/docs/developer-guide/plugin-configuration-properties.md new file mode 100644 index 0000000..cf68554 --- /dev/null +++ b/docs/developer-guide/plugin-configuration-properties.md @@ -0,0 +1,78 @@ +# 插件外部配置 + +插件外部配置功能允许用户在特定目录添加插件相关的配置,插件启动的时候能够自动读取到该配置。 + +## 配置优先级 + +> 优先级从上到下由高到低。 + +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 + ``` + +## 可能存在的问题 + +- 增加未来实现"集群"架构的难度。 diff --git a/docs/email-verification/README.md b/docs/email-verification/README.md new file mode 100644 index 0000000..d58b9b9 --- /dev/null +++ b/docs/email-verification/README.md @@ -0,0 +1,129 @@ +## 背景 + +在 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. 验证状态的指示和反馈 + +我们将能够提供一个安全、可靠且用户友好的邮箱验证功能。 diff --git a/docs/extension-points/authentication.md b/docs/extension-points/authentication.md new file mode 100644 index 0000000..a97db1b --- /dev/null +++ b/docs/extension-points/authentication.md @@ -0,0 +1,118 @@ +# 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 留下机会。 + +后续会根据需求实现其他认证相关的扩展点。 \ No newline at end of file diff --git a/docs/extension-points/content.md b/docs/extension-points/content.md new file mode 100644 index 0000000..31b5787 --- /dev/null +++ b/docs/extension-points/content.md @@ -0,0 +1,107 @@ +# 内容扩展点 + +## 文章内容扩展点 + +文章内容扩展点用于在主题端文章内容渲染之前对文章内容进行修改,比如添加广告、添加版权声明、插入脚本等。 + +## 使用方式 + +在插件中通过实现 `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." +``` diff --git a/docs/extension-points/search-engine.md b/docs/extension-points/search-engine.md new file mode 100644 index 0000000..c42cd29 --- /dev/null +++ b/docs/extension-points/search-engine.md @@ -0,0 +1,64 @@ +# 搜索引擎扩展点 + +随着 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)); + } + + } + ``` + \ No newline at end of file diff --git a/docs/full-text-search/README.md b/docs/full-text-search/README.md new file mode 100644 index 0000000..447d144 --- /dev/null +++ b/docs/full-text-search/README.md @@ -0,0 +1,356 @@ +# 在 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 是否是多实例部署。 diff --git a/docs/full-text-search/algolia.png b/docs/full-text-search/algolia.png new file mode 100644 index 0000000..d169642 Binary files /dev/null and b/docs/full-text-search/algolia.png differ diff --git a/docs/full-text-search/meilisearch.jpg b/docs/full-text-search/meilisearch.jpg new file mode 100644 index 0000000..f594677 Binary files /dev/null and b/docs/full-text-search/meilisearch.jpg differ diff --git a/docs/index/README.md b/docs/index/README.md new file mode 100644 index 0000000..0a49372 --- /dev/null +++ b/docs/index/README.md @@ -0,0 +1,316 @@ +# 索引机制 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 以减少全量加载数据的操作。 diff --git a/docs/notification/README.md b/docs/notification/README.md new file mode 100644 index 0000000..1408ac1 --- /dev/null +++ b/docs/notification/README.md @@ -0,0 +1,366 @@ +## 背景 + +在 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 中查看所有的通知,包括已读和未读的通知。 +- 标记通知:用户可以标记通知为已读或未读状态,以便更好地管理和处理通知。 + +### 通知管理列表条件筛选 + +我们支持以下通知条件筛选策略: + +- 按事件类型:列出特定类型的事件通知,例如新文章,新评论、状态更新等。 +- 按已读状态:根据通知是否已读列出,方便用户查看未读通知。 +- 按关键词:列出通知中包含特定关键词的事件通知,例如包含用户名称、标题等关键词的通知。 +- 按时间:列出在特定时间段内发生的事件通知,例如最近一周、最近一个月等时间段内的通知。 + +### 定制化选项 + +如果后续有足够的使用场景,可以考虑支持以下定制化选项: + +- 通知时间段:用户可以设置通知的时间段,例如只在工作时间内推送通知。 +- 通知频率:用户可以设置通知的频率,例如每天、每周、每月等。 +- 摘要通知:用户可以设置接收每周摘要,总结一周内的通知合并为一条通知并通过如邮件等方式接收。 + +## 结论 + +通过以上方案和实现,我们设计了一个通知功能,可以根据用户的需求和偏好,自动筛选和推送通知。同时,为了支持更多的事件类型、通知方式和通知条件筛选策略,系统具有良好的可扩展性。 diff --git a/docs/notification/image-knhw.png b/docs/notification/image-knhw.png new file mode 100644 index 0000000..821c5d8 Binary files /dev/null and b/docs/notification/image-knhw.png differ diff --git a/docs/notification/notification-ui.png b/docs/notification/notification-ui.png new file mode 100644 index 0000000..76b01c3 Binary files /dev/null and b/docs/notification/notification-ui.png differ diff --git a/docs/plugin/shared-event.md b/docs/plugin/shared-event.md new file mode 100644 index 0000000..5e706ff --- /dev/null +++ b/docs/plugin/shared-event.md @@ -0,0 +1,83 @@ +# 插件中如何发送共享事件(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 仓库中,供其他插件引用。 diff --git a/docs/plugin/websocket.md b/docs/plugin/websocket.md new file mode 100644 index 0000000..bd63e19 --- /dev/null +++ b/docs/plugin/websocket.md @@ -0,0 +1,49 @@ +# 插件中如何实现 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 仍然受当前权限系统管理。 diff --git a/e2e/Dockerfile b/e2e/Dockerfile new file mode 100644 index 0000000..4cc3663 --- /dev/null +++ b/e2e/Dockerfile @@ -0,0 +1,4 @@ +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" ] diff --git a/e2e/Makefile b/e2e/Makefile new file mode 100644 index 0000000..3339af3 --- /dev/null +++ b/e2e/Makefile @@ -0,0 +1,6 @@ +all: + ./start.sh + ./start.sh compose-postgres.yaml + ./start.sh compose-mysql.yaml +demo: + docker-compose up halo diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..6a9722f --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,24 @@ +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 +``` diff --git a/e2e/compose-mysql.yaml b/e2e/compose-mysql.yaml new file mode 100644 index 0000000..62a9914 --- /dev/null +++ b/e2e/compose-mysql.yaml @@ -0,0 +1,46 @@ +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 diff --git a/e2e/compose-postgres.yaml b/e2e/compose-postgres.yaml new file mode 100644 index 0000000..08f0ce6 --- /dev/null +++ b/e2e/compose-postgres.yaml @@ -0,0 +1,48 @@ +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 diff --git a/e2e/compose.yaml b/e2e/compose.yaml new file mode 100644 index 0000000..c7892e9 --- /dev/null +++ b/e2e/compose.yaml @@ -0,0 +1,21 @@ +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 diff --git a/e2e/start.sh b/e2e/start.sh new file mode 100644 index 0000000..2ed7905 --- /dev/null +++ b/e2e/start.sh @@ -0,0 +1,10 @@ +#!/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 diff --git a/e2e/testsuite.yaml b/e2e/testsuite.yaml new file mode 100644 index 0000000..32f2dc0 --- /dev/null +++ b/e2e/testsuite.yaml @@ -0,0 +1,290 @@ +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: init + request: + api: /api.console.halo.run/v1alpha1/system/initialize + method: POST + header: + Content-Type: application/json + body: | + { + "siteTitle": "testing", + "username": "admin", + "password": "123456", + "email": "testing@halo.com", + "password_confirm": "123456" + } + expect: + statusCode: 201 +- 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 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3e7456f --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +version=2.19.0-SNAPSHOT +r2dbc-mariadb.version=1.2.1 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a441313 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 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. +# + +############################################################################## +# +# 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/HEAD/subprojects/plugins/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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || 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 + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# 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" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + 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, 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" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# 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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..25da30d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@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 + +@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 + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +: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 diff --git a/hack/cherry_pick_pull.sh b/hack/cherry_pick_pull.sh new file mode 100644 index 0000000..a2e9ad0 --- /dev/null +++ b/hack/cherry_pick_pull.sh @@ -0,0 +1,257 @@ +#!/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 diff --git a/platform/application/build.gradle b/platform/application/build.gradle new file mode 100644 index 0000000..18f3715 --- /dev/null +++ b/platform/application/build.gradle @@ -0,0 +1,61 @@ +import org.springframework.boot.gradle.plugin.SpringBootPlugin + +plugins { + id 'org.springframework.boot' apply false + id 'java-platform' + id 'halo.publish' + id 'signing' +} + +group = 'run.halo.tools.platform' +description = 'Platform of application.' + +ext { + commonsLang3 = "3.12.0" + base62 = "0.1.3" + pf4j = '3.12.0' + javaDiffUtils = "4.12" + guava = "32.0.1-jre" + jsoup = '1.15.3' + jsonPatch = "1.13" + springDocOpenAPI = "2.6.0" + lucene = "9.11.1" + resilience4jVersion = "2.2.0" + twoFactorAuth = "1.3" + tika = "2.9.2" +} + +javaPlatform { + allowDependencies() +} + +dependencies { + api platform(SpringBootPlugin.BOM_COORDINATES) + + constraints { + api "org.springdoc:springdoc-openapi-starter-webflux-ui:$springDocOpenAPI" + api 'org.openapi4j:openapi-schema-validator:1.0.7' + + // Apache Lucene + api "org.apache.lucene:lucene-core:$lucene" + api "org.apache.lucene:lucene-queryparser:$lucene" + api "org.apache.lucene:lucene-highlighter:$lucene" + api "org.apache.lucene:lucene-backward-codecs:$lucene" + api "org.apache.lucene:lucene-analysis-common:$lucene" + + api "org.apache.commons:commons-lang3:$commonsLang3" + api "io.seruco.encoding:base62:$base62" + api "org.pf4j:pf4j:$pf4j" + api "com.google.guava:guava:$guava" + api "org.jsoup:jsoup:$jsoup" + api "io.github.java-diff-utils:java-diff-utils:$javaDiffUtils" + api "org.springframework.integration:spring-integration-core" + api "com.github.java-json-tools:json-patch:$jsonPatch" + api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6" + api "io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion" + api "io.github.resilience4j:resilience4j-reactor:$resilience4jVersion" + api "com.j256.two-factor-auth:two-factor-auth:$twoFactorAuth" + api "org.apache.tika:tika-core:$tika" + } + +} diff --git a/platform/plugin/build.gradle b/platform/plugin/build.gradle new file mode 100644 index 0000000..39f1a91 --- /dev/null +++ b/platform/plugin/build.gradle @@ -0,0 +1,21 @@ +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' + } +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..693b581 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,9 @@ +pluginManagement { + repositories { + maven { url 'https://repo.spring.io/milestone' } + gradlePluginPortal() + } +} + +rootProject.name = 'halo' +include 'api', 'application', 'platform:application', 'platform:plugin', 'ui'