For now? I'm experimenting on an old concept.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add 'backend/impl-rs/' from commit 'fbf12ba4aaa63af0723c5d2e5c61e59adb63afae'

git-subtree-dir: backend/impl-rs
git-subtree-mainline: 94f084cd909b894f55b5bc276521759fd3ce1a40
git-subtree-split: fbf12ba4aaa63af0723c5d2e5c61e59adb63afae

+14350
+2
backend/impl-rs/.cargo/config.toml
··· 1 + [env] 2 + CARGO_WORKSPACE_DIR = { value = "", relative = true }
+29
backend/impl-rs/.dockerignore
··· 1 + # Ignore git and build files 2 + .git 3 + .gitignore 4 + *.log 5 + *.tmp 6 + *.swp 7 + *.swo 8 + *.bak 9 + *.DS_Store 10 + 11 + # Rust build output 12 + target/ 13 + 14 + # Node/Bun/Gleam build output 15 + client/build/ 16 + client/node_modules/ 17 + client/bun.lockb 18 + 19 + # Editor/IDE files 20 + .vscode/ 21 + .idea/ 22 + 23 + # Misc 24 + *.env.local 25 + .envrc 26 + .env.*.local 27 + 28 + docker/ 29 + data/
+696
backend/impl-rs/.editorconfig
··· 1 + # EditorConfig is awesome: https://EditorConfig.org 2 + # top-most EditorConfig file 3 + root = true 4 + 5 + [*] 6 + indent_style = tab 7 + indent_size = 4 8 + end_of_line = lf 9 + charset = utf-8 10 + trim_trailing_whitespace = true 11 + insert_final_newline = true 12 + max_line_length = 120 13 + tab_width = 4 14 + ij_continuation_indent_size = 8 15 + ij_formatter_off_tag = @formatter:off 16 + ij_formatter_on_tag = @formatter:on 17 + ij_formatter_tags_enabled = true 18 + ij_smart_tabs = false 19 + ij_visual_guides = 20 + ij_wrap_on_typing = false 21 + 22 + [*.css] 23 + ij_css_align_closing_brace_with_properties = false 24 + ij_css_blank_lines_around_nested_selector = 1 25 + ij_css_blank_lines_between_blocks = 1 26 + ij_css_block_comment_add_space = false 27 + ij_css_brace_placement = end_of_line 28 + ij_css_enforce_quotes_on_format = false 29 + ij_css_hex_color_long_format = false 30 + ij_css_hex_color_lower_case = false 31 + ij_css_hex_color_short_format = false 32 + ij_css_hex_color_upper_case = false 33 + ij_css_keep_blank_lines_in_code = 2 34 + ij_css_keep_indents_on_empty_lines = false 35 + ij_css_keep_single_line_blocks = false 36 + 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 37 + ij_css_space_after_colon = true 38 + ij_css_space_before_opening_brace = true 39 + ij_css_use_double_quotes = true 40 + ij_css_value_alignment = do_not_align 41 + 42 + [*.rs] 43 + max_line_length = 100 44 + ij_continuation_indent_size = 4 45 + ij_rust_align_multiline_chained_methods = false 46 + ij_rust_align_multiline_parameters = true 47 + ij_rust_align_multiline_parameters_in_calls = true 48 + ij_rust_align_ret_type = true 49 + ij_rust_align_type_params = false 50 + ij_rust_align_where_bounds = true 51 + ij_rust_align_where_clause = false 52 + ij_rust_allow_one_line_match = false 53 + ij_rust_block_comment_at_first_column = false 54 + ij_rust_do_not_format_rustfmt_skip = true 55 + ij_rust_indent_where_clause = false 56 + ij_rust_keep_blank_lines_in_code = 2 57 + ij_rust_keep_blank_lines_in_declarations = 2 58 + ij_rust_keep_first_column_comment = false 59 + ij_rust_keep_indents_on_empty_lines = false 60 + ij_rust_keep_line_breaks = true 61 + ij_rust_line_comment_add_space = true 62 + ij_rust_line_comment_at_first_column = false 63 + ij_rust_max_number_of_blanks_between_field_patterns = 1 64 + ij_rust_max_number_of_blanks_between_value_parameters = 1 65 + ij_rust_min_number_of_blanks_between_items = 0 66 + ij_rust_new_line_after_where = true 67 + ij_rust_preserve_end_of_line_comments_on_the_same_line = true 68 + ij_rust_preserve_punctuation = false 69 + ij_rust_spaces_around_assoc_type_binding = false 70 + ij_rust_where_on_new_line = true 71 + 72 + [.editorconfig] 73 + ij_editorconfig_align_group_field_declarations = false 74 + ij_editorconfig_space_after_colon = false 75 + ij_editorconfig_space_after_comma = true 76 + ij_editorconfig_space_before_colon = false 77 + ij_editorconfig_space_before_comma = false 78 + ij_editorconfig_spaces_around_assignment_operators = true 79 + 80 + [{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wadl,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}] 81 + ij_xml_align_attributes = true 82 + ij_xml_align_text = false 83 + ij_xml_attribute_wrap = normal 84 + ij_xml_block_comment_add_space = false 85 + ij_xml_block_comment_at_first_column = true 86 + ij_xml_keep_blank_lines = 2 87 + ij_xml_keep_indents_on_empty_lines = false 88 + ij_xml_keep_line_breaks = true 89 + ij_xml_keep_line_breaks_in_text = true 90 + ij_xml_keep_whitespaces = false 91 + ij_xml_keep_whitespaces_around_cdata = preserve 92 + ij_xml_keep_whitespaces_inside_cdata = false 93 + ij_xml_line_comment_at_first_column = true 94 + ij_xml_space_after_tag_name = false 95 + ij_xml_space_around_equals_in_attribute = false 96 + ij_xml_space_inside_empty_tag = false 97 + ij_xml_text_wrap = normal 98 + 99 + [{*.ats,*.cts,*.mts,*.ts}] 100 + ij_continuation_indent_size = 4 101 + ij_typescript_align_imports = false 102 + ij_typescript_align_multiline_array_initializer_expression = false 103 + ij_typescript_align_multiline_binary_operation = false 104 + ij_typescript_align_multiline_chained_methods = false 105 + ij_typescript_align_multiline_extends_list = false 106 + ij_typescript_align_multiline_for = true 107 + ij_typescript_align_multiline_parameters = true 108 + ij_typescript_align_multiline_parameters_in_calls = false 109 + ij_typescript_align_multiline_ternary_operation = false 110 + ij_typescript_align_object_properties = 0 111 + ij_typescript_align_union_types = false 112 + ij_typescript_align_var_statements = 0 113 + ij_typescript_array_initializer_new_line_after_left_brace = false 114 + ij_typescript_array_initializer_right_brace_on_new_line = false 115 + ij_typescript_array_initializer_wrap = off 116 + ij_typescript_assignment_wrap = off 117 + ij_typescript_binary_operation_sign_on_next_line = false 118 + ij_typescript_binary_operation_wrap = off 119 + ij_typescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** 120 + ij_typescript_blank_lines_after_imports = 1 121 + ij_typescript_blank_lines_around_class = 1 122 + ij_typescript_blank_lines_around_field = 0 123 + ij_typescript_blank_lines_around_field_in_interface = 0 124 + ij_typescript_blank_lines_around_function = 1 125 + ij_typescript_blank_lines_around_method = 1 126 + ij_typescript_blank_lines_around_method_in_interface = 1 127 + ij_typescript_block_brace_style = end_of_line 128 + ij_typescript_block_comment_add_space = false 129 + ij_typescript_block_comment_at_first_column = true 130 + ij_typescript_call_parameters_new_line_after_left_paren = false 131 + ij_typescript_call_parameters_right_paren_on_new_line = false 132 + ij_typescript_call_parameters_wrap = off 133 + ij_typescript_catch_on_new_line = false 134 + ij_typescript_chained_call_dot_on_new_line = true 135 + ij_typescript_class_brace_style = end_of_line 136 + ij_typescript_class_decorator_wrap = split_into_lines 137 + ij_typescript_class_field_decorator_wrap = off 138 + ij_typescript_class_method_decorator_wrap = off 139 + ij_typescript_comma_on_new_line = false 140 + ij_typescript_do_while_brace_force = never 141 + ij_typescript_else_on_new_line = false 142 + ij_typescript_enforce_trailing_comma = keep 143 + ij_typescript_enum_constants_wrap = on_every_item 144 + ij_typescript_extends_keyword_wrap = off 145 + ij_typescript_extends_list_wrap = off 146 + ij_typescript_field_prefix = _ 147 + ij_typescript_file_name_style = relaxed 148 + ij_typescript_finally_on_new_line = false 149 + ij_typescript_for_brace_force = never 150 + ij_typescript_for_statement_new_line_after_left_paren = false 151 + ij_typescript_for_statement_right_paren_on_new_line = false 152 + ij_typescript_for_statement_wrap = off 153 + ij_typescript_force_quote_style = false 154 + ij_typescript_force_semicolon_style = false 155 + ij_typescript_function_expression_brace_style = end_of_line 156 + ij_typescript_function_parameter_decorator_wrap = off 157 + ij_typescript_if_brace_force = never 158 + ij_typescript_import_merge_members = global 159 + ij_typescript_import_prefer_absolute_path = global 160 + ij_typescript_import_sort_members = true 161 + ij_typescript_import_sort_module_name = false 162 + ij_typescript_import_use_node_resolution = true 163 + ij_typescript_imports_wrap = on_every_item 164 + ij_typescript_indent_case_from_switch = true 165 + ij_typescript_indent_chained_calls = true 166 + ij_typescript_indent_package_children = 0 167 + ij_typescript_jsdoc_include_types = false 168 + ij_typescript_jsx_attribute_value = braces 169 + ij_typescript_keep_blank_lines_in_code = 2 170 + ij_typescript_keep_first_column_comment = true 171 + ij_typescript_keep_indents_on_empty_lines = false 172 + ij_typescript_keep_line_breaks = true 173 + ij_typescript_keep_simple_blocks_in_one_line = false 174 + ij_typescript_keep_simple_methods_in_one_line = false 175 + ij_typescript_line_comment_add_space = true 176 + ij_typescript_line_comment_at_first_column = false 177 + ij_typescript_method_brace_style = end_of_line 178 + ij_typescript_method_call_chain_wrap = off 179 + ij_typescript_method_parameters_new_line_after_left_paren = false 180 + ij_typescript_method_parameters_right_paren_on_new_line = false 181 + ij_typescript_method_parameters_wrap = off 182 + ij_typescript_object_literal_wrap = on_every_item 183 + ij_typescript_object_types_wrap = on_every_item 184 + ij_typescript_parentheses_expression_new_line_after_left_paren = false 185 + ij_typescript_parentheses_expression_right_paren_on_new_line = false 186 + ij_typescript_place_assignment_sign_on_next_line = false 187 + ij_typescript_prefer_as_type_cast = false 188 + ij_typescript_prefer_explicit_types_function_expression_returns = false 189 + ij_typescript_prefer_explicit_types_function_returns = false 190 + ij_typescript_prefer_explicit_types_vars_fields = false 191 + ij_typescript_prefer_parameters_wrap = false 192 + ij_typescript_property_prefix = 193 + ij_typescript_reformat_c_style_comments = false 194 + ij_typescript_space_after_colon = true 195 + ij_typescript_space_after_comma = true 196 + ij_typescript_space_after_dots_in_rest_parameter = false 197 + ij_typescript_space_after_generator_mult = true 198 + ij_typescript_space_after_property_colon = true 199 + ij_typescript_space_after_quest = true 200 + ij_typescript_space_after_type_colon = true 201 + ij_typescript_space_after_unary_not = false 202 + ij_typescript_space_before_async_arrow_lparen = true 203 + ij_typescript_space_before_catch_keyword = true 204 + ij_typescript_space_before_catch_left_brace = true 205 + ij_typescript_space_before_catch_parentheses = true 206 + ij_typescript_space_before_class_lbrace = true 207 + ij_typescript_space_before_class_left_brace = true 208 + ij_typescript_space_before_colon = true 209 + ij_typescript_space_before_comma = false 210 + ij_typescript_space_before_do_left_brace = true 211 + ij_typescript_space_before_else_keyword = true 212 + ij_typescript_space_before_else_left_brace = true 213 + ij_typescript_space_before_finally_keyword = true 214 + ij_typescript_space_before_finally_left_brace = true 215 + ij_typescript_space_before_for_left_brace = true 216 + ij_typescript_space_before_for_parentheses = true 217 + ij_typescript_space_before_for_semicolon = false 218 + ij_typescript_space_before_function_left_parenth = true 219 + ij_typescript_space_before_generator_mult = false 220 + ij_typescript_space_before_if_left_brace = true 221 + ij_typescript_space_before_if_parentheses = true 222 + ij_typescript_space_before_method_call_parentheses = false 223 + ij_typescript_space_before_method_left_brace = true 224 + ij_typescript_space_before_method_parentheses = false 225 + ij_typescript_space_before_property_colon = false 226 + ij_typescript_space_before_quest = true 227 + ij_typescript_space_before_switch_left_brace = true 228 + ij_typescript_space_before_switch_parentheses = true 229 + ij_typescript_space_before_try_left_brace = true 230 + ij_typescript_space_before_type_colon = false 231 + ij_typescript_space_before_unary_not = false 232 + ij_typescript_space_before_while_keyword = true 233 + ij_typescript_space_before_while_left_brace = true 234 + ij_typescript_space_before_while_parentheses = true 235 + ij_typescript_spaces_around_additive_operators = true 236 + ij_typescript_spaces_around_arrow_function_operator = true 237 + ij_typescript_spaces_around_assignment_operators = true 238 + ij_typescript_spaces_around_bitwise_operators = true 239 + ij_typescript_spaces_around_equality_operators = true 240 + ij_typescript_spaces_around_logical_operators = true 241 + ij_typescript_spaces_around_multiplicative_operators = true 242 + ij_typescript_spaces_around_relational_operators = true 243 + ij_typescript_spaces_around_shift_operators = true 244 + ij_typescript_spaces_around_unary_operator = false 245 + ij_typescript_spaces_within_array_initializer_brackets = false 246 + ij_typescript_spaces_within_brackets = false 247 + ij_typescript_spaces_within_catch_parentheses = false 248 + ij_typescript_spaces_within_for_parentheses = false 249 + ij_typescript_spaces_within_if_parentheses = false 250 + ij_typescript_spaces_within_imports = false 251 + ij_typescript_spaces_within_interpolation_expressions = false 252 + ij_typescript_spaces_within_method_call_parentheses = false 253 + ij_typescript_spaces_within_method_parentheses = false 254 + ij_typescript_spaces_within_object_literal_braces = false 255 + ij_typescript_spaces_within_object_type_braces = true 256 + ij_typescript_spaces_within_parentheses = false 257 + ij_typescript_spaces_within_switch_parentheses = false 258 + ij_typescript_spaces_within_type_assertion = false 259 + ij_typescript_spaces_within_union_types = true 260 + ij_typescript_spaces_within_while_parentheses = false 261 + ij_typescript_special_else_if_treatment = true 262 + ij_typescript_ternary_operation_signs_on_next_line = false 263 + ij_typescript_ternary_operation_wrap = off 264 + ij_typescript_union_types_wrap = on_every_item 265 + ij_typescript_use_chained_calls_group_indents = false 266 + ij_typescript_use_double_quotes = true 267 + ij_typescript_use_explicit_js_extension = auto 268 + ij_typescript_use_import_type = auto 269 + ij_typescript_use_path_mapping = always 270 + ij_typescript_use_public_modifier = false 271 + ij_typescript_use_semicolon_after_statement = true 272 + ij_typescript_var_declaration_wrap = normal 273 + ij_typescript_while_brace_force = never 274 + ij_typescript_while_on_new_line = false 275 + ij_typescript_wrap_comments = false 276 + 277 + [{*.cjs,*.es6,*.js,*.mjs}] 278 + ij_continuation_indent_size = 4 279 + ij_javascript_align_imports = false 280 + ij_javascript_align_multiline_array_initializer_expression = false 281 + ij_javascript_align_multiline_binary_operation = false 282 + ij_javascript_align_multiline_chained_methods = false 283 + ij_javascript_align_multiline_extends_list = false 284 + ij_javascript_align_multiline_for = true 285 + ij_javascript_align_multiline_parameters = true 286 + ij_javascript_align_multiline_parameters_in_calls = false 287 + ij_javascript_align_multiline_ternary_operation = false 288 + ij_javascript_align_object_properties = 0 289 + ij_javascript_align_union_types = false 290 + ij_javascript_align_var_statements = 0 291 + ij_javascript_array_initializer_new_line_after_left_brace = false 292 + ij_javascript_array_initializer_right_brace_on_new_line = false 293 + ij_javascript_array_initializer_wrap = off 294 + ij_javascript_assignment_wrap = off 295 + ij_javascript_binary_operation_sign_on_next_line = false 296 + ij_javascript_binary_operation_wrap = off 297 + ij_javascript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** 298 + ij_javascript_blank_lines_after_imports = 1 299 + ij_javascript_blank_lines_around_class = 1 300 + ij_javascript_blank_lines_around_field = 0 301 + ij_javascript_blank_lines_around_function = 1 302 + ij_javascript_blank_lines_around_method = 1 303 + ij_javascript_block_brace_style = end_of_line 304 + ij_javascript_block_comment_add_space = false 305 + ij_javascript_block_comment_at_first_column = true 306 + ij_javascript_call_parameters_new_line_after_left_paren = false 307 + ij_javascript_call_parameters_right_paren_on_new_line = false 308 + ij_javascript_call_parameters_wrap = off 309 + ij_javascript_catch_on_new_line = false 310 + ij_javascript_chained_call_dot_on_new_line = true 311 + ij_javascript_class_brace_style = end_of_line 312 + ij_javascript_class_decorator_wrap = split_into_lines 313 + ij_javascript_class_field_decorator_wrap = off 314 + ij_javascript_class_method_decorator_wrap = off 315 + ij_javascript_comma_on_new_line = false 316 + ij_javascript_do_while_brace_force = never 317 + ij_javascript_else_on_new_line = false 318 + ij_javascript_enforce_trailing_comma = keep 319 + ij_javascript_extends_keyword_wrap = off 320 + ij_javascript_extends_list_wrap = off 321 + ij_javascript_field_prefix = _ 322 + ij_javascript_file_name_style = relaxed 323 + ij_javascript_finally_on_new_line = false 324 + ij_javascript_for_brace_force = never 325 + ij_javascript_for_statement_new_line_after_left_paren = false 326 + ij_javascript_for_statement_right_paren_on_new_line = false 327 + ij_javascript_for_statement_wrap = off 328 + ij_javascript_force_quote_style = false 329 + ij_javascript_force_semicolon_style = false 330 + ij_javascript_function_expression_brace_style = end_of_line 331 + ij_javascript_function_parameter_decorator_wrap = off 332 + ij_javascript_if_brace_force = never 333 + ij_javascript_import_merge_members = global 334 + ij_javascript_import_prefer_absolute_path = global 335 + ij_javascript_import_sort_members = true 336 + ij_javascript_import_sort_module_name = false 337 + ij_javascript_import_use_node_resolution = true 338 + ij_javascript_imports_wrap = on_every_item 339 + ij_javascript_indent_case_from_switch = true 340 + ij_javascript_indent_chained_calls = true 341 + ij_javascript_indent_package_children = 0 342 + ij_javascript_jsx_attribute_value = braces 343 + ij_javascript_keep_blank_lines_in_code = 2 344 + ij_javascript_keep_first_column_comment = true 345 + ij_javascript_keep_indents_on_empty_lines = false 346 + ij_javascript_keep_line_breaks = true 347 + ij_javascript_keep_simple_blocks_in_one_line = false 348 + ij_javascript_keep_simple_methods_in_one_line = false 349 + ij_javascript_line_comment_add_space = true 350 + ij_javascript_line_comment_at_first_column = false 351 + ij_javascript_method_brace_style = end_of_line 352 + ij_javascript_method_call_chain_wrap = off 353 + ij_javascript_method_parameters_new_line_after_left_paren = false 354 + ij_javascript_method_parameters_right_paren_on_new_line = false 355 + ij_javascript_method_parameters_wrap = off 356 + ij_javascript_object_literal_wrap = on_every_item 357 + ij_javascript_object_types_wrap = on_every_item 358 + ij_javascript_parentheses_expression_new_line_after_left_paren = false 359 + ij_javascript_parentheses_expression_right_paren_on_new_line = false 360 + ij_javascript_place_assignment_sign_on_next_line = false 361 + ij_javascript_prefer_as_type_cast = false 362 + ij_javascript_prefer_explicit_types_function_expression_returns = false 363 + ij_javascript_prefer_explicit_types_function_returns = false 364 + ij_javascript_prefer_explicit_types_vars_fields = false 365 + ij_javascript_prefer_parameters_wrap = false 366 + ij_javascript_property_prefix = 367 + ij_javascript_reformat_c_style_comments = false 368 + ij_javascript_space_after_colon = true 369 + ij_javascript_space_after_comma = true 370 + ij_javascript_space_after_dots_in_rest_parameter = false 371 + ij_javascript_space_after_generator_mult = true 372 + ij_javascript_space_after_property_colon = true 373 + ij_javascript_space_after_quest = true 374 + ij_javascript_space_after_type_colon = true 375 + ij_javascript_space_after_unary_not = false 376 + ij_javascript_space_before_async_arrow_lparen = true 377 + ij_javascript_space_before_catch_keyword = true 378 + ij_javascript_space_before_catch_left_brace = true 379 + ij_javascript_space_before_catch_parentheses = true 380 + ij_javascript_space_before_class_lbrace = true 381 + ij_javascript_space_before_class_left_brace = true 382 + ij_javascript_space_before_colon = true 383 + ij_javascript_space_before_comma = false 384 + ij_javascript_space_before_do_left_brace = true 385 + ij_javascript_space_before_else_keyword = true 386 + ij_javascript_space_before_else_left_brace = true 387 + ij_javascript_space_before_finally_keyword = true 388 + ij_javascript_space_before_finally_left_brace = true 389 + ij_javascript_space_before_for_left_brace = true 390 + ij_javascript_space_before_for_parentheses = true 391 + ij_javascript_space_before_for_semicolon = false 392 + ij_javascript_space_before_function_left_parenth = true 393 + ij_javascript_space_before_generator_mult = false 394 + ij_javascript_space_before_if_left_brace = true 395 + ij_javascript_space_before_if_parentheses = true 396 + ij_javascript_space_before_method_call_parentheses = false 397 + ij_javascript_space_before_method_left_brace = true 398 + ij_javascript_space_before_method_parentheses = false 399 + ij_javascript_space_before_property_colon = false 400 + ij_javascript_space_before_quest = true 401 + ij_javascript_space_before_switch_left_brace = true 402 + ij_javascript_space_before_switch_parentheses = true 403 + ij_javascript_space_before_try_left_brace = true 404 + ij_javascript_space_before_type_colon = false 405 + ij_javascript_space_before_unary_not = false 406 + ij_javascript_space_before_while_keyword = true 407 + ij_javascript_space_before_while_left_brace = true 408 + ij_javascript_space_before_while_parentheses = true 409 + ij_javascript_spaces_around_additive_operators = true 410 + ij_javascript_spaces_around_arrow_function_operator = true 411 + ij_javascript_spaces_around_assignment_operators = true 412 + ij_javascript_spaces_around_bitwise_operators = true 413 + ij_javascript_spaces_around_equality_operators = true 414 + ij_javascript_spaces_around_logical_operators = true 415 + ij_javascript_spaces_around_multiplicative_operators = true 416 + ij_javascript_spaces_around_relational_operators = true 417 + ij_javascript_spaces_around_shift_operators = true 418 + ij_javascript_spaces_around_unary_operator = false 419 + ij_javascript_spaces_within_array_initializer_brackets = false 420 + ij_javascript_spaces_within_brackets = false 421 + ij_javascript_spaces_within_catch_parentheses = false 422 + ij_javascript_spaces_within_for_parentheses = false 423 + ij_javascript_spaces_within_if_parentheses = false 424 + ij_javascript_spaces_within_imports = false 425 + ij_javascript_spaces_within_interpolation_expressions = false 426 + ij_javascript_spaces_within_method_call_parentheses = false 427 + ij_javascript_spaces_within_method_parentheses = false 428 + ij_javascript_spaces_within_object_literal_braces = false 429 + ij_javascript_spaces_within_object_type_braces = true 430 + ij_javascript_spaces_within_parentheses = false 431 + ij_javascript_spaces_within_switch_parentheses = false 432 + ij_javascript_spaces_within_type_assertion = false 433 + ij_javascript_spaces_within_union_types = true 434 + ij_javascript_spaces_within_while_parentheses = false 435 + ij_javascript_special_else_if_treatment = true 436 + ij_javascript_ternary_operation_signs_on_next_line = false 437 + ij_javascript_ternary_operation_wrap = off 438 + ij_javascript_union_types_wrap = on_every_item 439 + ij_javascript_use_chained_calls_group_indents = false 440 + ij_javascript_use_double_quotes = true 441 + ij_javascript_use_explicit_js_extension = auto 442 + ij_javascript_use_import_type = auto 443 + ij_javascript_use_path_mapping = always 444 + ij_javascript_use_public_modifier = false 445 + ij_javascript_use_semicolon_after_statement = true 446 + ij_javascript_var_declaration_wrap = normal 447 + ij_javascript_while_brace_force = never 448 + ij_javascript_while_on_new_line = false 449 + ij_javascript_wrap_comments = false 450 + 451 + [{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_environment,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,jest.config}] 452 + indent_size = 2 453 + ij_json_array_wrapping = split_into_lines 454 + ij_json_keep_blank_lines_in_code = 0 455 + ij_json_keep_indents_on_empty_lines = false 456 + ij_json_keep_line_breaks = true 457 + ij_json_keep_trailing_comma = false 458 + ij_json_object_wrapping = split_into_lines 459 + ij_json_property_alignment = do_not_align 460 + ij_json_space_after_colon = true 461 + ij_json_space_after_comma = true 462 + ij_json_space_before_colon = false 463 + ij_json_space_before_comma = false 464 + ij_json_spaces_within_braces = false 465 + ij_json_spaces_within_brackets = false 466 + ij_json_wrap_long_lines = false 467 + 468 + [{*.htm,*.html,*.sht,*.shtm,*.shtml}] 469 + ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3 470 + ij_html_align_attributes = true 471 + ij_html_align_text = false 472 + ij_html_attribute_wrap = normal 473 + ij_html_block_comment_add_space = false 474 + ij_html_block_comment_at_first_column = true 475 + ij_html_do_not_align_children_of_min_lines = 0 476 + ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p 477 + ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot 478 + ij_html_enforce_quotes = false 479 + 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 480 + ij_html_keep_blank_lines = 2 481 + ij_html_keep_indents_on_empty_lines = false 482 + ij_html_keep_line_breaks = true 483 + ij_html_keep_line_breaks_in_text = true 484 + ij_html_keep_whitespaces = false 485 + ij_html_keep_whitespaces_inside = span,pre,textarea 486 + ij_html_line_comment_at_first_column = true 487 + ij_html_new_line_after_last_attribute = never 488 + ij_html_new_line_before_first_attribute = never 489 + ij_html_quote_style = double 490 + ij_html_remove_new_line_before_tags = br 491 + ij_html_space_after_tag_name = false 492 + ij_html_space_around_equality_in_attribute = false 493 + ij_html_space_inside_empty_tag = false 494 + ij_html_text_wrap = normal 495 + 496 + [{*.http,*.rest}] 497 + ij_continuation_indent_size = 4 498 + ij_http-request_call_parameters_wrap = normal 499 + ij_http-request_method_parameters_wrap = split_into_lines 500 + ij_http-request_space_before_comma = true 501 + ij_http-request_spaces_around_assignment_operators = true 502 + 503 + [{*.markdown,*.md}] 504 + ij_markdown_force_one_space_after_blockquote_symbol = true 505 + ij_markdown_force_one_space_after_header_symbol = true 506 + ij_markdown_force_one_space_after_list_bullet = true 507 + ij_markdown_force_one_space_between_words = true 508 + ij_markdown_format_tables = true 509 + ij_markdown_insert_quote_arrows_on_wrap = true 510 + ij_markdown_keep_indents_on_empty_lines = false 511 + ij_markdown_keep_line_breaks_inside_text_blocks = true 512 + ij_markdown_max_lines_around_block_elements = 1 513 + ij_markdown_max_lines_around_header = 1 514 + ij_markdown_max_lines_between_paragraphs = 1 515 + ij_markdown_min_lines_around_block_elements = 1 516 + ij_markdown_min_lines_around_header = 1 517 + ij_markdown_min_lines_between_paragraphs = 1 518 + ij_markdown_wrap_text_if_long = true 519 + ij_markdown_wrap_text_inside_blockquotes = true 520 + 521 + [{*.qml,*.qmltypes}] 522 + ij_continuation_indent_size = 4 523 + ij_qmllang_align_imports = false 524 + ij_qmllang_align_multiline_array_initializer_expression = false 525 + ij_qmllang_align_multiline_binary_operation = false 526 + ij_qmllang_align_multiline_chained_methods = false 527 + ij_qmllang_align_multiline_extends_list = false 528 + ij_qmllang_align_multiline_for = true 529 + ij_qmllang_align_multiline_parameters = true 530 + ij_qmllang_align_multiline_parameters_in_calls = false 531 + ij_qmllang_align_multiline_ternary_operation = false 532 + ij_qmllang_align_object_properties = 0 533 + ij_qmllang_align_union_types = false 534 + ij_qmllang_align_var_statements = 0 535 + ij_qmllang_array_initializer_new_line_after_left_brace = false 536 + ij_qmllang_array_initializer_right_brace_on_new_line = false 537 + ij_qmllang_array_initializer_wrap = off 538 + ij_qmllang_assignment_wrap = off 539 + ij_qmllang_binary_operation_sign_on_next_line = false 540 + ij_qmllang_binary_operation_wrap = off 541 + ij_qmllang_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** 542 + ij_qmllang_blank_lines_after_imports = 1 543 + ij_qmllang_blank_lines_around_class = 1 544 + ij_qmllang_blank_lines_around_field = 0 545 + ij_qmllang_blank_lines_around_function = 1 546 + ij_qmllang_blank_lines_around_method = 1 547 + ij_qmllang_block_brace_style = end_of_line 548 + ij_qmllang_block_comment_add_space = false 549 + ij_qmllang_block_comment_at_first_column = true 550 + ij_qmllang_call_parameters_new_line_after_left_paren = false 551 + ij_qmllang_call_parameters_right_paren_on_new_line = false 552 + ij_qmllang_call_parameters_wrap = off 553 + ij_qmllang_catch_on_new_line = false 554 + ij_qmllang_chained_call_dot_on_new_line = true 555 + ij_qmllang_class_brace_style = end_of_line 556 + ij_qmllang_class_decorator_wrap = split_into_lines 557 + ij_qmllang_class_field_decorator_wrap = off 558 + ij_qmllang_class_method_decorator_wrap = off 559 + ij_qmllang_comma_on_new_line = false 560 + ij_qmllang_do_while_brace_force = never 561 + ij_qmllang_else_on_new_line = false 562 + ij_qmllang_enforce_trailing_comma = keep 563 + ij_qmllang_extends_keyword_wrap = off 564 + ij_qmllang_extends_list_wrap = off 565 + ij_qmllang_field_prefix = _ 566 + ij_qmllang_file_name_style = relaxed 567 + ij_qmllang_finally_on_new_line = false 568 + ij_qmllang_for_brace_force = never 569 + ij_qmllang_for_statement_new_line_after_left_paren = false 570 + ij_qmllang_for_statement_right_paren_on_new_line = false 571 + ij_qmllang_for_statement_wrap = off 572 + ij_qmllang_force_quote_style = false 573 + ij_qmllang_force_semicolon_style = false 574 + ij_qmllang_function_expression_brace_style = end_of_line 575 + ij_qmllang_function_parameter_decorator_wrap = off 576 + ij_qmllang_if_brace_force = never 577 + ij_qmllang_import_merge_members = global 578 + ij_qmllang_import_prefer_absolute_path = global 579 + ij_qmllang_import_sort_members = true 580 + ij_qmllang_import_sort_module_name = false 581 + ij_qmllang_import_use_node_resolution = true 582 + ij_qmllang_imports_wrap = on_every_item 583 + ij_qmllang_indent_case_from_switch = true 584 + ij_qmllang_indent_chained_calls = true 585 + ij_qmllang_indent_package_children = 0 586 + ij_qmllang_jsx_attribute_value = braces 587 + ij_qmllang_keep_blank_lines_in_code = 2 588 + ij_qmllang_keep_first_column_comment = true 589 + ij_qmllang_keep_indents_on_empty_lines = false 590 + ij_qmllang_keep_line_breaks = true 591 + ij_qmllang_keep_simple_blocks_in_one_line = false 592 + ij_qmllang_keep_simple_methods_in_one_line = false 593 + ij_qmllang_line_comment_add_space = true 594 + ij_qmllang_line_comment_at_first_column = false 595 + ij_qmllang_method_brace_style = end_of_line 596 + ij_qmllang_method_call_chain_wrap = off 597 + ij_qmllang_method_parameters_new_line_after_left_paren = false 598 + ij_qmllang_method_parameters_right_paren_on_new_line = false 599 + ij_qmllang_method_parameters_wrap = off 600 + ij_qmllang_object_literal_wrap = on_every_item 601 + ij_qmllang_object_types_wrap = on_every_item 602 + ij_qmllang_parentheses_expression_new_line_after_left_paren = false 603 + ij_qmllang_parentheses_expression_right_paren_on_new_line = false 604 + ij_qmllang_place_assignment_sign_on_next_line = false 605 + ij_qmllang_prefer_as_type_cast = false 606 + ij_qmllang_prefer_explicit_types_function_expression_returns = false 607 + ij_qmllang_prefer_explicit_types_function_returns = false 608 + ij_qmllang_prefer_explicit_types_vars_fields = false 609 + ij_qmllang_prefer_parameters_wrap = false 610 + ij_qmllang_property_prefix = 611 + ij_qmllang_reformat_c_style_comments = false 612 + ij_qmllang_space_after_colon = true 613 + ij_qmllang_space_after_comma = true 614 + ij_qmllang_space_after_dots_in_rest_parameter = false 615 + ij_qmllang_space_after_generator_mult = true 616 + ij_qmllang_space_after_property_colon = true 617 + ij_qmllang_space_after_quest = true 618 + ij_qmllang_space_after_type_colon = true 619 + ij_qmllang_space_after_unary_not = false 620 + ij_qmllang_space_before_async_arrow_lparen = true 621 + ij_qmllang_space_before_catch_keyword = true 622 + ij_qmllang_space_before_catch_left_brace = true 623 + ij_qmllang_space_before_catch_parentheses = true 624 + ij_qmllang_space_before_class_lbrace = true 625 + ij_qmllang_space_before_class_left_brace = true 626 + ij_qmllang_space_before_colon = true 627 + ij_qmllang_space_before_comma = false 628 + ij_qmllang_space_before_do_left_brace = true 629 + ij_qmllang_space_before_else_keyword = true 630 + ij_qmllang_space_before_else_left_brace = true 631 + ij_qmllang_space_before_finally_keyword = true 632 + ij_qmllang_space_before_finally_left_brace = true 633 + ij_qmllang_space_before_for_left_brace = true 634 + ij_qmllang_space_before_for_parentheses = true 635 + ij_qmllang_space_before_for_semicolon = false 636 + ij_qmllang_space_before_function_left_parenth = true 637 + ij_qmllang_space_before_generator_mult = false 638 + ij_qmllang_space_before_if_left_brace = true 639 + ij_qmllang_space_before_if_parentheses = true 640 + ij_qmllang_space_before_method_call_parentheses = false 641 + ij_qmllang_space_before_method_left_brace = true 642 + ij_qmllang_space_before_method_parentheses = false 643 + ij_qmllang_space_before_property_colon = false 644 + ij_qmllang_space_before_quest = true 645 + ij_qmllang_space_before_switch_left_brace = true 646 + ij_qmllang_space_before_switch_parentheses = true 647 + ij_qmllang_space_before_try_left_brace = true 648 + ij_qmllang_space_before_type_colon = false 649 + ij_qmllang_space_before_unary_not = false 650 + ij_qmllang_space_before_while_keyword = true 651 + ij_qmllang_space_before_while_left_brace = true 652 + ij_qmllang_space_before_while_parentheses = true 653 + ij_qmllang_spaces_around_additive_operators = true 654 + ij_qmllang_spaces_around_arrow_function_operator = true 655 + ij_qmllang_spaces_around_assignment_operators = true 656 + ij_qmllang_spaces_around_bitwise_operators = true 657 + ij_qmllang_spaces_around_equality_operators = true 658 + ij_qmllang_spaces_around_logical_operators = true 659 + ij_qmllang_spaces_around_multiplicative_operators = true 660 + ij_qmllang_spaces_around_relational_operators = true 661 + ij_qmllang_spaces_around_shift_operators = true 662 + ij_qmllang_spaces_around_unary_operator = false 663 + ij_qmllang_spaces_within_array_initializer_brackets = false 664 + ij_qmllang_spaces_within_brackets = false 665 + ij_qmllang_spaces_within_catch_parentheses = false 666 + ij_qmllang_spaces_within_for_parentheses = false 667 + ij_qmllang_spaces_within_if_parentheses = false 668 + ij_qmllang_spaces_within_imports = false 669 + ij_qmllang_spaces_within_interpolation_expressions = false 670 + ij_qmllang_spaces_within_method_call_parentheses = false 671 + ij_qmllang_spaces_within_method_parentheses = false 672 + ij_qmllang_spaces_within_object_literal_braces = false 673 + ij_qmllang_spaces_within_object_type_braces = true 674 + ij_qmllang_spaces_within_parentheses = false 675 + ij_qmllang_spaces_within_switch_parentheses = false 676 + ij_qmllang_spaces_within_type_assertion = false 677 + ij_qmllang_spaces_within_union_types = true 678 + ij_qmllang_spaces_within_while_parentheses = false 679 + ij_qmllang_special_else_if_treatment = true 680 + ij_qmllang_ternary_operation_signs_on_next_line = false 681 + ij_qmllang_ternary_operation_wrap = off 682 + ij_qmllang_union_types_wrap = on_every_item 683 + ij_qmllang_use_chained_calls_group_indents = false 684 + ij_qmllang_use_double_quotes = true 685 + ij_qmllang_use_explicit_js_extension = auto 686 + ij_qmllang_use_import_type = auto 687 + ij_qmllang_use_path_mapping = always 688 + ij_qmllang_use_public_modifier = false 689 + ij_qmllang_use_semicolon_after_statement = true 690 + ij_qmllang_var_declaration_wrap = normal 691 + ij_qmllang_while_brace_force = never 692 + ij_qmllang_while_on_new_line = false 693 + ij_qmllang_wrap_comments = false 694 + 695 + [{*.toml,Cargo.lock,Cargo.toml.orig,Gopkg.lock,Pipfile,poetry.lock,uv.lock}] 696 + ij_toml_keep_indents_on_empty_lines = false
+4
backend/impl-rs/.envrc
··· 1 + if nix flake show &>/dev/null; then 2 + use flake 3 + fi 4 + export DATABASE_URL="postgres://lumina:lumina_pw@localhost:5432/lumina_config"
+7
backend/impl-rs/.gitattributes
··· 1 + 2 + * text=auto 3 + *.svg linguist-detectable 4 + *_ffi.ts linguist-language=Gleam 5 + *_ffi.mjs linguist-language=Gleam 6 + /notes/**/* linguist-documentation=true 7 + /notes/.obsidian/**/* linguist-generated=true
+52
backend/impl-rs/.gitignore
··· 1 + *.beam 2 + *.ez 3 + /target/ 4 + 5 + # Ignore Gleam backend build artifacts 6 + server/build 7 + client/priv/generated 8 + 9 + # Ignore Rust backend build artifacts 10 + backend-rs/target 11 + backend-rs/generated 12 + 13 + # Ignore Gleam frontend build artifacts 14 + frontend/prelude.mjs 15 + 16 + erl_crash.dump 17 + /test 18 + *.log 19 + package-lock.json 20 + rsffi/target 21 + rsffi/test 22 + node_modules 23 + # Ignore Editor files 24 + .idea/ 25 + .vscode/ 26 + 27 + 28 + 29 + # Added by cargo 30 + /target 31 + 32 + # Added by me 33 + client/build/dev/javascript/lumina_client/lumina_client.ts 34 + client/build/ 35 + /client/priv/static/lumina_client*.css 36 + /client/priv/static/lumina_client*.hash 37 + /client/priv/static/lumina_client*.mjs 38 + instance.sqlite 39 + instance.sqlite-shm 40 + instance.sqlite-wal 41 + .env 42 + /data 43 + /built 44 + # mise system-specific config file 45 + .mise.toml 46 + 47 + 48 + # Obsidian 49 + /notes/.obsidian/appearance.json 50 + /notes/.obsidian/workspace.json 51 + /notes/.obsidian/app.json 52 + .direnv/
+8
backend/impl-rs/.idea/.gitignore
··· 1 + # Default ignored files 2 + /shelf/ 3 + /workspace.xml 4 + # Editor-based HTTP Client requests 5 + /httpRequests/ 6 + # Datasource local storage ignored files 7 + /dataSources/ 8 + /dataSources.local.xml
+12
backend/impl-rs/.idea/Lumina.iml
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <module type="EMPTY_MODULE" version="4"> 3 + <component name="NewModuleRootManager"> 4 + <content url="file://$MODULE_DIR$"> 5 + <sourceFolder url="file://$MODULE_DIR$/server/src" isTestSource="false" /> 6 + <excludeFolder url="file://$MODULE_DIR$/data" /> 7 + <excludeFolder url="file://$MODULE_DIR$/target" /> 8 + </content> 9 + <orderEntry type="inheritedJdk" /> 10 + <orderEntry type="sourceFolder" forTests="false" /> 11 + </component> 12 + </module>
+6
backend/impl-rs/.idea/copyright/GNU_AGPLv3.xml
··· 1 + <component name="CopyrightManager"> 2 + <copyright> 3 + <option name="notice" value=" Lumina/Peonies&#10; Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors.&#10;&#10; This program is free software: you can redistribute it and/or modify&#10; it under the terms of the GNU Affero General Public License as published&#10; by the Free Software Foundation, either version 3 of the License, or&#10; (at your option) any later version.&#10;&#10; This program is distributed in the hope that it will be useful,&#10; but WITHOUT ANY WARRANTY; without even the implied warranty of&#10; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the&#10; GNU Affero General Public License for more details.&#10;&#10; You should have received a copy of the GNU Affero General Public License&#10; along with this program. If not, see &lt;https://www.gnu.org/licenses/&gt;." /> 4 + <option name="myName" value="GNU AGPLv3" /> 5 + </copyright> 6 + </component>
+7
backend/impl-rs/.idea/copyright/profiles_settings.xml
··· 1 + <component name="CopyrightManager"> 2 + <settings default="GNU AGPLv3"> 3 + <module2copyright> 4 + <element module="Source files" copyright="GNU AGPLv3" /> 5 + </module2copyright> 6 + </settings> 7 + </component>
+16
backend/impl-rs/.idea/inspectionProfiles/Project_Default.xml
··· 1 + <component name="InspectionProjectProfileManager"> 2 + <profile version="1.0"> 3 + <option name="myName" value="Project Default" /> 4 + <inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true"> 5 + <Languages> 6 + <language minSize="58" name="Rust" /> 7 + </Languages> 8 + </inspection_tool> 9 + <inspection_tool class="RsUnusedImport" enabled="false" level="WARNING" enabled_by_default="false" /> 10 + <inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false"> 11 + <option name="processCode" value="true" /> 12 + <option name="processLiterals" value="true" /> 13 + <option name="processComments" value="true" /> 14 + </inspection_tool> 15 + </profile> 16 + </component>
+8
backend/impl-rs/.idea/modules.xml
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <project version="4"> 3 + <component name="ProjectModuleManager"> 4 + <modules> 5 + <module fileurl="file://$PROJECT_DIR$/.idea/Lumina.iml" filepath="$PROJECT_DIR$/.idea/Lumina.iml" /> 6 + </modules> 7 + </component> 8 + </project>
+3
backend/impl-rs/.idea/scopes/Source_files.xml
··· 1 + <component name="DependencyValidationManager"> 2 + <scope name="Source files" pattern="(file:*.rs||file:*.gleam||file:*.svg||file:*.css)&amp;&amp;!file[Lumina]:notes//*" /> 3 + </component>
+6
backend/impl-rs/.idea/vcs.xml
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <project version="4"> 3 + <component name="VcsDirectoryMappings"> 4 + <mapping directory="" vcs="Git" /> 5 + </component> 6 + </project>
+26
backend/impl-rs/.run/Local development run with watcher.run.xml
··· 1 + <!-- 2 + ~ Lumina/Peonies 3 + ~ Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. 4 + ~ 5 + ~ This program is free software: you can redistribute it and/or modify 6 + ~ it under the terms of the GNU Affero General Public License as published 7 + ~ by the Free Software Foundation, either version 3 of the License, or 8 + ~ (at your option) any later version. 9 + ~ 10 + ~ This program is distributed in the hope that it will be useful, 11 + ~ but WITHOUT ANY WARRANTY; without even the implied warranty of 12 + ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 + ~ GNU Affero General Public License for more details. 14 + ~ 15 + ~ You should have received a copy of the GNU Affero General Public License 16 + ~ along with this program. If not, see <https://www.gnu.org/licenses/>. 17 + --> 18 + 19 + <component name="ProjectRunConfigurationManager"> 20 + <configuration default="false" name="Local development run with watcher" type="MiseTomlTaskRunConfigurationType" factoryName="MiseTomlTask"> 21 + <mise configEnvironment="" taskName="local-devel-watch" workingDirectory="$PROJECT_DIR$" taskParams=""> 22 + <envs /> 23 + </mise> 24 + <method v="2" /> 25 + </configuration> 26 + </component>
+26
backend/impl-rs/.run/Local development run.run.xml
··· 1 + <!-- 2 + ~ Lumina/Peonies 3 + ~ Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. 4 + ~ 5 + ~ This program is free software: you can redistribute it and/or modify 6 + ~ it under the terms of the GNU Affero General Public License as published 7 + ~ by the Free Software Foundation, either version 3 of the License, or 8 + ~ (at your option) any later version. 9 + ~ 10 + ~ This program is distributed in the hope that it will be useful, 11 + ~ but WITHOUT ANY WARRANTY; without even the implied warranty of 12 + ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 + ~ GNU Affero General Public License for more details. 14 + ~ 15 + ~ You should have received a copy of the GNU Affero General Public License 16 + ~ along with this program. If not, see <https://www.gnu.org/licenses/>. 17 + --> 18 + 19 + <component name="ProjectRunConfigurationManager"> 20 + <configuration default="false" name="Local development run" type="MiseTomlTaskRunConfigurationType" factoryName="MiseTomlTask"> 21 + <mise configEnvironment="" taskName="local-devel" workingDirectory="$PROJECT_DIR$" taskParams=""> 22 + <envs /> 23 + </mise> 24 + <method v="2" /> 25 + </configuration> 26 + </component>
+26
backend/impl-rs/.run/Prep for local development run.run.xml
··· 1 + <!-- 2 + ~ Lumina/Peonies 3 + ~ Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. 4 + ~ 5 + ~ This program is free software: you can redistribute it and/or modify 6 + ~ it under the terms of the GNU Affero General Public License as published 7 + ~ by the Free Software Foundation, either version 3 of the License, or 8 + ~ (at your option) any later version. 9 + ~ 10 + ~ This program is distributed in the hope that it will be useful, 11 + ~ but WITHOUT ANY WARRANTY; without even the implied warranty of 12 + ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 + ~ GNU Affero General Public License for more details. 14 + ~ 15 + ~ You should have received a copy of the GNU Affero General Public License 16 + ~ along with this program. If not, see <https://www.gnu.org/licenses/>. 17 + --> 18 + 19 + <component name="ProjectRunConfigurationManager"> 20 + <configuration default="false" name="Prep for local development run" type="MiseTomlTaskRunConfigurationType" factoryName="MiseTomlTask"> 21 + <mise configEnvironment="" taskName="local-devel-prep" workingDirectory="$PROJECT_DIR$" taskParams=""> 22 + <envs /> 23 + </mise> 24 + <method v="2" /> 25 + </configuration> 26 + </component>
+29
backend/impl-rs/.tangled/workflows/check.yaml
··· 1 + when: 2 + - event: ["push", "manual"] 3 + branch: ["develop"] 4 + - event: ["pull_request"] 5 + branch: ["main"] # We have no main, yet. 6 + 7 + engine: "nixery" 8 + 9 + # using the default values 10 + clone: 11 + skip: false 12 + depth: 1 13 + submodules: false 14 + 15 + dependencies: 16 + nixpkgs/nixpkgs-unstable: 17 + # I wish I could just use the flake, but alas aargh 18 + - mise 19 + 20 + #environment: 21 + # MY_ENV_VAR: "MY_ENV_VALUE" 22 + 23 + steps: 24 + - name: "Run tests through Mise" 25 + # I don't think this'll work, we have no database to reflect from! 26 + command: "mise i && mise run check" 27 + environment: 28 + GOOS: "darwin" 29 + GOARCH: "arm64"
+34
backend/impl-rs/ABOUT.md
··· 1 + # Lumina Social Platform 2 + 3 + Lumina is a modern, privacy-conscious social platform designed for real-time communication and content sharing. Built 4 + with Rust on the backend and Gleam/Lustre on the client, Lumina aims to provide a secure, flexible, and user-friendly 5 + experience for individuals and communities. 6 + 7 + ## Key Features 8 + 9 + - **User Registration & Authentication:** Secure account creation, login, and session management with support for both 10 + SQLite and Postgres databases. 11 + - **Real-Time Communication:** WebSocket-based messaging for instant updates and interactions. 12 + - **Content Sharing:** Support for articles, media, and text posts 13 + - **Semi-decentralised:** Instances (servers) can communicate over websockets with allowlisted other instances to blend 14 + certain aspects of their respective timelines, the identification relies on the existing DNS system and on SSH 15 + keys. 16 + - **Bubbles vs timelines:** While Lumina has a global timeline and one for following and mutuals, it also has 'bubbles', 17 + semi-isolated timelines you can be a member of, that sort of operate as communities for specific subjects. 18 + - **Post modes:** Posts and DM's switch modes depending on what you want to put in them. _Want to post a short text? 19 + Sure! An article post? Okay. A gram post? Great!_ In DM's this is slightly different, but following the same 20 + principle! 21 + 22 + ## Vision 23 + 24 + Lumina is intended to be a welcoming, open-source alternative to mainstream social networks, prioritizing user control, 25 + transparency, and extensibility. The project is in active development, and contributions are encouraged! 26 + 27 + ## Getting Involved 28 + 29 + If you're interested in contributing, please read this file and the codebase to understand the current architecture and 30 + goals. Feel free to open issues, suggest features, or submit pull requests. 31 + 32 + --- 33 + 34 + _For more details, see the README.MD and WHY.md files._
+226
backend/impl-rs/CONTRIBUTING.md
··· 1 + # Contributing to Lumina 2 + 3 + > IMPORTANT 4 + > 5 + > This project is primarily hosted on [Tangled.org](https://tangled.org/strawmelonjuice.com/Lumina). 6 + 7 + Thank you for your interest in contributing! This document outlines how to set up your environment, follow the style, 8 + and submit changes. 9 + 10 + --- 11 + 12 + ## Where to contribute 13 + 14 + - Main repository (primary): <https://tangled.org/strawmelonjuice.com/Lumina> 15 + - Email-to-pull: [lumina@strawmelonjuice.com](mailto:lumina@strawmelonjuice.com) 16 + 17 + Please open issues and submit pull requests (PRs) on the main repository or mirror and email me to pull them to the 18 + main repository. 19 + 20 + --- 21 + 22 + ## Code of Conduct 23 + 24 + - Be respectful and constructive. 25 + - Assume good intent and seek clarity. 26 + - Harassment, discrimination, and personal attacks are not tolerated. 27 + 28 + If you experience or witness unacceptable behavior, contact the maintainer via the main forge, [mlcbloeiman@strawmelonjuice.com](mailto:mlcbloeiman@strawmelonjuice.com) or [mar:strawmelonjuice.com on Matrix](https://matrix.to/#/@mar:strawmelonjuice.com). 29 + 30 + --- 31 + 32 + ## Project layout 33 + 34 + - `server/` — Rust (Rocket) server application. 35 + - `client/` — Gleam application targeting JavaScript (bundled to browser). 36 + - `data/` — Local runtime data directory (created by tasks). 37 + - Root files — Workspace-level configuration, licence, docs, and Docker-related files. 38 + 39 + --- 40 + 41 + ## Prerequisites (Needs to be updated) 42 + 43 + - Rust toolchain (latest stable) with `rustfmt` and (optionally) `clippy`. 44 + - Gleam. 45 + - Bun. 46 + - Node is not required when using Bun. 47 + - Redis/Postgres if you want to test those backends, otherwise SQLite is the default for development. 48 + - Optional: Watchexec (installed automatically via tasks), Taplo, Prettier (run via tasks). 49 + 50 + This repository uses `mise` to manage tools and developer tasks: 51 + 52 + - Install mise: https://mise.jdx.dev/ 53 + - Then install toolchain/tool deps used by the project and tasks: 54 + ```sh 55 + mise install 56 + ``` 57 + 58 + --- 59 + 60 + ## Local setup (Needs to be updated) 61 + 62 + Local setup is pretty easy with mise, and to not do it with mise is actually kind of unthinkable for me at this point. 63 + 64 + Typical preparation: 65 + 66 + ```sh 67 + # From repo root 68 + mise install 69 + mise run build-env-image-podman # Build the environment image for podman, allowing you to build quicker. 70 + # And then later: 71 + mise run devel # or devel-watch 72 + ``` 73 + 74 + Running in the typical dev pod allows you to also access PGWeb on http://127.0.0.1:8081 and Redis Commander 75 + on http://127.0.0.1:8082. 76 + 77 + Running 'bare-metal' is usually prepared as well, since we need a database: 78 + 79 + ```sh 80 + # From repo root 81 + mise install 82 + mise run local-devel-prep # Because you'll need a database running somewhere. 83 + # And then later: 84 + mise run local-devel # or local-devel-watch 85 + # To have the tools from the pod development environment available on your host machine, run: 86 + mise run local-devel-dataexplorer # For PGWeb and Redis Commander on host machine. 87 + ``` 88 + 89 + This means everything is still running in podman, except for lumina itself, which runs on your host machine. This 90 + sometimes speeds up build speeds. 91 + 92 + The README should have some environment variables you could set. 93 + 94 + There are more variations. Run `mise run` to list all of them. 95 + 96 + The build pipeline (mise) takes care of client (Gleam) compilation and styles, and it will create necessary data 97 + directories. 98 + 99 + When running Lumina server in devel mode, it automatically creates two accounts for you and one of those has an attached 100 + post on the global timeline. 101 + 102 + | Username | Email | Password | 103 + | --------- | ----------------- | ---------------- | 104 + | testuser1 | test@lumina123.co | MyTestPassw9292! | 105 + | testuser2 | test@lumina234.co | MyTestPassw9292! | 106 + 107 + --- 108 + 109 + ## Formatting, checks, and quality 110 + 111 + Before pushing or opening a PR, run: 112 + 113 + ```sh 114 + # Format Rust, Gleam, and meta files 115 + mise run format 116 + 117 + # Basic checks (Rust and Gleam) 118 + mise run check 119 + 120 + # Build to ensure it compiles 121 + mise run build-server 122 + 123 + # Optionally: There are some watching tasks and tasks to run Lumina in development mode 124 + mise run check-watch 125 + mise run devel 126 + # ..etc. 127 + ``` 128 + 129 + Conventions: 130 + 131 + - Rust code is formatted with `rustfmt`. 132 + - Gleam code is formatted with `gleam format`. 133 + - Meta files are formatted via Prettier and Taplo. 134 + - Prefer clear, explicit error handling and logs over silent failures. 135 + - Keep modules cohesive and prefer small, testable units. 136 + 137 + --- 138 + 139 + ## Branching and commit messages 140 + 141 + - Create feature branches from the default branch (typically `developemnt`). 142 + - Suggested naming: `feat/<short-name>`, `fix/<short-name>`, `docs/<short-name>`, `chore/<short-name>`. These are 143 + preferred but not enforced. 144 + - Commit messages: 145 + - Be concise and descriptive. 146 + - Prefer Conventional Commits style when possible: 147 + - `feat: add user session cleanup job` 148 + - `fix(server): handle empty redis url` 149 + - `docs: improve contributing guide` 150 + - Prefer signing and adding a `signed-off-by` trail. 151 + 152 + --- 153 + 154 + ## Pull requests 155 + 156 + PR checklist: 157 + 158 + - Code is formatted and builds locally. 159 + - `mise run check` passes. 160 + - Include tests when adding logic or fixing bugs (Rust: `cargo test`; Gleam: `gleam test`). 161 + - Update docs (README/WHY/ABOUT) where relevant. 162 + - Keep PRs focused. Large refactors should be split or well-justified. 163 + 164 + Review expectations: 165 + 166 + - Be prepared to discuss design decisions and trade-offs. 167 + - Address review comments via additional commits (avoid force-push unless asked). 168 + - Squash commits at merge time if appropriate. 169 + 170 + --- 171 + 172 + ## Reporting bugs 173 + 174 + When filing a bug report: 175 + 176 + - Describe what you expected to happen and what actually happened. 177 + - Include steps to reproduce. 178 + - Provide version info (commit hash) and environment (OS, DB type, Redis/Postgres versions). 179 + - Include relevant logs or stack traces when possible. 180 + 181 + --- 182 + 183 + ## Feature requests 184 + 185 + When proposing a feature: 186 + 187 + - Explain the problem it solves and the target use-cases. 188 + - Consider alternatives and why this approach is preferred. 189 + - If possible, include a small design sketch (API, data flow, or UI). 190 + - Prototype branches are welcome if they help the discussion. 191 + 192 + --- 193 + 194 + ## Security 195 + 196 + If you discover a security issue: 197 + 198 + - Do not open a public issue with sensitive details. 199 + - Contact me privately via the email on my main forge. 200 + - Provide clear reproduction steps and affected versions. 201 + - A fix or mitigation plan will be discussed before public disclosure. 202 + 203 + --- 204 + 205 + ## Tests 206 + 207 + - Rust: place tests alongside modules or in `server/` integration tests, use `cargo test`. 208 + - Gleam: use `gleam test` for client-side logic where applicable. 209 + - Prefer deterministic tests; avoid timing-based flakes. 210 + - Add tests for new behavior and regression tests for fixed bugs. 211 + 212 + --- 213 + 214 + ## Licence and contributor terms 215 + 216 + By contributing, you agree that your contributions are licensed under the Licence of this repository, 217 + unless explicitly stated otherwise in writing. 218 + 219 + See `Licence` at the repository root. 220 + 221 + --- 222 + 223 + ## Thank you 224 + 225 + Your time and effort are appreciated. Whether you’re reporting a bug, improving docs, or adding features—every 226 + contribution helps make Lumina better.
+1
backend/impl-rs/CONTRIBUTORS
··· 1 + MLC 'strawmelonjuice' Bloeiman <https://strawmelonjuice.com/> -- Original Author, Maintainer, Currently Sole Contributor
+3424
backend/impl-rs/Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "addr2line" 7 + version = "0.24.2" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 + dependencies = [ 11 + "gimli", 12 + ] 13 + 14 + [[package]] 15 + name = "adler2" 16 + version = "2.0.0" 17 + source = "registry+https://github.com/rust-lang/crates.io-index" 18 + checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 + 20 + [[package]] 21 + name = "aho-corasick" 22 + version = "1.1.3" 23 + source = "registry+https://github.com/rust-lang/crates.io-index" 24 + checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 25 + dependencies = [ 26 + "memchr", 27 + ] 28 + 29 + [[package]] 30 + name = "allocator-api2" 31 + version = "0.2.21" 32 + source = "registry+https://github.com/rust-lang/crates.io-index" 33 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 34 + 35 + [[package]] 36 + name = "anyhow" 37 + version = "1.0.101" 38 + source = "registry+https://github.com/rust-lang/crates.io-index" 39 + checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" 40 + 41 + [[package]] 42 + name = "arc-swap" 43 + version = "1.7.1" 44 + source = "registry+https://github.com/rust-lang/crates.io-index" 45 + checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" 46 + 47 + [[package]] 48 + name = "async-stream" 49 + version = "0.3.6" 50 + source = "registry+https://github.com/rust-lang/crates.io-index" 51 + checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" 52 + dependencies = [ 53 + "async-stream-impl", 54 + "futures-core", 55 + "pin-project-lite", 56 + ] 57 + 58 + [[package]] 59 + name = "async-stream-impl" 60 + version = "0.3.6" 61 + source = "registry+https://github.com/rust-lang/crates.io-index" 62 + checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" 63 + dependencies = [ 64 + "proc-macro2", 65 + "quote", 66 + "syn", 67 + ] 68 + 69 + [[package]] 70 + name = "async-trait" 71 + version = "0.1.88" 72 + source = "registry+https://github.com/rust-lang/crates.io-index" 73 + checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" 74 + dependencies = [ 75 + "proc-macro2", 76 + "quote", 77 + "syn", 78 + ] 79 + 80 + [[package]] 81 + name = "atoi" 82 + version = "2.0.0" 83 + source = "registry+https://github.com/rust-lang/crates.io-index" 84 + checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" 85 + dependencies = [ 86 + "num-traits", 87 + ] 88 + 89 + [[package]] 90 + name = "atomic" 91 + version = "0.5.3" 92 + source = "registry+https://github.com/rust-lang/crates.io-index" 93 + checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" 94 + 95 + [[package]] 96 + name = "atomic" 97 + version = "0.6.0" 98 + source = "registry+https://github.com/rust-lang/crates.io-index" 99 + checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" 100 + dependencies = [ 101 + "bytemuck", 102 + ] 103 + 104 + [[package]] 105 + name = "autocfg" 106 + version = "1.4.0" 107 + source = "registry+https://github.com/rust-lang/crates.io-index" 108 + checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 109 + 110 + [[package]] 111 + name = "backon" 112 + version = "1.6.0" 113 + source = "registry+https://github.com/rust-lang/crates.io-index" 114 + checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" 115 + dependencies = [ 116 + "fastrand", 117 + ] 118 + 119 + [[package]] 120 + name = "backtrace" 121 + version = "0.3.74" 122 + source = "registry+https://github.com/rust-lang/crates.io-index" 123 + checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" 124 + dependencies = [ 125 + "addr2line", 126 + "cfg-if", 127 + "libc", 128 + "miniz_oxide", 129 + "object", 130 + "rustc-demangle", 131 + "windows-targets 0.52.6", 132 + ] 133 + 134 + [[package]] 135 + name = "base64" 136 + version = "0.22.1" 137 + source = "registry+https://github.com/rust-lang/crates.io-index" 138 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 139 + 140 + [[package]] 141 + name = "base64ct" 142 + version = "1.8.3" 143 + source = "registry+https://github.com/rust-lang/crates.io-index" 144 + checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" 145 + 146 + [[package]] 147 + name = "bb8" 148 + version = "0.8.6" 149 + source = "registry+https://github.com/rust-lang/crates.io-index" 150 + checksum = "d89aabfae550a5c44b43ab941844ffcd2e993cb6900b342debf59e9ea74acdb8" 151 + dependencies = [ 152 + "async-trait", 153 + "futures-util", 154 + "parking_lot", 155 + "tokio", 156 + ] 157 + 158 + [[package]] 159 + name = "bb8-postgres" 160 + version = "0.8.1" 161 + source = "registry+https://github.com/rust-lang/crates.io-index" 162 + checksum = "56ac82c42eb30889b5c4ee4763a24b8c566518171ebea648cd7e3bc532c60680" 163 + dependencies = [ 164 + "async-trait", 165 + "bb8", 166 + "tokio", 167 + "tokio-postgres", 168 + ] 169 + 170 + [[package]] 171 + name = "bb8-redis" 172 + version = "0.17.0" 173 + source = "registry+https://github.com/rust-lang/crates.io-index" 174 + checksum = "1781f22daa0ae97d934fdf04a5c66646f154a164c4bdc157ec8d3c11166c05cc" 175 + dependencies = [ 176 + "async-trait", 177 + "bb8", 178 + "redis", 179 + ] 180 + 181 + [[package]] 182 + name = "bcrypt" 183 + version = "0.17.0" 184 + source = "registry+https://github.com/rust-lang/crates.io-index" 185 + checksum = "92758ad6077e4c76a6cadbce5005f666df70d4f13b19976b1a8062eef880040f" 186 + dependencies = [ 187 + "base64", 188 + "blowfish", 189 + "getrandom 0.3.2", 190 + "subtle", 191 + "zeroize", 192 + ] 193 + 194 + [[package]] 195 + name = "binascii" 196 + version = "0.1.4" 197 + source = "registry+https://github.com/rust-lang/crates.io-index" 198 + checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" 199 + 200 + [[package]] 201 + name = "bitflags" 202 + version = "2.11.0" 203 + source = "registry+https://github.com/rust-lang/crates.io-index" 204 + checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 205 + dependencies = [ 206 + "serde_core", 207 + ] 208 + 209 + [[package]] 210 + name = "block-buffer" 211 + version = "0.10.4" 212 + source = "registry+https://github.com/rust-lang/crates.io-index" 213 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 214 + dependencies = [ 215 + "generic-array", 216 + ] 217 + 218 + [[package]] 219 + name = "blowfish" 220 + version = "0.9.1" 221 + source = "registry+https://github.com/rust-lang/crates.io-index" 222 + checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" 223 + dependencies = [ 224 + "byteorder", 225 + "cipher", 226 + ] 227 + 228 + [[package]] 229 + name = "bumpalo" 230 + version = "3.17.0" 231 + source = "registry+https://github.com/rust-lang/crates.io-index" 232 + checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 233 + 234 + [[package]] 235 + name = "bytecount" 236 + version = "0.6.8" 237 + source = "registry+https://github.com/rust-lang/crates.io-index" 238 + checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" 239 + 240 + [[package]] 241 + name = "bytemuck" 242 + version = "1.22.0" 243 + source = "registry+https://github.com/rust-lang/crates.io-index" 244 + checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" 245 + 246 + [[package]] 247 + name = "byteorder" 248 + version = "1.5.0" 249 + source = "registry+https://github.com/rust-lang/crates.io-index" 250 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 251 + 252 + [[package]] 253 + name = "bytes" 254 + version = "1.10.1" 255 + source = "registry+https://github.com/rust-lang/crates.io-index" 256 + checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 257 + 258 + [[package]] 259 + name = "cc" 260 + version = "1.2.19" 261 + source = "registry+https://github.com/rust-lang/crates.io-index" 262 + checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" 263 + dependencies = [ 264 + "shlex", 265 + ] 266 + 267 + [[package]] 268 + name = "cfg-if" 269 + version = "1.0.0" 270 + source = "registry+https://github.com/rust-lang/crates.io-index" 271 + checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 272 + 273 + [[package]] 274 + name = "cipher" 275 + version = "0.4.4" 276 + source = "registry+https://github.com/rust-lang/crates.io-index" 277 + checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" 278 + dependencies = [ 279 + "crypto-common", 280 + "inout", 281 + ] 282 + 283 + [[package]] 284 + name = "combine" 285 + version = "4.6.7" 286 + source = "registry+https://github.com/rust-lang/crates.io-index" 287 + checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" 288 + dependencies = [ 289 + "bytes", 290 + "futures-core", 291 + "memchr", 292 + "pin-project-lite", 293 + "tokio", 294 + "tokio-util", 295 + ] 296 + 297 + [[package]] 298 + name = "concurrent-queue" 299 + version = "2.5.0" 300 + source = "registry+https://github.com/rust-lang/crates.io-index" 301 + checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" 302 + dependencies = [ 303 + "crossbeam-utils", 304 + ] 305 + 306 + [[package]] 307 + name = "const-oid" 308 + version = "0.9.6" 309 + source = "registry+https://github.com/rust-lang/crates.io-index" 310 + checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 311 + 312 + [[package]] 313 + name = "cookie" 314 + version = "0.18.1" 315 + source = "registry+https://github.com/rust-lang/crates.io-index" 316 + checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" 317 + dependencies = [ 318 + "percent-encoding", 319 + "time", 320 + "version_check", 321 + ] 322 + 323 + [[package]] 324 + name = "core-foundation" 325 + version = "0.10.1" 326 + source = "registry+https://github.com/rust-lang/crates.io-index" 327 + checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" 328 + dependencies = [ 329 + "core-foundation-sys", 330 + "libc", 331 + ] 332 + 333 + [[package]] 334 + name = "core-foundation-sys" 335 + version = "0.8.7" 336 + source = "registry+https://github.com/rust-lang/crates.io-index" 337 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 338 + 339 + [[package]] 340 + name = "cpufeatures" 341 + version = "0.2.17" 342 + source = "registry+https://github.com/rust-lang/crates.io-index" 343 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 344 + dependencies = [ 345 + "libc", 346 + ] 347 + 348 + [[package]] 349 + name = "crc" 350 + version = "3.4.0" 351 + source = "registry+https://github.com/rust-lang/crates.io-index" 352 + checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" 353 + dependencies = [ 354 + "crc-catalog", 355 + ] 356 + 357 + [[package]] 358 + name = "crc-catalog" 359 + version = "2.4.0" 360 + source = "registry+https://github.com/rust-lang/crates.io-index" 361 + checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" 362 + 363 + [[package]] 364 + name = "crossbeam-queue" 365 + version = "0.3.12" 366 + source = "registry+https://github.com/rust-lang/crates.io-index" 367 + checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" 368 + dependencies = [ 369 + "crossbeam-utils", 370 + ] 371 + 372 + [[package]] 373 + name = "crossbeam-utils" 374 + version = "0.8.21" 375 + source = "registry+https://github.com/rust-lang/crates.io-index" 376 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 377 + 378 + [[package]] 379 + name = "crypto-common" 380 + version = "0.1.6" 381 + source = "registry+https://github.com/rust-lang/crates.io-index" 382 + checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 383 + dependencies = [ 384 + "generic-array", 385 + "typenum", 386 + ] 387 + 388 + [[package]] 389 + name = "cynthia_con" 390 + version = "0.1.4" 391 + source = "registry+https://github.com/rust-lang/crates.io-index" 392 + checksum = "888624dbb81bb9beb266af908f26fd3a97255ff0a687f7f26edaaa5f1de69bbb" 393 + dependencies = [ 394 + "termsize", 395 + ] 396 + 397 + [[package]] 398 + name = "data-encoding" 399 + version = "2.9.0" 400 + source = "registry+https://github.com/rust-lang/crates.io-index" 401 + checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" 402 + 403 + [[package]] 404 + name = "der" 405 + version = "0.7.10" 406 + source = "registry+https://github.com/rust-lang/crates.io-index" 407 + checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 408 + dependencies = [ 409 + "const-oid", 410 + "pem-rfc7468", 411 + "zeroize", 412 + ] 413 + 414 + [[package]] 415 + name = "deranged" 416 + version = "0.4.0" 417 + source = "registry+https://github.com/rust-lang/crates.io-index" 418 + checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" 419 + dependencies = [ 420 + "powerfmt", 421 + ] 422 + 423 + [[package]] 424 + name = "devise" 425 + version = "0.4.2" 426 + source = "registry+https://github.com/rust-lang/crates.io-index" 427 + checksum = "f1d90b0c4c777a2cad215e3c7be59ac7c15adf45cf76317009b7d096d46f651d" 428 + dependencies = [ 429 + "devise_codegen", 430 + "devise_core", 431 + ] 432 + 433 + [[package]] 434 + name = "devise_codegen" 435 + version = "0.4.2" 436 + source = "registry+https://github.com/rust-lang/crates.io-index" 437 + checksum = "71b28680d8be17a570a2334922518be6adc3f58ecc880cbb404eaeb8624fd867" 438 + dependencies = [ 439 + "devise_core", 440 + "quote", 441 + ] 442 + 443 + [[package]] 444 + name = "devise_core" 445 + version = "0.4.2" 446 + source = "registry+https://github.com/rust-lang/crates.io-index" 447 + checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" 448 + dependencies = [ 449 + "bitflags", 450 + "proc-macro2", 451 + "proc-macro2-diagnostics", 452 + "quote", 453 + "syn", 454 + ] 455 + 456 + [[package]] 457 + name = "digest" 458 + version = "0.10.7" 459 + source = "registry+https://github.com/rust-lang/crates.io-index" 460 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 461 + dependencies = [ 462 + "block-buffer", 463 + "const-oid", 464 + "crypto-common", 465 + "subtle", 466 + ] 467 + 468 + [[package]] 469 + name = "displaydoc" 470 + version = "0.2.5" 471 + source = "registry+https://github.com/rust-lang/crates.io-index" 472 + checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 473 + dependencies = [ 474 + "proc-macro2", 475 + "quote", 476 + "syn", 477 + ] 478 + 479 + [[package]] 480 + name = "dotenv" 481 + version = "0.15.0" 482 + source = "registry+https://github.com/rust-lang/crates.io-index" 483 + checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" 484 + 485 + [[package]] 486 + name = "dotenvy" 487 + version = "0.15.7" 488 + source = "registry+https://github.com/rust-lang/crates.io-index" 489 + checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" 490 + 491 + [[package]] 492 + name = "either" 493 + version = "1.15.0" 494 + source = "registry+https://github.com/rust-lang/crates.io-index" 495 + checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 496 + dependencies = [ 497 + "serde", 498 + ] 499 + 500 + [[package]] 501 + name = "encoding_rs" 502 + version = "0.8.35" 503 + source = "registry+https://github.com/rust-lang/crates.io-index" 504 + checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 505 + dependencies = [ 506 + "cfg-if", 507 + ] 508 + 509 + [[package]] 510 + name = "equivalent" 511 + version = "1.0.2" 512 + source = "registry+https://github.com/rust-lang/crates.io-index" 513 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 514 + 515 + [[package]] 516 + name = "errno" 517 + version = "0.3.11" 518 + source = "registry+https://github.com/rust-lang/crates.io-index" 519 + checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" 520 + dependencies = [ 521 + "libc", 522 + "windows-sys 0.59.0", 523 + ] 524 + 525 + [[package]] 526 + name = "etcetera" 527 + version = "0.8.0" 528 + source = "registry+https://github.com/rust-lang/crates.io-index" 529 + checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" 530 + dependencies = [ 531 + "cfg-if", 532 + "home", 533 + "windows-sys 0.48.0", 534 + ] 535 + 536 + [[package]] 537 + name = "event-listener" 538 + version = "5.4.1" 539 + source = "registry+https://github.com/rust-lang/crates.io-index" 540 + checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" 541 + dependencies = [ 542 + "concurrent-queue", 543 + "parking", 544 + "pin-project-lite", 545 + ] 546 + 547 + [[package]] 548 + name = "fallible-iterator" 549 + version = "0.2.0" 550 + source = "registry+https://github.com/rust-lang/crates.io-index" 551 + checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" 552 + 553 + [[package]] 554 + name = "fastrand" 555 + version = "2.3.0" 556 + source = "registry+https://github.com/rust-lang/crates.io-index" 557 + checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 558 + 559 + [[package]] 560 + name = "figment" 561 + version = "0.10.19" 562 + source = "registry+https://github.com/rust-lang/crates.io-index" 563 + checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" 564 + dependencies = [ 565 + "atomic 0.6.0", 566 + "pear", 567 + "serde", 568 + "toml", 569 + "uncased", 570 + "version_check", 571 + ] 572 + 573 + [[package]] 574 + name = "flume" 575 + version = "0.11.1" 576 + source = "registry+https://github.com/rust-lang/crates.io-index" 577 + checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" 578 + dependencies = [ 579 + "futures-core", 580 + "futures-sink", 581 + "spin", 582 + ] 583 + 584 + [[package]] 585 + name = "fnv" 586 + version = "1.0.7" 587 + source = "registry+https://github.com/rust-lang/crates.io-index" 588 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 589 + 590 + [[package]] 591 + name = "foldhash" 592 + version = "0.1.5" 593 + source = "registry+https://github.com/rust-lang/crates.io-index" 594 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 595 + 596 + [[package]] 597 + name = "foreign-types" 598 + version = "0.3.2" 599 + source = "registry+https://github.com/rust-lang/crates.io-index" 600 + checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 601 + dependencies = [ 602 + "foreign-types-shared", 603 + ] 604 + 605 + [[package]] 606 + name = "foreign-types-shared" 607 + version = "0.1.1" 608 + source = "registry+https://github.com/rust-lang/crates.io-index" 609 + checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 610 + 611 + [[package]] 612 + name = "form_urlencoded" 613 + version = "1.2.1" 614 + source = "registry+https://github.com/rust-lang/crates.io-index" 615 + checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" 616 + dependencies = [ 617 + "percent-encoding", 618 + ] 619 + 620 + [[package]] 621 + name = "futures" 622 + version = "0.3.31" 623 + source = "registry+https://github.com/rust-lang/crates.io-index" 624 + checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 625 + dependencies = [ 626 + "futures-channel", 627 + "futures-core", 628 + "futures-executor", 629 + "futures-io", 630 + "futures-sink", 631 + "futures-task", 632 + "futures-util", 633 + ] 634 + 635 + [[package]] 636 + name = "futures-channel" 637 + version = "0.3.31" 638 + source = "registry+https://github.com/rust-lang/crates.io-index" 639 + checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 640 + dependencies = [ 641 + "futures-core", 642 + "futures-sink", 643 + ] 644 + 645 + [[package]] 646 + name = "futures-core" 647 + version = "0.3.31" 648 + source = "registry+https://github.com/rust-lang/crates.io-index" 649 + checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 650 + 651 + [[package]] 652 + name = "futures-executor" 653 + version = "0.3.31" 654 + source = "registry+https://github.com/rust-lang/crates.io-index" 655 + checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" 656 + dependencies = [ 657 + "futures-core", 658 + "futures-task", 659 + "futures-util", 660 + ] 661 + 662 + [[package]] 663 + name = "futures-intrusive" 664 + version = "0.5.0" 665 + source = "registry+https://github.com/rust-lang/crates.io-index" 666 + checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" 667 + dependencies = [ 668 + "futures-core", 669 + "lock_api", 670 + "parking_lot", 671 + ] 672 + 673 + [[package]] 674 + name = "futures-io" 675 + version = "0.3.31" 676 + source = "registry+https://github.com/rust-lang/crates.io-index" 677 + checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 678 + 679 + [[package]] 680 + name = "futures-macro" 681 + version = "0.3.31" 682 + source = "registry+https://github.com/rust-lang/crates.io-index" 683 + checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 684 + dependencies = [ 685 + "proc-macro2", 686 + "quote", 687 + "syn", 688 + ] 689 + 690 + [[package]] 691 + name = "futures-sink" 692 + version = "0.3.31" 693 + source = "registry+https://github.com/rust-lang/crates.io-index" 694 + checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 695 + 696 + [[package]] 697 + name = "futures-task" 698 + version = "0.3.31" 699 + source = "registry+https://github.com/rust-lang/crates.io-index" 700 + checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 701 + 702 + [[package]] 703 + name = "futures-util" 704 + version = "0.3.31" 705 + source = "registry+https://github.com/rust-lang/crates.io-index" 706 + checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 707 + dependencies = [ 708 + "futures-channel", 709 + "futures-core", 710 + "futures-io", 711 + "futures-macro", 712 + "futures-sink", 713 + "futures-task", 714 + "memchr", 715 + "pin-project-lite", 716 + "pin-utils", 717 + "slab", 718 + ] 719 + 720 + [[package]] 721 + name = "generator" 722 + version = "0.7.5" 723 + source = "registry+https://github.com/rust-lang/crates.io-index" 724 + checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" 725 + dependencies = [ 726 + "cc", 727 + "libc", 728 + "log", 729 + "rustversion", 730 + "windows", 731 + ] 732 + 733 + [[package]] 734 + name = "generic-array" 735 + version = "0.14.7" 736 + source = "registry+https://github.com/rust-lang/crates.io-index" 737 + checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 738 + dependencies = [ 739 + "typenum", 740 + "version_check", 741 + ] 742 + 743 + [[package]] 744 + name = "getrandom" 745 + version = "0.2.16" 746 + source = "registry+https://github.com/rust-lang/crates.io-index" 747 + checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 748 + dependencies = [ 749 + "cfg-if", 750 + "libc", 751 + "wasi 0.11.0+wasi-snapshot-preview1", 752 + ] 753 + 754 + [[package]] 755 + name = "getrandom" 756 + version = "0.3.2" 757 + source = "registry+https://github.com/rust-lang/crates.io-index" 758 + checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" 759 + dependencies = [ 760 + "cfg-if", 761 + "libc", 762 + "r-efi", 763 + "wasi 0.14.2+wasi-0.2.4", 764 + ] 765 + 766 + [[package]] 767 + name = "gimli" 768 + version = "0.31.1" 769 + source = "registry+https://github.com/rust-lang/crates.io-index" 770 + checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 771 + 772 + [[package]] 773 + name = "glob" 774 + version = "0.3.2" 775 + source = "registry+https://github.com/rust-lang/crates.io-index" 776 + checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" 777 + 778 + [[package]] 779 + name = "h2" 780 + version = "0.3.26" 781 + source = "registry+https://github.com/rust-lang/crates.io-index" 782 + checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" 783 + dependencies = [ 784 + "bytes", 785 + "fnv", 786 + "futures-core", 787 + "futures-sink", 788 + "futures-util", 789 + "http 0.2.12", 790 + "indexmap", 791 + "slab", 792 + "tokio", 793 + "tokio-util", 794 + "tracing", 795 + ] 796 + 797 + [[package]] 798 + name = "hashbrown" 799 + version = "0.15.2" 800 + source = "registry+https://github.com/rust-lang/crates.io-index" 801 + checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 802 + dependencies = [ 803 + "allocator-api2", 804 + "equivalent", 805 + "foldhash", 806 + ] 807 + 808 + [[package]] 809 + name = "hashlink" 810 + version = "0.10.0" 811 + source = "registry+https://github.com/rust-lang/crates.io-index" 812 + checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" 813 + dependencies = [ 814 + "hashbrown", 815 + ] 816 + 817 + [[package]] 818 + name = "heck" 819 + version = "0.5.0" 820 + source = "registry+https://github.com/rust-lang/crates.io-index" 821 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 822 + 823 + [[package]] 824 + name = "hermit-abi" 825 + version = "0.3.9" 826 + source = "registry+https://github.com/rust-lang/crates.io-index" 827 + checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" 828 + 829 + [[package]] 830 + name = "hermit-abi" 831 + version = "0.5.0" 832 + source = "registry+https://github.com/rust-lang/crates.io-index" 833 + checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" 834 + 835 + [[package]] 836 + name = "hex" 837 + version = "0.4.3" 838 + source = "registry+https://github.com/rust-lang/crates.io-index" 839 + checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 840 + 841 + [[package]] 842 + name = "hkdf" 843 + version = "0.12.4" 844 + source = "registry+https://github.com/rust-lang/crates.io-index" 845 + checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" 846 + dependencies = [ 847 + "hmac", 848 + ] 849 + 850 + [[package]] 851 + name = "hmac" 852 + version = "0.12.1" 853 + source = "registry+https://github.com/rust-lang/crates.io-index" 854 + checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 855 + dependencies = [ 856 + "digest", 857 + ] 858 + 859 + [[package]] 860 + name = "home" 861 + version = "0.5.12" 862 + source = "registry+https://github.com/rust-lang/crates.io-index" 863 + checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" 864 + dependencies = [ 865 + "windows-sys 0.61.2", 866 + ] 867 + 868 + [[package]] 869 + name = "http" 870 + version = "0.2.12" 871 + source = "registry+https://github.com/rust-lang/crates.io-index" 872 + checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" 873 + dependencies = [ 874 + "bytes", 875 + "fnv", 876 + "itoa", 877 + ] 878 + 879 + [[package]] 880 + name = "http" 881 + version = "1.3.1" 882 + source = "registry+https://github.com/rust-lang/crates.io-index" 883 + checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" 884 + dependencies = [ 885 + "bytes", 886 + "fnv", 887 + "itoa", 888 + ] 889 + 890 + [[package]] 891 + name = "http-body" 892 + version = "0.4.6" 893 + source = "registry+https://github.com/rust-lang/crates.io-index" 894 + checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" 895 + dependencies = [ 896 + "bytes", 897 + "http 0.2.12", 898 + "pin-project-lite", 899 + ] 900 + 901 + [[package]] 902 + name = "httparse" 903 + version = "1.10.1" 904 + source = "registry+https://github.com/rust-lang/crates.io-index" 905 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 906 + 907 + [[package]] 908 + name = "httpdate" 909 + version = "1.0.3" 910 + source = "registry+https://github.com/rust-lang/crates.io-index" 911 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 912 + 913 + [[package]] 914 + name = "hyper" 915 + version = "0.14.32" 916 + source = "registry+https://github.com/rust-lang/crates.io-index" 917 + checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" 918 + dependencies = [ 919 + "bytes", 920 + "futures-channel", 921 + "futures-core", 922 + "futures-util", 923 + "h2", 924 + "http 0.2.12", 925 + "http-body", 926 + "httparse", 927 + "httpdate", 928 + "itoa", 929 + "pin-project-lite", 930 + "socket2", 931 + "tokio", 932 + "tower-service", 933 + "tracing", 934 + "want", 935 + ] 936 + 937 + [[package]] 938 + name = "icu_collections" 939 + version = "1.5.0" 940 + source = "registry+https://github.com/rust-lang/crates.io-index" 941 + checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" 942 + dependencies = [ 943 + "displaydoc", 944 + "yoke", 945 + "zerofrom", 946 + "zerovec", 947 + ] 948 + 949 + [[package]] 950 + name = "icu_locid" 951 + version = "1.5.0" 952 + source = "registry+https://github.com/rust-lang/crates.io-index" 953 + checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" 954 + dependencies = [ 955 + "displaydoc", 956 + "litemap", 957 + "tinystr", 958 + "writeable", 959 + "zerovec", 960 + ] 961 + 962 + [[package]] 963 + name = "icu_locid_transform" 964 + version = "1.5.0" 965 + source = "registry+https://github.com/rust-lang/crates.io-index" 966 + checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" 967 + dependencies = [ 968 + "displaydoc", 969 + "icu_locid", 970 + "icu_locid_transform_data", 971 + "icu_provider", 972 + "tinystr", 973 + "zerovec", 974 + ] 975 + 976 + [[package]] 977 + name = "icu_locid_transform_data" 978 + version = "1.5.1" 979 + source = "registry+https://github.com/rust-lang/crates.io-index" 980 + checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" 981 + 982 + [[package]] 983 + name = "icu_normalizer" 984 + version = "1.5.0" 985 + source = "registry+https://github.com/rust-lang/crates.io-index" 986 + checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" 987 + dependencies = [ 988 + "displaydoc", 989 + "icu_collections", 990 + "icu_normalizer_data", 991 + "icu_properties", 992 + "icu_provider", 993 + "smallvec", 994 + "utf16_iter", 995 + "utf8_iter", 996 + "write16", 997 + "zerovec", 998 + ] 999 + 1000 + [[package]] 1001 + name = "icu_normalizer_data" 1002 + version = "1.5.1" 1003 + source = "registry+https://github.com/rust-lang/crates.io-index" 1004 + checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" 1005 + 1006 + [[package]] 1007 + name = "icu_properties" 1008 + version = "1.5.1" 1009 + source = "registry+https://github.com/rust-lang/crates.io-index" 1010 + checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" 1011 + dependencies = [ 1012 + "displaydoc", 1013 + "icu_collections", 1014 + "icu_locid_transform", 1015 + "icu_properties_data", 1016 + "icu_provider", 1017 + "tinystr", 1018 + "zerovec", 1019 + ] 1020 + 1021 + [[package]] 1022 + name = "icu_properties_data" 1023 + version = "1.5.1" 1024 + source = "registry+https://github.com/rust-lang/crates.io-index" 1025 + checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" 1026 + 1027 + [[package]] 1028 + name = "icu_provider" 1029 + version = "1.5.0" 1030 + source = "registry+https://github.com/rust-lang/crates.io-index" 1031 + checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" 1032 + dependencies = [ 1033 + "displaydoc", 1034 + "icu_locid", 1035 + "icu_provider_macros", 1036 + "stable_deref_trait", 1037 + "tinystr", 1038 + "writeable", 1039 + "yoke", 1040 + "zerofrom", 1041 + "zerovec", 1042 + ] 1043 + 1044 + [[package]] 1045 + name = "icu_provider_macros" 1046 + version = "1.5.0" 1047 + source = "registry+https://github.com/rust-lang/crates.io-index" 1048 + checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" 1049 + dependencies = [ 1050 + "proc-macro2", 1051 + "quote", 1052 + "syn", 1053 + ] 1054 + 1055 + [[package]] 1056 + name = "idna" 1057 + version = "1.0.3" 1058 + source = "registry+https://github.com/rust-lang/crates.io-index" 1059 + checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" 1060 + dependencies = [ 1061 + "idna_adapter", 1062 + "smallvec", 1063 + "utf8_iter", 1064 + ] 1065 + 1066 + [[package]] 1067 + name = "idna_adapter" 1068 + version = "1.2.0" 1069 + source = "registry+https://github.com/rust-lang/crates.io-index" 1070 + checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" 1071 + dependencies = [ 1072 + "icu_normalizer", 1073 + "icu_properties", 1074 + ] 1075 + 1076 + [[package]] 1077 + name = "indexmap" 1078 + version = "2.9.0" 1079 + source = "registry+https://github.com/rust-lang/crates.io-index" 1080 + checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" 1081 + dependencies = [ 1082 + "equivalent", 1083 + "hashbrown", 1084 + "serde", 1085 + ] 1086 + 1087 + [[package]] 1088 + name = "inlinable_string" 1089 + version = "0.1.15" 1090 + source = "registry+https://github.com/rust-lang/crates.io-index" 1091 + checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" 1092 + 1093 + [[package]] 1094 + name = "inout" 1095 + version = "0.1.4" 1096 + source = "registry+https://github.com/rust-lang/crates.io-index" 1097 + checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" 1098 + dependencies = [ 1099 + "generic-array", 1100 + ] 1101 + 1102 + [[package]] 1103 + name = "is-terminal" 1104 + version = "0.4.16" 1105 + source = "registry+https://github.com/rust-lang/crates.io-index" 1106 + checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" 1107 + dependencies = [ 1108 + "hermit-abi 0.5.0", 1109 + "libc", 1110 + "windows-sys 0.59.0", 1111 + ] 1112 + 1113 + [[package]] 1114 + name = "itertools" 1115 + version = "0.13.0" 1116 + source = "registry+https://github.com/rust-lang/crates.io-index" 1117 + checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 1118 + dependencies = [ 1119 + "either", 1120 + ] 1121 + 1122 + [[package]] 1123 + name = "itoa" 1124 + version = "1.0.15" 1125 + source = "registry+https://github.com/rust-lang/crates.io-index" 1126 + checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 1127 + 1128 + [[package]] 1129 + name = "js-sys" 1130 + version = "0.3.77" 1131 + source = "registry+https://github.com/rust-lang/crates.io-index" 1132 + checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 1133 + dependencies = [ 1134 + "once_cell", 1135 + "wasm-bindgen", 1136 + ] 1137 + 1138 + [[package]] 1139 + name = "lazy_static" 1140 + version = "1.5.0" 1141 + source = "registry+https://github.com/rust-lang/crates.io-index" 1142 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 1143 + dependencies = [ 1144 + "spin", 1145 + ] 1146 + 1147 + [[package]] 1148 + name = "libc" 1149 + version = "0.2.172" 1150 + source = "registry+https://github.com/rust-lang/crates.io-index" 1151 + checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 1152 + 1153 + [[package]] 1154 + name = "libm" 1155 + version = "0.2.16" 1156 + source = "registry+https://github.com/rust-lang/crates.io-index" 1157 + checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" 1158 + 1159 + [[package]] 1160 + name = "libsqlite3-sys" 1161 + version = "0.30.1" 1162 + source = "registry+https://github.com/rust-lang/crates.io-index" 1163 + checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" 1164 + dependencies = [ 1165 + "pkg-config", 1166 + "vcpkg", 1167 + ] 1168 + 1169 + [[package]] 1170 + name = "linux-raw-sys" 1171 + version = "0.9.4" 1172 + source = "registry+https://github.com/rust-lang/crates.io-index" 1173 + checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" 1174 + 1175 + [[package]] 1176 + name = "litemap" 1177 + version = "0.7.5" 1178 + source = "registry+https://github.com/rust-lang/crates.io-index" 1179 + checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" 1180 + 1181 + [[package]] 1182 + name = "lock_api" 1183 + version = "0.4.12" 1184 + source = "registry+https://github.com/rust-lang/crates.io-index" 1185 + checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 1186 + dependencies = [ 1187 + "autocfg", 1188 + "scopeguard", 1189 + ] 1190 + 1191 + [[package]] 1192 + name = "log" 1193 + version = "0.4.27" 1194 + source = "registry+https://github.com/rust-lang/crates.io-index" 1195 + checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 1196 + 1197 + [[package]] 1198 + name = "loom" 1199 + version = "0.5.6" 1200 + source = "registry+https://github.com/rust-lang/crates.io-index" 1201 + checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" 1202 + dependencies = [ 1203 + "cfg-if", 1204 + "generator", 1205 + "scoped-tls", 1206 + "serde", 1207 + "serde_json", 1208 + "tracing", 1209 + "tracing-subscriber", 1210 + ] 1211 + 1212 + [[package]] 1213 + name = "lumina-server" 1214 + version = "0.1.0" 1215 + dependencies = [ 1216 + "anyhow", 1217 + "base64", 1218 + "bb8", 1219 + "bb8-postgres", 1220 + "bb8-redis", 1221 + "bcrypt", 1222 + "cynthia_con", 1223 + "dotenv", 1224 + "redis", 1225 + "regex", 1226 + "rocket", 1227 + "rocket_ws", 1228 + "serde", 1229 + "serde_json", 1230 + "sqlx", 1231 + "tabled", 1232 + "time", 1233 + "tokio", 1234 + "uuid", 1235 + ] 1236 + 1237 + [[package]] 1238 + name = "matchers" 1239 + version = "0.1.0" 1240 + source = "registry+https://github.com/rust-lang/crates.io-index" 1241 + checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" 1242 + dependencies = [ 1243 + "regex-automata 0.1.10", 1244 + ] 1245 + 1246 + [[package]] 1247 + name = "md-5" 1248 + version = "0.10.6" 1249 + source = "registry+https://github.com/rust-lang/crates.io-index" 1250 + checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" 1251 + dependencies = [ 1252 + "cfg-if", 1253 + "digest", 1254 + ] 1255 + 1256 + [[package]] 1257 + name = "memchr" 1258 + version = "2.7.4" 1259 + source = "registry+https://github.com/rust-lang/crates.io-index" 1260 + checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 1261 + 1262 + [[package]] 1263 + name = "mime" 1264 + version = "0.3.17" 1265 + source = "registry+https://github.com/rust-lang/crates.io-index" 1266 + checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1267 + 1268 + [[package]] 1269 + name = "miniz_oxide" 1270 + version = "0.8.8" 1271 + source = "registry+https://github.com/rust-lang/crates.io-index" 1272 + checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" 1273 + dependencies = [ 1274 + "adler2", 1275 + ] 1276 + 1277 + [[package]] 1278 + name = "mio" 1279 + version = "1.0.3" 1280 + source = "registry+https://github.com/rust-lang/crates.io-index" 1281 + checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" 1282 + dependencies = [ 1283 + "libc", 1284 + "wasi 0.11.0+wasi-snapshot-preview1", 1285 + "windows-sys 0.52.0", 1286 + ] 1287 + 1288 + [[package]] 1289 + name = "multer" 1290 + version = "3.1.0" 1291 + source = "registry+https://github.com/rust-lang/crates.io-index" 1292 + checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" 1293 + dependencies = [ 1294 + "bytes", 1295 + "encoding_rs", 1296 + "futures-util", 1297 + "http 1.3.1", 1298 + "httparse", 1299 + "memchr", 1300 + "mime", 1301 + "spin", 1302 + "tokio", 1303 + "tokio-util", 1304 + "version_check", 1305 + ] 1306 + 1307 + [[package]] 1308 + name = "native-tls" 1309 + version = "0.2.18" 1310 + source = "registry+https://github.com/rust-lang/crates.io-index" 1311 + checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" 1312 + dependencies = [ 1313 + "libc", 1314 + "log", 1315 + "openssl", 1316 + "openssl-probe", 1317 + "openssl-sys", 1318 + "schannel", 1319 + "security-framework", 1320 + "security-framework-sys", 1321 + "tempfile", 1322 + ] 1323 + 1324 + [[package]] 1325 + name = "nu-ansi-term" 1326 + version = "0.46.0" 1327 + source = "registry+https://github.com/rust-lang/crates.io-index" 1328 + checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" 1329 + dependencies = [ 1330 + "overload", 1331 + "winapi", 1332 + ] 1333 + 1334 + [[package]] 1335 + name = "num-bigint" 1336 + version = "0.4.6" 1337 + source = "registry+https://github.com/rust-lang/crates.io-index" 1338 + checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" 1339 + dependencies = [ 1340 + "num-integer", 1341 + "num-traits", 1342 + ] 1343 + 1344 + [[package]] 1345 + name = "num-bigint-dig" 1346 + version = "0.8.6" 1347 + source = "registry+https://github.com/rust-lang/crates.io-index" 1348 + checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" 1349 + dependencies = [ 1350 + "lazy_static", 1351 + "libm", 1352 + "num-integer", 1353 + "num-iter", 1354 + "num-traits", 1355 + "rand 0.8.5", 1356 + "smallvec", 1357 + "zeroize", 1358 + ] 1359 + 1360 + [[package]] 1361 + name = "num-conv" 1362 + version = "0.1.0" 1363 + source = "registry+https://github.com/rust-lang/crates.io-index" 1364 + checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 1365 + 1366 + [[package]] 1367 + name = "num-integer" 1368 + version = "0.1.46" 1369 + source = "registry+https://github.com/rust-lang/crates.io-index" 1370 + checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 1371 + dependencies = [ 1372 + "num-traits", 1373 + ] 1374 + 1375 + [[package]] 1376 + name = "num-iter" 1377 + version = "0.1.45" 1378 + source = "registry+https://github.com/rust-lang/crates.io-index" 1379 + checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" 1380 + dependencies = [ 1381 + "autocfg", 1382 + "num-integer", 1383 + "num-traits", 1384 + ] 1385 + 1386 + [[package]] 1387 + name = "num-traits" 1388 + version = "0.2.19" 1389 + source = "registry+https://github.com/rust-lang/crates.io-index" 1390 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1391 + dependencies = [ 1392 + "autocfg", 1393 + "libm", 1394 + ] 1395 + 1396 + [[package]] 1397 + name = "num_cpus" 1398 + version = "1.16.0" 1399 + source = "registry+https://github.com/rust-lang/crates.io-index" 1400 + checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" 1401 + dependencies = [ 1402 + "hermit-abi 0.3.9", 1403 + "libc", 1404 + ] 1405 + 1406 + [[package]] 1407 + name = "object" 1408 + version = "0.36.7" 1409 + source = "registry+https://github.com/rust-lang/crates.io-index" 1410 + checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 1411 + dependencies = [ 1412 + "memchr", 1413 + ] 1414 + 1415 + [[package]] 1416 + name = "once_cell" 1417 + version = "1.21.3" 1418 + source = "registry+https://github.com/rust-lang/crates.io-index" 1419 + checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 1420 + 1421 + [[package]] 1422 + name = "openssl" 1423 + version = "0.10.75" 1424 + source = "registry+https://github.com/rust-lang/crates.io-index" 1425 + checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" 1426 + dependencies = [ 1427 + "bitflags", 1428 + "cfg-if", 1429 + "foreign-types", 1430 + "libc", 1431 + "once_cell", 1432 + "openssl-macros", 1433 + "openssl-sys", 1434 + ] 1435 + 1436 + [[package]] 1437 + name = "openssl-macros" 1438 + version = "0.1.1" 1439 + source = "registry+https://github.com/rust-lang/crates.io-index" 1440 + checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" 1441 + dependencies = [ 1442 + "proc-macro2", 1443 + "quote", 1444 + "syn", 1445 + ] 1446 + 1447 + [[package]] 1448 + name = "openssl-probe" 1449 + version = "0.2.1" 1450 + source = "registry+https://github.com/rust-lang/crates.io-index" 1451 + checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" 1452 + 1453 + [[package]] 1454 + name = "openssl-sys" 1455 + version = "0.9.111" 1456 + source = "registry+https://github.com/rust-lang/crates.io-index" 1457 + checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" 1458 + dependencies = [ 1459 + "cc", 1460 + "libc", 1461 + "pkg-config", 1462 + "vcpkg", 1463 + ] 1464 + 1465 + [[package]] 1466 + name = "overload" 1467 + version = "0.1.1" 1468 + source = "registry+https://github.com/rust-lang/crates.io-index" 1469 + checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" 1470 + 1471 + [[package]] 1472 + name = "papergrid" 1473 + version = "0.17.0" 1474 + source = "registry+https://github.com/rust-lang/crates.io-index" 1475 + checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" 1476 + dependencies = [ 1477 + "bytecount", 1478 + "fnv", 1479 + "unicode-width", 1480 + ] 1481 + 1482 + [[package]] 1483 + name = "parking" 1484 + version = "2.2.1" 1485 + source = "registry+https://github.com/rust-lang/crates.io-index" 1486 + checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" 1487 + 1488 + [[package]] 1489 + name = "parking_lot" 1490 + version = "0.12.3" 1491 + source = "registry+https://github.com/rust-lang/crates.io-index" 1492 + checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 1493 + dependencies = [ 1494 + "lock_api", 1495 + "parking_lot_core", 1496 + ] 1497 + 1498 + [[package]] 1499 + name = "parking_lot_core" 1500 + version = "0.9.10" 1501 + source = "registry+https://github.com/rust-lang/crates.io-index" 1502 + checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 1503 + dependencies = [ 1504 + "cfg-if", 1505 + "libc", 1506 + "redox_syscall", 1507 + "smallvec", 1508 + "windows-targets 0.52.6", 1509 + ] 1510 + 1511 + [[package]] 1512 + name = "pear" 1513 + version = "0.2.9" 1514 + source = "registry+https://github.com/rust-lang/crates.io-index" 1515 + checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" 1516 + dependencies = [ 1517 + "inlinable_string", 1518 + "pear_codegen", 1519 + "yansi", 1520 + ] 1521 + 1522 + [[package]] 1523 + name = "pear_codegen" 1524 + version = "0.2.9" 1525 + source = "registry+https://github.com/rust-lang/crates.io-index" 1526 + checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" 1527 + dependencies = [ 1528 + "proc-macro2", 1529 + "proc-macro2-diagnostics", 1530 + "quote", 1531 + "syn", 1532 + ] 1533 + 1534 + [[package]] 1535 + name = "pem-rfc7468" 1536 + version = "0.7.0" 1537 + source = "registry+https://github.com/rust-lang/crates.io-index" 1538 + checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" 1539 + dependencies = [ 1540 + "base64ct", 1541 + ] 1542 + 1543 + [[package]] 1544 + name = "percent-encoding" 1545 + version = "2.3.1" 1546 + source = "registry+https://github.com/rust-lang/crates.io-index" 1547 + checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 1548 + 1549 + [[package]] 1550 + name = "phf" 1551 + version = "0.11.3" 1552 + source = "registry+https://github.com/rust-lang/crates.io-index" 1553 + checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" 1554 + dependencies = [ 1555 + "phf_shared", 1556 + ] 1557 + 1558 + [[package]] 1559 + name = "phf_shared" 1560 + version = "0.11.3" 1561 + source = "registry+https://github.com/rust-lang/crates.io-index" 1562 + checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" 1563 + dependencies = [ 1564 + "siphasher", 1565 + ] 1566 + 1567 + [[package]] 1568 + name = "pin-project-lite" 1569 + version = "0.2.16" 1570 + source = "registry+https://github.com/rust-lang/crates.io-index" 1571 + checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 1572 + 1573 + [[package]] 1574 + name = "pin-utils" 1575 + version = "0.1.0" 1576 + source = "registry+https://github.com/rust-lang/crates.io-index" 1577 + checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 1578 + 1579 + [[package]] 1580 + name = "pkcs1" 1581 + version = "0.7.5" 1582 + source = "registry+https://github.com/rust-lang/crates.io-index" 1583 + checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" 1584 + dependencies = [ 1585 + "der", 1586 + "pkcs8", 1587 + "spki", 1588 + ] 1589 + 1590 + [[package]] 1591 + name = "pkcs8" 1592 + version = "0.10.2" 1593 + source = "registry+https://github.com/rust-lang/crates.io-index" 1594 + checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" 1595 + dependencies = [ 1596 + "der", 1597 + "spki", 1598 + ] 1599 + 1600 + [[package]] 1601 + name = "pkg-config" 1602 + version = "0.3.32" 1603 + source = "registry+https://github.com/rust-lang/crates.io-index" 1604 + checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 1605 + 1606 + [[package]] 1607 + name = "postgres-protocol" 1608 + version = "0.6.8" 1609 + source = "registry+https://github.com/rust-lang/crates.io-index" 1610 + checksum = "76ff0abab4a9b844b93ef7b81f1efc0a366062aaef2cd702c76256b5dc075c54" 1611 + dependencies = [ 1612 + "base64", 1613 + "byteorder", 1614 + "bytes", 1615 + "fallible-iterator", 1616 + "hmac", 1617 + "md-5", 1618 + "memchr", 1619 + "rand 0.9.1", 1620 + "sha2", 1621 + "stringprep", 1622 + ] 1623 + 1624 + [[package]] 1625 + name = "postgres-types" 1626 + version = "0.2.9" 1627 + source = "registry+https://github.com/rust-lang/crates.io-index" 1628 + checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48" 1629 + dependencies = [ 1630 + "bytes", 1631 + "fallible-iterator", 1632 + "postgres-protocol", 1633 + ] 1634 + 1635 + [[package]] 1636 + name = "powerfmt" 1637 + version = "0.2.0" 1638 + source = "registry+https://github.com/rust-lang/crates.io-index" 1639 + checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 1640 + 1641 + [[package]] 1642 + name = "ppv-lite86" 1643 + version = "0.2.21" 1644 + source = "registry+https://github.com/rust-lang/crates.io-index" 1645 + checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 1646 + dependencies = [ 1647 + "zerocopy", 1648 + ] 1649 + 1650 + [[package]] 1651 + name = "proc-macro-error-attr2" 1652 + version = "2.0.0" 1653 + source = "registry+https://github.com/rust-lang/crates.io-index" 1654 + checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" 1655 + dependencies = [ 1656 + "proc-macro2", 1657 + "quote", 1658 + ] 1659 + 1660 + [[package]] 1661 + name = "proc-macro-error2" 1662 + version = "2.0.1" 1663 + source = "registry+https://github.com/rust-lang/crates.io-index" 1664 + checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" 1665 + dependencies = [ 1666 + "proc-macro-error-attr2", 1667 + "proc-macro2", 1668 + "quote", 1669 + "syn", 1670 + ] 1671 + 1672 + [[package]] 1673 + name = "proc-macro2" 1674 + version = "1.0.95" 1675 + source = "registry+https://github.com/rust-lang/crates.io-index" 1676 + checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 1677 + dependencies = [ 1678 + "unicode-ident", 1679 + ] 1680 + 1681 + [[package]] 1682 + name = "proc-macro2-diagnostics" 1683 + version = "0.10.1" 1684 + source = "registry+https://github.com/rust-lang/crates.io-index" 1685 + checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" 1686 + dependencies = [ 1687 + "proc-macro2", 1688 + "quote", 1689 + "syn", 1690 + "version_check", 1691 + "yansi", 1692 + ] 1693 + 1694 + [[package]] 1695 + name = "quote" 1696 + version = "1.0.40" 1697 + source = "registry+https://github.com/rust-lang/crates.io-index" 1698 + checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 1699 + dependencies = [ 1700 + "proc-macro2", 1701 + ] 1702 + 1703 + [[package]] 1704 + name = "r-efi" 1705 + version = "5.2.0" 1706 + source = "registry+https://github.com/rust-lang/crates.io-index" 1707 + checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" 1708 + 1709 + [[package]] 1710 + name = "rand" 1711 + version = "0.8.5" 1712 + source = "registry+https://github.com/rust-lang/crates.io-index" 1713 + checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1714 + dependencies = [ 1715 + "libc", 1716 + "rand_chacha 0.3.1", 1717 + "rand_core 0.6.4", 1718 + ] 1719 + 1720 + [[package]] 1721 + name = "rand" 1722 + version = "0.9.1" 1723 + source = "registry+https://github.com/rust-lang/crates.io-index" 1724 + checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" 1725 + dependencies = [ 1726 + "rand_chacha 0.9.0", 1727 + "rand_core 0.9.3", 1728 + ] 1729 + 1730 + [[package]] 1731 + name = "rand_chacha" 1732 + version = "0.3.1" 1733 + source = "registry+https://github.com/rust-lang/crates.io-index" 1734 + checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 1735 + dependencies = [ 1736 + "ppv-lite86", 1737 + "rand_core 0.6.4", 1738 + ] 1739 + 1740 + [[package]] 1741 + name = "rand_chacha" 1742 + version = "0.9.0" 1743 + source = "registry+https://github.com/rust-lang/crates.io-index" 1744 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 1745 + dependencies = [ 1746 + "ppv-lite86", 1747 + "rand_core 0.9.3", 1748 + ] 1749 + 1750 + [[package]] 1751 + name = "rand_core" 1752 + version = "0.6.4" 1753 + source = "registry+https://github.com/rust-lang/crates.io-index" 1754 + checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 1755 + dependencies = [ 1756 + "getrandom 0.2.16", 1757 + ] 1758 + 1759 + [[package]] 1760 + name = "rand_core" 1761 + version = "0.9.3" 1762 + source = "registry+https://github.com/rust-lang/crates.io-index" 1763 + checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 1764 + dependencies = [ 1765 + "getrandom 0.3.2", 1766 + ] 1767 + 1768 + [[package]] 1769 + name = "redis" 1770 + version = "0.27.6" 1771 + source = "registry+https://github.com/rust-lang/crates.io-index" 1772 + checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" 1773 + dependencies = [ 1774 + "arc-swap", 1775 + "async-trait", 1776 + "backon", 1777 + "bytes", 1778 + "combine", 1779 + "futures", 1780 + "futures-util", 1781 + "itertools", 1782 + "itoa", 1783 + "num-bigint", 1784 + "percent-encoding", 1785 + "pin-project-lite", 1786 + "ryu", 1787 + "sha1_smol", 1788 + "socket2", 1789 + "tokio", 1790 + "tokio-util", 1791 + "url", 1792 + ] 1793 + 1794 + [[package]] 1795 + name = "redox_syscall" 1796 + version = "0.5.11" 1797 + source = "registry+https://github.com/rust-lang/crates.io-index" 1798 + checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" 1799 + dependencies = [ 1800 + "bitflags", 1801 + ] 1802 + 1803 + [[package]] 1804 + name = "ref-cast" 1805 + version = "1.0.24" 1806 + source = "registry+https://github.com/rust-lang/crates.io-index" 1807 + checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" 1808 + dependencies = [ 1809 + "ref-cast-impl", 1810 + ] 1811 + 1812 + [[package]] 1813 + name = "ref-cast-impl" 1814 + version = "1.0.24" 1815 + source = "registry+https://github.com/rust-lang/crates.io-index" 1816 + checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" 1817 + dependencies = [ 1818 + "proc-macro2", 1819 + "quote", 1820 + "syn", 1821 + ] 1822 + 1823 + [[package]] 1824 + name = "regex" 1825 + version = "1.11.1" 1826 + source = "registry+https://github.com/rust-lang/crates.io-index" 1827 + checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 1828 + dependencies = [ 1829 + "aho-corasick", 1830 + "memchr", 1831 + "regex-automata 0.4.9", 1832 + "regex-syntax 0.8.5", 1833 + ] 1834 + 1835 + [[package]] 1836 + name = "regex-automata" 1837 + version = "0.1.10" 1838 + source = "registry+https://github.com/rust-lang/crates.io-index" 1839 + checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" 1840 + dependencies = [ 1841 + "regex-syntax 0.6.29", 1842 + ] 1843 + 1844 + [[package]] 1845 + name = "regex-automata" 1846 + version = "0.4.9" 1847 + source = "registry+https://github.com/rust-lang/crates.io-index" 1848 + checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 1849 + dependencies = [ 1850 + "aho-corasick", 1851 + "memchr", 1852 + "regex-syntax 0.8.5", 1853 + ] 1854 + 1855 + [[package]] 1856 + name = "regex-syntax" 1857 + version = "0.6.29" 1858 + source = "registry+https://github.com/rust-lang/crates.io-index" 1859 + checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" 1860 + 1861 + [[package]] 1862 + name = "regex-syntax" 1863 + version = "0.8.5" 1864 + source = "registry+https://github.com/rust-lang/crates.io-index" 1865 + checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 1866 + 1867 + [[package]] 1868 + name = "rocket" 1869 + version = "0.5.1" 1870 + source = "registry+https://github.com/rust-lang/crates.io-index" 1871 + checksum = "a516907296a31df7dc04310e7043b61d71954d703b603cc6867a026d7e72d73f" 1872 + dependencies = [ 1873 + "async-stream", 1874 + "async-trait", 1875 + "atomic 0.5.3", 1876 + "binascii", 1877 + "bytes", 1878 + "either", 1879 + "figment", 1880 + "futures", 1881 + "indexmap", 1882 + "log", 1883 + "memchr", 1884 + "multer", 1885 + "num_cpus", 1886 + "parking_lot", 1887 + "pin-project-lite", 1888 + "rand 0.8.5", 1889 + "ref-cast", 1890 + "rocket_codegen", 1891 + "rocket_http", 1892 + "serde", 1893 + "state", 1894 + "tempfile", 1895 + "time", 1896 + "tokio", 1897 + "tokio-stream", 1898 + "tokio-util", 1899 + "ubyte", 1900 + "version_check", 1901 + "yansi", 1902 + ] 1903 + 1904 + [[package]] 1905 + name = "rocket_codegen" 1906 + version = "0.5.1" 1907 + source = "registry+https://github.com/rust-lang/crates.io-index" 1908 + checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" 1909 + dependencies = [ 1910 + "devise", 1911 + "glob", 1912 + "indexmap", 1913 + "proc-macro2", 1914 + "quote", 1915 + "rocket_http", 1916 + "syn", 1917 + "unicode-xid", 1918 + "version_check", 1919 + ] 1920 + 1921 + [[package]] 1922 + name = "rocket_http" 1923 + version = "0.5.1" 1924 + source = "registry+https://github.com/rust-lang/crates.io-index" 1925 + checksum = "e274915a20ee3065f611c044bd63c40757396b6dbc057d6046aec27f14f882b9" 1926 + dependencies = [ 1927 + "cookie", 1928 + "either", 1929 + "futures", 1930 + "http 0.2.12", 1931 + "hyper", 1932 + "indexmap", 1933 + "log", 1934 + "memchr", 1935 + "pear", 1936 + "percent-encoding", 1937 + "pin-project-lite", 1938 + "ref-cast", 1939 + "serde", 1940 + "smallvec", 1941 + "stable-pattern", 1942 + "state", 1943 + "time", 1944 + "tokio", 1945 + "uncased", 1946 + ] 1947 + 1948 + [[package]] 1949 + name = "rocket_ws" 1950 + version = "0.1.1" 1951 + source = "registry+https://github.com/rust-lang/crates.io-index" 1952 + checksum = "25f1877668c937b701177c349f21383c556cd3bb4ba8fa1d07fa96ccb3a8782e" 1953 + dependencies = [ 1954 + "rocket", 1955 + "tokio-tungstenite", 1956 + ] 1957 + 1958 + [[package]] 1959 + name = "rsa" 1960 + version = "0.9.10" 1961 + source = "registry+https://github.com/rust-lang/crates.io-index" 1962 + checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" 1963 + dependencies = [ 1964 + "const-oid", 1965 + "digest", 1966 + "num-bigint-dig", 1967 + "num-integer", 1968 + "num-traits", 1969 + "pkcs1", 1970 + "pkcs8", 1971 + "rand_core 0.6.4", 1972 + "signature", 1973 + "spki", 1974 + "subtle", 1975 + "zeroize", 1976 + ] 1977 + 1978 + [[package]] 1979 + name = "rustc-demangle" 1980 + version = "0.1.24" 1981 + source = "registry+https://github.com/rust-lang/crates.io-index" 1982 + checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 1983 + 1984 + [[package]] 1985 + name = "rustix" 1986 + version = "1.0.5" 1987 + source = "registry+https://github.com/rust-lang/crates.io-index" 1988 + checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" 1989 + dependencies = [ 1990 + "bitflags", 1991 + "errno", 1992 + "libc", 1993 + "linux-raw-sys", 1994 + "windows-sys 0.59.0", 1995 + ] 1996 + 1997 + [[package]] 1998 + name = "rustversion" 1999 + version = "1.0.20" 2000 + source = "registry+https://github.com/rust-lang/crates.io-index" 2001 + checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" 2002 + 2003 + [[package]] 2004 + name = "ryu" 2005 + version = "1.0.20" 2006 + source = "registry+https://github.com/rust-lang/crates.io-index" 2007 + checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 2008 + 2009 + [[package]] 2010 + name = "schannel" 2011 + version = "0.1.28" 2012 + source = "registry+https://github.com/rust-lang/crates.io-index" 2013 + checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" 2014 + dependencies = [ 2015 + "windows-sys 0.61.2", 2016 + ] 2017 + 2018 + [[package]] 2019 + name = "scoped-tls" 2020 + version = "1.0.1" 2021 + source = "registry+https://github.com/rust-lang/crates.io-index" 2022 + checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" 2023 + 2024 + [[package]] 2025 + name = "scopeguard" 2026 + version = "1.2.0" 2027 + source = "registry+https://github.com/rust-lang/crates.io-index" 2028 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 2029 + 2030 + [[package]] 2031 + name = "security-framework" 2032 + version = "3.7.0" 2033 + source = "registry+https://github.com/rust-lang/crates.io-index" 2034 + checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" 2035 + dependencies = [ 2036 + "bitflags", 2037 + "core-foundation", 2038 + "core-foundation-sys", 2039 + "libc", 2040 + "security-framework-sys", 2041 + ] 2042 + 2043 + [[package]] 2044 + name = "security-framework-sys" 2045 + version = "2.17.0" 2046 + source = "registry+https://github.com/rust-lang/crates.io-index" 2047 + checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" 2048 + dependencies = [ 2049 + "core-foundation-sys", 2050 + "libc", 2051 + ] 2052 + 2053 + [[package]] 2054 + name = "serde" 2055 + version = "1.0.228" 2056 + source = "registry+https://github.com/rust-lang/crates.io-index" 2057 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 2058 + dependencies = [ 2059 + "serde_core", 2060 + "serde_derive", 2061 + ] 2062 + 2063 + [[package]] 2064 + name = "serde_core" 2065 + version = "1.0.228" 2066 + source = "registry+https://github.com/rust-lang/crates.io-index" 2067 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 2068 + dependencies = [ 2069 + "serde_derive", 2070 + ] 2071 + 2072 + [[package]] 2073 + name = "serde_derive" 2074 + version = "1.0.228" 2075 + source = "registry+https://github.com/rust-lang/crates.io-index" 2076 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 2077 + dependencies = [ 2078 + "proc-macro2", 2079 + "quote", 2080 + "syn", 2081 + ] 2082 + 2083 + [[package]] 2084 + name = "serde_json" 2085 + version = "1.0.140" 2086 + source = "registry+https://github.com/rust-lang/crates.io-index" 2087 + checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 2088 + dependencies = [ 2089 + "itoa", 2090 + "memchr", 2091 + "ryu", 2092 + "serde", 2093 + ] 2094 + 2095 + [[package]] 2096 + name = "serde_spanned" 2097 + version = "0.6.8" 2098 + source = "registry+https://github.com/rust-lang/crates.io-index" 2099 + checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" 2100 + dependencies = [ 2101 + "serde", 2102 + ] 2103 + 2104 + [[package]] 2105 + name = "serde_urlencoded" 2106 + version = "0.7.1" 2107 + source = "registry+https://github.com/rust-lang/crates.io-index" 2108 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 2109 + dependencies = [ 2110 + "form_urlencoded", 2111 + "itoa", 2112 + "ryu", 2113 + "serde", 2114 + ] 2115 + 2116 + [[package]] 2117 + name = "sha1" 2118 + version = "0.10.6" 2119 + source = "registry+https://github.com/rust-lang/crates.io-index" 2120 + checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 2121 + dependencies = [ 2122 + "cfg-if", 2123 + "cpufeatures", 2124 + "digest", 2125 + ] 2126 + 2127 + [[package]] 2128 + name = "sha1_smol" 2129 + version = "1.0.1" 2130 + source = "registry+https://github.com/rust-lang/crates.io-index" 2131 + checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" 2132 + 2133 + [[package]] 2134 + name = "sha2" 2135 + version = "0.10.8" 2136 + source = "registry+https://github.com/rust-lang/crates.io-index" 2137 + checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" 2138 + dependencies = [ 2139 + "cfg-if", 2140 + "cpufeatures", 2141 + "digest", 2142 + ] 2143 + 2144 + [[package]] 2145 + name = "sharded-slab" 2146 + version = "0.1.7" 2147 + source = "registry+https://github.com/rust-lang/crates.io-index" 2148 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 2149 + dependencies = [ 2150 + "lazy_static", 2151 + ] 2152 + 2153 + [[package]] 2154 + name = "shlex" 2155 + version = "1.3.0" 2156 + source = "registry+https://github.com/rust-lang/crates.io-index" 2157 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 2158 + 2159 + [[package]] 2160 + name = "signal-hook-registry" 2161 + version = "1.4.5" 2162 + source = "registry+https://github.com/rust-lang/crates.io-index" 2163 + checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" 2164 + dependencies = [ 2165 + "libc", 2166 + ] 2167 + 2168 + [[package]] 2169 + name = "signature" 2170 + version = "2.2.0" 2171 + source = "registry+https://github.com/rust-lang/crates.io-index" 2172 + checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 2173 + dependencies = [ 2174 + "digest", 2175 + "rand_core 0.6.4", 2176 + ] 2177 + 2178 + [[package]] 2179 + name = "siphasher" 2180 + version = "1.0.1" 2181 + source = "registry+https://github.com/rust-lang/crates.io-index" 2182 + checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" 2183 + 2184 + [[package]] 2185 + name = "slab" 2186 + version = "0.4.9" 2187 + source = "registry+https://github.com/rust-lang/crates.io-index" 2188 + checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 2189 + dependencies = [ 2190 + "autocfg", 2191 + ] 2192 + 2193 + [[package]] 2194 + name = "smallvec" 2195 + version = "1.15.0" 2196 + source = "registry+https://github.com/rust-lang/crates.io-index" 2197 + checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" 2198 + dependencies = [ 2199 + "serde", 2200 + ] 2201 + 2202 + [[package]] 2203 + name = "socket2" 2204 + version = "0.5.9" 2205 + source = "registry+https://github.com/rust-lang/crates.io-index" 2206 + checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" 2207 + dependencies = [ 2208 + "libc", 2209 + "windows-sys 0.52.0", 2210 + ] 2211 + 2212 + [[package]] 2213 + name = "spin" 2214 + version = "0.9.8" 2215 + source = "registry+https://github.com/rust-lang/crates.io-index" 2216 + checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 2217 + dependencies = [ 2218 + "lock_api", 2219 + ] 2220 + 2221 + [[package]] 2222 + name = "spki" 2223 + version = "0.7.3" 2224 + source = "registry+https://github.com/rust-lang/crates.io-index" 2225 + checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" 2226 + dependencies = [ 2227 + "base64ct", 2228 + "der", 2229 + ] 2230 + 2231 + [[package]] 2232 + name = "sqlx" 2233 + version = "0.8.6" 2234 + source = "registry+https://github.com/rust-lang/crates.io-index" 2235 + checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" 2236 + dependencies = [ 2237 + "sqlx-core", 2238 + "sqlx-macros", 2239 + "sqlx-mysql", 2240 + "sqlx-postgres", 2241 + "sqlx-sqlite", 2242 + ] 2243 + 2244 + [[package]] 2245 + name = "sqlx-core" 2246 + version = "0.8.6" 2247 + source = "registry+https://github.com/rust-lang/crates.io-index" 2248 + checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" 2249 + dependencies = [ 2250 + "base64", 2251 + "bytes", 2252 + "crc", 2253 + "crossbeam-queue", 2254 + "either", 2255 + "event-listener", 2256 + "futures-core", 2257 + "futures-intrusive", 2258 + "futures-io", 2259 + "futures-util", 2260 + "hashbrown", 2261 + "hashlink", 2262 + "indexmap", 2263 + "log", 2264 + "memchr", 2265 + "native-tls", 2266 + "once_cell", 2267 + "percent-encoding", 2268 + "serde", 2269 + "serde_json", 2270 + "sha2", 2271 + "smallvec", 2272 + "thiserror 2.0.18", 2273 + "time", 2274 + "tokio", 2275 + "tokio-stream", 2276 + "tracing", 2277 + "url", 2278 + "uuid", 2279 + ] 2280 + 2281 + [[package]] 2282 + name = "sqlx-macros" 2283 + version = "0.8.6" 2284 + source = "registry+https://github.com/rust-lang/crates.io-index" 2285 + checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" 2286 + dependencies = [ 2287 + "proc-macro2", 2288 + "quote", 2289 + "sqlx-core", 2290 + "sqlx-macros-core", 2291 + "syn", 2292 + ] 2293 + 2294 + [[package]] 2295 + name = "sqlx-macros-core" 2296 + version = "0.8.6" 2297 + source = "registry+https://github.com/rust-lang/crates.io-index" 2298 + checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" 2299 + dependencies = [ 2300 + "dotenvy", 2301 + "either", 2302 + "heck", 2303 + "hex", 2304 + "once_cell", 2305 + "proc-macro2", 2306 + "quote", 2307 + "serde", 2308 + "serde_json", 2309 + "sha2", 2310 + "sqlx-core", 2311 + "sqlx-mysql", 2312 + "sqlx-postgres", 2313 + "sqlx-sqlite", 2314 + "syn", 2315 + "tokio", 2316 + "url", 2317 + ] 2318 + 2319 + [[package]] 2320 + name = "sqlx-mysql" 2321 + version = "0.8.6" 2322 + source = "registry+https://github.com/rust-lang/crates.io-index" 2323 + checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" 2324 + dependencies = [ 2325 + "atoi", 2326 + "base64", 2327 + "bitflags", 2328 + "byteorder", 2329 + "bytes", 2330 + "crc", 2331 + "digest", 2332 + "dotenvy", 2333 + "either", 2334 + "futures-channel", 2335 + "futures-core", 2336 + "futures-io", 2337 + "futures-util", 2338 + "generic-array", 2339 + "hex", 2340 + "hkdf", 2341 + "hmac", 2342 + "itoa", 2343 + "log", 2344 + "md-5", 2345 + "memchr", 2346 + "once_cell", 2347 + "percent-encoding", 2348 + "rand 0.8.5", 2349 + "rsa", 2350 + "serde", 2351 + "sha1", 2352 + "sha2", 2353 + "smallvec", 2354 + "sqlx-core", 2355 + "stringprep", 2356 + "thiserror 2.0.18", 2357 + "time", 2358 + "tracing", 2359 + "uuid", 2360 + "whoami", 2361 + ] 2362 + 2363 + [[package]] 2364 + name = "sqlx-postgres" 2365 + version = "0.8.6" 2366 + source = "registry+https://github.com/rust-lang/crates.io-index" 2367 + checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" 2368 + dependencies = [ 2369 + "atoi", 2370 + "base64", 2371 + "bitflags", 2372 + "byteorder", 2373 + "crc", 2374 + "dotenvy", 2375 + "etcetera", 2376 + "futures-channel", 2377 + "futures-core", 2378 + "futures-util", 2379 + "hex", 2380 + "hkdf", 2381 + "hmac", 2382 + "home", 2383 + "itoa", 2384 + "log", 2385 + "md-5", 2386 + "memchr", 2387 + "once_cell", 2388 + "rand 0.8.5", 2389 + "serde", 2390 + "serde_json", 2391 + "sha2", 2392 + "smallvec", 2393 + "sqlx-core", 2394 + "stringprep", 2395 + "thiserror 2.0.18", 2396 + "time", 2397 + "tracing", 2398 + "uuid", 2399 + "whoami", 2400 + ] 2401 + 2402 + [[package]] 2403 + name = "sqlx-sqlite" 2404 + version = "0.8.6" 2405 + source = "registry+https://github.com/rust-lang/crates.io-index" 2406 + checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" 2407 + dependencies = [ 2408 + "atoi", 2409 + "flume", 2410 + "futures-channel", 2411 + "futures-core", 2412 + "futures-executor", 2413 + "futures-intrusive", 2414 + "futures-util", 2415 + "libsqlite3-sys", 2416 + "log", 2417 + "percent-encoding", 2418 + "serde", 2419 + "serde_urlencoded", 2420 + "sqlx-core", 2421 + "thiserror 2.0.18", 2422 + "time", 2423 + "tracing", 2424 + "url", 2425 + "uuid", 2426 + ] 2427 + 2428 + [[package]] 2429 + name = "stable-pattern" 2430 + version = "0.1.0" 2431 + source = "registry+https://github.com/rust-lang/crates.io-index" 2432 + checksum = "4564168c00635f88eaed410d5efa8131afa8d8699a612c80c455a0ba05c21045" 2433 + dependencies = [ 2434 + "memchr", 2435 + ] 2436 + 2437 + [[package]] 2438 + name = "stable_deref_trait" 2439 + version = "1.2.0" 2440 + source = "registry+https://github.com/rust-lang/crates.io-index" 2441 + checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 2442 + 2443 + [[package]] 2444 + name = "state" 2445 + version = "0.6.0" 2446 + source = "registry+https://github.com/rust-lang/crates.io-index" 2447 + checksum = "2b8c4a4445d81357df8b1a650d0d0d6fbbbfe99d064aa5e02f3e4022061476d8" 2448 + dependencies = [ 2449 + "loom", 2450 + ] 2451 + 2452 + [[package]] 2453 + name = "stringprep" 2454 + version = "0.1.5" 2455 + source = "registry+https://github.com/rust-lang/crates.io-index" 2456 + checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" 2457 + dependencies = [ 2458 + "unicode-bidi", 2459 + "unicode-normalization", 2460 + "unicode-properties", 2461 + ] 2462 + 2463 + [[package]] 2464 + name = "subtle" 2465 + version = "2.6.1" 2466 + source = "registry+https://github.com/rust-lang/crates.io-index" 2467 + checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 2468 + 2469 + [[package]] 2470 + name = "syn" 2471 + version = "2.0.100" 2472 + source = "registry+https://github.com/rust-lang/crates.io-index" 2473 + checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" 2474 + dependencies = [ 2475 + "proc-macro2", 2476 + "quote", 2477 + "unicode-ident", 2478 + ] 2479 + 2480 + [[package]] 2481 + name = "synstructure" 2482 + version = "0.13.1" 2483 + source = "registry+https://github.com/rust-lang/crates.io-index" 2484 + checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" 2485 + dependencies = [ 2486 + "proc-macro2", 2487 + "quote", 2488 + "syn", 2489 + ] 2490 + 2491 + [[package]] 2492 + name = "tabled" 2493 + version = "0.20.0" 2494 + source = "registry+https://github.com/rust-lang/crates.io-index" 2495 + checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d" 2496 + dependencies = [ 2497 + "papergrid", 2498 + "tabled_derive", 2499 + "testing_table", 2500 + ] 2501 + 2502 + [[package]] 2503 + name = "tabled_derive" 2504 + version = "0.11.0" 2505 + source = "registry+https://github.com/rust-lang/crates.io-index" 2506 + checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846" 2507 + dependencies = [ 2508 + "heck", 2509 + "proc-macro-error2", 2510 + "proc-macro2", 2511 + "quote", 2512 + "syn", 2513 + ] 2514 + 2515 + [[package]] 2516 + name = "tempfile" 2517 + version = "3.19.1" 2518 + source = "registry+https://github.com/rust-lang/crates.io-index" 2519 + checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" 2520 + dependencies = [ 2521 + "fastrand", 2522 + "getrandom 0.3.2", 2523 + "once_cell", 2524 + "rustix", 2525 + "windows-sys 0.59.0", 2526 + ] 2527 + 2528 + [[package]] 2529 + name = "termsize" 2530 + version = "0.1.9" 2531 + source = "registry+https://github.com/rust-lang/crates.io-index" 2532 + checksum = "6f11ff5c25c172608d5b85e2fb43ee9a6d683a7f4ab7f96ae07b3d8b590368fd" 2533 + dependencies = [ 2534 + "libc", 2535 + "winapi", 2536 + ] 2537 + 2538 + [[package]] 2539 + name = "testing_table" 2540 + version = "0.3.0" 2541 + source = "registry+https://github.com/rust-lang/crates.io-index" 2542 + checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" 2543 + dependencies = [ 2544 + "unicode-width", 2545 + ] 2546 + 2547 + [[package]] 2548 + name = "thiserror" 2549 + version = "1.0.69" 2550 + source = "registry+https://github.com/rust-lang/crates.io-index" 2551 + checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 2552 + dependencies = [ 2553 + "thiserror-impl 1.0.69", 2554 + ] 2555 + 2556 + [[package]] 2557 + name = "thiserror" 2558 + version = "2.0.18" 2559 + source = "registry+https://github.com/rust-lang/crates.io-index" 2560 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 2561 + dependencies = [ 2562 + "thiserror-impl 2.0.18", 2563 + ] 2564 + 2565 + [[package]] 2566 + name = "thiserror-impl" 2567 + version = "1.0.69" 2568 + source = "registry+https://github.com/rust-lang/crates.io-index" 2569 + checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 2570 + dependencies = [ 2571 + "proc-macro2", 2572 + "quote", 2573 + "syn", 2574 + ] 2575 + 2576 + [[package]] 2577 + name = "thiserror-impl" 2578 + version = "2.0.18" 2579 + source = "registry+https://github.com/rust-lang/crates.io-index" 2580 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 2581 + dependencies = [ 2582 + "proc-macro2", 2583 + "quote", 2584 + "syn", 2585 + ] 2586 + 2587 + [[package]] 2588 + name = "thread_local" 2589 + version = "1.1.8" 2590 + source = "registry+https://github.com/rust-lang/crates.io-index" 2591 + checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" 2592 + dependencies = [ 2593 + "cfg-if", 2594 + "once_cell", 2595 + ] 2596 + 2597 + [[package]] 2598 + name = "time" 2599 + version = "0.3.41" 2600 + source = "registry+https://github.com/rust-lang/crates.io-index" 2601 + checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" 2602 + dependencies = [ 2603 + "deranged", 2604 + "itoa", 2605 + "num-conv", 2606 + "powerfmt", 2607 + "serde", 2608 + "time-core", 2609 + "time-macros", 2610 + ] 2611 + 2612 + [[package]] 2613 + name = "time-core" 2614 + version = "0.1.4" 2615 + source = "registry+https://github.com/rust-lang/crates.io-index" 2616 + checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" 2617 + 2618 + [[package]] 2619 + name = "time-macros" 2620 + version = "0.2.22" 2621 + source = "registry+https://github.com/rust-lang/crates.io-index" 2622 + checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" 2623 + dependencies = [ 2624 + "num-conv", 2625 + "time-core", 2626 + ] 2627 + 2628 + [[package]] 2629 + name = "tinystr" 2630 + version = "0.7.6" 2631 + source = "registry+https://github.com/rust-lang/crates.io-index" 2632 + checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" 2633 + dependencies = [ 2634 + "displaydoc", 2635 + "zerovec", 2636 + ] 2637 + 2638 + [[package]] 2639 + name = "tinyvec" 2640 + version = "1.9.0" 2641 + source = "registry+https://github.com/rust-lang/crates.io-index" 2642 + checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" 2643 + dependencies = [ 2644 + "tinyvec_macros", 2645 + ] 2646 + 2647 + [[package]] 2648 + name = "tinyvec_macros" 2649 + version = "0.1.1" 2650 + source = "registry+https://github.com/rust-lang/crates.io-index" 2651 + checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 2652 + 2653 + [[package]] 2654 + name = "tokio" 2655 + version = "1.44.2" 2656 + source = "registry+https://github.com/rust-lang/crates.io-index" 2657 + checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" 2658 + dependencies = [ 2659 + "backtrace", 2660 + "bytes", 2661 + "libc", 2662 + "mio", 2663 + "parking_lot", 2664 + "pin-project-lite", 2665 + "signal-hook-registry", 2666 + "socket2", 2667 + "tokio-macros", 2668 + "windows-sys 0.52.0", 2669 + ] 2670 + 2671 + [[package]] 2672 + name = "tokio-macros" 2673 + version = "2.5.0" 2674 + source = "registry+https://github.com/rust-lang/crates.io-index" 2675 + checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" 2676 + dependencies = [ 2677 + "proc-macro2", 2678 + "quote", 2679 + "syn", 2680 + ] 2681 + 2682 + [[package]] 2683 + name = "tokio-postgres" 2684 + version = "0.7.13" 2685 + source = "registry+https://github.com/rust-lang/crates.io-index" 2686 + checksum = "6c95d533c83082bb6490e0189acaa0bbeef9084e60471b696ca6988cd0541fb0" 2687 + dependencies = [ 2688 + "async-trait", 2689 + "byteorder", 2690 + "bytes", 2691 + "fallible-iterator", 2692 + "futures-channel", 2693 + "futures-util", 2694 + "log", 2695 + "parking_lot", 2696 + "percent-encoding", 2697 + "phf", 2698 + "pin-project-lite", 2699 + "postgres-protocol", 2700 + "postgres-types", 2701 + "rand 0.9.1", 2702 + "socket2", 2703 + "tokio", 2704 + "tokio-util", 2705 + "whoami", 2706 + ] 2707 + 2708 + [[package]] 2709 + name = "tokio-stream" 2710 + version = "0.1.17" 2711 + source = "registry+https://github.com/rust-lang/crates.io-index" 2712 + checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" 2713 + dependencies = [ 2714 + "futures-core", 2715 + "pin-project-lite", 2716 + "tokio", 2717 + ] 2718 + 2719 + [[package]] 2720 + name = "tokio-tungstenite" 2721 + version = "0.21.0" 2722 + source = "registry+https://github.com/rust-lang/crates.io-index" 2723 + checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" 2724 + dependencies = [ 2725 + "futures-util", 2726 + "log", 2727 + "tokio", 2728 + "tungstenite", 2729 + ] 2730 + 2731 + [[package]] 2732 + name = "tokio-util" 2733 + version = "0.7.15" 2734 + source = "registry+https://github.com/rust-lang/crates.io-index" 2735 + checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" 2736 + dependencies = [ 2737 + "bytes", 2738 + "futures-core", 2739 + "futures-sink", 2740 + "pin-project-lite", 2741 + "tokio", 2742 + ] 2743 + 2744 + [[package]] 2745 + name = "toml" 2746 + version = "0.8.20" 2747 + source = "registry+https://github.com/rust-lang/crates.io-index" 2748 + checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" 2749 + dependencies = [ 2750 + "serde", 2751 + "serde_spanned", 2752 + "toml_datetime", 2753 + "toml_edit", 2754 + ] 2755 + 2756 + [[package]] 2757 + name = "toml_datetime" 2758 + version = "0.6.8" 2759 + source = "registry+https://github.com/rust-lang/crates.io-index" 2760 + checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" 2761 + dependencies = [ 2762 + "serde", 2763 + ] 2764 + 2765 + [[package]] 2766 + name = "toml_edit" 2767 + version = "0.22.24" 2768 + source = "registry+https://github.com/rust-lang/crates.io-index" 2769 + checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" 2770 + dependencies = [ 2771 + "indexmap", 2772 + "serde", 2773 + "serde_spanned", 2774 + "toml_datetime", 2775 + "winnow", 2776 + ] 2777 + 2778 + [[package]] 2779 + name = "tower-service" 2780 + version = "0.3.3" 2781 + source = "registry+https://github.com/rust-lang/crates.io-index" 2782 + checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 2783 + 2784 + [[package]] 2785 + name = "tracing" 2786 + version = "0.1.41" 2787 + source = "registry+https://github.com/rust-lang/crates.io-index" 2788 + checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 2789 + dependencies = [ 2790 + "log", 2791 + "pin-project-lite", 2792 + "tracing-attributes", 2793 + "tracing-core", 2794 + ] 2795 + 2796 + [[package]] 2797 + name = "tracing-attributes" 2798 + version = "0.1.28" 2799 + source = "registry+https://github.com/rust-lang/crates.io-index" 2800 + checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" 2801 + dependencies = [ 2802 + "proc-macro2", 2803 + "quote", 2804 + "syn", 2805 + ] 2806 + 2807 + [[package]] 2808 + name = "tracing-core" 2809 + version = "0.1.33" 2810 + source = "registry+https://github.com/rust-lang/crates.io-index" 2811 + checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" 2812 + dependencies = [ 2813 + "once_cell", 2814 + "valuable", 2815 + ] 2816 + 2817 + [[package]] 2818 + name = "tracing-log" 2819 + version = "0.2.0" 2820 + source = "registry+https://github.com/rust-lang/crates.io-index" 2821 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 2822 + dependencies = [ 2823 + "log", 2824 + "once_cell", 2825 + "tracing-core", 2826 + ] 2827 + 2828 + [[package]] 2829 + name = "tracing-subscriber" 2830 + version = "0.3.19" 2831 + source = "registry+https://github.com/rust-lang/crates.io-index" 2832 + checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" 2833 + dependencies = [ 2834 + "matchers", 2835 + "nu-ansi-term", 2836 + "once_cell", 2837 + "regex", 2838 + "sharded-slab", 2839 + "smallvec", 2840 + "thread_local", 2841 + "tracing", 2842 + "tracing-core", 2843 + "tracing-log", 2844 + ] 2845 + 2846 + [[package]] 2847 + name = "try-lock" 2848 + version = "0.2.5" 2849 + source = "registry+https://github.com/rust-lang/crates.io-index" 2850 + checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2851 + 2852 + [[package]] 2853 + name = "tungstenite" 2854 + version = "0.21.0" 2855 + source = "registry+https://github.com/rust-lang/crates.io-index" 2856 + checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" 2857 + dependencies = [ 2858 + "byteorder", 2859 + "bytes", 2860 + "data-encoding", 2861 + "http 1.3.1", 2862 + "httparse", 2863 + "log", 2864 + "rand 0.8.5", 2865 + "sha1", 2866 + "thiserror 1.0.69", 2867 + "url", 2868 + "utf-8", 2869 + ] 2870 + 2871 + [[package]] 2872 + name = "typenum" 2873 + version = "1.18.0" 2874 + source = "registry+https://github.com/rust-lang/crates.io-index" 2875 + checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 2876 + 2877 + [[package]] 2878 + name = "ubyte" 2879 + version = "0.10.4" 2880 + source = "registry+https://github.com/rust-lang/crates.io-index" 2881 + checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" 2882 + dependencies = [ 2883 + "serde", 2884 + ] 2885 + 2886 + [[package]] 2887 + name = "uncased" 2888 + version = "0.9.10" 2889 + source = "registry+https://github.com/rust-lang/crates.io-index" 2890 + checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" 2891 + dependencies = [ 2892 + "serde", 2893 + "version_check", 2894 + ] 2895 + 2896 + [[package]] 2897 + name = "unicode-bidi" 2898 + version = "0.3.18" 2899 + source = "registry+https://github.com/rust-lang/crates.io-index" 2900 + checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" 2901 + 2902 + [[package]] 2903 + name = "unicode-ident" 2904 + version = "1.0.18" 2905 + source = "registry+https://github.com/rust-lang/crates.io-index" 2906 + checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 2907 + 2908 + [[package]] 2909 + name = "unicode-normalization" 2910 + version = "0.1.24" 2911 + source = "registry+https://github.com/rust-lang/crates.io-index" 2912 + checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" 2913 + dependencies = [ 2914 + "tinyvec", 2915 + ] 2916 + 2917 + [[package]] 2918 + name = "unicode-properties" 2919 + version = "0.1.3" 2920 + source = "registry+https://github.com/rust-lang/crates.io-index" 2921 + checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" 2922 + 2923 + [[package]] 2924 + name = "unicode-width" 2925 + version = "0.2.0" 2926 + source = "registry+https://github.com/rust-lang/crates.io-index" 2927 + checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 2928 + 2929 + [[package]] 2930 + name = "unicode-xid" 2931 + version = "0.2.6" 2932 + source = "registry+https://github.com/rust-lang/crates.io-index" 2933 + checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 2934 + 2935 + [[package]] 2936 + name = "url" 2937 + version = "2.5.4" 2938 + source = "registry+https://github.com/rust-lang/crates.io-index" 2939 + checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" 2940 + dependencies = [ 2941 + "form_urlencoded", 2942 + "idna", 2943 + "percent-encoding", 2944 + ] 2945 + 2946 + [[package]] 2947 + name = "utf-8" 2948 + version = "0.7.6" 2949 + source = "registry+https://github.com/rust-lang/crates.io-index" 2950 + checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 2951 + 2952 + [[package]] 2953 + name = "utf16_iter" 2954 + version = "1.0.5" 2955 + source = "registry+https://github.com/rust-lang/crates.io-index" 2956 + checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" 2957 + 2958 + [[package]] 2959 + name = "utf8_iter" 2960 + version = "1.0.4" 2961 + source = "registry+https://github.com/rust-lang/crates.io-index" 2962 + checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 2963 + 2964 + [[package]] 2965 + name = "uuid" 2966 + version = "1.16.0" 2967 + source = "registry+https://github.com/rust-lang/crates.io-index" 2968 + checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" 2969 + dependencies = [ 2970 + "getrandom 0.3.2", 2971 + "serde", 2972 + ] 2973 + 2974 + [[package]] 2975 + name = "valuable" 2976 + version = "0.1.1" 2977 + source = "registry+https://github.com/rust-lang/crates.io-index" 2978 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 2979 + 2980 + [[package]] 2981 + name = "vcpkg" 2982 + version = "0.2.15" 2983 + source = "registry+https://github.com/rust-lang/crates.io-index" 2984 + checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 2985 + 2986 + [[package]] 2987 + name = "version_check" 2988 + version = "0.9.5" 2989 + source = "registry+https://github.com/rust-lang/crates.io-index" 2990 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 2991 + 2992 + [[package]] 2993 + name = "want" 2994 + version = "0.3.1" 2995 + source = "registry+https://github.com/rust-lang/crates.io-index" 2996 + checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 2997 + dependencies = [ 2998 + "try-lock", 2999 + ] 3000 + 3001 + [[package]] 3002 + name = "wasi" 3003 + version = "0.11.0+wasi-snapshot-preview1" 3004 + source = "registry+https://github.com/rust-lang/crates.io-index" 3005 + checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 3006 + 3007 + [[package]] 3008 + name = "wasi" 3009 + version = "0.14.2+wasi-0.2.4" 3010 + source = "registry+https://github.com/rust-lang/crates.io-index" 3011 + checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 3012 + dependencies = [ 3013 + "wit-bindgen-rt", 3014 + ] 3015 + 3016 + [[package]] 3017 + name = "wasite" 3018 + version = "0.1.0" 3019 + source = "registry+https://github.com/rust-lang/crates.io-index" 3020 + checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" 3021 + 3022 + [[package]] 3023 + name = "wasm-bindgen" 3024 + version = "0.2.100" 3025 + source = "registry+https://github.com/rust-lang/crates.io-index" 3026 + checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 3027 + dependencies = [ 3028 + "cfg-if", 3029 + "once_cell", 3030 + "wasm-bindgen-macro", 3031 + ] 3032 + 3033 + [[package]] 3034 + name = "wasm-bindgen-backend" 3035 + version = "0.2.100" 3036 + source = "registry+https://github.com/rust-lang/crates.io-index" 3037 + checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 3038 + dependencies = [ 3039 + "bumpalo", 3040 + "log", 3041 + "proc-macro2", 3042 + "quote", 3043 + "syn", 3044 + "wasm-bindgen-shared", 3045 + ] 3046 + 3047 + [[package]] 3048 + name = "wasm-bindgen-macro" 3049 + version = "0.2.100" 3050 + source = "registry+https://github.com/rust-lang/crates.io-index" 3051 + checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 3052 + dependencies = [ 3053 + "quote", 3054 + "wasm-bindgen-macro-support", 3055 + ] 3056 + 3057 + [[package]] 3058 + name = "wasm-bindgen-macro-support" 3059 + version = "0.2.100" 3060 + source = "registry+https://github.com/rust-lang/crates.io-index" 3061 + checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 3062 + dependencies = [ 3063 + "proc-macro2", 3064 + "quote", 3065 + "syn", 3066 + "wasm-bindgen-backend", 3067 + "wasm-bindgen-shared", 3068 + ] 3069 + 3070 + [[package]] 3071 + name = "wasm-bindgen-shared" 3072 + version = "0.2.100" 3073 + source = "registry+https://github.com/rust-lang/crates.io-index" 3074 + checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 3075 + dependencies = [ 3076 + "unicode-ident", 3077 + ] 3078 + 3079 + [[package]] 3080 + name = "web-sys" 3081 + version = "0.3.77" 3082 + source = "registry+https://github.com/rust-lang/crates.io-index" 3083 + checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" 3084 + dependencies = [ 3085 + "js-sys", 3086 + "wasm-bindgen", 3087 + ] 3088 + 3089 + [[package]] 3090 + name = "whoami" 3091 + version = "1.6.0" 3092 + source = "registry+https://github.com/rust-lang/crates.io-index" 3093 + checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" 3094 + dependencies = [ 3095 + "redox_syscall", 3096 + "wasite", 3097 + "web-sys", 3098 + ] 3099 + 3100 + [[package]] 3101 + name = "winapi" 3102 + version = "0.3.9" 3103 + source = "registry+https://github.com/rust-lang/crates.io-index" 3104 + checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 3105 + dependencies = [ 3106 + "winapi-i686-pc-windows-gnu", 3107 + "winapi-x86_64-pc-windows-gnu", 3108 + ] 3109 + 3110 + [[package]] 3111 + name = "winapi-i686-pc-windows-gnu" 3112 + version = "0.4.0" 3113 + source = "registry+https://github.com/rust-lang/crates.io-index" 3114 + checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 3115 + 3116 + [[package]] 3117 + name = "winapi-x86_64-pc-windows-gnu" 3118 + version = "0.4.0" 3119 + source = "registry+https://github.com/rust-lang/crates.io-index" 3120 + checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 3121 + 3122 + [[package]] 3123 + name = "windows" 3124 + version = "0.48.0" 3125 + source = "registry+https://github.com/rust-lang/crates.io-index" 3126 + checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" 3127 + dependencies = [ 3128 + "windows-targets 0.48.5", 3129 + ] 3130 + 3131 + [[package]] 3132 + name = "windows-link" 3133 + version = "0.2.1" 3134 + source = "registry+https://github.com/rust-lang/crates.io-index" 3135 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 3136 + 3137 + [[package]] 3138 + name = "windows-sys" 3139 + version = "0.48.0" 3140 + source = "registry+https://github.com/rust-lang/crates.io-index" 3141 + checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 3142 + dependencies = [ 3143 + "windows-targets 0.48.5", 3144 + ] 3145 + 3146 + [[package]] 3147 + name = "windows-sys" 3148 + version = "0.52.0" 3149 + source = "registry+https://github.com/rust-lang/crates.io-index" 3150 + checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 3151 + dependencies = [ 3152 + "windows-targets 0.52.6", 3153 + ] 3154 + 3155 + [[package]] 3156 + name = "windows-sys" 3157 + version = "0.59.0" 3158 + source = "registry+https://github.com/rust-lang/crates.io-index" 3159 + checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 3160 + dependencies = [ 3161 + "windows-targets 0.52.6", 3162 + ] 3163 + 3164 + [[package]] 3165 + name = "windows-sys" 3166 + version = "0.61.2" 3167 + source = "registry+https://github.com/rust-lang/crates.io-index" 3168 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 3169 + dependencies = [ 3170 + "windows-link", 3171 + ] 3172 + 3173 + [[package]] 3174 + name = "windows-targets" 3175 + version = "0.48.5" 3176 + source = "registry+https://github.com/rust-lang/crates.io-index" 3177 + checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 3178 + dependencies = [ 3179 + "windows_aarch64_gnullvm 0.48.5", 3180 + "windows_aarch64_msvc 0.48.5", 3181 + "windows_i686_gnu 0.48.5", 3182 + "windows_i686_msvc 0.48.5", 3183 + "windows_x86_64_gnu 0.48.5", 3184 + "windows_x86_64_gnullvm 0.48.5", 3185 + "windows_x86_64_msvc 0.48.5", 3186 + ] 3187 + 3188 + [[package]] 3189 + name = "windows-targets" 3190 + version = "0.52.6" 3191 + source = "registry+https://github.com/rust-lang/crates.io-index" 3192 + checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 3193 + dependencies = [ 3194 + "windows_aarch64_gnullvm 0.52.6", 3195 + "windows_aarch64_msvc 0.52.6", 3196 + "windows_i686_gnu 0.52.6", 3197 + "windows_i686_gnullvm", 3198 + "windows_i686_msvc 0.52.6", 3199 + "windows_x86_64_gnu 0.52.6", 3200 + "windows_x86_64_gnullvm 0.52.6", 3201 + "windows_x86_64_msvc 0.52.6", 3202 + ] 3203 + 3204 + [[package]] 3205 + name = "windows_aarch64_gnullvm" 3206 + version = "0.48.5" 3207 + source = "registry+https://github.com/rust-lang/crates.io-index" 3208 + checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 3209 + 3210 + [[package]] 3211 + name = "windows_aarch64_gnullvm" 3212 + version = "0.52.6" 3213 + source = "registry+https://github.com/rust-lang/crates.io-index" 3214 + checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 3215 + 3216 + [[package]] 3217 + name = "windows_aarch64_msvc" 3218 + version = "0.48.5" 3219 + source = "registry+https://github.com/rust-lang/crates.io-index" 3220 + checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 3221 + 3222 + [[package]] 3223 + name = "windows_aarch64_msvc" 3224 + version = "0.52.6" 3225 + source = "registry+https://github.com/rust-lang/crates.io-index" 3226 + checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 3227 + 3228 + [[package]] 3229 + name = "windows_i686_gnu" 3230 + version = "0.48.5" 3231 + source = "registry+https://github.com/rust-lang/crates.io-index" 3232 + checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 3233 + 3234 + [[package]] 3235 + name = "windows_i686_gnu" 3236 + version = "0.52.6" 3237 + source = "registry+https://github.com/rust-lang/crates.io-index" 3238 + checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 3239 + 3240 + [[package]] 3241 + name = "windows_i686_gnullvm" 3242 + version = "0.52.6" 3243 + source = "registry+https://github.com/rust-lang/crates.io-index" 3244 + checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 3245 + 3246 + [[package]] 3247 + name = "windows_i686_msvc" 3248 + version = "0.48.5" 3249 + source = "registry+https://github.com/rust-lang/crates.io-index" 3250 + checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 3251 + 3252 + [[package]] 3253 + name = "windows_i686_msvc" 3254 + version = "0.52.6" 3255 + source = "registry+https://github.com/rust-lang/crates.io-index" 3256 + checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 3257 + 3258 + [[package]] 3259 + name = "windows_x86_64_gnu" 3260 + version = "0.48.5" 3261 + source = "registry+https://github.com/rust-lang/crates.io-index" 3262 + checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 3263 + 3264 + [[package]] 3265 + name = "windows_x86_64_gnu" 3266 + version = "0.52.6" 3267 + source = "registry+https://github.com/rust-lang/crates.io-index" 3268 + checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 3269 + 3270 + [[package]] 3271 + name = "windows_x86_64_gnullvm" 3272 + version = "0.48.5" 3273 + source = "registry+https://github.com/rust-lang/crates.io-index" 3274 + checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 3275 + 3276 + [[package]] 3277 + name = "windows_x86_64_gnullvm" 3278 + version = "0.52.6" 3279 + source = "registry+https://github.com/rust-lang/crates.io-index" 3280 + checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 3281 + 3282 + [[package]] 3283 + name = "windows_x86_64_msvc" 3284 + version = "0.48.5" 3285 + source = "registry+https://github.com/rust-lang/crates.io-index" 3286 + checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 3287 + 3288 + [[package]] 3289 + name = "windows_x86_64_msvc" 3290 + version = "0.52.6" 3291 + source = "registry+https://github.com/rust-lang/crates.io-index" 3292 + checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 3293 + 3294 + [[package]] 3295 + name = "winnow" 3296 + version = "0.7.6" 3297 + source = "registry+https://github.com/rust-lang/crates.io-index" 3298 + checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10" 3299 + dependencies = [ 3300 + "memchr", 3301 + ] 3302 + 3303 + [[package]] 3304 + name = "wit-bindgen-rt" 3305 + version = "0.39.0" 3306 + source = "registry+https://github.com/rust-lang/crates.io-index" 3307 + checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 3308 + dependencies = [ 3309 + "bitflags", 3310 + ] 3311 + 3312 + [[package]] 3313 + name = "write16" 3314 + version = "1.0.0" 3315 + source = "registry+https://github.com/rust-lang/crates.io-index" 3316 + checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" 3317 + 3318 + [[package]] 3319 + name = "writeable" 3320 + version = "0.5.5" 3321 + source = "registry+https://github.com/rust-lang/crates.io-index" 3322 + checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" 3323 + 3324 + [[package]] 3325 + name = "yansi" 3326 + version = "1.0.1" 3327 + source = "registry+https://github.com/rust-lang/crates.io-index" 3328 + checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" 3329 + dependencies = [ 3330 + "is-terminal", 3331 + ] 3332 + 3333 + [[package]] 3334 + name = "yoke" 3335 + version = "0.7.5" 3336 + source = "registry+https://github.com/rust-lang/crates.io-index" 3337 + checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" 3338 + dependencies = [ 3339 + "serde", 3340 + "stable_deref_trait", 3341 + "yoke-derive", 3342 + "zerofrom", 3343 + ] 3344 + 3345 + [[package]] 3346 + name = "yoke-derive" 3347 + version = "0.7.5" 3348 + source = "registry+https://github.com/rust-lang/crates.io-index" 3349 + checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" 3350 + dependencies = [ 3351 + "proc-macro2", 3352 + "quote", 3353 + "syn", 3354 + "synstructure", 3355 + ] 3356 + 3357 + [[package]] 3358 + name = "zerocopy" 3359 + version = "0.8.24" 3360 + source = "registry+https://github.com/rust-lang/crates.io-index" 3361 + checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" 3362 + dependencies = [ 3363 + "zerocopy-derive", 3364 + ] 3365 + 3366 + [[package]] 3367 + name = "zerocopy-derive" 3368 + version = "0.8.24" 3369 + source = "registry+https://github.com/rust-lang/crates.io-index" 3370 + checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" 3371 + dependencies = [ 3372 + "proc-macro2", 3373 + "quote", 3374 + "syn", 3375 + ] 3376 + 3377 + [[package]] 3378 + name = "zerofrom" 3379 + version = "0.1.6" 3380 + source = "registry+https://github.com/rust-lang/crates.io-index" 3381 + checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 3382 + dependencies = [ 3383 + "zerofrom-derive", 3384 + ] 3385 + 3386 + [[package]] 3387 + name = "zerofrom-derive" 3388 + version = "0.1.6" 3389 + source = "registry+https://github.com/rust-lang/crates.io-index" 3390 + checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 3391 + dependencies = [ 3392 + "proc-macro2", 3393 + "quote", 3394 + "syn", 3395 + "synstructure", 3396 + ] 3397 + 3398 + [[package]] 3399 + name = "zeroize" 3400 + version = "1.8.1" 3401 + source = "registry+https://github.com/rust-lang/crates.io-index" 3402 + checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" 3403 + 3404 + [[package]] 3405 + name = "zerovec" 3406 + version = "0.10.4" 3407 + source = "registry+https://github.com/rust-lang/crates.io-index" 3408 + checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" 3409 + dependencies = [ 3410 + "yoke", 3411 + "zerofrom", 3412 + "zerovec-derive", 3413 + ] 3414 + 3415 + [[package]] 3416 + name = "zerovec-derive" 3417 + version = "0.10.3" 3418 + source = "registry+https://github.com/rust-lang/crates.io-index" 3419 + checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" 3420 + dependencies = [ 3421 + "proc-macro2", 3422 + "quote", 3423 + "syn", 3424 + ]
+15
backend/impl-rs/Cargo.toml
··· 1 + [workspace] 2 + resolver = "3" 3 + members = ["./server"] 4 + license = "EUPL-1.2" 5 + default-members = ["./server"] 6 + 7 + [workspace.lints.clippy] 8 + expect_used = { level = "deny", priority = 0 } 9 + panic_in_result_fn = { level = "deny", priority = 0 } 10 + useless_attribute = "deny" 11 + arithmetic_side_effects = "warn" 12 + missing_docs = "forbid" 13 + style = { level = "warn", priority = -1 } 14 + missing_panics_doc = "warn" 15 + pedantic = { level = "warn", priority = -1 }
+37
backend/impl-rs/Dockerfile
··· 1 + # syntax=docker/dockerfile:1 2 + 3 + FROM alpine:3.19 AS builder 4 + 5 + ENV MISE_DATA_DIR="/mise" 6 + ENV MISE_CONFIG_DIR="/mise" 7 + ENV MISE_CACHE_DIR="/mise/cache" 8 + ENV MISE_INSTALL_PATH="/usr/local/bin/mise" 9 + ENV BUN_INSTALL="/usr/local/bin/bun" 10 + ENV PATH="/usr/local/bin/bun/bin:/mise/shims:$PATH" 11 + 12 + RUN apk add --no-cache curl git unzip build-base bash 13 + 14 + # Install bun outside of mise because Alpine uses musl libc which the mise bun package does not support 15 + RUN curl -fsSL https://bun.sh/install | bash 16 + RUN curl https://mise.run | sh 17 + 18 + WORKDIR /build 19 + # Copy and install the mise.toml file first to leverage Docker cache 20 + COPY mise.toml ./mise.toml 21 + RUN mise trust && mise unuse bun && mise install 22 + # Copy the project files excluding mise.toml 23 + COPY --exclude=mise.toml . . 24 + # Build the project itself in release mode. 25 + RUN mkdir -p target/output && \ 26 + mise run build-server-release && \ 27 + cp ./target/release/lumina-server ./target/output/; 28 + 29 + 30 + # --- Final runtime image --- 31 + FROM alpine:3.19 32 + RUN apk add --no-cache ca-certificates 33 + WORKDIR /app 34 + COPY --from=builder /build/target/output/lumina-server /app/lumina-server 35 + EXPOSE 8085 36 + CMD ["/app/lumina-server"] 37 +
+101
backend/impl-rs/Justfile
··· 1 + [private] 2 + default: 3 + @just --list 4 + 5 + [doc("Build the styles for Lumina client")] 6 + [group('building')] 7 + build-styles: 8 + cd ./client/ && bun x @tailwindcss/cli@4.1.18 -i ./app.css -o ./priv/static/lumina_client.css 9 + 10 + [doc("Build the server-side of Lumina")] 11 + [group('building')] 12 + build-server: build-client 13 + cargo build 14 + 15 + [doc("Build the server-side of Lumina optimised for release")] 16 + [group('building')] 17 + build-server-release: build-client 18 + cargo build --release 19 + 20 + [doc("Build the client-side of Lumina and it's styles")] 21 + [group('building')] 22 + build-client: build-styles 23 + cd ./client/ &&\ 24 + gleam build --target javascript &&\ 25 + find ./client/src/ -type f -print0 | xargs -0 sha256sum | sha256sum | awk '{print $1}' > "./priv/static/lumina_client_rev.hash" &&\ 26 + echo 'import { main } from "./lumina_client.mjs";document.addEventListener("DOMContentLoaded", main())' > "./build/dev/javascript/lumina_client/lumina_client.ts" &&\ 27 + bun build ./build/dev/javascript/lumina_client/lumina_client.ts --minify --outfile ./priv/static/lumina_client.min.mjs --target=browser &&\ 28 + bun build ./build/dev/javascript/lumina_client/lumina_client.ts --outfile ./priv/static/lumina_client.mjs --target=browser 29 + 30 + [doc("Prefetch Gleam dependencies to speed up future builds")] 31 + [group('prepare')] 32 + prefetch-gleam-deps: 33 + cd ./client && gleam deps download 34 + 35 + [doc("Install Bun dependencies")] 36 + [group('prepare')] 37 + bun-install: 38 + cd ./client && bun i 39 + 40 + [group('prepare')] 41 + create-data-dirs: 42 + mkdir -p ./data 43 + mkdir -p ./data/postgres 44 + mkdir -p ./data/redis 45 + 46 + [doc("Clean all build artifacts")] 47 + clean-all: 48 + cargo clean 49 + rm -rf ./client/node_modules 50 + rm -rf ./client/build 51 + rm -rf ./client/build/dev/javascript/lumina_client/lumina_client.mjs 52 + rm -rf ./client/build/dev/javascript/lumina_client/lumina_client.ts 53 + rm -rf ./client/priv/static/lumina_client.min.mjs 54 + rm -rf ./client/priv/static/lumina_client.css 55 + 56 + [doc("Just runs the Podman image for a Redis and Postgres server for local development run to connect to.")] 57 + [group("local-devel")] 58 + local-devel-prep: create-data-dirs 59 + @podman inspect -f '{{{{.State.Running}}}}' lumina-redis 2>/dev/null | grep -q 'true' \ 60 + && echo "lumina-redis is already running." \ 61 + || podman run -d --replace \ 62 + --name lumina-redis \ 63 + -p 6379:6379 \ 64 + -v ./data/redis:/data \ 65 + docker.io/redis/redis-stack:7.2.0-v18 66 + @podman inspect -f '{{{{.State.Running}}}}' luminadb 2>/dev/null | grep -q 'true' \ 67 + && echo "luminadb is already running." \ 68 + || podman run -d --replace \ 69 + -p 5432:5432 \ 70 + --name luminadb \ 71 + -e POSTGRES_USER=lumina \ 72 + -e POSTGRES_PASSWORD=lumina_pw \ 73 + -e POSTGRES_DB=lumina_config \ 74 + -v ./data/postgres:/var/lib/postgresql/data:Z \ 75 + docker.io/library/postgres:17-alpine3.22 76 + sqlx db create 77 + sqlx migrate run 78 + echo "Postgres database created and migrations ran" 79 + 80 + 81 + [doc("Run the server in development mode")] 82 + [group("local-devel")] 83 + local-devel $LUMINA_POSTGRES_PASSWORD="lumina_pw": build-server 84 + ./target/debug/lumina-server 85 + 86 + [doc("Run the server in development mode with file watching")] 87 + [group("local-devel")] 88 + local-devel-watch: 89 + watchexec --restart --stop-timeout=0 --shell=sh -e rs,gleam,toml,css,ts,json -- just local-devel 90 + 91 + [doc("Runs the commands from local-devel automatically, watches")] 92 + [group("local-devel")] 93 + dev: 94 + @just local-devel-prep 95 + @just local-devel-watch 96 + 97 + [group("local-devel")] 98 + [doc("Run pgweb (8081) and redis-commander (8082) for local development")] 99 + local-devel-dataexplorer: local-devel-prep 100 + podman run -d --replace --name lumina-redis-commander -p 8082:8081 -e REDIS_HOSTS=host.containers.internal:6379 ghcr.io/joeferner/redis-commander:latest 101 + podman run -d --replace --name lumina-pgweb -p 8081:8081 -e'PGWEB_DATABASE_URL=postgres://lumina:lumina_pw@host.containers.internal:5432/lumina_config?sslmode=disable' sosedoff/pgweb:latest
+329
backend/impl-rs/LICENCE
··· 1 + Lumina/Peonies 2 + Copyright (C) 2018-2026 M.L.C. 'Strawmelonjuice' Bloeiman and contributors. 3 + 4 + =============================================================================== 5 + OFFICIAL NOTICE: RESERVATION OF RIGHTS & DERIVATIVE WORKS (ART. 4(3) DIR 2019/790) 6 + =============================================================================== 7 + 1. MACHINE-READABLE OPT-OUT: In accordance with Article 4(3) of Directive 8 + (EU) 2019/790, the Licensor hereby expressly reserves all rights for 9 + Text and Data Mining (TDM) and the training of Artificial Intelligence (AI) 10 + models in an appropriate and machine-readable manner. 11 + 12 + A robots.txt is added to the web interface of this program itself (/robots.txt), 13 + and exists as a file in the root of the code repository available on 14 + <https://tangled.org/strawmelonjuice.com/Lumina> and official mirrors. 15 + 16 + 17 + 2. CLARIFICATION ON DERIVATION: For the purpose of Article 1 of this Licence, 18 + the Licensor defines the use of this Work for training, fine-tuning, or 19 + optimizing AI models as the creation of a Derivative Work. 20 + 21 + 3. COPYLEFT OBLIGATION: Any party utilizing this Work for the development 22 + of AI models acknowledges that the resulting model weights, parameters, 23 + and architecture are subject to the Copyleft Clause (Article 5) of the 24 + EUPL v1.2. Distribution of such derivatives must occur under the terms 25 + of the EUPL or a Compatible Licence. 26 + 27 + 4. SEVERABILITY: Pursuant to Article 13, if any provision of this Special 28 + Notice is found to be invalid or unenforceable, the validity of the 29 + remainder of the Licence and its core Copyleft protections shall remain 30 + in full effect. 31 + =============================================================================== 32 + 33 + For the EUPL v1.2 in other languages, see 34 + <https://interoperable-europe.ec.europa.eu/collection/eupl/eupl-text-eupl-12>. 35 + 36 + This software is licensed under the European Union Public Licence (EUPL) v1.2. 37 + 38 + EUROPEAN UNION PUBLIC LICENCE v. 1.2 39 + EUPL © the European Union 2007, 2016 40 + 41 + This European Union Public Licence (the ‘EUPL’) applies to the Work (as 42 + defined below) which is provided under the terms of this Licence. Any use of 43 + the Work, other than as authorised under this Licence is prohibited (to the 44 + extent such use is covered by a right of the copyright holder of the Work). 45 + 46 + The Work is provided under the terms of this Licence when the Licensor (as 47 + defined below) has placed the following notice immediately following the 48 + copyright notice for the Work: 49 + 50 + Licensed under the EUPL 51 + 52 + or has expressed by any other means his willingness to license under the EUPL. 53 + 54 + 1. Definitions 55 + 56 + In this Licence, the following terms have the following meaning: 57 + 58 + - ‘The Licence’: this Licence. 59 + 60 + - ‘The Original Work’: the work or software distributed or communicated by the 61 + Licensor under this Licence, available as Source Code and also as Executable 62 + Code as the case may be. 63 + 64 + - ‘Derivative Works’: the works or software that could be created by the 65 + Licensee, based upon the Original Work or modifications thereof. This 66 + Licence does not define the extent of modification or dependence on the 67 + Original Work required in order to classify a work as a Derivative Work; 68 + this extent is determined by copyright law applicable in the country 69 + mentioned in Article 15. 70 + 71 + - ‘The Work’: the Original Work or its Derivative Works. 72 + 73 + - ‘The Source Code’: the human-readable form of the Work which is the most 74 + convenient for people to study and modify. 75 + 76 + - ‘The Executable Code’: any code which has generally been compiled and which 77 + is meant to be interpreted by a computer as a program. 78 + 79 + - ‘The Licensor’: the natural or legal person that distributes or communicates 80 + the Work under the Licence. 81 + 82 + - ‘Contributor(s)’: any natural or legal person who modifies the Work under 83 + the Licence, or otherwise contributes to the creation of a Derivative Work. 84 + 85 + - ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of 86 + the Work under the terms of the Licence. 87 + 88 + - ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, 89 + renting, distributing, communicating, transmitting, or otherwise making 90 + available, online or offline, copies of the Work or providing access to its 91 + essential functionalities at the disposal of any other natural or legal 92 + person. 93 + 94 + 2. Scope of the rights granted by the Licence 95 + 96 + The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, 97 + sublicensable licence to do the following, for the duration of copyright 98 + vested in the Original Work: 99 + 100 + - use the Work in any circumstance and for all usage, 101 + - reproduce the Work, 102 + - modify the Work, and make Derivative Works based upon the Work, 103 + - communicate to the public, including the right to make available or display 104 + the Work or copies thereof to the public and perform publicly, as the case 105 + may be, the Work, 106 + - distribute the Work or copies thereof, 107 + - lend and rent the Work or copies thereof, 108 + - sublicense rights in the Work or copies thereof. 109 + 110 + Those rights can be exercised on any media, supports and formats, whether now 111 + known or later invented, as far as the applicable law permits so. 112 + 113 + In the countries where moral rights apply, the Licensor waives his right to 114 + exercise his moral right to the extent allowed by law in order to make 115 + effective the licence of the economic rights here above listed. 116 + 117 + The Licensor grants to the Licensee royalty-free, non-exclusive usage rights 118 + to any patents held by the Licensor, to the extent necessary to make use of 119 + the rights granted on the Work under this Licence. 120 + 121 + 3. Communication of the Source Code 122 + 123 + The Licensor may provide the Work either in its Source Code form, or as 124 + Executable Code. If the Work is provided as Executable Code, the Licensor 125 + provides in addition a machine-readable copy of the Source Code of the Work 126 + along with each copy of the Work that the Licensor distributes or indicates, 127 + in a notice following the copyright notice attached to the Work, a repository 128 + where the Source Code is easily and freely accessible for as long as the 129 + Licensor continues to distribute or communicate the Work. 130 + 131 + 4. Limitations on copyright 132 + 133 + Nothing in this Licence is intended to deprive the Licensee of the benefits 134 + from any exception or limitation to the exclusive rights of the rights owners 135 + in the Work, of the exhaustion of those rights or of other applicable 136 + limitations thereto. 137 + 138 + 5. Obligations of the Licensee 139 + 140 + The grant of the rights mentioned above is subject to some restrictions and 141 + obligations imposed on the Licensee. Those obligations are the following: 142 + 143 + Attribution right: The Licensee shall keep intact all copyright, patent or 144 + trademarks notices and all notices that refer to the Licence and to the 145 + disclaimer of warranties. The Licensee must include a copy of such notices and 146 + a copy of the Licence with every copy of the Work he/she distributes or 147 + communicates. The Licensee must cause any Derivative Work to carry prominent 148 + notices stating that the Work has been modified and the date of modification. 149 + 150 + Copyleft clause: If the Licensee distributes or communicates copies of the 151 + Original Works or Derivative Works, this Distribution or Communication will be 152 + done under the terms of this Licence or of a later version of this Licence 153 + unless the Original Work is expressly distributed only under this version of 154 + the Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee 155 + (becoming Licensor) cannot offer or impose any additional terms or conditions 156 + on the Work or Derivative Work that alter or restrict the terms of the 157 + Licence. 158 + 159 + Compatibility clause: If the Licensee Distributes or Communicates Derivative 160 + Works or copies thereof based upon both the Work and another work licensed 161 + under a Compatible Licence, this Distribution or Communication can be done 162 + under the terms of this Compatible Licence. For the sake of this clause, 163 + ‘Compatible Licence’ refers to the licences listed in the appendix attached to 164 + this Licence. Should the Licensee's obligations under the Compatible Licence 165 + conflict with his/her obligations under this Licence, the obligations of the 166 + Compatible Licence shall prevail. 167 + 168 + Provision of Source Code: When distributing or communicating copies of the 169 + Work, the Licensee will provide a machine-readable copy of the Source Code or 170 + indicate a repository where this Source will be easily and freely available 171 + for as long as the Licensee continues to distribute or communicate the Work. 172 + 173 + Legal Protection: This Licence does not grant permission to use the trade 174 + names, trademarks, service marks, or names of the Licensor, except as required 175 + for reasonable and customary use in describing the origin of the Work and 176 + reproducing the content of the copyright notice. 177 + 178 + 6. Chain of Authorship 179 + 180 + The original Licensor warrants that the copyright in the Original Work granted 181 + hereunder is owned by him/her or licensed to him/her and that he/she has the 182 + power and authority to grant the Licence. 183 + 184 + Each Contributor warrants that the copyright in the modifications he/she 185 + brings to the Work are owned by him/her or licensed to him/her and that he/she 186 + has the power and authority to grant the Licence. 187 + 188 + Each time You accept the Licence, the original Licensor and subsequent 189 + Contributors grant You a licence to their contributions to the Work, under the 190 + terms of this Licence. 191 + 192 + 7. Disclaimer of Warranty 193 + 194 + The Work is a work in progress, which is continuously improved by numerous 195 + Contributors. It is not a finished work and may therefore contain defects or 196 + ‘bugs’ inherent to this type of development. 197 + 198 + For the above reason, the Work is provided under the Licence on an ‘as is’ 199 + basis and without warranties of any kind concerning the Work, including 200 + without limitation merchantability, fitness for a particular purpose, absence 201 + of defects or errors, accuracy, non-infringement of intellectual property 202 + rights other than copyright as stated in Article 6 of this Licence. 203 + 204 + This disclaimer of warranty is an essential part of the Licence and a 205 + condition for the grant of any rights to the Work. 206 + 207 + 8. Disclaimer of Liability 208 + 209 + Except in the cases of wilful misconduct or damages directly caused to natural 210 + persons, the Licensor will in no event be liable for any direct or indirect, 211 + material or moral, damages of any kind, arising out of the Licence or of the 212 + use of the Work, including without limitation, damages for loss of goodwill, 213 + work stoppage, computer failure or malfunction, loss of data or any commercial 214 + damage, even if the Licensor has been advised of the possibility of such 215 + damage. However, the Licensor will be liable under statutory product liability 216 + laws as far such laws apply to the Work. 217 + 218 + 9. Additional agreements 219 + 220 + While distributing the Work, You may choose to conclude an additional 221 + agreement, defining obligations or services consistent with this Licence. 222 + However, if accepting obligations, You may act only on your own behalf and on 223 + your sole responsibility, not on behalf of the original Licensor or any other 224 + Contributor, and only if You agree to indemnify, defend, and hold each 225 + Contributor harmless for any liability incurred by, or claims asserted against 226 + such Contributor by the fact You have accepted any warranty or additional 227 + liability. 228 + 229 + 10. Acceptance of the Licence 230 + 231 + The provisions of this Licence can be accepted by clicking on an icon ‘I 232 + agree’ placed under the bottom of a window displaying the text of this Licence 233 + or by affirming consent in any other similar way, in accordance with the rules 234 + of applicable law. Clicking on that icon indicates your clear and irrevocable 235 + acceptance of this Licence and all of its terms and conditions. 236 + 237 + Similarly, you irrevocably accept this Licence and all of its terms and 238 + conditions by exercising any rights granted to You by Article 2 of this 239 + Licence, such as the use of the Work, the creation by You of a Derivative Work 240 + or the Distribution or Communication by You of the Work or copies thereof. 241 + 242 + 11. Information to the public 243 + 244 + In case of any Distribution or Communication of the Work by means of 245 + electronic communication by You (for example, by offering to download the Work 246 + from a remote location) the distribution channel or media (for example, a 247 + website) must at least provide to the public the information requested by the 248 + applicable law regarding the Licensor, the Licence and the way it may be 249 + accessible, concluded, stored and reproduced by the Licensee. 250 + 251 + 12. Termination of the Licence 252 + 253 + The Licence and the rights granted hereunder will terminate automatically upon 254 + any breach by the Licensee of the terms of the Licence. 255 + 256 + Such a termination will not terminate the licences of any person who has 257 + received the Work from the Licensee under the Licence, provided such persons 258 + remain in full compliance with the Licence. 259 + 260 + 13. Miscellaneous 261 + 262 + Without prejudice of Article 9 above, the Licence represents the complete 263 + agreement between the Parties as to the Work. 264 + 265 + If any provision of the Licence is invalid or unenforceable under applicable 266 + law, this will not affect the validity or enforceability of the Licence as a 267 + whole. Such provision will be construed or reformed so as necessary to make it 268 + valid and enforceable. 269 + 270 + The European Commission may publish other linguistic versions or new versions 271 + of this Licence or updated versions of the Appendix, so far this is required 272 + and reasonable, without reducing the scope of the rights granted by the 273 + Licence. New versions of the Licence will be published with a unique version 274 + number. 275 + 276 + All linguistic versions of this Licence, approved by the European Commission, 277 + have identical value. Parties can take advantage of the linguistic version of 278 + their choice. 279 + 280 + 14. Jurisdiction 281 + 282 + Without prejudice to specific agreement between parties, 283 + 284 + - any litigation resulting from the interpretation of this License, arising 285 + between the European Union institutions, bodies, offices or agencies, as a 286 + Licensor, and any Licensee, will be subject to the jurisdiction of the Court 287 + of Justice of the European Union, as laid down in article 272 of the Treaty 288 + on the Functioning of the European Union, 289 + 290 + - any litigation arising between other parties and resulting from the 291 + interpretation of this License, will be subject to the exclusive 292 + jurisdiction of the competent court where the Licensor resides or conducts 293 + its primary business. 294 + 295 + 15. Applicable Law 296 + 297 + Without prejudice to specific agreement between parties, 298 + 299 + - this Licence shall be governed by the law of the European Union Member State 300 + where the Licensor has his seat, resides or has his registered office, 301 + 302 + - this licence shall be governed by Belgian law if the Licensor has no seat, 303 + residence or registered office inside a European Union Member State. 304 + 305 + Appendix 306 + 307 + ‘Compatible Licences’ according to Article 5 EUPL are: 308 + 309 + - GNU General Public License (GPL) v. 2, v. 3 310 + - GNU Affero General Public License (AGPL) v. 3 311 + - Open Software License (OSL) v. 2.1, v. 3.0 312 + - Eclipse Public License (EPL) v. 1.0 313 + - CeCILL v. 2.0, v. 2.1 314 + - Mozilla Public Licence (MPL) v. 2 315 + - GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 316 + - Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for 317 + works other than software 318 + - European Union Public Licence (EUPL) v. 1.1, v. 1.2 319 + - Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong 320 + Reciprocity (LiLiQ-R+). 321 + 322 + The European Commission may update this Appendix to later versions of the 323 + above licences without producing a new version of the EUPL, as long as they 324 + provide the rights granted in Article 2 of this Licence and protect the 325 + covered Source Code from exclusive appropriation. 326 + 327 + All other changes or additions to this Appendix require the production of a 328 + new EUPL version. 329 +
+101
backend/impl-rs/README.md
··· 1 + # Lumina(/peonies) server 2 + 3 + > Notice: 4 + > This project lives on [Tangled](https://tangled.org/strawmelonjuice.com/Lumina), it is mirrored to Codeberg and a few 5 + > other places, but the main development happens on Tangled. 6 + > Please report issues and contribute on Tangled. 7 + 8 + Lumina is a project in development, as the short description says "Just trying out an old concept.". It is not in any 9 + way ready for you to try. However, you are encouraged to contribute in any way! 10 + 11 + ## Progress 12 + 13 + This 'roadmap' is only meant to support development, not place new constraints on an already overwhelmed...me. 14 + 15 + - [ ] Web client in Gleam+Lustre 16 + - [x] A DaisyUI theme and basic defined interface, previewing what Lumina/Peonies will look like. 17 + - [x] Login/Register UI/flow for Username-Password Authentication 18 + - [ ] User settings 19 + - [ ] Timelineview (tested mainly on `global`) 20 + - [ ] Post to load in timeline 21 + - [ ] Timelineswitch (seeing other timelines) 22 + - [ ] ... more to be documented 23 + - [ ] Server backend and API's 24 + - [ ] Server can send timeline global paginated... 25 + - [x] Over authorized WS 26 + - [ ] Through public HTTPS GET 27 + - [ ] Client can request other timelines, by ID, paginated... 28 + - [ ] Over session-protected WS 29 + - [ ] 🧪 Not over unauthorized HTTPS GET 30 + - [ ] Over authorized HTTPS GET 31 + - [ ] Server can authorize session... 32 + - [x] ...Based on username-password over WS 33 + - [x] ...Based on session token over WS 34 + - [ ] ...Based on API token over WS 35 + - [ ] ...Based on API token over HTTPS POST. 36 + - [ ] ...Based on oauth over HTTPS get. 37 + - [ ] ... More? 38 + - [ ] A DM timeline should be available to both (or more) users in the DM. 39 + - [ ] ... More to be documented 40 + - [ ] Authentication: 41 + - [x] Username-Password based login 42 + - [ ] Oauth-based OIDC/Bsky login 43 + - [ ] two-factor-auth 44 + - [ ] IIC (InterInstance Communicating) 45 + - [ ] Redesign the IIC's ways completely, also see notes for this. 46 + - [ ] Investigate federating to ATProto, making any Lumina instance also a PDS. (Except Lumina data would live mostly 47 + off-protocol, and for example article posts would be published as Links to the Bluesky feed.) 48 + - [ ] ... to be documented 49 + - [ ] ... More to be documented 50 + 51 + This list is missing a lot, but it's primary goal is to help ME find a next thing to fix. 52 + 53 + ### Environment variables 54 + 55 + Part of the configuration is loaded from the database, part of it in environment variables. 56 + Environment variables can be set in the _environment_ before run, but Lumina prefers them to be loaded from 57 + `$LUMINAFOLDER/.env`. 58 + 59 + | NAME | DEFAULT | FOR | 60 + | -------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | 61 + | `DATABASE_URL` | Development: `postgres://lumina:lumina_pw@localhost:5432/lumina_config`, production: - | The Postgres database url to connect to. Overrides all `LUMINA_POSTGRES_*` values. | 62 + | `LUMINA_POSTGRES_PORT` | `5432` | The port to contact the database on. | 63 + | `LUMINA_POSTGRES_HOST` | `localhost` | The address to contact the database on. | 64 + | `LUMINA_POSTGRES_USERNAME` | `lumina` | The username to log in to the database with. | 65 + | `LUMINA_POSTGRES_PASSWORD` | - | The password to log in to the database with. If not set, Lumina will try without. | 66 + | `LUMINA_POSTGRES_DATABASE` | `lumina_config` | The database to use. | 67 + | `LUMINA_REDIS_URL` | `redis://127.0.0.1/` | Redis URL to connect to. | 68 + | `LUMINA_DB_SALT` | `sal` | The salting to use for some data on the database. | 69 + | `LUMINA_SERVER_PORT` | `8085` | Port for Lumina to accept HTTP requests on. | 70 + | `LUMINA_SERVER_ADDR` | `127.0.0.1` | Address for Lumina to accept HTTP requests on. (usually `127.0.0.1` or `0.0.0.0`) | 71 + | `LUMINA_SERVER_HTTPS` | `false` | Whether to use 'https' rather than 'http' in links, etc. (please do!) | 72 + | `LUMINA_SYNC_IID` | `localhost` | A name Lumina uses when communicating with other instances, must be equal to where it's http is facing the public internet | 73 + | `LUMINA_SYNC_INTERVAL` | `30` | Specifies the interval between syncs. Minimum is 30. | 74 + 75 + ## Development 76 + 77 + ### With Nix (preffered) 78 + 79 + Use `flake.nix`, either using `direnv` (there's an `.envrc` file to do this!) or with `nix develop`. 80 + This gets you all the dependencies, including Just. 81 + 82 + ```sh 83 + just dev # Prepares your enviroment and builds/runs the server with file watching. 84 + ``` 85 + 86 + ### Without Nix 87 + 88 + Make sure you have [mise-en-place](https://mise.jdx.dev/) and [Just](https://just.systems/) preinstalled. 89 + 90 + ```sh 91 + mise install # Installs mise deps 92 + just dev # Prepares your enviroment and builds/runs the server with file watching. 93 + ``` 94 + 95 + When running Lumina server in development mode, it automatically creates two accounts for you and one of those has an attached 96 + post on the global timeline. 97 + 98 + | Username | Email | Password | 99 + | --------- | ----------------- | ---------------- | 100 + | testuser1 | test@lumina123.co | MyTestPassw9292! | 101 + | testuser2 | test@lumina234.co | MyTestPassw9292! |
+36
backend/impl-rs/assets/svgs/dummy_user_120px.svg
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!-- 3 + - Lumina/Peonies 4 + - Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 5 + - 6 + - This software is licensed under the European Union Public Licence (EUPL) v1.2. 7 + - You may not use this work except in compliance with the Licence. 8 + - You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 9 + - 10 + - AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 11 + - under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 12 + - See LICENSE file in the repository root for full details. 13 + - 14 + - 15 + - This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 16 + - See the Licence for the specific language governing permissions and limitations. [cite: 6] 17 + --> 18 + 19 + <!-- Created with Inkscape (http://www.inkscape.org/) --> 20 + <svg width="120" height="120" version="1.1" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg"> 21 + <rect x=".13191" y=".11064" width="100%" height="100%" fill="#eee"/> 22 + <path transform="matrix(1.2205 0 0 1.0901 24.568 11.706)" 23 + d="m44.528 92.528-30.629 0.89732-10.318-28.852 24.252-18.729 25.307 17.277z" fill="#ddd"/> 24 + <circle cx="59.723" cy="44.519" r="26" fill="#cfcfcf"/> 25 + <g fill="#ddd"> 26 + <rect x="59.366" y="5.217" width="1.634" height="7.7617"/> 27 + <rect x="73.255" y="15.838" width="5.3106" height="3.4723"/> 28 + <rect x="42.413" y="14.817" width="3.0638" height="2.0426"/> 29 + <rect x="71.008" y="8.6894" width="7.1489" height="2.8596"/> 30 + <rect x="65.902" y="5.8298" width="3.4723" height="5.9234"/> 31 + <rect x="48.54" y="4.1957" width="1.2255" height="10.621"/> 32 + <rect x="80.404" y="21.149" width="3.2681" height="3.6766"/> 33 + <rect x="87.962" y="30.953" width="3.2681" height="4.0851"/> 34 + <rect x="34.243" y="18.902" width="1.634" height="5.9234"/> 35 + </g> 36 + </svg>
+23
backend/impl-rs/build.Dockerfile
··· 1 + # syntax=docker/dockerfile:1 2 + 3 + # --- Build stage --- 4 + FROM lumina-build-environment AS builder 5 + 6 + WORKDIR /build 7 + 8 + # Copy the project files excluding mise.toml 9 + COPY --exclude=mise.toml . . 10 + 11 + # Builds debug version 12 + RUN mkdir -p target/output && \ 13 + mise run build-server && \ 14 + cp ./target/debug/lumina-server ./target/output/; 15 + 16 + # --- Final runtime image --- 17 + FROM alpine:3.19 18 + RUN apk add --no-cache ca-certificates 19 + WORKDIR /app 20 + COPY --from=builder /build/target/output/lumina-server /app/lumina-server 21 + COPY assets /app/assets 22 + EXPOSE 8085 23 + CMD ["/app/lumina-server"]
+4
backend/impl-rs/client/.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump
+304
backend/impl-rs/client/app.css
··· 1 + /* 2 + * Lumina/Peonies 3 + * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 4 + * 5 + * This software is licensed under the European Union Public Licence (EUPL) v1.2. 6 + * You may not use this work except in compliance with the Licence. 7 + * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 8 + * 9 + * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 10 + * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 11 + * See LICENSE file in the repository root for full details. 12 + * 13 + * 14 + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 15 + * See the Licence for the specific language governing permissions and limitations. [cite: 6] 16 + */ 17 + 18 + @import "tailwindcss"; 19 + 20 + @source "./src/**/*.gleam"; 21 + 22 + @theme { 23 + /* Vend Sans is used for: 24 + * Main theme 25 + */ 26 + --font-sans: "Vend Sans"; 27 + 28 + /* The Gantari font is used for: 29 + * 'Lumina' name typography 30 + */ 31 + --font-logo: "Gantari"; 32 + 33 + /* The Elms sans is used for: 34 + * (User-generated) Content on pages, form input fields. 35 + */ 36 + --font-content: "Elms Sans"; 37 + 38 + /* The Josefin Sans is used for: 39 + * Menu items 40 + */ 41 + --font-menuitems: "Josefin Sans"; 42 + 43 + /* The DM Mono font is used for: 44 + * Code blocks and monospaced text 45 + */ 46 + --font-script: "DM Mono"; 47 + } 48 + 49 + @plugin "daisyui" {} 50 + 51 + @plugin "daisyui/theme" { 52 + name: "lumina-light"; 53 + default: true; 54 + prefersdark: false; 55 + color-scheme: "light"; 56 + --color-base-100: oklch(0.9516 0.0312 70.53); 57 + --color-base-200: oklch(0.8622 0.0447 122.78); 58 + --color-base-300: oklch(94% 0.028 342.258); 59 + --color-base-content: oklch(51% 0.096 186.391); 60 + --color-primary: oklch(87% 0.15 154.449); 61 + --color-primary-content: oklch(30% 0.056 229.695); 62 + --color-secondary: oklch(51% 0.253 323.949); 63 + --color-secondary-content: oklch(94% 0.028 342.258); 64 + --color-accent: oklch(80% 0.114 19.571); 65 + --color-accent-content: oklch(44% 0.043 257.281); 66 + --color-neutral: oklch(98% 0.003 247.858); 67 + --color-neutral-content: oklch(12% 0.042 264.695); 68 + --color-info: oklch(85% 0.138 181.071); 69 + --color-info-content: oklch(29% 0.066 243.157); 70 + --color-success: oklch(76% 0.233 130.85); 71 + --color-success-content: oklch(37% 0.077 168.94); 72 + --color-warning: oklch(70% 0.213 47.604); 73 + --color-warning-content: oklch(27% 0.077 45.635); 74 + --color-error: oklch(63% 0.237 25.331); 75 + --color-error-content: oklch(27% 0.105 12.094); 76 + --radius-selector: 0.75rem; 77 + --radius-field: 1.25rem; 78 + --radius-box: 1.5rem; 79 + --size-selector: 0.25rem; 80 + --size-field: 0.25rem; 81 + --border: 1px; 82 + --depth: 1; 83 + --noise: 0; 84 + /* --spacing: 0.5rem; */ 85 + } 86 + 87 + @plugin "daisyui/theme" { 88 + name: "lumina-dark"; 89 + default: false; 90 + prefersdark: true; 91 + color-scheme: "dark"; 92 + 93 + --color-base-100: oklch(14% 0.02 156.743); 94 + --color-base-200: oklch(26.9% 0 5); 95 + --color-base-300: oklch(25% 0.02 342.258); 96 + --color-base-content: oklch(85% 0.08 186.391); 97 + --color-primary: oklch(50% 0.15 154.449); 98 + --color-primary-content: oklch(88% 0.09 229.695); 99 + --color-secondary: oklch(38% 0.23 323.949); 100 + --color-secondary-content: oklch(90% 0.05 342.258); 101 + --color-accent: oklch(55% 0.12 19.571); 102 + --color-accent-content: oklch(87% 0.07 257.281); 103 + --color-neutral: oklch(12% 0.01 247.858); 104 + --color-neutral-content: oklch(88% 0.06 264.695); 105 + --color-info: oklch(55% 0.14 181.071); 106 + --color-info-content: oklch(90% 0.08 243.157); 107 + --color-success: oklch(42% 0.22 130.85); 108 + --color-success-content: oklch(85% 0.09 168.94); 109 + --color-warning: oklch(50% 0.2 47.604); 110 + --color-warning-content: oklch(88% 0.08 45.635); 111 + --color-error: oklch(45% 0.23 25.331); 112 + --color-error-content: oklch(90% 0.11 12.094); 113 + --radius-selector: 0.75rem; 114 + --radius-field: 1.25rem; 115 + --radius-box: 1.5rem; 116 + --size-selector: 0.25rem; 117 + --size-field: 0.25rem; 118 + --border: 1px; 119 + --depth: 1; 120 + --noise: 0; 121 + /* --spacing: 0.5rem; */ 122 + } 123 + 124 + @layer base { 125 + html { 126 + /* HTML overflow hidden */ 127 + overflow: clip; 128 + } 129 + 130 + * { 131 + /* By default, none is selectable, selectable stuff gets 'text-select' class so tailwind re-enables it there. */ 132 + -webkit-user-select: none; 133 + user-select: none; 134 + } 135 + 136 + /*Pride month banner*/ 137 + body:has(.monthclass-6) main::before { 138 + margin: 0; 139 + content: "Happy Pride Month! 💖🏳️‍🌈"; 140 + justify-content: center; 141 + align-items: center; 142 + height: 1.4em; 143 + color: black; 144 + width: 100vw; 145 + border-radius: 0; 146 + display: inline-flex; 147 + background-image: linear-gradient(to right, 148 + rgb(237, 34, 36), 149 + rgb(243, 91, 34), 150 + rgb(249, 150, 33), 151 + rgb(245, 193, 30), 152 + rgb(241, 235, 27) 27%, 153 + rgb(241, 235, 27), 154 + rgb(241, 235, 27) 33%, 155 + rgb(99, 199, 32), 156 + rgb(12, 155, 73), 157 + rgb(33, 135, 141), 158 + rgb(57, 84, 165), 159 + rgb(97, 55, 155), 160 + rgb(147, 40, 142)); 161 + } 162 + 163 + body:has(.monthclass-6) { 164 + --bs: 300% 100%; 165 + } 166 + 167 + body:has(.monthclass-6) main:hover::before { 168 + animation: prideBannerAnimation 10s linear infinite; 169 + } 170 + 171 + @keyframes prideBannerAnimation { 172 + 0% {} 173 + 174 + 25% { 175 + background-position: 0 0; 176 + background-size: var(--bs); 177 + background-repeat: repeat; 178 + } 179 + 180 + 30% { 181 + background-position: 50% 0; 182 + content: "Protect LGBTQ+ Rights! 🏳️‍🌈✊"; 183 + background-size: var(--bs); 184 + background-repeat: repeat; 185 + } 186 + 187 + 50% { 188 + background-position: 100% 0; 189 + content: "Protect LGBTQ+ Rights! 🏳️‍🌈✊"; 190 + background-size: var(--bs); 191 + background-repeat: repeat; 192 + } 193 + 194 + 75% { 195 + background-position: 0 0; 196 + background-size: var(--bs); 197 + background-repeat: repeat; 198 + } 199 + 200 + 80% { 201 + background-position: 50% 0; 202 + content: "Protect LGBTQ+ Rights! 🏳️‍🌈 ✊"; 203 + background-size: var(--bs); 204 + background-repeat: repeat; 205 + } 206 + 207 + 100% {} 208 + } 209 + 210 + body:has(.monthclass-6):active main::before { 211 + animation: none; 212 + animation-delay: 3s; 213 + animation-duration: 999s; 214 + animation-name: transrights; 215 + animation-iteration-count: 1; 216 + animation-timing-function: ease-in-out; 217 + } 218 + 219 + @keyframes transrights { 220 + 0% { 221 + content: "Protect trans Rights! ✊ 🩵🩷🤍🩷🩵"; 222 + background-image: linear-gradient(to right, 223 + rgb(85, 205, 252), 224 + rgb(179, 157, 233), 225 + rgb(247, 168, 184), 226 + rgb(246, 216, 221), 227 + rgb(255, 255, 255) 45%, 228 + rgb(255, 255, 255), 229 + rgb(255, 255, 255) 55%, 230 + rgb(246, 216, 221), 231 + rgb(247, 168, 184), 232 + rgb(179, 157, 233), 233 + rgb(85, 205, 252)); 234 + } 235 + } 236 + 237 + /*29th of februari is nonexistent in non-leap years*/ 238 + body:has(.dayclass-29.monthclass-2) main::before { 239 + margin-top: 0.8em; 240 + margin-bottom: 0.8em; 241 + content: "[This day does not exist]"; 242 + justify-content: center; 243 + align-items: center; 244 + height: 2.4em; 245 + flex: none; 246 + color: yellow; 247 + width: 100%; 248 + display: inline-flex; 249 + background-color: black; 250 + text-shadow: 22px 4px 2px rgba(255, 255, 0, 0.6); 251 + box-shadow: 2px 2px 10px 8px #3d3a3a; 252 + animation-name: glitched; 253 + animation-duration: 3s; 254 + animation-iteration-count: infinite; 255 + animation-timing-function: linear; 256 + animation-direction: alternate; 257 + } 258 + 259 + @keyframes glitched { 260 + 0% { 261 + transform: skew(-20deg); 262 + left: -4px; 263 + } 264 + 265 + 10% { 266 + transform: skew(-20deg); 267 + left: -4px; 268 + } 269 + 270 + 11% { 271 + transform: skew(0deg); 272 + left: 2px; 273 + } 274 + 275 + 50% { 276 + transform: skew(0deg); 277 + } 278 + 279 + 51% { 280 + transform: skew(10deg); 281 + } 282 + 283 + 59% { 284 + transform: skew(10deg); 285 + } 286 + 287 + 60% { 288 + transform: skew(0deg); 289 + } 290 + 291 + 100% { 292 + transform: skew(0deg); 293 + } 294 + } 295 + 296 + .lg\:freeroam { 297 + @media (width >=64rem) { 298 + left: var(--left); 299 + top: var(--top); 300 + transform: var(--transform); 301 + position: absolute; 302 + } 303 + } 304 + }
+17
backend/impl-rs/client/bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 0, 4 + "workspaces": { 5 + "": { 6 + "dependencies": { 7 + "daisyui": "latest", 8 + "tailwindcss": "^4", 9 + }, 10 + }, 11 + }, 12 + "packages": { 13 + "daisyui": ["daisyui@5.0.43", "", {}, "sha512-2pshHJ73vetSpsbAyaOncGnNYL0mwvgseS1EWy1I9Qpw8D11OuBoDNIWrPIME4UFcq2xuff3A9x+eXbuFR9fUQ=="], 14 + 15 + "tailwindcss": ["tailwindcss@4.1.8", "", {}, "sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og=="], 16 + } 17 + }
+19
backend/impl-rs/client/gleam.toml
··· 1 + name = "lumina_client" 2 + version = "1.0.0" 3 + target = "javascript" 4 + licences=["EUPL-1.2"] 5 + 6 + [dependencies] 7 + gleam_json = "2.3.0" 8 + gleam_stdlib = "0.59.0" 9 + lustre = ">= 5.0.3 and < 6.0.0" 10 + lustre_websocket = ">= 0.9.0 and < 1.0.0" 11 + gleamy_lights = ">= 2.3.0 and < 3.0.0" 12 + plinth = ">= 0.5.9 and < 1.0.0" 13 + gleam_time = ">= 1.4.0 and < 2.0.0" 14 + 15 + [dev-dependencies] 16 + gleeunit = "~> 1.0" 17 + 18 + [metadata] 19 + tdm_reservation = "true"
+108
backend/impl-rs/client/manifest.toml
··· 1 + # This file was generated by Gleam 2 + # You typically do not need to edit this file 3 + 4 + packages = [ 5 + { name = "conversation", version = "2.0.1", build_tools = [ 6 + "gleam", 7 + ], requirements = [ 8 + "gleam_http", 9 + "gleam_javascript", 10 + "gleam_stdlib", 11 + ], otp_app = "conversation", source = "hex", outer_checksum = "103DF47463B8432AB713D6643DC17244B9C82E2B172A343150805129FE584A2F" }, 12 + { name = "envoy", version = "1.0.2", build_tools = [ 13 + "gleam", 14 + ], requirements = [ 15 + "gleam_stdlib", 16 + ], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, 17 + { name = "gleam_community_colour", version = "2.0.1", build_tools = [ 18 + "gleam", 19 + ], requirements = [ 20 + "gleam_json", 21 + "gleam_stdlib", 22 + ], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "F0ACE69E3A47E913B03D3D0BB23A5563A91A4A7D20956916286068F4A9F817FE" }, 23 + { name = "gleam_erlang", version = "0.34.0", build_tools = [ 24 + "gleam", 25 + ], requirements = [ 26 + "gleam_stdlib", 27 + ], otp_app = "gleam_erlang", source = "hex", outer_checksum = "0C38F2A128BAA0CEF17C3000BD2097EB80634E239CE31A86400C4416A5D0FDCC" }, 28 + { name = "gleam_http", version = "3.7.2", build_tools = [ 29 + "gleam", 30 + ], requirements = [ 31 + "gleam_stdlib", 32 + ], otp_app = "gleam_http", source = "hex", outer_checksum = "8A70D2F70BB7CFEB5DF048A2183FFBA91AF6D4CF5798504841744A16999E33D2" }, 33 + { name = "gleam_javascript", version = "1.0.0", build_tools = [ 34 + "gleam", 35 + ], requirements = [ 36 + "gleam_stdlib", 37 + ], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, 38 + { name = "gleam_json", version = "2.3.0", build_tools = [ 39 + "gleam", 40 + ], requirements = [ 41 + "gleam_stdlib", 42 + ], otp_app = "gleam_json", source = "hex", outer_checksum = "C55C5C2B318533A8072D221C5E06E5A75711C129E420DD1CE463342106012E5D" }, 43 + { name = "gleam_otp", version = "0.16.1", build_tools = [ 44 + "gleam", 45 + ], requirements = [ 46 + "gleam_erlang", 47 + "gleam_stdlib", 48 + ], otp_app = "gleam_otp", source = "hex", outer_checksum = "50DA1539FC8E8FA09924EB36A67A2BBB0AD6B27BCDED5A7EF627057CF69D035E" }, 49 + { name = "gleam_stdlib", version = "0.59.0", build_tools = [ 50 + "gleam", 51 + ], requirements = [ 52 + ], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "F8FEE9B35797301994B81AF75508CF87C328FE1585558B0FFD188DC2B32EAA95" }, 53 + { name = "gleam_time", version = "1.4.0", build_tools = [ 54 + "gleam", 55 + ], requirements = [ 56 + "gleam_stdlib", 57 + ], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" }, 58 + { name = "gleamy_lights", version = "2.3.1", build_tools = [ 59 + "gleam", 60 + ], requirements = [ 61 + "envoy", 62 + "gleam_community_colour", 63 + "gleam_stdlib", 64 + ], otp_app = "gleamy_lights", source = "hex", outer_checksum = "CD89DD48BBCD8FBB6B8CB84101C70221CBFB901F711C3C7F81F47288EC8074FD" }, 65 + { name = "gleeunit", version = "1.3.1", build_tools = [ 66 + "gleam", 67 + ], requirements = [ 68 + "gleam_stdlib", 69 + ], otp_app = "gleeunit", source = "hex", outer_checksum = "A7DD6C07B7DA49A6E28796058AA89E651D233B357D5607006D70619CD89DAAAB" }, 70 + { name = "houdini", version = "1.1.0", build_tools = [ 71 + "gleam", 72 + ], requirements = [ 73 + "gleam_stdlib", 74 + ], otp_app = "houdini", source = "hex", outer_checksum = "5BA517E5179F132F0471CB314F27FE210A10407387DA1EA4F6FD084F74469FC2" }, 75 + { name = "lustre", version = "5.0.3", build_tools = [ 76 + "gleam", 77 + ], requirements = [ 78 + "gleam_erlang", 79 + "gleam_json", 80 + "gleam_otp", 81 + "gleam_stdlib", 82 + "houdini", 83 + ], otp_app = "lustre", source = "hex", outer_checksum = "0BB69D69A9E75E675AA2C32A4A0E5086041D037829FC8AD385BA6A59E45A60A2" }, 84 + { name = "lustre_websocket", version = "0.9.0", build_tools = [ 85 + "gleam", 86 + ], requirements = [ 87 + "gleam_stdlib", 88 + "lustre", 89 + ], otp_app = "lustre_websocket", source = "hex", outer_checksum = "7C986F711ACCF7F4EF4C24BDE0BE1D25D805A92ED3BFFE10BE61EBE1E92065D6" }, 90 + { name = "plinth", version = "0.5.9", build_tools = [ 91 + "gleam", 92 + ], requirements = [ 93 + "conversation", 94 + "gleam_javascript", 95 + "gleam_json", 96 + "gleam_stdlib", 97 + ], otp_app = "plinth", source = "hex", outer_checksum = "9684C5D768F99B34537B48B100509389C45D2E7C045426E93ACB250993611724" }, 98 + ] 99 + 100 + [requirements] 101 + gleam_json = { version = "2.3.0" } 102 + gleam_stdlib = { version = "0.59.0" } 103 + gleam_time = { version = ">= 1.4.0 and < 2.0.0" } 104 + gleamy_lights = { version = ">= 2.3.0 and < 3.0.0" } 105 + gleeunit = { version = "~> 1.0" } 106 + lustre = { version = ">= 5.0.3 and < 6.0.0" } 107 + lustre_websocket = { version = ">= 0.9.0 and < 1.0.0" } 108 + plinth = { version = ">= 0.5.9 and < 1.0.0" }
+6
backend/impl-rs/client/package.json
··· 1 + { 2 + "dependencies": { 3 + "daisyui": "latest", 4 + "tailwindcss": "^4" 5 + } 6 + }
backend/impl-rs/client/priv/static/logo.png

This is a binary file and will not be displayed.

+105
backend/impl-rs/client/priv/static/logo.svg
··· 1 + <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 + <!-- 3 + - Lumina/Peonies 4 + - Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 5 + - 6 + - This software is licensed under the European Union Public Licence (EUPL) v1.2. 7 + - You may not use this work except in compliance with the Licence. 8 + - You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 9 + - 10 + - AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 11 + - under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 12 + - See LICENSE file in the repository root for full details. 13 + - 14 + - 15 + - This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 16 + - See the Licence for the specific language governing permissions and limitations. [cite: 6] 17 + --> 18 + 19 + <svg 20 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 21 + id="svg1" 22 + width="120" 23 + height="120" 24 + version="1.1" 25 + 26 + 27 + viewBox="0 0 120 120" 28 + xmlns="http://www.w3.org/2000/svg" 29 + > 30 + <defs 31 + id="defs1"/> 32 + <sodipodi:namedview 33 + id="namedview1" 34 + pagecolor="#ffffff" 35 + bordercolor="#000000" 36 + borderopacity="0.25" 37 + /> 38 + <g 39 + id="layer2" 40 + style="display:inline;fill:#ff6f08;fill-opacity:0.524951" 41 + transform="translate(10.078565,21.743467)"> 42 + <path 43 + style="fill:#ff6f08;fill-opacity:0.524951" 44 + id="path6" 45 + d="m 59.920493,35.97327 c 0.02434,0.290094 0.06487,0.579283 0.07303,0.870282 0.01131,0.403437 -0.0064,0.807188 -0.01469,1.210697 -0.05537,2.686992 -0.310969,5.349396 -0.604281,8.019455 -0.106169,2.837652 -0.638764,5.639873 -0.814527,8.470747 -0.04226,0.680659 -0.07323,2.790624 -0.08607,3.501434 0.0277,2.689403 -8.45e-4,5.379505 -0.125408,8.066236 -0.09936,1.501992 0.01475,3.011357 0.197177,4.502895 0.18554,1.065579 0.406733,2.12569 0.658557,3.177578 0.233487,0.829929 0.428099,1.672688 0.697953,2.4918 0.15861,0.674107 0.418834,1.299222 0.727503,1.915881 0.521263,0.912555 1.196993,1.709777 1.883529,2.499603 0.639982,0.672658 1.363774,1.299117 2.168539,1.769866 12.558905,7.34636 8.506542,5.38692 13.682471,7.860804 0.665028,0.338812 1.374121,0.551272 2.082153,0.77281 0.438852,0.137313 0.703704,0.256905 1.149913,0.34846 0.177844,0.03649 0.359571,0.05048 0.539356,0.07573 0.518886,0.0311 1.040244,0.104878 1.5614,0.08885 0.497768,-0.01531 0.989018,-0.11971 1.479493,-0.19632 0.659206,-0.181827 1.298249,-0.439176 1.917167,-0.729161 0.307243,-0.133551 0.607881,-0.290232 0.927638,-0.393461 0.135487,-0.04374 0.27761,-0.06488 0.412977,-0.108988 1.890769,-0.616103 -0.88541,0.226671 0.938814,-0.319168 0.377858,-0.131535 0.778978,-0.142572 1.168105,-0.209501 0.214806,-0.03695 0.425372,-0.09579 0.639657,-0.135645 0,0 -12.209607,-8.45218 -12.209607,-8.45218 v 0 c -0.581512,0.164075 -1.167576,0.309306 -1.728315,0.540912 -0.842263,0.281829 -1.708842,0.493059 -2.530454,0.836332 -0.511785,0.204425 -1.015543,0.435988 -1.576605,0.465593 -0.892182,0.09574 -1.79237,-0.03477 -2.659459,-0.252228 -1.159405,-0.36404 -2.314346,-0.742668 -3.367466,-1.365985 -4.542034,-2.579233 -0.799218,-0.456481 11.000309,6.391652 0.21726,0.126092 -0.437465,-0.24722 -0.652055,-0.377806 -0.268427,-0.163349 -0.753488,-0.482226 -1.018052,-0.678238 -0.139255,-0.103172 -0.271663,-0.21528 -0.407494,-0.322919 -0.394614,-0.35058 -0.753781,-0.719271 -1.107072,-1.111628 -0.02426,-0.02695 -0.540442,-0.611006 -0.582238,-0.67203 -0.07216,-0.105355 -0.119835,-0.225532 -0.179752,-0.338298 -0.186438,-0.243567 -0.312176,-0.486041 -0.465793,-0.748918 -0.159681,-0.273254 -0.34586,-0.526382 -0.411041,-0.84603 -0.258232,-0.838446 -0.627088,-1.640644 -0.869039,-2.486231 -0.291198,-1.025474 -0.507349,-2.072221 -0.767053,-3.106035 -0.02222,-0.09355 -0.276309,-1.16084 -0.289439,-1.22702 -0.211621,-1.06661 -0.281172,-2.157808 -0.361743,-3.239794 -0.202839,-2.776164 -0.369193,-5.556343 -0.435037,-8.33941 -0.134457,-4.010728 -0.185302,-8.049303 0.412949,-12.028841 0.394999,-2.900178 0.828375,-5.83255 1.755081,-8.621441 0.07983,-0.24024 0.185911,-0.470944 0.278866,-0.706415 z"/> 46 + </g> 47 + <g 48 + id="layer1-5" 49 + transform="translate(0.73545524,-0.09329462)"> 50 + <ellipse 51 + style="fill:#ffcc00" 52 + id="path1-3" 53 + cx="32.622002" 54 + cy="99.763" 55 + rx="14.187" 56 + ry="13.374"/> 57 + <ellipse 58 + style="fill:#000080" 59 + id="path2-5" 60 + cx="107.5645" 61 + cy="46.181362" 62 + rx="13.374" 63 + ry="15.091"/> 64 + <ellipse 65 + style="fill:#4d4d4d" 66 + id="path3-6" 67 + cx="26.115999" 68 + cy="28.646" 69 + rx="26.837999" 70 + ry="27.561001"/> 71 + <ellipse 72 + style="fill:#d38d5f" 73 + id="path4-2" 74 + cx="78.708" 75 + cy="30.181999" 76 + rx="6.5970001" 77 + ry="6.3260002"/> 78 + <path 79 + style="fill:#000000" 80 + id="path8-9" 81 + d="m 28.555366,54.21905 c 4.698985,33.615811 4.698985,33.615811 4.698985,33.615811"/> 82 + <path 83 + style="fill:#ffaeae;fill-opacity:1" 84 + id="path12-1" 85 + d="m 26.748065,29.459017 9.036508,67.231623 -0.72292,0.18073"/> 86 + <path 87 + style="fill:#790000;fill-opacity:1;stroke-width:2.16341" 88 + id="path14-2" 89 + d="m 112.07197,52.140484 -80.699252,-12.786469 2.519513,5.460883 v 0 0" 90 + sodipodi:nodetypes="ccccc"/> 91 + <path 92 + style="fill:#cd0909;fill-opacity:1" 93 + id="path13-7" 94 + d="m 35.423113,96.69064 42.83305,-65.424321 0.90365,0.542191 -42.833049,66.147241 h -0.18073 z"/> 95 + <path 96 + style="fill:#ff0000;stroke-width:3.57723" 97 + id="path15-0" 98 + d="M 82.607667,34.648745 111.7959,52.394116 v 0 L 78.614522,29.156547 Z"/> 99 + <path 100 + style="fill:#b94646;fill-opacity:1" 101 + id="path16-9" 102 + d="m 74.641559,29.820478 -39.760637,7.409937 v 0 l 38.676256,-9.397969 v 0 0 z"/> 103 + </g> 104 + </svg> 105 +
+1053
backend/impl-rs/client/src/lumina_client.gleam
··· 1 + //// Lumina > Client 2 + //// Main entry point for Lumina client. This module contains all side-effects, the update function. Lustre initialisation and more. 3 + 4 + // Lumina/Peonies 5 + // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 + // 7 + // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 + // You may not use this work except in compliance with the Licence. 9 + // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 + // 11 + // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 + // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 + // See LICENSE file in the repository root for full details. 14 + // 15 + // 16 + // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 + // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 + 19 + import gleam/bool 20 + import gleam/dict 21 + import gleam/dynamic/decode 22 + import gleam/float 23 + import gleam/int 24 + import gleam/json 25 + import gleam/list 26 + import gleam/option.{None, Some} 27 + import gleam/result 28 + import gleam/string 29 + import gleam/time/timestamp 30 + import gleamy_lights/console 31 + import gleamy_lights/premixed 32 + import lumina_client/dom 33 + import lumina_client/helpers.{login_view_checker, model_local_storage_key} 34 + import lumina_client/model_type.{ 35 + type Model, type Msg, EffectPast150ms, EmailFieldLostFocus, HomeTimeline, 36 + Landing, Licence, Login, LoginFields, Model, NotFound, Register, 37 + RegisterPageFields, UpdateLastRefreshRequestTime, UserClickedLogout, 38 + UserNavigatedToLandingPage, UserNavigatedToLoginPage, 39 + UserNavigatedToRegisterPage, UserSubmittedLogin, UserSubmittedSignup, 40 + UserUpdatedControlledEmailField, UserUpdatedControlledPasswordConfirmField, 41 + UserUpdatedControlledPasswordField, UserUpdatedControlledUsernameField, 42 + WSTryReconnect, WebSocketIncomingMessage, WsDisconnectDefinitive, 43 + } 44 + import lumina_client/view.{view} 45 + import lumina_client/view/homepage 46 + import lustre 47 + import lustre/effect.{type Effect} 48 + import lustre_websocket 49 + import plinth/javascript/storage 50 + 51 + // HELPER FUNCTIONS ------------------------------------------------------------ 52 + 53 + /// Get posts for display from a timeline cache 54 + /// Returns a list of all cached posts in order, or empty list if timeline not found 55 + pub fn get_timeline_posts_for_display( 56 + model: Model, 57 + timeline_name: String, 58 + ) -> List(String) { 59 + case model.cache.cached_timelines |> dict.get(timeline_name) { 60 + Ok(timeline) -> homepage.get_all_posts(timeline) 61 + Error(_) -> [] 62 + } 63 + } 64 + 65 + /// Check if a timeline needs more data to be loaded 66 + pub fn timeline_needs_more_data( 67 + model: Model, 68 + timeline_name: String, 69 + position: Int, 70 + ) -> Bool { 71 + case model.cache.cached_timelines |> dict.get(timeline_name) { 72 + Ok(timeline) -> homepage.should_load_more(timeline, position, 10) 73 + Error(_) -> True 74 + // If no timeline cached, we definitely need data 75 + } 76 + } 77 + 78 + /// Request next page for a timeline 79 + pub fn request_next_timeline_page( 80 + model: Model, 81 + timeline_name: String, 82 + ) -> Effect(Msg) { 83 + let assert model_type.WsConnectionConnected(socket) = model.ws 84 + as "Socket not connected" 85 + 86 + case model.cache.cached_timelines |> dict.get(timeline_name) { 87 + Ok(timeline) -> { 88 + case homepage.get_next_page_to_load(timeline) { 89 + Some(next_page) -> 90 + TimeLineRequest(timeline_name, next_page) 91 + |> encode_ws_msg 92 + |> json.to_string 93 + |> lustre_websocket.send(socket, _) 94 + None -> effect.none() 95 + } 96 + } 97 + Error(_) -> 98 + TimeLineRequest(timeline_name, 0) 99 + |> encode_ws_msg 100 + |> json.to_string 101 + |> lustre_websocket.send(socket, _) 102 + } 103 + } 104 + 105 + // MAIN ------------------------------------------------------------------------ 106 + 107 + pub fn main() { 108 + let app = lustre.application(init, update, view) 109 + let assert Ok(_) = lustre.start(app, "#app", False) 110 + } 111 + 112 + // INIT ------------------------------------------------------------------------ 113 + 114 + fn init(rerun: Bool) -> #(Model, Effect(Msg)) { 115 + let assert Ok(localstorage) = storage.local() 116 + as "localstorage should be available on ALL major browsers." 117 + let empty_model = 118 + Model( 119 + page: Landing, 120 + user: None, 121 + ws: model_type.WsConnectionInitial, 122 + token: None, 123 + status: Ok(Nil), 124 + cache: model_type.Cached( 125 + cached_posts: dict.new(), 126 + cached_timelines: dict.new(), 127 + cached_users: dict.new(), 128 + ), 129 + has_been_running_for_150ms: rerun, 130 + last_refresh_request_time: float.truncate( 131 + timestamp.to_unix_seconds(timestamp.system_time()), 132 + ), 133 + ) 134 + #( 135 + case storage.get_item(localstorage, model_local_storage_key) { 136 + Ok(l) -> { 137 + case model_type.deserialize_serializable_model(l) { 138 + Ok(loadable_model) -> { 139 + Model( 140 + page: loadable_model.page, 141 + user: None, 142 + ws: { 143 + case rerun { 144 + True -> model_type.WsConnectionRetrying 145 + False -> model_type.WsConnectionInitial 146 + } 147 + }, 148 + token: loadable_model.token, 149 + status: Ok(Nil), 150 + cache: model_type.Cached( 151 + cached_posts: dict.new(), 152 + cached_timelines: dict.new(), 153 + cached_users: dict.new(), 154 + ), 155 + has_been_running_for_150ms: rerun, 156 + last_refresh_request_time: float.truncate( 157 + timestamp.to_unix_seconds(timestamp.system_time()), 158 + ), 159 + ) 160 + } 161 + Error(_) -> { 162 + console.error("Could not deserialise last saved model.") 163 + empty_model 164 + } 165 + } 166 + } 167 + Error(_) -> { 168 + console.log("No model to restore") 169 + empty_model 170 + } 171 + }, 172 + effect.batch([ 173 + lustre_websocket.init("/connection", WebSocketIncomingMessage), 174 + count_to_150(), 175 + ]), 176 + ) 177 + } 178 + 179 + pub fn start_tracking_mouse_movements(x: Float, y: Float) { 180 + use dispatcher <- effect.from 181 + dom.start_dragging_modal_box(x, y, model_type.MoveModalBoxTo, dispatcher) 182 + } 183 + 184 + pub fn count_to_150() { 185 + use dispatch <- effect.from 186 + use <- helpers.set_timeout_nilled(150) 187 + dispatch(EffectPast150ms) 188 + } 189 + 190 + fn let_definitely_disconnect(model: Model) { 191 + use dispatch <- effect.from 192 + case model.ws, model.has_been_running_for_150ms { 193 + model_type.WsConnectionUnsure, False 194 + | model_type.WsConnectionDisconnected, _ 195 + | model_type.WsConnectionInitial, _ 196 + | model_type.WsConnectionRetrying, _ 197 + | model_type.WsConnectionConnected(..), _ 198 + -> Nil 199 + model_type.WsConnectionUnsure, True -> dispatch(WsDisconnectDefinitive) 200 + } 201 + } 202 + 203 + // UPDATE ---------------------------------------------------------------------- 204 + 205 + fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 206 + case msg { 207 + EffectPast150ms -> { 208 + #(Model(..model, has_been_running_for_150ms: True), effect.none()) 209 + } 210 + UpdateLastRefreshRequestTime(new_time) -> { 211 + #(Model(..model, last_refresh_request_time: new_time), effect.none()) 212 + } 213 + WSTryReconnect -> { 214 + case model.ws { 215 + model_type.WsConnectionDisconnected -> 216 + init(model.has_been_running_for_150ms) 217 + _ -> #(model, effect.none()) 218 + } 219 + } 220 + WsDisconnectDefinitive -> { 221 + let timed_trigger_to_retry_connect = fn(h) { 222 + use dispatch <- effect.from 223 + use <- helpers.set_timeout_nilled(h) 224 + dispatch(WSTryReconnect) 225 + } 226 + #( 227 + Model(..model, ws: model_type.WsConnectionDisconnected), 228 + effect.batch([ 229 + timed_trigger_to_retry_connect(1500), 230 + timed_trigger_to_retry_connect(3000), 231 + timed_trigger_to_retry_connect(6000), 232 + timed_trigger_to_retry_connect(12_000), 233 + timed_trigger_to_retry_connect(24_000), 234 + ]), 235 + ) 236 + } 237 + // Catch other Ws Events in a different function, since that is generally very different stuff. 238 + WebSocketIncomingMessage(event) -> update_ws(model, event) 239 + UserNavigatedToLoginPage -> #( 240 + Model(..model, page: Login(fields: LoginFields("", ""), success: None)), 241 + effect.none(), 242 + ) 243 + UserNavigatedToRegisterPage -> #( 244 + Model( 245 + ..model, 246 + page: Register(fields: RegisterPageFields("", "", "", ""), ready: None), 247 + ), 248 + effect.none(), 249 + ) 250 + UserNavigatedToLandingPage -> #( 251 + Model(..model, page: Landing), 252 + effect.none(), 253 + ) 254 + UserUpdatedControlledEmailField(new_email) -> { 255 + case model.page { 256 + Register(fields, ready) -> #( 257 + Model( 258 + ..model, 259 + page: Register( 260 + fields: RegisterPageFields(..fields, emailfield: new_email), 261 + ready:, 262 + ), 263 + ), 264 + { 265 + // This block emits an effect to send RegisterPrecheck message to the server 266 + let assert model_type.WsConnectionConnected(socket) = model.ws 267 + as "Socket not connected" 268 + encode_ws_msg(RegisterPrecheck( 269 + fields.emailfield, 270 + fields.usernamefield, 271 + fields.passwordfield, 272 + )) 273 + |> json.to_string() 274 + |> lustre_websocket.send(socket, _) 275 + }, 276 + ) 277 + Login(fields, _) -> #( 278 + Model( 279 + ..model, 280 + page: Login( 281 + fields: LoginFields(..fields, emailfield: new_email), 282 + success: None, 283 + ), 284 + ), 285 + effect.none(), 286 + ) 287 + _ -> #(model, effect.none()) 288 + } 289 + } 290 + UserUpdatedControlledPasswordField(new_password) -> { 291 + case model.page { 292 + Register(fields, ready) -> #( 293 + Model( 294 + ..model, 295 + page: Register( 296 + RegisterPageFields(..fields, passwordfield: new_password), 297 + ready:, 298 + ), 299 + ), 300 + { 301 + // This block emits an effect to send RegisterPrecheck message to the server 302 + let assert model_type.WsConnectionConnected(socket) = model.ws 303 + as "Socket not connected" 304 + encode_ws_msg(RegisterPrecheck( 305 + fields.emailfield, 306 + fields.usernamefield, 307 + fields.passwordfield, 308 + )) 309 + |> json.to_string() 310 + |> lustre_websocket.send(socket, _) 311 + }, 312 + ) 313 + Login(fields, _success) -> { 314 + let username_email = case string.starts_with(fields.emailfield, "@") { 315 + True -> string.drop_start(fields.emailfield, 1) 316 + False -> fields.emailfield 317 + } 318 + let new_username_email = case string.contains(username_email, "@") { 319 + True -> { 320 + // Is an email, what now! 321 + username_email 322 + } 323 + False -> { 324 + string.trim(username_email) 325 + |> string.replace(" ", "") 326 + |> string.lowercase() 327 + |> string.replace("@", "") 328 + |> string.replace(".", "") 329 + } 330 + } 331 + #( 332 + Model( 333 + ..model, 334 + page: Login( 335 + fields: LoginFields( 336 + passwordfield: new_password, 337 + emailfield: new_username_email, 338 + ), 339 + success: None, 340 + ), 341 + ), 342 + effect.none(), 343 + ) 344 + } 345 + _ -> #(model, effect.none()) 346 + } 347 + } 348 + UserUpdatedControlledPasswordConfirmField(new_password_confirmation) -> { 349 + case model.page { 350 + Register(fields, ready) -> #( 351 + Model( 352 + ..model, 353 + page: Register( 354 + fields: RegisterPageFields( 355 + ..fields, 356 + passwordconfirmfield: new_password_confirmation, 357 + ), 358 + ready:, 359 + ), 360 + ), 361 + { 362 + // This block emits an effect to send RegisterPrecheck message to the server 363 + let assert model_type.WsConnectionConnected(socket) = model.ws 364 + as "Socket not connected" 365 + encode_ws_msg(RegisterPrecheck( 366 + fields.emailfield, 367 + fields.usernamefield, 368 + fields.passwordfield, 369 + )) 370 + |> json.to_string() 371 + |> lustre_websocket.send(socket, _) 372 + }, 373 + ) 374 + _ -> #(model, effect.none()) 375 + } 376 + } 377 + UserUpdatedControlledUsernameField(new_username) -> { 378 + case model.page { 379 + Register(fields, ready) -> #( 380 + Model( 381 + ..model, 382 + page: Register( 383 + fields: RegisterPageFields(..fields, usernamefield: { 384 + case string.starts_with(new_username, "@") { 385 + True -> string.drop_start(new_username, 1) 386 + False -> new_username 387 + } 388 + |> string.trim() 389 + |> string.replace(" ", "") 390 + |> string.lowercase() 391 + |> string.replace("@", "") 392 + |> string.replace(".", "") 393 + }), 394 + ready:, 395 + ), 396 + ), 397 + { 398 + let assert model_type.WsConnectionConnected(socket) = model.ws 399 + as "Socket not connected" 400 + encode_ws_msg(RegisterPrecheck( 401 + fields.emailfield, 402 + fields.usernamefield, 403 + fields.passwordfield, 404 + )) 405 + |> json.to_string() 406 + |> lustre_websocket.send(socket, _) 407 + }, 408 + ) 409 + _ -> #(model, effect.none()) 410 + } 411 + } 412 + EmailFieldLostFocus -> { 413 + // This handles the login username/email field value once the user seems to be done typing. 414 + let assert Login(fields, _success) = model.page 415 + let value = case string.starts_with(fields.emailfield, "@") { 416 + True -> string.drop_start(fields.emailfield, 1) 417 + False -> fields.emailfield 418 + } 419 + let new_value = case string.contains(value, "@") { 420 + True -> { 421 + // Is an email, what now! 422 + value 423 + } 424 + False -> { 425 + string.trim(value) 426 + |> string.replace(" ", "") 427 + |> string.lowercase() 428 + |> string.replace("@", "") 429 + |> string.replace(".", "") 430 + } 431 + } 432 + #( 433 + Model( 434 + ..model, 435 + page: Login( 436 + fields: LoginFields(..fields, emailfield: new_value), 437 + success: None, 438 + ), 439 + ), 440 + effect.none(), 441 + ) 442 + } 443 + UserClickedLogout -> session_destroy() 444 + UserSubmittedLogin(_) -> { 445 + let assert Login(fields, _) = model.page 446 + let values_ok = login_view_checker(fields) 447 + case values_ok { 448 + True -> { 449 + console.log("Submitting login form") 450 + let json = 451 + encode_ws_msg(LoginAuthenticationRequest( 452 + fields.emailfield, 453 + fields.passwordfield, 454 + )) 455 + |> json.to_string() 456 + let assert model_type.WsConnectionConnected(socket) = model.ws 457 + as "Socket not connected" 458 + #( 459 + Model(..model, ws: model_type.WsConnectionConnected(socket)), 460 + lustre_websocket.send(socket, json), 461 + ) 462 + } 463 + False -> { 464 + console.error("Form not ready to submit") 465 + #(model, effect.none()) 466 + } 467 + } 468 + } 469 + UserSubmittedSignup(_) -> { 470 + let assert Register(fields, ready) = model.page 471 + 472 + case 473 + { 474 + { ready |> option.is_some() } 475 + && { ready |> option.unwrap(Error("")) |> result.is_ok() } 476 + && { fields.passwordfield == fields.passwordconfirmfield } 477 + } 478 + { 479 + True -> { 480 + console.log("Submitting signup form") 481 + let json = 482 + encode_ws_msg(RegisterRequest( 483 + fields.emailfield, 484 + fields.usernamefield, 485 + fields.passwordfield, 486 + )) 487 + |> json.to_string() 488 + let assert model_type.WsConnectionConnected(socket) = model.ws 489 + as "Socket not connected" 490 + #( 491 + Model(..model, ws: model_type.WsConnectionConnected(socket)), 492 + lustre_websocket.send(socket, json), 493 + ) 494 + } 495 + False -> { 496 + console.error("Form not ready to submit") 497 + #(model, effect.none()) 498 + } 499 + } 500 + } 501 + model_type.UserSwitchedTimeLineTo(tid) -> { 502 + let assert model_type.WsConnectionConnected(socket) = model.ws 503 + as "Socket not connected" 504 + let model = case model.page { 505 + HomeTimeline(timeline_name: _, modal:) -> { 506 + model_type.Model(..model, page: HomeTimeline(Some(tid), modal:)) 507 + } 508 + _ -> model 509 + } 510 + // Request unless cached or load next page if needed. 511 + let requ = case model.cache.cached_timelines |> dict.get(tid) { 512 + Error(..) -> 513 + TimeLineRequest(tid, 0) 514 + |> encode_ws_msg 515 + |> json.to_string 516 + |> lustre_websocket.send(socket, _) 517 + Ok(timeline) -> { 518 + // Check if we need to load more pages 519 + case homepage.should_load_more(timeline, 20, 10) { 520 + True -> { 521 + case homepage.get_next_page_to_load(timeline) { 522 + Some(next_page) -> 523 + TimeLineRequest(tid, next_page) 524 + |> encode_ws_msg 525 + |> json.to_string 526 + |> lustre_websocket.send(socket, _) 527 + None -> effect.none() 528 + } 529 + } 530 + False -> effect.none() 531 + } 532 + } 533 + } 534 + #(model, requ) 535 + } 536 + model_type.LoadMorePosts(timeline_name) -> { 537 + let effect = request_next_timeline_page(model, timeline_name) 538 + #(model, effect) 539 + } 540 + model_type.SetModal(to) -> { 541 + case model.page { 542 + HomeTimeline(timeline_name:, modal: _) -> #( 543 + Model( 544 + ..model, 545 + page: HomeTimeline(timeline_name:, modal: Some(#(to, dict.new()))), 546 + ), 547 + effect.none(), 548 + ) 549 + _ -> #(model, effect.none()) 550 + } 551 + } 552 + model_type.UserClosedModal -> { 553 + case model.page { 554 + HomeTimeline(timeline_name:, modal: _) -> #( 555 + Model(..model, page: HomeTimeline(timeline_name:, modal: None)), 556 + effect.none(), 557 + ) 558 + _ -> #(model, effect.none()) 559 + } 560 + } 561 + model_type.StartDraggingModalBox(x, y) -> { 562 + // Start a sideffect that tracks mouse movements and sends MoveModalBoxTo messages 563 + #(model, start_tracking_mouse_movements(x, y)) 564 + } 565 + model_type.MoveModalBoxTo(x, y) -> { 566 + case model.page { 567 + HomeTimeline(timeline_name:, modal: Some(#("mdl-postedit", params))) -> { 568 + let new_params = 569 + params 570 + |> dict.insert("pos_x", float.to_string(x)) 571 + |> dict.insert("pos_y", float.to_string(y)) 572 + #( 573 + Model( 574 + ..model, 575 + page: HomeTimeline( 576 + timeline_name:, 577 + modal: Some(#("mdl-postedit", new_params)), 578 + ), 579 + ), 580 + effect.none(), 581 + ) 582 + } 583 + _ -> #(model, effect.none()) 584 + } 585 + } 586 + } 587 + } 588 + 589 + fn update_ws(model: Model, wsevent: lustre_websocket.WebSocketEvent) { 590 + echo wsevent 591 + case wsevent { 592 + lustre_websocket.InvalidUrl -> panic 593 + lustre_websocket.OnTextMessage(notice) -> 594 + case 595 + json.parse(notice, { 596 + ws_msg_decoder( 597 + json.parse(notice, ws_msg_typedefiner()) 598 + |> result.unwrap("Unparsable message"), 599 + ) 600 + }) 601 + { 602 + Ok(Greeting(m)) -> { 603 + console.log("The server says hi! '" <> m <> "'") 604 + #(model, effect.none()) 605 + } 606 + Ok(RegisterPrecheckResponse(ok, why)) -> { 607 + console.log("Register precheck response: " <> string.inspect(ok)) 608 + let ready = 609 + case ok { 610 + True -> Ok(Nil) 611 + False -> Error(why) 612 + } 613 + |> Some 614 + 615 + case model.page { 616 + Register(fields, _) -> #( 617 + Model(..model, page: Register(fields:, ready:)), 618 + effect.none(), 619 + ) 620 + _ -> #(model, effect.none()) 621 + } 622 + } 623 + Ok(OwnUserInformationResponse( 624 + username:, 625 + email:, 626 + avatar:, 627 + uuid:, 628 + unread_notifications:, 629 + )) -> { 630 + // avatar is Option(#(String, String)) == Option((mime, base64)) 631 + let avatar_string = case avatar { 632 + Some(#(mime, b64)) -> "data:" <> mime <> ";base64," <> b64 633 + None -> "" 634 + } 635 + let new_users = 636 + model.cache.cached_users 637 + |> dict.insert( 638 + uuid, 639 + model_type.CachedUser( 640 + username:, 641 + source_instance: "local", 642 + avatar: avatar_string, 643 + last_updated: float.truncate( 644 + timestamp.to_unix_seconds(timestamp.system_time()), 645 + ), 646 + ), 647 + ) 648 + #( 649 + Model( 650 + ..model, 651 + cache: model_type.Cached(..model.cache, cached_users: new_users), 652 + user: Some(model_type.UserSubmodel( 653 + uid: uuid, 654 + username:, 655 + email:, 656 + avatar: avatar_string, 657 + notifs: model_type.NotificationsSubModel( 658 + unread_count: unread_notifications, 659 + cached_notifications: [], 660 + ), 661 + )), 662 + ), 663 + effect.none(), 664 + ) 665 + } 666 + Ok(AuthenticationSuccess(_username, token:)) -> { 667 + let assert model_type.WsConnectionConnected(socket) = model.ws 668 + as "Socket not connected" 669 + #( 670 + Model( 671 + ..model, 672 + // Global is default until user information says otherwise, however, we can't set it here, for that'd make it impossible to know if it's set by user or by default. 673 + page: HomeTimeline(None, None), 674 + token: Some(token), 675 + ), 676 + effect.batch([ 677 + OwnUserInformationRequest 678 + |> encode_ws_msg 679 + |> json.to_string 680 + |> lustre_websocket.send(socket, _), 681 + // Even though 'officially' we don't show the global timeline, this should be the one requested firstly. 682 + TimeLineRequest("global", 0) 683 + |> encode_ws_msg 684 + |> json.to_string 685 + |> lustre_websocket.send(socket, _), 686 + ]), 687 + ) 688 + } 689 + Ok(AuthenticationFailure) -> { 690 + case model.page { 691 + model_type.Landing | HomeTimeline(..) | NotFound(..) | Licence -> 692 + session_destroy() 693 + Login(fields:, success: _) -> #( 694 + Model(..model, page: Login(fields:, success: Some(False))), 695 + effect.none(), 696 + ) 697 + // If on register page, do nothing. 698 + Register(..) -> #(model, effect.none()) 699 + } 700 + } 701 + Ok(TimeLineResponse( 702 + timeline_name:, 703 + timeline_id:, 704 + items:, 705 + total_count:, 706 + page:, 707 + has_more:, 708 + )) -> { 709 + console.log( 710 + "Received timeline response for " 711 + <> timeline_name 712 + <> " (id: " 713 + <> timeline_id 714 + <> ")" 715 + <> " with " 716 + <> int.to_string(list.length(items)) 717 + <> " items (page " 718 + <> int.to_string(page) 719 + <> " of " 720 + <> int.to_string(total_count) 721 + <> " total, has_more: " 722 + <> bool.to_string(has_more) 723 + <> ").", 724 + ) 725 + let assert model_type.WsConnectionConnected(socket) = model.ws 726 + as "Socket not connected" 727 + let posts_fetches = 728 + effect.batch( 729 + list.map(items, fn(post_id) { 730 + PostContentRequest(post_id:) 731 + |> encode_ws_msg 732 + |> json.to_string 733 + |> lustre_websocket.send(socket, _) 734 + }), 735 + ) 736 + 737 + // Create or update timeline cache using utilities 738 + let cached_timeline = case 739 + model.cache.cached_timelines |> dict.get(timeline_name) 740 + { 741 + Ok(existing) -> { 742 + homepage.add_page_to_timeline( 743 + existing, 744 + timeline_id, 745 + page, 746 + items, 747 + total_count, 748 + has_more, 749 + ) 750 + } 751 + Error(..) -> { 752 + homepage.create_empty_timeline() 753 + |> homepage.add_page_to_timeline( 754 + page:, 755 + timeline_id:, 756 + items:, 757 + count: total_count, 758 + has_more:, 759 + ) 760 + } 761 + } 762 + 763 + console.log(homepage.timeline_info_string( 764 + cached_timeline, 765 + timeline_name, 766 + )) 767 + 768 + let cached_timelines = 769 + model.cache.cached_timelines 770 + |> dict.insert(timeline_name, cached_timeline) 771 + 772 + #( 773 + Model( 774 + ..model, 775 + cache: model_type.Cached(..model.cache, cached_timelines:), 776 + ), 777 + posts_fetches, 778 + ) 779 + } 780 + Error(err) -> { 781 + console.error( 782 + "Message could not be parsed:" 783 + <> premixed.text_error_red(string.inspect(err)) 784 + <> "\nin:\n" 785 + <> premixed.text_error_red(notice), 786 + ) 787 + #(model, effect.none()) 788 + } 789 + Ok(Undecodable) -> 790 + panic as "Received message that was explicitly marked as undecodable, this should not happen 791 + as the decoder should have returned an error instead of Undecodable. Check the decoder implementation and the logs 792 + for the raw message." 793 + } 794 + lustre_websocket.OnBinaryMessage(msg) -> { 795 + console.warn( 796 + "Received unexpected: " <> premixed.text_cyan(string.inspect(msg)), 797 + ) 798 + // Ignore this. We don't expect binary messages, as we cannot tag them with how the decoder works right now. We only expect text messages, with base64-encoded bitarrays in their fields if so needed. 799 + // So, continue with the model as is: 800 + #(model, effect.none()) 801 + } 802 + lustre_websocket.OnClose(reason) -> { 803 + console.warn( 804 + "Given close reason: " 805 + <> premixed.text_cyan({ 806 + case reason { 807 + lustre_websocket.AbnormalClose -> 808 + "Abnormal close (no close frame was received)" 809 + lustre_websocket.FailedExtensionNegotation -> 810 + "Failed extension negotation" 811 + lustre_websocket.FailedTLSHandshake -> "Failed TLS handshake" 812 + lustre_websocket.GoingAway -> "Going away" 813 + lustre_websocket.IncomprehensibleFrame -> "Incomprehensible frame" 814 + lustre_websocket.MessageTooBig -> "Message was too big" 815 + lustre_websocket.NoCodeFromServer -> "No code from server" 816 + lustre_websocket.Normal -> "Normal close" 817 + lustre_websocket.OtherCloseReason -> "Other close reason (unknown)" 818 + lustre_websocket.PolicyViolated -> "Policy violation" 819 + lustre_websocket.ProtocolError -> "Protocol error" 820 + lustre_websocket.UnexpectedFailure -> "Unexpected faillure" 821 + lustre_websocket.UnexpectedTypeOfData -> "Unexpected type of data" 822 + } 823 + }), 824 + ) 825 + case model.ws { 826 + model_type.WsConnectionInitial -> #(model, effect.none()) 827 + model_type.WsConnectionRetrying -> #( 828 + Model(..model, ws: model_type.WsConnectionDisconnected), 829 + effect.none(), 830 + ) 831 + _ -> { 832 + let new_model = Model(..model, ws: model_type.WsConnectionUnsure) 833 + #(new_model, let_definitely_disconnect(new_model)) 834 + } 835 + } 836 + } 837 + lustre_websocket.OnOpen(socket) -> #( 838 + Model(..model, ws: model_type.WsConnectionConnected(socket)), 839 + lustre_websocket.send( 840 + socket, 841 + { 842 + let x = [ 843 + #("type", json.string("introduction")), 844 + #("client_kind", json.string("web")), 845 + ] 846 + json.object(case model.user, model.token { 847 + None, Some(token) -> { 848 + // traversing x is okay. 849 + list.append(x, [#("try_revive", json.string(token))]) 850 + } 851 + _, _ -> x 852 + }) 853 + } 854 + |> json.to_string(), 855 + ), 856 + ) 857 + } 858 + } 859 + 860 + // WS Message decoding --------------------------------------------------------- 861 + 862 + type WsMsgFromServer { 863 + Greeting(greeting: String) 864 + RegisterPrecheckResponse(ok: Bool, why: String) 865 + AuthenticationSuccess(username: String, token: String) 866 + AuthenticationFailure 867 + TimeLineResponse( 868 + timeline_name: String, 869 + timeline_id: String, 870 + /// List of post ids as string. 871 + items: List(String), 872 + /// Total number of posts in timeline 873 + total_count: Int, 874 + /// Current page number 875 + page: Int, 876 + /// Whether there are more pages available 877 + has_more: Bool, 878 + ) 879 + OwnUserInformationResponse( 880 + username: String, 881 + email: String, 882 + // Optional field populated with mime type and base64 of a profile picture. 883 + avatar: option.Option(#(String, String)), 884 + uuid: String, 885 + /// Number of unread notifications, a timeline request for "notifications" can be used to get the actual notifications and fill the cache. 886 + unread_notifications: Int, 887 + ) 888 + Undecodable 889 + } 890 + 891 + type WsMsgFromClient { 892 + OwnUserInformationRequest 893 + LoginAuthenticationRequest(email_username: String, password: String) 894 + RegisterRequest(email: String, username: String, password: String) 895 + TimeLineRequest(timeline_name: String, page: Int) 896 + RegisterPrecheck( 897 + email: String, 898 + username: String, 899 + // Password only once? Yes, the equal password check is done in the view/update themselves. 900 + password: String, 901 + ) 902 + PostContentRequest(post_id: String) 903 + } 904 + 905 + fn encode_ws_msg(message: WsMsgFromClient) -> json.Json { 906 + case message { 907 + OwnUserInformationRequest -> 908 + json.object([#("type", json.string("own_user_information_request"))]) 909 + LoginAuthenticationRequest(email_username, password) -> 910 + json.object([ 911 + #("type", json.string("login_authentication_request")), 912 + #("email_username", json.string(email_username)), 913 + #("password", json.string(password)), 914 + ]) 915 + 916 + RegisterRequest(email, username, password) -> 917 + json.object([ 918 + #("type", json.string("register_request")), 919 + #("email", json.string(email)), 920 + #("username", json.string(username)), 921 + #("password", json.string(password)), 922 + ]) 923 + RegisterPrecheck(email, username, password) -> 924 + json.object([ 925 + #("type", json.string("register_precheck")), 926 + #("email", json.string(email)), 927 + #("username", json.string(username)), 928 + #("password", json.string(password)), 929 + ]) 930 + TimeLineRequest(timeline_name:, page:) -> 931 + json.object([ 932 + #("type", json.string("timeline_request")), 933 + #("by_name", json.string(timeline_name)), 934 + #("page", json.int(page)), 935 + ]) 936 + PostContentRequest(post_id:) -> { 937 + json.object([ 938 + #("type", json.string("post_view_request")), 939 + #("post_id", json.string(post_id)), 940 + ]) 941 + } 942 + } 943 + } 944 + 945 + fn send_refresh_request(model: model_type.Model) -> Effect(Msg) { 946 + let current_time = 947 + timestamp.system_time() 948 + |> timestamp.to_unix_seconds 949 + |> float.truncate 950 + use dispatcher <- effect.from 951 + dispatcher(model_type.UpdateLastRefreshRequestTime(current_time)) 952 + case model.last_refresh_request_time - current_time < 30 { 953 + True -> { 954 + Nil 955 + } 956 + False -> { 957 + let inventory = model |> model_type.create_cache_inventory() 958 + 959 + // Todo: send this to server to get updates on cached items. 960 + console.log( 961 + "Would send cache inventory to server: \n" 962 + <> string.inspect(inventory) 963 + <> "\n\nNot yet implemented.", 964 + ) 965 + } 966 + } 967 + } 968 + 969 + fn ws_msg_decoder(variant: String) -> decode.Decoder(WsMsgFromServer) { 970 + case variant { 971 + "auth_success" -> { 972 + use username <- decode.field("username", decode.string) 973 + use token <- decode.field("token", decode.string) 974 + decode.success(AuthenticationSuccess(username:, token:)) 975 + } 976 + "auth_failure" -> { 977 + decode.success(AuthenticationFailure) 978 + } 979 + "unknown" -> decode.success(Undecodable) 980 + "register_precheck_response" -> { 981 + use ok <- decode.field("ok", decode.bool) 982 + use why <- decode.field("why", decode.string) 983 + decode.success(RegisterPrecheckResponse(ok, why)) 984 + } 985 + "greeting" -> { 986 + use greeting <- decode.field("greeting", decode.string) 987 + decode.success(Greeting(greeting:)) 988 + } 989 + "timeline_response" -> { 990 + console.log("Decoding timeline response: " <> variant) 991 + use timeline_name <- decode.field("timeline_name", decode.string) 992 + use timeline_id <- decode.field("timeline_id", decode.string) 993 + use items <- decode.field("post_ids", decode.list(decode.string)) 994 + use total_count <- decode.field("total_count", decode.int) 995 + use page <- decode.field("page", decode.int) 996 + use has_more <- decode.field("has_more", decode.bool) 997 + decode.success(TimeLineResponse( 998 + timeline_name:, 999 + timeline_id:, 1000 + items:, 1001 + total_count:, 1002 + page:, 1003 + has_more:, 1004 + )) 1005 + } 1006 + "own_user_information_response" -> { 1007 + use username <- decode.field("username", decode.string) 1008 + use email <- decode.field("email", decode.string) 1009 + use unread_notifications <- decode.field( 1010 + "unread_notifications", 1011 + decode.int, 1012 + ) 1013 + // avatar may be null or an array [mime, base64] 1014 + use avatar_list_opt <- decode.field( 1015 + "avatar", 1016 + decode.optional(decode.list(decode.string)), 1017 + ) 1018 + let avatar = case avatar_list_opt { 1019 + Some(list) -> 1020 + case list { 1021 + [mime, b64] -> Some(#(mime, b64)) 1022 + _ -> None 1023 + } 1024 + None -> None 1025 + } 1026 + use uuid <- decode.field("uuid", decode.string) 1027 + decode.success(OwnUserInformationResponse( 1028 + username:, 1029 + email:, 1030 + avatar:, 1031 + uuid:, 1032 + unread_notifications:, 1033 + )) 1034 + } 1035 + g -> { 1036 + console.error("Unknown message type: " <> g) 1037 + decode.failure(Undecodable, g) 1038 + } 1039 + } 1040 + } 1041 + 1042 + fn ws_msg_typedefiner() -> decode.Decoder(String) { 1043 + use variant <- decode.field("type", decode.string) 1044 + decode.success(variant) 1045 + } 1046 + 1047 + fn session_destroy() -> #(Model, Effect(Msg)) { 1048 + console.info("Destroying session.") 1049 + let assert Ok(s) = storage.local() 1050 + storage.clear(s) 1051 + console.info("Recreating model.") 1052 + init(False) 1053 + }
+45
backend/impl-rs/client/src/lumina_client/dom.gleam
··· 1 + //// Lumina > Client > DOM 2 + //// This module contains DOM related FFI functions. 3 + 4 + // Lumina/Peonies 5 + // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 + // 7 + // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 + // You may not use this work except in compliance with the Licence. 9 + // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 + // 11 + // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 + // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 + // See LICENSE file in the repository root for full details. 14 + // 15 + // 16 + // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 + // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 + 19 + import gleam/dynamic/decode 20 + import lumina_client/model_type 21 + 22 + /// Get the color scheme of the user's system (media query) 23 + @external(javascript, "./dom_ffi.mjs", "get_color_scheme") 24 + pub fn get_color_scheme() -> String 25 + 26 + @external(javascript, "./dom_ffi.mjs", "classfoundintree") 27 + pub fn classfoundintree(element: decode.Dynamic, class_name: String) -> Bool 28 + 29 + /// Start dragging a modal box 30 + /// This is a side effect that sets up event listeners for mousemove and mouseup and sends messages back accordingly. 31 + /// The function takes the current mouse x and y positions, and the constructor for the Msg to send back. 32 + @external(javascript, "./dom_ffi.mjs", "start_dragging_modal_box") 33 + pub fn start_dragging_modal_box( 34 + curr_x: Float, 35 + curr_y: Float, 36 + constructor: fn(Float, Float) -> model_type.Msg, 37 + dispatch: fn(model_type.Msg) -> Nil, 38 + ) -> Nil 39 + 40 + /// Get the window dimensions in pixels 41 + /// Returns: #(width_px, height_px) 42 + /// 43 + /// // This should be used in an effect and saved to the model, not called directly in views, but is for now called as an helper in views. 44 + @external(javascript, "./dom_ffi.mjs", "get_window_dimensions_px") 45 + pub fn get_window_dimensions_px() -> #(Int, Int)
+93
backend/impl-rs/client/src/lumina_client/dom_ffi.mjs
··· 1 + /* 2 + * Lumina/Peonies 3 + * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 4 + * 5 + * This software is licensed under the European Union Public Licence (EUPL) v1.2. 6 + * You may not use this work except in compliance with the Licence. 7 + * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 8 + * 9 + * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 10 + * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 11 + * See LICENSE file in the repository root for full details. 12 + * 13 + * 14 + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 15 + * See the Licence for the specific language governing permissions and limitations. [cite: 6] 16 + */ 17 + 18 + /** 19 + * @description Returns the color scheme of the user 20 + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme 21 + * @returns {string} 22 + */ 23 + export function get_color_scheme() { 24 + // Media queries the preferred color colorscheme 25 + 26 + if (window.matchMedia("(prefers-color-scheme: dark)").matches) { 27 + return "dark"; 28 + } 29 + return "light"; 30 + } 31 + 32 + /** 33 + * @description Goes up the DOM tree to see if a class is found 34 + * @returns {boolean} 35 + * @param {HTMLElement} starting_element 36 + * @param {string} className 37 + */ 38 + export function classfoundintree(starting_element, className) { 39 + let element = starting_element; 40 + do { 41 + if (element.classList && element.classList.contains(className)) { 42 + return true; 43 + } 44 + // Might be null if we reach the top of the tree 45 + element = element.parentElement; 46 + } while (element); 47 + return false; 48 + } 49 + 50 + // /// Start dragging a modal box 51 + // /// This is a side effect that sets up event listeners for mousemove and mouseup and sends messages back accordingly. 52 + // /// The function takes the current mouse x and y positions, and the constructor for the Msg to send back. 53 + // @external(javascript, "./dom_ffi.mjs", "start_dragging_modal_box") 54 + // pub fn start_dragging_modal_box( 55 + // curr_x: Float, 56 + // curr_y: Float, 57 + // constructor: fn(Float, Float) -> message_type.Msg, 58 + // dispatch: fn(message_type.Msg) -> Nil, 59 + // ) -> Nil 60 + 61 + /** 62 + * @description Is ran on on_mouse_down of the modal title bar and starts tracking mouse movements and mouseup to drag the modal box 63 + * @returns {undefined} 64 + * @param {start_x} number Current element x position, in pixels 65 + * @param {start_y} number Current element y position, in pixels 66 + * @param {function} constructor Function that constructs the message to send back 67 + * @param {function} dispatcher Function that dispatches the message back to the runtime. 68 + */ 69 + export function start_dragging_modal_box(start_x, start_y, constructor, dispatcher) { 70 + // Track current position starting from provided element coordinates 71 + let current_x = start_x; 72 + let current_y = start_y; 73 + const dispatchnewlocation = () => { 74 + const msg = constructor(current_x, current_y); 75 + dispatcher(msg); 76 + }; 77 + const on_mouse_move = (event) => { 78 + // Use movement deltas to avoid initial jump to cursor top-left 79 + current_x += event.movementX; 80 + current_y += event.movementY; 81 + dispatchnewlocation(); 82 + }; 83 + const on_mouse_up = () => { 84 + window.removeEventListener("mousemove", on_mouse_move); 85 + window.removeEventListener("mouseup", on_mouse_up); 86 + }; 87 + window.addEventListener("mousemove", on_mouse_move); 88 + window.addEventListener("mouseup", on_mouse_up); 89 + return undefined; 90 + } 91 + export function get_window_dimensions_px() { 92 + return [window.innerWidth, window.innerHeight]; 93 + }
+22
backend/impl-rs/client/src/lumina_client/errors.gleam
··· 1 + //// Lumina > Client > Errors 2 + //// Error collection module 3 + 4 + // Lumina/Peonies 5 + // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 + // 7 + // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 + // You may not use this work except in compliance with the Licence. 9 + // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 + // 11 + // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 + // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 + // See LICENSE file in the repository root for full details. 14 + // 15 + // 16 + // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 + // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 + 19 + /// An error somewhere in the app. 20 + pub type Error { 21 + DecodeError 22 + }
+57
backend/impl-rs/client/src/lumina_client/helpers.gleam
··· 1 + //// Lumina > Client > Helper functions 2 + //// This module contains helper functions used across the Lumina client. 3 + 4 + // Lumina/Peonies 5 + // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 + // 7 + // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 + // You may not use this work except in compliance with the Licence. 9 + // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 + // 11 + // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 + // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 + // See LICENSE file in the repository root for full details. 14 + // 15 + // 16 + // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 + // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 + 19 + import gleam/int 20 + import gleam/list 21 + import lumina_client/dom 22 + import lumina_client/model_type.{type LoginFields, type Msg} 23 + import lustre/attribute 24 + import plinth/javascript/global 25 + 26 + pub fn get_color_scheme(_model_) -> attribute.Attribute(Msg) { 27 + // Will get overruled by model later 28 + // For now, just return system default 29 + attribute.none() 30 + // case dom.get_color_scheme() { 31 + // "dark" -> attribute.attribute("data-theme", "lumina-dark") 32 + // _ -> attribute.attribute("data-theme", "lumina-light") 33 + // } 34 + } 35 + 36 + /// Under which key the model is stored in local storage. 37 + pub const model_local_storage_key = "luminaModelJSOB" 38 + 39 + pub fn login_view_checker(fieldvalues: LoginFields) { 40 + [{ fieldvalues.passwordfield != "" }, { fieldvalues.emailfield != "" }] 41 + |> list.all(fn(x) { x }) 42 + } 43 + 44 + pub fn set_timeout_nilled(delay: Int, cb: fn() -> a) -> Nil { 45 + global.set_timeout(delay, cb) 46 + Nil 47 + } 48 + 49 + /// Get centered position for modal box in px 50 + pub fn get_center_positioned_style_px() -> #(Float, Float) { 51 + let #(window_w, window_h) = dom.get_window_dimensions_px() |> echo 52 + let x_int = window_h / 2 53 + let y_int = window_w / 2 54 + let x = int.to_float(x_int) 55 + let y = int.to_float(y_int) 56 + #(x, y) 57 + }
+449
backend/impl-rs/client/src/lumina_client/model_type.gleam
··· 1 + //// Lumina > Client > Model 2 + //// Lumina's model is the central source of truth for the client application state. 3 + 4 + // Lumina/Peonies 5 + // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 + // 7 + // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 + // You may not use this work except in compliance with the Licence. 9 + // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 + // 11 + // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 + // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 + // See LICENSE file in the repository root for full details. 14 + // 15 + // 16 + // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 + // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 + 19 + import gleam/dict.{type Dict} 20 + import gleam/dynamic/decode 21 + import gleam/json 22 + import gleam/list 23 + import gleam/option.{type Option, None, Some} 24 + import gleam/uri.{type Uri} 25 + import lustre_websocket 26 + 27 + pub type Msg { 28 + WSTryReconnect 29 + EffectPast150ms 30 + UpdateLastRefreshRequestTime(Int) 31 + WsDisconnectDefinitive 32 + WebSocketIncomingMessage(lustre_websocket.WebSocketEvent) 33 + UserNavigatedToLoginPage 34 + UserNavigatedToRegisterPage 35 + UserNavigatedToLandingPage 36 + UserSubmittedLogin(List(#(String, String))) 37 + UserSubmittedSignup(List(#(String, String))) 38 + // Can be re-used for both login and register pages 39 + UserUpdatedControlledEmailField(String) 40 + UserUpdatedControlledPasswordField(String) 41 + // Register page 42 + UserUpdatedControlledUsernameField(String) 43 + UserUpdatedControlledPasswordConfirmField(String) 44 + EmailFieldLostFocus 45 + /// Travel to a different timeline. 46 + UserSwitchedTimeLineTo(String) 47 + /// Load more posts for the current timeline 48 + LoadMorePosts(String) 49 + /// Log the user out (destroys session and recreates model) 50 + UserClickedLogout 51 + /// Close current modal 52 + UserClosedModal 53 + /// Browse modal to different page 54 + SetModal(String) 55 + /// Start dragging the modal box 56 + /// Parameters: the event, current mouse x and y positions 57 + /// Starts a sideffect that tracks mouse movements and sends MoveModalBoxTo messages 58 + StartDraggingModalBox(Float, Float) 59 + /// Move the modal box to a new position 60 + /// Parameters: new x and y positions 61 + MoveModalBoxTo(Float, Float) 62 + } 63 + 64 + pub type Route = 65 + Page 66 + 67 + pub fn parse_route(uri: Uri) -> Route { 68 + case uri.path_segments(uri.path) { 69 + [] | [""] -> Landing 70 + ["login"] -> Login(fields: LoginFields("", ""), success: None) 71 + ["signup"] -> 72 + Register(fields: RegisterPageFields("", "", "", ""), ready: None) 73 + ["publication", _post_id] -> { 74 + todo as "We don't have a publication zoom Page variant yet." 75 + } 76 + ["home"] | ["timeline"] -> HomeTimeline(None, None) 77 + ["timeline", tid] -> HomeTimeline(Some(tid), None) 78 + ["licence"] | ["license"] -> Licence 79 + 80 + _ -> NotFound(uri:) 81 + } 82 + } 83 + 84 + /// # Page 85 + /// 86 + /// Lumina has always been an SPA behind the login page, splitting the three "main" pages: Login, Signup, and Home from "subpages". Home contained subpages like Dashboard, Profile, and Settings, etc. 87 + /// In this model, Login and Dashboard would be equal. The model keeps track of the current page and the user's authentication status. 88 + /// The Page type is, pretty explanatory, an enum of all the pages in the app. Nested if needed, to track fields like the current tab in the Dashboard or the username form field in the login page. 89 + pub type Page { 90 + Landing 91 + Register(fields: RegisterPageFields, ready: Option(Result(Nil, String))) 92 + Login(fields: LoginFields, success: Option(Bool)) 93 + HomeTimeline( 94 + timeline_name: Option(String), 95 + modal: Option(#(String, Dict(String, String))), 96 + ) 97 + Licence 98 + NotFound(uri: Uri) 99 + } 100 + 101 + /// # Model 102 + /// 103 + pub type Model { 104 + Model( 105 + /// Page currently browsing. 106 + /// This is synced to the url through modem, but can contain more context. 107 + page: Page, 108 + /// User, if known 109 + user: Option(UserSubmodel), 110 + /// WebSocket connection 111 + ws: WsConnectionStatus, 112 + /// Used to restore sessions 113 + token: Option(String), 114 + /// Used to show error screens on unrecoverable errors 115 + status: Result(Nil, String), 116 + /// To keep the client going while navigating, the websocket just requests certain data and then stores it in the model so that view can update once it's there 117 + /// Displaying some loading screen in between. 118 + /// Once it is there, this is where it's stored: 119 + cache: Cached, 120 + // /// Ticks are upped by one every 50ms since initialisation. 121 + // ticks: Int, 122 + /// Replaces ticks: Tracks if the client has been running for over 150ms 123 + has_been_running_for_150ms: Bool, 124 + /// Last time send_refresh_request was called, in unix timestamp seconds. 125 + /// If send_refresh_request(), it will update this value. If the last refresh request was over 30 seconds ago, 126 + /// the client will send a new refresh request to the server. 127 + last_refresh_request_time: Int, 128 + ) 129 + } 130 + 131 + pub type NotificationsSubModel { 132 + NotificationsSubModel( 133 + /// Unread notifications count, calculated by the server based on the last time the user checked notifications 134 + unread_count: Int, 135 + /// Cached notifications 136 + cached_notifications: List(Nil), 137 + ) 138 + } 139 + 140 + pub fn create_cache_inventory(model: Model) -> CacheInventory { 141 + let cache = model.cache 142 + let timelines = 143 + cache.cached_timelines 144 + |> dict.to_list() 145 + |> list.map(fn(timeline) { 146 + let timeline = timeline.1 147 + #(timeline.id, timeline.last_updated) 148 + }) 149 + let users = 150 + cache.cached_users 151 + |> dict.to_list() 152 + |> list.map(fn(user) { #(user.0, { user.1 }.last_updated) }) 153 + let posts = 154 + cache.cached_posts 155 + |> dict.to_list() 156 + |> list.map(fn(post) { #(post.0, { post.1 }.last_updated) }) 157 + CacheInventory(timelines:, users:, posts:) 158 + } 159 + 160 + pub type CacheInventory { 161 + CacheInventory( 162 + /// Timelines by #(id, last_updated) 163 + timelines: List(#(String, Int)), 164 + /// Users by #(id, last_updated) 165 + users: List(#(String, Int)), 166 + /// Posts by #(id, last_updated) 167 + posts: List(#(String, Int)), 168 + ) 169 + } 170 + 171 + pub type WsConnectionStatus { 172 + /// Before connection is created 173 + WsConnectionInitial 174 + /// An established socket 175 + WsConnectionConnected(lustre_websocket.WebSocket) 176 + /// A disconnected socket 177 + WsConnectionDisconnected 178 + /// A non-connected socket, may also occur while connecting. 179 + /// This'll either turn into a `WsConnectionConnected` or an `WsConnectionDisconnected`. 180 + WsConnectionUnsure 181 + /// Retrying to connect. 182 + WsConnectionRetrying 183 + } 184 + 185 + pub type Cached { 186 + Cached( 187 + /// Posts are requested if nonexistent in the dict, and a loading screen can be displayed immediately 188 + /// The server will afterwards send all corresponding comments, which can also be stored and, if deemed 189 + /// necessary by the Lustre runtime, also update the DOM. 190 + /// 191 + /// Commnents under a post are in fact stored as a timeline and possess the exact same capabilities. 192 + /// 193 + /// `Dict(post_uuid, CachedPost)` 194 + cached_posts: dict.Dict(String, CachedPost), 195 + /// Users received: 196 + cached_users: Dict(String, CachedUser), 197 + /// Cached timelines with pagination support 198 + /// `Dict(timeline_id, CachedTimeline)` 199 + cached_timelines: Dict(String, CachedTimeline), 200 + ) 201 + } 202 + 203 + pub type CachedUser { 204 + CachedUser( 205 + /// Source instance. 'local' by default, hostname if external. 206 + source_instance: String, 207 + /// Username 208 + username: String, 209 + /// Avatar as uri string, either a full URL or a base64-encoded 'data:'-string 210 + avatar: String, 211 + /// Last updated timestamp (seconds) to help with cache invalidation 212 + last_updated: Int, 213 + ) 214 + } 215 + 216 + pub type CachedTimeline { 217 + CachedTimeline( 218 + /// Timeline ID, as given by the server 219 + id: String, 220 + /// Post IDs for all loaded pages, organized by page number 221 + pages: Dict(Int, List(String)), 222 + /// Total number of posts in the timeline 223 + total_count: Int, 224 + /// Current page being displayed 225 + current_page: Int, 226 + /// Whether there are more pages available 227 + has_more: Bool, 228 + /// Last updated timestamp (seconds) to help with cache invalidation 229 + last_updated: Int, 230 + ) 231 + } 232 + 233 + pub type CachedPost { 234 + CachedPost( 235 + /// Post ID -- taken from the current instance, we don't have to deal with remote IDs here. 236 + id: String, 237 + /// Source instance. 'local' by default, hostname if external. 238 + source_instance: String, 239 + /// User id of poster, which is why the source_instance matters. 240 + /// This means that client will do a lookup and stores the user once it gets it. 241 + author_id: String, 242 + /// Unix timestamp of the moment of posting 243 + timestamp: Int, 244 + /// Last updated timestamp (seconds) to help with cache invalidation 245 + last_updated: Int, 246 + /// Cached post interior 247 + interior: CachedPostInterior, 248 + ) 249 + } 250 + 251 + pub type CachedPostInterior { 252 + /// A media post, embedded is either webp or mp4. 253 + CachedMediaPost( 254 + /// Media description 255 + description: String, 256 + /// Media files as base64-encoded 'data:'-strings 257 + /// Try matching on the substring of content-type 258 + /// to determine the valid HTML embed element to put it in. 259 + medias: List(String), 260 + ) 261 + /// The 'default', bluesky-like post, contains markdown and not much else. 262 + CachedTextualPost( 263 + /// Markdown content. 264 + content: String, 265 + ) 266 + /// Article posts 267 + CachedArticlePost( 268 + /// Title of the article post 269 + title: String, 270 + /// Markdown content 271 + content: String, 272 + ) 273 + } 274 + 275 + fn encode_page(page: Page) -> json.Json { 276 + case page { 277 + Landing -> json.object([#("type", json.string("landing"))]) 278 + Register(fields:, ready:) -> 279 + json.object([ 280 + #("type", json.string("register")), 281 + #("fields", { 282 + let RegisterPageFields( 283 + usernamefield:, 284 + emailfield:, 285 + passwordfield:, 286 + passwordconfirmfield:, 287 + ) = fields 288 + json.object([ 289 + #("usernamefield", json.string(usernamefield)), 290 + #("emailfield", json.string(emailfield)), 291 + #("passwordfield", json.string(passwordfield)), 292 + #("passwordconfirmfield", json.string(passwordconfirmfield)), 293 + ]) 294 + }), 295 + #("ready", { 296 + let _ = ready 297 + json.null() 298 + }), 299 + ]) 300 + Login(fields:, success: _) -> 301 + json.object([ 302 + #("type", json.string("login")), 303 + #("fields", { 304 + let LoginFields(emailfield:, passwordfield:) = fields 305 + json.object([ 306 + #("emailfield", json.string(emailfield)), 307 + #("passwordfield", json.string(passwordfield)), 308 + ]) 309 + }), 310 + ]) 311 + HomeTimeline(timeline_name:, modal:) -> 312 + json.object( 313 + [#("type", json.string("home_timeline"))] 314 + |> list.append(case timeline_name { 315 + None -> [] 316 + Some(i) -> [#("timeline_name", json.string(i))] 317 + }) 318 + |> list.append(case modal { 319 + None -> [] 320 + Some(i) -> [#("modal", json.string(i.0))] 321 + }), 322 + ) 323 + NotFound(_) -> json.object([#("type", json.string("landing"))]) 324 + 325 + Licence -> json.object([#("type", json.string("licence"))]) 326 + } 327 + } 328 + 329 + fn page_decoder() -> decode.Decoder(Page) { 330 + use variant <- decode.field("type", decode.string) 331 + case variant { 332 + "landing" -> decode.success(Landing) 333 + "licence" -> decode.success(Licence) 334 + "register" -> { 335 + use fields <- decode.field("fields", { 336 + use usernamefield <- decode.field("usernamefield", decode.string) 337 + use emailfield <- decode.field("emailfield", decode.string) 338 + use passwordfield <- decode.field("passwordfield", decode.string) 339 + use passwordconfirmfield <- decode.field( 340 + "passwordconfirmfield", 341 + decode.string, 342 + ) 343 + decode.success(RegisterPageFields( 344 + usernamefield:, 345 + emailfield:, 346 + passwordfield:, 347 + passwordconfirmfield:, 348 + )) 349 + }) 350 + let ready = None 351 + decode.success(Register(fields:, ready:)) 352 + } 353 + "login" -> { 354 + use fields <- decode.field("fields", { 355 + use emailfield <- decode.field("emailfield", decode.string) 356 + use passwordfield <- decode.field("passwordfield", decode.string) 357 + decode.success(LoginFields(emailfield:, passwordfield:)) 358 + }) 359 + decode.success(Login(fields:, success: None)) 360 + } 361 + "home_timeline" -> { 362 + use timeline_name: Option(String) <- decode.optional_field( 363 + "timeline_name", 364 + None, 365 + decode.optional(decode.string), 366 + ) 367 + use modal_n <- decode.optional_field( 368 + "modal", 369 + None, 370 + decode.optional(decode.string), 371 + ) 372 + let modal = modal_n |> option.map(fn(m) { #(m, dict.new()) }) 373 + decode.success(HomeTimeline(timeline_name:, modal:)) 374 + } 375 + _ -> decode.failure(Landing, "Page") 376 + } 377 + } 378 + 379 + pub type RegisterPageFields { 380 + RegisterPageFields( 381 + usernamefield: String, 382 + emailfield: String, 383 + passwordfield: String, 384 + passwordconfirmfield: String, 385 + ) 386 + } 387 + 388 + pub type LoginFields { 389 + LoginFields(emailfield: String, passwordfield: String) 390 + } 391 + 392 + /// # User submodel 393 + /// 394 + /// The User type is a struct that holds the user's data. It's an Option in the Model because the user might not be logged in. 395 + /// Authentication STATUS is not stored in the Model, but in the websocket connection (the token is). The user is only stored in the Model for the UI to easy displaying the user's data. 396 + pub type UserSubmodel { 397 + UserSubmodel( 398 + /// User ID (uuid) 399 + uid: String, 400 + /// Username 401 + username: String, 402 + /// Email 403 + email: String, 404 + /// Avatar as uri string, either a full URL or a base64-encoded 'data:'-string 405 + avatar: String, 406 + /// Notifications 407 + notifs: NotificationsSubModel, 408 + ) 409 + } 410 + 411 + pub type SerializableModel { 412 + SerializableModel( 413 + // Only storing page name for now. Maybe I'll do full Page type, so that fields can be stored as well some day. 414 + // Oh, nevermind 415 + page: Page, 416 + /// Token, so that sessions can be revived. 417 + token: Option(String), 418 + ) 419 + } 420 + 421 + pub fn serialize_serializable_model( 422 + serializable_model: SerializableModel, 423 + ) -> json.Json { 424 + let SerializableModel(page:, token:) = serializable_model 425 + json.object([ 426 + #("page", encode_page(page)), 427 + #("token", case token { 428 + option.None -> json.null() 429 + Some(value) -> json.string(value) 430 + }), 431 + ]) 432 + } 433 + 434 + pub fn deserialize_serializable_model(jsod: String) { 435 + json.parse(jsod, serializable_model_decoder()) 436 + } 437 + 438 + fn serializable_model_decoder() -> decode.Decoder(SerializableModel) { 439 + use page <- decode.field("page", page_decoder()) 440 + use token <- decode.field("token", decode.optional(decode.string)) 441 + decode.success(SerializableModel(page:, token:)) 442 + } 443 + 444 + pub fn serialize(normal_model: Model) { 445 + let Model(page:, token:, ..): Model = normal_model 446 + SerializableModel(page:, token:) 447 + |> serialize_serializable_model 448 + |> json.to_string 449 + }
+794
backend/impl-rs/client/src/lumina_client/view.gleam
··· 1 + //// Lumina > Client > View 2 + //// Module containing the view function and it's splits 3 + 4 + // Lumina/Peonies 5 + // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 + // 7 + // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 + // You may not use this work except in compliance with the Licence. 9 + // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 + // 11 + // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 + // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 + // See LICENSE file in the repository root for full details. 14 + // 15 + // 16 + // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 + // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 + 19 + import gleam/dynamic/decode 20 + import gleam/list 21 + import gleam/option.{None, Some} 22 + import gleam/result 23 + import gleam/string 24 + import lumina_client/helpers.{ 25 + get_color_scheme, login_view_checker, model_local_storage_key, 26 + } 27 + import lumina_client/model_type.{ 28 + type Model, type Msg, HomeTimeline, Landing, Licence, Login, NotFound, 29 + Register, UserNavigatedToLandingPage, UserNavigatedToLoginPage, 30 + UserNavigatedToRegisterPage, UserSubmittedLogin, UserSubmittedSignup, 31 + UserUpdatedControlledEmailField, UserUpdatedControlledPasswordConfirmField, 32 + UserUpdatedControlledPasswordField, UserUpdatedControlledUsernameField, 33 + WSTryReconnect, 34 + } 35 + import lumina_client/view/common_view_parts.{common_view_parts} 36 + import lumina_client/view/common_view_parts/svgs 37 + import lumina_client/view/homepage.{view as view_homepage} 38 + import lustre/attribute 39 + import lustre/element.{type Element} 40 + import lustre/element/html 41 + import lustre/event 42 + import plinth/javascript/storage 43 + 44 + pub fn view(model: Model) -> Element(Msg) { 45 + let assert Ok(localstorage) = storage.local() 46 + as "localstorage should be available on ALL major browsers." 47 + let _ = 48 + storage.set_item( 49 + localstorage, 50 + model_local_storage_key, 51 + model_type.serialize(model), 52 + ) 53 + let content = case model.page { 54 + Landing -> view_landing() 55 + Register(..) -> view_register(model) 56 + Login(..) -> view_login(model) 57 + HomeTimeline(..) -> view_homepage(model) 58 + NotFound(uri:) -> todo as "No 404 page yet." 59 + Licence -> 60 + todo as "Licence should be shown by the client if it's not shown by the server." 61 + } 62 + html.div( 63 + [get_color_scheme(model), attribute.class("w-screen h-screen content")], 64 + [ 65 + case model.ws { 66 + model_type.WsConnectionInitial -> 67 + html.div( 68 + [ 69 + attribute.attribute("open", ""), 70 + attribute.class("modal modal-bottom sm:modal-middle"), 71 + ], 72 + [ 73 + html.div([attribute.class("modal-box")], [ 74 + element.text("Connecting to server..."), 75 + html.div([attribute.class("float-right")], [ 76 + html.span( 77 + [attribute.class("loading loading-spinner loading-xl")], 78 + [], 79 + ), 80 + ]), 81 + ]), 82 + ], 83 + ) 84 + model_type.WsConnectionDisconnected -> 85 + html.div( 86 + [ 87 + attribute.attribute("open", ""), 88 + attribute.class("toast toast-top toast-center z-100"), 89 + ], 90 + [ 91 + html.div([attribute.class("alert alert-info")], [ 92 + element.text("Connection to server ended! "), 93 + html.button( 94 + [ 95 + attribute.class("btn btn-primary font-menuitems"), 96 + event.on_click(WSTryReconnect), 97 + ], 98 + [element.text("Reconnect")], 99 + ), 100 + ]), 101 + ], 102 + ) 103 + 104 + model_type.WsConnectionRetrying -> 105 + html.div( 106 + [ 107 + attribute.attribute("open", ""), 108 + attribute.class("toast toast-top toast-center z-100"), 109 + ], 110 + [ 111 + html.div([attribute.class("alert alert-info")], [ 112 + element.text("Connection to server ended! Reconnecting..."), 113 + html.div([attribute.class("float-right")], [ 114 + html.span( 115 + [attribute.class("loading loading-spinner loading-lg")], 116 + [], 117 + ), 118 + ]), 119 + ]), 120 + ], 121 + ) 122 + 123 + model_type.WsConnectionConnected(..) | model_type.WsConnectionUnsure -> 124 + element.none() 125 + }, 126 + content, 127 + ], 128 + ) 129 + } 130 + 131 + fn view_landing() -> Element(Msg) { 132 + [ 133 + html.div( 134 + [attribute.class("hero h-screen max-h-[calc(100vh-4rem)] overflow-auto")], 135 + [ 136 + html.div([attribute.class("hero-content text-center")], [ 137 + html.div([attribute.class("max-w-md")], [ 138 + html.h1([attribute.class("text-5xl font-bold")], [ 139 + element.text("Welcome to Lumina!"), 140 + ]), 141 + html.p([attribute.class("py-6")], [ 142 + element.text( 143 + "This should be a nice landing page, but I don't know what to put here right now. Go away! Skram!", 144 + ), 145 + ]), 146 + html.button( 147 + [ 148 + attribute.class("btn btn-primary font-menuitems"), 149 + event.on_click(UserNavigatedToLoginPage), 150 + ], 151 + [element.text("Login")], 152 + ), 153 + html.button( 154 + [ 155 + attribute.class("btn btn-secondary font-menuitems"), 156 + event.on_click(UserNavigatedToRegisterPage), 157 + ], 158 + [element.text("Register")], 159 + ), 160 + ]), 161 + ]), 162 + ], 163 + ), 164 + html.input([ 165 + attribute.class("modal-toggle"), 166 + attribute.id("landing-attributions-show"), 167 + attribute.type_("checkbox"), 168 + ]), 169 + html.div([attribute.role("dialog"), attribute.class("modal")], [ 170 + html.div([attribute.class("modal-box max-h-[70VH] overflow-y-clip")], [ 171 + html.h3([attribute.class("text-lg font-bold")], [ 172 + html.text("Attributions"), 173 + ]), 174 + html.p([attribute.class("py-4")], [ 175 + attributions(), 176 + ]), 177 + html.div([attribute.class("modal-action")], [ 178 + html.label( 179 + [ 180 + attribute.class("btn btn-error font-menuitems"), 181 + attribute.for("landing-attributions-show"), 182 + ], 183 + [ 184 + html.text("Close"), 185 + ], 186 + ), 187 + ]), 188 + ]), 189 + ]), 190 + html.footer( 191 + [ 192 + attribute.class( 193 + "absolute footer footer-center p-4 bg-base-300 text-base-content bottom-0", 194 + ), 195 + ], 196 + [ 197 + html.div([], [ 198 + html.p([], [ 199 + element.text( 200 + "The Lumina/Peonies project, by MLC 'Strawmelonjuice' Bloeiman and contributors. ", 201 + ), 202 + html.a( 203 + [ 204 + attribute.href("/licence"), 205 + attribute.class("link link-neutral-content"), 206 + ], 207 + [ 208 + element.text( 209 + "Licensed under the European Union Public Licence, with special notice for AI usage.", 210 + ), 211 + ], 212 + ), 213 + element.text("."), 214 + ]), 215 + html.p([], [ 216 + element.text("Also uses some CC-BY and other open-source assets, "), 217 + html.label( 218 + [ 219 + attribute.class("link link-neutral-content"), 220 + attribute.for("landing-attributions-show"), 221 + ], 222 + [ 223 + html.text("see attributions"), 224 + ], 225 + ), 226 + element.text("."), 227 + ]), 228 + ]), 229 + ], 230 + ), 231 + ] 232 + |> common_view_parts(with_menu: []) 233 + } 234 + 235 + fn attributions() -> Element(Msg) { 236 + html.div( 237 + [ 238 + attribute.class("overflow-y-auto max-h-[45vh]"), 239 + ], 240 + [ 241 + html.ul([], [ 242 + html.li( 243 + [ 244 + attribute.class("card block bg-neutral p-4 mb-4 rounded-lg"), 245 + ], 246 + [ 247 + html.h4([attribute.class("text-lg font-bold mb-2")], [ 248 + html.text("Icons from SVGrepo.com"), 249 + ]), 250 + html.h5([attribute.class("text-[1.100rem] font-bold mb-2")], [ 251 + html.text("Solar Linear icon set"), 252 + ]), 253 + html.div( 254 + [attribute.class("flex flex-row items-center w-full")], 255 + svgs.sources_solar_linear() 256 + |> list.map(fn(am: #(fn(String) -> Element(Msg), String)) { 257 + let #(svg_fn, link) = am 258 + html.a([attribute.href(link)], [ 259 + svg_fn("w-6 h-6 me-2 hover:scale-110"), 260 + ]) 261 + }), 262 + ), 263 + html.text("Vectors and icons by "), 264 + html.a( 265 + [ 266 + attribute.target("_blank"), 267 + attribute.class("link"), 268 + attribute.href( 269 + "https://www.figma.com/community/file/1166831539721848736?ref=svgrepo.com", 270 + ), 271 + ], 272 + [html.text("Solar Icons")], 273 + ), 274 + html.text(" in CC Attribution License via "), 275 + html.a( 276 + [ 277 + attribute.class("link"), 278 + attribute.target("_blank"), 279 + attribute.href("https://www.svgrepo.com/"), 280 + ], 281 + [html.text("SVG Repo")], 282 + ), 283 + ], 284 + ), 285 + html.li([attribute.class("card block bg-neutral p-4 mb-4 rounded-lg")], [ 286 + html.h4([attribute.class("text-lg font-bold mb-2")], [ 287 + html.img([ 288 + attribute.src("https://gleam.run/images/lucy/lucy.svg"), 289 + attribute.class("inline-block w-5 h-auto ms-2 align-middle"), 290 + ]), 291 + html.text("Gleam"), 292 + ]), 293 + element.text("Much thanks to the "), 294 + html.a( 295 + [ 296 + attribute.href("https://gleam.run/"), 297 + attribute.class("link "), 298 + ], 299 + [ 300 + html.text("Gleam programming language"), 301 + ], 302 + ), 303 + element.text(" and its community!"), 304 + ]), 305 + html.li([attribute.class("card block bg-neutral p-4 mb-4 rounded-lg")], [ 306 + html.h4([attribute.class("text-lg font-bold mb-2")], [ 307 + html.text("Fonts used"), 308 + ]), 309 + html.ul([attribute.class("list-disc list-inside")], [ 310 + { 311 + html.li([], [ 312 + html.span([], [ 313 + html.a( 314 + [ 315 + attribute.href( 316 + "https://fonts.google.com/specimen/Vend+Sans", 317 + ), 318 + attribute.class("link font-sans"), 319 + ], 320 + [ 321 + html.text("Vend Sans"), 322 + ], 323 + ), 324 + element.text(" "), 325 + html.span( 326 + [ 327 + attribute.class( 328 + "badge badge-xs badge-soft badge-secondary text-xs", 329 + ), 330 + ], 331 + [element.text("font-sans")], 332 + ), 333 + ]), 334 + html.p([attribute.class("text-xs")], [ 335 + element.text( 336 + "Designed by Bloom Type Foundry and Baptiste Guesnon under SIL Open Font License.", 337 + ), 338 + ]), 339 + ]) 340 + }, 341 + { 342 + html.li([], [ 343 + html.span([], [ 344 + html.a( 345 + [ 346 + attribute.href( 347 + "https://fonts.google.com/specimen/Gantari", 348 + ), 349 + attribute.class("link font-logo"), 350 + ], 351 + [ 352 + html.text("Gantari"), 353 + ], 354 + ), 355 + element.text(" "), 356 + 357 + html.span( 358 + [ 359 + attribute.class( 360 + "badge badge-xs badge-soft badge-secondary text-xs", 361 + ), 362 + ], 363 + [element.text("font-logo")], 364 + ), 365 + ]), 366 + html.p([attribute.class("text-xs")], [ 367 + element.text("Designed by Lafontype"), 368 + ]), 369 + ]) 370 + }, 371 + { 372 + html.li([], [ 373 + html.span([], [ 374 + html.a( 375 + [ 376 + attribute.href( 377 + "https://fonts.google.com/specimen/Elms+Sans", 378 + ), 379 + attribute.class("link font-content"), 380 + ], 381 + [ 382 + html.text("Elms Sans"), 383 + ], 384 + ), 385 + element.text(" "), 386 + 387 + html.span( 388 + [ 389 + attribute.class( 390 + "badge badge-xs badge-soft badge-secondary text-xs", 391 + ), 392 + ], 393 + [element.text("font-content")], 394 + ), 395 + ]), 396 + html.p([attribute.class("text-xs")], [ 397 + element.text( 398 + "Designed by Amarachi Nwauwa under SIL Open Font License", 399 + ), 400 + ]), 401 + ]) 402 + }, 403 + 404 + { 405 + html.li([], [ 406 + html.span([], [ 407 + html.a( 408 + [ 409 + attribute.href( 410 + "https://fonts.google.com/specimen/Josefin+Sans", 411 + ), 412 + attribute.class("link font-menuitems"), 413 + ], 414 + [ 415 + html.text("Josefin Sans"), 416 + ], 417 + ), 418 + element.text(" "), 419 + 420 + html.span( 421 + [ 422 + attribute.class( 423 + "badge badge-xs badge-soft badge-secondary text-xs", 424 + ), 425 + ], 426 + [element.text("font-menuitems")], 427 + ), 428 + ]), 429 + html.p([attribute.class("text-xs")], [ 430 + element.text( 431 + "Designed by Santiago Orozco under SIL Open Font License", 432 + ), 433 + ]), 434 + ]) 435 + }, 436 + { 437 + html.li([], [ 438 + html.span([], [ 439 + html.a( 440 + [ 441 + attribute.href( 442 + "https://fonts.google.com/specimen/DM+Mono", 443 + ), 444 + attribute.class("link font-script"), 445 + ], 446 + [ 447 + html.text("DM Mono"), 448 + ], 449 + ), 450 + element.text(" "), 451 + 452 + html.span( 453 + [ 454 + attribute.class( 455 + "badge badge-xs badge-soft badge-secondary text-xs", 456 + ), 457 + ], 458 + [element.text("font-script")], 459 + ), 460 + ]), 461 + html.p([attribute.class("text-xs")], [ 462 + element.text( 463 + "Designed by Colophon Foundry under SIL Open Font License", 464 + ), 465 + ]), 466 + ]) 467 + }, 468 + ]), 469 + ]), 470 + ]), 471 + ], 472 + ) 473 + } 474 + 475 + fn view_login(model: Model) -> Element(Msg) { 476 + // We know that the model is a Login page, so we can safely unwrap it 477 + let assert Login(fieldvalues, successful) = model.page 478 + let values_ok = login_view_checker(fieldvalues) 479 + [ 480 + html.div( 481 + [attribute.class("hero h-screen max-h-[calc(100vh-4rem)] overflow-auto")], 482 + [ 483 + html.div( 484 + [attribute.class("hero-content flex-col lg:flex-row-reverse")], 485 + [ 486 + html.div([attribute.class("text-center lg:text-left")], [ 487 + html.h1([attribute.class("text-5xl font-bold")], [ 488 + element.text("Log in to Lumina!"), 489 + ]), 490 + html.p([attribute.class("py-6")], [ 491 + element.text( 492 + "And we have boiling water. I REALLY don't know what to put here right now.", 493 + ), 494 + ]), 495 + ]), 496 + html.div( 497 + [ 498 + attribute.class( 499 + "card w-full max-w-sm shrink-0 shadow-2xl transition-colors bg-neutral", 500 + ), 501 + ], 502 + [ 503 + html.form( 504 + [ 505 + attribute.class( 506 + "card-body m-4 transition-[height] duration-300 ease-in-out transition", 507 + ), 508 + event.on_submit(UserSubmittedLogin), 509 + ], 510 + [ 511 + html.fieldset([attribute.class("fieldset")], [ 512 + html.label([attribute.class("fieldset-label")], [ 513 + element.text("Email or username"), 514 + ]), 515 + html.input([ 516 + attribute.placeholder("me@mymail.com"), 517 + attribute.class( 518 + "input input-primary bg-primary font-content", 519 + ), 520 + attribute.type_("text"), 521 + attribute.value(fieldvalues.emailfield), 522 + event.on_input(UserUpdatedControlledEmailField), 523 + event.on("focusout", { 524 + decode.success(model_type.EmailFieldLostFocus) 525 + }), 526 + ]), 527 + html.label([attribute.class("fieldset-label")], [ 528 + element.text("Password"), 529 + ]), 530 + html.input([ 531 + attribute.value(fieldvalues.passwordfield), 532 + event.on_input(UserUpdatedControlledPasswordField), 533 + attribute.placeholder("Password"), 534 + attribute.class( 535 + "input input-primary bg-primary font-content", 536 + ), 537 + attribute.type_("password"), 538 + ]), 539 + html.div([], [ 540 + html.a([attribute.class("link link-hover")], [ 541 + element.text("Forgot password?"), 542 + ]), 543 + ]), 544 + case successful { 545 + Some(False) -> 546 + html.div( 547 + [ 548 + attribute.class( 549 + "text-error-content bg-error p-3 rounded-lg", 550 + ), 551 + ], 552 + [ 553 + element.text( 554 + "Incorrect password and/or username!", 555 + ), 556 + ], 557 + ) 558 + _ -> element.none() 559 + }, 560 + html.button( 561 + case values_ok { 562 + True -> [ 563 + attribute.class( 564 + "btn btn-accent w-full mt-4 font-menuitems", 565 + ), 566 + attribute.type_("submit"), 567 + ] 568 + False -> [ 569 + attribute.class( 570 + "btn btn-accent w-full mt-4 btn-disabled font-menuitems bg-accent hidden", 571 + ), 572 + attribute.disabled(True), 573 + ] 574 + }, 575 + [element.text("Login")], 576 + ), 577 + ]), 578 + ], 579 + ), 580 + ], 581 + ), 582 + ], 583 + ), 584 + ], 585 + ), 586 + ] 587 + |> common_view_parts(with_menu: [ 588 + html.li([event.on_click(UserNavigatedToLandingPage)], [ 589 + html.a([], [element.text("Back")]), 590 + ]), 591 + html.li([event.on_click(UserNavigatedToRegisterPage)], [ 592 + html.a([], [element.text("Register")]), 593 + ]), 594 + html.li([event.on_click(UserNavigatedToLoginPage)], [ 595 + html.a([attribute.class("bg-primary text-primary-content")], [ 596 + element.text("Login"), 597 + ]), 598 + ]), 599 + ]) 600 + } 601 + 602 + fn view_register(model_: Model) -> Element(Msg) { 603 + // We know that the model is a Login page, so we can safely unwrap it 604 + let assert Register(fieldvalues, ready): model_type.Page = model_.page 605 + // Check if the password and password confirmation fields match and if the email and username fields are not empty 606 + [ 607 + html.div( 608 + [ 609 + attribute.class("hero h-screen max-h-[calc(100vh-4rem)] overflow-auto"), 610 + ], 611 + [ 612 + html.div( 613 + [attribute.class("hero-content flex-col lg:flex-row-reverse")], 614 + [ 615 + html.div( 616 + [ 617 + attribute.class( 618 + "card bg-neutral w-full max-w-sm shrink-0 shadow-2xl", 619 + ), 620 + ], 621 + [ 622 + html.form( 623 + [ 624 + attribute.class( 625 + "card-body m-4 delay-150 duration-300 ease-in-out transition-[height]", 626 + ), 627 + event.on_submit(UserSubmittedSignup), 628 + ], 629 + [ 630 + html.fieldset([attribute.class("fieldset")], [ 631 + html.label([attribute.class("fieldset-label")], [ 632 + element.text("Email"), 633 + ]), 634 + html.input([ 635 + attribute.placeholder("Email"), 636 + attribute.class( 637 + "input input-primary bg-primary font-content", 638 + ), 639 + attribute.type_("email"), 640 + attribute.value(fieldvalues.emailfield), 641 + event.on_input(UserUpdatedControlledEmailField), 642 + ]), 643 + html.label([attribute.class("fieldset-label")], [ 644 + element.text("Username"), 645 + ]), 646 + html.input([ 647 + attribute.placeholder("Username"), 648 + attribute.class( 649 + "input input-primary bg-primary font-content", 650 + ), 651 + attribute.type_("string"), 652 + attribute.value(fieldvalues.usernamefield), 653 + event.on_input(UserUpdatedControlledUsernameField), 654 + ]), 655 + html.label([attribute.class("fieldset-label")], [ 656 + element.text("Password"), 657 + ]), 658 + html.input([ 659 + attribute.value(fieldvalues.passwordfield), 660 + event.on_input(UserUpdatedControlledPasswordField), 661 + attribute.placeholder("Password"), 662 + attribute.class( 663 + "input input-primary bg-primary font-content", 664 + ), 665 + attribute.type_("password"), 666 + ]), 667 + html.label([attribute.class("fieldset-label")], [ 668 + element.text("Confirm Password"), 669 + ]), 670 + html.input([ 671 + attribute.value(fieldvalues.passwordconfirmfield), 672 + event.on_input( 673 + UserUpdatedControlledPasswordConfirmField, 674 + ), 675 + attribute.placeholder("Re-type password"), 676 + attribute.class( 677 + "input input-primary bg-primary font-content", 678 + ), 679 + attribute.type_("password"), 680 + ]), 681 + 682 + case 683 + ready |> option.is_some() 684 + && ready |> option.unwrap(Error("")) |> result.is_ok() 685 + && fieldvalues.passwordfield 686 + == fieldvalues.passwordconfirmfield 687 + { 688 + True -> 689 + html.button( 690 + [ 691 + attribute.class( 692 + "btn btn-accent font-menuitems w-full m-0 p-0 mt-2", 693 + ), 694 + attribute.type_("submit"), 695 + ], 696 + [ 697 + html.text( 698 + case 699 + ready |> option.is_some() 700 + && ready 701 + |> option.unwrap(Error("")) 702 + |> result.is_ok() 703 + { 704 + True -> 705 + "Sign up as " <> fieldvalues.usernamefield 706 + False -> "Sign up" 707 + }, 708 + ), 709 + ], 710 + ) 711 + False -> 712 + html.div( 713 + [ 714 + attribute.class(case ready |> option.is_some() { 715 + True -> 716 + "btn bg-base-200 hover:bg-base-200 text-warning-content font-menuitems w-full m-0 p-0 rounded-lg mt-2 opacity-80 hover:opacity-80 cursor-default no-animation disabled" 717 + False -> "hidden" 718 + }), 719 + ], 720 + [ 721 + case 722 + ready |> option.unwrap(Ok(Nil)), 723 + fieldvalues.passwordfield 724 + == fieldvalues.passwordconfirmfield 725 + { 726 + Error(why), _ -> 727 + html.div([attribute.class("")], [ 728 + html.span( 729 + [], 730 + case string.contains(why, "in use") { 731 + True -> [ 732 + element.text( 733 + " " <> why <> ", do you want to ", 734 + ), 735 + html.a( 736 + [ 737 + event.on_click( 738 + UserNavigatedToLoginPage, 739 + ), 740 + attribute.class( 741 + "link link-primary", 742 + ), 743 + ], 744 + [element.text("log in instead")], 745 + ), 746 + element.text("?"), 747 + ] 748 + False -> [element.text(" " <> why)] 749 + }, 750 + ), 751 + ]) 752 + Ok(_), True -> element.none() 753 + Ok(_), False -> 754 + html.div([attribute.class("")], [ 755 + element.text("Passwords don't match!"), 756 + ]) 757 + }, 758 + ], 759 + ) 760 + }, 761 + ]), 762 + ], 763 + ), 764 + ], 765 + ), 766 + html.div([attribute.class("text-center lg:text-left")], [ 767 + html.h1([attribute.class("text-5xl font-bold")], [ 768 + element.text("Sign up for Lumina!"), 769 + ]), 770 + html.p([attribute.class("py-6")], [ 771 + element.text( 772 + "We have real good food, I don't know what to put here right now.", 773 + ), 774 + ]), 775 + ]), 776 + ], 777 + ), 778 + ], 779 + ), 780 + ] 781 + |> common_view_parts(with_menu: [ 782 + html.li([event.on_click(UserNavigatedToLandingPage)], [ 783 + html.a([], [element.text("Back")]), 784 + ]), 785 + html.li([event.on_click(UserNavigatedToRegisterPage)], [ 786 + html.a([attribute.class("bg-primary text-primary-content")], [ 787 + element.text("Register"), 788 + ]), 789 + ]), 790 + html.li([event.on_click(UserNavigatedToLoginPage)], [ 791 + html.a([], [element.text("Login")]), 792 + ]), 793 + ]) 794 + }
+71
backend/impl-rs/client/src/lumina_client/view/common_view_parts.gleam
··· 1 + //// Lumina > Client > View > Application/Homepage > Common View Parts 2 + //// This module contains common view parts used across Lumina client views. 3 + 4 + // Lumina/Peonies 5 + // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 + // 7 + // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 + // You may not use this work except in compliance with the Licence. 9 + // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 + // 11 + // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 + // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 + // See LICENSE file in the repository root for full details. 14 + // 15 + // 16 + // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 + // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 + 19 + import gleam/option.{Some} 20 + import lumina_client/model_type.{type Msg, type Page} 21 + import lustre/attribute 22 + import lustre/element.{type Element} 23 + import lustre/element/html 24 + 25 + pub fn common_view_parts( 26 + main_body: List(Element(Msg)), 27 + with_menu menuitems: List(Element(Msg)), 28 + ) { 29 + html.div([attribute.class("font-sans")], [ 30 + html.div([attribute.class("navbar bg-base-200 shadow-sm")], [ 31 + html.div([attribute.class("flex-none")], [ 32 + html.button([attribute.class("")], [ 33 + html.img([ 34 + attribute.src("/static/logo.svg"), 35 + attribute.alt("Lumina logo"), 36 + attribute.class("h-8"), 37 + ]), 38 + ]), 39 + ]), 40 + html.div([attribute.class("flex-1")], [ 41 + html.a([attribute.class("btn btn-ghost text-xl font-logo")], [ 42 + element.text("Lumina"), 43 + ]), 44 + ]), 45 + html.div([attribute.class("flex-none")], [ 46 + html.ul( 47 + [attribute.class("menu menu-horizontal px-1 font-menuitems")], 48 + menuitems, 49 + ), 50 + ]), 51 + ]), 52 + html.div( 53 + [attribute.class("bg-base-100 h-screen max-h-[calc(100vh-4rem)]")], 54 + main_body, 55 + ), 56 + ]) 57 + } 58 + 59 + pub fn href(route: Page) -> attribute.Attribute(Msg) { 60 + case route { 61 + model_type.Landing -> "/" 62 + model_type.Register(_, _) -> "/signup/" 63 + model_type.Login(_, _) -> "/login/" 64 + model_type.HomeTimeline(timeline_name: Some(m), modal:) -> 65 + "/timeline/" <> m <> "/" 66 + model_type.HomeTimeline(timeline_name: option.None, modal:) -> "/home/" 67 + model_type.Licence -> "/licence" 68 + model_type.NotFound(_) -> "/404" 69 + } 70 + |> attribute.href() 71 + }
+381
backend/impl-rs/client/src/lumina_client/view/common_view_parts/svgs.gleam
··· 1 + //// Lumina > Client > View > Application/Homepage > Common View Parts > SVGs 2 + //// This module contains reusable SVG components used throughout the Lumina client. 3 + 4 + // Lumina/Peonies 5 + // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 + // 7 + // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 + // You may not use this work except in compliance with the Licence. 9 + // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 + // 11 + // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 + // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 + // See LICENSE file in the repository root for full details. 14 + // 15 + // 16 + // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 + // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 + 19 + import gleam/list 20 + import lumina_client/model_type 21 + import lustre/attribute.{attribute, class} 22 + import lustre/element 23 + import lustre/element/svg 24 + 25 + const sourcelist_solar_linear = [ 26 + #(globe, "https://www.svgrepo.com/svg/524520/earth"), 27 + #(pen, "https://www.svgrepo.com/svg/524793/pen-2"), 28 + #(camera, "https://www.svgrepo.com/svg/524361/camera"), 29 + #(pen_paper, "https://www.svgrepo.com/svg/524800/pen-new-square"), 30 + #(hashtag_square, "https://www.svgrepo.com/svg/524621/hashtag-square"), 31 + #(add_square, "https://www.svgrepo.com/svg/524223/add-square"), 32 + #(archive_box, "https://www.svgrepo.com/svg/523982/archive"), 33 + ] 34 + 35 + /// Lists the SVG functions in a random order with their source URLs. 36 + pub fn sources_solar_linear() -> List( 37 + #(fn(String) -> element.Element(model_type.Msg), String), 38 + ) { 39 + sourcelist_solar_linear |> list.shuffle() 40 + } 41 + 42 + /// Globe SVG icon used in various parts of the Lumina client. 43 + /// 44 + /// Thank <https://www.svgrepo.com/svg/524520/earth> for this, otherwise we'd have been stuck with my older design. 45 + pub fn globe(classes: String) { 46 + svg.svg( 47 + [ 48 + attribute("xmlns", "http://www.w3.org/2000/svg"), 49 + class(classes), 50 + attribute("fill", "none"), 51 + attribute("viewBox", "0 0 24 24"), 52 + ], 53 + [ 54 + svg.circle([ 55 + attribute("stroke-width", "1.5"), 56 + attribute("stroke", "currentColor"), 57 + attribute("r", "10"), 58 + attribute("cy", "12"), 59 + attribute("cx", "12"), 60 + ]), 61 + svg.path([ 62 + attribute("stroke-width", "1.5"), 63 + attribute("stroke", "currentColor"), 64 + attribute( 65 + "d", 66 + "M6 4.71053C6.78024 5.42105 8.38755 7.36316 8.57481 9.44737C8.74984 11.3955 10.0357 12.9786 12 13C12.7549 13.0082 13.5183 12.4629 13.5164 11.708C13.5158 11.4745 13.4773 11.2358 13.417 11.0163C13.3331 10.7108 13.3257 10.3595 13.5 10C14.1099 8.74254 15.3094 8.40477 16.2599 7.72186C16.6814 7.41898 17.0659 7.09947 17.2355 6.84211C17.7037 6.13158 18.1718 4.71053 17.9377 4", 67 + ), 68 + ]), 69 + svg.path([ 70 + attribute("stroke-width", "1.5"), 71 + attribute("stroke", "currentColor"), 72 + attribute( 73 + "d", 74 + "M22 13C21.6706 13.931 21.4375 16.375 17.7182 16.4138C17.7182 16.4138 14.4246 16.4138 13.4365 18.2759C12.646 19.7655 13.1071 21.3793 13.4365 22", 75 + ), 76 + ]), 77 + ], 78 + ) 79 + } 80 + 81 + /// Two people overlapping 82 + /// This one is by me :) - Strawmelonjuice 83 + pub fn follows(classes: String) { 84 + svg.svg( 85 + [ 86 + attribute.class(classes), 87 + attribute.attribute("fill", "none"), 88 + attribute.attribute("stroke", "currentColor"), 89 + attribute.attribute("viewBox", "0 0 24 24"), 90 + attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), 91 + ], 92 + [ 93 + svg.circle([ 94 + attribute.attribute("cx", "8"), 95 + attribute.attribute("cy", "8"), 96 + attribute.attribute("r", "3"), 97 + attribute.attribute("opacity", "0.6"), 98 + attribute.attribute("stroke-width", "2"), 99 + ]), 100 + svg.circle([ 101 + attribute.attribute("cx", "16"), 102 + attribute.attribute("cy", "8"), 103 + attribute.attribute("r", "3"), 104 + attribute.attribute("opacity", "0.6"), 105 + attribute.attribute("stroke-width", "2"), 106 + ]), 107 + svg.path([ 108 + attribute.attribute("stroke-width", "2"), 109 + attribute.attribute("stroke-linecap", "round"), 110 + attribute.attribute("opacity", "0.6"), 111 + attribute.attribute("stroke-linejoin", "round"), 112 + attribute.attribute("d", "M2 20v-1a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v1"), 113 + ]), 114 + svg.path([ 115 + attribute.attribute("stroke-width", "2"), 116 + attribute.attribute("opacity", "0.6"), 117 + attribute.attribute("stroke-linecap", "round"), 118 + attribute.attribute("stroke-linejoin", "round"), 119 + attribute.attribute("d", "M14 20v-1a4 4 0 0 1 4-4h0a4 4 0 0 1 4 4v1"), 120 + ]), 121 + ], 122 + ) 123 + } 124 + 125 + /// Heart and star overlapping for 'mutuals' 126 + /// Also by me :) - Strawmelonjuice 127 + pub fn mutuals(classes: String) { 128 + svg.svg( 129 + [ 130 + attribute.class(classes), 131 + attribute.attribute("fill", "none"), 132 + attribute.attribute("stroke", "currentColor"), 133 + attribute.attribute("viewBox", "0 0 24 24"), 134 + attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), 135 + ], 136 + [ 137 + // Heart shape, offset to the left, with classic 'v' top and reduced opacity 138 + svg.path([ 139 + attribute.attribute("stroke-width", "2"), 140 + attribute.attribute("stroke-linecap", "round"), 141 + attribute.attribute("stroke-linejoin", "round"), 142 + attribute.attribute( 143 + "d", 144 + "M9 19C5 15 2 12.5 2 9.5C2 7 4 5 6.5 5C8 5 9 6.5 9 6.5C9 6.5 10 5 11.5 5C14 5 16 7 16 9.5C16 12.5 13 15 9 19Z", 145 + ), 146 + attribute.attribute("opacity", "0.6"), 147 + ]), 148 + // Star shape, offset to the right and overlapping, with reduced opacity 149 + svg.path([ 150 + attribute.attribute("stroke-width", "2"), 151 + attribute.attribute("stroke-linecap", "round"), 152 + attribute.attribute("stroke-linejoin", "round"), 153 + attribute.attribute( 154 + "d", 155 + "M15 4.5l2.09 4.24 4.68.68-3.39 3.3.8 4.63L15 15.77l-4.18 2.18.8-4.63-3.39-3.3 4.68-.68L15 4.5z", 156 + ), 157 + attribute.attribute("opacity", "0.6"), 158 + ]), 159 + ], 160 + ) 161 + } 162 + 163 + /// Pen, for editing text posts, also called 'jot mode'. 164 + /// 165 + /// Also from svgrepo: https://www.svgrepo.com/svg/524793/pen-2 166 + pub fn pen(classes: String) { 167 + svg.svg( 168 + [ 169 + attribute("xmlns", "http://www.w3.org/2000/svg"), 170 + attribute("fill", "none"), 171 + attribute("viewBox", "0 0 24 24"), 172 + class(classes), 173 + ], 174 + [ 175 + svg.path([ 176 + attribute("stroke-linecap", "round"), 177 + attribute("stroke-width", "1.5"), 178 + attribute("stroke", "currentColor"), 179 + attribute("d", "M4 22H20"), 180 + ]), 181 + svg.path([ 182 + attribute("stroke-width", "1.5"), 183 + attribute("stroke", "currentColor"), 184 + attribute( 185 + "d", 186 + "M13.8881 3.66293L14.6296 2.92142C15.8581 1.69286 17.85 1.69286 19.0786 2.92142C20.3071 4.14999 20.3071 6.14188 19.0786 7.37044L18.3371 8.11195M13.8881 3.66293C13.8881 3.66293 13.9807 5.23862 15.3711 6.62894C16.7614 8.01926 18.3371 8.11195 18.3371 8.11195M13.8881 3.66293L7.07106 10.4799C6.60933 10.9416 6.37846 11.1725 6.17992 11.4271C5.94571 11.7273 5.74491 12.0522 5.58107 12.396C5.44219 12.6874 5.33894 12.9972 5.13245 13.6167L4.25745 16.2417M18.3371 8.11195L11.5201 14.9289C11.0584 15.3907 10.8275 15.6215 10.5729 15.8201C10.2727 16.0543 9.94775 16.2551 9.60398 16.4189C9.31256 16.5578 9.00282 16.6611 8.38334 16.8675L5.75834 17.7426M5.75834 17.7426L5.11667 17.9564C4.81182 18.0581 4.47573 17.9787 4.2485 17.7515C4.02128 17.5243 3.94194 17.1882 4.04356 16.8833L4.25745 16.2417M5.75834 17.7426L4.25745 16.2417", 187 + ), 188 + ]), 189 + ], 190 + ) 191 + } 192 + 193 + /// Camera icon for 'media' posts. 194 + /// 195 + /// https://www.svgrepo.com/svg/524361/camera 196 + pub fn camera(classes: String) { 197 + svg.svg( 198 + [ 199 + attribute("xmlns", "http://www.w3.org/2000/svg"), 200 + attribute("fill", "none"), 201 + attribute("viewBox", "0 0 24 24"), 202 + class(classes), 203 + ], 204 + [ 205 + svg.circle([ 206 + attribute("stroke-width", "1.5"), 207 + attribute("stroke", "currentColor"), 208 + attribute("r", "3"), 209 + attribute("cy", "13"), 210 + attribute("cx", "12"), 211 + ]), 212 + svg.path([ 213 + attribute("stroke-width", "1.5"), 214 + attribute("stroke", "currentColor"), 215 + attribute( 216 + "d", 217 + "M9.77778 21H14.2222C17.3433 21 18.9038 21 20.0248 20.2646C20.51 19.9462 20.9267 19.5371 21.251 19.0607C22 17.9601 22 16.4279 22 13.3636C22 10.2994 22 8.76721 21.251 7.6666C20.9267 7.19014 20.51 6.78104 20.0248 6.46268C19.3044 5.99013 18.4027 5.82123 17.022 5.76086C16.3631 5.76086 15.7959 5.27068 15.6667 4.63636C15.4728 3.68489 14.6219 3 13.6337 3H10.3663C9.37805 3 8.52715 3.68489 8.33333 4.63636C8.20412 5.27068 7.63685 5.76086 6.978 5.76086C5.59733 5.82123 4.69555 5.99013 3.97524 6.46268C3.48995 6.78104 3.07328 7.19014 2.74902 7.6666C2 8.76721 2 10.2994 2 13.3636C2 16.4279 2 17.9601 2.74902 19.0607C3.07328 19.5371 3.48995 19.9462 3.97524 20.2646C5.09624 21 6.65675 21 9.77778 21Z", 218 + ), 219 + ]), 220 + svg.path([ 221 + attribute("stroke-linecap", "round"), 222 + attribute("stroke-width", "1.5"), 223 + attribute("stroke", "currentColor"), 224 + attribute("d", "M19 10H18"), 225 + ]), 226 + ], 227 + ) 228 + } 229 + 230 + /// Pen and paper icon for 'article' posts. 231 + /// 232 + /// From svgrepo: https://www.svgrepo.com/svg/524784/pen-paper 233 + pub fn pen_paper(classes: String) { 234 + svg.svg( 235 + [ 236 + attribute("xmlns", "http://www.w3.org/2000/svg"), 237 + attribute("fill", "none"), 238 + attribute("viewBox", "0 0 24 24"), 239 + class(classes), 240 + ], 241 + [ 242 + svg.path([ 243 + attribute("stroke-linecap", "round"), 244 + attribute("stroke-width", "1.5"), 245 + attribute("stroke", "currentColor"), 246 + attribute( 247 + "d", 248 + "M22 10.5V12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2H13.5", 249 + ), 250 + ]), 251 + svg.path([ 252 + attribute("stroke-width", "1.5"), 253 + attribute("stroke", "currentColor"), 254 + attribute( 255 + "d", 256 + "M16.652 3.45506L17.3009 2.80624C18.3759 1.73125 20.1188 1.73125 21.1938 2.80624C22.2687 3.88124 22.2687 5.62415 21.1938 6.69914L20.5449 7.34795M16.652 3.45506C16.652 3.45506 16.7331 4.83379 17.9497 6.05032C19.1662 7.26685 20.5449 7.34795 20.5449 7.34795M16.652 3.45506L10.6872 9.41993C10.2832 9.82394 10.0812 10.0259 9.90743 10.2487C9.70249 10.5114 9.52679 10.7957 9.38344 11.0965C9.26191 11.3515 9.17157 11.6225 8.99089 12.1646L8.41242 13.9M20.5449 7.34795L14.5801 13.3128C14.1761 13.7168 13.9741 13.9188 13.7513 14.0926C13.4886 14.2975 13.2043 14.4732 12.9035 14.6166C12.6485 14.7381 12.3775 14.8284 11.8354 15.0091L10.1 15.5876M10.1 15.5876L8.97709 15.9619C8.71035 16.0508 8.41626 15.9814 8.21744 15.7826C8.01862 15.5837 7.9492 15.2897 8.03811 15.0229L8.41242 13.9M10.1 15.5876L8.41242 13.9", 257 + ), 258 + ]), 259 + ], 260 + ) 261 + } 262 + 263 + /// Hashtag in a square for timeline switching. 264 + /// From svgrepo. 265 + pub fn hashtag_square(classes: String) { 266 + svg.svg( 267 + [ 268 + attribute("xmlns", "http://www.w3.org/2000/svg"), 269 + attribute("fill", "none"), 270 + attribute("viewBox", "0 0 24 24"), 271 + class(classes), 272 + ], 273 + [ 274 + svg.path([ 275 + attribute("stroke-linejoin", "round"), 276 + attribute("stroke-linecap", "round"), 277 + attribute("stroke-width", "1.5"), 278 + attribute("stroke", "currentColor"), 279 + attribute("d", "M11 7L8 17"), 280 + ]), 281 + svg.path([ 282 + attribute("stroke-linejoin", "round"), 283 + attribute("stroke-linecap", "round"), 284 + attribute("stroke-width", "1.5"), 285 + attribute("stroke", "currentColor"), 286 + attribute("d", "M16 7L13 17"), 287 + ]), 288 + svg.path([ 289 + attribute("stroke-linejoin", "round"), 290 + attribute("stroke-linecap", "round"), 291 + attribute("stroke-width", "1.5"), 292 + attribute("stroke", "currentColor"), 293 + attribute("d", "M18 10H7"), 294 + ]), 295 + svg.path([ 296 + attribute("stroke-linejoin", "round"), 297 + attribute("stroke-linecap", "round"), 298 + attribute("stroke-width", "1.5"), 299 + attribute("stroke", "currentColor"), 300 + attribute("d", "M17 14H6"), 301 + ]), 302 + svg.path([ 303 + attribute("stroke-width", "1.5"), 304 + attribute("stroke", "currentColor"), 305 + attribute( 306 + "d", 307 + "M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12Z", 308 + ), 309 + ]), 310 + ], 311 + ) 312 + } 313 + 314 + /// Add square icon for adding new posts. 315 + /// From svgrepo. 316 + pub fn add_square(classes: String) { 317 + svg.svg( 318 + [ 319 + attribute("xmlns", "http://www.w3.org/2000/svg"), 320 + attribute("fill", "none"), 321 + attribute("viewBox", "0 0 24 24"), 322 + class(classes), 323 + ], 324 + [ 325 + svg.path([ 326 + attribute("stroke-width", "1.5"), 327 + attribute("stroke", "currentColor"), 328 + attribute( 329 + "d", 330 + "M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12Z", 331 + ), 332 + ]), 333 + svg.path([ 334 + attribute("stroke-linecap", "round"), 335 + attribute("stroke-width", "1.5"), 336 + attribute("stroke", "currentColor"), 337 + attribute("d", "M15 12L12 12M12 12L9 12M12 12L12 9M12 12L12 15"), 338 + ]), 339 + ], 340 + ) 341 + } 342 + 343 + /// Archive box icon for notifications. 344 + /// From svgrepo. 345 + pub fn archive_box(classes: String) { 346 + svg.svg( 347 + [ 348 + attribute("xmlns", "http://www.w3.org/2000/svg"), 349 + attribute("fill", "none"), 350 + attribute("viewBox", "0 0 24 24"), 351 + class(classes), 352 + ], 353 + [ 354 + svg.path([ 355 + attribute("stroke-width", "1.5"), 356 + attribute("stroke", "currentColor"), 357 + attribute( 358 + "d", 359 + "M9 12C9 11.5341 9 11.3011 9.07612 11.1173C9.17761 10.8723 9.37229 10.6776 9.61732 10.5761C9.80109 10.5 10.0341 10.5 10.5 10.5H13.5C13.9659 10.5 14.1989 10.5 14.3827 10.5761C14.6277 10.6776 14.8224 10.8723 14.9239 11.1173C15 11.3011 15 11.5341 15 12C15 12.4659 15 12.6989 14.9239 12.8827C14.8224 13.1277 14.6277 13.3224 14.3827 13.4239C14.1989 13.5 13.9659 13.5 13.5 13.5H10.5C10.0341 13.5 9.80109 13.5 9.61732 13.4239C9.37229 13.3224 9.17761 13.1277 9.07612 12.8827C9 12.6989 9 12.4659 9 12Z", 360 + ), 361 + ]), 362 + svg.path([ 363 + attribute("stroke-linecap", "round"), 364 + attribute("stroke-width", "1.5"), 365 + attribute("stroke", "currentColor"), 366 + attribute( 367 + "d", 368 + "M20.5 7V13C20.5 16.7712 20.5 18.6569 19.3284 19.8284C18.1569 21 16.2712 21 12.5 21H11.5C7.72876 21 5.84315 21 4.67157 19.8284C3.5 18.6569 3.5 16.7712 3.5 13V7", 369 + ), 370 + ]), 371 + svg.path([ 372 + attribute("stroke-width", "1.5"), 373 + attribute("stroke", "currentColor"), 374 + attribute( 375 + "d", 376 + "M2 5C2 4.05719 2 3.58579 2.29289 3.29289C2.58579 3 3.05719 3 4 3H20C20.9428 3 21.4142 3 21.7071 3.29289C22 3.58579 22 4.05719 22 5C22 5.94281 22 6.41421 21.7071 6.70711C21.4142 7 20.9428 7 20 7H4C3.05719 7 2.58579 7 2.29289 6.70711C2 6.41421 2 5.94281 2 5Z", 377 + ), 378 + ]), 379 + ], 380 + ) 381 + }
+831
backend/impl-rs/client/src/lumina_client/view/homepage.gleam
··· 1 + //// Lumina > Client > View > Application/Homepage 2 + //// This module focuses on the main application, mostly layout and modals. 3 + //// It's children shape the content inside the main application layout. 4 + 5 + // Lumina/Peonies 6 + // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 7 + // 8 + // This software is licensed under the European Union Public Licence (EUPL) v1.2. 9 + // You may not use this work except in compliance with the Licence. 10 + // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 11 + // 12 + // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 13 + // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 14 + // See LICENSE file in the repository root for full details. 15 + // 16 + // 17 + // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 18 + // See the Licence for the specific language governing permissions and limitations. [cite: 6] 19 + 20 + import gleam/bool 21 + import gleam/dict 22 + import gleam/dynamic/decode 23 + import gleam/float 24 + import gleam/int 25 + import gleam/list 26 + import gleam/option.{type Option, None, Some} 27 + import gleam/order 28 + import gleam/result 29 + import gleam/time/calendar 30 + import gleam/time/timestamp 31 + import lumina_client/dom 32 + import lumina_client/helpers 33 + import lumina_client/model_type.{ 34 + type CachedTimeline, type Model, type Msg, CachedTimeline, SetModal, 35 + StartDraggingModalBox, UserClickedLogout, UserClosedModal, 36 + } 37 + import lumina_client/view/common_view_parts.{common_view_parts} 38 + import lumina_client/view/common_view_parts/svgs 39 + import lumina_client/view/homepage/post_editor 40 + import lumina_client/view/homepage/posts 41 + import lustre/attribute.{attribute} 42 + import lustre/element.{type Element} 43 + import lustre/element/html 44 + import lustre/event 45 + 46 + fn closemodal_not_for_modal_box() { 47 + use target <- decode.field("target", decode.dynamic) 48 + case bool.negate(dom.classfoundintree(target, "modal-box")) { 49 + True -> decode.success(UserClosedModal) 50 + False -> 51 + decode.failure(UserClosedModal, "Clicked inside modal-box, ignoring") 52 + } 53 + } 54 + 55 + pub fn view(model: model_type.Model) -> Element(Msg) { 56 + // Dissect the model 57 + let assert model_type.Model( 58 + page: model_type.HomeTimeline(timeline_name:, modal:), 59 + user:, 60 + .., 61 + ) = model 62 + use <- 63 + bool.lazy_guard(option.is_some(user), _, fn() { 64 + element.text("Loading user...") 65 + }) 66 + let assert Some(user) = user 67 + as "User must be logged in to see homepage, got None from model where a user-submodel was expected. (Got past a guard?)" 68 + let timeline_name = option.unwrap(timeline_name, "global") 69 + let modal_element = case 70 + modal |> option.map(modal_by_id(_, model)) |> option.unwrap(NoModal) 71 + { 72 + CentralBig(mod) -> 73 + html.div( 74 + [ 75 + attribute.class( 76 + "modal modal-open fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50 w-screen h-screen", 77 + ), 78 + event.on("click", closemodal_not_for_modal_box()), 79 + ], 80 + [ 81 + html.div( 82 + [ 83 + attribute.class( 84 + "modal-box w-[99vw] lg:w-[80vw] max-w-[unset] h-[80lvh] flex flex-col justify-center items-center bg-base-100 shadow-2xl relative", 85 + ), 86 + ], 87 + [ 88 + html.button( 89 + [ 90 + attribute.class( 91 + "btn rounded-none rounded-bl-sm btn-error absolute top-0 right-0 text-2xl", 92 + ), 93 + 94 + event.on_click(UserClosedModal), 95 + ], 96 + [ 97 + element.text( 98 + // &times; 99 + "×", 100 + ), 101 + ], 102 + ), 103 + mod, 104 + html.div([attribute.class("modal-action")], []), 105 + ], 106 + ), 107 + ], 108 + ) 109 + CentralSmall(id, title, mod, closable, params) -> { 110 + let def_x = helpers.get_center_positioned_style_px().1 111 + let def_y = helpers.get_center_positioned_style_px().0 112 + let set_x = dict.get(params, "pos_x") 113 + let set_y = dict.get(params, "pos_y") 114 + let pos_x = case set_x { 115 + Ok(v) -> float.parse(v) |> result.unwrap(def_x) 116 + Error(_) -> def_x 117 + } 118 + let pos_y = case set_y { 119 + Ok(v) -> float.parse(v) |> result.unwrap(def_y) 120 + Error(_) -> def_y 121 + } 122 + html.div( 123 + [ 124 + attribute.class( 125 + "modal modal-open fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50 w-screen h-screen", 126 + ), 127 + event.on("click", closemodal_not_for_modal_box()), 128 + ], 129 + [ 130 + html.div( 131 + [ 132 + attribute.id(id), 133 + attribute.class( 134 + "modal-box lg:freeroam flex flex-col justify-center items-center bg-base-100 shadow-2xl w-[99vw] lg:w-[32rem] max-w-[unset] lg:max-w-[99vw] h-[80lvh] lg:h-[80lvh] lg:max-h-[90vh] relative lg:absolute", 135 + ), 136 + // Positioning styles from left to right 137 + attribute.style("--left", pos_x |> float.to_string() <> "px"), 138 + // Positioning styles from top to bottom 139 + attribute.style("--top", pos_y |> float.to_string() <> "px"), 140 + // Centering transform 141 + attribute.style("--transform", "translate(-50%, -50%)"), 142 + ], 143 + [ 144 + // Title bar 145 + html.section( 146 + [ 147 + attribute.class( 148 + "w-full h-10 absolute top-0 left-0 bg-transparent cursor-move bg-info text-info-content rounded-t-xl flex items-center justify-center", 149 + ), 150 + event.on_mouse_down(StartDraggingModalBox(pos_x, pos_y)), 151 + ], 152 + [element.text(title)], 153 + ), 154 + // Close button on the title bar, if closable 155 + case closable { 156 + True -> 157 + html.button( 158 + [ 159 + attribute.class( 160 + "btn rounded-none rounded-bl-sm btn-error absolute top-0 right-0 text-2xl", 161 + ), 162 + event.on_click(UserClosedModal), 163 + ], 164 + [element.text("×")], 165 + ) 166 + False -> element.none() 167 + }, 168 + 169 + html.div([attribute.class("w-full h-full mt-10")], [ 170 + mod, 171 + ]), 172 + ], 173 + ), 174 + ], 175 + ) 176 + } 177 + SideOrCentral(Right, mod) -> 178 + html.div( 179 + [ 180 + attribute.class( 181 + "modal modal-open fixed top-[4rem] right-0 left-0 bottom-0 flex items-end justify-end z-50 bg-black bg-opacity-50 w-screen max-h-[calc(100vh-4rem)]", 182 + ), 183 + event.on("click", closemodal_not_for_modal_box()), 184 + ], 185 + [ 186 + html.div( 187 + [ 188 + attribute.class( 189 + "modal-box w-[24rem] lg:max-h-[calc(100vh-4rem)] flex flex-col justify-start items-center bg-base-100 shadow-2xl relative rounded-xl md:max-h-[calc(100vh-4rem)] h-[60vh] max-h-[60vh] mb-[20vh]", 190 + ), 191 + ], 192 + [ 193 + html.button( 194 + [ 195 + attribute.class( 196 + "btn rounded-none rounded-bl-sm btn-error absolute top-0 right-0 text-2xl", 197 + ), 198 + event.on_click(UserClosedModal), 199 + ], 200 + [element.text("×")], 201 + ), 202 + mod, 203 + html.div([attribute.class("modal-action")], []), 204 + ], 205 + ), 206 + ], 207 + ) 208 + SideOrCentral(Left, mod) -> 209 + html.div( 210 + [ 211 + attribute.class( 212 + "modal modal-open fixed top-[4rem] right-0 left-0 bottom-0 flex items-end justify-start z-50 bg-black bg-opacity-50 w-screen max-h-[calc(100vh-4rem)]", 213 + ), 214 + event.on("click", closemodal_not_for_modal_box()), 215 + ], 216 + [ 217 + html.div( 218 + [ 219 + attribute.class( 220 + "modal-box w-[24rem] lg:max-h-[calc(100vh-4rem)] flex flex-col justify-start items-center bg-base-100 shadow-2xl relative rounded-xl md:max-h-[calc(100vh-4rem)] h-[60vh] max-h-[60vh] mb-[20vh]", 221 + ), 222 + ], 223 + [ 224 + html.button( 225 + [ 226 + attribute.class( 227 + "btn btn-circle btn-error absolute top-4 right-4 text-2xl", 228 + ), 229 + event.on_click(UserClosedModal), 230 + ], 231 + [element.text("×")], 232 + ), 233 + mod, 234 + html.div([attribute.class("modal-action")], []), 235 + ], 236 + ), 237 + ], 238 + ) 239 + NoModal -> { 240 + // Floating items and such to be rendered when no modal is open 241 + html.div([attribute.class("items")], [ 242 + html.div([attribute.class("dock lg:hidden")], [ 243 + html.label( 244 + [ 245 + attribute.class("drawer-button"), 246 + attribute.for("timelineswitcher"), 247 + ], 248 + [ 249 + svgs.hashtag_square("size-[1.2em]"), 250 + html.span([attribute.class("dock-label")], [html.text("Switch")]), 251 + ], 252 + ), 253 + html.button( 254 + [ 255 + attribute.class(""), 256 + event.on_click(SetModal("mdl-postedit")), 257 + ], 258 + [ 259 + svgs.add_square("size-[1.2em]"), 260 + html.span([attribute.class("dock-label")], [html.text("Create")]), 261 + ], 262 + ), 263 + 264 + html.button([], [ 265 + html.div([attribute.class("indicator")], [ 266 + case user.notifs.unread_count { 267 + 0 -> element.none() 268 + n -> 269 + html.span( 270 + [attribute.class("indicator-item badge badge-secondary")], 271 + [html.text(int.to_string(n))], 272 + ) 273 + }, 274 + svgs.archive_box("size-[1.2em]"), 275 + ]), 276 + html.span([attribute.class("dock-label")], [ 277 + html.text("Notifications"), 278 + ]), 279 + ]), 280 + ]), 281 + html.div( 282 + [ 283 + attribute.class( 284 + "absolute bottom-4 right-4 p-4 z-50 hidden lg:block", 285 + ), 286 + ], 287 + [ 288 + html.button( 289 + [ 290 + attribute.class("btn btn-circle btn-success btn-lg text-3xl"), 291 + attribute.id("btn-new-post"), 292 + event.on_click(SetModal("mdl-postedit")), 293 + ], 294 + [element.text("+")], 295 + ), 296 + ], 297 + ), 298 + html.div([attribute.class("fixed bottom-20 right-4 p-4 z-50 ")], []), 299 + ]) 300 + } 301 + // SideOrCentral(Bottom, _) -> todo 302 + // SideOrCentral(Top, _) -> todo 303 + } 304 + [ 305 + modal_element, 306 + html.div( 307 + [attribute.class("drawer lg:drawer-open max-h-[calc(100vh-4rem)]")], 308 + [ 309 + html.input([ 310 + attribute.class("drawer-toggle"), 311 + attribute.type_("checkbox"), 312 + attribute.id("timelineswitcher"), 313 + ]), 314 + html.main( 315 + [ 316 + attribute.class( 317 + "drawer-content items-center flex flex-col bg-neutral text-neutral-content h-screen max-h-[calc(100vh-4rem)] overflow-y-auto" 318 + <> { 319 + let rn = timestamp.system_time() 320 + let #(calendar.Date(year, month, day), _) = 321 + timestamp.to_calendar(rn, calendar.local_offset()) 322 + " " 323 + <> { 324 + // Year 325 + "yearclass-" <> int.to_string(year) 326 + } 327 + <> " " 328 + <> { 329 + // Month 330 + case month { 331 + calendar.January -> "monthclass-1" 332 + calendar.February -> "monthclass-2" 333 + calendar.March -> "monthclass-3" 334 + calendar.April -> "monthclass-4" 335 + calendar.May -> "monthclass-5" 336 + calendar.June -> "monthclass-6" 337 + calendar.July -> "monthclass-7" 338 + calendar.August -> "monthclass-8" 339 + calendar.September -> "monthclass-9" 340 + calendar.October -> "monthclass-10" 341 + calendar.November -> "monthclass-11" 342 + calendar.December -> "monthclass-12" 343 + } 344 + } 345 + <> " " 346 + <> { 347 + // Day 348 + "dayclass-" <> int.to_string(day) 349 + } 350 + }, 351 + ), 352 + ], 353 + [timeline(model)], 354 + ), 355 + html.div([attribute.class("drawer-side font-menuitems")], [ 356 + html.label( 357 + [ 358 + attribute.class("drawer-overlay"), 359 + attribute("aria-label", "close sidebar"), 360 + attribute.for("timelineswitcher"), 361 + ], 362 + [], 363 + ), 364 + html.ul( 365 + [ 366 + attribute.class( 367 + "menu bg-base-200 bg-opacity-75 text-base-content h-screen lg:max-h-[calc(100vh-4rem)] w-80 p-4", 368 + ), 369 + ], 370 + [ 371 + html.li([attribute.class("menu-title font-sans")], [ 372 + element.text("Timeline"), 373 + ]), 374 + html.ul([], [ 375 + html.li([], [ 376 + html.a( 377 + [ 378 + bool.lazy_guard( 379 + when: timeline_name == "global", 380 + return: fn() { attribute.class("menu-active") }, 381 + otherwise: fn() { attribute.none() }, 382 + ), 383 + event.on_click(model_type.UserSwitchedTimeLineTo("global")), 384 + ], 385 + [ 386 + svgs.globe("inline h-5 w-5 mr-2"), 387 + element.text("Global"), 388 + ], 389 + ), 390 + ]), 391 + html.li([], [ 392 + html.a( 393 + [ 394 + bool.lazy_guard( 395 + when: timeline_name == "following", 396 + return: fn() { attribute.class("menu-active") }, 397 + otherwise: fn() { attribute.none() }, 398 + ), 399 + event.on_click(model_type.UserSwitchedTimeLineTo( 400 + "following", 401 + )), 402 + ], 403 + [ 404 + svgs.follows("inline h-5 w-5 mr-2"), 405 + element.text("Following"), 406 + ], 407 + ), 408 + ]), 409 + html.li([], [ 410 + html.a( 411 + [ 412 + bool.lazy_guard( 413 + when: timeline_name == "mutuals", 414 + return: fn() { attribute.class("menu-active") }, 415 + otherwise: fn() { attribute.none() }, 416 + ), 417 + event.on_click(model_type.UserSwitchedTimeLineTo( 418 + "mutuals", 419 + )), 420 + ], 421 + [ 422 + // SVG: Heart and star overlapping for 'Mutuals' 423 + svgs.mutuals("inline h-5 w-5 mr-2"), 424 + element.text("Mutuals"), 425 + ], 426 + ), 427 + ]), 428 + ]), 429 + ], 430 + ), 431 + ]), 432 + ], 433 + ), 434 + ] 435 + |> common_view_parts(with_menu: [ 436 + html.li( 437 + [ 438 + attribute.class("hidden md:flex"), 439 + event.on_click(SetModal("selfsettings")), 440 + ], 441 + [ 442 + html.button([attribute.class("btn md:btn-neutral btn-ghost")], [ 443 + element.text("Settings"), 444 + ]), 445 + ], 446 + ), 447 + html.li([], [ 448 + html.button( 449 + [ 450 + attribute.class("btn md:btn-neutral btn-ghost"), 451 + event.on_click(SetModal("selfmenu")), 452 + ], 453 + [ 454 + html.span([attribute.class("hidden md:inline")], [ 455 + element.text("@" <> user.username), 456 + ]), 457 + html.div([attribute.class("avatar")], [ 458 + html.div([attribute.class("h-8 w-8 mask-squircle mask")], [ 459 + html.img([ 460 + attribute.src(user.avatar), 461 + attribute.alt(user.username), 462 + ]), 463 + ]), 464 + ]), 465 + ], 466 + ), 467 + ]), 468 + ]) 469 + } 470 + 471 + pub fn timeline(model: Model) -> Element(Msg) { 472 + // Dissect the model 473 + let assert model_type.Model( 474 + page: model_type.HomeTimeline(timeline_name:, modal: _), 475 + cache:, 476 + .., 477 + ) = model 478 + let timeline_name = option.unwrap(timeline_name, "global") 479 + // case timeline_name { 480 + // Some(timeline_name) -> { 481 + let timeline_posts = dict.get(cache.cached_timelines, timeline_name) 482 + case timeline_posts { 483 + Ok(cached_timeline) -> { 484 + let post_ids: List(String) = get_all_posts(cached_timeline) 485 + let show_load_more = cached_timeline.has_more 486 + html.div([attribute.class("flex w-4/6 flex-col gap-4 items-start")], { 487 + case post_ids { 488 + [] -> [ 489 + html.div([attribute.class("justify-center p-4")], [ 490 + element.text("This timeline is empty! Make sure to fill it!"), 491 + ]), 492 + ] 493 + 494 + _ -> { 495 + let post_elements = 496 + list.map(post_ids, posts.element_from_id(model, _)) 497 + 498 + case show_load_more { 499 + True -> 500 + list.append(post_elements, [ 501 + html.div([attribute.class("flex justify-center p-4")], [ 502 + html.button( 503 + [ 504 + attribute.class("btn btn-primary font-menuitems"), 505 + event.on_click(model_type.LoadMorePosts(timeline_name)), 506 + ], 507 + [element.text("Load More Posts")], 508 + ), 509 + ]), 510 + ]) 511 + False -> post_elements 512 + } 513 + } 514 + } 515 + }) 516 + } 517 + Error(..) -> 518 + html.div([attribute.class("flex w-4/6 flex-col gap-4 items-start")], [ 519 + element.text("Loading timeline \"" <> timeline_name <> "\" ..."), 520 + html.div([attribute.class("skeleton h-32 w-full")], []), 521 + html.div([attribute.class("skeleton h-4 w-28")], []), 522 + html.div([attribute.class("skeleton h-4 w-full")], []), 523 + html.div([attribute.class("skeleton h-32 w-full")], []), 524 + html.div([attribute.class("skeleton h-4 w-28")], []), 525 + html.div([attribute.class("skeleton h-4 w-full")], []), 526 + html.div([attribute.class("skeleton h-4 w-full")], []), 527 + html.div([attribute.class("skeleton h-32 w-full")], []), 528 + html.div([attribute.class("skeleton h-4 w-28")], []), 529 + html.div([attribute.class("skeleton h-4 w-full")], []), 530 + html.div([attribute.class("skeleton h-32 w-full")], []), 531 + html.div([attribute.class("skeleton h-4 w-28")], []), 532 + html.div([attribute.class("skeleton h-4 w-full")], []), 533 + element.text( 534 + "Skeleton should be remodeled after the actual post view later.", 535 + ), 536 + ]) 537 + } 538 + // } 539 + // None -> 540 + // html.div([attribute.class("")], [ 541 + // html.div([attribute.class("justify-center p-4")], [ 542 + // element.text("Still, I've to put something on here innit?"), 543 + // ]), 544 + // ]) 545 + // } 546 + } 547 + 548 + /// Get all post IDs from a cached timeline in order (page 0, page 1, etc.) 549 + pub fn get_all_posts(timeline: CachedTimeline) -> List(String) { 550 + timeline.pages 551 + |> dict.to_list 552 + |> list.sort(fn(a, b) { 553 + let #(page_a, _) = a 554 + let #(page_b, _) = b 555 + case page_a < page_b { 556 + True -> order.Lt 557 + False -> 558 + case page_a == page_b { 559 + True -> order.Eq 560 + False -> order.Gt 561 + } 562 + } 563 + }) 564 + |> list.map(fn(x) { 565 + let #(_, posts) = x 566 + posts 567 + }) 568 + |> list.flatten 569 + } 570 + 571 + /// Get posts for a specific page 572 + pub fn get_page_posts( 573 + timeline: CachedTimeline, 574 + page: Int, 575 + ) -> Option(List(String)) { 576 + case { timeline.pages |> dict.get(page) } { 577 + Ok(c) -> option.Some(c) 578 + _ -> option.None 579 + } 580 + } 581 + 582 + /// Check if a specific page is cached 583 + pub fn has_page_cached(timeline: CachedTimeline, page: Int) -> Bool { 584 + case timeline.pages |> dict.get(page) { 585 + Ok(_) -> True 586 + Error(_) -> False 587 + } 588 + } 589 + 590 + /// Get the highest cached page number 591 + pub fn get_highest_cached_page(timeline: CachedTimeline) -> Int { 592 + timeline.pages 593 + |> dict.keys 594 + |> list.fold(0, fn(max, page) { 595 + case page > max { 596 + True -> page 597 + False -> max 598 + } 599 + }) 600 + } 601 + 602 + /// Calculate total number of cached posts 603 + pub fn get_cached_posts_count(timeline: CachedTimeline) -> Int { 604 + timeline.pages 605 + |> dict.values 606 + |> list.map(list.length) 607 + |> list.fold(0, fn(acc, count) { acc + count }) 608 + } 609 + 610 + /// Check if we need to load more pages for a given position 611 + /// Returns True if the position is near the end of cached content 612 + pub fn should_load_more( 613 + timeline: CachedTimeline, 614 + position: Int, 615 + lookahead: Int, 616 + ) -> Bool { 617 + let cached_count = get_cached_posts_count(timeline) 618 + let needs_more = position + lookahead >= cached_count 619 + needs_more && timeline.has_more 620 + } 621 + 622 + /// Create a new empty cached timeline 623 + pub fn create_empty_timeline() -> CachedTimeline { 624 + CachedTimeline( 625 + pages: dict.new(), 626 + id: "", 627 + total_count: 0, 628 + current_page: 0, 629 + has_more: False, 630 + last_updated: 0, 631 + ) 632 + } 633 + 634 + /// Add a page of posts to a timeline cache 635 + pub fn add_page_to_timeline( 636 + to_timeline timeline: CachedTimeline, 637 + timeline_id tlid: String, 638 + page page: Int, 639 + items posts: List(String), 640 + count total_count: Int, 641 + has_more has_more: Bool, 642 + ) -> CachedTimeline { 643 + CachedTimeline( 644 + pages: timeline.pages |> dict.insert(page, posts), 645 + id: tlid, 646 + total_count: total_count, 647 + current_page: page, 648 + has_more: has_more, 649 + last_updated: float.truncate( 650 + timestamp.to_unix_seconds(timestamp.system_time()), 651 + ), 652 + ) 653 + } 654 + 655 + /// Clear all cached pages (useful for timeline refresh) 656 + pub fn clear_timeline_cache(old: CachedTimeline) -> CachedTimeline { 657 + CachedTimeline( 658 + pages: dict.new(), 659 + id: old.id, 660 + total_count: 0, 661 + current_page: 0, 662 + has_more: False, 663 + last_updated: 0, 664 + ) 665 + } 666 + 667 + /// Get the next page number that should be loaded 668 + pub fn get_next_page_to_load(timeline: CachedTimeline) -> Option(Int) { 669 + case timeline.has_more { 670 + False -> None 671 + True -> { 672 + let highest_page = get_highest_cached_page(timeline) 673 + Some(highest_page + 1) 674 + } 675 + } 676 + } 677 + 678 + /// Check if timeline is empty (no pages cached) 679 + pub fn is_timeline_empty(timeline: CachedTimeline) -> Bool { 680 + dict.size(timeline.pages) == 0 681 + } 682 + 683 + /// Get pagination info as a readable string (for debugging/logging) 684 + pub fn timeline_info_string( 685 + timeline: CachedTimeline, 686 + timeline_name: String, 687 + ) -> String { 688 + let cached_count = get_cached_posts_count(timeline) 689 + let highest_page = get_highest_cached_page(timeline) 690 + 691 + "Timeline '" 692 + <> timeline_name 693 + <> "': " 694 + <> int.to_string(cached_count) 695 + <> "/" 696 + <> int.to_string(timeline.total_count) 697 + <> " posts cached, pages 0-" 698 + <> int.to_string(highest_page) 699 + <> ", has_more: " 700 + <> bool.to_string(timeline.has_more) 701 + } 702 + 703 + /// Merge two timeline caches (useful when updating with new data) 704 + pub fn merge_timelines( 705 + old: CachedTimeline, 706 + new: CachedTimeline, 707 + ) -> CachedTimeline { 708 + // Merge pages, preferring new data for conflicts 709 + let merged_pages = 710 + dict.fold(new.pages, old.pages, fn(acc, page, posts) { 711 + dict.insert(acc, page, posts) 712 + }) 713 + 714 + CachedTimeline( 715 + pages: merged_pages, 716 + id: new.id, 717 + total_count: new.total_count, 718 + // Use new total count 719 + current_page: new.current_page, 720 + has_more: new.has_more, 721 + last_updated: new.last_updated, 722 + ) 723 + } 724 + 725 + type ModalSide { 726 + Right 727 + Left 728 + // Bottom 729 + // Top 730 + } 731 + 732 + type ModalWithShape { 733 + /// Central takes up most of the screen space, and is used for things like a settings screen. 734 + CentralBig(Element(Msg)) 735 + /// Takes up less of the screen space, and is used for things like a 'write a post' editor. On wide screens it can be moved around (following Lumina-peonies pre-25 design concepts.) 736 + /// On wide screens it also shows an empty title bar (draggable) containing a close button. This button will always be shown but can be disabled. 737 + CentralSmall( 738 + /// Just the #id. 739 + id: String, 740 + /// Title on the modal. 741 + title: String, 742 + /// Content of the modal, this one makes sense. 743 + containing: Element(Msg), 744 + /// Let the title bar [x] close this modal. 745 + closeable: Bool, 746 + /// Additional parameters, for example position. 747 + params: dict.Dict(String, String), 748 + ) 749 + /// Side or central takes up a little less screen space, looks roughly the same as Central(Big) on mobile screens but tries to out-center itself if possible. 750 + /// Used for for example the user menu. 751 + SideOrCentral(ModalSide, Element(Msg)) 752 + NoModal 753 + } 754 + 755 + // TODO: Think about different VARIANTS of modals, like for the user menu a right-side one for example. 756 + fn modal_by_id( 757 + f: #(String, dict.Dict(String, String)), 758 + model: Model, 759 + ) -> ModalWithShape { 760 + let #(id, params) = f 761 + let assert model_type.Model( 762 + page: model_type.HomeTimeline(timeline_name: _, modal: _), 763 + user: Some(user), 764 + .., 765 + ): Model = model 766 + case id { 767 + "test" -> 768 + CentralBig( 769 + html.div([], [ 770 + element.text("Welcome to Lumina! This is a test modal screen."), 771 + ]), 772 + ) 773 + "selfmenu" -> 774 + SideOrCentral( 775 + Right, 776 + html.ul( 777 + [ 778 + attribute.class( 779 + "menu menu-xl rounded-box w-2/3 justify-center text-center items-center space-y-4", 780 + ), 781 + ], 782 + [ 783 + html.li([attribute.class("menu-title")], [ 784 + element.text("Hi, @" <> user.username), 785 + ]), 786 + html.li([], [ 787 + element.text("There's not much in this menu as of yet."), 788 + ]), 789 + html.li([attribute.class("md:hidden")], [ 790 + html.a( 791 + [ 792 + attribute.class("btn btn-info font-menuitems"), 793 + event.on_click(SetModal("selfsettings")), 794 + ], 795 + [ 796 + element.text("Settings"), 797 + ], 798 + ), 799 + ]), 800 + html.li([], [ 801 + html.a( 802 + [ 803 + attribute.class("btn btn-warn font-menuitems"), 804 + event.on_click(UserClickedLogout), 805 + ], 806 + [ 807 + element.text("Log out"), 808 + ], 809 + ), 810 + ]), 811 + ], 812 + ), 813 + ) 814 + "selfsettings" -> 815 + CentralBig( 816 + html.div([], [ 817 + element.text("User settings will be here eventually."), 818 + ]), 819 + ) 820 + "mdl-postedit" -> 821 + CentralSmall( 822 + "mdl-postedit", 823 + "New Post", 824 + post_editor.main(params, model), 825 + True, 826 + params:, 827 + ) 828 + 829 + _ -> NoModal 830 + } 831 + }
+94
backend/impl-rs/client/src/lumina_client/view/homepage/post_editor.gleam
··· 1 + //// Lumina > Client > View > Application/Homepage > Post Editor 2 + //// This module contains the post editor. 3 + 4 + // Lumina/Peonies 5 + // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 + // 7 + // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 + // You may not use this work except in compliance with the Licence. 9 + // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 + // 11 + // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 + // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 + // See LICENSE file in the repository root for full details. 14 + // 15 + // 16 + // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 + // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 + 19 + import gleam/dict 20 + import lumina_client/model_type.{type Msg} 21 + import lumina_client/view/common_view_parts/svgs 22 + import lustre/attribute 23 + import lustre/element.{type Element} 24 + import lustre/element/html 25 + 26 + /// Post editor's exposed view function. 27 + /// Parameters: 28 + /// params - dict of String to String, these are params specific to the post editor modal, and also exist in the wider model, beit behind a wrapped option. 29 + /// model - the full application model, in case the post editor needs to read from it 30 + pub fn main( 31 + params: dict.Dict(String, String), 32 + model: model_type.Model, 33 + ) -> Element(Msg) { 34 + // Placeholder implementation 35 + html.div([attribute.class("tabs tabs-lift h-full")], [ 36 + html.label([attribute.class("tab")], [ 37 + html.input([attribute.name("editortypeswitch"), attribute.type_("radio")]), 38 + svgs.camera("class size-4 me-2"), 39 + 40 + html.text(" Snap "), 41 + ]), 42 + html.label([attribute.class("tab")], [ 43 + html.input([ 44 + attribute.name("editortypeswitch"), 45 + attribute.type_("radio"), 46 + attribute.checked(True), 47 + ]), 48 + svgs.pen("class size-4 me-2"), 49 + html.text(" Jot "), 50 + ]), 51 + html.div([attribute.class("tab-content bg-base-100 border-base-300 p-6")], [ 52 + text_post_editor(params, model), 53 + ]), 54 + html.div([attribute.class("tab-content bg-base-100 border-base-300 p-6")], [ 55 + media_post_editor(params, model), 56 + ]), 57 + html.label([attribute.class("tab")], [ 58 + html.input([attribute.name("editortypeswitch"), attribute.type_("radio")]), 59 + svgs.pen_paper("class size-4 me-2"), 60 + 61 + html.text(" Compose "), 62 + ]), 63 + html.div([attribute.class("tab-content bg-base-100 border-base-300 p-6")], [ 64 + article_post_editor(params, model), 65 + ]), 66 + ]) 67 + } 68 + 69 + fn text_post_editor( 70 + params: dict.Dict(String, String), 71 + _model: model_type.Model, 72 + ) -> Element(Msg) { 73 + html.div([], [ 74 + html.text("This is the text post editor!"), 75 + ]) 76 + } 77 + 78 + fn media_post_editor( 79 + params: dict.Dict(String, String), 80 + _model: model_type.Model, 81 + ) -> Element(Msg) { 82 + html.div([], [ 83 + html.text("This is the media post editor!"), 84 + ]) 85 + } 86 + 87 + fn article_post_editor( 88 + params: dict.Dict(String, String), 89 + _model: model_type.Model, 90 + ) -> Element(Msg) { 91 + html.div([], [ 92 + html.text("This is the article post editor!"), 93 + ]) 94 + }
+60
backend/impl-rs/client/src/lumina_client/view/homepage/posts.gleam
··· 1 + //// Lumina > Client > View > Application/Homepage > Posts 2 + //// This module contains the homepage timeline posts view as well as handling the rendering of posts on their own. 3 + 4 + // Lumina/Peonies 5 + // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 + // 7 + // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 + // You may not use this work except in compliance with the Licence. 9 + // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 + // 11 + // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 + // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 + // See LICENSE file in the repository root for full details. 14 + // 15 + // 16 + // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 + // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 + 19 + import gleam/dict 20 + import gleam/list 21 + import lumina_client/model_type.{ 22 + type CachedTimeline, type Model, type Msg, CachedTimeline, 23 + } 24 + import lustre/attribute.{attribute} 25 + import lustre/element.{type Element} 26 + import lustre/element/html 27 + 28 + pub fn element_from_id(model: Model, post_id: String) -> Element(Msg) { 29 + let post = dict.get(model.cache.cached_posts, post_id) 30 + 31 + html.div( 32 + [ 33 + attribute.class( 34 + "flex flex-col gap-2 p-4 m-8 bg-base-300 text-base-300-content rounded-md w-full bg-opacity-25 font-content", 35 + // Other candidates were: 36 + // // "flex flex-col gap-2 p-4 m-8 bg-secondary text-secondary-content rounded-md w-full", 37 + // // "flex flex-col gap-2 p-4 m-8 bg-info text-info-content rounded-md w-full bg-opacity-25", 38 + ), 39 + ], 40 + case post { 41 + Ok(_) -> todo as "Post rendering not yet implemented" 42 + _ -> [ 43 + html.p([], [ 44 + element.text("Loading post..."), 45 + html.span( 46 + [ 47 + attribute.class("loading loading-spinner loading-md float-right"), 48 + ], 49 + [], 50 + ), 51 + ]), 52 + ] 53 + } 54 + |> list.append([ 55 + html.small([attribute.class("opacity-50 text-xs font-script")], [ 56 + element.text("ID:" <> post_id), 57 + ]), 58 + ]), 59 + ) 60 + }
+12
backend/impl-rs/client/test/lumina_client_test.gleam
··· 1 + import gleeunit 2 + import gleeunit/should 3 + 4 + pub fn main() { 5 + gleeunit.main() 6 + } 7 + 8 + // gleeunit test functions end in `_test` 9 + pub fn hello_world_test() { 10 + 1 11 + |> should.equal(1) 12 + }
+42
backend/impl-rs/env.Dockerfile
··· 1 + # syntax=docker/dockerfile:1 2 + 3 + FROM alpine:3.19 4 + 5 + ENV MISE_DATA_DIR="/mise" 6 + ENV MISE_CONFIG_DIR="/mise" 7 + ENV MISE_CACHE_DIR="/mise/cache" 8 + ENV MISE_INSTALL_PATH="/usr/local/bin/mise" 9 + ENV BUN_INSTALL="/usr/local/bin/bun" 10 + ENV PATH="/usr/local/bin/bun/bin:/mise/shims:$PATH" 11 + 12 + RUN apk add --no-cache curl git unzip build-base bash 13 + 14 + # Install bun outside of mise because Alpine uses musl libc which the mise bun package does not support 15 + RUN curl -fsSL https://bun.sh/install | bash 16 + RUN curl https://mise.run | sh 17 + 18 + WORKDIR /build 19 + # Copy and install the mise.toml file first to leverage Docker cache 20 + COPY mise.toml ./mise.toml 21 + COPY mise/ ./mise/ 22 + RUN mise trust && mise unuse bun && mise install 23 + 24 + # ------- Prefetch Rust dependencies ------- 25 + 26 + # Copy the manifests cargo needs to prefetch dependencies 27 + COPY Cargo.toml Cargo.lock ./ 28 + RUN mkdir server 29 + COPY server/Cargo.toml ./server/ 30 + # Prefetch dependencies 31 + RUN cargo fetch --locked 32 + 33 + # ------- Prefetch Bun dependencies ------- 34 + 35 + RUN mkdir client 36 + COPY client/package.json client/bun.lock ./client/ 37 + RUN mise run bun-install --locked 38 + 39 + # ------- Prefetch Gleam dependencies ------- 40 + 41 + COPY client/gleam.toml client/manifest.toml ./client/ 42 + RUN mise run prefetch-gleam-deps
+100
backend/impl-rs/flake.lock
··· 1 + { 2 + "nodes": { 3 + "fenix": { 4 + "inputs": { 5 + "nixpkgs": [ 6 + "nixpkgs" 7 + ], 8 + "rust-analyzer-src": "rust-analyzer-src" 9 + }, 10 + "locked": { 11 + "lastModified": 1773818109, 12 + "narHash": "sha256-Wsk92HrZODmCgBb+v7XfTMUAIEhqU+Obwj+09IKRTpU=", 13 + "owner": "nix-community", 14 + "repo": "fenix", 15 + "rev": "b8b443c5a1bd8dd99df899b4ac786a7f410193e5", 16 + "type": "github" 17 + }, 18 + "original": { 19 + "owner": "nix-community", 20 + "repo": "fenix", 21 + "type": "github" 22 + } 23 + }, 24 + "nixpkgs": { 25 + "locked": { 26 + "lastModified": 1773734432, 27 + "narHash": "sha256-IF5ppUWh6gHGHYDbtVUyhwy/i7D261P7fWD1bPefOsw=", 28 + "owner": "NixOS", 29 + "repo": "nixpkgs", 30 + "rev": "cda48547b432e8d3b18b4180ba07473762ec8558", 31 + "type": "github" 32 + }, 33 + "original": { 34 + "owner": "NixOS", 35 + "ref": "nixos-unstable", 36 + "repo": "nixpkgs", 37 + "type": "github" 38 + } 39 + }, 40 + "root": { 41 + "inputs": { 42 + "fenix": "fenix", 43 + "nixpkgs": "nixpkgs", 44 + "utils": "utils" 45 + } 46 + }, 47 + "rust-analyzer-src": { 48 + "flake": false, 49 + "locked": { 50 + "lastModified": 1773775226, 51 + "narHash": "sha256-413aE+fhubk1GA2v4IlRrpdZZzW/b89wJGuDfZCVtEs=", 52 + "owner": "rust-lang", 53 + "repo": "rust-analyzer", 54 + "rev": "4eac290b58a70961e78f2e0c04f61a08b995b2cb", 55 + "type": "github" 56 + }, 57 + "original": { 58 + "owner": "rust-lang", 59 + "ref": "nightly", 60 + "repo": "rust-analyzer", 61 + "type": "github" 62 + } 63 + }, 64 + "systems": { 65 + "locked": { 66 + "lastModified": 1681028828, 67 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 68 + "owner": "nix-systems", 69 + "repo": "default", 70 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 71 + "type": "github" 72 + }, 73 + "original": { 74 + "owner": "nix-systems", 75 + "repo": "default", 76 + "type": "github" 77 + } 78 + }, 79 + "utils": { 80 + "inputs": { 81 + "systems": "systems" 82 + }, 83 + "locked": { 84 + "lastModified": 1731533236, 85 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 86 + "owner": "numtide", 87 + "repo": "flake-utils", 88 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 89 + "type": "github" 90 + }, 91 + "original": { 92 + "owner": "numtide", 93 + "repo": "flake-utils", 94 + "type": "github" 95 + } 96 + } 97 + }, 98 + "root": "root", 99 + "version": 7 100 + }
+79
backend/impl-rs/flake.nix
··· 1 + { 2 + description = "Lumina Development Environment"; 3 + inputs = { 4 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 5 + utils.url = "github:numtide/flake-utils"; 6 + fenix = { 7 + url = "github:nix-community/fenix"; 8 + inputs.nixpkgs.follows = "nixpkgs"; 9 + }; 10 + }; 11 + 12 + outputs = 13 + { 14 + nixpkgs, 15 + utils, 16 + fenix, 17 + ... 18 + }: 19 + utils.lib.eachDefaultSystem ( 20 + system: 21 + let 22 + pkgs = import nixpkgs { inherit system; }; 23 + rustToolchain = fenix.packages.${system}.stable.withComponents [ 24 + "cargo" 25 + "rustc" 26 + "rustfmt" 27 + "clippy" 28 + "rust-analyzer" 29 + "rust-src" 30 + ]; 31 + # Define libraries in one place to avoid repetition 32 + libraries = with pkgs; [ 33 + stdenv.cc.cc 34 + glib 35 + dbus 36 + curl 37 + openssl 38 + ]; 39 + 40 + packages = with pkgs; [ 41 + 42 + # Language tool chains: Rust, Gleam 43 + rustToolchain 44 + gleam 45 + bun 46 + # For tidying and typing 47 + # nodePackages.prettier 48 + sqlx-cli 49 + 50 + # Pkg config 51 + pkg-config-unwrapped 52 + 53 + # Podman 54 + podman 55 + 56 + # Runners 57 + watchexec 58 + just 59 + ]; 60 + in 61 + { 62 + devShells.default = pkgs.mkShell { 63 + # Tools go here 64 + nativeBuildInputs = [ pkgs.pkg-config ]; 65 + 66 + # Libraries go here 67 + buildInputs = packages ++ libraries; 68 + 69 + shellHook = '' 70 + export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH" 71 + 72 + bun install --cwd=client/ --silent --only-missing 73 + echo "❄️ dev environment loaded, use 'just dev' next, or use either 'just --list' or 'mise tasks' for recipies." 74 + ''; 75 + }; 76 + } 77 + ); 78 + 79 + }
+8
backend/impl-rs/metadata.json
··· 1 + { 2 + "version": "1.0", 3 + "attribution": "MLC Strawmelonjuice Bloeiman", 4 + "tdm_reservation": true, 5 + "ai_training_allowed": false, 6 + "license": "EUPL-1.2", 7 + "derivative_work_claim": "AI models trained on this source are considered derivative works." 8 + }
+78
backend/impl-rs/migrations/0001_luminadb.sql
··· 1 + -- Create logs table 2 + CREATE TABLE IF NOT EXISTS logs 3 + ( 4 + type VARCHAR NOT NULL, 5 + message TEXT NOT NULL, 6 + timestamp TIMESTAMP NOT NULL 7 + ); 8 + 9 + -- Create users table 10 + CREATE TABLE IF NOT EXISTS users 11 + ( 12 + id UUID DEFAULT gen_random_uuid() UNIQUE PRIMARY KEY, 13 + foreign_instance_id VARCHAR, 14 + foreign_user_id UUID, 15 + email VARCHAR NOT NULL UNIQUE, 16 + username VARCHAR NOT NULL UNIQUE, 17 + password VARCHAR NOT NULL 18 + ); 19 + 20 + -- Create timelines table 21 + CREATE TABLE IF NOT EXISTS timelines 22 + ( 23 + tlid UUID NOT NULL, 24 + item_id UUID NOT NULL, 25 + timestamp TIMESTAMP WITH TIME ZONE NOT NULL, 26 + PRIMARY KEY (tlid, item_id) 27 + ); 28 + 29 + -- Create item type lookup table 30 + CREATE TABLE IF NOT EXISTS itemtypelookupdb 31 + ( 32 + itemtype VARCHAR NOT NULL, 33 + item_id UUID NOT NULL PRIMARY KEY 34 + ); 35 + 36 + -- Create sesions table 37 + CREATE TABLE IF NOT EXISTS sessions 38 + ( 39 + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, 40 + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, 41 + session_key VARCHAR NOT NULL UNIQUE, 42 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP 43 + ); 44 + 45 + -- Create table for posts in text type 46 + CREATE TABLE IF NOT EXISTS post_text 47 + ( 48 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 49 + author_id UUID REFERENCES users (id), 50 + content TEXT NOT NULL, 51 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, 52 + foreign_instance_id VARCHAR, 53 + foreign_post_id VARCHAR 54 + ); 55 + 56 + -- Create table for posts of media type 57 + CREATE TABLE IF NOT EXISTS post_media 58 + ( 59 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 60 + author_id UUID REFERENCES users (id), 61 + minio_object_id VARCHAR NOT NULL, 62 + caption TEXT, 63 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, 64 + foreign_instance_id VARCHAR, 65 + foreign_post_id VARCHAR 66 + ); 67 + 68 + -- Create table for posts of article type 69 + CREATE TABLE IF NOT EXISTS post_article 70 + ( 71 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 72 + author_id UUID REFERENCES users (id), 73 + title VARCHAR NOT NULL, 74 + content TEXT NOT NULL, 75 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, 76 + foreign_instance_id VARCHAR, 77 + foreign_post_id VARCHAR 78 + );
+1
backend/impl-rs/migrations/0002_Rename_'itemtypelookupdb'_to_'itemtypes'.sql
··· 1 + ALTER TABLE itemtypelookupdb RENAME TO itemtypes;
+24
backend/impl-rs/mise.toml
··· 1 + [tools] 2 + bun = "1.2.20" 3 + "cargo:sqlx-cli" = "latest" 4 + gleam = "1.15.0" 5 + just = "latest" 6 + "rust" = { version = "1.94.0", components = "rust-analyzer" } 7 + watchexec = "latest" 8 + 9 + 10 + [settings] 11 + [settings.cargo] 12 + binstall = false # This fails in some cases, watchexec can be compiled from source quickly enough 13 + [settings.npm] 14 + bun = true 15 + 16 + [task_config] 17 + includes = [ 18 + "./mise/tasks/podman.toml", 19 + "./mise/tasks/format.toml", 20 + "./mise/tasks/check.toml", 21 + "./mise/tasks/run.toml", 22 + "./mise/tasks/moved-to-just.toml", 23 + ] 24 + dir = "{{ config_root }}"
+47
backend/impl-rs/mise/tasks/check.toml
··· 1 + # Check/lint/test-related tasks for Lumina 2 + [check] 3 + description = "Run the check command on both server and client." 4 + run = { tasks = ["check-server", "check-client"] } 5 + 6 + [check-server] 7 + depends = ["build-client"] 8 + hide = true 9 + run = "cargo check" 10 + dir = "./server" 11 + 12 + [check-client] 13 + hide = true 14 + run = "gleam check" 15 + dir = "./client/" 16 + 17 + [check-watch] 18 + tools.watchexec = "latest" 19 + description = "Run the server in development mode with file watching" 20 + run = "mise watch --restart -e rs,gleam,toml,css,ts,json --clear=clear check" 21 + 22 + [clippy_watch] 23 + tools.watchexec = "latest" 24 + run = "watchexec -c clear -restart -e rs,toml 'clear ; cargo clippy'" 25 + 26 + 27 + [local-devel-test] 28 + alias = "test" 29 + description = "Run the check command on both server and client." 30 + run = { tasks = ["check-server", "check-client"] } 31 + 32 + [local-devel-test-server] 33 + depends = ["build-client"] 34 + hide = true 35 + run = "cargo test" 36 + dir = "./server" 37 + env.LUMINA_POSTGRES_PASSWORD = "lumina_pw" 38 + 39 + [local-devel-test-client] 40 + hide = true 41 + run = "gleam test" 42 + dir = "./client/" 43 + 44 + [local-devel-test-watch] 45 + tools.watchexec = "latest" 46 + description = "Run the server in development mode with file watching" 47 + run = "mise watch --restart -e rs,gleam,toml,css,ts,json --clear=clear test"
+20
backend/impl-rs/mise/tasks/format.toml
··· 1 + # Formatting-related tasks for Lumina 2 + [format] 3 + description = "Format the codebase" 4 + depends = ["format-server", "format-client", "format-metafiles"] 5 + run = [] 6 + 7 + [format-server] 8 + hide = true 9 + run = "cargo fmt" 10 + dir = "./server" 11 + 12 + [format-client] 13 + hide = true 14 + run = "gleam format" 15 + dir = "./client" 16 + 17 + [format-metafiles] 18 + hide = true 19 + run = ["bun x prettier . '!./data' '!./notes' --write", "taplo format"] 20 + tools.taplo = "latest"
+60
backend/impl-rs/mise/tasks/moved-to-just.toml
··· 1 + # Moved to just, kept here to preserve backwards compatibility. 2 + 3 + [build-styles] 4 + hide = true 5 + description = "$just build-styles" 6 + run = "just build-styles" 7 + 8 + [build-server] 9 + hide = true 10 + description = "$just build-server" 11 + run = "just build-server" 12 + 13 + [build-client] 14 + hide = true 15 + description = "$just build-client" 16 + run = "just build-client" 17 + 18 + [build-server-release] 19 + hide = true 20 + description = "$just build-server-release" 21 + run = "just build-server-release" 22 + 23 + [clean-all] 24 + hide = true 25 + description = "$just clean-all" 26 + run = "just clean-all" 27 + 28 + [prefetch-gleam-deps] 29 + hide = true 30 + description = "$just prefetch-gleam-deps" 31 + run = "prefetch-gleam-deps" 32 + 33 + [create-data-dirs] 34 + hide = true 35 + run = "just create-data-dirs" 36 + 37 + [bun-install] 38 + hide = true 39 + description = "$just bun-install" 40 + run = "just bun-install" 41 + 42 + [local-devel-prep] 43 + hide = true 44 + description = "$just local-devel-prep" 45 + run = "just local-devel-prep" 46 + 47 + [local-devel] 48 + hide = true 49 + description = "$just local-devel" 50 + run = "just local-devel" 51 + 52 + [local-devel-watch] 53 + hide = true 54 + description = "$just local-devel-watch" 55 + run = "just local-devel-watch" 56 + 57 + [local-devel-dataexplorer] 58 + hide = true 59 + description = "$just local-devel-dataexplorer" 60 + run = "just local-devel-dataexplorer"
+62
backend/impl-rs/mise/tasks/podman.toml
··· 1 + # Podman-related tasks for Lumina 2 + # == Pod management =================================================================== 3 + [pod-stop-all] 4 + description = "Stop and remove all Lumina pods and containers" 5 + run = [ 6 + "podman pod stop lumina-postgres-pod || echo ok", 7 + "podman pod rm lumina-postgres-pod || echo ok", 8 + "podman container rm lumina-server-postgres luminadb || echo ok", 9 + ] 10 + 11 + # ===================================================================================== 12 + # == with postgres ==================================================================== 13 + [devel] 14 + description = "Run Lumina with PostgreSQL in a pod, development build (no optimisations)" 15 + depends = "create-data-dirs" 16 + run = [ 17 + # Clean up any existing pods/containers first 18 + "podman pod stop lumina-postgres-pod || echo ok", 19 + "podman pod rm lumina-postgres-pod || echo ok", 20 + "podman container rm lumina-server-postgres luminadb lumina-redis || echo ok", 21 + # Create the pod 22 + "podman pod create --name lumina-postgres-pod -p 8085:8085 -p 5432:5432 -p 8081:8081 -p 8082:8082", 23 + # Pre-run the redis commander and pgweb containers so they are available immediately 24 + "podman run -d --replace --name lumina-redis-commander --pod lumina-postgres-pod -e REDIS_HOSTS=lumina-redis -e PORT=8082 ghcr.io/joeferner/redis-commander:latest", 25 + "podman run -d --replace --name lumina-pgweb --pod lumina-postgres-pod -e DATABASE_URL=postgres://lumina:lumina_pw@luminadb:5432/lumina_config?sslmode=disable sosedoff/pgweb:latest", 26 + # Build and run 27 + "podman run -d --name luminadb --pod lumina-postgres-pod -e POSTGRES_USER=lumina -e POSTGRES_PASSWORD=lumina_pw -e POSTGRES_DB=lumina_config -v ./data/postgres:/var/lib/postgresql/data:Z docker.io/library/postgres:17-alpine3.22", 28 + "podman run -d --replace --name lumina-redis --pod lumina-postgres-pod -v ./data/redis:/data:Z redis", 29 + "podman build -f build.Dockerfile -t lumina-server:dev .", 30 + "echo pgweb on http://127.0.0.1:8081, Redis Commander on http://127.0.0.1:8082", 31 + "podman run --name lumina-server-postgres --pod lumina-postgres-pod -e LUMINA_DB_TYPE=postgres -e LUMINA_POSTGRES_HOST=localhost -e LUMINA_POSTGRES_PORT=5432 -e LUMINA_POSTGRES_USERNAME=lumina -e LUMINA_POSTGRES_PASSWORD=lumina_pw -e LUMINA_POSTGRES_DATABASE=lumina_config -e LUMINA_SERVER_PORT=8085 -e LUMINA_SERVER_ADDR=0.0.0.0 lumina-server:dev", 32 + ] 33 + 34 + 35 + [pod-up] 36 + description = "Run Lumina with PostgreSQL in a pod [optimised build]" 37 + env = { LUMINA_DOCKER_OPTIMIZE_BUILD = "true" } 38 + depends = "create-data-dirs" 39 + run = [ 40 + # Clean up any existing pods/containers first 41 + "podman pod stop lumina-postgres-pod || echo ok", 42 + "podman pod rm lumina-postgres-pod || echo ok", 43 + "podman container rm lumina-server-postgres luminadb lumina-redis || echo ok", 44 + # Build and run 45 + "podman build --build-arg optimize_build=true -t lumina-server:latest .", 46 + "podman pod create --name lumina-postgres-pod -p 8085:8085 -p 5432:5432", 47 + "podman run -d --replace --name lumina-redis --pod lumina-postgres-pod -v ./data/redis:/data redis", 48 + "podman run -d --name luminadb --pod lumina-postgres-pod -e POSTGRES_USER=lumina -e POSTGRES_PASSWORD=lumina_pw -e POSTGRES_DB=lumina_config -v ./data/postgres:/var/lib/postgresql/data:Z docker.io/library/postgres:17-alpine3.22", 49 + "podman run --name lumina-server-postgres --pod lumina-postgres-pod -e LUMINA_DB_TYPE=postgres -e LUMINA_POSTGRES_HOST=localhost -e LUMINA_POSTGRES_PORT=5432 -e LUMINA_POSTGRES_USERNAME=lumina -e LUMINA_POSTGRES_PASSWORD=lumina_pw -e LUMINA_POSTGRES_DATABASE=lumina_config -e LUMINA_SERVER_PORT=8085 -e LUMINA_SERVER_ADDR=0.0.0.0 lumina-server:latest", 50 + ] 51 + 52 + # ==================================================================================== 53 + # == Build environment image ========================================================= 54 + [build-env-image] 55 + description = "Build and tag the lumina-build-environment image from env.Dockerfile" 56 + run = ["podman build -f env.Dockerfile -t lumina-build-environment ."] 57 + 58 + [devel-watch] 59 + tools.watchexec = "latest" 60 + description = "Run the server in development mode with file watching" 61 + run = "mise x -- watchexec --restart --stop-timeout=0 --shell=sh -e rs,gleam,toml,css,ts,json mise run devel" 62 + run_windows = "mise x -- watchexec --restart --stop-timeout=0 --shell=cmd -e rs,gleam,toml,css,ts,json mise run devel"
backend/impl-rs/mise/tasks/run.toml

This is a binary file and will not be displayed.

+3
backend/impl-rs/notes/.obsidian/backlink.json
··· 1 + { 2 + "backlinkInDocument": true 3 + }
+1
backend/impl-rs/notes/.obsidian/community-plugins.json
··· 1 + []
+33
backend/impl-rs/notes/.obsidian/core-plugins.json
··· 1 + { 2 + "file-explorer": true, 3 + "global-search": true, 4 + "switcher": true, 5 + "graph": true, 6 + "backlink": true, 7 + "canvas": true, 8 + "outgoing-link": true, 9 + "tag-pane": true, 10 + "footnotes": true, 11 + "properties": false, 12 + "page-preview": true, 13 + "daily-notes": true, 14 + "templates": true, 15 + "note-composer": true, 16 + "command-palette": true, 17 + "slash-command": false, 18 + "editor-status": true, 19 + "bookmarks": true, 20 + "markdown-importer": false, 21 + "zk-prefixer": true, 22 + "random-note": false, 23 + "outline": true, 24 + "word-count": true, 25 + "slides": false, 26 + "audio-recorder": false, 27 + "workspaces": false, 28 + "file-recovery": true, 29 + "publish": false, 30 + "sync": false, 31 + "bases": true, 32 + "webviewer": false 33 + }
+22
backend/impl-rs/notes/.obsidian/graph.json
··· 1 + { 2 + "collapse-filter": true, 3 + "search": "", 4 + "showTags": false, 5 + "showAttachments": false, 6 + "hideUnresolved": false, 7 + "showOrphans": true, 8 + "collapse-color-groups": true, 9 + "colorGroups": [], 10 + "collapse-display": true, 11 + "showArrow": false, 12 + "textFadeMultiplier": 0, 13 + "nodeSizeMultiplier": 1, 14 + "lineSizeMultiplier": 1, 15 + "collapse-forces": true, 16 + "centerStrength": 0.518713248970312, 17 + "repelStrength": 10, 18 + "linkStrength": 1, 19 + "linkDistance": 250, 20 + "scale": 1, 21 + "close": true 22 + }
+14
backend/impl-rs/notes/Design choices/Future concepts/Federation/IIC (Interinstance Communication).md
··· 1 + The server should a poll-inspired syncing system for Federating posts with other servers (instances). This is a [[High-level requirements#^4c6bf0|must]]. 2 + 3 + Having established this, a _how_ remains. In an earlier iteration of Lumina/Ephew (`Lumina:Peonies:itr1`), this how was conceptually answered by introducing HTTP requests fetching other instances' post IDs and then letting the client fetch the actual post content. This is a sound strategy in theory, however, you would possibly fetch posts from an instance impersonating the instance you talked to previously. 4 + To keep this from happening, a choice was made to only accept domain names as instance ID's, this choice is still present in the current iteration, however, DNS is not infallible. 5 + 6 + To solve that, some form of key checking has to be done. Either by sharing a secret token and having an instance store it, or... more sane, by `ed25519`? 7 + 8 + Furthermore, these relatively big HTTP requests would be rate-limited on the requesting side, on the serving side, request spammers would be autoremoved from the allowlist. 9 + 10 + That initially created the concept of WebSocket connections, preferably ones that stay open forever (which is a long time). However, more recently, the [polyproto](https://polyproto.org) has been on my mind. 11 + 12 + ### Polyproto 13 + 14 + I have to look into protocol-specific details later, however, each Lumina instance could also be a Polyproto 'homeserver', thereby allowing the instance to communicate to other instances using an instance user e.g., `iic@peonies.xyz`, AND as users on the instance, e.g. `user+comment@peonies.xyz`. Then federating timelines would of course go over the iic username, however things like comments or DM's, that'd require more direct federation, would be sent directly using JSON from `<username>+<reason>@<instance>` to the instance user of another instance. One of the pitfalls of the earlier conceptual implementation, was that due to the rate-limits of HTTP polling, there was at least 30 seconds of delay, this concept seems to resolve that fantastically.
+11
backend/impl-rs/notes/Design choices/Future concepts/Push notifications/push notification resolve links.md
··· 1 + I notice on other social media platforms that when an edit or upload has unforseen consequences, this usually results in 2 + broken push notifications. 3 + Notifications that lead to a 404. 4 + 5 + When creating a push notification for something we know nothing about, this is what it is. But in Lumina these 6 + notifications lead to a postview using a JSON string after the # in the url (url hash). 7 + 8 + That JSON string can just contain a post or notification id, but we generate a preview of a post, and that 9 + preview should be pushed but not saved once again in the database. Instead, we add to the JSON object. Client holds an 10 + absolute reference to the post id, but also requests a post by both id and a part of its preview. Lumina client should 11 + be smart enough to figure out what post this is even when the ID leads to a 404.
+69
backend/impl-rs/notes/Design choices/Philosophies/'Timeline carries most' and the database.md
··· 1 + Rationale: [[Backend > Timeline carries most]] 2 + # Lumina Data Storage Architecture 3 + 4 + This document outlines the data storage architecture of the Lumina social platform, based on the existing implementation 5 + and design principles. 6 + 7 + 8 + 9 + ## Core Philosophy: "Timeline-carries-most" 10 + 11 + Lumina's storage is designed around a central principle referred to as "Timeline-carries-most". The core idea is that 12 + the `timelines` table, which is expected to be the most frequently accessed table, should be as minimal and efficient as 13 + possible. 14 + 15 + - It acts as an index, containing only a timeline ID (`tlid`), an item ID (`item_id`), and a `timestamp`. 16 + - It does not store any content itself, only the relationship between a timeline and an item. 17 + 18 + 19 + ## Database System 20 + 21 + Lumina supports two SQL database backends: 22 + 23 + - **PostgreSQL**: The recommended database for production environments. 24 + - **SQLite**: Supported for testing and development purposes. 25 + 26 + The choice of a database is configured via the `LUMINA_DB_TYPE` environment variable. 27 + 28 + ## Item and Content Storage 29 + 30 + Content in Lumina is stored in a flexible, multi-table system that allows for various types of items to be added to 31 + timelines. 32 + 33 + ### 1. The Item Lookup Table (`itemtypelookupdb`) 34 + 35 + This table acts as a central directory or "forwarding table". Its purpose is to map a generic `item_id` to its specific 36 + content type. 37 + 38 + - It contains an `item_id` and an `itemtype` string. 39 + - The `itemtype` string directly corresponds to the name of the database table where the item's specific data is 40 + stored (e.g., `post_text`, `post_article`). 41 + 42 + ### 2. Specific Content Tables 43 + 44 + Each type of content has its own dedicated table. This design allows for adding new content types without altering the 45 + core timeline logic. The initial three content tables are: 46 + 47 + - `post_text`: For short, microblog-style text posts. 48 + - `post_media`: For media-focused posts (e.g., images, videos). The table stores a reference to a **MinIO object ID**, 49 + not the media file itself. 50 + - `post_article`: For long-form content with a title and body. 51 + 52 + For direct messages among others, there will be more variants of these. 53 + 54 + ### 3. Handling Foreign Content (Federation) 55 + 56 + The content tables are designed to accommodate posts from other federated instances. Each content table includes the 57 + following nullable fields: 58 + 59 + - `foreign_instance_id`: Stores the identifier of the instance where the post originated. 60 + - `foreign_post_id`: Stores the post's original ID from its home instance. 61 + 62 + This allows a local copy of the content to exist while preserving a reference to its original source. 63 + 64 + ## Caching Layer 65 + 66 + - **Redis** is used as a caching and performance-optimization layer. 67 + - It is used for timeline caching and for ephemeral data structures like Bloom filters to quickly check for the 68 + existence of usernames and emails. 69 + - Redis does not store any persistent, canonical data.
+1
backend/impl-rs/notes/Design choices/Philosophies/Frontend > Non-minimalist design.md
··· 1 + Lumina is not minimalistic. It follows web design trends from decades ago with that approach, but also prefers a clean and modern design. This is why coloured elements are very important, but it also allows us to implement features considered old-school nowadays. Like the ability
+57
backend/impl-rs/notes/Design choices/Rationale/Backend > Connection pooling and caching.md
··· 1 + ## Overview 2 + - PostgreSQL and Redis are pooled with `bb8` (see `server/src/database.rs`). 3 + - Redis is used only for performance: bloom filters and timeline caches; PostgreSQL remains source of truth. 4 + - Background maintainer (`database::maintain`) periodically deletes old sessions and prunes timeline caches. 5 + 6 + ## PostgreSQL pool (bb8-postgres) 7 + - Built from `tokio_postgres::Config` with `NoTls`. 8 + - Pools are cloned everywhere via `DatabaseConnections::get_postgres_pool` to keep acquisition cheap. 9 + - Avoid `unwrap()` on `.get()`; surface bb8 run errors via `LuminaError::Bb8RunErrorPg`. 10 + 11 + Example (simplified): 12 + ```rust 13 + let pg_pool = PgConn { postgres_pool, redis_pool }; 14 + let client = pg_pool.postgres_pool.get().await?; // ? maps to LuminaError::Bb8RunErrorPg 15 + ``` 16 + 17 + ## Redis pool (bb8-redis) 18 + - Configured pool builder (currently max_size 50, 5s timeout, 5m idle timeout). 19 + - Connection type is `MultiplexedConnection`; use `redis::cmd(...).query_async(&mut **conn)`. Pool errors surface as `LuminaError::Bb8RunErrorRedis`. 20 + 21 + ### Bloom filters 22 + - Keys: `bloom:email`, `bloom:username`. 23 + - Populated at startup from Postgres (`database::setup`). 24 + - Checked in `user::register_validitycheck` before DB uniqueness queries. 25 + 26 + Example add/check: 27 + ```rust 28 + let mut conn = redis_pool.get().await?; 29 + redis::cmd("BF.ADD").arg("bloom:email").arg(email).query_async(&mut *conn).await?; 30 + let exists: bool = redis::cmd("BF.EXISTS").arg("bloom:email").arg(email).query_async(&mut *conn).await?; 31 + ``` 32 + 33 + ### Timeline cache 34 + - Cache keys: `timeline_cache:{tlid}:page:{page}`; metadata key: `timeline_cache:{tlid}:meta`. 35 + - Cache TTL: 3600s; high-traffic threshold: 100 lookups (global always high-traffic). 36 + - Write path (`cache_timeline_page`) stores page JSON and total count; read path (`get_cached_timeline_page`) returns `CachedTimelinePage`. 37 + - Invalidation: `invalidate_timeline_cache` SCANs matching keys and DELs; called after timeline writes and from the maintainer loop when timelines change. 38 + - Background invalidation cursor uses `timeline_cache_last_check` stored in Redis. 39 + 40 + Example invalidate: 41 + ```rust 42 + let mut conn = redis_pool.get().await?; 43 + timeline::invalidate_timeline_cache(&mut conn, tlid).await?; 44 + ``` 45 + 46 + ## Background maintainer 47 + - `database::maintain` (spawned at setup) runs two intervals: 48 + - Every 60s: delete sessions older than 20 days. 49 + - Every 300s: prune expired timeline cache entries; check timeline invalidations based on latest timestamps. 50 + 51 + ## Tests 52 + - `src/tests.rs` covers: pool setup, bloom filter add/exists, timeline cache invalidation. 53 + 54 + ## Operational cautions 55 + - Pool exhaustion: handle `.get()` errors; avoid panics from `unwrap()`. 56 + - Redis is non-authoritative; always fall back to Postgres on cache miss or bloom filter hit. 57 + - Keep TTLs and thresholds in sync if tuning (`timeline.rs` constants).
+20
backend/impl-rs/notes/Design choices/Rationale/Backend > Error handling and logging.md
··· 1 + ## LuminaError 2 + - Defined in `server/src/errors.rs`. 3 + - Key variants: `DbError(LuminaDbError)`, `Bb8RunErrorPg(bb8::RunError<postgres::Error>)`, `Bb8RunErrorRedis(bb8::RunError<redis::RedisError>)`, auth/registration errors, `SerializationError(String)`, `RocketFaillure(Box<rocket::Error>)`. 4 + - Conversions implemented for Rocket, Postgres, Redis, and bb8 run errors. 5 + - Guidance: propagate the source error (`?`) to keep context; avoid lossy `to_string()` unless necessary. 6 + 7 + ## Logging 8 + - Event logging macros `info_elog!`, `warn_elog!`, `error_elog!`, `success_elog!` are used across DB/timeline flows. 9 + - `EventLogger` can log to stdout and (optionally) Postgres `logs` table (see `helpers/events.rs`). 10 + - When logging DB failures, prefer structured context (timeline id, page, user) to aid diagnosis. 11 + 12 + ## Failure-handling patterns 13 + - Pool acquisition: use `?` so `.get()` maps to `LuminaError::Bb8RunErrorPg/Redis`; avoid panics. 14 + - Redis is non-authoritative: on Redis errors, proceed with Postgres path to avoid request failure where possible. 15 + - Bloom filters: treat `BF.EXISTS` positives as hints; always confirm with Postgres. 16 + - Timeline cache: cache misses/failures should fall back to DB fetch; invalidation is best-effort. 17 + 18 + ## Operational notes 19 + - If Rocket state is missing (e.g., limiter), guards fail-open; verify state wiring at startup. 20 + - Add tracing/metrics around pool usage and cache hit/miss for production readiness.
+27
backend/impl-rs/notes/Design choices/Rationale/Backend > Rate limiting.md
··· 1 + ## Overview 2 + - Token-bucket limiter in `server/src/rate_limiter.rs` using in-memory `HashMap` protected by `tokio::sync::Mutex`. 3 + - Rocket request guard `RateLimit` pulls `State<GeneralRateLimiter>`; missing state = allow (fail-open). 4 + - Separate wrapper types: `GeneralRateLimiter` and `AuthRateLimiter` so Rocket can manage both independently. 5 + 6 + ## Defaults / Tuning 7 + - Constructor requires `refill_per_second` and `capacity`; no hardcoded defaults. Decide per endpoint. 8 + - In-memory only: resets on process restart; not distributed. For multi-node, replace with shared store (e.g., Redis token bucket) or IP hash partitioning. 9 + - Keyed by client IP (`Request::client_ip()`); missing IP maps to key "unknown". 10 + 11 + ## Usage pattern 12 + ```rust 13 + // Configure and mount in Rocket managed state 14 + let limiter = GeneralRateLimiter::new(refill_per_second, capacity); 15 + rocket::build().manage(limiter); 16 + 17 + // Handler signature adds guard 18 + #[get("/protected")] 19 + async fn protected(_rate: RateLimit) -> &'static str { 20 + "ok" 21 + } 22 + ``` 23 + 24 + ## Gotchas 25 + - Fail-open if the guard cannot fetch state; ensure the limiter is registered in Rocket. 26 + - No per-route tuning baked in; provide distinct limiters via type wrappers if needed. 27 + - Single-threaded bottleneck: Mutex over HashMap is fine for moderate QPS; consider sharding or lock-free structure if contention grows.
+25
backend/impl-rs/notes/Design choices/Rationale/Backend > Timeline carries most.md
··· 1 + # Timeline-carries-most 2 + [['Timeline carries most' and the database]] 3 + 4 + One of the busiest tables you'll see is the timeline, containing just some ID's and timestamps. 5 + 6 + | Kind | timeline ID | item ID | Timestamp | 7 + | ---------------------------------------- | ----------- | ------- | ------------------ | 8 + | `'USER'`, `'DIRECT'`, `'TL'`, `'BUBBLE'` | uuidv4 | uuidv4 | Database timestamp | 9 + 10 + The `global` timeline, here being `00000000-0000-0000-0000-000000000000` as the only constant-assigned timeline ID. The 11 + user-profiles being the same as their user id counterpart. 12 + 13 + This is too vague to actually be able to pull a post, which is why the item forward table exists, combining a UUID and a 14 + string to forward to the right item. 15 + 16 + Now I say `item`, not `post` here. This because you might expect only timelines (global, userprofiles) and bubbles ( 17 + timelines meant for a specific subject, forming a community within the larger site) in this table, but direct message 18 + threads are actually also saved here. 19 + 20 + This means this table might become a little overcrowded, and optimizations such as caching, sharding and mirrorring to 21 + Redis will be needed to keep it somewhat performant, especially since this is essentially a constant hot path. I am 22 + aware. 23 + 24 + Which is why we also would need to log every timeline request to be able to identify for example over-requested 25 + timelines.
+6
backend/impl-rs/notes/Design choices/Rationale/Web client > Gleam language.md
··· 1 + # The `gleam` language 2 + 3 + I am a big fan of the language. I think it's a great language for 4 + writing maintainable and scalable code. I think it's a great language 5 + for writing maintainable and scalable code. It's simplicity and the 6 + young yet strong community set a good foundation for Lumina.
+4
backend/impl-rs/notes/High-level requirements.md
··· 1 + - Must provide a timeline for 'global' and user-specific (dynamic timelines), viewable on the front end. 2 + - Must be able to federate, and users be able to interact with "external" content ^4c6bf0 3 + - Must have a responsive web client implementing all 'Must' end-user-features. 4 + - Must have a web admin panel
+17
backend/impl-rs/notes/README.md
··· 1 + This is `Lumina/Peonies`'s Obsidian vault for design choices, philosophies and concepts or even psuedocode. 2 + ## Earlier iterations 3 + 4 + `Lumina:Peonies:itr2` is the current and seemingly final iteration of this project, as of 2026. It uses a Rust server and Gleam/Lustre SPA as web frontend. 5 + 6 + This project has been conceptualised and prototyped into many earlier iterations before, each with different approaches and final result. Some known older iterations had different names, listing a few: 7 + 8 + | Codenamed | About | Introduced | 9 + | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | 10 + | _Peonies-Lumina_ | factually `Lumina:Peonies:itr1`, had a much bigger approach where multiple backends were explored, including ones based on the BEAM (Gleam-Erlang backend to be precise), is what itr2 draws most inspiration of.<br><br>Having multiple backends with non-matching features proved to be too complicated to maintain or draw straight. | Federation, conceptually | 11 + | _Lumina-Ephew_ | A concept-only iteration that never made it past the drawing board. | Lumina's principles and the global chronological timeline | 12 + | _Ephew_ | A near-complete PHP implementation with a plain HTML+CSS frontend (no scripts), fell apart due to the quickly aging PHP ecosystem at the time. | introducing the idea that 'multiple types of posts can feel native' | 13 + | FNew | A public text-only message pinboard | ~~Criticism, mostly~~ | 14 + 15 + 16 + 17 + The current iteration is a more well-documented and slower approach, giving time to learn and chances to refactor. It also comes in a time where the tech for it is perfect and
+1
backend/impl-rs/notes/Todo's.md
··· 1 + Todo's from these notes are deprecated and replaced by Issues on the repository.
+21
backend/impl-rs/robots.txt
··· 1 + # AI training and TDM are expressly reserved. 2 + # See LICENSE file for full legal terms. 3 + 4 + User-agent: * 5 + Disallow: / 6 + 7 + # Specifically targeting AI crawlers 8 + User-agent: GPTBot 9 + Disallow: / 10 + 11 + User-agent: ChatGPT-User 12 + Disallow: / 13 + 14 + User-agent: Google-Extended 15 + Disallow: / 16 + 17 + User-agent: CCBot 18 + Disallow: / 19 + 20 + X-Robots-Tag: noai 21 + X-Robots-Tag: noimageai
+37
backend/impl-rs/server/Cargo.toml
··· 1 + [package] 2 + name = "lumina-server" 3 + version = "0.1.0" 4 + edition = "2024" 5 + license = "EUPL-1.2" 6 + 7 + [[bin]] 8 + name = "lumina-server" 9 + path = "src/main.rs" 10 + 11 + 12 + [dependencies] 13 + tokio = { version = "1.0", features = ["full"] } 14 + serde = { version = "1.0", features = ["derive"] } 15 + rocket = "0.5.1" 16 + ws = { package = "rocket_ws", version = "0.1.1" } 17 + serde_json = "1.0.140" 18 + uuid = { version = "1.16.0", features = ["v4", "serde"] } 19 + sqlx = { version = "0.8.6", features = [ 20 + "postgres", 21 + "runtime-tokio", 22 + "tls-native-tls", 23 + "time", 24 + "uuid", 25 + ] } 26 + anyhow = "1.0.101" 27 + cynthia_con = { version = "0.1.4" } 28 + dotenv = "0.15.0" 29 + bcrypt = "0.17.0" 30 + bb8 = "0.8" 31 + bb8-postgres = "0.8" 32 + bb8-redis = "0.17" 33 + tabled = "0.20.0" 34 + regex = "1.10.5" 35 + redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } 36 + time = "0.3.20" 37 + base64 = "0.22"
+597
backend/impl-rs/server/src/client_communication.rs
··· 1 + //! Lumina > Server > Client Communication 2 + //! 3 + //! This module handles communication between the Lumina server and its clients 4 + //! over WebSockets. It defines the message formats, handles incoming messages, 5 + //! and sends appropriate responses back to the clients. 6 + //! 7 + //! For future clients that might not be web-based, this module is designed to be 8 + //! extensible and adaptable to different client types. 9 + //! It might for example be expanded to also feature a REST API for clients that 10 + //! cannot use WebSockets. 11 + 12 + /* 13 + * Lumina/Peonies 14 + * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 15 + * 16 + * This software is licensed under the European Union Public Licence (EUPL) v1.2. 17 + * You may not use this work except in compliance with the Licence. 18 + * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 19 + * 20 + * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 21 + * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 22 + * See LICENSE file in the repository root for full details. 23 + * 24 + * 25 + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 26 + * See the Licence for the specific language governing permissions and limitations. [cite: 6] 27 + */ 28 + 29 + extern crate rocket; 30 + use crate::database::DatabaseConnections; 31 + use crate::errors::LuminaDbError; 32 + use crate::rate_limiter::RateLimit; 33 + use crate::timeline::fetch_timeline_post_ids_by_timeline_name; 34 + use crate::user::User; 35 + use crate::{ 36 + AppState, LuminaError, authentication_error_elog, error_elog, http_code_elog, incoming_elog, 37 + info_elog, registration_error_elog, warn_elog, 38 + }; 39 + use base64::Engine; 40 + use base64::engine::general_purpose::STANDARD; 41 + use cynthia_con::{CynthiaColors, CynthiaStyles}; 42 + use rocket::State; 43 + use std::net::IpAddr; 44 + use uuid::Uuid; 45 + use ws::frame::{CloseCode, CloseFrame}; 46 + 47 + #[get("/connection")] 48 + pub(crate) async fn wsconnection<'k>( 49 + ws: ws::WebSocket, 50 + state: &'k State<AppState>, 51 + _rate_limit: RateLimit, 52 + _limiter: &'k State<crate::rate_limiter::GeneralRateLimiter>, 53 + auth_limiter: &'k State<crate::rate_limiter::AuthRateLimiter>, 54 + client_ip: Option<IpAddr>, 55 + ) -> ws::Channel<'k> { 56 + let ev_log = { 57 + let appstate = state.0.clone(); 58 + appstate.event_logger.clone() 59 + }; 60 + let db_pool = state.inner().0.db.get_postgres_pool(); 61 + http_code_elog!(ev_log, 200, "/connection"); 62 + use rocket::futures::{SinkExt, StreamExt}; 63 + 64 + ws.channel(move |mut stream| { 65 + Box::pin(async move { 66 + http_code_elog!(ev_log, 101, "/connection"); 67 + let mut client_session_data: SessionData = SessionData { 68 + client_type: None, 69 + user: None, 70 + }; 71 + while let Some(message) = stream.next().await { 72 + match message? { 73 + ws::Message::Text(msg) => { 74 + match msg.as_str() { 75 + "ping" => { 76 + let _ = stream.send(ws::Message::Text("pong".to_string())).await; 77 + } 78 + possibly_json => { 79 + match serde_json::from_str::<Message>(possibly_json) { 80 + Ok(Message::PostViewRequest { post_id }) => 81 + { 82 + info_elog!( 83 + ev_log, "Post was requested: {}", post_id); 84 + 85 + match crate::database::operations::get_public_timelineitem(&db_pool,post_id).await { 86 + Ok(post) => { 87 + let _ = match post { 88 + crate::database::typedreturns::PostItem::TextPost{post_id,source_instance,content,timestamp}=> 89 + { 90 + info_elog!( 91 + ev_log, "Serving text post {}", post_id); 92 + stream 93 + .send(ws::Message::from(msgtojson(Message::TextPostDataSent { post_id, source_instance, content }))) 94 + .await} 95 + , 96 + crate::database::typedreturns::PostItem::MediaPost { post_id, source_instance, description, medias, timestamp } => { 97 + info_elog!(ev_log, "Serving media post {}", post_id); 98 + 99 + 100 + stream 101 + .send(ws::Message::from(msgtojson(Message::MediaPostDataSent { post_id, source_instance, description, medias }))) 102 + .await}, 103 + crate::database::typedreturns::PostItem::ArticlePost { post_id, source_instance,title, content, timestamp,author_id, } => { 104 + info_elog!(ev_log, "Serving article post {}", post_id); 105 + let timestamp = todo!("Convert timestamp to unix or Rfc3339 format here?"); 106 + stream 107 + .send(ws::Message::from(msgtojson(Message::ArticlePostDataSent { post_id, source_instance, title, content, timestamp, author_id }))) 108 + .await}, 109 + }; 110 + 111 + } 112 + Err(m) => { 113 + warn_elog!(ev_log, "Not serving {}, {m}.", post_id); 114 + } 115 + }; 116 + 117 + } 118 + Ok(Message::Introduction { client_kind, try_revive }) => { 119 + match client_kind.as_str() { 120 + "web" => { 121 + client_session_data.client_type = Some(ClientType::Web) 122 + } 123 + "mobile" => todo!(), 124 + _ => {} 125 + } 126 + match try_revive { 127 + Some(token) => { 128 + let appstate = state.0.clone(); 129 + let db = &appstate.db; 130 + match User::revive_session_from_token(token.clone(), db).await { 131 + Ok(user) => { 132 + incoming_elog!(ev_log, "Session revived for user: {}", 133 + user.clone().username.color_bright_cyan() 134 + ); 135 + client_session_data.user = Some(user.clone()); 136 + let _ = stream 137 + .send(ws::Message::from(msgtojson(Message::AuthSuccess { 138 + token, 139 + username: user.username, 140 + }))) 141 + .await; 142 + } 143 + Err(e) => { 144 + match e { 145 + LuminaError::DbError(LuminaDbError::Postgres(postgres_error)) => { 146 + // This is not the kind of practice I'd want, but it does the job 147 + let postgres_error_dbg = format!("{:?}", postgres_error); 148 + // Check if it's a "no rows returned" type error 149 + // I wish I could just open those fields... 150 + if postgres_error_dbg.contains("kind: RowCount, cause: None") { 151 + info_elog!( ev_log,"Session revival failed: token not found or expired."); 152 + } else { 153 + info_elog!(ev_log,"Session revival failed: database error: {postgres_error_dbg}"); 154 + } 155 + } 156 + 157 + _ => { 158 + info_elog!(ev_log,"Session revival failed: {:?}", e); 159 + } 160 + } 161 + let _ = stream 162 + .send(ws::Message::from(msgtojson(Message::AuthFailure))) 163 + .await; 164 + } 165 + } 166 + } 167 + None => { 168 + let _ = stream 169 + .send(ws::Message::from(msgtojson(Message::Greeting { 170 + greeting: "Hello from server!".to_string(), 171 + }))) 172 + .await; 173 + } 174 + } 175 + } 176 + Ok(Message::RegisterRequest { 177 + email, 178 + username, 179 + password, 180 + }) => { 181 + incoming_elog!( 182 + ev_log, 183 + "Register request: {} {}", 184 + email.clone().color_orange(), 185 + username.clone().color_bright_cyan() 186 + ); 187 + 188 + // register the user 189 + { 190 + let appstate = state.0.clone(); 191 + let db = &appstate.db; 192 + match User::create_user(email.clone(), username.clone(), password, db).await 193 + { 194 + Ok(user) => { 195 + info_elog!( 196 + ev_log, 197 + "User created: {}", 198 + user.clone().username.color_bright_cyan() 199 + ); 200 + match User::create_session(user, db, ev_log.clone()).await { 201 + Ok((session_reference, user)) => { 202 + client_session_data.user = 203 + Some(user.clone()); 204 + incoming_elog!(ev_log,"User {} authenticated.", 205 + user.clone().username.color_bright_cyan() 206 + ); 207 + let _ = stream 208 + .send(ws::Message::from(msgtojson( 209 + Message::AuthSuccess { 210 + token: session_reference.token, 211 + username: user.username, 212 + }, 213 + ))) 214 + .await; 215 + } 216 + Err(e) => { 217 + match e { 218 + LuminaError::DbError(crate::errors::LuminaDbError::Postgres(e)) => 219 + error_elog!(ev_log,"While creating session token: {:?}", e), 220 + _ => {} 221 + } 222 + // I would return a more specific error message 223 + // to the client here, but if the server knows the 224 + // error, the client should know the error twice as 225 + // well. 226 + 227 + let _ = stream 228 + .send(ws::Message::from(msgtojson( 229 + Message::AuthFailure, 230 + ))) 231 + .await; 232 + } 233 + } 234 + } 235 + 236 + Err(e) => { 237 + match e { 238 + LuminaError::RegisterUsernameInUse => { 239 + registration_error_elog!(ev_log, "User {} already exists", 240 + username.clone().color_bright_cyan() 241 + ); 242 + } 243 + LuminaError::RegisterEmailNotValid => { 244 + registration_error_elog!(ev_log, "Email {} is not valid", 245 + email.clone().color_bright_cyan() 246 + ); 247 + } 248 + LuminaError::RegisterUsernameInvalid(why) => { 249 + registration_error_elog!(ev_log, "Username '{}' is not valid: {}", 250 + username.clone().color_bright_cyan(), 251 + why 252 + ); 253 + } 254 + LuminaError::RegisterPasswordNotValid(why) => { 255 + registration_error_elog!(ev_log, "Password is not valid: {}", 256 + why 257 + ); 258 + } 259 + e => { 260 + registration_error_elog!(ev_log, "Error creating user: {:?}", 261 + e 262 + ); 263 + } 264 + } 265 + 266 + // I would return a more specific error message 267 + // to the client here, but if the server knows the 268 + // error, the client should know the error twice as 269 + // well. 270 + 271 + let _ = stream 272 + .send(ws::Message::from(msgtojson( 273 + Message::AuthFailure, 274 + ))) 275 + .await; 276 + } 277 + } 278 + } 279 + } 280 + Ok(Message::RegisterPrecheck { email, username, password }) => { 281 + let appstate = state.0.clone(); 282 + let db = &appstate.db; 283 + match crate::user::register_validitycheck(email, username, password, db).await { 284 + Err(LuminaError::RegisterEmailInUse) => { 285 + let _ = stream.send(ws::Message::from(msgtojson(Message::RegisterPrecheckResponse { 286 + ok: false, 287 + why: "Email already in use".to_string(), 288 + }))).await; 289 + } 290 + Err(LuminaError::RegisterUsernameInUse) => { 291 + let _ = stream.send(ws::Message::from(msgtojson(Message::RegisterPrecheckResponse { 292 + ok: false, 293 + why: "Username already in use".to_string(), 294 + }))).await; 295 + } 296 + Err(LuminaError::RegisterEmailNotValid) => { 297 + let _ = stream.send(ws::Message::from(msgtojson(Message::RegisterPrecheckResponse { 298 + ok: false, 299 + why: "Email not valid".to_string(), 300 + }))).await; 301 + } 302 + Err(LuminaError::RegisterUsernameInvalid(why)) => { 303 + let _ = stream.send(ws::Message::from(msgtojson(Message::RegisterPrecheckResponse { 304 + ok: false, 305 + why: format!("Username invalid: {}", why), 306 + }))).await; 307 + } 308 + Err(LuminaError::RegisterPasswordNotValid(why)) => { 309 + let _ = stream.send(ws::Message::from(msgtojson(Message::RegisterPrecheckResponse { 310 + ok: false, 311 + why: format!("Password invalid: {}", why), 312 + }))).await; 313 + } 314 + Ok(_) => { 315 + let _ = stream.send(ws::Message::from(msgtojson(Message::RegisterPrecheckResponse { 316 + ok: true, 317 + why: "".to_string(), 318 + }))).await; 319 + } 320 + _ => {} 321 + } 322 + } 323 + Ok(Message::LoginAuthenticationRequest { email_username, password }) => { 324 + // Quick pre-check: if the limiter says this IP is blocked, avoid DB work. 325 + if !auth_limiter.allow_ip(client_ip).await { 326 + authentication_error_elog!(ev_log, "Rate-limited authentication attempt from IP: {:?}", client_ip); 327 + let _ = stream.send(ws::Message::from(msgtojson(Message::AuthFailure))).await; 328 + } else { 329 + let appstate = state.0.clone(); 330 + let db = &appstate.db; 331 + let msgback = match User::authenticate(email_username.clone(), password, db, ev_log.clone()).await { 332 + Ok((session_reference, user)) => { 333 + incoming_elog!(ev_log,"User {} authenticated to session with id {}.\n{}", user.username.clone().color_bright_cyan(), session_reference.session_id.to_string().color_pink(), format!("(User id: {})", user.id).style_dim()); 334 + client_session_data.user = Some(user.clone()); 335 + Message::AuthSuccess { token: session_reference.token, username: user.username } 336 + } 337 + Err(s) => { 338 + match s { 339 + LuminaError::AuthenticationWrongPassword => { 340 + authentication_error_elog!(ev_log,"User {} {} authenticated: Incorrect credentials", email_username.color_bright_cyan(), "not".color_red()); 341 + } 342 + // LuminaError::AuthenticationUserNotFound => { 343 + // authentication_error_elog!(ev_log,"User {} {} authenticated: User not found", email_username.color_bright_cyan(), "not".color_red()); 344 + // } 345 + _ => { 346 + authentication_error_elog!(ev_log,"User {} {} authenticated: {:?}", email_username.color_bright_cyan(), "not".color_red(), s); 347 + } 348 + } 349 + Message::AuthFailure 350 + } 351 + }; 352 + let _ = stream.send(ws::Message::from(msgtojson(msgback))).await; 353 + } 354 + } 355 + Ok(Message::OwnUserInformationRequest) => { 356 + // Handle request for user's own information 357 + match &client_session_data.user { 358 + Some(user) => { 359 + // For now, send back basic user info as a greeting 360 + // This could be expanded to a proper user info response message type 361 + let response = Message::OwnUserInformationResponse { 362 + username: user.username.clone(), 363 + email: user.email.clone(), 364 + // Provide a compile-time included SVG placeholder avatar when none is available. 365 + // The SVG file is included as bytes and base64-encoded here. 366 + avatar: Some(( 367 + "image/svg+xml".to_string(), 368 + // Encode the included SVG bytes as base64 at compile time. 369 + STANDARD.encode(include_bytes!("../../assets/svgs/dummy_user_120px.svg")), 370 + )), 371 + uuid: user.id.to_string(), 372 + //TODO: Fetch actual unread notification count 373 + //Based on how many notifications are younger than last time user checked notifications. (WsMessage is sent when user opens notifications) 374 + unread_notifications: 11 375 + }; 376 + let msg_json = msgtojson(response); 377 + // println!("Sending own user information response: {}", msg_json); 378 + let _ = stream.send(ws::Message::from(msg_json)).await; 379 + } 380 + None => { 381 + let _ = stream.send(ws::Message::from(msgtojson(Message::AuthFailure))).await; 382 + } 383 + } 384 + } 385 + Ok(Message::TimelineRequest { by_name: name, page }) => { 386 + let appstate = state.0.clone(); 387 + let db = &appstate.db; 388 + // Fetch post IDs for the requested timeline 389 + match fetch_timeline_post_ids_by_timeline_name( 390 + ev_log.clone(), 391 + db, 392 + &name, 393 + client_session_data.user.clone().unwrap(), 394 + page, 395 + ).await { 396 + Ok((tlid, post_ids, total_count, has_more)) => { 397 + let response = Message::TimelineResponse { 398 + post_ids, 399 + timeline_name: name, 400 + timeline_id: tlid, 401 + total_count, 402 + page: page.unwrap_or(0), 403 + has_more, 404 + }; 405 + let _ = stream.send(ws::Message::from(msgtojson(response))).await; 406 + } 407 + Err(e) => { 408 + error_elog!(ev_log, "Error fetching timeline: {:?}", e); 409 + let _ = stream.send(ws::Message::from(msgtojson(Message::SerialisationError { 410 + error: format!("{:?}", e), 411 + }))).await; 412 + } 413 + } 414 + } 415 + // Responding variants are not supposed to ever arrive here. 416 + Ok(Message::ClientInit { .. }) | 417 + Ok(Message::Greeting { .. }) | Ok(Message::SerialisationError { .. }) 418 + | Ok(Message::RegisterPrecheckResponse { .. }) 419 + | Ok(Message::AuthSuccess { .. }) 420 + | Ok(Message::AuthFailure) 421 + | Ok(Message::MediaPostDataSent { .. }) 422 + | Ok(Message::TextPostDataSent { .. }) 423 + | Ok(Message::ArticlePostDataSent { .. }) 424 + | Ok(Message::OwnUserInformationResponse { .. }) 425 + | Ok(Message::TimelineResponse { .. }) => { 426 + panic!("These messages should never arrive here.") 427 + } 428 + // This one makes sense. 429 + Ok(Message::Unknown) => { 430 + panic!("Unknown message received?") 431 + } 432 + // And to handle straight up errors: 433 + Err(e) => { 434 + error_elog!(ev_log, "Error deserialising message: {:?}\n\n{}" , e, 435 + format!("The message: {}", possibly_json).style_dim() 436 + ); 437 + let _ = stream.close(Some(CloseFrame { 438 + code: CloseCode::Invalid, 439 + reason: std::borrow::Cow::Borrowed("Serialisation error"), 440 + })).await; 441 + } 442 + } 443 + } 444 + } 445 + } 446 + ws::Message::Close(_) => { 447 + let _ = stream.send(ws::Message::Close(None)).await; 448 + break; 449 + } 450 + _ => { 451 + let _ = stream.send(ws::Message::from("unknown")).await; 452 + } 453 + } 454 + } 455 + 456 + Ok(()) 457 + }) 458 + }) 459 + } 460 + 461 + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] 462 + #[serde(tag = "type", rename_all = "snake_case")] 463 + // An example of a JSON message that the client might send to the server: 464 + // {"type": "client-init", "data": "hi"} 465 + pub(crate) enum Message { 466 + #[serde(rename = "client-init")] 467 + ClientInit { data: String }, 468 + #[serde(rename = "introduction")] 469 + Introduction { 470 + client_kind: String, 471 + try_revive: Option<String>, 472 + }, 473 + #[serde(rename = "greeting")] 474 + Greeting { greeting: String }, 475 + #[serde(rename = "serialisation_error")] 476 + SerialisationError { error: String }, 477 + #[serde(rename = "login_authentication_request")] 478 + LoginAuthenticationRequest { 479 + email_username: String, 480 + password: String, 481 + }, 482 + #[serde(rename = "register_request")] 483 + RegisterRequest { 484 + email: String, 485 + username: String, 486 + password: String, 487 + }, 488 + #[serde(rename = "register_precheck")] 489 + RegisterPrecheck { 490 + email: String, 491 + username: String, 492 + password: String, 493 + }, 494 + #[serde(rename = "register_precheck_response")] 495 + RegisterPrecheckResponse { ok: bool, why: String }, 496 + #[serde(rename = "auth_success")] 497 + AuthSuccess { token: String, username: String }, 498 + #[serde(rename = "auth_failure")] 499 + AuthFailure, 500 + #[serde(rename = "data_article_post")] 501 + ArticlePostDataSent { 502 + post_id: Uuid, 503 + /// Source instance. 'local' by default, hostname if external. 504 + source_instance: String, 505 + title: String, 506 + content: String, 507 + /// Unix timestamp of the moment of posting 508 + timestamp: u64, 509 + /// User id of poster, which is why the source_instance matters. 510 + /// This means that client will do a lookup and stores the user once it gets it. 511 + author_id: String, 512 + }, 513 + #[serde(rename = "data_embed_post")] 514 + MediaPostDataSent { 515 + post_id: Uuid, 516 + /// Source instance. 'local' by default, hostname if external. 517 + source_instance: String, 518 + /// Media description 519 + description: String, 520 + /// Base64 encoded media strings, either webp or mp4. 521 + medias: Vec<String>, 522 + }, 523 + #[serde(rename = "data_textual_post")] 524 + TextPostDataSent { 525 + post_id: Uuid, 526 + /// Source instance. 'local' by default, hostname (IID) if external. 527 + source_instance: String, 528 + /// Markdown content. 529 + content: String, 530 + }, 531 + #[serde(rename = "own_user_information_request")] 532 + /// Request for the server to send back the user's own information. 533 + /// This is used to get the user's own information after logging in. 534 + OwnUserInformationRequest, 535 + #[serde(rename = "own_user_information_response")] 536 + /// Response to the `OwnUserInformationRequest` containing the user's own information. 537 + OwnUserInformationResponse { 538 + username: String, 539 + email: String, 540 + // Optional field populated with mime type and base64 of a profile picture. 541 + avatar: Option<(String, String)>, 542 + uuid: String, 543 + unread_notifications: u64, 544 + }, 545 + /// Requests a list of strings to represent a certain timeline or bubble timeline. 546 + #[serde(rename = "timeline_request")] 547 + TimelineRequest { 548 + by_name: String, 549 + #[serde(default)] 550 + page: Option<usize>, 551 + }, 552 + TimelineResponse { 553 + timeline_name: String, 554 + timeline_id: Uuid, 555 + /// A list of post IDs for the requested timeline. 556 + post_ids: Vec<String>, 557 + /// Total number of posts in timeline 558 + total_count: usize, 559 + /// Current page number 560 + page: usize, 561 + /// Whether there are more pages available 562 + has_more: bool, 563 + }, 564 + /// User would like to view a post and its details. 565 + #[serde(rename = "post_view_request")] 566 + PostViewRequest { post_id: Uuid }, 567 + /// "Yeah I don't know what I'm sending either!" 568 + #[serde(rename = "unknown")] 569 + Unknown, 570 + } 571 + 572 + pub(crate) fn msgtojson(msg: Message) -> String { 573 + serde_json::to_string(&msg).unwrap_or_else(|e| -> String { 574 + serde_json::to_string(&Message::SerialisationError { 575 + error: format!("{:?}", e), 576 + }) 577 + .unwrap_or_else(|e| { 578 + format!( 579 + "{{\"type\": \"serialisation_error\", \"error\": \"{}\"}}", 580 + e 581 + ) 582 + }) 583 + }) 584 + } 585 + 586 + pub(crate) struct SessionData { 587 + pub(crate) client_type: Option<ClientType>, 588 + pub(crate) user: Option<User>, 589 + } 590 + 591 + pub enum ClientType { 592 + Web, 593 + // NativeApp will one day mean a native application, like a mobile app. 594 + // For now, it is nothing. 595 + #[expect(dead_code, reason = "Will be used when other clients are added.")] 596 + NativeApp, 597 + }
+573
backend/impl-rs/server/src/database.rs
··· 1 + //! Lumina > Server > Database 2 + //! 3 + //! Database management and connection pooling module. 4 + 5 + /* 6 + * Lumina/Peonies 7 + * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 8 + * 9 + * This software is licensed under the European Union Public Licence (EUPL) v1.2. 10 + * You may not use this work except in compliance with the Licence. 11 + * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 12 + * 13 + * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 14 + * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 15 + * See LICENSE file in the repository root for full details. 16 + * 17 + * 18 + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 19 + * See the Licence for the specific language governing permissions and limitations. [cite: 6] 20 + */ 21 + use crate::EnvVar::*; 22 + use crate::errors::LuminaError::{self}; 23 + use crate::helpers::events::EventLogger; 24 + use crate::timeline; 25 + use crate::{info_elog, success_elog, warn_elog}; 26 + use bb8::Pool; 27 + use bb8_redis::RedisConnectionManager; 28 + use cynthia_con::{CynthiaColors, CynthiaStyles}; 29 + use sqlx::Postgres; 30 + use sqlx::postgres::PgPool; 31 + use std::time::Duration; 32 + 33 + struct DatabaseConfig { 34 + postgres_username: String, 35 + postgres_password: Option<String>, 36 + postgres_host: String, 37 + postgres_port: u16, 38 + postgres_dbname: String, 39 + } 40 + 41 + pub(crate) async fn setup() -> Result<PgConn, LuminaError> { 42 + let ev_log = EventLogger::new(&None); 43 + let redis_url = 44 + std::env::var("LUMINA_REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1/".into()); 45 + let redis_pool = { 46 + info_elog!(ev_log, "Setting up Redis connection to {}...", redis_url); 47 + let manager = RedisConnectionManager::new(redis_url.clone())?; 48 + // Configure pool sizes 49 + let redis_pool = Pool::builder() 50 + .max_size(50) 51 + .connection_timeout(Duration::from_secs(5)) 52 + .idle_timeout(Some(Duration::from_secs(300))) 53 + .build(manager) 54 + .await?; 55 + success_elog!( 56 + ev_log, 57 + "Redis connection to {} created successfully.", 58 + redis_url 59 + ); 60 + 61 + redis_pool 62 + }; 63 + 64 + { 65 + let uri = if let Ok(uri) = std::env::var("DATABASE_URL") { 66 + info_elog!( 67 + ev_log, 68 + "DATABASE_URL set, using that to connect to Postgres", 69 + ); 70 + uri 71 + } else { 72 + let pg_config: DatabaseConfig = { 73 + let mut uuu = ( 74 + "unspecified database".to_string(), 75 + "unspecified host".to_string(), 76 + "unknown port".to_string(), 77 + ); 78 + let mut pg_config = DatabaseConfig { 79 + postgres_username: std::env::var("LUMINA_POSTGRES_USERNAME") 80 + .unwrap_or("lumina".to_string()), 81 + postgres_password: std::env::var("LUMINA_POSTGRES_PASSWORD").ok(), 82 + postgres_host: std::env::var("LUMINA_POSTGRES_HOST") 83 + .unwrap_or("localhost".to_string()), 84 + postgres_port: std::env::var("LUMINA_POSTGRES_PORT") 85 + .ok() 86 + .and_then(|p| p.parse::<u16>().ok()) 87 + .unwrap_or(5432), 88 + postgres_dbname: std::env::var("LUMINA_POSTGRES_DATABASE") 89 + .unwrap_or("lumina_config".to_string()), 90 + }; 91 + pg_config.postgres_username = 92 + std::env::var("LUMINA_POSTGRES_USERNAME").unwrap_or("lumina".to_string()); 93 + let dbname = std::env::var("LUMINA_POSTGRES_DATABASE") 94 + .unwrap_or("lumina_config".to_string()); 95 + uuu.0 = dbname.clone(); 96 + pg_config.postgres_dbname = dbname; 97 + let port = match std::env::var("LUMINA_POSTGRES_PORT") { 98 + Err(..) => { 99 + warn_elog!( 100 + ev_log, 101 + "No Postgres database port provided under environment variable 'LUMINA_POSTGRES_PORT'. Using default value '5432'." 102 + ); 103 + "5432".to_string() 104 + } 105 + Ok(c) => c, 106 + }; 107 + uuu.2 = port.clone(); 108 + // Parse the port as u16, if it fails, return an error 109 + pg_config.postgres_port = port 110 + .parse::<u16>() 111 + .map_err(|_| LuminaError::ConfInvalid(LUMINA_POSTGRES_PORT))?; 112 + match std::env::var("LUMINA_POSTGRES_HOST") { 113 + Ok(val) => { 114 + uuu.1 = val.clone(); 115 + pg_config.postgres_host = val; 116 + } 117 + Err(_) => { 118 + warn_elog!( 119 + ev_log, 120 + "No Postgres database host provided under environment variable 'LUMINA_POSTGRES_HOST'. Using default value 'localhost'." 121 + ); 122 + // Default to localhost if not set 123 + uuu.1 = "localhost".to_string(); 124 + pg_config.postgres_host = "localhost".to_string(); 125 + } 126 + }; 127 + match std::env::var("LUMINA_POSTGRES_PASSWORD") { 128 + Ok(val) => { 129 + pg_config.postgres_password = Some(val); 130 + } 131 + Err(_) => { 132 + warn_elog!( 133 + ev_log, 134 + "No Postgres database password provided under environment variable 'LUMINA_POSTGRES_PASSWORD'. Trying passwordless authentication." 135 + ); 136 + } 137 + }; 138 + info_elog!( 139 + ev_log, 140 + "Using Postgres database at: {} on host: {} at port: {}", 141 + uuu.0.color_bright_cyan().style_bold(), 142 + uuu.1.color_bright_cyan().style_bold(), 143 + uuu.2.color_bright_cyan().style_bold(), 144 + ); 145 + pg_config 146 + }; 147 + 148 + // Create Postgres connection pool 149 + format!( 150 + "postgres://{}{}@{}:{}/{}", 151 + pg_config.postgres_username, 152 + pg_config 153 + .postgres_password 154 + .as_deref() 155 + .map(|a| format!(":{}", a)) 156 + .unwrap_or_default(), 157 + pg_config.postgres_host, 158 + pg_config.postgres_port, 159 + pg_config.postgres_dbname 160 + ) 161 + }; 162 + let pg_pool: sqlx::Pool<Postgres> = PgPool::connect(uri.as_str()).await?; 163 + { 164 + // This is where previously the database schema was created if it did not exist, but now 165 + // we use sqlx and let it do that :) 166 + // pg_conn 167 + // .batch_execute(include_str!("../../SQL/create_pg.sql")) 168 + // .await?; 169 + // Populate bloom filters 170 + let mut redis_conn = redis_pool.get().await?; 171 + let email_key = "bloom:email"; 172 + let username_key = "bloom:username"; 173 + 174 + // (email, username) 175 + let users_and_emails = operations::list_users_and_emails(&pg_pool).await?; 176 + for (email, username) in users_and_emails { 177 + let _: () = redis::cmd("BF.ADD") 178 + .arg(email_key) 179 + .arg(email) 180 + .query_async(&mut *redis_conn) 181 + .await?; 182 + let _: () = redis::cmd("BF.ADD") 183 + .arg(username_key) 184 + .arg(username) 185 + .query_async(&mut *redis_conn) 186 + .await?; 187 + } 188 + info_elog!(ev_log, "Bloom filters populated from PostgreSQL.",); 189 + }; 190 + let pg_pool_clone = pg_pool.clone(); 191 + let redis_pool_clone = redis_pool.clone(); 192 + tokio::spawn(async move { 193 + maintain(PgConn { 194 + postgres_pool: pg_pool_clone, 195 + redis_pool: redis_pool_clone, 196 + }) 197 + .await 198 + }); 199 + Ok(PgConn { 200 + postgres_pool: pg_pool, 201 + redis_pool, 202 + }) 203 + } 204 + } 205 + 206 + /// This enum contains the postgres and redis connection and pool respectively. It used to have more variants before, and maybe it will once again. 207 + #[derive()] 208 + pub enum DbConn { 209 + /// The main database is a Postgres database in this variant. 210 + PgsqlConnection(PgPool, Pool<RedisConnectionManager>), 211 + } 212 + 213 + pub(crate) trait DatabaseConnections { 214 + /// Get a reference to the redis pool 215 + /// This is useful for functions that need to access redis but not the main database 216 + /// such as timeline cache management 217 + /// This returns a clone of the pool without recreating it entirely, so it is cheap to call 218 + fn get_redis_pool(&self) -> Pool<RedisConnectionManager>; 219 + 220 + /// Get a reference to the Postgres pool 221 + /// This returns a clone of the pool without recreating it entirely, so it is cheap to call 222 + fn get_postgres_pool(&self) -> PgPool; 223 + 224 + /// Recreate the database connection. 225 + async fn recreate(&self) -> PgConn 226 + where 227 + Self: Sized; 228 + } 229 + 230 + impl DatabaseConnections for DbConn { 231 + fn get_redis_pool(&self) -> Pool<RedisConnectionManager> { 232 + match self { 233 + DbConn::PgsqlConnection(_, redis_pool) => redis_pool.clone(), 234 + } 235 + } 236 + 237 + fn get_postgres_pool(&self) -> PgPool { 238 + match self { 239 + DbConn::PgsqlConnection(pg_pool, _) => pg_pool.clone(), 240 + } 241 + } 242 + /// Recreate the database connection. 243 + /// This clones the pools - bb8 pools are cheap to clone as they share the underlying connections. 244 + // This function converts a generic DbConn to the more concrete PgConn type. 245 + async fn recreate(&self) -> PgConn { 246 + PgConn { 247 + postgres_pool: self.get_postgres_pool(), 248 + redis_pool: self.get_redis_pool(), 249 + } 250 + } 251 + } 252 + 253 + impl DatabaseConnections for PgConn { 254 + fn get_redis_pool(&self) -> Pool<RedisConnectionManager> { 255 + self.redis_pool.clone() 256 + } 257 + 258 + fn get_postgres_pool(&self) -> PgPool { 259 + self.postgres_pool.clone() 260 + } 261 + 262 + async fn recreate(&self) -> PgConn 263 + where 264 + Self: Sized, 265 + { 266 + self.clone() 267 + } 268 + } 269 + /// Simplified type only accounting for the Postgres struct, since the enum adds some future flexibility, but also a lot of overhead. 270 + /// If all goes well, this PgConn type will have replaced DbConn entirely after a few iterations of improvement over the years. 271 + pub struct PgConn { 272 + pub(crate) postgres_pool: PgPool, 273 + pub(crate) redis_pool: Pool<RedisConnectionManager>, 274 + } 275 + 276 + impl From<PgConn> for DbConn { 277 + /// Converts/unwraps the more concrete PgConn type to the generic DbConn counterpart. 278 + fn from(db: PgConn) -> Self { 279 + Self::PgsqlConnection(db.postgres_pool, db.redis_pool) 280 + } 281 + } 282 + 283 + impl Clone for PgConn { 284 + fn clone(&self) -> Self { 285 + PgConn { 286 + postgres_pool: self.postgres_pool.clone(), 287 + redis_pool: self.redis_pool.clone(), 288 + } 289 + } 290 + } 291 + 292 + // This function will be used to maintain the database, such as deleting old sessions 293 + // and managing timeline caches 294 + pub async fn maintain(db: PgConn) { 295 + let db = DbConn::from(db); 296 + match db { 297 + DbConn::PgsqlConnection(pg_pool, redis_pool) => { 298 + let mut session_interval = tokio::time::interval(Duration::from_secs(60)); 299 + let mut cache_interval = tokio::time::interval(Duration::from_secs(300)); // 5 minutes 300 + 301 + loop { 302 + tokio::select! { 303 + _ = session_interval.tick() => { 304 + // Delete any sessions older than 20 days 305 + match sqlx::query!("DELETE FROM sessions WHERE created_at < NOW() - INTERVAL '20 days'").execute(&pg_pool).await { 306 + Ok(_) => (), 307 + Err(err) => { 308 + error!("Failed to delete session: {}", err); 309 + } 310 + }; 311 + 312 + } 313 + _ = cache_interval.tick() => { 314 + // Clean up expired timeline caches and manage cache invalidation 315 + if let Ok(mut redis_conn) = redis_pool.get().await { 316 + let _ = cleanup_timeline_caches(&mut redis_conn).await; 317 + 318 + let _ = check_timeline_invalidations(&mut redis_conn, &pg_pool).await; 319 + } 320 + } 321 + } 322 + } 323 + } 324 + } 325 + } 326 + 327 + // Clean up expired timeline cache entries 328 + async fn cleanup_timeline_caches( 329 + redis_conn: &mut bb8::PooledConnection<'_, RedisConnectionManager>, 330 + ) -> Result<(), LuminaError> { 331 + let pattern = "timeline_cache:*"; 332 + let mut cursor = 0; 333 + 334 + loop { 335 + let result: (u64, Vec<String>) = redis::cmd("SCAN") 336 + .cursor_arg(cursor) 337 + .arg("MATCH") 338 + .arg(pattern) 339 + .query_async(&mut **redis_conn) 340 + .await?; 341 + 342 + cursor = result.0; 343 + let keys = result.1; 344 + 345 + let mut expired_keys = Vec::new(); 346 + 347 + for key in keys { 348 + // Check TTL, if -1 or 0, it should be cleaned up 349 + let ttl: i64 = redis::cmd("TTL") 350 + .arg(&key) 351 + .query_async(&mut **redis_conn) 352 + .await?; 353 + if ttl == -1 || ttl == 0 { 354 + expired_keys.push(key); 355 + } 356 + } 357 + 358 + if !expired_keys.is_empty() { 359 + let _: () = redis::cmd("DEL") 360 + .arg(&expired_keys) 361 + .query_async(&mut **redis_conn) 362 + .await?; 363 + } 364 + 365 + if cursor == 0 { 366 + break; 367 + } 368 + } 369 + 370 + Ok(()) 371 + } 372 + 373 + // Check for timeline changes and invalidate caches accordingly (PostgreSQL) 374 + async fn check_timeline_invalidations( 375 + redis_conn: &mut bb8::PooledConnection<'_, RedisConnectionManager>, 376 + pg_pool: &PgPool, 377 + ) -> Result<(), LuminaError> { 378 + // Get the last check timestamp 379 + let last_check = redis::cmd("GET") 380 + .arg("timeline_cache_last_check") 381 + .query_async(&mut **redis_conn) 382 + .await 383 + .unwrap_or(None) 384 + .map(|a: String| { 385 + time::OffsetDateTime::parse(a.as_str(), &time::format_description::well_known::Rfc3339) 386 + }); 387 + 388 + let query = if let Some(Ok(timestamp)) = last_check { 389 + sqlx::query!( 390 + "SELECT DISTINCT tlid FROM timelines WHERE timestamp > $1", 391 + timestamp 392 + ) 393 + .fetch_all(pg_pool) 394 + .await 395 + } else if let Some(Err(_)) = last_check { 396 + panic!( 397 + "timeline_cache_last_check returned an error, this means there's probably been tampering with the Redis DB." 398 + ); 399 + } else { 400 + // First run, don't invalidate anything 401 + let _: () = redis::cmd("SET") 402 + .arg("timeline_cache_last_check") 403 + .arg( 404 + time::OffsetDateTime::now_utc() 405 + .format(&time::format_description::well_known::Rfc3339) 406 + .unwrap(), 407 + ) 408 + .query_async(&mut **redis_conn) 409 + .await?; 410 + return Ok(()); 411 + }; 412 + 413 + match query { 414 + Ok(timelines) => { 415 + for timeline in timelines { 416 + let _ = timeline::invalidate_timeline_cache(redis_conn, timeline.tlid).await; 417 + } 418 + 419 + // Update last check timestamp 420 + let _: () = redis::cmd("SET") 421 + .arg("timeline_cache_last_check") 422 + .arg( 423 + time::OffsetDateTime::now_utc() 424 + .format(&time::format_description::well_known::Rfc3339) 425 + .unwrap(), 426 + ) 427 + .query_async(&mut **redis_conn) 428 + .await?; 429 + } 430 + Err(_) => { 431 + // If query fails, just update timestamp to avoid repeated failures 432 + let _: () = redis::cmd("SET") 433 + .arg("timeline_cache_last_check") 434 + .arg( 435 + time::OffsetDateTime::now_utc() 436 + .format(&time::format_description::well_known::Rfc3339) 437 + .unwrap(), 438 + ) 439 + .query_async(&mut **redis_conn) 440 + .await?; 441 + } 442 + } 443 + 444 + Ok(()) 445 + } 446 + 447 + pub(crate) mod operations { 448 + 449 + use std::str::FromStr; 450 + 451 + use anyhow::bail; 452 + use time::OffsetDateTime; 453 + use uuid::Uuid; 454 + 455 + use crate::timeline::GLOBAL_TIMELINE_ID; 456 + 457 + use super::*; 458 + /// List all users and their emails from the database, used for populating bloom filters on 459 + ///startup 460 + /// 461 + /// Returns a vector of tuples containing the email and username of each user in the database: 462 + /// ```rust 463 + /// Vec<(String, String)> // (email, username) 464 + /// ``` 465 + pub async fn list_users_and_emails( 466 + pool: &PgPool, 467 + ) -> Result<Vec<(String, String)>, sqlx::Error> { 468 + let recs = sqlx::query!( 469 + r#" 470 + SELECT email, username 471 + FROM users 472 + "# 473 + ) 474 + .fetch_all(pool) 475 + .await?; 476 + let mut res = vec![]; 477 + for rec in recs { 478 + res.push((rec.email, rec.username)); 479 + } 480 + Ok(res) 481 + } 482 + /// Returns a post if it is on any public timeline, including bubble timelines but excluding DM timelines. 483 + // (This because all bubble timelines are also published to the global timeline, for now) 484 + /// The answer from this is not necessarily safe for public API's, though it is yet unspecified how public API's would handle single postrequests. 485 + pub(crate) async fn get_public_timelineitem( 486 + pool: &PgPool, 487 + postid: Uuid, 488 + ) -> anyhow::Result<typedreturns::PostItem> { 489 + let gltl = Uuid::from_str(GLOBAL_TIMELINE_ID)?; 490 + let timeline_lookup = sqlx::query!( 491 + "SELECT * FROM timelines WHERE item_id = $1 AND tlid = $2", 492 + postid, 493 + gltl 494 + ) 495 + .fetch_optional(pool) 496 + .await?; 497 + let _ = if let None = timeline_lookup { 498 + bail!("No post was found on global timeline") 499 + }; 500 + let type_lookup = sqlx::query!("SELECT * FROM itemtypes WHERE item_id = $1", postid,) 501 + .fetch_one(pool) 502 + .await?; 503 + match type_lookup.itemtype.as_str() { 504 + "text" => { 505 + let post = sqlx::query!("SELECT * FROM post_text WHERE id = $1", postid,) 506 + .fetch_one(pool) 507 + .await?; 508 + let location = match (post.foreign_instance_id, post.foreign_post_id) { 509 + (Some(pid), Some(iid)) => (Uuid::from_str(pid.as_str())?, iid), 510 + _ => (postid, String::from("local")), 511 + }; 512 + anyhow::Ok(typedreturns::PostItem::TextPost { 513 + post_id: postid, 514 + source_instance: location.1, 515 + content: post.content, 516 + timestamp: post.created_at, 517 + }) 518 + } 519 + "media" => { 520 + todo!("Media post fetching not yet implemented."); 521 + } 522 + "article" => { 523 + todo!("Article post fetching not yet implemented."); 524 + } 525 + _ => { 526 + bail!("Unsupported post for the global timeline, something got mixed up here.") 527 + } 528 + } 529 + } 530 + } 531 + 532 + pub(crate) mod typedreturns { 533 + use time::OffsetDateTime; 534 + use uuid::Uuid; 535 + 536 + pub(crate) enum TimelineItem { 537 + Post(PostItem), 538 + } 539 + pub(crate) enum PostItem { 540 + ArticlePost { 541 + post_id: Uuid, 542 + /// Source instance. 'local' by default, hostname if external. 543 + source_instance: String, 544 + title: String, 545 + content: String, 546 + /// Timestamp of the moment of posting 547 + timestamp: OffsetDateTime, 548 + /// User id of poster, which is why the source_instance matters. 549 + /// This means that client will do a lookup and stores the user once it gets it. 550 + author_id: String, 551 + }, 552 + MediaPost { 553 + post_id: Uuid, 554 + /// Source instance. 'local' by default, hostname if external. 555 + source_instance: String, 556 + /// Media description 557 + description: String, 558 + /// Base64 encoded media strings, either webp or mp4. 559 + medias: Vec<String>, 560 + /// Timestamp of the moment of posting 561 + timestamp: OffsetDateTime, 562 + }, 563 + TextPost { 564 + post_id: Uuid, 565 + /// Source instance. 'local' by default, hostname (IID) if external. 566 + source_instance: String, 567 + /// Markdown content. 568 + content: String, 569 + /// Timestamp of the moment of posting 570 + timestamp: OffsetDateTime, 571 + }, 572 + } 573 + }
+122
backend/impl-rs/server/src/errors.rs
··· 1 + //! Lumina > Server > Errors 2 + //! 3 + //! This module defines custom error types used throughout the server. 4 + 5 + /* 6 + * Lumina/Peonies 7 + * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 8 + * 9 + * This software is licensed under the European Union Public Licence (EUPL) v1.2. 10 + * You may not use this work except in compliance with the Licence. 11 + * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 12 + * 13 + * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 14 + * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 15 + * See LICENSE file in the repository root for full details. 16 + * 17 + * 18 + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 19 + * See the Licence for the specific language governing permissions and limitations. [cite: 6] 20 + */ 21 + 22 + #[derive(Debug)] 23 + pub(crate) enum LuminaError { 24 + ConfInvalid(crate::EnvVar), 25 + DbError(LuminaDbError), 26 + Bb8RunErrorRedis(Box<bb8::RunError<redis::RedisError>>), 27 + Unknown, 28 + RocketFaillure(Box<rocket::Error>), 29 + BcryptError, 30 + RegisterEmailInUse, 31 + RegisterUsernameInUse, 32 + RegisterEmailNotValid, 33 + RegisterUsernameInvalid(crate::user::OnRegisterUsernameInvalid), 34 + RegisterPasswordNotValid(crate::user::OnRegisterPasswordNotValid), 35 + AuthenticationWrongPassword, 36 + UUidError, 37 + RegexError, 38 + SerializationError(serde_json::Error), 39 + JoinFaillure, 40 + AuthenticationNoSuchUser, 41 + } 42 + 43 + impl From<LuminaDbError> for LuminaError { 44 + fn from(v: LuminaDbError) -> Self { 45 + Self::DbError(v) 46 + } 47 + } 48 + 49 + #[derive(Debug)] 50 + pub(crate) enum LuminaDbError { 51 + Redis(Box<redis::RedisError>), 52 + Postgres(sqlx::Error), 53 + } 54 + 55 + impl From<rocket::Error> for LuminaError { 56 + fn from(err: rocket::Error) -> Self { 57 + LuminaError::RocketFaillure(Box::new(err)) 58 + } 59 + } 60 + 61 + impl From<serde_json::Error> for LuminaError { 62 + fn from(err: serde_json::Error) -> Self { 63 + LuminaError::SerializationError(err) 64 + } 65 + } 66 + 67 + impl From<sqlx::Error> for LuminaError { 68 + fn from(err: sqlx::Error) -> Self { 69 + LuminaError::DbError(LuminaDbError::Postgres(err)) 70 + } 71 + } 72 + 73 + impl From<redis::RedisError> for LuminaError { 74 + fn from(err: redis::RedisError) -> Self { 75 + LuminaError::DbError(LuminaDbError::Redis(Box::new(err))) 76 + } 77 + } 78 + 79 + impl From<bb8::RunError<redis::RedisError>> for LuminaError { 80 + fn from(err: bb8::RunError<redis::RedisError>) -> Self { 81 + LuminaError::Bb8RunErrorRedis(Box::new(err)) 82 + } 83 + } 84 + 85 + impl std::fmt::Display for LuminaError { 86 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 87 + write!( 88 + f, 89 + "{}", 90 + match self { 91 + LuminaError::ConfInvalid(s) => match s { 92 + crate::EnvVar::LUMINA_SERVER_ADDR => 93 + "LUMINA_SERVER_ADDR is an invalid address".to_string(), 94 + crate::EnvVar::LUMINA_SERVER_PORT => 95 + "LUMINA_SERVER_PORT is not a valid port number".to_string(), 96 + crate::EnvVar::LUMINA_POSTGRES_PORT => 97 + "LUMINA_POSTGRES_PORT is not a valid port number".to_string(), 98 + }, 99 + 100 + LuminaError::DbError(e) => match e { 101 + LuminaDbError::Redis(re) => format!("Redis error: {}", re), 102 + LuminaDbError::Postgres(pe) => format!("Postgres error: {}", pe), 103 + }, 104 + LuminaError::Bb8RunErrorRedis(e) => format!("Redis connection pool error: {}", e), 105 + LuminaError::RocketFaillure(e) => format!("Rocket error: {}", e), 106 + LuminaError::BcryptError => "Bcrypt error".to_string(), 107 + LuminaError::RegisterEmailInUse => "Email already in use".to_string(), 108 + LuminaError::RegisterUsernameInUse => "Username already in use".to_string(), 109 + LuminaError::RegisterEmailNotValid => "Email not valid".to_string(), 110 + LuminaError::RegisterUsernameInvalid(s) => format!("Username invalid: {}", s), 111 + LuminaError::RegisterPasswordNotValid(s) => format!("Password not valid: {}", s), 112 + LuminaError::AuthenticationWrongPassword => "Wrong password".to_string(), 113 + LuminaError::UUidError => "UUID error".to_string(), 114 + LuminaError::RegexError => "Regex error".to_string(), 115 + LuminaError::SerializationError(s) => format!("Serialization error: {}", s), 116 + LuminaError::JoinFaillure => "Process join failure".to_string(), 117 + LuminaError::AuthenticationNoSuchUser => "No such user".to_string(), 118 + LuminaError::Unknown => "Unknown error".to_string(), 119 + } 120 + ) 121 + } 122 + }
+340
backend/impl-rs/server/src/helpers/events.rs
··· 1 + //! Lumina > Server > Helpers > Events 2 + //! 3 + //! This module provides an event logging utility that logs messages to stdout 4 + //! with colored prefixes and optionally logs them to a database. 5 + //! 6 + //! This helps increase consistency in logging throughout the server codebase, centralises it, and 7 + //! improves readability and distinctability with its colored output. 8 + /* 9 + * Lumina/Peonies 10 + * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 11 + * 12 + * This software is licensed under the European Union Public Licence (EUPL) v1.2. 13 + * You may not use this work except in compliance with the Licence. 14 + * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 15 + * 16 + * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 17 + * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 18 + * See LICENSE file in the repository root for full details. 19 + * 20 + * 21 + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 22 + * See the Licence for the specific language governing permissions and limitations. [cite: 6] 23 + */ 24 + 25 + use crate::LuminaError; 26 + use crate::database::PgConn; 27 + use cynthia_con::{CynthiaColors, CynthiaStyles}; 28 + use time::{OffsetDateTime, PrimitiveDateTime}; 29 + 30 + /// Levels of logging supported by the Logger. 31 + #[derive(Debug)] 32 + pub enum EventType { 33 + Info, 34 + Warn, 35 + Error, 36 + Success, 37 + Failure, 38 + Log, 39 + Incoming, 40 + RegistrationError, 41 + AuthenticationError, 42 + SoftError, 43 + HTTPCode(u16), 44 + } 45 + 46 + /// A reusable logger that logs messages to stdout with colored prefixes 47 + /// and, when available, also logs entries into the database. 48 + /// 49 + /// The database log entry is simple, with the log type, the message, and a timestamp. 50 + pub(crate) enum EventLogger { 51 + /// Variant created when logger has a database, and the database nor environment have any settings blocking database logging. 52 + WithDatabase(Box<PgConn>), 53 + /// Only log to stdout 54 + OnlyStdout, 55 + } 56 + 57 + impl From<&PgConn> for EventLogger { 58 + fn from(db: &PgConn) -> Self { 59 + EventLogger::WithDatabase(Box::new(db.clone())) 60 + } 61 + } 62 + 63 + impl Clone for EventLogger { 64 + fn clone(&self) -> Self { 65 + match self { 66 + EventLogger::WithDatabase(db) => EventLogger::WithDatabase(Box::new((**db).clone())), 67 + EventLogger::OnlyStdout => EventLogger::OnlyStdout, 68 + } 69 + } 70 + } 71 + 72 + impl EventLogger { 73 + /// Creates a new logger instance. 74 + /// The `db` parameter can be `None` if the database isn't connected. 75 + pub fn new(db: &Option<PgConn>) -> Self { 76 + // For quick implementation we'll just check if not none and that's all. 77 + match db { 78 + Some(d) => Self::from(d), 79 + None => Self::OnlyStdout, 80 + } 81 + } 82 + 83 + /// Logs a message with the specified log level. 84 + /// This method prints to stdout with a colored prefix and, if a database connection is available, 85 + /// asynchronously inserts a log entry in the logs table. 86 + pub async fn log(&self, level: EventType, message: &str) { 87 + // Get the current timestamp. 88 + let now_odt = OffsetDateTime::now_utc(); 89 + let now_pdt = PrimitiveDateTime::new(now_odt.date(), now_odt.time()); 90 + 91 + // Determine the appropriate prefix for stdout. 92 + // These prefixes are colored and styled matching helpers::prefixes(). 93 + let (prefix, use_eprintln) = match level { 94 + EventType::Info => ("[INFO]".color_green().style_bold(), false), 95 + EventType::Warn => ("[WARN]".color_yellow().style_bold(), false), 96 + EventType::Error => ("[ERROR]".color_error_red().style_bold(), true), 97 + EventType::SoftError => ("[ERROR]".color_error_red().style_bold(), false), 98 + EventType::Success => ("[✅ SUCCESS]".color_ok_green().style_bold(), false), 99 + EventType::Failure => ("[✖️ FAILURE]".color_error_red().style_bold(), false), 100 + EventType::Log => ("[LOG]".color_blue().style_bold(), false), 101 + EventType::Incoming => ("[INCOMING]".color_lilac().style_bold(), false), 102 + EventType::RegistrationError => { 103 + ("[RegistrationError]".color_bright_red().style_bold(), true) 104 + } 105 + EventType::AuthenticationError => ( 106 + "[AuthenticationError]".color_bright_red().style_bold(), 107 + true, 108 + ), 109 + EventType::HTTPCode(code) => { 110 + let codestring = match code { 111 + 101 => format!("[HTTP/{} (Switching Protocols)]", code) 112 + .color_blue() 113 + .style_bold(), 114 + 200..=299 => format!("[HTTP/{} (OK)]", code) 115 + .color_ok_green() 116 + .style_bold(), 117 + 400..=499 => format!("[HTTP/{} (Client Error)]", code) 118 + .color_yellow() 119 + .style_bold(), 120 + 121 + 500..=599 => format!("[HTTP/{} (Server Error)]", code) 122 + .color_error_red() 123 + .style_bold(), 124 + _ => format!("[HTTP/{}]", code).color_blue().style_bold(), 125 + }; 126 + match code { 127 + 200..=499 => (codestring, false), 128 + 500..=599 => (codestring, true), 129 + _ => (codestring, false), 130 + } 131 + } 132 + }; 133 + 134 + let stdoutmsg = 135 + format!("{prefix} {message}").replace("\n", format!("\n{prefix} ").as_str()); 136 + 137 + // Log to the database if a connection is available. 138 + match self { 139 + EventLogger::WithDatabase(db_conn) => { 140 + // Log to stdout with the prefix. 141 + if use_eprintln { 142 + eprintln!("{stdoutmsg}"); 143 + } else { 144 + println!("{stdoutmsg}"); 145 + } 146 + // Prepare the basic values for the log entry. 147 + let level_str = match level { 148 + EventType::Info => String::from("INFO"), 149 + EventType::Warn => String::from("WARN"), 150 + EventType::SoftError | EventType::Error => String::from("ERROR"), 151 + EventType::Success => String::from("SUCCESS"), 152 + EventType::Failure => String::from("FAILURE"), 153 + EventType::Log => String::from("LOG"), 154 + EventType::Incoming => String::from("INCOMING"), 155 + EventType::RegistrationError => String::from("REGISTRATION_ERROR"), 156 + EventType::AuthenticationError => String::from("AUTHENTICATION_ERROR"), 157 + EventType::HTTPCode(code) => format!("HTTP/{}", code), 158 + }; 159 + let ansi_regex = regex::Regex::new(r"\x1B\[[0-?]*[ -/]*[@-~]") 160 + .map_err(|_| LuminaError::RegexError) 161 + .unwrap(); 162 + 163 + let message_db: String = ansi_regex 164 + .replace_all(message, "") 165 + .to_string() 166 + .chars() 167 + .filter(|c| !c.is_control() || c.is_whitespace()) 168 + .collect(); 169 + match sqlx::query!( 170 + "INSERT INTO logs (type, message, timestamp) VALUES ($1, $2, $3)", 171 + &level_str, 172 + &message_db, 173 + &now_pdt, 174 + ) 175 + .execute(&db_conn.postgres_pool) 176 + .await 177 + { 178 + Ok(_) => (), 179 + Err(p) => { 180 + panic!( 181 + "{0}\n\n\n{1}\n\n\n\n{0}", 182 + "Could not write logs to database! Crashing.", p 183 + ) 184 + } 185 + } 186 + } 187 + EventLogger::OnlyStdout => { 188 + // Log to stdout with the prefix. 189 + if use_eprintln { 190 + eprintln!("{stdoutmsg}"); 191 + } else { 192 + println!("{stdoutmsg}"); 193 + } 194 + } 195 + } 196 + } 197 + 198 + /// Convenience method to log an informational message. 199 + pub async fn info(&self, message: &str) { 200 + self.log(EventType::Info, message).await 201 + } 202 + 203 + /// Convenience method to log a warning message. 204 + pub async fn warn(&self, message: &str) { 205 + self.log(EventType::Warn, message).await 206 + } 207 + 208 + /// Convenience method to log an error message. 209 + pub async fn error(&self, message: &str) { 210 + self.log(EventType::Error, message).await 211 + } 212 + /// Convenience method to log a soft error message. 213 + pub async fn s_error(&self, message: &str) { 214 + self.log(EventType::Error, message).await 215 + } 216 + /// Convenience method to log a success message. 217 + pub async fn success(&self, message: &str) { 218 + self.log(EventType::Success, message).await 219 + } 220 + 221 + /// Convenience method to log a failure message. 222 + #[allow(unused)] 223 + pub async fn failure(&self, message: &str) { 224 + self.log(EventType::Failure, message).await 225 + } 226 + 227 + /// Convenience method to log a plain message without a specific log level. 228 + #[allow(unused)] 229 + pub async fn log_plain(&self, message: &str) { 230 + self.log(EventType::Log, message).await 231 + } 232 + 233 + /// Convenience method to log an incoming message. 234 + pub async fn incoming(&self, message: &str) { 235 + self.log(EventType::Incoming, message).await 236 + } 237 + 238 + /// Convenience method to log a registration error message. 239 + pub async fn registration_error(&self, message: &str) { 240 + self.log(EventType::RegistrationError, message).await 241 + } 242 + /// Convenience method to log a registration error message. 243 + pub async fn authentication_error(&self, message: &str) { 244 + self.log(EventType::AuthenticationError, message).await 245 + } 246 + 247 + /// Convenience method to log an HTTP code message. 248 + pub async fn http_code(&self, code: u16, message: &str) { 249 + self.log(EventType::HTTPCode(code), message).await 250 + } 251 + } 252 + #[macro_export] 253 + macro_rules! info_elog { 254 + ($logger:expr, $($arg:tt)*) => { 255 + $logger.info(&format!($($arg)*)).await 256 + }; 257 + } 258 + 259 + #[macro_export] 260 + /// Takes an event log object and then runs .warn on it, formatting using the other arguments. 261 + macro_rules! warn_elog { 262 + ($logger:expr, $($arg:tt)*) => { 263 + $logger.warn(&format!($($arg)*)).await 264 + }; 265 + } 266 + 267 + #[macro_export] 268 + /// Takes an event log object and then runs .error on it, formatting using the other arguments. 269 + macro_rules! error_elog { 270 + ($logger:expr, $($arg:tt)*) => { 271 + $logger.error(&format!($($arg)*)).await 272 + }; 273 + } 274 + 275 + #[macro_export] 276 + /// Takes an event log object and then runs .s_error on it, formatting using the other arguments. 277 + macro_rules! soft_error_elog { 278 + ($logger:expr, $($arg:tt)*) => { 279 + $logger.s_error(&format!($($arg)*)).await 280 + }; 281 + } 282 + 283 + #[macro_export] 284 + /// Takes an event log object and then runs .success on it, formatting using the other arguments. 285 + macro_rules! success_elog { 286 + ($logger:expr, $($arg:tt)*) => { 287 + $logger.success(&format!($($arg)*)).await 288 + }; 289 + } 290 + 291 + /// Takes an event log object and then runs .faillure on it, formatting using the other arguments. 292 + #[macro_export] 293 + macro_rules! fail_elog { 294 + ($logger:expr, $($arg:tt)*) => { 295 + $logger.failure(&format!($($arg)*)).await 296 + }; 297 + } 298 + 299 + /// Takes an event log object and then runs .log_plain on it, formatting using the other arguments. 300 + #[macro_export] 301 + macro_rules! elog { 302 + ($logger:expr, $($arg:tt)*) => { 303 + $logger.log_plain(&format!($($arg)*)).await 304 + }; 305 + } 306 + 307 + #[macro_export] 308 + /// Takes an event log object and then runs .incoming on it, formatting using the other arguments. 309 + macro_rules! incoming_elog { 310 + ($logger:expr, $($arg:tt)*) => { 311 + $logger.incoming(&format!($($arg)*)).await 312 + }; 313 + } 314 + 315 + #[macro_export] 316 + /// Takes an event log object and then runs .registration_error on it, formatting using the other arguments. 317 + macro_rules! registration_error_elog { 318 + ($logger:expr, $($arg:tt)*) => { 319 + $logger.registration_error(&format!($($arg)*)).await 320 + }; 321 + } 322 + 323 + #[macro_export] 324 + /// Takes an event log object and then runs .authentication_error on it, formatting using the other 325 + /// arguments. 326 + macro_rules! authentication_error_elog { 327 + ($logger:expr, $($arg:tt)*) => { 328 + $logger.authentication_error(&format!($($arg)*)).await 329 + }; 330 + } 331 + 332 + #[macro_export] 333 + /// Takes an event log object and then runs .http_code on it, formatting using the other 334 + /// arguments. 335 + macro_rules! http_code_elog { 336 + ($logger:expr, $code:expr, $($arg:tt)*) => 337 + { 338 + $logger.http_code($code, &format!($($arg)*)).await 339 + }; 340 + }
+22
backend/impl-rs/server/src/helpers/mod.rs
··· 1 + //! Lumina > Server > Helpers 2 + //! 3 + //! Shared helper functions and utilities for the server. 4 + 5 + /* 6 + * Lumina/Peonies 7 + * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 8 + * 9 + * This software is licensed under the European Union Public Licence (EUPL) v1.2. 10 + * You may not use this work except in compliance with the Licence. 11 + * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 12 + * 13 + * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 14 + * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 15 + * See LICENSE file in the repository root for full details. 16 + * 17 + * 18 + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 19 + * See the Licence for the specific language governing permissions and limitations. [cite: 6] 20 + */ 21 + /// Shared helper functions and utilities for the server. 22 + pub mod events;
+572
backend/impl-rs/server/src/main.rs
··· 1 + //! Lumina > Server 2 + //! 3 + //! The main entrypoint for the Lumina server-side, reaching out to other modules to compose 4 + //! the full server functionality including database connections, webserver, websockets, CLI 5 + //! commands, and more. 6 + 7 + /* 8 + * Lumina/Peonies 9 + * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 10 + * 11 + * This software is licensed under the European Union Public Licence (EUPL) v1.2. 12 + * You may not use this work except in compliance with the Licence. 13 + * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 14 + * 15 + * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 16 + * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 17 + * See LICENSE file in the repository root for full details. 18 + * 19 + * 20 + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 21 + * See the Licence for the specific language governing permissions and limitations. [cite: 6] 22 + */ 23 + 24 + extern crate dotenv; 25 + #[macro_use] 26 + extern crate rocket; 27 + mod client_communication; 28 + mod database; 29 + pub mod errors; 30 + pub mod helpers; 31 + mod staticroutes; 32 + #[cfg(test)] 33 + mod tests; 34 + mod timeline; 35 + use helpers::events::EventLogger; 36 + use rocket::config::LogLevel; 37 + use std::io::ErrorKind; 38 + use std::{net::IpAddr, process, sync::Arc}; 39 + use uuid::Uuid; 40 + mod user; 41 + 42 + struct AppState(Arc<InnerAppState>); 43 + struct InnerAppState { 44 + config: ServerConfig, 45 + db: DbConn, 46 + event_logger: EventLogger, 47 + } 48 + mod rate_limiter; 49 + use database::DbConn; 50 + use rate_limiter::{AuthRateLimiter, GeneralRateLimiter}; 51 + #[derive(Debug, Clone)] 52 + struct ServerConfig { 53 + port: u16, 54 + host: IpAddr, 55 + } 56 + 57 + #[allow(non_camel_case_types)] 58 + #[derive(Debug, Clone, Copy)] 59 + enum EnvVar { 60 + LUMINA_SERVER_ADDR, 61 + LUMINA_SERVER_PORT, 62 + LUMINA_POSTGRES_PORT, 63 + } 64 + impl std::fmt::Display for EnvVar { 65 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 66 + write!( 67 + f, 68 + "{}", 69 + match self { 70 + EnvVar::LUMINA_SERVER_ADDR => "LUMINA_SERVER_ADDR", 71 + EnvVar::LUMINA_SERVER_PORT => "LUMINA_SERVER_PORT", 72 + EnvVar::LUMINA_POSTGRES_PORT => "LUMINA_POSTGRES_PORT", 73 + } 74 + ) 75 + } 76 + } 77 + use crate::EnvVar::*; 78 + use crate::database::{DatabaseConnections, PgConn}; 79 + use crate::errors::LuminaError; 80 + use cynthia_con::{CynthiaColors, CynthiaStyles}; 81 + use dotenv::dotenv; 82 + use tokio::spawn; 83 + 84 + fn config_get() -> Result<ServerConfig, LuminaError> { 85 + let addr = { 86 + let s = std::env::var("LUMINA_SERVER_ADDR").unwrap_or(String::from("127.0.0.1")); 87 + s.parse::<IpAddr>() 88 + .map_err(|_| LuminaError::ConfInvalid(LUMINA_SERVER_ADDR))? 89 + }; 90 + let port = { 91 + let s = std::env::var("LUMINA_SERVER_PORT").unwrap_or(String::from("8085")); 92 + s.parse::<u16>() 93 + .map_err(|_| LuminaError::ConfInvalid(LUMINA_SERVER_PORT))? 94 + }; 95 + Ok(ServerConfig { port, host: addr }) 96 + } 97 + 98 + #[rocket::main] 99 + async fn main() { 100 + let me = format!("Lumina Server, version {}", env!("CARGO_PKG_VERSION")); 101 + let ev_log: EventLogger = EventLogger::new(&None); 102 + let args: Vec<String> = std::env::args().skip(1).collect(); 103 + match ( 104 + args.is_empty(), 105 + args.first().unwrap_or(&String::new()).as_str(), 106 + ) { 107 + (true, _) | (false, "start") | (false, "") => { 108 + dotenv().ok(); 109 + info_elog!(ev_log, "Starting {}.", me.clone().color_lightblue()); 110 + let greet = format!( 111 + "{} and contributors, licenced under the {} {}, as readable in our licence file.", 112 + "MLC Bloeiman".color_pink(), 113 + "European Union Public License v1.2".color_blue(), 114 + " -- with specific terms".color_cyan() 115 + ); 116 + info_elog!(ev_log, "{greet}"); 117 + println!("{}", cynthia_con::horizline()); 118 + warn_elog!( 119 + ev_log, 120 + "Lumina is still in early development, and should not be used in production in any way. Please use at your own risk." 121 + ); 122 + match config_get() { 123 + Ok(config) => { 124 + let mut interval = 125 + tokio::time::interval(std::time::Duration::from_millis(3000)); 126 + let mut db_mut: Option<PgConn> = None; 127 + let ev_log: EventLogger = EventLogger::new(&None); 128 + 129 + let mut db_tries: usize = 0; 130 + while db_mut.is_none() { 131 + interval.tick().await; 132 + db_mut = match database::setup().await { 133 + Ok(db) => Some(db), 134 + // Err(LuminaError::ConfMissing(a)) => { 135 + // error_elog!( 136 + // ev_log, 137 + // "Missing environment variable {}, which is required to continue. Please make sure it is set, or change other variables to make it redundant, if possible.", 138 + // a.color_bright_orange() 139 + // ); 140 + // None 141 + // } 142 + Err(LuminaError::ConfInvalid(a)) => { 143 + error_elog!( 144 + ev_log, 145 + "Invalid environment variable: {}", 146 + a.to_string().color_bright_orange() 147 + ); 148 + None 149 + } 150 + 151 + Err(LuminaError::DbError(crate::errors::LuminaDbError::Postgres( 152 + a, 153 + ))) => { 154 + error_elog!(ev_log, "While connecting to postgres database: {}", a); 155 + None 156 + } 157 + Err(LuminaError::DbError(crate::errors::LuminaDbError::Redis(a))) => { 158 + error_elog!(ev_log, "While connecting to Redis: {}", a); 159 + None 160 + } 161 + Err(LuminaError::Bb8RunErrorRedis(a)) => { 162 + error_elog!(ev_log, "While connecting to Redis: {}", a); 163 + None 164 + } 165 + Err(_) => { 166 + error_elog!( 167 + ev_log, 168 + "Unknown error: could not setup database connection.", 169 + ); 170 + None 171 + } 172 + }; 173 + if db_mut.is_none() { 174 + if db_tries < 4 { 175 + db_tries += 1; 176 + warn_elog!( 177 + ev_log, 178 + "Retrying database connection in 3 seconds. (try {})", 179 + db_tries 180 + ) 181 + } else { 182 + error_elog!( 183 + ev_log, 184 + "Failed to connect to database four times, not retrying." 185 + ); 186 + process::exit(1); 187 + } 188 + } 189 + } 190 + // If we got here, we have a database connection. 191 + 192 + let pg = db_mut.unwrap(); 193 + let db: DbConn = pg.clone().into(); 194 + let ev_log: EventLogger = EventLogger::new(&Some(pg)); 195 + success_elog!(ev_log, "Database connected."); 196 + 197 + if cfg!(debug_assertions) { 198 + let redis_pool = db.get_redis_pool(); 199 + let mut redis_conn = redis_pool.get().await.unwrap(); 200 + timeline::invalidate_timeline_cache(&mut redis_conn, Uuid::nil()) 201 + .await 202 + .unwrap(); 203 + let global = timeline::fetch_timeline_post_ids( 204 + ev_log.clone(), 205 + &db, 206 + &Uuid::nil(), 207 + None, 208 + ) 209 + .await 210 + .unwrap_or_default(); 211 + if global.1 == 0 { 212 + println!( 213 + "Debug mode: Inserting Hello World post and two test users if not exists." 214 + ); 215 + 216 + let generated_uuid = Uuid::new_v4(); 217 + let hello_content = "Hello world"; 218 + 219 + match db.recreate().await.into() { 220 + DbConn::PgsqlConnection(pg_pool, _) => { 221 + // Insert Hello World post and timeline entry if not exists 222 + let user_1_: Result<user::User, LuminaError> = 223 + match user::User::create_user( 224 + String::from("test@lumina123.co"), 225 + String::from("testuser1"), 226 + String::from("MyTestPassw9292!"), 227 + &db, 228 + ) 229 + .await 230 + { 231 + Ok(a) => Ok(a), 232 + // But if a user exists, we just pass the user. 233 + Err(LuminaError::RegisterUsernameInUse) 234 + | Err(LuminaError::RegisterEmailInUse) => { 235 + user::User::get_user_by_identifier( 236 + String::from("testuser1"), 237 + &db, 238 + ) 239 + .await 240 + } 241 + Err(e) => Err(e), 242 + }; 243 + 244 + let user_2_ = match user::User::create_user( 245 + String::from("test@lumina234.co"), 246 + String::from("testuser2"), 247 + String::from("MyTestPassw9292!"), 248 + &db, 249 + ) 250 + .await 251 + { 252 + Ok(a) => Ok(a), 253 + // But if a user exists, we just pass the user. 254 + Err(LuminaError::RegisterUsernameInUse) 255 + | Err(LuminaError::RegisterEmailInUse) => { 256 + user::User::get_user_by_identifier( 257 + String::from("testuser2"), 258 + &db, 259 + ) 260 + .await 261 + } 262 + Err(e) => Err(e), 263 + }; 264 + 265 + match (user_1_, user_2_) { 266 + (Ok(user_1), Ok(_)) => { 267 + println!( 268 + "Created two users with password 'MyTestPassw9292!' and usernames 'testuser1' and 'testuser2'." 269 + ); 270 + sqlx::query!("INSERT INTO post_text (id, author_id, content, created_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP)", 271 + &generated_uuid, &user_1.id, &hello_content 272 + ) 273 + .execute(&pg_pool) 274 + .await.unwrap_or_default(); 275 + let add_clone = ev_log.clone(); 276 + timeline::add_to_timeline( 277 + add_clone, 278 + &db, 279 + &Uuid::nil(), 280 + &generated_uuid, 281 + crate::timeline::ItemType::Text, 282 + ) 283 + .await 284 + .unwrap_or(()); 285 + } 286 + z => { 287 + println!( 288 + "Ran into some issues: user 1: {:?}, user 2: {:?} ", 289 + z.0, z.1 290 + ); 291 + } 292 + } 293 + } 294 + } 295 + } 296 + } 297 + 298 + let appstate = AppState(Arc::from(InnerAppState { 299 + config: config.clone(), 300 + db: db, 301 + event_logger: ev_log.clone(), 302 + })); 303 + 304 + // Create a simple in-memory IP-based rate limiter. 305 + // Default: allow 5 events per 10 seconds (0.5 tokens/sec) with capacity 10. 306 + let rate_limiter = GeneralRateLimiter::new(0.5, 10.0); 307 + 308 + // Dedicated, stricter limiter for authentication attempts (helps stop brute-force): 309 + // e.g. allow 2 attempts per 10 seconds (0.2 tokens/sec) with capacity 4. 310 + let auth_rate_limiter = AuthRateLimiter::new(0.2, 4.0); 311 + 312 + let def = rocket::Config { 313 + port: config.port, 314 + address: config.host, 315 + // TODO: Use Lumina's logging instead, no logging is bad practise. 316 + // Technically, we currently do this by just shipping it into each http 317 + // route. HOWEVER, we don't have a 404 route! 318 + log_level: LogLevel::Off, 319 + ..rocket::Config::default() 320 + }; 321 + let server = rocket::build() 322 + .configure(def) 323 + .mount( 324 + "/", 325 + routes![ 326 + staticroutes::index, 327 + staticroutes::lumina_js, 328 + staticroutes::lumina_d_js, 329 + staticroutes::client_rev, 330 + staticroutes::lumina_css, 331 + staticroutes::licence, 332 + staticroutes::robots, 333 + staticroutes::license_redirect, 334 + client_communication::wsconnection, 335 + staticroutes::logo_svg, 336 + staticroutes::logo_png, 337 + staticroutes::favicon, 338 + ], 339 + ) 340 + .manage(appstate) 341 + .manage(rate_limiter) 342 + .manage(auth_rate_limiter) 343 + .launch(); 344 + let s = spawn(server); 345 + // Wait for server to start, then check if it's running. 346 + tokio::time::sleep(std::time::Duration::from_secs(2)).await; 347 + // Check if server is still running 348 + if !s.is_finished() { 349 + // If it is, we can assume it started successfully. 350 + println!("{}", cynthia_con::horizline()); 351 + info_elog!(ev_log, "{}\n{greet}", me.clone().color_lightblue()); 352 + 353 + success_elog!( 354 + ev_log, 355 + "Lumina started successfully on {}.", 356 + format!( 357 + "{}://{}:{}/", 358 + if std::env::var("LUMINA_SERVER_HTTPS") 359 + .unwrap_or(String::from("false")) 360 + .to_lowercase() 361 + == "true" 362 + { 363 + "https" 364 + } else { 365 + "http" 366 + }, 367 + config.host, 368 + config.port 369 + ) 370 + .color_lightblue() 371 + ); 372 + info_elog!( 373 + ev_log, 374 + "\nRemember: You can also visit the licence on '{}'!", 375 + format!( 376 + "{}://{}:{}/licence", 377 + if std::env::var("LUMINA_SERVER_HTTPS") 378 + .unwrap_or(String::from("false")) 379 + .to_lowercase() 380 + == "true" 381 + { 382 + "https" 383 + } else { 384 + "http" 385 + }, 386 + config.host, 387 + config.port 388 + ) 389 + .color_lightblue() 390 + ); 391 + } 392 + let result = { 393 + let g = s.await; 394 + match g { 395 + Ok(x) => x.map_err(|e| LuminaError::RocketFaillure(Box::new(e))), 396 + Err(..) => Err(LuminaError::JoinFaillure), 397 + } 398 + }; 399 + match result { 400 + Ok(_) => {} 401 + Err(LuminaError::RocketFaillure(e)) => { 402 + // This handling should slowly expand as I run into newer ones, the 'defh' (default handling) is good enough, but for the most-bumped into errors, I'd like to give more human responses. 403 + let defh = 404 + async || error_elog!(ev_log, "Error starting server: {:?}", e); 405 + match e.kind() { 406 + rocket::error::ErrorKind::Bind(e) => match e.kind() { 407 + ErrorKind::AddrInUse => { 408 + error_elog!( 409 + ev_log, 410 + "Another program or instance is running on this port or adress." 411 + ); 412 + soft_error_elog!( 413 + ev_log, 414 + "Make sure you have not double-started Lumina, or have a different program serving on this port!" 415 + ); 416 + soft_error_elog!( 417 + ev_log, 418 + "{}", 419 + format!("Technical explanation: {}", e).style_dim() 420 + ); 421 + } 422 + _ => defh().await, 423 + }, 424 + _ => defh().await, 425 + } 426 + process::exit(1); 427 + } 428 + Err(_) => { 429 + error_elog!(ev_log, "Unknown error starting server.",); 430 + } 431 + } 432 + } 433 + // Err(LuminaError::ConfMissing(a)) => { 434 + // error_elog!( 435 + // ev_log, 436 + // "Missing environment variable {}, which is required to continue. Please make sure it is set, or change other variables to make it redundant, if possible.", 437 + // a.color_bright_orange() 438 + // ); 439 + // process::exit(1); 440 + // } 441 + Err(LuminaError::ConfInvalid(a)) => { 442 + error_elog!( 443 + ev_log, 444 + "Invalid environment variable: {}", 445 + a.to_string().color_bright_orange() 446 + ); 447 + process::exit(1); 448 + } 449 + Err(_) => { 450 + error_elog!( 451 + ev_log, 452 + "Unknown error: could not setup server configuration.", 453 + ); 454 + process::exit(1); 455 + } 456 + }; 457 + } 458 + (false, "licence") | (false, "license") => { 459 + println!( 460 + "Licence for {} and its {}.", 461 + me.color_lightblue().style_italic(), 462 + "Lumina Client".color_yellow().style_italic() 463 + ); 464 + println!("MLC Bloeiman and contributors."); 465 + println!("{}", cynthia_con::horizline()); 466 + println!("{}", include_str!("../../LICENCE")); 467 + } 468 + (false, "help") | (false, "man") => { 469 + fn table_to_centered_string(a: &mut tabled::Table) -> String { 470 + let s: Vec<String> = a 471 + .to_string() 472 + .split("\n") 473 + .map(|s| s.style_centered()) 474 + .collect(); 475 + s.join("\n") 476 + } 477 + println!("{}", me); 478 + { 479 + println!("{}", "Subcommands".style_centered().style_bold()); 480 + println!(); 481 + println!( 482 + "\t\t{}|{}\tShow this help", 483 + "help".color_lightblue().style_italic(), 484 + "man".color_lightblue().style_italic() 485 + ); 486 + println!( 487 + "\t\t{}\t\tShow version and exit", 488 + "version".color_lightblue().style_italic() 489 + ); 490 + println!( 491 + "\t\t{}\t\tShow licence and exit", 492 + "licence".color_lightblue().style_italic() 493 + ); 494 + println!( 495 + "\t\t{}\t\tStart Lumina server", 496 + "start".color_lightblue().style_italic() 497 + ); 498 + } 499 + println!(); 500 + { 501 + println!("{}", "Environment variables".style_centered().style_bold()); 502 + println!(); 503 + let mut builder = tabled::builder::Builder::new(); 504 + builder.push_record(["Name", "Default value", "Description"]); 505 + builder.push_record([ 506 + "LUMINA_REDIS_URL", 507 + r#"redis://127.0.0.1/"#, 508 + r#"The URL for the Redis server."#, 509 + ]); 510 + builder.push_record([ 511 + "LUMINA_DB_SALT", 512 + r#"sal"#, 513 + r#"The salting to use for some data on the database."#, 514 + ]); 515 + builder.push_record([ 516 + "LUMINA_SERVER_PORT", 517 + r#"8085"#, 518 + r#"Port for Lumina to accept HTTP requests on."#, 519 + ]); 520 + builder.push_record(["LUMINA_SERVER_ADDR", r#"127.0.0.1"#, "Address for Lumina to accept HTTP requests on. (usually '127.0.0.1' or '0.0.0.0')"]); 521 + builder.push_record(["LUMINA_SERVER_HTTPS", r#"false"#, "Wether to use 'https' rather than 'http' in links, etc. Recommendation is to set to true."]); 522 + builder.push_record([ 523 + "LUMINA_SYNC_IID", 524 + r#"localhost"#, 525 + "Broadcasted domain name, should be equal to public domain name.", 526 + ]); 527 + builder.push_record([ 528 + "LUMINA_SYNC_INTERVAL", 529 + r#"30"#, 530 + "Specifies the interval between syncs. Minimum is 30.", 531 + ]); 532 + builder.push_record([ 533 + "LUMINA_POSTGRES_PORT", 534 + r#"5432"#, 535 + r#"The port to contact the database on."#, 536 + ]); 537 + builder.push_record([ 538 + "LUMINA_POSTGRES_HOST", 539 + r#"localhost"#, 540 + r#"The address to contact the database on."#, 541 + ]); 542 + builder.push_record([ 543 + "LUMINA_POSTGRES_USERNAME", 544 + r#""#, 545 + r#"The username to log in to the database with."#, 546 + ]); 547 + builder.push_record(["LUMINA_POSTGRES_PASSWORD", r#""#, r#"The password to log in to the database with. If not set, Lumina will try without."#]); 548 + builder.push_record([ 549 + "LUMINA_POSTGRES_DATABASE", 550 + r#""#, 551 + r#"The name of the database to use."#, 552 + ]); 553 + println!( 554 + "{}", 555 + table_to_centered_string( 556 + builder.build().with(tabled::settings::Style::modern()) 557 + ) 558 + .color_lilac() 559 + .style_dim() 560 + ); 561 + } 562 + } 563 + (false, unknown) => { 564 + soft_error_elog!( 565 + ev_log, 566 + "Unknown subcommand, '{}', use '{}' for available commands.'", 567 + unknown.color_blue().style_italic(), 568 + "help".color_lightblue().style_italic() 569 + ) 570 + } 571 + } 572 + }
+148
backend/impl-rs/server/src/rate_limiter.rs
··· 1 + //! Lumina > Server > Rate Limiter 2 + //! 3 + //! This module implements a simple rate limiter using a token bucket algorithm. 4 + 5 + /* 6 + * Lumina/Peonies 7 + * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 8 + * 9 + * This software is licensed under the European Union Public Licence (EUPL) v1.2. 10 + * You may not use this work except in compliance with the Licence. 11 + * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 12 + * 13 + * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 14 + * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 15 + * See LICENSE file in the repository root for full details. 16 + * 17 + * 18 + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 19 + * See the Licence for the specific language governing permissions and limitations. [cite: 6] 20 + */ 21 + 22 + use rocket::State; 23 + use rocket::http::Status; 24 + use rocket::request::{FromRequest, Outcome, Request}; 25 + use std::collections::HashMap; 26 + use std::net::IpAddr; 27 + use std::time::Instant; 28 + use tokio::sync::Mutex; 29 + 30 + /// A request guard that enforces the rate limit. Add as a parameter to handlers 31 + /// (e.g. `rate: RateLimit`) to have the connection checked before the handler 32 + /// runs. On failure Rocket will respond with 429 Too Many Requests. 33 + pub struct RateLimit; 34 + 35 + #[rocket::async_trait] 36 + impl<'r> FromRequest<'r> for RateLimit { 37 + type Error = (); 38 + 39 + async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> { 40 + // Try to get client IP from Rocket request 41 + let ip = req.client_ip(); 42 + 43 + // Get the limiter from managed state (GeneralRateLimiter wrapper) 44 + let limiter = match req.guard::<&State<GeneralRateLimiter>>().await { 45 + Outcome::Success(s) => s, 46 + _ => return Outcome::Success(RateLimit), // If limiter not present, allow through 47 + }; 48 + 49 + let allowed = limiter.allow_ip(ip).await; 50 + if allowed { 51 + Outcome::Success(RateLimit) 52 + } else { 53 + Outcome::Error((Status::TooManyRequests, ())) 54 + } 55 + } 56 + } 57 + 58 + /// Simple token-bucket rate limiter keyed by string (IP address). 59 + pub struct RateLimiter { 60 + inner: Mutex<HashMap<String, TokenBucket>>, 61 + refill_per_second: f64, 62 + capacity: f64, 63 + } 64 + 65 + struct TokenBucket { 66 + tokens: f64, 67 + last: Instant, 68 + } 69 + 70 + impl RateLimiter { 71 + /// Create a new RateLimiter. 72 + /// refill_per_second: how many tokens are added per second 73 + /// capacity: maximum number of tokens stored 74 + pub fn new(refill_per_second: f64, capacity: f64) -> Self { 75 + Self { 76 + inner: Mutex::new(HashMap::new()), 77 + refill_per_second, 78 + capacity, 79 + } 80 + } 81 + 82 + /// Allow or deny a single event for the given key (usually an IP string). 83 + /// Returns true if allowed (consumes one token), false if rate limited. 84 + pub async fn allow(&self, key: &str) -> bool { 85 + let mut map = self.inner.lock().await; 86 + let now = Instant::now(); 87 + let bucket = map.entry(key.to_string()).or_insert(TokenBucket { 88 + tokens: self.capacity, 89 + last: now, 90 + }); 91 + 92 + // refill 93 + let elapsed = now.duration_since(bucket.last).as_secs_f64(); 94 + let refill = elapsed * self.refill_per_second; 95 + bucket.tokens = (bucket.tokens + refill).min(self.capacity); 96 + bucket.last = now; 97 + 98 + if bucket.tokens >= 1.0 { 99 + bucket.tokens -= 1.0; 100 + true 101 + } else { 102 + false 103 + } 104 + } 105 + 106 + /// Convenience: accept an Option<IpAddr> and use a string key. 107 + pub async fn allow_ip(&self, ip: Option<IpAddr>) -> bool { 108 + let key = match ip { 109 + Some(a) => a.to_string(), 110 + None => "unknown".to_string(), 111 + }; 112 + self.allow(&key).await 113 + } 114 + } 115 + 116 + // A lightweight request-guard helper is provided in `client_communication.rs` by using 117 + // Rocket's `State<RateLimiter>` and implementing a small guard there. The core limiter 118 + // lives here so other code can call it directly if needed. 119 + 120 + /// Wrapper type used to manage a separate auth-specific limiter in Rocket's state. 121 + /// Rocket distinguishes managed state by Rust type, so we expose this new type to 122 + /// allow both a general `RateLimiter` and a dedicated `AuthRateLimiter`. 123 + pub struct AuthRateLimiter(pub RateLimiter); 124 + 125 + impl AuthRateLimiter { 126 + /// Create a new AuthRateLimiter which internally uses a RateLimiter. 127 + pub fn new(refill_per_second: f64, capacity: f64) -> Self { 128 + AuthRateLimiter(RateLimiter::new(refill_per_second, capacity)) 129 + } 130 + 131 + /// Delegate allow_ip to the inner limiter. 132 + pub async fn allow_ip(&self, ip: Option<IpAddr>) -> bool { 133 + self.0.allow_ip(ip).await 134 + } 135 + } 136 + 137 + /// Wrapper for a general-purpose limiter so Rocket can manage a distinct type. 138 + pub struct GeneralRateLimiter(pub RateLimiter); 139 + 140 + impl GeneralRateLimiter { 141 + pub fn new(refill_per_second: f64, capacity: f64) -> Self { 142 + GeneralRateLimiter(RateLimiter::new(refill_per_second, capacity)) 143 + } 144 + 145 + pub async fn allow_ip(&self, ip: Option<IpAddr>) -> bool { 146 + self.0.allow_ip(ip).await 147 + } 148 + }
+194
backend/impl-rs/server/src/staticroutes.rs
··· 1 + //! Lumina > Server > Static Routes 2 + //! 3 + //! This module defines static routes for serving static files like CSS, JS, and images. 4 + 5 + /* 6 + * Lumina/Peonies 7 + * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 8 + * 9 + * This software is licensed under the European Union Public Licence (EUPL) v1.2. 10 + * You may not use this work except in compliance with the Licence. 11 + * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 12 + * 13 + * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 14 + * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 15 + * See LICENSE file in the repository root for full details. 16 + * 17 + * 18 + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 19 + * See the Licence for the specific language governing permissions and limitations. [cite: 6] 20 + */ 21 + 22 + extern crate rocket; 23 + use rocket::http::ContentType; 24 + 25 + use rocket::response::content::{RawCss, RawText}; 26 + 27 + use rocket::response::content::RawJavaScript; 28 + 29 + use rocket::response::content::RawHtml; 30 + 31 + use crate::{AppState, http_code_elog}; 32 + use rocket::State; 33 + 34 + #[get("/")] 35 + pub(crate) async fn index(state: &State<AppState>) -> RawHtml<String> { 36 + let ev_log = { 37 + let appstate = state.0.clone(); 38 + appstate.event_logger.clone() 39 + }; 40 + http_code_elog!(ev_log, 200, "/"); 41 + let js = if cfg!(debug_assertions) { 42 + "/static/lumina.mjs" 43 + } else { 44 + "/static/lumina.min.mjs" 45 + }; 46 + RawHtml(format!( 47 + r#"<!doctype html> 48 + <html lang="en"> 49 + <head> 50 + <meta charset="UTF-8" /> 51 + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> 52 + <title>Lumina</title> 53 + <link rel="preconnect" href="https://fontlay.com" corossorigin /> 54 + <link href="https://fontlay.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Elms+Sans:ital,wght@0,100..900;1,100..900&family=Gantari:ital,wght@0,100..900;1,100..900&family=Josefin+Sans:ital,wght@0,100..700;1,100..700&family=Vend+Sans&display=swap" rel="stylesheet"> 55 + 56 + <link 57 + rel="stylesheet" 58 + href="/static/lumina.css" 59 + /> 60 + <meta name="robots" content="noai, noimageai, nofollow"> 61 + <script> 62 + window.clientHash = "{}"; 63 + </script> 64 + <script type="module" src="{}"></script> 65 + </head> 66 + <body id="app"></body> 67 + </html>"#, 68 + include_str!("../../client/priv/static/lumina_client_rev.hash").trim(), 69 + js, 70 + )) 71 + } 72 + 73 + #[get("/static/lumina.min.mjs")] 74 + pub(crate) async fn lumina_js(state: &State<AppState>) -> RawJavaScript<String> { 75 + let ev_log = { 76 + let appstate = state.0.clone(); 77 + appstate.event_logger.clone() 78 + }; 79 + http_code_elog!(ev_log, 200, "/static/lumina.min.mjs"); 80 + 81 + RawJavaScript(include_str!("../../client/priv/static/lumina_client.min.mjs").to_string()) 82 + } 83 + 84 + #[get("/static/lumina.mjs")] 85 + pub(crate) async fn lumina_d_js(state: &State<AppState>) -> RawJavaScript<String> { 86 + let ev_log = { 87 + let appstate = state.0.clone(); 88 + appstate.event_logger.clone() 89 + }; 90 + http_code_elog!(ev_log, 200, "/static/lumina.mjs"); 91 + 92 + RawJavaScript(include_str!("../../client/priv/static/lumina_client.mjs").to_string()) 93 + } 94 + 95 + /// Serves a single hash, meant to indicate what client version is expected (what client version the 96 + /// server was built with, more specifically...) 97 + /// This hash is also incorporated into the HTML loading the client as 98 + /// ```javascript 99 + /// window.clientHash 100 + /// ``` 101 + /// This allows a client to check if the hash it carries still matches the hash currently served, if 102 + /// not, it'll prompt a reload. 103 + #[get("/api/client-rev")] 104 + pub(crate) async fn client_rev(state: &State<AppState>) -> RawText<String> { 105 + let ev_log = { 106 + let appstate = state.0.clone(); 107 + appstate.event_logger.clone() 108 + }; 109 + http_code_elog!(ev_log, 200, "/client-rev"); 110 + 111 + RawText( 112 + include_str!("../../client/priv/static/lumina_client_rev.hash") 113 + .trim() 114 + .to_string(), 115 + ) 116 + } 117 + 118 + #[get("/static/lumina.css")] 119 + pub(crate) async fn lumina_css(state: &State<AppState>) -> RawCss<String> { 120 + let ev_log = { 121 + let appstate = state.0.clone(); 122 + appstate.event_logger.clone() 123 + }; 124 + http_code_elog!(ev_log, 200, "/static/lumina.css"); 125 + 126 + RawCss(include_str!("../../client/priv/static/lumina_client.css").to_string()) 127 + } 128 + 129 + #[get("/licence")] 130 + pub(crate) async fn licence(state: &State<AppState>) -> RawText<String> { 131 + let ev_log = { 132 + let appstate = state.0.clone(); 133 + appstate.event_logger.clone() 134 + }; 135 + http_code_elog!(ev_log, 200, "/licence"); 136 + 137 + RawText(include_str!("../../LICENCE").to_string()) 138 + } 139 + 140 + #[get("/robots.txt")] 141 + pub(crate) async fn robots(state: &State<AppState>) -> RawText<String> { 142 + let ev_log = { 143 + let appstate = state.0.clone(); 144 + appstate.event_logger.clone() 145 + }; 146 + http_code_elog!(ev_log, 200, "/robots.txt"); 147 + 148 + RawText(include_str!("../../robots.txt").to_string()) 149 + } 150 + #[get("/license")] 151 + pub(crate) async fn license_redirect() -> rocket::response::Redirect { 152 + rocket::response::Redirect::to(uri!(licence)) 153 + } 154 + 155 + #[get("/static/logo.svg")] 156 + pub(crate) async fn logo_svg(state: &State<AppState>) -> (ContentType, &'static str) { 157 + let ev_log = { 158 + let appstate = state.0.clone(); 159 + appstate.event_logger.clone() 160 + }; 161 + http_code_elog!(ev_log, 200, "/static/logo.svg"); 162 + 163 + ( 164 + ContentType::SVG, 165 + include_str!("../../client/priv/static/logo.svg"), 166 + ) 167 + } 168 + 169 + #[get("/favicon.ico")] 170 + pub(crate) async fn favicon(state: &State<AppState>) -> (ContentType, &'static [u8]) { 171 + let ev_log = { 172 + let appstate = state.0.clone(); 173 + appstate.event_logger.clone() 174 + }; 175 + http_code_elog!(ev_log, 200, "/favicon.ico"); 176 + produce_logo_png() 177 + } 178 + 179 + #[get("/static/logo.png")] 180 + pub(crate) async fn logo_png(state: &State<AppState>) -> (ContentType, &'static [u8]) { 181 + let ev_log = { 182 + let appstate = state.0.clone(); 183 + appstate.event_logger.clone() 184 + }; 185 + http_code_elog!(ev_log, 200, "/static/logo.png"); 186 + produce_logo_png() 187 + } 188 + 189 + fn produce_logo_png() -> (ContentType, &'static [u8]) { 190 + ( 191 + ContentType::PNG, 192 + include_bytes!("../../client/priv/static/logo.png"), 193 + ) 194 + }
+144
backend/impl-rs/server/src/tests.rs
··· 1 + /* 2 + * Lumina/Peonies 3 + * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 4 + * 5 + * This software is licensed under the European Union Public Licence (EUPL) v1.2. 6 + * You may not use this work except in compliance with the Licence. 7 + * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 8 + * 9 + * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 10 + * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 11 + * See LICENSE file in the repository root for full details. 12 + * 13 + * 14 + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 15 + * See the Licence for the specific language governing permissions and limitations. [cite: 6] 16 + */ 17 + 18 + use uuid::Uuid; 19 + 20 + use crate::database::{self, DatabaseConnections}; 21 + use crate::errors::LuminaError; 22 + use crate::timeline; 23 + use std::mem; 24 + 25 + #[tokio::test] 26 + async fn test_database_setup() { 27 + let result = database::setup() 28 + .await 29 + .expect("Database setup should succeed."); 30 + assert!( 31 + result.get_redis_pool().get().await.is_ok(), 32 + "Should get Redis connection" 33 + ); 34 + } 35 + 36 + #[tokio::test] 37 + async fn test_redis_bloom_filter() { 38 + let db = database::setup().await.expect("DB setup"); 39 + let redis_pool = db.get_redis_pool(); 40 + let mut conn = redis_pool.get().await.expect("Redis conn"); 41 + let email_key = "test_bloom:email"; 42 + let test_email = "testuser@example.com"; 43 + 44 + // Add to bloom filter 45 + let _: () = redis::cmd("BF.ADD") 46 + .arg(email_key) 47 + .arg(test_email) 48 + .query_async(&mut *conn) 49 + .await 50 + .expect("BF.ADD"); 51 + 52 + // Check if exists 53 + let exists: bool = redis::cmd("BF.EXISTS") 54 + .arg(email_key) 55 + .arg(test_email) 56 + .query_async(&mut *conn) 57 + .await 58 + .expect("BF.EXISTS"); 59 + 60 + assert!(exists, "Bloom filter should contain the test email"); 61 + 62 + // Clean up 63 + let _: () = redis::cmd("DEL") 64 + .arg(email_key) 65 + .query_async(&mut *conn) 66 + .await 67 + .unwrap_or(()); 68 + } 69 + 70 + #[tokio::test] 71 + async fn test_timeline_invalidation() { 72 + let db = database::setup().await.expect("DB setup"); 73 + let redis_pool = db.get_redis_pool(); 74 + let mut conn = redis_pool.get().await.expect("Redis conn"); 75 + // Global timeline 76 + let timeline_id = Uuid::nil(); 77 + 78 + // Set a test cache key 79 + let cache_key = format!("timeline_cache:{}:page:0", timeline_id); 80 + let _: () = redis::cmd("SET") 81 + .arg(&cache_key) 82 + .arg("test_data") 83 + .query_async(&mut *conn) 84 + .await 85 + .expect("SET"); 86 + 87 + // Invalidate the timeline 88 + timeline::invalidate_timeline_cache(&mut conn, timeline_id) 89 + .await 90 + .expect("Invalidate cache"); 91 + 92 + // Verify cache was cleared 93 + let result: Option<String> = redis::cmd("GET") 94 + .arg(&cache_key) 95 + .query_async(&mut *conn) 96 + .await 97 + .unwrap_or(None); 98 + 99 + assert!(result.is_none(), "Cache should be invalidated"); 100 + } 101 + 102 + #[test] 103 + fn print_sizes() { 104 + println!( 105 + "Size of LuminaError: {} bytes", 106 + mem::size_of::<LuminaError>() 107 + ); 108 + println!( 109 + "Size of errors::LuminaDbError: {} bytes", 110 + mem::size_of::<crate::errors::LuminaDbError>() 111 + ); 112 + println!("Size of EnvVar: {} bytes", mem::size_of::<crate::EnvVar>()); 113 + println!( 114 + "Size of InnerAppState: {} bytes", 115 + mem::size_of::<crate::InnerAppState>() 116 + ); 117 + } 118 + 119 + #[test] 120 + fn test_error_sizes() { 121 + // We want to keep our error types small to minimize overhead when passing them around. 122 + assert!( 123 + mem::size_of::<LuminaError>() <= 16, 124 + "LuminaError should be 16 bytes or less" 125 + ); 126 + assert!( 127 + mem::size_of::<crate::errors::LuminaDbError>() <= 16, 128 + "LuminaDbError should be 16 bytes or less" 129 + ); 130 + } 131 + 132 + #[test] 133 + fn test_appstate_size() { 134 + // Appstate is moved around a lot, so we want to keep it small, which is pretty easy since it just holds a single Arc pointer to a InnerAppState. 135 + assert!( 136 + mem::size_of::<crate::AppState>() <= 8, 137 + "AppState should be 8 bytes or less" 138 + ); 139 + // This constraint should lower over time as we optimize InnerAppState 140 + assert!( 141 + mem::size_of::<crate::InnerAppState>() <= 88, 142 + "InnerAppState should be 88 bytes or less" 143 + ); 144 + }
+73
backend/impl-rs/server/src/tests/tests.rs
··· 1 + use crate::database::{self, DatabaseConnections}; 2 + use crate::timeline; 3 + 4 + #[tokio::test] 5 + async fn test_database_setup() { 6 + let result = database::setup().await; 7 + assert!(result.is_ok(), "Database setup should succeed"); 8 + } 9 + 10 + #[tokio::test] 11 + async fn test_redis_bloom_filter() { 12 + let db = database::setup().await.expect("DB setup"); 13 + let redis_pool = db.get_redis_pool(); 14 + let mut conn = redis_pool.get().await.expect("Redis conn"); 15 + let email_key = "test_bloom:email"; 16 + let test_email = "testuser@example.com"; 17 + 18 + // Add to bloom filter 19 + let _: () = redis::cmd("BF.ADD") 20 + .arg(email_key) 21 + .arg(test_email) 22 + .query_async(&mut *conn) 23 + .await 24 + .expect("BF.ADD"); 25 + 26 + // Check if exists 27 + let exists: bool = redis::cmd("BF.EXISTS") 28 + .arg(email_key) 29 + .arg(test_email) 30 + .query_async(&mut *conn) 31 + .await 32 + .expect("BF.EXISTS"); 33 + 34 + assert!(exists, "Bloom filter should contain the test email"); 35 + 36 + // Clean up 37 + let _: () = redis::cmd("DEL") 38 + .arg(email_key) 39 + .query_async(&mut *conn) 40 + .await 41 + .unwrap_or(()); 42 + } 43 + 44 + #[tokio::test] 45 + async fn test_timeline_invalidation() { 46 + let db = database::setup().await.expect("DB setup"); 47 + let redis_pool = db.get_redis_pool(); 48 + let mut conn = redis_pool.get().await.expect("Redis conn"); 49 + let timeline_id = "test-timeline-invalidation"; 50 + 51 + // Set a test cache key 52 + let cache_key = format!("timeline_cache:{}:page:0", timeline_id); 53 + let _: () = redis::cmd("SET") 54 + .arg(&cache_key) 55 + .arg("test_data") 56 + .query_async(&mut *conn) 57 + .await 58 + .expect("SET"); 59 + 60 + // Invalidate the timeline 61 + timeline::invalidate_timeline_cache(&mut conn, timeline_id) 62 + .await 63 + .expect("Invalidate cache"); 64 + 65 + // Verify cache was cleared 66 + let result: Option<String> = redis::cmd("GET") 67 + .arg(&cache_key) 68 + .query_async(&mut *conn) 69 + .await 70 + .unwrap_or(None); 71 + 72 + assert!(result.is_none(), "Cache should be invalidated"); 73 + }
+429
backend/impl-rs/server/src/timeline.rs
··· 1 + //! Lumina > Server > Posts > Timeline 2 + //! 3 + //! Timeline management module for posts. 4 + 5 + /* 6 + * Lumina/Peonies 7 + * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 8 + * 9 + * This software is licensed under the European Union Public Licence (EUPL) v1.2. 10 + * You may not use this work except in compliance with the Licence. 11 + * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 12 + * 13 + * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 14 + * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 15 + * See LICENSE file in the repository root for full details. 16 + * 17 + * 18 + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 19 + * See the Licence for the specific language governing permissions and limitations. [cite: 6] 20 + */ 21 + 22 + use crate::errors::{LuminaDbError, LuminaError}; 23 + use crate::helpers::events::EventLogger; 24 + use crate::{DbConn, error_elog, info_elog, user}; 25 + use serde::{Deserialize, Serialize}; 26 + use uuid::Uuid; 27 + 28 + /// The UUID for the global timeline (all zeroes) 29 + pub const GLOBAL_TIMELINE_ID: &str = "00000000-0000-0000-0000-000000000000"; 30 + 31 + /// Maximum number of results per page 32 + pub const TIMELINE_PAGE_SIZE: usize = 40; 33 + 34 + /// Minimum lookup count to consider a timeline high-traffic (excluding global) 35 + pub const HIGH_TRAFFIC_THRESHOLD: i64 = 100; 36 + 37 + /// Cache TTL in seconds (1 hour) 38 + pub const CACHE_TTL: usize = 3600; 39 + 40 + #[derive(Serialize, Deserialize)] 41 + struct CachedTimelinePage { 42 + post_ids: Vec<String>, 43 + total_count: usize, 44 + page: usize, 45 + cached_at: i64, 46 + } 47 + 48 + /// Check if a timeline should be cached based on traffic 49 + async fn is_high_traffic_timeline( 50 + redis_conn: &mut bb8::PooledConnection<'_, bb8_redis::RedisConnectionManager>, 51 + timeline_id: &str, 52 + ) -> Result<bool, LuminaError> { 53 + // Global timeline is always high traffic 54 + if timeline_id == GLOBAL_TIMELINE_ID { 55 + return Ok(true); 56 + } 57 + 58 + // Check lookup count for other timelines 59 + let lookup_count: i64 = redis::cmd("GET") 60 + .arg(format!("timeline_lookup:{}", timeline_id)) 61 + .query_async(&mut **redis_conn) 62 + .await 63 + .unwrap_or(0); 64 + 65 + Ok(lookup_count >= HIGH_TRAFFIC_THRESHOLD) 66 + } 67 + 68 + /// Get cache key for a timeline page 69 + fn get_cache_key(timeline_id: &str, page: usize) -> String { 70 + format!("timeline_cache:{}:page:{}", timeline_id, page) 71 + } 72 + 73 + /// Get cache metadata key 74 + fn get_cache_meta_key(timeline_id: &str) -> String { 75 + format!("timeline_cache:{}:meta", timeline_id) 76 + } 77 + 78 + /// Store timeline page in Redis cache 79 + async fn cache_timeline_page( 80 + redis_conn: &mut bb8::PooledConnection<'_, bb8_redis::RedisConnectionManager>, 81 + timeline_id: &str, 82 + page: usize, 83 + post_ids: &[String], 84 + total_count: usize, 85 + ) -> Result<(), LuminaError> { 86 + let cached_page = CachedTimelinePage { 87 + post_ids: post_ids.to_vec(), 88 + total_count, 89 + page, 90 + cached_at: time::OffsetDateTime::now_utc().unix_timestamp(), 91 + }; 92 + 93 + let cache_key = get_cache_key(timeline_id, page); 94 + let serialized = serde_json::to_string(&cached_page)?; 95 + 96 + let _: () = redis::cmd("SETEX") 97 + .arg(cache_key) 98 + .arg(CACHE_TTL) 99 + .arg(serialized) 100 + .query_async(&mut **redis_conn) 101 + .await?; 102 + 103 + // Also cache metadata 104 + let meta_key = get_cache_meta_key(timeline_id); 105 + let _: () = redis::cmd("SETEX") 106 + .arg(meta_key) 107 + .arg(CACHE_TTL) 108 + .arg(total_count) 109 + .query_async(&mut **redis_conn) 110 + .await?; 111 + 112 + Ok(()) 113 + } 114 + 115 + /// Retrieve timeline page from Redis cache 116 + async fn get_cached_timeline_page( 117 + redis_conn: &mut bb8::PooledConnection<'_, bb8_redis::RedisConnectionManager>, 118 + timeline_id: &str, 119 + page: usize, 120 + ) -> Result<Option<CachedTimelinePage>, LuminaError> { 121 + let cache_key = get_cache_key(timeline_id, page); 122 + 123 + let cached_data: Option<String> = redis::cmd("GET") 124 + .arg(cache_key) 125 + .query_async(&mut **redis_conn) 126 + .await?; 127 + 128 + match cached_data { 129 + Some(data) => { 130 + let cached_page: CachedTimelinePage = serde_json::from_str(&data)?; 131 + Ok(Some(cached_page)) 132 + } 133 + None => Ok(None), 134 + } 135 + } 136 + 137 + /// Invalidate all cache entries for a timeline 138 + pub async fn invalidate_timeline_cache( 139 + redis_conn: &mut bb8::PooledConnection<'_, bb8_redis::RedisConnectionManager>, 140 + timeline_id: Uuid, 141 + ) -> Result<(), LuminaError> { 142 + let timeline_id_string = Uuid::to_string(&timeline_id); 143 + // Use SCAN to find all cache keys for this timeline 144 + let pattern = format!("timeline_cache:{}:*", timeline_id_string); 145 + 146 + let mut cursor = 0; 147 + loop { 148 + let result: (u64, Vec<String>) = redis::cmd("SCAN") 149 + .cursor_arg(cursor) 150 + .arg("MATCH") 151 + .arg(&pattern) 152 + .query_async(&mut **redis_conn) 153 + .await?; 154 + 155 + cursor = result.0; 156 + let keys = result.1; 157 + 158 + if !keys.is_empty() { 159 + let _: () = redis::cmd("DEL") 160 + .arg(&keys) 161 + .query_async(&mut **redis_conn) 162 + .await?; 163 + } 164 + 165 + if cursor == 0 { 166 + break; 167 + } 168 + } 169 + 170 + Ok(()) 171 + } 172 + 173 + /// Fetch total count for a timeline from database 174 + async fn fetch_timeline_total_count(db: &DbConn, timeline_id: &str) -> Result<usize, LuminaError> { 175 + match db { 176 + DbConn::PgsqlConnection(pg_pool, _redis_pool) => { 177 + let timeline_uuid = Uuid::parse_str(timeline_id).map_err(|_| LuminaError::UUidError)?; 178 + let row = sqlx::query!( 179 + "SELECT COUNT(*) AS count FROM timelines WHERE tlid = $1", 180 + &timeline_uuid 181 + ) 182 + .fetch_one(pg_pool) 183 + .await?; 184 + 185 + let count: i64 = row.count.unwrap_or(0); 186 + Ok(count as usize) 187 + } 188 + } 189 + } 190 + 191 + /// Fetch timeline post IDs from database with pagination 192 + async fn fetch_timeline_from_db( 193 + db: &DbConn, 194 + timeline_id: &str, 195 + offset: usize, 196 + limit: usize, 197 + ) -> Result<Vec<String>, LuminaError> { 198 + match db { 199 + DbConn::PgsqlConnection(pg_pool, _redis_pool) => { 200 + let timeline_uuid = Uuid::parse_str(timeline_id).map_err(|_| LuminaError::UUidError)?; 201 + let rows = 202 + sqlx::query!( 203 + "SELECT item_id FROM timelines WHERE tlid = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3", 204 + &timeline_uuid, &(limit as i64), &(offset as i64), 205 + ) 206 + .fetch_all(pg_pool) 207 + .await 208 + ?; 209 + 210 + let post_ids = rows 211 + .into_iter() 212 + .map(|row| row.item_id.to_string()) 213 + .collect(); 214 + Ok(post_ids) 215 + } 216 + } 217 + } 218 + 219 + /// Fetch a paginated list of post IDs for a given timeline. 220 + /// Returns (post_ids, total_count, has_more_pages) 221 + pub async fn fetch_timeline_post_ids( 222 + event_logger: EventLogger, 223 + db: &DbConn, 224 + timeline: &Uuid, 225 + page: Option<usize>, 226 + ) -> Result<(Vec<String>, usize, bool), LuminaError> { 227 + let timeline_id = timeline.to_string(); 228 + let page = page.unwrap_or(0); 229 + let offset = page * TIMELINE_PAGE_SIZE; 230 + 231 + // Get Redis connection 232 + let mut redis_conn = match db { 233 + DbConn::PgsqlConnection(_, redis_pool) => redis_pool.get().await?, 234 + }; 235 + 236 + // Log the requested timeline id for tracking 237 + let _: () = redis::cmd("INCR") 238 + .arg(format!("timeline_lookup:{}", timeline_id)) 239 + .query_async(&mut *redis_conn) 240 + .await?; 241 + 242 + // Check if this timeline should be cached 243 + let should_cache = is_high_traffic_timeline(&mut redis_conn, &timeline_id).await?; 244 + 245 + // Try to get from cache if it's a high-traffic timeline 246 + if should_cache 247 + && let Some(cached_page) = 248 + get_cached_timeline_page(&mut redis_conn, &timeline_id, page).await? 249 + { 250 + let has_more = (page + 1) * TIMELINE_PAGE_SIZE < cached_page.total_count; 251 + return Ok((cached_page.post_ids, cached_page.total_count, has_more)); 252 + } 253 + 254 + // Cache miss or low-traffic timeline - fetch from database 255 + if timeline_id == GLOBAL_TIMELINE_ID || should_cache { 256 + // Get total count 257 + let total_count = fetch_timeline_total_count(db, &timeline_id).await?; 258 + 259 + // Get page data 260 + let post_ids = fetch_timeline_from_db(db, &timeline_id, offset, TIMELINE_PAGE_SIZE).await?; 261 + 262 + // Cache the result if it's high-traffic 263 + if should_cache { 264 + match cache_timeline_page(&mut redis_conn, &timeline_id, page, &post_ids, total_count) 265 + .await 266 + { 267 + Ok(_) => info_elog!( 268 + event_logger, 269 + "Cached timeline {} page {} with {} posts", 270 + timeline_id, 271 + page, 272 + post_ids.len() 273 + ), 274 + Err(e) => match e { 275 + LuminaError::SerializationError(s) => error_elog!( 276 + event_logger, 277 + "Failed to serialize timeline {} page {} for caching: {}", 278 + timeline_id, 279 + page, 280 + s 281 + ), 282 + LuminaError::DbError(LuminaDbError::Redis(redis_err)) => error_elog!( 283 + event_logger, 284 + "Failed to cache timeline {} page {}: {:?}", 285 + timeline_id, 286 + page, 287 + redis_err 288 + ), 289 + _ => error_elog!( 290 + event_logger, 291 + "Unexpected error while caching timeline {} page {}: {:?}", 292 + timeline_id, 293 + page, 294 + e 295 + ), 296 + }, 297 + }; 298 + } 299 + 300 + let has_more = (page + 1) * TIMELINE_PAGE_SIZE < total_count; 301 + Ok((post_ids, total_count, has_more)) 302 + } else { 303 + // Non-global, low-traffic timeline - return empty for now 304 + Ok((vec![], 0, false)) 305 + } 306 + } 307 + 308 + /// Fetch post IDs for a timeline by its name. Also returns the UUID of the timeline. 309 + /// Needs to know the user to check for permissions or for example for the 'following' timeline. 310 + /// Returns (timeline_uuid, post_ids, total_count, has_more_pages) 311 + pub async fn fetch_timeline_post_ids_by_timeline_name( 312 + event_logger: EventLogger, 313 + db: &DbConn, 314 + timeline_name: &str, 315 + user: user::User, 316 + page: Option<usize>, 317 + ) -> Result<(Uuid, Vec<String>, usize, bool), LuminaError> { 318 + info_elog!( 319 + event_logger, 320 + "Fetching timeline '{}' for user '{}'", 321 + timeline_name, 322 + user.username 323 + ); 324 + // For now, only global timeline is supported. 325 + if timeline_name == "global" { 326 + let global_timeline_uuid = 327 + Uuid::parse_str(GLOBAL_TIMELINE_ID).map_err(|_| LuminaError::UUidError)?; 328 + let (post_ids, total_count, has_more) = 329 + fetch_timeline_post_ids(event_logger, db, &global_timeline_uuid, page).await?; 330 + Ok((global_timeline_uuid, post_ids, total_count, has_more)) 331 + } else { 332 + // Handle other timelines in the future 333 + error_elog!( 334 + event_logger, 335 + "Yet unsupported timeline name: {}", 336 + timeline_name 337 + ); 338 + Err(LuminaError::Unknown) 339 + } 340 + } 341 + 342 + /// Add a post to a timeline and invalidate cache if necessary 343 + pub async fn add_to_timeline( 344 + event_logger: EventLogger, 345 + db: &DbConn, 346 + timeline: &Uuid, 347 + item: &Uuid, 348 + item_type: ItemType, 349 + ) -> Result<(), LuminaError> { 350 + // Add to database 351 + match db { 352 + DbConn::PgsqlConnection(pg_pool, redis_pool) => { 353 + sqlx::query!( 354 + "INSERT INTO timelines (tlid, item_id, timestamp) VALUES ($1, $2, NOW())", 355 + *timeline, 356 + item, 357 + ) 358 + .execute(pg_pool) 359 + .await?; 360 + 361 + sqlx::query!( 362 + "INSERT INTO itemtypes (itemtype, item_id) VALUES ($1, $2)", 363 + match item_type { 364 + ItemType::Text => "text", 365 + ItemType::Article => "article", 366 + ItemType::Media => "media", 367 + }, 368 + item, 369 + ) 370 + .execute(pg_pool) 371 + .await?; 372 + 373 + // Invalidate cache 374 + let mut redis_conn = redis_pool.get().await?; 375 + if let Err(e) = invalidate_timeline_cache(&mut redis_conn, *timeline).await { 376 + error_elog!( 377 + event_logger, 378 + "Failed to invalidate cache for timeline {}: {:?}", 379 + timeline.to_string(), 380 + e 381 + ); 382 + } 383 + } 384 + } 385 + 386 + Ok(()) 387 + } 388 + 389 + pub(crate) enum ItemType { 390 + Text, 391 + Article, 392 + Media, 393 + // ... More 394 + } 395 + 396 + #[expect(dead_code, reason = "Not used yet")] 397 + /// Remove a post from a timeline and invalidate cache if necessary 398 + pub async fn remove_from_timeline( 399 + event_logger: EventLogger, 400 + db: &DbConn, 401 + timeline: &Uuid, 402 + item: &Uuid, 403 + ) -> Result<(), LuminaError> { 404 + // Remove from database 405 + match db { 406 + DbConn::PgsqlConnection(pg_pool, redis_pool) => { 407 + sqlx::query!( 408 + "DELETE FROM timelines WHERE tlid = $1 AND item_id = $2", 409 + &timeline, 410 + &item 411 + ) 412 + .execute(pg_pool) 413 + .await?; 414 + 415 + // Invalidate cache 416 + let mut redis_conn = redis_pool.get().await?; 417 + if let Err(e) = invalidate_timeline_cache(&mut redis_conn, *timeline).await { 418 + error_elog!( 419 + event_logger, 420 + "Failed to invalidate cache for timeline {}: {:?}", 421 + timeline.to_string(), 422 + e 423 + ); 424 + } 425 + } 426 + } 427 + 428 + Ok(()) 429 + }
+431
backend/impl-rs/server/src/user.rs
··· 1 + //! Lumina > Server > Users 2 + //! 3 + //! User management module, including user struct and database interactions. 4 + /* 5 + * Lumina/Peonies 6 + * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 7 + * 8 + * This software is licensed under the European Union Public Licence (EUPL) v1.2. 9 + * You may not use this work except in compliance with the Licence. 10 + * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 11 + * 12 + * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 13 + * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 14 + * See LICENSE file in the repository root for full details. 15 + * 16 + * 17 + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 18 + * See the Licence for the specific language governing permissions and limitations. [cite: 6] 19 + */ 20 + 21 + use crate::{LuminaError, database::DbConn, helpers::events::EventLogger, info_elog}; 22 + use cynthia_con::CynthiaColors; 23 + use uuid::Uuid; 24 + 25 + #[derive(Debug, Clone)] 26 + pub struct User { 27 + pub id: Uuid, 28 + pub email: String, 29 + pub username: String, 30 + #[expect(dead_code, reason = "Will be used for federated posts in the future")] 31 + pub foreign_instance_id: String, // Added to handle foreign_instance_id 32 + } 33 + 34 + #[derive(Debug, Clone)] 35 + pub struct SessionReference { 36 + pub session_id: Uuid, 37 + pub token: String, 38 + } 39 + 40 + impl User { 41 + pub async fn authenticate( 42 + email_username: String, 43 + password: String, 44 + db: &DbConn, 45 + ev_log: EventLogger, 46 + ) -> Result<(SessionReference, User), LuminaError> { 47 + let user = match User::get_user_by_identifier(email_username, db).await { 48 + // Replace some errors 49 + 50 + // Pass through other errors 51 + Ok(user) => Ok(user), 52 + Err(e) => Err(e), 53 + }?; 54 + let hashed_password = user.clone().get_hashed_password(db).await?; 55 + if bcrypt::verify(password, &hashed_password).map_err(|_| LuminaError::BcryptError)? { 56 + user.create_session(db, ev_log).await 57 + } else { 58 + Err(LuminaError::AuthenticationWrongPassword) 59 + } 60 + } 61 + async fn get_hashed_password(self, database: &DbConn) -> Result<String, LuminaError> { 62 + match database { 63 + DbConn::PgsqlConnection(pg_pool, _) => { 64 + let row = sqlx::query!("SELECT password FROM users WHERE id = $1", &self.id) 65 + .fetch_one(pg_pool) 66 + .await?; 67 + let password: String = row.password; 68 + Ok(password) 69 + } 70 + } 71 + } 72 + pub async fn create_user( 73 + email: String, 74 + username: String, 75 + password: String, 76 + db: &DbConn, 77 + ) -> Result<User, LuminaError> { 78 + register_validitycheck(email.clone(), username.clone(), password.clone(), db).await?; 79 + // hash the password 80 + let password = 81 + bcrypt::hash(password, bcrypt::DEFAULT_COST).map_err(|_| LuminaError::BcryptError)?; 82 + match db { 83 + DbConn::PgsqlConnection(pg_pool, _) => { 84 + // Some username and email validation should be done here 85 + // Check if the email is already in use 86 + let email_exists = sqlx::query!("SELECT * FROM users WHERE email = $1", &email) 87 + .fetch_optional(pg_pool) 88 + .await?; 89 + if !email_exists.is_none() { 90 + return Err(LuminaError::RegisterEmailInUse); 91 + } 92 + // Check if the username is already in use 93 + let username_exists = 94 + sqlx::query!("SELECT * FROM users WHERE username = $1", &username) 95 + .fetch_optional(pg_pool) 96 + .await?; 97 + if !username_exists.is_none() { 98 + return Err(LuminaError::RegisterUsernameInUse); 99 + } 100 + 101 + let id = sqlx::query!("INSERT INTO users (email, username, password) VALUES ($1, $2, $3) RETURNING id", &email, &username, &password) 102 + .fetch_one(pg_pool) 103 + .await 104 + ?; 105 + Ok(User { 106 + id: id.id, 107 + email, 108 + username, 109 + foreign_instance_id: "".to_string(), // Default value for new users 110 + }) 111 + } 112 + } 113 + } 114 + pub async fn get_user_by_identifier( 115 + identifier: String, 116 + db: &DbConn, 117 + ) -> Result<User, LuminaError> { 118 + let DbConn::PgsqlConnection(pg_pool, _) = db; 119 + // todo: Find a way to not repeat here, without it 'never matching' (which is what happens if you 120 + // parameterise the left side of the WHERE...) 121 + if identifier.contains('@') { 122 + match sqlx::query!("SELECT id, email, username, coalesce(foreign_instance_id, '') as foreign_instance_id FROM users WHERE email = $1", &identifier).fetch_optional(pg_pool).await? 123 + { 124 + None => Err(LuminaError::AuthenticationNoSuchUser), 125 + Some(user) => Ok( 126 + User { 127 + id: user.id, 128 + email: user.email, 129 + username: user.username, 130 + foreign_instance_id: user.foreign_instance_id.unwrap_or("".to_string()), 131 + } 132 + ), 133 + } 134 + } else { 135 + match sqlx::query!("SELECT id, email, username, coalesce(foreign_instance_id, '') as foreign_instance_id FROM users WHERE username = $1", &identifier).fetch_optional(pg_pool).await? 136 + { 137 + None => Err(LuminaError::AuthenticationNoSuchUser), 138 + Some(user) => Ok( 139 + User { 140 + id: user.id, 141 + email: user.email, 142 + username: user.username, 143 + foreign_instance_id: user.foreign_instance_id.unwrap_or("".to_string()), 144 + } 145 + ) 146 + } 147 + } 148 + } 149 + 150 + pub async fn create_session( 151 + self, 152 + db: &DbConn, 153 + ev_log: EventLogger, 154 + ) -> Result<(SessionReference, User), LuminaError> { 155 + let user = self; 156 + let user_id = user.id; 157 + match db { 158 + DbConn::PgsqlConnection(pg_pool, _) => { 159 + let session_key = Uuid::new_v4().to_string(); 160 + let id = sqlx::query!( 161 + "INSERT INTO sessions (user_id, session_key) VALUES ($1, $2) RETURNING id", 162 + &user_id, 163 + &session_key, 164 + ) 165 + .fetch_one(pg_pool) 166 + .await?; 167 + info_elog!( 168 + ev_log, 169 + "New session created by {}", 170 + user.clone().username.color_bright_cyan() 171 + ); 172 + let session_id = id.id; 173 + Ok(( 174 + SessionReference { 175 + session_id, 176 + token: session_key, 177 + }, 178 + user, 179 + )) 180 + } 181 + } 182 + } 183 + pub async fn revive_session_from_token( 184 + token: String, 185 + db: &DbConn, 186 + ) -> Result<User, LuminaError> { 187 + match db { 188 + DbConn::PgsqlConnection(pg_pool, _) => { 189 + let user = sqlx::query!("SELECT users.id, users.email, users.username FROM users JOIN sessions ON users.id = sessions.user_id WHERE sessions.session_key = $1", &token) 190 + .fetch_one(pg_pool) 191 + .await 192 + ?; 193 + Ok(User { 194 + id: user.id, 195 + email: user.email, 196 + username: user.username, 197 + foreign_instance_id: "".to_string(), // Default value for revived sessions 198 + }) 199 + } 200 + } 201 + } 202 + } 203 + 204 + pub(crate) async fn register_validitycheck( 205 + email: String, 206 + username: String, 207 + password: String, 208 + db: &DbConn, 209 + ) -> Result<(), LuminaError> { 210 + { 211 + // Check if the email or username is already in use using fastbloom algorithm with Redis, and fallback to DB check if not found. If not in either, we can go on. 212 + match db { 213 + DbConn::PgsqlConnection(pg_pool, redis_pool) => { 214 + let mut redis_conn = redis_pool.get().await?; 215 + // fastbloom_rs expects bytes, so we use the string as bytes 216 + let email_key = String::from("bloom:email"); 217 + let username_key = String::from("bloom:username"); 218 + let email_exists: bool = redis::cmd("BF.EXISTS") 219 + .arg(&email_key) 220 + .arg(&email) 221 + .query_async(&mut *redis_conn) 222 + .await 223 + .unwrap_or(false); 224 + if email_exists { 225 + // Fallback to DB check if in bloom filter 226 + let email_db = sqlx::query!("SELECT * FROM users WHERE email = $1", &email) 227 + .fetch_optional(pg_pool) 228 + .await?; 229 + if !email_db.is_none() { 230 + return Err(LuminaError::RegisterEmailInUse); 231 + } 232 + } 233 + let username_exists: bool = redis::cmd("BF.EXISTS") 234 + .arg(&username_key) 235 + .arg(&username) 236 + .query_async(&mut *redis_conn) 237 + .await 238 + .unwrap_or(false); 239 + if username_exists { 240 + // Fallback to DB check if in bloom filter 241 + let username_db = 242 + sqlx::query!("SELECT * FROM users WHERE username = $1", &username) 243 + .fetch_optional(pg_pool) 244 + .await?; 245 + if !username_db.is_none() { 246 + return Err(LuminaError::RegisterUsernameInUse); 247 + } 248 + } 249 + // Fallback to DB check if not in bloom filter 250 + let email_db = sqlx::query!("SELECT * FROM users WHERE email = $1", &email) 251 + .fetch_optional(pg_pool) 252 + .await?; 253 + if !email_db.is_none() { 254 + // Update bloom filter after DB check 255 + let _: () = redis::cmd("BF.ADD") 256 + .arg(&email_key) 257 + .arg(&email) 258 + .query_async(&mut *redis_conn) 259 + .await 260 + .unwrap_or(()); 261 + return Err(LuminaError::RegisterEmailInUse); 262 + } 263 + let username_db = 264 + sqlx::query!("SELECT * FROM users WHERE username = $1", &username) 265 + .fetch_optional(pg_pool) 266 + .await?; 267 + if !username_db.is_none() { 268 + let _: () = redis::cmd("BF.ADD") 269 + .arg(&username_key) 270 + .arg(&username) 271 + .query_async(&mut *redis_conn) 272 + .await 273 + .unwrap_or(()); 274 + return Err(LuminaError::RegisterUsernameInUse); 275 + } 276 + } 277 + } 278 + } 279 + 280 + // 281 + // 282 + // Email checks 283 + // 284 + { 285 + let email_regex = regex::Regex::new( 286 + r"^([a-z0-9_+]([a-z0-9_+.]*[a-z0-9_+])?)@([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{1,6})", 287 + ) 288 + .map_err(|_| LuminaError::RegexError)?; 289 + if !email_regex.is_match(&email) { 290 + return Err(LuminaError::RegisterEmailNotValid); 291 + }; 292 + } 293 + 294 + // 295 + // 296 + // Username checks 297 + // 298 + { 299 + // Check if username is valid 300 + if username.chars().any(|c| { 301 + match c { 302 + ' ' | '\\' | '/' | '@' | '\n' | '\r' | '\t' | '\x0b' | '\'' | '"' | '(' | ')' 303 + | '`' | '%' | '?' | '!' => true, 304 + '#' => ( 305 + // Make sure, if a # is in the username, only 4 numbers may follow it. 306 + || { 307 + let split_username = username.split('#'); 308 + let array_split_username: Vec<&str> = split_username.collect(); 309 + let lastbit = username.replacen(array_split_username[0], "", 1); 310 + let firstbit = username.replacen(&*lastbit, "", 1); 311 + let vec_split_username: Vec<&str> = vec![&*firstbit, &*lastbit]; 312 + // println!("array: {:?}", array_split_username); 313 + // println!("vec: {:?}", vec_split_username); 314 + if vec_split_username.is_empty() || array_split_username[1].is_empty() { 315 + return true; 316 + }; 317 + (!array_split_username[1].chars().all(char::is_numeric)) 318 + || !(vec_split_username[1].len() == 5 319 + || vec_split_username[1].len() == 7) 320 + } 321 + )(), 322 + _ => false, 323 + } 324 + }) || !username 325 + .replace(['_', '-', '.'], "") 326 + .replacen('#', "", 1) 327 + .chars() 328 + .all(char::is_alphanumeric) 329 + { 330 + return Err(LuminaError::RegisterUsernameInvalid( 331 + OnRegisterUsernameInvalid::InvalidCharacters, 332 + )); 333 + } 334 + // Check if the username is too long 335 + if username.len() > 20 { 336 + return Err(LuminaError::RegisterUsernameInvalid( 337 + OnRegisterUsernameInvalid::TooLong, 338 + )); 339 + } 340 + // Check if the username is too short 341 + if username.len() < 4 { 342 + return Err(LuminaError::RegisterUsernameInvalid( 343 + OnRegisterUsernameInvalid::TooShort, 344 + )); 345 + } 346 + } 347 + 348 + // 349 + // 350 + // Password checks 351 + // 352 + { 353 + if password.len() < 8 { 354 + return Err(LuminaError::RegisterPasswordNotValid( 355 + OnRegisterPasswordNotValid::TooShort, 356 + )); 357 + } 358 + if password.len() > 100 { 359 + return Err(LuminaError::RegisterPasswordNotValid( 360 + OnRegisterPasswordNotValid::TooLong, 361 + )); 362 + } 363 + if !password.chars().any(char::is_uppercase) { 364 + return Err(LuminaError::RegisterPasswordNotValid( 365 + OnRegisterPasswordNotValid::MissingUppercase, 366 + )); 367 + } 368 + if !password.chars().any(char::is_lowercase) { 369 + return Err(LuminaError::RegisterPasswordNotValid( 370 + OnRegisterPasswordNotValid::MissingLowercase, 371 + )); 372 + } 373 + if !password.chars().any(char::is_numeric) { 374 + return Err(LuminaError::RegisterPasswordNotValid( 375 + OnRegisterPasswordNotValid::MissingNumber, 376 + )); 377 + } 378 + } 379 + Ok(()) 380 + } 381 + 382 + #[derive(Debug)] 383 + pub(crate) enum OnRegisterUsernameInvalid { 384 + TooLong, 385 + TooShort, 386 + InvalidCharacters, 387 + } 388 + impl std::fmt::Display for OnRegisterUsernameInvalid { 389 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 390 + write!( 391 + f, 392 + "{}", 393 + match self { 394 + OnRegisterUsernameInvalid::TooLong => "Username too long", 395 + OnRegisterUsernameInvalid::TooShort => "Username too short", 396 + OnRegisterUsernameInvalid::InvalidCharacters => { 397 + "Username contains invalid characters" 398 + } 399 + } 400 + ) 401 + } 402 + } 403 + #[derive(Debug)] 404 + pub(crate) enum OnRegisterPasswordNotValid { 405 + TooShort, 406 + TooLong, 407 + MissingUppercase, 408 + MissingLowercase, 409 + MissingNumber, 410 + } 411 + impl std::fmt::Display for OnRegisterPasswordNotValid { 412 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 413 + write!( 414 + f, 415 + "{}", 416 + match self { 417 + OnRegisterPasswordNotValid::TooShort => "Password too short", 418 + OnRegisterPasswordNotValid::TooLong => "Password too long", 419 + OnRegisterPasswordNotValid::MissingUppercase => { 420 + "Password must contain at least one uppercase letter" 421 + } 422 + OnRegisterPasswordNotValid::MissingLowercase => { 423 + "Password must contain at least one lowercase letter" 424 + } 425 + OnRegisterPasswordNotValid::MissingNumber => { 426 + "Password must contain at least one number" 427 + } 428 + } 429 + ) 430 + } 431 + }