diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..f7925ec
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,2 @@
+/.git
+/.github
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..3dcffab
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,905 @@
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+insert_final_newline = true
+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 = false
+ij_smart_tabs = false
+ij_visual_guides = none
+ij_wrap_on_typing = false
+
+[*.blade.php]
+ij_blade_keep_indents_on_empty_lines = false
+
+[*.css]
+ij_css_align_closing_brace_with_properties = false
+ij_css_blank_lines_around_nested_selector = 1
+ij_css_blank_lines_between_blocks = 1
+ij_css_brace_placement = end_of_line
+ij_css_enforce_quotes_on_format = false
+ij_css_hex_color_long_format = false
+ij_css_hex_color_lower_case = false
+ij_css_hex_color_short_format = false
+ij_css_hex_color_upper_case = false
+ij_css_keep_blank_lines_in_code = 2
+ij_css_keep_indents_on_empty_lines = false
+ij_css_keep_single_line_blocks = false
+ij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
+ij_css_space_after_colon = true
+ij_css_space_before_opening_brace = true
+ij_css_use_double_quotes = true
+ij_css_value_alignment = do_not_align
+
+[*.feature]
+indent_size = 2
+ij_gherkin_keep_indents_on_empty_lines = false
+
+[*.haml]
+indent_size = 2
+ij_haml_keep_indents_on_empty_lines = false
+
+[*.less]
+indent_size = 2
+ij_less_align_closing_brace_with_properties = false
+ij_less_blank_lines_around_nested_selector = 1
+ij_less_blank_lines_between_blocks = 1
+ij_less_brace_placement = 0
+ij_less_enforce_quotes_on_format = false
+ij_less_hex_color_long_format = false
+ij_less_hex_color_lower_case = false
+ij_less_hex_color_short_format = false
+ij_less_hex_color_upper_case = false
+ij_less_keep_blank_lines_in_code = 2
+ij_less_keep_indents_on_empty_lines = false
+ij_less_keep_single_line_blocks = false
+ij_less_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
+ij_less_space_after_colon = true
+ij_less_space_before_opening_brace = true
+ij_less_use_double_quotes = true
+ij_less_value_alignment = 0
+
+[*.rs]
+max_line_length = 100
+ij_continuation_indent_size = 4
+ij_rust_align_multiline_chained_methods = false
+ij_rust_align_multiline_parameters = true
+ij_rust_align_multiline_parameters_in_calls = true
+ij_rust_align_ret_type = true
+ij_rust_align_type_params = false
+ij_rust_align_where_bounds = true
+ij_rust_align_where_clause = false
+ij_rust_allow_one_line_match = false
+ij_rust_block_comment_at_first_column = false
+ij_rust_indent_where_clause = true
+ij_rust_keep_blank_lines_in_code = 2
+ij_rust_keep_blank_lines_in_declarations = 2
+ij_rust_keep_indents_on_empty_lines = false
+ij_rust_keep_line_breaks = true
+ij_rust_line_comment_add_space = true
+ij_rust_line_comment_at_first_column = false
+ij_rust_min_number_of_blanks_between_items = 1
+ij_rust_preserve_punctuation = false
+ij_rust_spaces_around_assoc_type_binding = false
+
+[*.sass]
+indent_size = 2
+ij_sass_align_closing_brace_with_properties = false
+ij_sass_blank_lines_around_nested_selector = 1
+ij_sass_blank_lines_between_blocks = 1
+ij_sass_brace_placement = 0
+ij_sass_enforce_quotes_on_format = false
+ij_sass_hex_color_long_format = false
+ij_sass_hex_color_lower_case = false
+ij_sass_hex_color_short_format = false
+ij_sass_hex_color_upper_case = false
+ij_sass_keep_blank_lines_in_code = 2
+ij_sass_keep_indents_on_empty_lines = false
+ij_sass_keep_single_line_blocks = false
+ij_sass_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
+ij_sass_space_after_colon = true
+ij_sass_space_before_opening_brace = true
+ij_sass_use_double_quotes = true
+ij_sass_value_alignment = 0
+
+[*.scss]
+indent_size = 2
+ij_scss_align_closing_brace_with_properties = false
+ij_scss_blank_lines_around_nested_selector = 1
+ij_scss_blank_lines_between_blocks = 1
+ij_scss_brace_placement = 0
+ij_scss_enforce_quotes_on_format = false
+ij_scss_hex_color_long_format = false
+ij_scss_hex_color_lower_case = false
+ij_scss_hex_color_short_format = false
+ij_scss_hex_color_upper_case = false
+ij_scss_keep_blank_lines_in_code = 2
+ij_scss_keep_indents_on_empty_lines = false
+ij_scss_keep_single_line_blocks = false
+ij_scss_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
+ij_scss_space_after_colon = true
+ij_scss_space_before_opening_brace = true
+ij_scss_use_double_quotes = true
+ij_scss_value_alignment = 0
+
+[*.twig]
+ij_twig_keep_indents_on_empty_lines = false
+ij_twig_spaces_inside_comments_delimiters = true
+ij_twig_spaces_inside_delimiters = true
+ij_twig_spaces_inside_variable_delimiters = true
+
+[*.vue]
+indent_size = 4
+tab_width = 4
+ij_continuation_indent_size = 4
+ij_vue_indent_children_of_top_level = template
+ij_vue_interpolation_new_line_after_start_delimiter = true
+ij_vue_interpolation_new_line_before_end_delimiter = true
+ij_vue_interpolation_wrap = off
+ij_vue_keep_indents_on_empty_lines = false
+ij_vue_spaces_within_interpolation_expressions = true
+
+[.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,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul,phpunit.xml.dist}]
+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
+
+[{*.ats,*.ts}]
+ij_continuation_indent_size = 4
+ij_typescript_align_imports = false
+ij_typescript_align_multiline_array_initializer_expression = false
+ij_typescript_align_multiline_binary_operation = false
+ij_typescript_align_multiline_chained_methods = false
+ij_typescript_align_multiline_extends_list = false
+ij_typescript_align_multiline_for = true
+ij_typescript_align_multiline_parameters = true
+ij_typescript_align_multiline_parameters_in_calls = false
+ij_typescript_align_multiline_ternary_operation = false
+ij_typescript_align_object_properties = 0
+ij_typescript_align_union_types = false
+ij_typescript_align_var_statements = 0
+ij_typescript_array_initializer_new_line_after_left_brace = false
+ij_typescript_array_initializer_right_brace_on_new_line = false
+ij_typescript_array_initializer_wrap = off
+ij_typescript_assignment_wrap = off
+ij_typescript_binary_operation_sign_on_next_line = false
+ij_typescript_binary_operation_wrap = off
+ij_typescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/**
+ij_typescript_blank_lines_after_imports = 1
+ij_typescript_blank_lines_around_class = 1
+ij_typescript_blank_lines_around_field = 0
+ij_typescript_blank_lines_around_field_in_interface = 0
+ij_typescript_blank_lines_around_function = 1
+ij_typescript_blank_lines_around_method = 1
+ij_typescript_blank_lines_around_method_in_interface = 1
+ij_typescript_block_brace_style = end_of_line
+ij_typescript_call_parameters_new_line_after_left_paren = false
+ij_typescript_call_parameters_right_paren_on_new_line = false
+ij_typescript_call_parameters_wrap = off
+ij_typescript_catch_on_new_line = false
+ij_typescript_chained_call_dot_on_new_line = true
+ij_typescript_class_brace_style = end_of_line
+ij_typescript_comma_on_new_line = false
+ij_typescript_do_while_brace_force = never
+ij_typescript_else_on_new_line = false
+ij_typescript_enforce_trailing_comma = keep
+ij_typescript_extends_keyword_wrap = off
+ij_typescript_extends_list_wrap = off
+ij_typescript_field_prefix = _
+ij_typescript_file_name_style = relaxed
+ij_typescript_finally_on_new_line = false
+ij_typescript_for_brace_force = never
+ij_typescript_for_statement_new_line_after_left_paren = false
+ij_typescript_for_statement_right_paren_on_new_line = false
+ij_typescript_for_statement_wrap = off
+ij_typescript_force_quote_style = false
+ij_typescript_force_semicolon_style = false
+ij_typescript_function_expression_brace_style = end_of_line
+ij_typescript_if_brace_force = never
+ij_typescript_import_merge_members = global
+ij_typescript_import_prefer_absolute_path = global
+ij_typescript_import_sort_members = true
+ij_typescript_import_sort_module_name = false
+ij_typescript_import_use_node_resolution = true
+ij_typescript_imports_wrap = on_every_item
+ij_typescript_indent_case_from_switch = true
+ij_typescript_indent_chained_calls = true
+ij_typescript_indent_package_children = 0
+ij_typescript_jsdoc_include_types = false
+ij_typescript_jsx_attribute_value = braces
+ij_typescript_keep_blank_lines_in_code = 2
+ij_typescript_keep_first_column_comment = true
+ij_typescript_keep_indents_on_empty_lines = false
+ij_typescript_keep_line_breaks = true
+ij_typescript_keep_simple_blocks_in_one_line = false
+ij_typescript_keep_simple_methods_in_one_line = false
+ij_typescript_line_comment_add_space = true
+ij_typescript_line_comment_at_first_column = false
+ij_typescript_method_brace_style = end_of_line
+ij_typescript_method_call_chain_wrap = off
+ij_typescript_method_parameters_new_line_after_left_paren = false
+ij_typescript_method_parameters_right_paren_on_new_line = false
+ij_typescript_method_parameters_wrap = off
+ij_typescript_object_literal_wrap = on_every_item
+ij_typescript_parentheses_expression_new_line_after_left_paren = false
+ij_typescript_parentheses_expression_right_paren_on_new_line = false
+ij_typescript_place_assignment_sign_on_next_line = false
+ij_typescript_prefer_as_type_cast = false
+ij_typescript_prefer_explicit_types_function_expression_returns = false
+ij_typescript_prefer_explicit_types_function_returns = false
+ij_typescript_prefer_explicit_types_vars_fields = false
+ij_typescript_prefer_parameters_wrap = false
+ij_typescript_reformat_c_style_comments = false
+ij_typescript_space_after_colon = true
+ij_typescript_space_after_comma = true
+ij_typescript_space_after_dots_in_rest_parameter = false
+ij_typescript_space_after_generator_mult = true
+ij_typescript_space_after_property_colon = true
+ij_typescript_space_after_quest = true
+ij_typescript_space_after_type_colon = true
+ij_typescript_space_after_unary_not = false
+ij_typescript_space_before_async_arrow_lparen = true
+ij_typescript_space_before_catch_keyword = true
+ij_typescript_space_before_catch_left_brace = true
+ij_typescript_space_before_catch_parentheses = true
+ij_typescript_space_before_class_lbrace = true
+ij_typescript_space_before_class_left_brace = true
+ij_typescript_space_before_colon = true
+ij_typescript_space_before_comma = false
+ij_typescript_space_before_do_left_brace = true
+ij_typescript_space_before_else_keyword = true
+ij_typescript_space_before_else_left_brace = true
+ij_typescript_space_before_finally_keyword = true
+ij_typescript_space_before_finally_left_brace = true
+ij_typescript_space_before_for_left_brace = true
+ij_typescript_space_before_for_parentheses = true
+ij_typescript_space_before_for_semicolon = false
+ij_typescript_space_before_function_left_parenth = true
+ij_typescript_space_before_generator_mult = false
+ij_typescript_space_before_if_left_brace = true
+ij_typescript_space_before_if_parentheses = true
+ij_typescript_space_before_method_call_parentheses = false
+ij_typescript_space_before_method_left_brace = true
+ij_typescript_space_before_method_parentheses = false
+ij_typescript_space_before_property_colon = false
+ij_typescript_space_before_quest = true
+ij_typescript_space_before_switch_left_brace = true
+ij_typescript_space_before_switch_parentheses = true
+ij_typescript_space_before_try_left_brace = true
+ij_typescript_space_before_type_colon = false
+ij_typescript_space_before_unary_not = false
+ij_typescript_space_before_while_keyword = true
+ij_typescript_space_before_while_left_brace = true
+ij_typescript_space_before_while_parentheses = true
+ij_typescript_spaces_around_additive_operators = true
+ij_typescript_spaces_around_arrow_function_operator = true
+ij_typescript_spaces_around_assignment_operators = true
+ij_typescript_spaces_around_bitwise_operators = true
+ij_typescript_spaces_around_equality_operators = true
+ij_typescript_spaces_around_logical_operators = true
+ij_typescript_spaces_around_multiplicative_operators = true
+ij_typescript_spaces_around_relational_operators = true
+ij_typescript_spaces_around_shift_operators = true
+ij_typescript_spaces_around_unary_operator = false
+ij_typescript_spaces_within_array_initializer_brackets = false
+ij_typescript_spaces_within_brackets = false
+ij_typescript_spaces_within_catch_parentheses = false
+ij_typescript_spaces_within_for_parentheses = false
+ij_typescript_spaces_within_if_parentheses = false
+ij_typescript_spaces_within_imports = false
+ij_typescript_spaces_within_interpolation_expressions = false
+ij_typescript_spaces_within_method_call_parentheses = false
+ij_typescript_spaces_within_method_parentheses = false
+ij_typescript_spaces_within_object_literal_braces = false
+ij_typescript_spaces_within_object_type_braces = true
+ij_typescript_spaces_within_parentheses = false
+ij_typescript_spaces_within_switch_parentheses = false
+ij_typescript_spaces_within_type_assertion = false
+ij_typescript_spaces_within_union_types = true
+ij_typescript_spaces_within_while_parentheses = false
+ij_typescript_special_else_if_treatment = true
+ij_typescript_ternary_operation_signs_on_next_line = false
+ij_typescript_ternary_operation_wrap = off
+ij_typescript_union_types_wrap = on_every_item
+ij_typescript_use_chained_calls_group_indents = false
+ij_typescript_use_double_quotes = true
+ij_typescript_use_explicit_js_extension = global
+ij_typescript_use_path_mapping = always
+ij_typescript_use_public_modifier = false
+ij_typescript_use_semicolon_after_statement = true
+ij_typescript_var_declaration_wrap = normal
+ij_typescript_while_brace_force = never
+ij_typescript_while_on_new_line = false
+ij_typescript_wrap_comments = false
+
+[{*.bash,*.bats,*.dash,*.ksh,*.mksh,*.sh,*.zsh,.bash_aliases,.bash_logout,.bash_profile,.bashrc,.profile,cli_with_proxy}]
+indent_size = 2
+tab_width = 2
+ij_shell_binary_ops_start_line = false
+ij_shell_function_brace_newline = false
+ij_shell_keep_column_alignment_padding = false
+ij_shell_minify_program = false
+ij_shell_redirect_followed_by_space = false
+ij_shell_simplify_code = false
+ij_shell_switch_cases_indented = false
+ij_shell_unix_line_feeds = true
+ij_shell_use_google_code_style = false
+
+[{*.cjs,*.js}]
+ij_continuation_indent_size = 4
+ij_javascript_align_imports = false
+ij_javascript_align_multiline_array_initializer_expression = false
+ij_javascript_align_multiline_binary_operation = false
+ij_javascript_align_multiline_chained_methods = false
+ij_javascript_align_multiline_extends_list = false
+ij_javascript_align_multiline_for = true
+ij_javascript_align_multiline_parameters = true
+ij_javascript_align_multiline_parameters_in_calls = false
+ij_javascript_align_multiline_ternary_operation = false
+ij_javascript_align_object_properties = 0
+ij_javascript_align_union_types = false
+ij_javascript_align_var_statements = 0
+ij_javascript_array_initializer_new_line_after_left_brace = false
+ij_javascript_array_initializer_right_brace_on_new_line = false
+ij_javascript_array_initializer_wrap = off
+ij_javascript_assignment_wrap = off
+ij_javascript_binary_operation_sign_on_next_line = false
+ij_javascript_binary_operation_wrap = off
+ij_javascript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/**
+ij_javascript_blank_lines_after_imports = 1
+ij_javascript_blank_lines_around_class = 1
+ij_javascript_blank_lines_around_field = 0
+ij_javascript_blank_lines_around_function = 1
+ij_javascript_blank_lines_around_method = 1
+ij_javascript_block_brace_style = end_of_line
+ij_javascript_call_parameters_new_line_after_left_paren = false
+ij_javascript_call_parameters_right_paren_on_new_line = false
+ij_javascript_call_parameters_wrap = off
+ij_javascript_catch_on_new_line = false
+ij_javascript_chained_call_dot_on_new_line = true
+ij_javascript_class_brace_style = end_of_line
+ij_javascript_comma_on_new_line = false
+ij_javascript_do_while_brace_force = never
+ij_javascript_else_on_new_line = false
+ij_javascript_enforce_trailing_comma = keep
+ij_javascript_extends_keyword_wrap = off
+ij_javascript_extends_list_wrap = off
+ij_javascript_field_prefix = _
+ij_javascript_file_name_style = relaxed
+ij_javascript_finally_on_new_line = false
+ij_javascript_for_brace_force = never
+ij_javascript_for_statement_new_line_after_left_paren = false
+ij_javascript_for_statement_right_paren_on_new_line = false
+ij_javascript_for_statement_wrap = off
+ij_javascript_force_quote_style = false
+ij_javascript_force_semicolon_style = false
+ij_javascript_function_expression_brace_style = end_of_line
+ij_javascript_if_brace_force = never
+ij_javascript_import_merge_members = global
+ij_javascript_import_prefer_absolute_path = global
+ij_javascript_import_sort_members = true
+ij_javascript_import_sort_module_name = false
+ij_javascript_import_use_node_resolution = true
+ij_javascript_imports_wrap = on_every_item
+ij_javascript_indent_case_from_switch = true
+ij_javascript_indent_chained_calls = true
+ij_javascript_indent_package_children = 0
+ij_javascript_jsx_attribute_value = braces
+ij_javascript_keep_blank_lines_in_code = 2
+ij_javascript_keep_first_column_comment = true
+ij_javascript_keep_indents_on_empty_lines = false
+ij_javascript_keep_line_breaks = true
+ij_javascript_keep_simple_blocks_in_one_line = false
+ij_javascript_keep_simple_methods_in_one_line = false
+ij_javascript_line_comment_add_space = true
+ij_javascript_line_comment_at_first_column = false
+ij_javascript_method_brace_style = end_of_line
+ij_javascript_method_call_chain_wrap = off
+ij_javascript_method_parameters_new_line_after_left_paren = false
+ij_javascript_method_parameters_right_paren_on_new_line = false
+ij_javascript_method_parameters_wrap = off
+ij_javascript_object_literal_wrap = on_every_item
+ij_javascript_parentheses_expression_new_line_after_left_paren = false
+ij_javascript_parentheses_expression_right_paren_on_new_line = false
+ij_javascript_place_assignment_sign_on_next_line = false
+ij_javascript_prefer_as_type_cast = false
+ij_javascript_prefer_explicit_types_function_expression_returns = false
+ij_javascript_prefer_explicit_types_function_returns = false
+ij_javascript_prefer_explicit_types_vars_fields = false
+ij_javascript_prefer_parameters_wrap = false
+ij_javascript_reformat_c_style_comments = false
+ij_javascript_space_after_colon = true
+ij_javascript_space_after_comma = true
+ij_javascript_space_after_dots_in_rest_parameter = false
+ij_javascript_space_after_generator_mult = true
+ij_javascript_space_after_property_colon = true
+ij_javascript_space_after_quest = true
+ij_javascript_space_after_type_colon = true
+ij_javascript_space_after_unary_not = false
+ij_javascript_space_before_async_arrow_lparen = true
+ij_javascript_space_before_catch_keyword = true
+ij_javascript_space_before_catch_left_brace = true
+ij_javascript_space_before_catch_parentheses = true
+ij_javascript_space_before_class_lbrace = true
+ij_javascript_space_before_class_left_brace = true
+ij_javascript_space_before_colon = true
+ij_javascript_space_before_comma = false
+ij_javascript_space_before_do_left_brace = true
+ij_javascript_space_before_else_keyword = true
+ij_javascript_space_before_else_left_brace = true
+ij_javascript_space_before_finally_keyword = true
+ij_javascript_space_before_finally_left_brace = true
+ij_javascript_space_before_for_left_brace = true
+ij_javascript_space_before_for_parentheses = true
+ij_javascript_space_before_for_semicolon = false
+ij_javascript_space_before_function_left_parenth = true
+ij_javascript_space_before_generator_mult = false
+ij_javascript_space_before_if_left_brace = true
+ij_javascript_space_before_if_parentheses = true
+ij_javascript_space_before_method_call_parentheses = false
+ij_javascript_space_before_method_left_brace = true
+ij_javascript_space_before_method_parentheses = false
+ij_javascript_space_before_property_colon = false
+ij_javascript_space_before_quest = true
+ij_javascript_space_before_switch_left_brace = true
+ij_javascript_space_before_switch_parentheses = true
+ij_javascript_space_before_try_left_brace = true
+ij_javascript_space_before_type_colon = false
+ij_javascript_space_before_unary_not = false
+ij_javascript_space_before_while_keyword = true
+ij_javascript_space_before_while_left_brace = true
+ij_javascript_space_before_while_parentheses = true
+ij_javascript_spaces_around_additive_operators = true
+ij_javascript_spaces_around_arrow_function_operator = true
+ij_javascript_spaces_around_assignment_operators = true
+ij_javascript_spaces_around_bitwise_operators = true
+ij_javascript_spaces_around_equality_operators = true
+ij_javascript_spaces_around_logical_operators = true
+ij_javascript_spaces_around_multiplicative_operators = true
+ij_javascript_spaces_around_relational_operators = true
+ij_javascript_spaces_around_shift_operators = true
+ij_javascript_spaces_around_unary_operator = false
+ij_javascript_spaces_within_array_initializer_brackets = false
+ij_javascript_spaces_within_brackets = false
+ij_javascript_spaces_within_catch_parentheses = false
+ij_javascript_spaces_within_for_parentheses = false
+ij_javascript_spaces_within_if_parentheses = false
+ij_javascript_spaces_within_imports = false
+ij_javascript_spaces_within_interpolation_expressions = false
+ij_javascript_spaces_within_method_call_parentheses = false
+ij_javascript_spaces_within_method_parentheses = false
+ij_javascript_spaces_within_object_literal_braces = false
+ij_javascript_spaces_within_object_type_braces = true
+ij_javascript_spaces_within_parentheses = false
+ij_javascript_spaces_within_switch_parentheses = false
+ij_javascript_spaces_within_type_assertion = false
+ij_javascript_spaces_within_union_types = true
+ij_javascript_spaces_within_while_parentheses = false
+ij_javascript_special_else_if_treatment = true
+ij_javascript_ternary_operation_signs_on_next_line = false
+ij_javascript_ternary_operation_wrap = off
+ij_javascript_union_types_wrap = on_every_item
+ij_javascript_use_chained_calls_group_indents = false
+ij_javascript_use_double_quotes = true
+ij_javascript_use_explicit_js_extension = global
+ij_javascript_use_path_mapping = always
+ij_javascript_use_public_modifier = false
+ij_javascript_use_semicolon_after_statement = true
+ij_javascript_var_declaration_wrap = normal
+ij_javascript_while_brace_force = never
+ij_javascript_while_on_new_line = false
+ij_javascript_wrap_comments = false
+
+[{*.cjsx,*.coffee}]
+indent_size = 2
+tab_width = 2
+ij_continuation_indent_size = 2
+ij_coffeescript_align_function_body = false
+ij_coffeescript_align_imports = false
+ij_coffeescript_align_multiline_array_initializer_expression = true
+ij_coffeescript_align_multiline_parameters = true
+ij_coffeescript_align_multiline_parameters_in_calls = false
+ij_coffeescript_align_object_properties = 0
+ij_coffeescript_align_union_types = false
+ij_coffeescript_align_var_statements = 0
+ij_coffeescript_array_initializer_new_line_after_left_brace = false
+ij_coffeescript_array_initializer_right_brace_on_new_line = false
+ij_coffeescript_array_initializer_wrap = normal
+ij_coffeescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/**
+ij_coffeescript_blank_lines_around_function = 1
+ij_coffeescript_call_parameters_new_line_after_left_paren = false
+ij_coffeescript_call_parameters_right_paren_on_new_line = false
+ij_coffeescript_call_parameters_wrap = normal
+ij_coffeescript_chained_call_dot_on_new_line = true
+ij_coffeescript_comma_on_new_line = false
+ij_coffeescript_enforce_trailing_comma = keep
+ij_coffeescript_field_prefix = _
+ij_coffeescript_file_name_style = relaxed
+ij_coffeescript_force_quote_style = false
+ij_coffeescript_force_semicolon_style = false
+ij_coffeescript_function_expression_brace_style = end_of_line
+ij_coffeescript_import_merge_members = global
+ij_coffeescript_import_prefer_absolute_path = global
+ij_coffeescript_import_sort_members = true
+ij_coffeescript_import_sort_module_name = false
+ij_coffeescript_import_use_node_resolution = true
+ij_coffeescript_imports_wrap = on_every_item
+ij_coffeescript_indent_chained_calls = true
+ij_coffeescript_indent_package_children = 0
+ij_coffeescript_jsx_attribute_value = braces
+ij_coffeescript_keep_blank_lines_in_code = 2
+ij_coffeescript_keep_first_column_comment = true
+ij_coffeescript_keep_indents_on_empty_lines = false
+ij_coffeescript_keep_line_breaks = true
+ij_coffeescript_keep_simple_methods_in_one_line = false
+ij_coffeescript_method_parameters_new_line_after_left_paren = false
+ij_coffeescript_method_parameters_right_paren_on_new_line = false
+ij_coffeescript_method_parameters_wrap = off
+ij_coffeescript_object_literal_wrap = on_every_item
+ij_coffeescript_prefer_as_type_cast = false
+ij_coffeescript_prefer_explicit_types_function_expression_returns = false
+ij_coffeescript_prefer_explicit_types_function_returns = false
+ij_coffeescript_prefer_explicit_types_vars_fields = false
+ij_coffeescript_reformat_c_style_comments = false
+ij_coffeescript_space_after_comma = true
+ij_coffeescript_space_after_dots_in_rest_parameter = false
+ij_coffeescript_space_after_generator_mult = true
+ij_coffeescript_space_after_property_colon = true
+ij_coffeescript_space_after_type_colon = true
+ij_coffeescript_space_after_unary_not = false
+ij_coffeescript_space_before_async_arrow_lparen = true
+ij_coffeescript_space_before_class_lbrace = true
+ij_coffeescript_space_before_comma = false
+ij_coffeescript_space_before_function_left_parenth = true
+ij_coffeescript_space_before_generator_mult = false
+ij_coffeescript_space_before_property_colon = false
+ij_coffeescript_space_before_type_colon = false
+ij_coffeescript_space_before_unary_not = false
+ij_coffeescript_spaces_around_additive_operators = true
+ij_coffeescript_spaces_around_arrow_function_operator = true
+ij_coffeescript_spaces_around_assignment_operators = true
+ij_coffeescript_spaces_around_bitwise_operators = true
+ij_coffeescript_spaces_around_equality_operators = true
+ij_coffeescript_spaces_around_logical_operators = true
+ij_coffeescript_spaces_around_multiplicative_operators = true
+ij_coffeescript_spaces_around_relational_operators = true
+ij_coffeescript_spaces_around_shift_operators = true
+ij_coffeescript_spaces_around_unary_operator = false
+ij_coffeescript_spaces_within_array_initializer_braces = false
+ij_coffeescript_spaces_within_array_initializer_brackets = false
+ij_coffeescript_spaces_within_imports = false
+ij_coffeescript_spaces_within_index_brackets = false
+ij_coffeescript_spaces_within_interpolation_expressions = false
+ij_coffeescript_spaces_within_method_call_parentheses = false
+ij_coffeescript_spaces_within_method_parentheses = false
+ij_coffeescript_spaces_within_object_braces = false
+ij_coffeescript_spaces_within_object_literal_braces = false
+ij_coffeescript_spaces_within_object_type_braces = true
+ij_coffeescript_spaces_within_range_brackets = false
+ij_coffeescript_spaces_within_type_assertion = false
+ij_coffeescript_spaces_within_union_types = true
+ij_coffeescript_union_types_wrap = on_every_item
+ij_coffeescript_use_chained_calls_group_indents = false
+ij_coffeescript_use_double_quotes = true
+ij_coffeescript_use_explicit_js_extension = global
+ij_coffeescript_use_path_mapping = always
+ij_coffeescript_use_public_modifier = false
+ij_coffeescript_use_semicolon_after_statement = false
+ij_coffeescript_var_declaration_wrap = normal
+
+[{*.ctp,*.hphp,*.inc,*.module,*.php,*.php4,*.php5,*.phtml,metadata}]
+ij_continuation_indent_size = 4
+ij_php_align_assignments = false
+ij_php_align_class_constants = false
+ij_php_align_group_field_declarations = false
+ij_php_align_inline_comments = false
+ij_php_align_key_value_pairs = false
+ij_php_align_match_arm_bodies = false
+ij_php_align_multiline_array_initializer_expression = false
+ij_php_align_multiline_binary_operation = false
+ij_php_align_multiline_chained_methods = false
+ij_php_align_multiline_extends_list = true
+ij_php_align_multiline_for = true
+ij_php_align_multiline_parameters = false
+ij_php_align_multiline_parameters_in_calls = false
+ij_php_align_multiline_ternary_operation = false
+ij_php_align_named_arguments = false
+ij_php_align_phpdoc_comments = false
+ij_php_align_phpdoc_param_names = false
+ij_php_anonymous_brace_style = end_of_line
+ij_php_api_weight = 28
+ij_php_array_initializer_new_line_after_left_brace = true
+ij_php_array_initializer_right_brace_on_new_line = true
+ij_php_array_initializer_wrap = on_every_item
+ij_php_assignment_wrap = off
+ij_php_attributes_wrap = off
+ij_php_author_weight = 28
+ij_php_binary_operation_sign_on_next_line = false
+ij_php_binary_operation_wrap = off
+ij_php_blank_lines_after_class_header = 0
+ij_php_blank_lines_after_function = 1
+ij_php_blank_lines_after_imports = 1
+ij_php_blank_lines_after_opening_tag = 1
+ij_php_blank_lines_after_package = 1
+ij_php_blank_lines_around_class = 1
+ij_php_blank_lines_around_constants = 0
+ij_php_blank_lines_around_field = 0
+ij_php_blank_lines_around_method = 1
+ij_php_blank_lines_before_class_end = 0
+ij_php_blank_lines_before_imports = 1
+ij_php_blank_lines_before_method_body = 0
+ij_php_blank_lines_before_package = 1
+ij_php_blank_lines_before_return_statement = 0
+ij_php_blank_lines_between_imports = 1
+ij_php_block_brace_style = end_of_line
+ij_php_call_parameters_new_line_after_left_paren = true
+ij_php_call_parameters_right_paren_on_new_line = true
+ij_php_call_parameters_wrap = on_every_item
+ij_php_catch_on_new_line = false
+ij_php_category_weight = 28
+ij_php_class_brace_style = next_line
+ij_php_comma_after_last_array_element = true
+ij_php_concat_spaces = true
+ij_php_copyright_weight = 28
+ij_php_deprecated_weight = 28
+ij_php_do_while_brace_force = always
+ij_php_else_if_style = combine
+ij_php_else_on_new_line = false
+ij_php_example_weight = 28
+ij_php_extends_keyword_wrap = off
+ij_php_extends_list_wrap = on_every_item
+ij_php_fields_default_visibility = protected
+ij_php_filesource_weight = 28
+ij_php_finally_on_new_line = false
+ij_php_for_brace_force = always
+ij_php_for_statement_new_line_after_left_paren = true
+ij_php_for_statement_right_paren_on_new_line = true
+ij_php_for_statement_wrap = off
+ij_php_force_short_declaration_array_style = true
+ij_php_getters_setters_naming_style = camel_case
+ij_php_getters_setters_order_style = getters_first
+ij_php_global_weight = 28
+ij_php_group_use_wrap = on_every_item
+ij_php_if_brace_force = always
+ij_php_if_lparen_on_next_line = false
+ij_php_if_rparen_on_next_line = false
+ij_php_ignore_weight = 28
+ij_php_import_sorting = alphabetic
+ij_php_indent_break_from_case = true
+ij_php_indent_case_from_switch = true
+ij_php_indent_code_in_php_tags = false
+ij_php_internal_weight = 28
+ij_php_keep_blank_lines_after_lbrace = 0
+ij_php_keep_blank_lines_before_right_brace = 0
+ij_php_keep_blank_lines_in_code = 2
+ij_php_keep_blank_lines_in_declarations = 2
+ij_php_keep_control_statement_in_one_line = true
+ij_php_keep_first_column_comment = true
+ij_php_keep_indents_on_empty_lines = false
+ij_php_keep_line_breaks = true
+ij_php_keep_rparen_and_lbrace_on_one_line = true
+ij_php_keep_simple_classes_in_one_line = false
+ij_php_keep_simple_methods_in_one_line = false
+ij_php_lambda_brace_style = end_of_line
+ij_php_license_weight = 28
+ij_php_line_comment_add_space = false
+ij_php_line_comment_at_first_column = true
+ij_php_link_weight = 28
+ij_php_lower_case_boolean_const = true
+ij_php_lower_case_keywords = true
+ij_php_lower_case_null_const = true
+ij_php_method_brace_style = next_line
+ij_php_method_call_chain_wrap = on_every_item
+ij_php_method_parameters_new_line_after_left_paren = true
+ij_php_method_parameters_right_paren_on_new_line = true
+ij_php_method_parameters_wrap = on_every_item
+ij_php_method_weight = 28
+ij_php_modifier_list_wrap = false
+ij_php_multiline_chained_calls_semicolon_on_new_line = false
+ij_php_namespace_brace_style = 1
+ij_php_new_line_after_php_opening_tag = true
+ij_php_null_type_position = in_the_end
+ij_php_package_weight = 28
+ij_php_param_weight = 0
+ij_php_parameters_attributes_wrap = off
+ij_php_parentheses_expression_new_line_after_left_paren = false
+ij_php_parentheses_expression_right_paren_on_new_line = false
+ij_php_phpdoc_blank_line_before_tags = false
+ij_php_phpdoc_blank_lines_around_parameters = false
+ij_php_phpdoc_keep_blank_lines = true
+ij_php_phpdoc_param_spaces_between_name_and_description = 1
+ij_php_phpdoc_param_spaces_between_tag_and_type = 1
+ij_php_phpdoc_param_spaces_between_type_and_name = 1
+ij_php_phpdoc_use_fqcn = false
+ij_php_phpdoc_wrap_long_lines = false
+ij_php_place_assignment_sign_on_next_line = false
+ij_php_place_parens_for_constructor = 0
+ij_php_property_read_weight = 28
+ij_php_property_weight = 28
+ij_php_property_write_weight = 28
+ij_php_return_type_on_new_line = false
+ij_php_return_weight = 1
+ij_php_see_weight = 28
+ij_php_since_weight = 28
+ij_php_sort_phpdoc_elements = true
+ij_php_space_after_colon = true
+ij_php_space_after_colon_in_enum_backed_type = true
+ij_php_space_after_colon_in_named_argument = true
+ij_php_space_after_colon_in_return_type = true
+ij_php_space_after_comma = true
+ij_php_space_after_for_semicolon = true
+ij_php_space_after_quest = true
+ij_php_space_after_type_cast = false
+ij_php_space_after_unary_not = false
+ij_php_space_before_array_initializer_left_brace = false
+ij_php_space_before_catch_keyword = true
+ij_php_space_before_catch_left_brace = true
+ij_php_space_before_catch_parentheses = true
+ij_php_space_before_class_left_brace = true
+ij_php_space_before_closure_left_parenthesis = true
+ij_php_space_before_colon = true
+ij_php_space_before_colon_in_enum_backed_type = false
+ij_php_space_before_colon_in_named_argument = false
+ij_php_space_before_colon_in_return_type = false
+ij_php_space_before_comma = false
+ij_php_space_before_do_left_brace = true
+ij_php_space_before_else_keyword = true
+ij_php_space_before_else_left_brace = true
+ij_php_space_before_finally_keyword = true
+ij_php_space_before_finally_left_brace = true
+ij_php_space_before_for_left_brace = true
+ij_php_space_before_for_parentheses = true
+ij_php_space_before_for_semicolon = false
+ij_php_space_before_if_left_brace = true
+ij_php_space_before_if_parentheses = true
+ij_php_space_before_method_call_parentheses = false
+ij_php_space_before_method_left_brace = true
+ij_php_space_before_method_parentheses = false
+ij_php_space_before_quest = true
+ij_php_space_before_short_closure_left_parenthesis = false
+ij_php_space_before_switch_left_brace = true
+ij_php_space_before_switch_parentheses = true
+ij_php_space_before_try_left_brace = true
+ij_php_space_before_unary_not = false
+ij_php_space_before_while_keyword = true
+ij_php_space_before_while_left_brace = true
+ij_php_space_before_while_parentheses = true
+ij_php_space_between_ternary_quest_and_colon = false
+ij_php_spaces_around_additive_operators = true
+ij_php_spaces_around_arrow = false
+ij_php_spaces_around_assignment_in_declare = false
+ij_php_spaces_around_assignment_operators = true
+ij_php_spaces_around_bitwise_operators = true
+ij_php_spaces_around_equality_operators = true
+ij_php_spaces_around_logical_operators = true
+ij_php_spaces_around_multiplicative_operators = true
+ij_php_spaces_around_null_coalesce_operator = true
+ij_php_spaces_around_pipe_in_union_type = false
+ij_php_spaces_around_relational_operators = true
+ij_php_spaces_around_shift_operators = true
+ij_php_spaces_around_unary_operator = false
+ij_php_spaces_around_var_within_brackets = false
+ij_php_spaces_within_array_initializer_braces = false
+ij_php_spaces_within_brackets = false
+ij_php_spaces_within_catch_parentheses = false
+ij_php_spaces_within_for_parentheses = false
+ij_php_spaces_within_if_parentheses = false
+ij_php_spaces_within_method_call_parentheses = false
+ij_php_spaces_within_method_parentheses = false
+ij_php_spaces_within_parentheses = false
+ij_php_spaces_within_short_echo_tags = true
+ij_php_spaces_within_switch_parentheses = false
+ij_php_spaces_within_while_parentheses = false
+ij_php_special_else_if_treatment = false
+ij_php_subpackage_weight = 28
+ij_php_ternary_operation_signs_on_next_line = false
+ij_php_ternary_operation_wrap = off
+ij_php_throws_weight = 2
+ij_php_todo_weight = 28
+ij_php_unknown_tag_weight = 28
+ij_php_upper_case_boolean_const = false
+ij_php_upper_case_null_const = false
+ij_php_uses_weight = 28
+ij_php_var_weight = 28
+ij_php_variable_naming_style = camel_case
+ij_php_version_weight = 28
+ij_php_while_brace_force = always
+ij_php_while_on_new_line = false
+
+[{*.har,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,bowerrc,composer.lock,jest.config}]
+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 = false
+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,*.ng,*.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,translate
+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
+
+[{*.markdown,*.md}]
+ij_markdown_force_one_space_after_blockquote_symbol = true
+ij_markdown_force_one_space_after_header_symbol = true
+ij_markdown_force_one_space_after_list_bullet = true
+ij_markdown_force_one_space_between_words = true
+ij_markdown_keep_indents_on_empty_lines = false
+ij_markdown_max_lines_around_block_elements = 1
+ij_markdown_max_lines_around_header = 1
+ij_markdown_max_lines_between_paragraphs = 1
+ij_markdown_min_lines_around_block_elements = 1
+ij_markdown_min_lines_around_header = 1
+ij_markdown_min_lines_between_paragraphs = 1
+
+[{*.toml,Cargo.lock,Cargo.toml.orig,Gopkg.lock,Pipfile,poetry.lock}]
+ij_toml_keep_indents_on_empty_lines = false
+
+[{*.yaml,*.yml}]
+indent_size = 2
+ij_yaml_align_values_properties = do_not_align
+ij_yaml_autoinsert_sequence_marker = true
+ij_yaml_block_mapping_on_new_line = false
+ij_yaml_indent_sequence_value = true
+ij_yaml_keep_indents_on_empty_lines = false
+ij_yaml_keep_line_breaks = true
+ij_yaml_sequence_on_new_line = false
+ij_yaml_space_before_colon = false
+ij_yaml_spaces_within_braces = true
+ij_yaml_spaces_within_brackets = true
+
+[*.neon]
+indent_style = tab
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..041ba5e
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,14 @@
+# Set the default behavior, in case people don't have core.autocrlf set.
+* text=auto
+
+# Force LF line ending on shell scripts
+Dockerfile text eol=lf
+*.cnf text eol=lf
+*.sh text eol=lf
+*.yml text eol=lf
+*.yaml text eol=lf
+*.json text eol=lf
+
+# Denote all files that are truly binary and should not be modified.
+*.png binary
+*.jpg binary
\ No newline at end of file
diff --git a/.github/workflows/test-and-deploy.yml b/.github/workflows/test-and-deploy.yml
new file mode 100644
index 0000000..9e87197
--- /dev/null
+++ b/.github/workflows/test-and-deploy.yml
@@ -0,0 +1,78 @@
+name: Test and Deploy
+
+on: [ push, pull_request, workflow_dispatch ]
+
+jobs:
+ build:
+ name: Build and Test
+ runs-on: self-hosted
+ steps:
+ - uses: actions/checkout@master
+
+ - name: Cache PHP dependencies
+ uses: actions/cache@v4
+ with:
+ path: www/vendor
+ key: ${{ runner.OS }}-build-${{ hashFiles('**/composer.lock') }}
+
+ - name: Run Composer install
+ run: |
+ composer install --no-interaction --ignore-platform-reqs
+ composer require staabm/annotate-pull-request-from-checkstyle
+
+ - name : Run PHP Linter
+ run : |
+ vendor/bin/parallel-lint . --exclude vendor --checkstyle | vendor/bin/cs2pr
+
+ - name : Run PHPStan
+ run : |
+ vendor/bin/phpstan analyze --error-format=checkstyle | vendor/bin/cs2pr
+
+ - name : Run PHP Code Sniffer
+ run : |
+ vendor/bin/phpcs --report=checkstyle | vendor/bin/cs2pr
+
+ publish:
+ name: Publish
+ needs: build
+ runs-on: self-hosted
+ if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
+ steps:
+ - uses: actions/checkout@master
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build Docker Metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: |
+ ghcr.io/waterwolfdev/waterwolf-site
+ tags: |
+ type=ref,event=branch
+
+ - name: Debug Docker Metadata
+ run: |
+ echo "Tags: ${{ steps.meta.outputs.tags }}"
+ echo "GitHub Ref: ${{ github.ref }}"
+ echo "Default Branch: ${{ github.event.repository.default_branch }}"
+ echo "GitHub Event Name: ${{ github.event_name }}"
+
+ - name: Publish to Docker Image Repo
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: true
+ platforms: linux/amd64
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=registry,ref=ghcr.io/waterwolfdev/waterwolf-site:buildcache,mode=max
+ cache-to: type=registry,ref=ghcr.io/waterwolfdev/waterwolf-site:buildcache,mode=max
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c730f58
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+# Compiled Code
+/web/static/dist/*
+
+# User Uploaded Content
+/web/media/*
+
+# Local Dev/Editors
+/build/dev/db_full.sql
+/.idea
+/dev.env
+/docker-compose.override.yml
+
+# Packages
+/vendor/*
+/node_modules/*
diff --git a/.phplint.yml b/.phplint.yml
new file mode 100644
index 0000000..6879918
--- /dev/null
+++ b/.phplint.yml
@@ -0,0 +1,8 @@
+path: ./
+jobs: 10
+cache: ../www_tmp/phplint.cache
+extensions:
+ - php
+ - phtml
+exclude:
+ - vendor
diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php
new file mode 100644
index 0000000..80b02b5
--- /dev/null
+++ b/.phpstorm.meta.php
@@ -0,0 +1,13 @@
+ '@',
+ ]
+ )
+ );
+}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..300f4f3
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,90 @@
+#
+# Base build (common steps)
+#
+FROM php:8.3-fpm-alpine3.19 AS base
+
+ENV TZ=UTC
+
+COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
+
+RUN install-php-extensions @composer gd curl xml zip mbstring pdo_mysql apcu
+
+RUN apk add --no-cache zip git curl bash \
+ supervisor \
+ caddy \
+ nodejs npm \
+ supercronic \
+ su-exec
+
+# Set up App user
+RUN mkdir -p /var/app/www \
+ && addgroup -g 1000 app \
+ && adduser -u 1000 -G app -h /var/app/ -s /bin/sh -D app \
+ && addgroup app www-data \
+ && mkdir -p /var/app/media /var/app/www /var/app/www_tmp /run/supervisord /logs \
+ && chown -R app:app /var/app /logs
+
+COPY --chown=app:app ./build/scripts/ /usr/local/bin
+RUN chmod a+x /usr/local/bin/*
+
+COPY ./build/supervisord.conf /etc/supervisord.conf
+COPY ./build/services/ /etc/supervisor.d/
+
+COPY --chown=app:app ./build/cron /etc/cron.d/app
+
+COPY ./build/phpfpmpool.conf /usr/local/etc/php-fpm.d/www.conf
+COPY ./build/php.ini /usr/local/etc/php/php.ini
+
+VOLUME ["/var/app/www_tmp"]
+VOLUME ["/var/app/media"]
+
+EXPOSE 8080
+
+WORKDIR /var/app/www
+
+COPY --chown=app:app . .
+
+#
+# Development Build
+#
+FROM base AS development
+
+COPY ./build/dev/services/ /etc/supervisor.d/
+COPY ./build/dev/Caddyfile /etc/Caddyfile
+COPY ./build/dev/entrypoint.sh /var/app/entrypoint.sh
+
+RUN chmod a+x /var/app/entrypoint.sh
+
+USER root
+
+ENV APPLICATION_ENV=development
+
+ENTRYPOINT ["/var/app/entrypoint.sh"]
+CMD ["supervisord", "-c", "/etc/supervisord.conf"]
+
+#
+# Production Build
+#
+FROM base AS production
+
+COPY ./build/prod/Caddyfile /etc/Caddyfile
+COPY ./build/prod/entrypoint.sh /var/app/entrypoint.sh
+
+RUN chmod a+x /var/app/entrypoint.sh
+
+USER app
+
+RUN composer install --no-dev --no-ansi --no-autoloader --no-interaction \
+ && composer dump-autoload --optimize --classmap-authoritative \
+ && composer clear-cache
+
+RUN npm ci --include=dev \
+ && npm run build \
+ && npm cache clean --force
+
+USER root
+
+ENV APPLICATION_ENV=production
+
+ENTRYPOINT ["/var/app/entrypoint.sh"]
+CMD ["supervisord", "-c", "/etc/supervisord.conf"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..93d93b8
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2024 WaterWolf
+
+ 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.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..9fab6e6
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,23 @@
+SHELL=/bin/bash
+.PHONY: *
+
+list:
+ @LC_ALL=C $(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | sort | egrep -v -e '^[^[:alnum:]]' -e '^$@$$'
+
+up:
+ docker-compose up -d
+
+down:
+ docker-compose down
+
+restart: down up
+
+build: # Rebuild all containers and restart
+ docker-compose build --no-cache
+ $(MAKE) restart
+
+bash:
+ docker-compose exec --user=app web bash
+
+bash-root:
+ docker-compose exec web bash
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..36f4d50
--- /dev/null
+++ b/README.md
@@ -0,0 +1,127 @@
+# WaterWolf Site
+
+This is a rewritten version of the WaterWolf web site that employs modern security and coding best practices while
+still remaining easy to maintain and contribute to for a variety of skill levels.
+
+See the TODO section for pending work that can be claimed.
+
+## Production
+
+This repository is deployed to remote servers via the Docker image it builds as part of its GitHub Actions CI suite.
+
+See `Dockerfile` for the build details, and the `.github` folder for the GitHub Actions details.
+
+## Development
+
+### Live Development Site
+
+Live dev website can be found [https://dev.waterwolf.club](https://dev.waterwolf.club)
+
+### Developing Locally
+
+Developers running [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop/) or MacOS or Docker
+for Linux can take advantage of the built-in support for Docker and Docker Compose.
+
+A `Makefile` also exists to allow easy shorthand access to common commands. You should have `make` installed on your
+host OS to take advantage of this file, but all instructions are provided in both formats.
+
+#### Initial Setup
+
+Copy `dev.dist.env` to `dev.env` and update it with any missing secrets.
+
+The database will be created from the DB migrations (in `/db/migrations`) on initial startup.
+
+The following user accounts are created on local dev, all with the password `WaterWolf!`:
+
+| UID | Username | E-mail Address |
+|-----|------------|--------------------------|
+| 1 | User | user@waterwolf.dev |
+| 2 | TeamMember | teammember@waterwolf.dev |
+| 3 | Moderator | mod@waterwolf.dev |
+| 4 | Admin | admin@waterwolf.dev |
+| 5 | Banned | banned@waterwolf.dev |
+
+#### Building the Base Image
+
+```bash
+docker-compose build
+# Or
+make build
+```
+
+#### Spinning Up Containers
+
+```bash
+docker-compose up -d
+# Or
+make up
+```
+
+Your local instance will be available at https://localhost:8080.
+
+#### Stopping Containers
+
+```bash
+docker-compose down
+# Or
+make down
+```
+
+To spin down all containers **and permanently delete volumes** (like DB data), run:
+
+```bash
+docker-compose down -v
+```
+
+#### Accessing Bash Shell Inside Container
+
+As the `app` user:
+
+```bash
+docker-compose exec --user=app web bash
+# Or
+make bash
+```
+
+As the `root` user:
+
+```bash
+docker-compose exec --user=app web bash
+# Or
+make bash-root
+```
+
+## Asset Hosting
+
+Static assets used by the web site are stored inside this repository and can be referenced directly via `/static` links.
+
+User-uploaded content should instead be stored in the media storage subsystem, which resolves in
+production to `media.waterwolf.town`.
+
+`media.waterwolf.town` structure.
+- site/ -> `# Website assets`
+ - css/
+ - js/
+ - img/
+ - video/
+ - uploads/ -> `# User generated assets`
+- public/ -> `# Long-term file sharing`
+ - unity/
+ - video/
+ - img/
+
+## TODO
+
+I have added //// TODO to mark work inline. Use a TODO plugin or search TODO for tasks
+
+Infrastructure tasks:
+- Implement a full local CSS/JS build system including dev hot-reloading using a tool like Vite
+- Track database changes in-repo in a schema migration tool
+
+General tasks:
+- Add self-service password reset
+- Add Recaptcha to public forms.
+- Optimize images, either by offloading to cloudflare or by resizing them.
+- Optimize video. Server as webp or something smaller. Consider removing videos from the backgrounds.
+- Replace ip/user ban system.
+- AJAX forms.
diff --git a/backend/bin/console b/backend/bin/console
new file mode 100644
index 0000000..1ba0e6a
--- /dev/null
+++ b/backend/bin/console
@@ -0,0 +1,9 @@
+#!/usr/bin/env php
+run();
diff --git a/backend/bootstrap/functions.php b/backend/bootstrap/functions.php
new file mode 100644
index 0000000..ec7d227
--- /dev/null
+++ b/backend/bootstrap/functions.php
@@ -0,0 +1,98 @@
+escapeHtmlAttr($htmlAttribute ?? '');
+}
+
+function escapeJs(mixed $string): string
+{
+ return json_encode($string, JSON_THROW_ON_ERROR);
+}
+
+function mediaUrl(string $url): string
+{
+ // Encode individual portions of the URL between slashes.
+ $url = implode("/", array_map("rawurlencode", explode("/", $url)));
+
+ return Environment::getInstance()->getMediaUrl() . '/' . ltrim($url, '/');
+}
+
+function mediaPath(string $path): string
+{
+ $mediaDir = Environment::getInstance()->getMediaPath();
+ $mediaPath = Symfony\Component\Filesystem\Path::canonicalize($mediaDir . '/' . ltrim($path, '/'));
+
+ // Check for path traversal and throw if detected.
+ if (!Symfony\Component\Filesystem\Path::isBasePath($mediaDir, $mediaPath)) {
+ throw new \InvalidArgumentException('Invalid media path!');
+ }
+
+ (new Filesystem())->mkdir(dirname($mediaPath));
+
+ return $mediaPath;
+}
+
+function avatarUrl(string|bool|null $userImg): string
+{
+ return (!empty($userImg))
+ ? mediaUrl('/img/profile/' . $userImg)
+ : '/static/img/avatar.webp';
+}
+
+function djAvatarUrl(
+ string|bool|null $djImg,
+ string|bool|null $userImg
+): string {
+ return (!empty($djImg))
+ ? mediaUrl('/img/djs/' . $djImg)
+ : avatarUrl($userImg);
+}
+
+/*
+ * Input Escaping
+ */
+
+function humanTime(string|int|null $timestamp = "", string $format = 'D, M d, Y \a\t g:i A'): string
+{
+ if (empty($timestamp) || !is_numeric($timestamp)) {
+ $timestamp = time();
+ }
+
+ return date($format, $timestamp);
+}
+
+function timeAgo(int|null $time): string
+{
+ if ($time === null) {
+ return '';
+ }
+
+ $periods = ["sec", "min", "hour", "day", "week", "month", "year", "decade"];
+ $lengths = ["60", "60", "24", "7", "4.35", "12", "10"];
+
+ $difference = time() - $time;
+ for ($j = 0; $difference >= $lengths[$j] && $j < count($lengths) - 1; $j++) {
+ $difference /= $lengths[$j];
+ }
+
+ $difference = round($difference);
+ if ($difference != 1) {
+ $periods[$j] .= "s";
+ }
+
+ return "$difference $periods[$j] ago";
+}
diff --git a/backend/bootstrap/routes.php b/backend/bootstrap/routes.php
new file mode 100644
index 0000000..54ac7a7
--- /dev/null
+++ b/backend/bootstrap/routes.php
@@ -0,0 +1,236 @@
+group('', function (RouteCollectorProxy $group) {
+ $group->get('/', View::staticPage('index'))
+ ->setName('home');
+
+ $group->get('/about', View::staticPage('about'))
+ ->setName('about');
+
+ $group->get('/calendar', View::staticPage('calendar'))
+ ->setName('calendar');
+
+ $group->group('/dashboard', function (RouteCollectorProxy $group) {
+ $group->get('', View::staticPage('dashboard/index'))
+ ->setName('dashboard');
+
+ $group->group('/admin', function (RouteCollectorProxy $group) {
+ $group->map(
+ ['GET', 'POST'],
+ '/add_world',
+ App\Controller\Dashboard\Admin\AddWorldAction::class
+ )->setName('dashboard:admin:add_world');
+
+ $group->get(
+ '/users',
+ App\Controller\Dashboard\Admin\UsersAction::class
+ )->setName('dashboard:admin:users');
+ })->add(new App\Middleware\Auth\RequireAdmin());
+
+ $group->map(
+ ['GET', 'POST'],
+ '/avatar[/{type}]',
+ App\Controller\Dashboard\AvatarAction::class
+ )->setName('dashboard:avatar');
+
+ $group->map(
+ ['GET', 'POST'],
+ '/dmx',
+ App\Controller\Dashboard\DmxController::class
+ )->setName('dashboard:dmx');
+
+ $group->map(
+ ['GET', 'POST'],
+ '/password',
+ App\Controller\Dashboard\PasswordAction::class
+ )->setName('dashboard:password');
+
+ $group->group('/posters', function (RouteCollectorProxy $group) {
+ $group->get(
+ '',
+ App\Controller\Dashboard\PostersController::class . ':listAction'
+ )->setName('dashboard:posters');
+
+ $group->map(
+ ['GET', 'POST'],
+ '/create',
+ App\Controller\Dashboard\PostersController::class . ':createAction'
+ )->setName('dashboard:posters:create');
+
+ $group->map(
+ ['GET', 'POST'],
+ '/edit[/{id}]',
+ App\Controller\Dashboard\PostersController::class . ':editAction'
+ )->setName('dashboard:posters:edit');
+
+ $group->get(
+ '/delete[/{id}]',
+ App\Controller\Dashboard\PostersController::class . ':deleteAction'
+ )->setName('dashboard:posters:delete');
+ });
+
+ $group->map(
+ ['GET', 'POST'],
+ '/profile[/{id}]',
+ App\Controller\Dashboard\EditProfileAction::class
+ )->setName('dashboard:profile');
+
+ $group->group('/short_urls', function (RouteCollectorProxy $group) {
+ $group->get(
+ '',
+ App\Controller\Dashboard\ShortUrlsController::class . ':listAction'
+ )->setName('dashboard:short_urls');
+
+ $group->map(
+ ['GET', 'POST'],
+ '/create',
+ App\Controller\Dashboard\ShortUrlsController::class . ':createAction'
+ )->setName('dashboard:short_urls:create');
+
+ $group->map(
+ ['GET', 'POST'],
+ '/edit[/{id}]',
+ App\Controller\Dashboard\ShortUrlsController::class . ':editAction'
+ )->setName('dashboard:short_urls:edit');
+
+ $group->get(
+ '/delete[/{id}]',
+ App\Controller\Dashboard\ShortUrlsController::class . ':deleteAction'
+ )->setName('dashboard:short_urls:delete');
+ })->add(new App\Middleware\Auth\RequireMod());
+
+ $group->map(
+ ['GET', 'POST'],
+ '/skills',
+ App\Controller\Dashboard\SkillsController::class
+ )->setName('dashboard:skills');
+ })->add(new App\Middleware\Auth\RequireLoggedIn());
+
+ $group->get('/defective', View::staticPage('defective/index'))
+ ->setName('defective');
+
+ $group->get('/donate', View::staticPage('donate'))
+ ->setName('donate');
+
+ $group->map(['GET', 'POST'], '/forgot', App\Controller\Account\ForgotAction::class)
+ ->setName('forgot');
+
+ $group->group('/foxxcon', function (RouteCollectorProxy $group) {
+ $group->get('', View::staticPage('foxxcon/index'))
+ ->setName('foxxcon');
+
+ $group->get('/instances', View::staticPage('foxxcon/instances'))
+ ->setName('foxxcon:instances');
+ });
+
+ $group->get('/live', View::staticPage('live'))
+ ->setName('live');
+
+ $group->map(['GET', 'POST'], '/login', App\Controller\Account\LoginAction::class)
+ ->setName('login');
+
+ $group->get('/logout', App\Controller\Account\LogoutAction::class)
+ ->setName('logout');
+
+ $group->get('/portals', View::staticPage('portals'))
+ ->setName('portals');
+
+ $group->get('/posters/faq', App\Controller\Posters\GetFaqAction::class)
+ ->setName('posters:faq');
+
+ $group->get('/profile[/{user}]', App\Controller\ProfileAction::class)
+ ->setName('profile');
+
+ $group->map(['GET', 'POST'], '/recover', App\Controller\Account\RecoverAction::class)
+ ->setName('recover');
+
+ $group->map(['GET', 'POST'], '/register', App\Controller\Account\RegisterAction::class)
+ ->setName('register');
+
+ $group->get('/talent', App\Controller\TalentAction::class)
+ ->setName('talent');
+
+ $group->get('/team', App\Controller\TeamAction::class)
+ ->setName('team');
+
+ $group->group('/wwradio', function (RouteCollectorProxy $group) {
+ $group->get('', View::staticPage('wwradio/index'))
+ ->setName('wwradio');
+
+ $group->get('/info', View::staticPage('wwradio/info'))
+ ->setName('wwradio:info');
+ });
+
+ $group->get('/worlds', App\Controller\WorldsController::class . ':listAction')
+ ->setName('worlds');
+
+ $group->get('/world[/{id}]', App\Controller\WorldsController::class . ':getAction')
+ ->setName('world');
+ })->add(App\Middleware\EnableView::class)
+ ->add(App\Middleware\GetCurrentUser::class)
+ ->add(App\Middleware\EnableSession::class);
+
+ /*
+ * No view, public-facing APIs
+ */
+ $app->group('/api', function (RouteCollectorProxy $group) {
+ $group->get('/json', App\Controller\Api\JsonAction::class)
+ ->setName('api:json');
+
+ $group->post('/vrc_api', App\Controller\Api\VrcApiAction::class)
+ ->setName('api:vrc_api');
+
+ $group->group('/comments', function (RouteCollectorProxy $group) {
+ $group->get('/{location}', App\Controller\Api\CommentsController::class . ':listAction')
+ ->setName('api:comments');
+
+ $group->post('/{location}', App\Controller\Api\CommentsController::class . ':postAction')
+ ->setName('api:comments:post')
+ ->add(new App\Middleware\Auth\RequireLoggedIn())
+ ->add(App\Middleware\GetCurrentUser::class)
+ ->add(App\Middleware\EnableSession::class);
+
+ $group->delete('/{location}', App\Controller\Api\CommentsController::class . ':deleteAction')
+ ->setName('api:comments:delete')
+ ->add(new App\Middleware\Auth\RequireMod())
+ ->add(App\Middleware\GetCurrentUser::class)
+ ->add(App\Middleware\EnableSession::class);
+ });
+ });
+
+ $app->get('/posters[/{id}]', App\Controller\Posters\GetPosterAction::class)
+ ->setName('posters');
+
+ $app->get('/short_url[/{url}]', App\Controller\GetShortUrlAction::class)
+ ->setName('short_url');
+
+ /*
+ * URL Redirects
+ */
+ $redirects = [
+ 'discord' => 'https://discord.gg/waterwolf',
+ 'twitch' => 'https://www.twitch.tv/waterwolfvr',
+ 'twitter' => 'https://twitter.com/waterwolftown',
+ 'x' => 'https://twitter.com/waterwolftown',
+ 'vrchat' => 'https://vrc.group/WWOLF.1912',
+ ];
+
+ foreach ($redirects as $url => $dest) {
+ $app->get(
+ '/' . $url,
+ function (ServerRequest $request, Response $response) use ($dest): ResponseInterface {
+ return $response->withRedirect($dest);
+ }
+ )->setName($url);
+ }
+};
diff --git a/backend/bootstrap/services.php b/backend/bootstrap/services.php
new file mode 100644
index 0000000..fcd3869
--- /dev/null
+++ b/backend/bootstrap/services.php
@@ -0,0 +1,180 @@
+ function (
+ ContainerInterface $di,
+ LoggerInterface $logger,
+ Environment $env
+ ) {
+ $httpFactory = new HttpFactory();
+
+ ServerRequestCreatorFactory::setSlimHttpDecoratorsAutomaticDetection(false);
+ ServerRequestCreatorFactory::setServerRequestCreator($httpFactory);
+
+ $app = new Slim\App(
+ responseFactory: $httpFactory,
+ container: $di,
+ );
+
+ $routeCollector = $app->getRouteCollector();
+ $routeCollector->setDefaultInvocationStrategy(new RequestResponse());
+
+ if ($env->isProduction()) {
+ $routeCollector->setCacheFile($env->getTempDirectory() . '/app_routes.cache.php');
+ }
+
+ call_user_func(include(__DIR__ . '/routes.php'), $app);
+
+ // System middleware for routing and body parsing.
+ $app->addBodyParsingMiddleware();
+ $app->addRoutingMiddleware();
+
+ // Redirects and updates that should happen before system middleware.
+ $app->add(new App\Middleware\RemoveSlashes());
+ $app->add(new App\Middleware\GetRemoteIp());
+
+ // Add an error handler for most in-controller/task situations.
+ $errorHandler = $app->addErrorMiddleware(
+ $env->isDev(),
+ true,
+ true,
+ $logger
+ );
+ $errorHandler->setDefaultErrorHandler(App\Http\ErrorHandler::class);
+
+ return $app;
+ },
+
+ RouteParserInterface::class => fn(Slim\App $app) => $app->getRouteCollector()->getRouteParser(),
+
+ // Console
+ ConsoleApplication::class => function (
+ ContainerInterface $di
+ ) {
+ $console = new ConsoleApplication(
+ 'WaterWolf CLI',
+ '1.0.0'
+ );
+
+ // Add commands here
+ $commandLoader = new ContainerCommandLoader(
+ $di,
+ [
+ 'init' => App\Console\Command\InitCommand::class,
+ 'migrate' => App\Console\Command\MigrateCommand::class,
+ 'seed' => App\Console\Command\SeedCommand::class,
+ 'sync' => App\Console\Command\SyncCommand::class,
+ 'uptime-wait' => App\Console\Command\UptimeWaitCommand::class,
+ ]
+ );
+
+ $console->setCommandLoader($commandLoader);
+
+ return $console;
+ },
+
+ // Escapes HTML, HTML attributes and URL chunks.
+ Escaper::class => static fn() => new Escaper('utf-8'),
+
+ // Database Abstraction Layer
+ Connection::class => static function (Environment $env) {
+ $connectionParams = [
+ ...$env->getDatabaseInfo(),
+ 'driver' => 'pdo_mysql',
+ 'options' => [
+ \PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES 'UTF8MB4' COLLATE 'utf8mb4_unicode_ci'",
+ ],
+ ];
+
+ return Doctrine\DBAL\DriverManager::getConnection($connectionParams);
+ },
+
+ // Image manager (resizer, processor, etc.)
+ ImageManager::class => static fn() => new ImageManager(new ImageManagerGdDriver()),
+
+ // E-mail delivery service
+ Mailer::class => static function (Environment $env) {
+ $dsn = MailerDsn::fromString($env->getMailerDsn());
+ $transport = (new SesTransportFactory())->create($dsn);
+
+ return new Mailer($transport);
+ },
+
+ // HTTP client
+ HttpClient::class => static fn() => new HttpClient([
+ 'headers' => [
+ 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36',
+ ],
+ ]),
+
+ // Filesystem Utilities
+ Filesystem::class => static fn() => new Filesystem(),
+
+ // PSR-6 cache
+ CacheItemPoolInterface::class => static function (
+ Logger $logger,
+ Environment $env
+ ) {
+ $cacheInterface = new FilesystemAdapter(
+ directory: $env->getTempDirectory() . '/cache'
+ );
+ $cacheInterface->setLogger($logger);
+ return $cacheInterface;
+ },
+
+ // PSR-16 cache
+ CacheInterface::class => static fn(CacheItemPoolInterface $psr6Cache) => new Psr16Cache($psr6Cache),
+
+ // PSR Logger
+ Logger::class => function (Environment $env) {
+ $logger = new Logger('site');
+
+ $loggingLevel = $env->isProduction()
+ ? LogLevel::Warning
+ : LogLevel::Debug;
+
+ $logger->pushHandler(
+ new StreamHandler('php://stderr', $loggingLevel, true)
+ );
+
+ $logger->pushHandler(
+ new Monolog\Handler\RotatingFileHandler(
+ '/logs/site.log',
+ 5,
+ $loggingLevel,
+ true
+ )
+ );
+
+ return $logger;
+ },
+
+ LoggerInterface::class => DI\Get(Logger::class),
+];
diff --git a/backend/src/AppFactory.php b/backend/src/AppFactory.php
new file mode 100644
index 0000000..7fc150d
--- /dev/null
+++ b/backend/src/AppFactory.php
@@ -0,0 +1,109 @@
+get(SlimApp::class);
+ }
+
+ public static function createCli(
+ array $appEnvironment = []
+ ): ConsoleApplication {
+ $environment = self::buildEnvironment($appEnvironment);
+ $di = self::buildContainer($environment);
+
+ // Some CLI commands require the App to be injected for routing.
+ $di->get(SlimApp::class);
+
+ return $di->get(ConsoleApplication::class);
+ }
+
+ public static function buildContainer(Environment $environment): ContainerInterface
+ {
+ Environment::setInstance($environment);
+
+ $containerBuilder = new ContainerBuilder();
+ $containerBuilder->useAutowiring(true);
+ $containerBuilder->useAttributes(true);
+
+ if ($environment->isProduction()) {
+ $containerBuilder->enableCompilation($environment->getTempDirectory());
+ }
+
+ $containerBuilder->addDefinitions([
+ Environment::class => $environment,
+ ]);
+ $containerBuilder->addDefinitions(dirname(__DIR__) . '/bootstrap/services.php');
+
+ $di = $containerBuilder->build();
+
+ // Monolog setup
+ $logger = $di->get(Logger::class);
+
+ $errorHandler = new ErrorHandler($logger);
+ $errorHandler->registerFatalHandler();
+
+ return $di;
+ }
+
+ /**
+ * @param array $rawEnvironment
+ */
+ public static function buildEnvironment(array $rawEnvironment = []): Environment
+ {
+ $_ENV = getenv();
+ $rawEnvironment = array_merge(array_filter($_ENV), $rawEnvironment);
+ $environment = new Environment($rawEnvironment);
+
+ self::applyPhpSettings($environment);
+
+ return $environment;
+ }
+
+ private static function applyPhpSettings(Environment $environment): void
+ {
+ error_reporting(
+ $environment->isProduction()
+ ? E_ALL & ~E_NOTICE & ~E_WARNING & ~E_STRICT & ~E_DEPRECATED
+ : E_ALL & ~E_NOTICE
+ );
+
+ $displayStartupErrors = (!$environment->isProduction() || $environment->isCli())
+ ? '1'
+ : '0';
+ ini_set('display_startup_errors', $displayStartupErrors);
+ ini_set('display_errors', $displayStartupErrors);
+
+ ini_set('log_errors', '1');
+ ini_set('error_log', '/dev/stderr');
+
+ mb_internal_encoding('UTF-8');
+ ini_set('default_charset', 'utf-8');
+
+ if (!headers_sent()) {
+ ini_set('session.use_only_cookies', '1');
+ ini_set('session.cookie_httponly', '1');
+ ini_set('session.cookie_lifetime', '86400');
+ ini_set('session.use_strict_mode', '1');
+
+ session_cache_limiter('');
+ }
+
+ date_default_timezone_set('UTC');
+ }
+}
diff --git a/backend/src/Console/Command/AbstractCommand.php b/backend/src/Console/Command/AbstractCommand.php
new file mode 100644
index 0000000..723f94f
--- /dev/null
+++ b/backend/src/Console/Command/AbstractCommand.php
@@ -0,0 +1,28 @@
+getApplication()?->find($commandName);
+ if (null === $command) {
+ throw new \RuntimeException(sprintf('Command %s not found.', $commandName));
+ }
+
+ $input = new ArrayInput(['command' => $commandName] + $commandArgs);
+ $input->setInteractive(false);
+
+ return $command->run($input, $output);
+ }
+}
diff --git a/backend/src/Console/Command/InitCommand.php b/backend/src/Console/Command/InitCommand.php
new file mode 100644
index 0000000..50f9df2
--- /dev/null
+++ b/backend/src/Console/Command/InitCommand.php
@@ -0,0 +1,28 @@
+title('Initialization');
+
+ $uptimeRet = $this->runCommand($output, 'uptime-wait');
+ if ($uptimeRet !== 0) {
+ return $uptimeRet;
+ }
+
+ $this->runCommand($output, 'migrate');
+
+ return 0;
+ }
+}
diff --git a/backend/src/Console/Command/MigrateCommand.php b/backend/src/Console/Command/MigrateCommand.php
new file mode 100644
index 0000000..10a08e9
--- /dev/null
+++ b/backend/src/Console/Command/MigrateCommand.php
@@ -0,0 +1,34 @@
+find('migrate');
+
+ $arguments = [
+ 'command' => 'migrate',
+ '--environment' => 'db',
+ '--configuration' => $this->environment->getBaseDirectory() . '/phinx.php',
+ ];
+
+ return $command->run(new ArrayInput($arguments), $output);
+ }
+}
diff --git a/backend/src/Console/Command/SeedCommand.php b/backend/src/Console/Command/SeedCommand.php
new file mode 100644
index 0000000..1a7b6da
--- /dev/null
+++ b/backend/src/Console/Command/SeedCommand.php
@@ -0,0 +1,56 @@
+environment->isDev()) {
+ $io->error('This can only be used in development mode.');
+ return 1;
+ }
+
+ $userCount = $this->db->fetchOne(
+ <<<'SQL'
+ SELECT COUNT(*)
+ FROM web_users
+ SQL
+ );
+
+ if ($userCount > 0) {
+ $io->warning('Cannot pre-populate database: database already seeded!');
+ return 1;
+ }
+
+ $phinx = new PhinxApplication();
+ $command = $phinx->find('seed:run');
+
+ $arguments = [
+ 'command' => 'seed:run',
+ '--environment' => 'db',
+ '--configuration' => $this->environment->getBaseDirectory() . '/phinx.php',
+ ];
+
+ return $command->run(new ArrayInput($arguments), $output);
+ }
+}
diff --git a/backend/src/Console/Command/SyncCommand.php b/backend/src/Console/Command/SyncCommand.php
new file mode 100644
index 0000000..2fbfb28
--- /dev/null
+++ b/backend/src/Console/Command/SyncCommand.php
@@ -0,0 +1,45 @@
+info('Starting sync tasks...');
+
+ $this->clearUserLoginTokens($io);
+
+ $io->success('Sync tasks completed.');
+ return 0;
+ }
+
+ private function clearUserLoginTokens(SymfonyStyle $io): void
+ {
+ $thresholdDate = new \DateTimeImmutable('-2 days', new \DateTimeZone('UTC'));
+ $this->db->executeQuery(
+ <<<'SQL'
+ DELETE FROM web_user_login_tokens
+ WHERE created_at < :threshold
+ SQL,
+ [
+ 'threshold' => $thresholdDate->format('Y-m-d H:i:s'),
+ ]
+ );
+ }
+}
diff --git a/backend/src/Console/Command/UptimeWaitCommand.php b/backend/src/Console/Command/UptimeWaitCommand.php
new file mode 100644
index 0000000..24800a6
--- /dev/null
+++ b/backend/src/Console/Command/UptimeWaitCommand.php
@@ -0,0 +1,57 @@
+info('Starting services...');
+
+ $elapsed = 0;
+ $timeout = 180;
+
+ $connectionParams = [
+ 'driver' => 'pdo_mysql',
+ ...$this->environment->getDatabaseInfo(),
+ ];
+
+ while ($elapsed <= $timeout) {
+ try {
+ $conn = DriverManager::getConnection($connectionParams);
+ $pdo = $conn->getNativeConnection();
+
+ assert($pdo instanceof \PDO);
+
+ $pdo->exec('SELECT 1');
+
+ $io->success('Services started up and ready!');
+ return 0;
+ } catch (\Throwable $e) {
+ sleep(1);
+ $elapsed += 1;
+
+ $io->writeln($e->getMessage());
+ }
+ }
+
+ $io->error('Timed out waiting for services to start.');
+ return 1;
+ }
+}
diff --git a/backend/src/Controller/Account/ForgotAction.php b/backend/src/Controller/Account/ForgotAction.php
new file mode 100644
index 0000000..74393c7
--- /dev/null
+++ b/backend/src/Controller/Account/ForgotAction.php
@@ -0,0 +1,115 @@
+isLoggedIn()) {
+ return $response->withRedirect(
+ $request->getRouter()->urlFor('dashboard')
+ );
+ }
+
+ $error = null;
+
+ if ($request->isPost()) {
+ try {
+ $email = $request->getParam('email');
+ if (empty($email)) {
+ throw new \InvalidArgumentException('Must provide e-mail address.');
+ }
+
+ $userId = $this->db->fetchOne(
+ <<<'SQL'
+ SELECT id
+ FROM web_users
+ WHERE LOWER(email) = LOWER(:email)
+ SQL,
+ [
+ 'email' => $email,
+ ]
+ );
+
+ if ($userId === false) {
+ throw new \InvalidArgumentException('E-mail address not found!');
+ }
+
+ /*
+ * Create a new "split-token" key for password reset
+ * per ParagonIE's PHP security recommendations:
+ * https://paragonie.com/blog/2017/02/split-tokens-token-based-authentication-protocols-without-side-channels
+ */
+
+ $randomStr = hash('sha256', random_bytes(32));
+ $tokenIdentifier = substr($randomStr, 0, 16);
+ $tokenVerifier = substr($randomStr, 16, 32);
+
+ $this->db->insert(
+ 'web_user_login_tokens',
+ [
+ 'id' => $tokenIdentifier,
+ 'verifier' => hash('sha512', $tokenVerifier),
+ 'creator' => $userId,
+ ]
+ );
+
+ $token = $tokenIdentifier . ':' . $tokenVerifier;
+
+ $recoverUrl = $request->getRouter()->fullUrlFor(
+ $request->getUri(),
+ 'recover',
+ queryParams: [
+ 'token' => $token,
+ ]
+ );
+
+ // Send e-mail
+ $mailBody = $request->getView()->render(
+ 'emails/forgot',
+ [
+ 'url' => $recoverUrl,
+ ]
+ );
+
+ $email = (new Email())
+ ->from(new Address('noreply@mail.waterwolf.club', 'WaterWolf Community'))
+ ->subject('Recover your WaterWolf Account')
+ ->to($email)
+ ->text($mailBody);
+
+ $this->mailer->send($email);
+
+ $request->getFlash()->success(
+ 'Recovery code successfully sent! Check your inbox for instructions. If you don\'t see a message, check your "Spam" folder.'
+ );
+ return $response->withRedirect('/');
+ } catch (\Throwable $e) {
+ $error = $e->getMessage();
+ }
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'account/forgot',
+ [
+ 'error' => $error,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/Account/LoginAction.php b/backend/src/Controller/Account/LoginAction.php
new file mode 100644
index 0000000..0261098
--- /dev/null
+++ b/backend/src/Controller/Account/LoginAction.php
@@ -0,0 +1,125 @@
+getSession();
+
+ if (!$session->has('valid_id') && $request->isPost()) {
+ try {
+ $postParams = $request->getParsedBody();
+
+ $username = $postParams['username'] ?? null;
+ $userPassword = $postParams['pass'] ?? null;
+
+ if (empty($username) || empty($userPassword)) {
+ throw new \InvalidArgumentException('Missing username or password!');
+ }
+
+ $userRow = $this->db->fetchAssociative(
+ <<<'SQL'
+ SELECT id, password, banned
+ FROM web_users
+ WHERE LOWER(username) = LOWER(:user)
+ OR LOWER(email) = LOWER(:user)
+ SQL,
+ [
+ 'user' => $username,
+ ]
+ );
+
+ if ($userRow === false) {
+ throw new \InvalidArgumentException('This user does not exist!');
+ }
+
+ if ($userRow['banned'] == 1) {
+ throw new \InvalidArgumentException('You are banned and cannot log in.');
+ }
+
+ // Check legacy password.
+ if (hash_equals($userRow['password'], md5($userPassword))) {
+ // Migrate to new password.
+ $newPassword = password_hash($userPassword, PASSWORD_ARGON2ID);
+ $this->db->update(
+ 'web_users',
+ [
+ 'password' => $newPassword,
+ ],
+ [
+ 'id' => $userRow['id'],
+ ]
+ );
+ } elseif (!password_verify($userPassword, $userRow['password'])) {
+ $this->db->executeQuery(
+ <<<'SQL'
+ UPDATE web_users
+ SET badpass = badpass + 1
+ WHERE id = :id
+ SQL,
+ [
+ 'id' => $userRow['id'],
+ ]
+ );
+
+ throw new \InvalidArgumentException('Your credentials could not be validated.');
+ }
+
+ $this->db->executeQuery(
+ <<<'SQL'
+ UPDATE web_users
+ SET goodpass=goodpass + 1
+ WHERE id=:id
+ SQL,
+ [
+ 'id' => $userRow['id'],
+ ]
+ );
+
+ $session->set('valid_id', $userRow['id']);
+ $session->set('valid_time', time());
+ $session->regenerate();
+
+ $request->getFlash()->success('Successfully logged in! Welcome!');
+ } catch (\Exception $e) {
+ $error = $e->getMessage();
+ }
+ }
+
+ $return = $request->getParam('return');
+
+ // Redirect logged in users.
+ if ($session->has('valid_id')) {
+ if (empty($return)) {
+ $return = '/dashboard';
+ }
+
+ return $response->withRedirect(
+ $request->getUri()->withQuery('')->withPath($return)
+ );
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'account/login',
+ [
+ 'error' => $error,
+ 'return' => $return,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/Account/LogoutAction.php b/backend/src/Controller/Account/LogoutAction.php
new file mode 100644
index 0000000..defe204
--- /dev/null
+++ b/backend/src/Controller/Account/LogoutAction.php
@@ -0,0 +1,40 @@
+getCurrentUser();
+
+ if ($user !== null) {
+ $this->db->update(
+ 'web_users',
+ [
+ 'online' => '0',
+ ],
+ [
+ 'id' => $user['id'],
+ ]
+ );
+ }
+
+ $session = $request->getSession();
+
+ $session->clear();
+ $session->regenerate();
+
+ return $response->withRedirect('/');
+ }
+}
diff --git a/backend/src/Controller/Account/RecoverAction.php b/backend/src/Controller/Account/RecoverAction.php
new file mode 100644
index 0000000..0e9363d
--- /dev/null
+++ b/backend/src/Controller/Account/RecoverAction.php
@@ -0,0 +1,123 @@
+isLoggedIn()) {
+ return $response->withRedirect(
+ $request->getRouter()->urlFor('dashboard')
+ );
+ }
+
+ $token = $request->getParam('token');
+ if (empty($token)) {
+ throw new \InvalidArgumentException('No token provided!');
+ }
+
+ // Look up token
+ [$tokenId, $tokenVerifier] = explode(':', $token);
+
+ $threshold = new \DateTimeImmutable('-1 day', new \DateTimeZone('UTC'));
+ $tokenRow = $this->db->fetchAssociative(
+ <<<'SQL'
+ SELECT verifier, creator, created_at
+ FROM web_user_login_tokens
+ WHERE id=:id
+ AND created_at > :threshold
+ SQL,
+ [
+ 'id' => $tokenId,
+ 'threshold' => $threshold->format('Y-m-d h:i:s'),
+ ]
+ );
+
+ if ($tokenRow === false) {
+ throw new \InvalidArgumentException('Invalid token!');
+ }
+
+ if (
+ !hash_equals(
+ $tokenRow['verifier'],
+ hash('sha512', $tokenVerifier)
+ )
+ ) {
+ throw new \InvalidArgumentException('Invalid token!');
+ }
+
+ $error = null;
+
+ if ($request->isPost()) {
+ try {
+ $postParams = $request->getParsedBody();
+ $newPassword = $postParams['new_password'] ?? null;
+ $newPasswordConfirm = $postParams['new_password_confirm'] ?? null;
+
+ if (empty($newPassword) || empty($newPasswordConfirm)) {
+ throw new \InvalidArgumentException('Please provide all required fields.');
+ }
+
+ if ($newPassword !== $newPasswordConfirm) {
+ throw new \InvalidArgumentException('New password and confirmation do not match.');
+ }
+
+ $newPasswordHash = password_hash($newPassword, \PASSWORD_ARGON2ID);
+
+ // Update the user's password.
+ $this->db->update(
+ 'web_users',
+ [
+ 'password' => $newPasswordHash,
+ ],
+ [
+ 'id' => $tokenRow['creator'],
+ ]
+ );
+
+ // Remove the now-consumed token.
+ $this->db->delete(
+ 'web_user_login_tokens',
+ [
+ 'id' => $tokenId,
+ ]
+ );
+
+ $session = $request->getSession();
+ $session->set('valid_id', $tokenRow['creator']);
+ $session->set('valid_time', time());
+ $session->regenerate();
+
+ $request->getFlash()->success(
+ 'Your password has been reset and you have been logged in.'
+ );
+
+ return $response->withRedirect(
+ $request->getRouter()->urlFor('dashboard')
+ );
+ } catch (\Throwable $e) {
+ $error = $e->getMessage();
+ }
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'account/recover',
+ [
+ 'error' => $error,
+ 'token' => $token,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/Account/RegisterAction.php b/backend/src/Controller/Account/RegisterAction.php
new file mode 100644
index 0000000..b77073e
--- /dev/null
+++ b/backend/src/Controller/Account/RegisterAction.php
@@ -0,0 +1,112 @@
+isLoggedIn()) {
+ return $response->withRedirect(
+ $request->getRouter()->urlFor('dashboard')
+ );
+ }
+
+ $error = null;
+ $postData = $request->getParams();
+
+ if ($request->isPost()) {
+ try {
+ $regUsername = str_replace(['#', '&', '�'], '', $postData['reg_username'] ?? '');
+ $regEmail = filter_var($postData['reg_email'] ?? '', FILTER_SANITIZE_EMAIL);
+
+ if (empty($regUsername) || empty($regEmail)) {
+ throw new \Exception('Nickname and e-mail cannot be left blank.');
+ }
+
+ $regPass = str_replace(' ', '', $postData['reg_pass'] ?? '');
+ $regPassConfirm = str_replace(' ', '', $postData['reg_pass_confirm'] ?? '');
+
+ if ($regPass !== $regPassConfirm) {
+ throw new \Exception('The password and confirm password boxes do not match. Please try again.');
+ }
+
+ $checkUserOrEmail = $this->db->fetchOne(
+ <<<'SQL'
+ SELECT username
+ FROM web_users
+ WHERE LOWER(username) = LOWER(:username) OR LOWER(email) = LOWER(:email)
+ SQL,
+ [
+ 'username' => $regUsername,
+ 'email' => $regEmail,
+ ]
+ );
+
+ if ($checkUserOrEmail !== false) {
+ throw new \Exception('Username or e-mail address already registered.');
+ }
+
+ $this->db->insert(
+ 'web_users',
+ [
+ 'username' => $regUsername,
+ 'email' => $regEmail,
+ 'country' => $postData['reg_country'] ?? null,
+ 'reg_date' => time(),
+ 'lastip' => $request->getIp(),
+ 'password' => password_hash($regPass, PASSWORD_ARGON2ID),
+ 'vrchat' => $postData['vrchat_username'] ?? null,
+ 'discord' => $postData['discord_username'] ?? null,
+ 'ref' => $postData['ref'] ?? null,
+ ]
+ );
+
+ $newUserId = $this->db->lastInsertId();
+
+ $session = $request->getSession();
+ $session->set('valid_id', $newUserId);
+ $session->set('valid_time', time());
+ $session->regenerate();
+
+ $this->discord->sendMessage(
+ $this->environment->getDiscordWebookUrl(),
+ '',
+ hexdec('00FF00'),
+ 'New User Created on WaterWolf Website',
+ 'User ' . $regUsername . ' Has created a new account.',
+ (string)$request->getUri()->withPath('/static/img/waterwolf_community.png')
+ );
+
+ return $response->withRedirect(
+ $request->getRouter()->urlFor('dashboard')
+ );
+ } catch (\Throwable $e) {
+ $error = $e->getMessage();
+ }
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'account/register',
+ [
+ 'error' => $error,
+ 'data' => $postData,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/Api/CommentsController.php b/backend/src/Controller/Api/CommentsController.php
new file mode 100644
index 0000000..30812fe
--- /dev/null
+++ b/backend/src/Controller/Api/CommentsController.php
@@ -0,0 +1,101 @@
+db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT c.*, u.username
+ FROM web_comments AS c
+ JOIN waterwolf.web_users u on c.creator = u.id
+ WHERE c.location=:location
+ AND u.banned != 1
+ ORDER BY c.id DESC
+ SQL,
+ [
+ 'location' => $location,
+ ]
+ );
+
+ return $response->withJson($comments);
+ }
+
+ public function postAction(
+ ServerRequest $request,
+ Response $response,
+ array $params
+ ): ResponseInterface {
+ $location = $params['location'] ?? null;
+ if (empty($location)) {
+ throw new \InvalidArgumentException('No comment location provided.');
+ }
+
+ $currentUser = $request->getCurrentUser();
+ assert($currentUser !== null);
+
+ $postData = $request->getParsedBody();
+
+ $this->db->insert(
+ 'web_comments',
+ [
+ 'comment' => $postData['comment'],
+ 'location' => $location,
+ 'creator' => $currentUser['id'],
+ 'tstamp' => time(),
+ ]
+ );
+
+ return $response->withJson([
+ 'success' => true,
+ ]);
+ }
+
+ public function deleteAction(
+ ServerRequest $request,
+ Response $response,
+ array $params
+ ): ResponseInterface {
+ $location = $params['location'] ?? null;
+ if (empty($location)) {
+ throw new \InvalidArgumentException('No comment location provided.');
+ }
+
+ $id = $request->getParam('id');
+ if (empty($id)) {
+ throw new \InvalidArgumentException('No ID provided.');
+ }
+
+ $this->db->delete(
+ 'web_comments',
+ [
+ 'id' => $id,
+ 'location' => $location,
+ ]
+ );
+
+ return $response->withJson([
+ 'success' => true,
+ ]);
+ }
+}
diff --git a/backend/src/Controller/Api/JsonAction.php b/backend/src/Controller/Api/JsonAction.php
new file mode 100644
index 0000000..d90bbc7
--- /dev/null
+++ b/backend/src/Controller/Api/JsonAction.php
@@ -0,0 +1,39 @@
+db->fetchOne(
+ <<<'SQL'
+ SELECT count(*) AS user_count
+ FROM web_users
+ SQL
+ );
+
+ return $response->withJson([
+ 'success' => true,
+ 'total_users' => $userCount,
+ ]);
+ } catch (Exception $e) {
+ return $response->withJson([
+ 'success' => false,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+}
diff --git a/backend/src/Controller/Api/VrcApiAction.php b/backend/src/Controller/Api/VrcApiAction.php
new file mode 100644
index 0000000..7cbd3bb
--- /dev/null
+++ b/backend/src/Controller/Api/VrcApiAction.php
@@ -0,0 +1,40 @@
+getBody()->getContents();
+ $request = json_decode($postdata, true);
+
+ if ($request['type'] == 'notification') {
+ $notification = $request['content'];
+ $notification_type = $notification['type'];
+
+ if ($notification_type == 'friendRequest') {
+ $notification_id = $notification['id'];
+
+ $this->vrcApi->sendRequest(
+ method: 'PUT',
+ path: "api/1/auth/user/notifications/$notification_id/accept",
+ priority: true,
+ async: true
+ );
+ }
+ }
+
+ return $response->withStatus(200);
+ }
+}
diff --git a/backend/src/Controller/Dashboard/Admin/AddWorldAction.php b/backend/src/Controller/Dashboard/Admin/AddWorldAction.php
new file mode 100644
index 0000000..95eb5c5
--- /dev/null
+++ b/backend/src/Controller/Dashboard/Admin/AddWorldAction.php
@@ -0,0 +1,108 @@
+getCurrentUser();
+ assert($currentUser !== null);
+
+ $error = null;
+
+ if ($request->isPost()) {
+ try {
+ $worldId = $request->getParam('id');
+ if (empty($worldId)) {
+ throw new \InvalidArgumentException('World ID not specified.');
+ }
+
+ // Get the URL from the form submission
+ $url = 'https://vrchat.com/home/launch?worldId=' . $worldId;
+
+ // Convert JSON data to an associative array
+ $body = $this->http->get($url)
+ ->getBody()->getContents();
+
+ // Regular expression pattern to find Twitter meta tags and their attributes
+ $pattern = '/ $property) {
+ $content = $matches[2][$index];
+ $worldData[$property] = htmlspecialchars_decode($content);
+ }
+
+ $worldTitleFull = preg_replace('/[^\w\s.]/', '', substr($worldData['title'], 0, 255));
+ $worldDescription = substr($worldData['description'], 0, 255);
+
+ // Split the text using the word "by"
+ $titleParts = explode(" by ", $worldTitleFull);
+
+ $worldTitle = str_replace(' ', ' ', trim($titleParts[0]));
+ $worldDbTitle = str_replace(' ', '_', $worldTitle);
+ $worldCreator = trim($titleParts[1]);
+
+ // Pull the world image
+ $imageData = (str_starts_with($worldData['image'], 'http'))
+ ? $this->http->get($worldData['image'])->getBody()->getContents()
+ : $worldData['image'];
+
+ $imageRelativePath = '/img/worlds/' . $worldDbTitle . '.png';
+ $imagePath = mediaPath($imageRelativePath);
+
+ $this->imageManager->read($imageData)->save($imagePath);
+
+ // Add the DB record
+ $this->db->insert(
+ 'web_worlds',
+ [
+ 'title' => $worldTitle,
+ 'creator' => $currentUser['id'],
+ 'image' => $imageRelativePath,
+ 'description' => $worldDescription,
+ 'world_id' => $worldId,
+ 'world_creator' => $worldCreator,
+ ]
+ );
+
+ $worldDbId = $this->db->lastInsertId();
+
+ $request->getFlash()->success('World successfully imported!');
+
+ return $response->withRedirect(
+ $request->getRouter()->urlFor('world', ['id' => $worldDbId])
+ );
+ } catch (\Throwable $e) {
+ $error = $e->getMessage();
+ }
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'dashboard/admin/add_world',
+ [
+ 'error' => $error,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/Dashboard/Admin/UsersAction.php b/backend/src/Controller/Dashboard/Admin/UsersAction.php
new file mode 100644
index 0000000..98717e1
--- /dev/null
+++ b/backend/src/Controller/Dashboard/Admin/UsersAction.php
@@ -0,0 +1,38 @@
+db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_users
+ ORDER BY id ASC
+ SQL
+ );
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'dashboard/admin/users',
+ [
+ 'users' => $users,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/Dashboard/AvatarAction.php b/backend/src/Controller/Dashboard/AvatarAction.php
new file mode 100644
index 0000000..e63fe3f
--- /dev/null
+++ b/backend/src/Controller/Dashboard/AvatarAction.php
@@ -0,0 +1,95 @@
+getCurrentUser();
+ assert($currentUser !== null);
+
+ $type = $params['type'] ?? $request->getParam('type', 'avatar');
+ $avatarField = match ($type) {
+ 'dj' => 'dj_img',
+ default => 'user_img'
+ };
+
+ $uploadFolder = match ($type) {
+ 'dj' => '/img/djs',
+ default => '/img/profile'
+ };
+
+ if ($request->isPost()) {
+ $files = $request->getUploadedFiles();
+
+ if (empty($files['file'])) {
+ throw new \InvalidArgumentException('File not uploaded!');
+ }
+
+ /** @var UploadedFile $uploadedFile */
+ $uploadedFile = $files['file'];
+
+ // Remove existing image if it's set.
+ if (!empty($currentUser[$avatarField])) {
+ $currentImage = mediaPath($uploadFolder . '/' . $currentUser[$avatarField]);
+ if (file_exists($currentImage)) {
+ $this->fsUtilities->remove($currentImage);
+ }
+ }
+
+ $imageFileType = strtolower(pathinfo($uploadedFile->getClientFilename(), PATHINFO_EXTENSION));
+ $imageBasename = $currentUser['id'] . '-' . time() . '.' . $imageFileType;
+
+ $targetFile = mediaPath($uploadFolder . '/' . $imageBasename);
+
+ // Resize the uploaded image and save it to the destination location.
+ $image = $this->imageManager->read($uploadedFile->getStream()->getContents());
+ $image->cover(332, 364);
+ $image->save($targetFile);
+
+ // Set the user's image location to the new image.
+ $this->db->update(
+ 'web_users',
+ [
+ $avatarField => $imageBasename,
+ ],
+ [
+ 'id' => $currentUser['id'],
+ ]
+ );
+
+ $request->getFlash()->success('Avatar updated!');
+
+ return $response->withRedirect(
+ $request->getRouter()->urlFor('dashboard:profile')
+ );
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'dashboard/avatar',
+ [
+ 'type' => $type,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/Dashboard/DmxController.php b/backend/src/Controller/Dashboard/DmxController.php
new file mode 100644
index 0000000..aa58442
--- /dev/null
+++ b/backend/src/Controller/Dashboard/DmxController.php
@@ -0,0 +1,126 @@
+getCurrentUser();
+ assert($currentUser !== null);
+
+ $isTeam = $currentUser->isTeam() || $currentUser->isMod();
+ $editRow = null;
+
+ if ($isTeam && $request->isPost()) {
+ $postData = $request->getParams();
+
+ // Insert
+ if (isset($postData['add_fixture'])) {
+ $this->db->insert(
+ 'web_dmx_fixtures',
+ [
+ 'fixture_name' => $postData['fixture_name'] ?? null,
+ 'universe_number' => $postData['universe_number'] ?? null,
+ 'channel_number' => $postData['channel_number'] ?? null,
+ 'rig_name' => $postData['rig_name'] ?? null,
+ ]
+ );
+ }
+
+ // Update
+ if (isset($postData['update_id'])) {
+ $this->db->update(
+ 'web_dmx_fixtures',
+ [
+ 'fixture_name' => $postData['update_fixture_name'] ?? null,
+ 'universe_number' => $postData['update_universe_number'] ?? null,
+ 'channel_number' => $postData['update_channel_number'] ?? null,
+ 'rig_name' => $postData['update_rig_name'] ?? null,
+ ],
+ [
+ 'id' => $postData['update_id'],
+ ]
+ );
+ }
+
+ // Delete
+ if (isset($postData['delete_id'])) {
+ $this->db->delete(
+ 'web_dmx_fixtures',
+ [
+ 'id' => $postData['delete_id'],
+ ]
+ );
+ }
+
+ if (isset($postData['edit_id'])) {
+ $editRow = $this->db->fetchAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_dmx_fixtures
+ WHERE id = :id
+ SQL,
+ [
+ 'id' => $postData['edit_id'],
+ ]
+ );
+ }
+ }
+
+ $selectedRigName = $request->getParam('rig_name');
+
+ if (!empty($selectedRigName)) {
+ $fixtures = $this->db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_dmx_fixtures
+ WHERE rig_name = :name
+ SQL,
+ [
+ 'name' => $selectedRigName,
+ ]
+ );
+ } else {
+ $fixtures = $this->db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_dmx_fixtures
+ SQL
+ );
+ }
+
+ $rigLookupRaw = $this->db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT DISTINCT rig_name
+ FROM web_dmx_fixtures
+ SQL
+ );
+ $rigLookup = array_column($rigLookupRaw, 'rig_name');
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'dashboard/dmx',
+ [
+ 'isTeam' => $isTeam,
+ 'editRow' => $editRow,
+ 'fixtures' => $fixtures,
+ 'rigLookup' => $rigLookup,
+ 'selectedRigName' => $selectedRigName,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/Dashboard/EditProfileAction.php b/backend/src/Controller/Dashboard/EditProfileAction.php
new file mode 100644
index 0000000..537ab6e
--- /dev/null
+++ b/backend/src/Controller/Dashboard/EditProfileAction.php
@@ -0,0 +1,204 @@
+getCurrentUser();
+ assert($currentUser !== null);
+
+ $editUserId = $params['id'] ?? $request->getParam('id');
+
+ if (!empty($editUserId) && $currentUser->isAdmin()) {
+ $isAdminMode = true;
+
+ $groupsRaw = $this->db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT g.id, g.name
+ FROM web_groups AS g
+ SQL
+ );
+ $groups = array_column($groupsRaw, 'name', 'id');
+ } else {
+ $isAdminMode = false;
+ $editUserId = $currentUser['id'];
+
+ $groups = [];
+ }
+
+ $profile = $this->db->fetchAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_users
+ WHERE id=:id
+ SQL,
+ [
+ 'id' => $editUserId,
+ ]
+ );
+
+ if ($profile === false) {
+ throw NotFoundException::user($request);
+ }
+
+ $userGroupsRaw = $this->db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT uhg.group_id
+ FROM web_user_has_group AS uhg
+ WHERE uhg.user_id = :id
+ SQL,
+ [
+ 'id' => $editUserId,
+ ]
+ );
+ $userGroups = array_column($userGroupsRaw, 'group_id', 'group_id');
+
+ $error = null;
+
+ if ($request->isPost()) {
+ try {
+ $postData = $request->getParsedBody();
+
+ $updateFields = [
+ 'username' => $postData['username'],
+ 'email' => $postData['email'],
+ 'discord' => $postData['discord'] ?? null,
+ 'twitch' => $postData['twitch'] ?? null,
+ 'vrchat' => $postData['vrchat'] ?? null,
+ 'vrcdn' => $postData['vrcdn'] ?? null,
+ 'website' => $postData['website'] ?? null,
+ 'aboutme' => $postData['aboutme'] ?? null,
+ 'pronouns' => $postData['pronouns'] ?? null,
+ 'dj_name' => $postData['dj_name'] ?? null,
+ 'dj_genre' => $postData['dj_genre'] ?? null,
+ ];
+
+ if ($updateFields['email'] !== $profile['email']) {
+ if (empty($updateFields['email'])) {
+ throw new \InvalidArgumentException('E-mail address is required.');
+ }
+
+ // Check if the new e-mail is a duplicate.
+ $checkEmail = $this->db->fetchOne(
+ <<<'SQL'
+ SELECT username
+ FROM web_users
+ WHERE LOWER(email) = LOWER(:email)
+ AND id != :id
+ SQL,
+ [
+ 'email' => $updateFields['email'],
+ 'id' => $editUserId,
+ ]
+ );
+
+ if ($checkEmail !== false) {
+ throw new \InvalidArgumentException('E-mail address is already in use by another user.');
+ }
+ }
+
+ if ($updateFields['username'] !== $profile['username']) {
+ if (empty($updateFields['username'])) {
+ throw new \InvalidArgumentException('Username is required.');
+ }
+
+ // Check if the new username is a duplicate.
+ $checkUsername = $this->db->fetchOne(
+ <<<'SQL'
+ SELECT username
+ FROM web_users
+ WHERE LOWER(username) = LOWER(:username)
+ AND id != :id
+ SQL,
+ [
+ 'username' => $updateFields['username'],
+ 'id' => $editUserId,
+ ]
+ );
+
+ if ($checkUsername !== false) {
+ throw new \InvalidArgumentException('Username is already in use by another user.');
+ }
+ }
+
+ if ($profile['is_team'] === 1) {
+ $updateFields['title'] = $postData['title'] ?? null;
+ }
+
+ if ($isAdminMode) {
+ $updateFields = [
+ ...$updateFields,
+ 'banned' => $postData['banned'] ?? 0,
+ 'is_team' => $postData['is_team'] ?? 0,
+ 'is_admin' => $postData['is_admin'] ?? 0,
+ 'is_mod' => $postData['is_mod'] ?? 0,
+ 'is_dj' => $postData['is_dj'] ?? 0,
+ ];
+
+ $this->db->delete(
+ 'web_user_has_group',
+ [
+ 'user_id' => $editUserId,
+ ]
+ );
+
+ foreach ((array)($postData['groups'] ?? []) as $groupId) {
+ if (isset($groups[$groupId])) {
+ $this->db->insert(
+ 'web_user_has_group',
+ [
+ 'user_id' => $editUserId,
+ 'group_id' => $groupId,
+ ]
+ );
+ }
+ }
+ }
+
+ $this->db->update(
+ 'web_users',
+ $updateFields,
+ [
+ 'id' => $editUserId,
+ ]
+ );
+
+ $request->getFlash()->success('Profile updated!');
+
+ return $response->withRedirect(
+ (string)$request->getUri()
+ );
+ } catch (\Throwable $e) {
+ $error = $e->getMessage();
+ }
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'dashboard/profile',
+ [
+ 'profile' => $profile,
+ 'isAdminMode' => $isAdminMode,
+ 'groups' => $groups,
+ 'userGroups' => $userGroups,
+ 'error' => $error,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/Dashboard/PasswordAction.php b/backend/src/Controller/Dashboard/PasswordAction.php
new file mode 100644
index 0000000..92f44c2
--- /dev/null
+++ b/backend/src/Controller/Dashboard/PasswordAction.php
@@ -0,0 +1,81 @@
+getCurrentUser();
+ assert($currentUser !== null);
+
+ $error = null;
+
+ if ($request->isPost()) {
+ try {
+ $postData = $request->getParsedBody();
+
+ $currentPassword = $postData['current_password'] ?? null;
+ $newPassword = $postData['new_password'] ?? null;
+ $newPasswordConfirm = $postData['new_password_confirm'] ?? null;
+
+ if (empty($currentPassword) || empty($newPassword) || empty($newPasswordConfirm)) {
+ throw new \InvalidArgumentException('Please provide all required fields.');
+ }
+
+ if ($newPassword !== $newPasswordConfirm) {
+ throw new \InvalidArgumentException('New password and confirmation do not match.');
+ }
+
+ if (!password_verify($currentPassword, $currentUser['password'])) {
+ throw new \InvalidArgumentException('Current password is not valid.');
+ }
+
+ $newPasswordHash = password_hash($newPassword, \PASSWORD_ARGON2ID);
+
+ $this->db->update(
+ 'web_users',
+ [
+ 'password' => $newPasswordHash,
+ ],
+ [
+ 'id' => $currentUser['id'],
+ ]
+ );
+
+ $session = $request->getSession();
+ $session->clear();
+ $session->regenerate();
+
+ $request->getFlash()->success('Password reset! Please log in again.');
+
+ return $response->withRedirect(
+ $request->getRouter()->urlFor('login')
+ );
+ } catch (\Throwable $e) {
+ $error = $e->getMessage();
+ }
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'dashboard/password',
+ [
+ 'error' => $error,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/Dashboard/PostersController.php b/backend/src/Controller/Dashboard/PostersController.php
new file mode 100644
index 0000000..18ac462
--- /dev/null
+++ b/backend/src/Controller/Dashboard/PostersController.php
@@ -0,0 +1,418 @@
+getCurrentUser();
+ assert($currentUser !== null);
+
+ $types = $this->getTypes();
+
+ $qb = $this->db->createQueryBuilder()
+ ->select('p.*, u.username')
+ ->from('web_posters', 'p')
+ ->join('p', 'web_users', 'u', 'p.creator = u.id')
+ ->where('u.banned != 1')
+ ->orderBy('p.id DESC');
+
+ $groupLookup = $this->getEditableGroups($request);
+
+ if (!$currentUser->isMod()) {
+ $qb->andWhere('(p.group_id IS NULL) OR (p.group_id IN (:groups))')
+ ->setParameter('user_id', $currentUser['id'])
+ ->setParameter('groups', array_keys($groupLookup), ArrayParameterType::STRING);
+ }
+
+ $groups = [
+ '_mine' => [
+ 'name' => 'My Posters',
+ 'canEdit' => true,
+ 'posters' => [],
+ ],
+ '_community' => [
+ 'name' => 'Community Posters',
+ 'canEdit' => $currentUser->isMod(),
+ 'posters' => [],
+ ],
+ ];
+
+ foreach ($groupLookup as $groupId => $groupName) {
+ $groups[$groupId] = [
+ 'name' => $groupName,
+ 'code' => $groupId,
+ 'canEdit' => true,
+ 'posters' => [],
+ ];
+ }
+
+ $nowDt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
+
+ foreach ($qb->fetchAllAssociative() as $poster) {
+ // Get poster URL
+ $tryMediaUrls = [
+ '/img/posters/' . urlencode($poster['file']) . '_thumb.jpg',
+ '/img/posters/' . urlencode($poster['file']) . '_150x200.jpeg',
+ ];
+
+ $mediaUrl = '/static/img/no_poster_thumb.jpg';
+ foreach ($tryMediaUrls as $tryMediaUrl) {
+ $mediaPath = mediaPath($tryMediaUrl);
+ if (file_exists($mediaPath)) {
+ $mediaUrl = mediaUrl($tryMediaUrl);
+ break;
+ }
+ }
+ $poster['mediaUrl'] = $mediaUrl;
+
+ // Calculate poster expiration.
+ if ($poster['expires_at']) {
+ $expiresAt = new \DateTimeImmutable($poster['expires_at'], new \DateTimeZone('UTC'));
+
+ $poster['isExpired'] = $expiresAt < $nowDt;
+ $poster['expiresAtText'] = $expiresAt->format('F j, Y g:ia');
+ } else {
+ $poster['isExpired'] = false;
+ $poster['expiresAtText'] = null;
+ }
+
+ // Assign poster to the correct group
+ if (!empty($poster['group_id'])) {
+ $groups[$poster['group_id']]['posters'][] = $poster;
+ } elseif ($poster['creator'] === $currentUser['id']) {
+ $groups['_mine']['posters'][] = $poster;
+ } else {
+ $groups['_community']['posters'][] = $poster;
+ }
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'dashboard/posters/list',
+ [
+ 'types' => $types,
+ 'groups' => $groups,
+ ]
+ );
+ }
+
+ public function createAction(
+ ServerRequest $request,
+ Response $response,
+ array $params
+ ): ResponseInterface {
+ $currentUser = $request->getCurrentUser();
+ assert($currentUser !== null);
+
+ $row = [];
+ $error = null;
+
+ $groups = $this->getEditableGroups($request);
+ $types = $this->getTypes();
+
+ if ($request->isPost()) {
+ try {
+ $files = $request->getUploadedFiles();
+ if (empty($files['fileToUpload'])) {
+ throw new \InvalidArgumentException('No poster uploaded.');
+ }
+
+ /** @var UploadedFileInterface $file */
+ $file = $files['fileToUpload'];
+
+ $basename = $this->generateAssets($file);
+
+ $row['creator'] = $currentUser['id'];
+ $row['file'] = $basename;
+
+ $postData = $request->getParsedBody();
+
+ $inputType = $postData['type'] ?? null;
+ if (!empty($inputType) && isset($types[$inputType])) {
+ $row['type_id'] = $inputType;
+ }
+
+ $inputGroup = $postData['group'] ?? null;
+ if (!empty($inputGroup) && isset($groups[$inputGroup])) {
+ $row['group_id'] = $inputGroup;
+ }
+
+ $inputCollection = $postData['collection'] ?? null;
+ if (!empty($inputCollection)) {
+ $row['collection'] = $inputCollection;
+ }
+
+ $inputExpiresAt = $postData['expires_at'] ?? null;
+ if (!empty($inputExpiresAt)) {
+ $dt = new \DateTimeImmutable($inputExpiresAt, new \DateTimeZone('UTC'));
+ $row['expires_at'] = $dt->format('Y-m-d H:i:s');
+ }
+
+ $this->db->insert('web_posters', $row);
+
+ $request->getFlash()->success('Poster uploaded.');
+ return $response->withRedirect(
+ $request->getRouter()->urlFor('dashboard:posters')
+ );
+ } catch (\Throwable $e) {
+ $error = $e->getMessage();
+ }
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'dashboard/posters/edit',
+ [
+ 'isEditMode' => false,
+ 'row' => $row,
+ 'error' => $error,
+ 'groups' => $groups,
+ 'types' => $types,
+ ]
+ );
+ }
+
+ public function editAction(
+ ServerRequest $request,
+ Response $response,
+ array $params
+ ): ResponseInterface {
+ $id = $params['id'] ?? $request->getParam('pid');
+ $row = $this->getEditablePoster($request, $id);
+
+ $groups = $this->getEditableGroups($request);
+ $types = $this->getTypes();
+
+ $error = null;
+
+ if ($request->isPost()) {
+ try {
+ $files = $request->getUploadedFiles();
+ if (isset($files['fileToUpload'])) {
+ /** @var UploadedFileInterface $file */
+ $file = $files['fileToUpload'];
+
+ if ($file->getError() === UPLOAD_ERR_OK) {
+ $this->deleteAssets($row['file']);
+ $row['file'] = $this->generateAssets($files['fileToUpload']);
+ }
+ }
+
+ $postData = $request->getParsedBody();
+
+ $inputType = $postData['type'] ?? null;
+ $row['type_id'] = (!empty($inputType) && isset($types[$inputType]))
+ ? $inputType
+ : null;
+
+ $inputGroup = $postData['group'] ?? null;
+ $row['group_id'] = (!empty($inputGroup) && isset($groups[$inputGroup]))
+ ? $inputGroup
+ : null;
+
+ $inputCollection = $postData['collection'] ?? null;
+ $row['collection'] = (!empty($inputCollection))
+ ? $inputCollection
+ : null;
+
+ $inputExpiresAt = $postData['expires_at'] ?? null;
+ if (!empty($inputExpiresAt)) {
+ $dt = new \DateTimeImmutable($inputExpiresAt, new \DateTimeZone('UTC'));
+ $row['expires_at'] = $dt->format('Y-m-d H:i:s');
+ } else {
+ $row['expires_at'] = null;
+ }
+
+ $id = $row['id'];
+ unset($row['id']);
+
+ $this->db->update(
+ 'web_posters',
+ $row,
+ [
+ 'id' => $id,
+ ]
+ );
+
+ $request->getFlash()->success('Poster updated.');
+
+ return $response->withRedirect(
+ $request->getRouter()->urlFor('dashboard:posters')
+ );
+ } catch (\Throwable $e) {
+ $error = $e->getMessage();
+ }
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'dashboard/posters/edit',
+ [
+ 'isEditMode' => true,
+ 'row' => $row,
+ 'error' => $error,
+ 'groups' => $groups,
+ 'types' => $types,
+ ]
+ );
+ }
+
+ public function deleteAction(
+ ServerRequest $request,
+ Response $response,
+ array $params
+ ): ResponseInterface {
+ $id = $params['id'] ?? $request->getParam('pid');
+ $row = $this->getEditablePoster($request, $id);
+
+ $this->deleteAssets($row['file']);
+
+ $this->db->delete(
+ 'web_posters',
+ [
+ 'id' => $row['id'],
+ ]
+ );
+
+ $request->getFlash()->success('Poster removed.');
+ return $response->withRedirect(
+ $request->getRouter()->urlFor('dashboard:posters')
+ );
+ }
+
+ private function getEditableGroups(ServerRequest $request): array
+ {
+ $currentUser = $request->getCurrentUser();
+ assert($currentUser !== null);
+
+ if ($currentUser->isMod()) {
+ $groupsRaw = $this->db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT g.id, g.name
+ FROM web_groups AS g
+ SQL
+ );
+ } else {
+ $groupsRaw = $this->db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT g.id, g.name
+ FROM web_groups AS g
+ JOIN web_user_has_group AS uhg ON g.id = uhg.group_id
+ WHERE uhg.user_id = :id
+ SQL,
+ [
+ 'id' => $currentUser['id'],
+ ]
+ );
+ }
+
+ return array_column($groupsRaw, 'name', 'id');
+ }
+
+ private function getTypes(): array
+ {
+ $typesRaw = $this->db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT id, description
+ FROM web_poster_types
+ SQL
+ );
+
+ return array_column($typesRaw, 'description', 'id');
+ }
+
+ private function getEditablePoster(
+ ServerRequest $request,
+ int|null $id
+ ): array {
+ if ($id === null) {
+ throw NotFoundException::poster($request);
+ }
+
+ $currentUser = $request->getCurrentUser();
+ assert($currentUser !== null);
+
+ $qb = $this->db->createQueryBuilder()
+ ->select('p.*')
+ ->from('web_posters', 'p')
+ ->where('id = :id')
+ ->setParameter('id', $id);
+
+ if (!$currentUser->isMod()) {
+ $groups = $this->getEditableGroups($request);
+
+ $qb->andWhere('(p.group_id IS NULL AND p.creator = :user_id) OR (p.group_id IN (:groups))')
+ ->setParameter('user_id', $currentUser['id'])
+ ->setParameter('groups', array_keys($groups), ArrayParameterType::STRING);
+ }
+
+ $row = $qb->fetchAssociative();
+
+ if ($row === false) {
+ throw NotFoundException::poster($request);
+ }
+
+ return $row;
+ }
+
+ private function deleteAssets(string $posterFile): void
+ {
+ $sizes = [
+ '_thumb.jpg',
+ '_full.jpg',
+ '_300x500.jpeg',
+ '_150x200.jpeg',
+ '_590x1000.jpeg',
+ ];
+
+ foreach ($sizes as $size) {
+ $filePath = mediaPath('/img/posters/' . $posterFile . $size);
+ if (file_exists($filePath)) {
+ unlink($filePath);
+ }
+ }
+ }
+
+ private function generateAssets(UploadedFileInterface $filePath): string
+ {
+ $image = $this->imageManager->read($filePath->getStream()->getContents());
+
+ // Create a random 12 digit hash for file name
+ $basename = bin2hex(random_bytes(6)); // 6 bytes = 12 hex characters
+
+ $sizes = [
+ [118, 200, $basename . '_thumb.jpg'],
+ [590, 1000, $basename . '_full.jpg'],
+ ];
+
+ foreach ($sizes as [$width, $height, $filename]) {
+ $thumbnail = clone $image;
+ $thumbnail->cover($width, $height);
+
+ $destPath = mediaPath('/img/posters/' . $filename);
+ $thumbnail->save($destPath);
+ }
+
+ return $basename;
+ }
+}
diff --git a/backend/src/Controller/Dashboard/ShortUrlsController.php b/backend/src/Controller/Dashboard/ShortUrlsController.php
new file mode 100644
index 0000000..fb9a9a0
--- /dev/null
+++ b/backend/src/Controller/Dashboard/ShortUrlsController.php
@@ -0,0 +1,182 @@
+db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_short_urls
+ ORDER BY short_url ASC
+ SQL
+ );
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'dashboard/short_urls/list',
+ [
+ 'shortUrls' => $shortUrls,
+ ]
+ );
+ }
+
+ public function createAction(
+ ServerRequest $request,
+ Response $response,
+ array $params
+ ): ResponseInterface {
+ $currentUser = $request->getCurrentUser();
+ assert($currentUser !== null);
+
+ $row = [];
+ $error = null;
+
+ if ($request->isPost()) {
+ try {
+ $postData = $request->getParsedBody();
+ $row['short_url'] = trim($postData['short_url'] ?? '', '/');
+ $row['long_url'] = $postData['long_url'] ?? null;
+
+ if (empty($row['short_url']) || empty($row['long_url'])) {
+ throw new \InvalidArgumentException('Short and Long URL are required.');
+ }
+
+ $this->db->insert(
+ 'web_short_urls',
+ [
+ 'short_url' => $row['short_url'],
+ 'long_url' => $row['long_url'],
+ 'creator' => $currentUser['id'],
+ ]
+ );
+
+ $request->getFlash()->success('Short URL created.');
+
+ return $response->withRedirect(
+ $request->getRouter()->urlFor('dashboard:short_urls')
+ );
+ } catch (\Throwable $e) {
+ $error = $e->getMessage();
+ }
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'dashboard/short_urls/edit',
+ [
+ 'isEditMode' => false,
+ 'row' => $row,
+ 'error' => $error,
+ ]
+ );
+ }
+
+ public function editAction(
+ ServerRequest $request,
+ Response $response,
+ array $params
+ ): ResponseInterface {
+ $id = $params['id'] ?? $request->getParam('id');
+ if (empty($id)) {
+ throw new \InvalidArgumentException('ID is required.');
+ }
+
+ $row = $this->db->fetchAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_short_urls
+ WHERE id = :id
+ SQL,
+ [
+ 'id' => $id,
+ ]
+ );
+
+ if ($row === false) {
+ throw new \InvalidArgumentException('Record not found!');
+ }
+
+ $error = null;
+
+ if ($request->isPost()) {
+ try {
+ $postData = $request->getParsedBody();
+ $row['short_url'] = trim($postData['short_url'] ?? '', '/');
+ $row['long_url'] = $postData['long_url'] ?? null;
+
+ if (empty($row['short_url']) || empty($row['long_url'])) {
+ throw new \InvalidArgumentException('Short and Long URL are required.');
+ }
+
+ $this->db->update(
+ 'web_short_urls',
+ [
+ 'short_url' => $row['short_url'],
+ 'long_url' => $row['long_url'],
+ ],
+ [
+ 'id' => $row['id'],
+ ]
+ );
+
+ $request->getFlash()->success('Short URL updated.');
+
+ return $response->withRedirect(
+ $request->getRouter()->urlFor('dashboard:short_urls')
+ );
+ } catch (\Throwable $e) {
+ $error = $e->getMessage();
+ }
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'dashboard/short_urls/edit',
+ [
+ 'isEditMode' => true,
+ 'row' => $row,
+ 'error' => $error,
+ ]
+ );
+ }
+
+ public function deleteAction(
+ ServerRequest $request,
+ Response $response,
+ array $params
+ ): ResponseInterface {
+ $id = $params['id'] ?? $request->getParam('id');
+ if (empty($id)) {
+ throw new \InvalidArgumentException('ID is required.');
+ }
+
+ $this->db->delete(
+ 'web_short_urls',
+ [
+ 'id' => $id,
+ ]
+ );
+
+ $request->getFlash()->success('Short URL deleted.');
+
+ return $response->withRedirect(
+ $request->getRouter()->urlFor('dashboard:short_urls')
+ );
+ }
+}
diff --git a/backend/src/Controller/Dashboard/SkillsController.php b/backend/src/Controller/Dashboard/SkillsController.php
new file mode 100644
index 0000000..3028289
--- /dev/null
+++ b/backend/src/Controller/Dashboard/SkillsController.php
@@ -0,0 +1,122 @@
+getCurrentUser();
+ assert($currentUser !== null);
+
+ $userId = $currentUser['id'];
+
+ $editRow = null;
+
+ if ($request->isPost()) {
+ $postData = $request->getParsedBody();
+
+ if (isset($postData['skill'])) {
+ $add_skill = $postData['skill'];
+
+ $existingSkill = $this->db->fetchOne(
+ <<<'SQL'
+ SELECT COUNT(*)
+ FROM web_user_skills
+ WHERE skill = :skill
+ AND creator = :creator
+ SQL,
+ [
+ 'skill' => $add_skill,
+ 'creator' => $userId,
+ ]
+ );
+
+ if ($existingSkill == 0) {
+ $this->db->insert(
+ 'web_user_skills',
+ [
+ 'skill' => $add_skill,
+ 'creator' => $userId,
+ ]
+ );
+ }
+ }
+
+ if (isset($postData['delete_id'])) {
+ $this->db->delete(
+ 'web_user_skills',
+ [
+ 'id' => $postData['delete_id'],
+ 'creator' => $userId,
+ ]
+ );
+ }
+
+ // Check for edit request
+ if (isset($postData['edit_id'])) {
+ $editRow = $this->db->fetchAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_user_skills
+ WHERE id = :id AND creator = :creator
+ SQL,
+ [
+ 'id' => $postData['edit_id'],
+ 'creator' => $userId,
+ ]
+ );
+ }
+ }
+
+ $myTalents = $this->db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_user_skills
+ WHERE creator=:creator
+ ORDER BY id DESC
+ SQL,
+ [
+ 'creator' => $userId,
+ ]
+ );
+
+ $communityTalents = $this->db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT skill, COUNT(*) as occurrences
+ FROM web_user_skills
+ GROUP BY skill
+ SQL
+ );
+
+ // Build a lookup for the autocomplete.
+ $skillLookup = [];
+ foreach ($communityTalents as $row) {
+ $skillLookup[] = $row['skill'];
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'dashboard/skills',
+ [
+ 'editRow' => $editRow,
+ 'myTalents' => $myTalents,
+ 'communityTalents' => $communityTalents,
+ 'skillLookup' => $skillLookup,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/GetShortUrlAction.php b/backend/src/Controller/GetShortUrlAction.php
new file mode 100644
index 0000000..522bcf1
--- /dev/null
+++ b/backend/src/Controller/GetShortUrlAction.php
@@ -0,0 +1,61 @@
+getParam('url', ''), '/');
+
+ if (empty($url)) {
+ return $response->withRedirect('/');
+ }
+
+ $shortUrl = $this->db->fetchAssociative(
+ <<<'SQL'
+ SELECT id, long_url
+ FROM web_short_urls
+ WHERE short_url = :url
+ SQL,
+ [
+ 'url' => $url,
+ ]
+ );
+
+ if (false === $shortUrl) {
+ return $response->withRedirect(
+ $request->getUri()->withQuery('')->withPath('/' . $url)
+ );
+ }
+
+ $this->db->executeQuery(
+ <<<'SQL'
+ UPDATE web_short_urls
+ SET views=views+1
+ WHERE id = :id
+ SQL,
+ [
+ 'id' => $shortUrl['id'],
+ ]
+ );
+
+ return $response->withRedirect($shortUrl['long_url']);
+ }
+}
diff --git a/backend/src/Controller/Posters/GetFaqAction.php b/backend/src/Controller/Posters/GetFaqAction.php
new file mode 100644
index 0000000..7a929ae
--- /dev/null
+++ b/backend/src/Controller/Posters/GetFaqAction.php
@@ -0,0 +1,48 @@
+getRouter()->fullUrlFor(
+ $request->getUri(),
+ 'posters'
+ );
+
+ $groups = $this->db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_groups
+ SQL
+ );
+
+ $types = $this->db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_poster_types
+ SQL
+ );
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'posters/faq',
+ [
+ 'baseUrl' => $baseUrl,
+ 'groups' => $groups,
+ 'types' => $types,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/Posters/GetPosterAction.php b/backend/src/Controller/Posters/GetPosterAction.php
new file mode 100644
index 0000000..e9a7bda
--- /dev/null
+++ b/backend/src/Controller/Posters/GetPosterAction.php
@@ -0,0 +1,151 @@
+getParam('pid', $request->getParam('id'));
+
+ $qb = $this->db->createQueryBuilder()
+ ->select('p.id', 'p.file')
+ ->from('web_posters', 'p')
+ ->join('p', 'web_users', 'u', 'p.creator = u.id')
+ ->where('u.banned != 1')
+ ->andWhere('(p.expires_at IS NULL OR p.expires_at > CURRENT_TIMESTAMP())');
+
+ $post = null;
+
+ if (!empty($posterId)) {
+ // Given an ID, just load the specified poster directly.
+ $qb->andWhere('p.id = :id')
+ ->setParameter('id', $posterId)
+ ->setMaxResults(1);
+
+ $post = $qb->fetchAssociative();
+ } else {
+ /*
+ * Apply filters to the query or cache. Any filters can be chained.
+ */
+
+ $filters = [];
+
+ $queryNickname = $request->getParam('collection');
+ if (!empty($queryNickname)) {
+ $qb->andWhere('LOWER(p.collection) = LOWER(:collection)')
+ ->setParameter('collection', $queryNickname);
+
+ $filters[] = 'collection:' . $queryNickname;
+ }
+
+ $queryType = $request->getParam('type');
+ if (!empty($queryType)) {
+ $qb->andWhere('p.type_id = :type')
+ ->setParameter('type', $queryType);
+
+ $filters[] = 'type:' . $queryType;
+ }
+
+ $queryGroup = $request->getParam('group');
+ if (!empty($queryGroup)) {
+ $qb->andWhere('p.group_id = :group')
+ ->setParameter('group', $queryGroup);
+
+ $filters[] = 'group:' . $queryGroup;
+ }
+
+ /*
+ * Cache a shuffled list of posters to avoid serving duplicate posters to people
+ * loading multiple posters in the same world. VRC imposes a delay of 5 seconds
+ * between image loads, so the cache lifetime has to exceed that.
+ */
+
+ $cacheKey = (count($filters) > 0)
+ ? 'posters_' . $request->getIp() . '_' . md5(implode('_', $filters))
+ : 'posters_' . $request->getIp() . '_all';
+
+ $cacheKey = str_replace(':', '.', $cacheKey);
+
+ if ($this->cache->has($cacheKey)) {
+ $shuffleQueue = (array)$this->cache->get($cacheKey);
+
+ if (count($shuffleQueue) > 0) {
+ $post = array_pop($shuffleQueue);
+ $post['cache'] = 'hit';
+
+ if (empty($shuffleQueue)) {
+ $this->cache->delete($cacheKey);
+ } else {
+ $this->cache->set($cacheKey, $shuffleQueue, 15);
+ }
+ }
+ }
+
+ if ($post === null) {
+ $shuffleQueue = $qb
+ ->orderBy('RAND()')
+ ->setMaxResults(30)
+ ->fetchAllAssociative();
+
+ if (!empty($shuffleQueue)) {
+ $post = array_pop($shuffleQueue);
+ $post['cache'] = 'miss';
+
+ if (!empty($shuffleQueue)) {
+ $this->cache->set($cacheKey, $shuffleQueue, 15);
+ }
+ }
+ }
+ }
+
+ $imagePath = $this->environment->getBaseDirectory() . '/web/static/img/no_poster.jpg';
+
+ if (!empty($post)) {
+ $tryPaths = [
+ '/img/posters/' . $post['file'] . '_full.jpg',
+ '/img/posters/' . $post['file'] . '_590x1000.jpeg',
+ ];
+
+ foreach ($tryPaths as $tryPath) {
+ $postPath = mediaPath($tryPath);
+ if (file_exists($postPath)) {
+ $imagePath = $postPath;
+ break;
+ }
+ }
+
+ // Update view count
+ $this->db->executeQuery(
+ <<<'SQL'
+ UPDATE web_posters
+ SET views=views+1, last_viewed = UNIX_TIMESTAMP()
+ WHERE id = :id
+ SQL,
+ [
+ 'id' => $post['id'],
+ ]
+ );
+ }
+
+ return $response->renderFile($imagePath)
+ ->withHeader('Content-Disposition', 'inline');
+ }
+}
diff --git a/backend/src/Controller/ProfileAction.php b/backend/src/Controller/ProfileAction.php
new file mode 100644
index 0000000..960f6e9
--- /dev/null
+++ b/backend/src/Controller/ProfileAction.php
@@ -0,0 +1,50 @@
+getParam('user', $request->getParam('id'));
+
+ $profile = $this->db->fetchAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_users
+ WHERE username = :username
+ AND banned != 1
+ LIMIT 1
+ SQL,
+ [
+ 'username' => $username,
+ ]
+ );
+
+ if ($profile === false) {
+ throw NotFoundException::user($request);
+ }
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'profile',
+ [
+ 'profile' => $profile,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/TalentAction.php b/backend/src/Controller/TalentAction.php
new file mode 100644
index 0000000..1f34902
--- /dev/null
+++ b/backend/src/Controller/TalentAction.php
@@ -0,0 +1,40 @@
+db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT s.skill, COUNT(s.id) as occurrences
+ FROM web_user_skills s
+ JOIN web_users u ON s.creator = u.id
+ WHERE u.banned != 1
+ GROUP BY skill
+ SQL
+ );
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'talent',
+ [
+ 'skills' => $skills,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/TeamAction.php b/backend/src/Controller/TeamAction.php
new file mode 100644
index 0000000..0560c46
--- /dev/null
+++ b/backend/src/Controller/TeamAction.php
@@ -0,0 +1,40 @@
+db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT id, username, title, pronouns, user_img
+ FROM web_users
+ WHERE is_team = 1
+ AND banned != 1
+ ORDER BY id ASC
+ SQL
+ );
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'team',
+ [
+ 'team_members' => $teamMembers,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Controller/WorldsController.php b/backend/src/Controller/WorldsController.php
new file mode 100644
index 0000000..943a572
--- /dev/null
+++ b/backend/src/Controller/WorldsController.php
@@ -0,0 +1,93 @@
+db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_worlds
+ ORDER BY id DESC
+ SQL
+ );
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'worlds',
+ [
+ 'worlds' => $worlds,
+ ]
+ );
+ }
+
+ public function getAction(
+ ServerRequest $request,
+ Response $response,
+ array $params
+ ): ResponseInterface {
+ $id = $params['id'] ?? $request->getParam('v', $request->getParam('id'));
+
+ $world = $this->db->fetchAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_worlds
+ WHERE id = :id
+ LIMIT 1
+ SQL,
+ [
+ 'id' => $id,
+ ]
+ );
+
+ if ($world === false) {
+ throw NotFoundException::world($request);
+ }
+
+ // Log visit to this page.
+ $this->db->executeQuery(
+ <<<'SQL'
+ UPDATE web_worlds
+ SET hits=hits+1
+ WHERE id = :id
+ SQL,
+ [
+ 'id' => $world['id'],
+ ]
+ );
+
+ $sidebar_worlds = $this->db->fetchAllAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_worlds
+ ORDER BY RAND()
+ LIMIT 15
+ SQL
+ );
+
+ return $request->getView()->renderToResponse(
+ $response,
+ 'world',
+ [
+ 'world' => $world,
+ 'sidebar_worlds' => $sidebar_worlds,
+ ]
+ );
+ }
+}
diff --git a/backend/src/Entity/User.php b/backend/src/Entity/User.php
new file mode 100644
index 0000000..de37a9f
--- /dev/null
+++ b/backend/src/Entity/User.php
@@ -0,0 +1,67 @@
+
+ */
+final class User implements ArrayAccess
+{
+ public function __construct(
+ private readonly array $data
+ ) {
+ }
+
+ public function isAdmin(): bool
+ {
+ return $this->data['is_admin'] === 1;
+ }
+
+ public function isMod(bool $strict = false): bool
+ {
+ if (!$strict && $this->isAdmin()) {
+ return true;
+ }
+
+ return $this->data['is_mod'] === 1;
+ }
+
+ public function isTeam(): bool
+ {
+ return $this->data['is_team'] === 1;
+ }
+
+ public function isDj(): bool
+ {
+ return $this->data['is_dj'] === 1;
+ }
+
+ public function toArray(): array
+ {
+ return $this->data;
+ }
+
+ public function offsetExists(mixed $offset): bool
+ {
+ return isset($this->data[$offset]);
+ }
+
+ public function offsetGet(mixed $offset): mixed
+ {
+ return $this->data[$offset] ?? null;
+ }
+
+ public function offsetSet(mixed $offset, mixed $value): void
+ {
+ throw new \LogicException('Cannot modify user object.');
+ }
+
+ public function offsetUnset(mixed $offset): void
+ {
+ throw new \LogicException('Cannot modify user object.');
+ }
+}
diff --git a/backend/src/Environment.php b/backend/src/Environment.php
new file mode 100644
index 0000000..4e84c08
--- /dev/null
+++ b/backend/src/Environment.php
@@ -0,0 +1,117 @@
+data[self::APPLICATION_ENV] ?? 'production';
+ }
+
+ public function isProduction(): bool
+ {
+ return $this->getApplicationEnv() === 'production';
+ }
+
+ public function isDev(): bool
+ {
+ return $this->getApplicationEnv() !== 'production';
+ }
+
+ public function isCli(): bool
+ {
+ return ('cli' === PHP_SAPI);
+ }
+
+ public function getBaseDirectory(): string
+ {
+ return dirname(__DIR__, 2);
+ }
+
+ public function getParentDirectory(): string
+ {
+ return dirname($this->getBaseDirectory());
+ }
+
+ public function getTempDirectory(): string
+ {
+ return $this->getParentDirectory() . '/www_tmp';
+ }
+
+ public function getDatabaseInfo(): array
+ {
+ return [
+ 'host' => $this->data[self::DB_HOST],
+ 'user' => $this->data[self::DB_USER],
+ 'password' => $this->data[self::DB_PASS],
+ 'dbname' => $this->data[self::DB_NAME],
+ ];
+ }
+
+ public function getMailerDsn(): string
+ {
+ return $this->data[self::MAILER_DSN]
+ ?? throw new \RuntimeException('Mailer not configured.');
+ }
+
+ public function getMediaUrl(): string
+ {
+ return $this->isDev()
+ ? '/media/site'
+ : $this->data[self::MEDIA_SITE_URL];
+ }
+
+ public function getMediaPath(): string
+ {
+ return $this->isDev()
+ ? $this->getBaseDirectory() . '/web/media/site'
+ : $this->data[self::PHP_MEDIA_PATH];
+ }
+
+ public function getVrcApiKey(): string
+ {
+ return $this->data[self::VRCHAT_API_KEY]
+ ?? throw new \RuntimeException('VRChat API key not configured.');
+ }
+
+ public function getDiscordWebookUrl(): string
+ {
+ return $this->data[self::DISCORD_WEBHOOK_URL]
+ ?? throw new \RuntimeException('Discord webhook URL not configured.');
+ }
+
+ public static function getInstance(): Environment
+ {
+ return self::$instance;
+ }
+
+ public static function setInstance(Environment $instance): void
+ {
+ self::$instance = $instance;
+ }
+}
diff --git a/backend/src/Exception/NotFoundException.php b/backend/src/Exception/NotFoundException.php
new file mode 100644
index 0000000..469a872
--- /dev/null
+++ b/backend/src/Exception/NotFoundException.php
@@ -0,0 +1,34 @@
+getCallableResolver(), $app->getResponseFactory(), $logger);
+ }
+
+ public function __invoke(
+ ServerRequestInterface $request,
+ Throwable $exception,
+ bool $displayErrorDetails,
+ bool $logErrors,
+ bool $logErrorDetails
+ ): ResponseInterface {
+ $this->showDetailed = $this->environment->isDev() && !($exception instanceof HttpException);
+
+ if ($exception instanceof HttpException) {
+ $this->loggerLevel = Level::Info;
+ }
+
+ $this->returnJson = $this->shouldReturnJson($request);
+
+ return parent::__invoke($request, $exception, $displayErrorDetails, $logErrors, $logErrorDetails);
+ }
+
+ private function shouldReturnJson(ServerRequestInterface $req): bool
+ {
+ $xhr = $req->getHeaderLine('X-Requested-With') === 'XMLHttpRequest';
+
+ if ($xhr || $this->environment->isCli()) {
+ return true;
+ }
+
+ if ($req->hasHeader('Accept')) {
+ $accept = $req->getHeaderLine('Accept');
+ if (false !== stripos($accept, 'application/json')) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function writeToErrorLog(): void
+ {
+ $context = [
+ 'file' => $this->exception->getFile(),
+ 'line' => $this->exception->getLine(),
+ 'code' => $this->exception->getCode(),
+ ];
+
+ if ($this->showDetailed) {
+ $context['trace'] = array_slice($this->exception->getTrace(), 0, 5);
+ }
+
+ $this->logger->log($this->loggerLevel, $this->exception->getMessage(), $context);
+ }
+
+ protected function respond(): ResponseInterface
+ {
+ if (!($this->request instanceof ServerRequest)) {
+ return parent::respond();
+ }
+
+ /** @var Response $response */
+ $response = $this->responseFactory->createResponse($this->statusCode);
+
+ // Special handling for cURL requests.
+ $ua = $this->request->getHeaderLine('User-Agent');
+
+ if (false !== stripos($ua, 'curl')) {
+ $response->getBody()->write(
+ sprintf(
+ 'Error: %s on %s L%s',
+ $this->exception->getMessage(),
+ $this->exception->getFile(),
+ $this->exception->getLine()
+ )
+ );
+
+ return $response;
+ }
+
+ if ($this->returnJson) {
+ return $response->withJson([
+ 'code' => $this->exception->getCode(),
+ 'type' => (new \ReflectionClass($this->exception))->getShortName(),
+ 'message' => $this->exception->getMessage(),
+ ]);
+ }
+
+ // Redirect to login page for not-logged-in users.
+ if ($this->exception instanceof NotLoggedInException) {
+ // Inject the session for subsequent handlers.
+ $sessionPersistence = $this->injectSession->getSessionPersistence();
+
+ /** @var Session $session */
+ $session = $sessionPersistence->initializeSessionFromRequest($this->request);
+
+ $flash = new Flash($session);
+ $flash->error($this->exception->getMessage());
+
+ // Set referrer for login redirection.
+ $session->set('login_referrer', $this->request->getUri()->getPath());
+
+ return $sessionPersistence->persistSession(
+ $session,
+ $response->withRedirect('/login?return=' . $this->request->getUri()->getPath())
+ );
+ }
+
+ // Bounce back to homepage for permission-denied users.
+ if ($this->exception instanceof PermissionDeniedException) {
+ // Inject the session for subsequent handlers.
+ $sessionPersistence = $this->injectSession->getSessionPersistence();
+
+ /** @var Session $session */
+ $session = $sessionPersistence->initializeSessionFromRequest($this->request);
+
+ $flash = new Flash($session);
+ $flash->error($this->exception->getMessage());
+
+ return $sessionPersistence->persistSession(
+ $session,
+ $response->withRedirect('/')
+ );
+ }
+
+ if ($this->showDetailed && class_exists(Run::class)) {
+ // Register error-handler.
+ $handler = new PrettyPageHandler();
+ $handler->setPageTitle('An error occurred!');
+
+ $run = new Run();
+ $run->prependHandler($handler);
+
+ return $response->write($run->handleException($this->exception));
+ }
+
+ try {
+ $view = $this->view->setRequest($this->request);
+
+ return $view->renderToResponse(
+ $response,
+ ($this->exception instanceof HttpException)
+ ? 'errors/http'
+ : 'errors/generic',
+ [
+ 'exception' => $this->exception,
+ ]
+ );
+ } catch (Throwable) {
+ return parent::respond();
+ }
+ }
+}
diff --git a/backend/src/Http/HttpFactory.php b/backend/src/Http/HttpFactory.php
new file mode 100644
index 0000000..f5045b8
--- /dev/null
+++ b/backend/src/Http/HttpFactory.php
@@ -0,0 +1,90 @@
+httpFactory = new GuzzleHttpFactory();
+ }
+
+ public function createUploadedFile(...$args): UploadedFileInterface
+ {
+ return $this->httpFactory->createUploadedFile(...$args);
+ }
+
+ public function createStream(string $content = ''): StreamInterface
+ {
+ return $this->httpFactory->createStream($content);
+ }
+
+ public function createStreamFromFile(...$args): StreamInterface
+ {
+ return $this->httpFactory->createStreamFromFile(...$args);
+ }
+
+ public function createStreamFromResource($resource): StreamInterface
+ {
+ return $this->httpFactory->createStreamFromResource($resource);
+ }
+
+ public function createServerRequest(...$args): ServerRequestInterface
+ {
+ $serverRequest = $this->httpFactory->createServerRequest(...$args);
+ return $this->decorateServerRequest($serverRequest);
+ }
+
+ public function createServerRequestFromGlobals(): ServerRequestInterface
+ {
+ return $this->decorateServerRequest(GuzzleServerRequest::fromGlobals());
+ }
+
+ private function decorateServerRequest(ServerRequestInterface $request): ServerRequestInterface
+ {
+ return new ServerRequest($request);
+ }
+
+ public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface
+ {
+ $response = $this->httpFactory->createResponse($code, $reasonPhrase);
+ return new Response($response, $this->httpFactory);
+ }
+
+ public function createRequest(string $method, $uri): RequestInterface
+ {
+ return $this->httpFactory->createRequest($method, $uri);
+ }
+
+ public function createUri(string $uri = ''): UriInterface
+ {
+ return $this->httpFactory->createUri($uri);
+ }
+}
diff --git a/backend/src/Http/Response.php b/backend/src/Http/Response.php
new file mode 100644
index 0000000..da5e64a
--- /dev/null
+++ b/backend/src/Http/Response.php
@@ -0,0 +1,165 @@
+response
+ ->withHeader('Pragma', 'no-cache')
+ ->withHeader('Expires', gmdate('D, d M Y H:i:s \G\M\T', 0))
+ ->withHeader('Cache-Control', 'private, no-cache, no-store')
+ ->withHeader('X-Accel-Expires', '0'); // CloudFlare
+
+ return new static($response, $this->streamFactory);
+ }
+
+ /**
+ * Send headers that expire the content in the specified number of seconds.
+ *
+ * @param int $seconds
+ *
+ * @return static
+ */
+ public function withCacheLifetime(int $seconds = self::CACHE_ONE_MONTH): static
+ {
+ $response = $this->response
+ ->withHeader('Pragma', '')
+ ->withHeader('Expires', gmdate('D, d M Y H:i:s \G\M\T', time() + $seconds))
+ ->withHeader('Cache-Control', 'public, must-revalidate, max-age=' . $seconds)
+ ->withHeader('X-Accel-Expires', (string)$seconds); // CloudFlare
+
+ return new static($response, $this->streamFactory);
+ }
+
+ /**
+ * Returns whether the request has a "cache lifetime" assigned to it.
+ */
+ public function hasCacheLifetime(): bool
+ {
+ if ($this->response->hasHeader('Pragma')) {
+ return (!str_contains($this->response->getHeaderLine('Pragma'), 'no-cache'));
+ }
+
+ return (!str_contains($this->response->getHeaderLine('Cache-Control'), 'no-cache'));
+ }
+
+ /**
+ * Don't escape forward slashes by default on JSON responses.
+ *
+ * @param mixed $data
+ * @param int|null $status
+ * @param int $options
+ * @param int $depth
+ */
+ public function withJson($data, ?int $status = null, int $options = 0, int $depth = 512): ResponseInterface
+ {
+ $options |= JSON_UNESCAPED_SLASHES;
+ $options |= JSON_UNESCAPED_UNICODE;
+
+ return parent::withJson($data, $status, $options, $depth);
+ }
+
+ /**
+ * Stream the contents of a file directly through to the response.
+ *
+ * @param string $file_path
+ * @param null $file_name
+ *
+ * @return static
+ */
+ public function renderFile(string $file_path, $file_name = null): static
+ {
+ set_time_limit(600);
+
+ if (null === $file_name) {
+ $file_name = basename($file_path);
+ }
+
+ $stream = $this->streamFactory->createStreamFromFile($file_path);
+
+ $response = $this->response
+ ->withHeader('Pragma', 'public')
+ ->withHeader('Expires', '0')
+ ->withHeader('Cache-Control', 'must-revalidate, post-check=0, pre-check=0')
+ ->withHeader('Content-Type', mime_content_type($file_path) ?: '')
+ ->withHeader('Content-Length', (string)filesize($file_path))
+ ->withHeader('Content-Disposition', 'attachment; filename=' . $file_name)
+ ->withBody($stream);
+
+ return new static($response, $this->streamFactory);
+ }
+
+ /**
+ * Write a string of file data to the response as if it is a file for download.
+ *
+ * @param string $file_data
+ * @param string $content_type
+ * @param string|null $file_name
+ *
+ * @return static
+ */
+ public function renderStringAsFile(string $file_data, string $content_type, ?string $file_name = null): static
+ {
+ $response = $this->response
+ ->withHeader('Pragma', 'public')
+ ->withHeader('Expires', '0')
+ ->withHeader('Cache-Control', 'must-revalidate, post-check=0, pre-check=0')
+ ->withHeader('Content-Type', $content_type);
+
+ if ($file_name !== null) {
+ $response = $response->withHeader('Content-Disposition', 'attachment; filename=' . $file_name);
+ }
+
+ $response->getBody()->write($file_data);
+
+ return new static($response, $this->streamFactory);
+ }
+
+ /**
+ * Write a stream to the response as if it is a file for download.
+ *
+ * @param StreamInterface $fileStream
+ * @param string $contentType
+ * @param string|null $fileName
+ *
+ * @return static
+ */
+ public function renderStreamAsFile(
+ StreamInterface $fileStream,
+ string $contentType,
+ ?string $fileName = null
+ ): static {
+ set_time_limit(600);
+
+ $response = $this->response
+ ->withHeader('Pragma', 'public')
+ ->withHeader('Expires', '0')
+ ->withHeader('Cache-Control', 'must-revalidate, post-check=0, pre-check=0')
+ ->withHeader('Content-Type', $contentType);
+
+ if ($fileName !== null) {
+ $response = $response->withHeader('Content-Disposition', 'attachment; filename=' . $fileName);
+ }
+
+ $response = $response->withBody($fileStream);
+
+ return new static($response, $this->streamFactory);
+ }
+}
diff --git a/backend/src/Http/ServerRequest.php b/backend/src/Http/ServerRequest.php
new file mode 100644
index 0000000..40815e2
--- /dev/null
+++ b/backend/src/Http/ServerRequest.php
@@ -0,0 +1,103 @@
+getAttribute(self::ATTR_IP, 'UNKNOWN');
+ }
+
+ public function getView(): View
+ {
+ return $this->getAttributeOfClass(self::ATTR_VIEW, View::class);
+ }
+
+ public function getSession(): SessionInterface
+ {
+ return $this->getAttributeOfClass(self::ATTR_SESSION, SessionInterface::class);
+ }
+
+ public function getCsrf(): Csrf
+ {
+ return $this->getAttributeOfClass(self::ATTR_SESSION_CSRF, Csrf::class);
+ }
+
+ public function getFlash(): Flash
+ {
+ return $this->getAttributeOfClass(self::ATTR_SESSION_FLASH, Flash::class);
+ }
+
+ public function getRouter(): RouteParserInterface
+ {
+ return $this->getAttributeOfClass(RouteContext::ROUTE_PARSER, RouteParserInterface::class);
+ }
+
+ public function getRoute(): ?RouteInterface
+ {
+ return $this->getAttribute(RouteContext::ROUTE);
+ }
+
+ public function getCurrentUser(): ?User
+ {
+ return $this->getAttribute(self::ATTR_CURRENT_USER);
+ }
+
+ public function isLoggedIn(): bool
+ {
+ $currentUser = $this->getCurrentUser();
+ return null !== $currentUser;
+ }
+
+ /**
+ * @param string $attr
+ * @param string $class_name
+ *
+ * @throws InvalidArgumentException
+ */
+ private function getAttributeOfClass(string $attr, string $class_name): mixed
+ {
+ $object = $this->serverRequest->getAttribute($attr);
+
+ if (empty($object)) {
+ throw new InvalidArgumentException(
+ sprintf(
+ 'Attribute "%s" is required and is empty in this request',
+ $attr
+ )
+ );
+ }
+
+ if (!($object instanceof $class_name)) {
+ throw new InvalidArgumentException(
+ sprintf(
+ 'Attribute "%s" must be of type "%s".',
+ $attr,
+ $class_name
+ )
+ );
+ }
+
+ return $object;
+ }
+}
diff --git a/backend/src/Middleware/Auth/RequireAdmin.php b/backend/src/Middleware/Auth/RequireAdmin.php
new file mode 100644
index 0000000..33b45a1
--- /dev/null
+++ b/backend/src/Middleware/Auth/RequireAdmin.php
@@ -0,0 +1,35 @@
+getAttribute(ServerRequest::ATTR_CURRENT_USER);
+
+ if ($user === null) {
+ throw new NotLoggedInException($request);
+ }
+
+ if (!$user->isAdmin()) {
+ throw new PermissionDeniedException($request);
+ }
+
+ return $handler->handle($request);
+ }
+}
diff --git a/backend/src/Middleware/Auth/RequireLoggedIn.php b/backend/src/Middleware/Auth/RequireLoggedIn.php
new file mode 100644
index 0000000..9bb94d7
--- /dev/null
+++ b/backend/src/Middleware/Auth/RequireLoggedIn.php
@@ -0,0 +1,28 @@
+getAttribute(ServerRequest::ATTR_CURRENT_USER);
+
+ if ($user === null) {
+ throw new NotLoggedInException($request);
+ }
+
+ return $handler->handle($request);
+ }
+}
diff --git a/backend/src/Middleware/Auth/RequireMod.php b/backend/src/Middleware/Auth/RequireMod.php
new file mode 100644
index 0000000..1f40e6b
--- /dev/null
+++ b/backend/src/Middleware/Auth/RequireMod.php
@@ -0,0 +1,35 @@
+getAttribute(ServerRequest::ATTR_CURRENT_USER);
+
+ if ($user === null) {
+ throw new NotLoggedInException($request);
+ }
+
+ if (!$user->isMod()) {
+ throw new PermissionDeniedException($request);
+ }
+
+ return $handler->handle($request);
+ }
+}
diff --git a/backend/src/Middleware/EnableSession.php b/backend/src/Middleware/EnableSession.php
new file mode 100644
index 0000000..4d6cf1f
--- /dev/null
+++ b/backend/src/Middleware/EnableSession.php
@@ -0,0 +1,77 @@
+environment->isCli()) {
+ $psr6Cache = new ArrayAdapter();
+ }
+
+ $this->cachePool = new ProxyAdapter($psr6Cache, 'session.');
+ }
+
+ public function getSessionPersistence(): CacheSessionPersistence
+ {
+ return new CacheSessionPersistence(
+ cache: $this->cachePool,
+ cookieName: 'app_session',
+ cookiePath: '/',
+ cacheLimiter: 'nocache',
+ cacheExpire: 43200,
+ lastModified: time(),
+ persistent: true,
+ cookieSecure: $this->environment->isProduction(),
+ cookieHttpOnly: true
+ );
+ }
+
+ public function injectSession(
+ SessionInterface $session,
+ ServerRequestInterface $request
+ ): ServerRequestInterface {
+ $csrf = new Csrf($session);
+ $flash = new Flash($session);
+
+ return $request->withAttribute(ServerRequest::ATTR_SESSION, $session)
+ ->withAttribute(ServerRequest::ATTR_SESSION_CSRF, $csrf)
+ ->withAttribute(ServerRequest::ATTR_SESSION_FLASH, $flash);
+ }
+
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+ {
+ $sessionPersistence = $this->getSessionPersistence();
+ $session = new LazySession($sessionPersistence, $request);
+
+ $request = $this->injectSession($session, $request);
+ $response = $handler->handle($request);
+
+ return $sessionPersistence->persistSession($session, $response);
+ }
+}
diff --git a/backend/src/Middleware/EnableView.php b/backend/src/Middleware/EnableView.php
new file mode 100644
index 0000000..3d51fb8
--- /dev/null
+++ b/backend/src/Middleware/EnableView.php
@@ -0,0 +1,37 @@
+view->setRequest($request);
+
+ $request = $request->withAttribute(
+ ServerRequest::ATTR_VIEW,
+ $view
+ );
+
+ return $handler->handle($request);
+ }
+}
diff --git a/backend/src/Middleware/GetCurrentUser.php b/backend/src/Middleware/GetCurrentUser.php
new file mode 100644
index 0000000..1eda6f5
--- /dev/null
+++ b/backend/src/Middleware/GetCurrentUser.php
@@ -0,0 +1,88 @@
+db = $di->get(Connection::class);
+ }
+
+ public function process(
+ ServerRequestInterface $request,
+ RequestHandlerInterface $handler
+ ): ResponseInterface {
+ $session = $request->getAttribute(ServerRequest::ATTR_SESSION);
+
+ if (!($session instanceof SessionInterface)) {
+ throw new \InvalidArgumentException('User check is before session init.');
+ }
+
+ if ($session->has('valid_id')) {
+ $uid = $session->get('valid_id');
+
+ $row = $this->db->fetchAssociative(
+ <<<'SQL'
+ SELECT *
+ FROM web_users
+ WHERE id = :id
+ SQL,
+ [
+ 'id' => $uid,
+ ]
+ );
+
+ // Check for banned status on all page loads.
+ if ($row === false || $row['banned'] === 1) {
+ $session->clear();
+ $session->regenerate();
+
+ return (new HttpFactory())
+ ->createResponse(302)
+ ->withHeader('Location', '/');
+ }
+
+ $session->set('valid_time', time());
+
+ $currentUser = new User($row);
+
+ $this->db->update(
+ 'web_users',
+ [
+ 'lastactive' => time(),
+ 'online' => '1',
+ 'lastip' => $request->getAttribute(ServerRequest::ATTR_IP, 'UNKNOWN'),
+ ],
+ [
+ 'id' => $uid,
+ ]
+ );
+ } else {
+ $currentUser = null;
+ }
+
+ $request = $request->withAttribute(ServerRequest::ATTR_CURRENT_USER, $currentUser);
+
+ return $handler->handle($request);
+ }
+}
diff --git a/backend/src/Middleware/GetRemoteIp.php b/backend/src/Middleware/GetRemoteIp.php
new file mode 100644
index 0000000..df74ac2
--- /dev/null
+++ b/backend/src/Middleware/GetRemoteIp.php
@@ -0,0 +1,34 @@
+withAttribute(
+ ServerRequest::ATTR_IP,
+ $ip
+ );
+
+ return $handler->handle($request);
+ }
+}
diff --git a/backend/src/Middleware/RemoveSlashes.php b/backend/src/Middleware/RemoveSlashes.php
new file mode 100644
index 0000000..bba9cfb
--- /dev/null
+++ b/backend/src/Middleware/RemoveSlashes.php
@@ -0,0 +1,43 @@
+getUri();
+ $path = $uri->getPath();
+
+ if ($path !== '/') {
+ if (str_ends_with($path, '/')) {
+ while (str_ends_with($path, '/')) {
+ $path = substr($path, 0, -1);
+ }
+ }
+
+ if (str_ends_with($path, '.php')) {
+ $path = substr($path, 0, -4);
+ }
+
+ $uri = $uri->withPath($path);
+ return $handler->handle(
+ $request->withUri($uri)
+ );
+ }
+
+ return $handler->handle($request);
+ }
+}
diff --git a/backend/src/Service/Discord.php b/backend/src/Service/Discord.php
new file mode 100644
index 0000000..2ee52ea
--- /dev/null
+++ b/backend/src/Service/Discord.php
@@ -0,0 +1,58 @@
+logger->debug(
+ 'Sending Discord message...',
+ func_get_args()
+ );
+
+ if (!$this->environment->isProduction()) {
+ return '';
+ }
+
+ $data = [
+ 'content' => $message,
+ 'embeds' => [
+ [
+ 'title' => $title_set,
+ 'description' => $desc,
+ 'color' => $color,
+ 'thumbnail' => [
+ 'url' => $img_url,
+ ],
+ ],
+ ],
+ ];
+
+ $response = $this->http->post(
+ $webhookUrl,
+ [
+ 'json' => $data,
+ ]
+ );
+
+ return $response->getBody()->getContents();
+ }
+}
diff --git a/backend/src/Service/VrcApi.php b/backend/src/Service/VrcApi.php
new file mode 100644
index 0000000..38d7de2
--- /dev/null
+++ b/backend/src/Service/VrcApi.php
@@ -0,0 +1,69 @@
+ [
+ 'Authorization' => $this->environment->getVrcApiKey(),
+ ],
+ ];
+
+ if ($priority) {
+ $requestConfig['headers']['X-Priority'] = 'high';
+ }
+ if ($async) {
+ $requestConfig['headers']['X-Background'] = '1';
+ }
+
+ if ($body !== null) {
+ $requestConfig['json'] = $body;
+ }
+
+ $response = $this->http->request(
+ $method,
+ $uri,
+ $requestConfig
+ );
+
+ if ($response->getHeaderLine('Content-Type') === 'application/json') {
+ return json_decode(
+ $response->getBody()->getContents(),
+ true,
+ JSON_THROW_ON_ERROR
+ );
+ } else {
+ return $response->getBody()->getContents();
+ }
+ }
+}
diff --git a/backend/src/Session/Csrf.php b/backend/src/Session/Csrf.php
new file mode 100644
index 0000000..a764e39
--- /dev/null
+++ b/backend/src/Session/Csrf.php
@@ -0,0 +1,95 @@
+_csrf_lifetime.
+ *
+ * @param string $namespace
+ */
+ public function generate(string $namespace = self::DEFAULT_NAMESPACE): string
+ {
+ $sessionKey = $this->getSessionIdentifier($namespace);
+ if ($this->session->has($sessionKey)) {
+ $csrf = $this->session->get($sessionKey);
+ if (null !== $csrf) {
+ return $csrf;
+ }
+ }
+
+ $key = $this->randomString();
+ $this->session->set($sessionKey, $key);
+
+ return $key;
+ }
+
+ /**
+ * Verify a supplied CSRF token against the tokens stored in the session.
+ *
+ * @param string $key
+ * @param string $namespace
+ */
+ public function verify(string $key, string $namespace = self::DEFAULT_NAMESPACE): void
+ {
+ if (empty($key)) {
+ throw new \InvalidArgumentException('A CSRF token is required for this request.');
+ }
+
+ if (strlen($key) !== self::CODE_LENGTH) {
+ throw new \InvalidArgumentException('Malformed CSRF token supplied.');
+ }
+
+ $sessionIdentifier = $this->getSessionIdentifier($namespace);
+ if (!$this->session->has($sessionIdentifier)) {
+ throw new \InvalidArgumentException('No CSRF token supplied for this namespace.');
+ }
+
+ $sessionKey = $this->session->get($sessionIdentifier);
+ if (0 !== strcmp($key, $sessionKey)) {
+ throw new \InvalidArgumentException('Invalid CSRF token supplied.');
+ }
+ }
+
+ /**
+ * Generates a random string of given $length.
+ *
+ * @param int $length The string length.
+ *
+ * @return string The randomly generated string.
+ */
+ private function randomString(int $length = self::CODE_LENGTH): string
+ {
+ $seed = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijqlmnopqrtsuvwxyz0123456789';
+ $max = strlen($seed) - 1;
+
+ $string = '';
+ for ($i = 0; $i < $length; ++$i) {
+ $string .= $seed[random_int(0, $max)];
+ }
+
+ return $string;
+ }
+
+ private function getSessionIdentifier(string $namespace): string
+ {
+ return 'csrf_' . $namespace;
+ }
+}
diff --git a/backend/src/Session/Flash.php b/backend/src/Session/Flash.php
new file mode 100644
index 0000000..cf32623
--- /dev/null
+++ b/backend/src/Session/Flash.php
@@ -0,0 +1,102 @@
+addMessage($message, FlashLevels::Success, $saveInSession);
+ }
+
+ public function warning(
+ string $message,
+ bool $saveInSession = true
+ ): void {
+ $this->addMessage($message, FlashLevels::Warning, $saveInSession);
+ }
+
+ public function error(
+ string $message,
+ bool $saveInSession = true
+ ): void {
+ $this->addMessage($message, FlashLevels::Error, $saveInSession);
+ }
+
+ public function info(
+ string $message,
+ bool $saveInSession = true
+ ): void {
+ $this->addMessage($message, FlashLevels::Info, $saveInSession);
+ }
+
+ public function addMessage(
+ string $message,
+ FlashLevels $level = FlashLevels::Info,
+ bool $saveInSession = true
+ ): void {
+ $messageRow = [
+ 'text' => $message,
+ 'color' => $level->value,
+ ];
+
+ $this->getMessages();
+ $this->messages[] = $messageRow;
+
+ if ($saveInSession) {
+ $messages = (array)$this->session->get(self::SESSION_KEY);
+ $messages[] = $messageRow;
+
+ $this->session->set(self::SESSION_KEY, $messages);
+ }
+ }
+
+ /**
+ * Indicate whether messages are currently pending display.
+ */
+ public function hasMessages(): bool
+ {
+ $messages = $this->getMessages();
+ return (count($messages) > 0);
+ }
+
+ /**
+ * Return all messages, removing them from the internal storage in the process.
+ *
+ * @return array
+ */
+ public function getMessages(): array
+ {
+ if (null === $this->messages) {
+ if ($this->session->has(self::SESSION_KEY)) {
+ $this->messages = (array)$this->session->get(self::SESSION_KEY);
+ $this->session->unset(self::SESSION_KEY);
+ } else {
+ $this->messages = [];
+ }
+ }
+
+ return $this->messages;
+ }
+}
diff --git a/backend/src/Session/FlashLevels.php b/backend/src/Session/FlashLevels.php
new file mode 100644
index 0000000..3bedc88
--- /dev/null
+++ b/backend/src/Session/FlashLevels.php
@@ -0,0 +1,13 @@
+sections = new GlobalSections();
+
+ $this->addFolder('layouts', dirname(__DIR__) . '/templates/layouts');
+
+ $this->addData(
+ [
+ 'sections' => $this->sections,
+ 'environment' => $environment,
+ 'router' => $router,
+ ]
+ );
+ }
+
+ public function setRequest(ServerRequestInterface $request): self
+ {
+ $view = clone $this;
+
+ $view->addData([
+ 'request' => $request,
+ ]);
+
+ if ($request instanceof ServerRequest) {
+ $view->addData([
+ 'route' => $request->getAttribute(RouteContext::ROUTE),
+ 'session' => $request->getAttribute(ServerRequest::ATTR_SESSION),
+ 'csrf' => $request->getAttribute(ServerRequest::ATTR_SESSION_CSRF),
+ 'flash' => $request->getAttribute(ServerRequest::ATTR_SESSION_FLASH),
+ 'user' => $request->getCurrentUser(),
+ 'is_logged_in' => $request->isLoggedIn(),
+ ]);
+ }
+
+ return $view;
+ }
+
+ public function reset(): void
+ {
+ $this->sections = new GlobalSections();
+ $this->data = new Data();
+ }
+
+ /**
+ * @param string $name
+ * @param array $data
+ */
+ public function fetch(string $name, array $data = []): string
+ {
+ return $this->render($name, $data);
+ }
+
+ /**
+ * Trigger rendering of template and write it directly to the PSR-7 compatible Response object.
+ *
+ * @param ResponseInterface $response
+ * @param string $templateName
+ * @param array $templateArgs
+ */
+ public function renderToResponse(
+ ResponseInterface $response,
+ string $templateName,
+ array $templateArgs = []
+ ): ResponseInterface {
+ $response->getBody()->write(
+ $this->render($templateName, $templateArgs)
+ );
+ return $response->withHeader('Content-type', 'text/html; charset=utf-8');
+ }
+
+ public static function staticPage(
+ string $templateName,
+ array $templateArgs = []
+ ): callable {
+ return function (
+ ServerRequest $request,
+ Response $response
+ ) use (
+ $templateName,
+ $templateArgs
+ ): ResponseInterface {
+ return $request->getView()->renderToResponse(
+ $response,
+ $templateName,
+ $templateArgs
+ );
+ };
+ }
+}
diff --git a/backend/src/View/GlobalSections.php b/backend/src/View/GlobalSections.php
new file mode 100644
index 0000000..3bbae51
--- /dev/null
+++ b/backend/src/View/GlobalSections.php
@@ -0,0 +1,115 @@
+
+ */
+final class GlobalSections implements ArrayAccess
+{
+ private int $sectionMode = Template::SECTION_MODE_REWRITE;
+ private array $sections = [];
+ private ?string $sectionName = null;
+
+ public function has(string $section): bool
+ {
+ return !empty($this->sections[$section]);
+ }
+
+ public function offsetExists(mixed $offset): bool
+ {
+ return $this->has((string)$offset);
+ }
+
+ public function get(string $section, ?string $default = null): ?string
+ {
+ return $this->sections[$section] ?? $default;
+ }
+
+ public function offsetGet(mixed $offset): mixed
+ {
+ return $this->get((string)$offset);
+ }
+
+ public function unset(string $section): void
+ {
+ unset($this->sections[$section]);
+ }
+
+ public function offsetUnset(mixed $offset): void
+ {
+ $this->unset((string)$offset);
+ }
+
+ public function set(
+ string $section,
+ ?string $value,
+ int $mode = Template::SECTION_MODE_REWRITE
+ ): void {
+ $initialValue = $this->sections[$section] ?? '';
+
+ $this->sections[$section] = match ($mode) {
+ Template::SECTION_MODE_PREPEND => $value . $initialValue,
+ Template::SECTION_MODE_APPEND => $initialValue . $value,
+ default => $value
+ };
+ }
+
+ public function offsetSet(mixed $offset, mixed $value): void
+ {
+ $this->set((string)$offset, (string)$value);
+ }
+
+ public function prepend(string $section, ?string $value): void
+ {
+ $this->set($section, $value, Template::SECTION_MODE_PREPEND);
+ }
+
+ public function append(string $section, ?string $value): void
+ {
+ $this->set($section, $value, Template::SECTION_MODE_APPEND);
+ }
+
+ public function start(string $name): void
+ {
+ if ($this->sectionName) {
+ throw new LogicException('You cannot nest sections within other sections.');
+ }
+
+ $this->sectionName = $name;
+
+ ob_start();
+ }
+
+ public function appendStart(string $name): void
+ {
+ $this->sectionMode = Template::SECTION_MODE_APPEND;
+ $this->start($name);
+ }
+
+ public function prependStart(string $name): void
+ {
+ $this->sectionMode = Template::SECTION_MODE_PREPEND;
+ $this->start($name);
+ }
+
+ public function end(): void
+ {
+ if (is_null($this->sectionName)) {
+ throw new LogicException(
+ 'You must start a section before you can stop it.'
+ );
+ }
+
+ $this->set($this->sectionName, ob_get_clean() ?: null, $this->sectionMode);
+
+ $this->sectionName = null;
+ $this->sectionMode = Template::SECTION_MODE_REWRITE;
+ }
+}
diff --git a/backend/templates/about.phtml b/backend/templates/about.phtml
new file mode 100644
index 0000000..5e8d4a3
--- /dev/null
+++ b/backend/templates/about.phtml
@@ -0,0 +1,88 @@
+layout('layouts::site');
+?>
+
+
+
About WaterWolf
+
+
+
Organization Structure
+
+
+
WaterWolf Mission Statement
+
WaterWolf exists at the forefront of VRChat adventures,
+ pioneering a realm where the possibilities of virtual reality are not just explored but
+ reimagined. Founded by the dynamic duo, Isaac and Cat, our mission is to revolutionize the
+ VRChat experience, creating a world that is both enchanting and welcoming to all.>
+
At the heart of WaterWolf lies a community-driven ethos. We believe in the power of
+ collaboration and creativity, where every player's feedback becomes a cornerstone in our
+ journey of innovation. Our adventures are more than just virtual experiences; they are
+ meticulously crafted journeys, rich in detail and imagination, designed to captivate,
+ engage, and inspire.
+
Our founders, Isaac and Cat, embody the spirit of partnership and unity. Their unique
+ talents
+ and perspectives blend seamlessly to offer unparalleled VRChat adventures. This synergy is
+ not just within our leadership but extends to every member of our community. We are a
+ tapestry of diverse voices, each adding depth and color to the WaterWolf experience.
+
In the world of WaterWolf, innovation is continuous. We are committed to pushing the
+ boundaries of what is possible in virtual reality, ensuring that each adventure is fresh,
+ unique, and tailored to the evolving desires of our players. Our vision is to make the VR
+ landscape not only more engaging but also a haven for friendship, discovery, and shared
+ experiences.
+
Led by Isaac and Cat, our community is the driving force behind an ever-expanding
+ empire of
+ kindness and connection. At WaterWolf, we don't just create virtual worlds; we foster an
+ environment where every individual feels valued, heard, and excited to be a part of
+ something groundbreaking.
+
Join us in this extraordinary journey. With WaterWolf, every step into VRChat becomes a
+ step
+ into a world of endless possibilities, a world where imagination knows no bounds, and where
+ every adventure is a gateway to new horizons.
+
+
WaterWolf: Embracing a New Horizon as a TV Network
+
WaterWolf, known for pioneering the realm of VRChat adventures, is excited to announce
+ our latest
+ venture as a TV network, dedicated to broadcasting our unique media content on the internet.
+ This expansion aligns with our mission to revolutionize the VRChat experience and to share our
+ enchanting and welcoming world with a broader audience.
+
+
Community-Driven Ethos
+
At the core of WaterWolf is our belief in collaboration and creativity. As a TV
+ network, we aim
+ to bring this community spirit to a wider audience, showcasing the rich, player-influenced
+ journeys that have defined us.
+
+
Creative Leadership
+
Our founders, Isaac and Cat, have always been the driving force behind our innovative
+ adventures.
+ Their vision will now extend to our TV network, promising broadcasts that are as captivating and
+ imaginative as our VRChat experiences.
+
+
Continuous Innovation
+
Just as we've continuously pushed the boundaries in virtual reality, our TV network
+ will strive
+ for groundbreaking content, offering fresh and unique perspectives to the world of online
+ media.
+
+
Expanding Our Community
+
This new venture into broadcasting is more than a platform expansion; it's an
+ invitation for more
+ individuals to experience the WaterWolf world. We aim to foster an environment where everyone
+ feels valued and part of something transformative, whether they join us in VRChat or through our
+ broadcasts.
+
+
Join us in this extraordinary new chapter. With WaterWolf, every adventure, whether in
+ VRChat or
+ through our TV network, is a gateway to new horizons and limitless possibilities.
+ Imagine a place where the boundaries of virtual reality are constantly pushed, where adventures are
+ crafted with meticulous care, and where your feedback becomes an integral part of the creation.
+ Welcome to WaterWolf - a beacon of creativity and innovation in the vast universe of VRChat.
+
+
+ WaterWolf is more than just a name; it's a community, a collaboration of two exceptionally talented
+ creators. Their unique styles and specialties converge to offer players an unparalleled journey.
+ Yet, the magic doesn't stop there. The diversity and creativity embedded in WaterWolf's DNA set it
+ apart. As players, we're always hungry for new adventures, new worlds, and novel experiences.
+ WaterWolf not only satiates this hunger but goes above and beyond, serving up experiences that are
+ fresh, captivating, and tailor-made, thanks to the invaluable feedback from its dedicated player
+ base.
+
+
+ WaterWolf needs your support to continue its operations. By supporting WaterWolf, you're helping to
+ ensure that we can continue our operations into the future. We support one-time donations via PayPal or
+ monthly recurring donations via Discord's Club VIP role.
+
+
+
Join the Revolution!
+
Join us in this exciting journey. Donate to WaterWolf today and become a part of
+ the revolution in VRChat adventures!
diff --git a/backend/templates/emails/forgot.phtml b/backend/templates/emails/forgot.phtml
new file mode 100644
index 0000000..9f9bc54
--- /dev/null
+++ b/backend/templates/emails/forgot.phtml
@@ -0,0 +1,11 @@
+Hewwo!
+
+A request has been submitted to recover your password on the WaterWolf web site.
+
+If you submitted this request, click the link below to choose a new password:
+=($url ?? null) ?>
+
+
+If you did not submit this request, you do not need to take any action at this time.
+
+ - The WaterWolf Team
diff --git a/backend/templates/errors/generic.phtml b/backend/templates/errors/generic.phtml
new file mode 100644
index 0000000..51b2da8
--- /dev/null
+++ b/backend/templates/errors/generic.phtml
@@ -0,0 +1,20 @@
+layout('layouts::minimal');
+?>
+
+
Dive into an immersive world of music and technology within VRChat. Foxxcon Cyberpunk,
+ the DJ club event created by WaterWolf team, invites you to experience three days of
+ high energy music—all completely free of charge.
+
+
+
+
+
+
+
+
+
Event badge
+
+
+
Prepare to immerse yourself in a futuristic world of music and technology as we bring the
+ cyberpunk vibes straight to your avatar. The Foxxcon badge is not just a symbol; it's a
+ statement, a testament to your unwavering passion for music, tech, and all things
+ cyberpunk.
+
We are thrilled to unveil the latest addition to the Foxxcon experience: the Cyber-pass
+ Badge! How to get it? It's simple, free and comes in only 2 pieces!
+
ID - Visit waterwolf.club/badge to create an
+ image with your name, pronouns, and photo.
WaterWolf is a community in VRChat focused on building, exploring and promoting
+ immersive and enjoyable worlds.
+
+
The WaterWolf community is active in many aspects of the VRChat world. Our skilled world
+ and avatar builders excel at creating the worlds, avatars, assets and technologies that
+ power some of the best VRChat experiences. Our community also explores the wider world of
+ VRChat, visiting and curating particularly interesting places.
+
+
WaterWolf offers a wide range of adventures to anyone who joins our community. Whether you're
+ making new friends, networking with talented world builders, or grooving to the beat at one
+ of our events, you're sure to find adventure and friendship as part of the WaterWolf family.
+
+
+
+
Dive into WaterWolf Adventures!
+
+
Ever dreamt of unparalleled adventures in VR? Welcome to WaterWolf. A mosaic
+ of diversity & creativity, this VRChat haven offers escapades that defy the ordinary.
+
+
Seeking memories that last? Craving resonating experiences? WaterWolf beckons. By hitting the
+ donate button, you're not just supporting - you're joining a story. A
+ chapter waiting to be written.
+
+ The WaterWolf team is passionate about bringing virtual spaces to life.
+ We collaborate with event hosts to offer Virtual Reality portals connecting
+ the virtual world with in-person events.
+
+
+
+ If you're looking to host a VR portal at your event, let our skilled team
+ of professionals build a powerful, custom experience. With our low-latency
+ streaming technology and one-to-one recreations of event venues inside VRChat,
+ our experiences are compelling for people on both sides of the portal, allowing
+ your event to reach thousands of individuals who might not have had the
+ opportunity to participate otherwise.
+
+
+
+ Our team has hosted, or will host, portals at the following events:
+
+
+
Everfree Northwest 2023
+
Anthro Northwest 2024
+
+
+
+ We also bring VR avatars to life with talented on-site VR dancers.
+ Our team is volunteering, or has volunteered, at these events:
+
The WaterWolf poster network allows you to easily upload and manage posters that are in
+ a uniform size and
+ format and can be used in VRChat worlds, promotional materials, and more.
+
+
Using the Poster Network
+
+
+
+
Loading a Random Poster
+
+
If you want to load a completely random poster from the uploaded collection,
+ load the
+ base URL with no query parameters:
Posters can optionally be part of a "collection" to make them easy to reference
+ in your worlds:
+
+
+
+ = $baseUrl ?>?collection=ww_events
+
+
+
+
More than one poster be in a collection; the poster that appears will be
+ shuffled between them. Collections aren't unique to groups, but you can chain filters
+ (see below) to select only posters owned by a group with a given collection.
+
+
Chaining Filters
+
+
You can combine the queries above to narrow your filter, for example, to only
+ show
+ posters that meet multiple criteria:
+ On this page, you will find a repository of worlds, both those made by our own
+ WaterWolf-associated creators, and those that we have discovered on our journey into VRChat and have
+ immensely enjoyed.
+
Comments
++ {{ row.username }} +
+ + {{ timeAgo(row.tstamp) }} + ++ {{ row.comment }} +
+Make a Comment
+ + + + +Join WaterWolf to Comment
++ Want to comment? Make an account or login. +
+ +