Auto-indexing service and GraphQL API for AT Protocol Records
0
fork

Configure Feed

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

add reset button on settings page, move settings handlers to separate module

+485 -221
server/priv/lexicons.zip

This is a binary file and will not be displayed.

+17 -2
server/src/backfill.gleam
··· 771 771 logging.log(logging.Info, "[backfill] Starting backfill operation") 772 772 773 773 case collections { 774 - [] -> logging.log(logging.Warning, "[backfill] No collections specified for backfill") 775 - _ -> 774 + [] -> { 775 + logging.log(logging.Error, "[backfill] No collections specified for backfill") 776 + Nil 777 + } 778 + _ -> { 776 779 logging.log( 777 780 logging.Info, 778 781 "[backfill] Processing " ··· 780 783 <> " collections: " 781 784 <> string.join(collections, ", "), 782 785 ) 786 + 787 + run_backfill(repos, collections, external_collections, config, conn) 788 + } 783 789 } 790 + } 791 + 792 + fn run_backfill( 793 + repos: List(String), 794 + collections: List(String), 795 + external_collections: List(String), 796 + config: BackfillConfig, 797 + conn: sqlight.Connection, 798 + ) -> Nil { 784 799 785 800 case external_collections { 786 801 [] -> Nil
+21 -31
server/src/components/alert.gleam
··· 12 12 13 13 /// Render an alert message with appropriate styling 14 14 pub fn alert(kind: AlertKind, message: String) -> Element(msg) { 15 - let #(bg_class, border_class, text_class, icon) = case kind { 16 - Success -> #("bg-green-900/30", "border-green-800", "text-green-300", "✓") 17 - Error -> #("bg-red-900/30", "border-red-800", "text-red-300", "✗") 18 - Info -> #("bg-blue-900/30", "border-blue-800", "text-blue-300", "ℹ") 19 - Warning -> #("bg-yellow-900/30", "border-yellow-800", "text-yellow-300", "⚠") 15 + let #(bg_class, border_class, text_class) = case kind { 16 + Success -> #("bg-green-900/30", "border-green-800", "text-green-300") 17 + Error -> #("bg-red-900/30", "border-red-800", "text-red-300") 18 + Info -> #("bg-blue-900/30", "border-blue-800", "text-blue-300") 19 + Warning -> #("bg-yellow-900/30", "border-yellow-800", "text-yellow-300") 20 20 } 21 21 22 22 html.div( ··· 26 26 ), 27 27 ], 28 28 [ 29 - html.div([attribute.class("flex items-center gap-3")], [ 30 - html.span([attribute.class("text-lg " <> text_class)], [ 31 - element.text(icon), 32 - ]), 33 - html.span([attribute.class("text-sm " <> text_class)], [ 34 - element.text(message), 35 - ]), 29 + html.span([attribute.class("text-sm " <> text_class)], [ 30 + element.text(message), 36 31 ]), 37 32 ], 38 33 ) ··· 45 40 link_text: String, 46 41 link_url: String, 47 42 ) -> Element(msg) { 48 - let #(bg_class, border_class, text_class, icon) = case kind { 49 - Success -> #("bg-green-900/30", "border-green-800", "text-green-300", "✓") 50 - Error -> #("bg-red-900/30", "border-red-800", "text-red-300", "✗") 51 - Info -> #("bg-blue-900/30", "border-blue-800", "text-blue-300", "ℹ") 52 - Warning -> #("bg-yellow-900/30", "border-yellow-800", "text-yellow-300", "⚠") 43 + let #(bg_class, border_class, text_class) = case kind { 44 + Success -> #("bg-green-900/30", "border-green-800", "text-green-300") 45 + Error -> #("bg-red-900/30", "border-red-800", "text-red-300") 46 + Info -> #("bg-blue-900/30", "border-blue-800", "text-blue-300") 47 + Warning -> #("bg-yellow-900/30", "border-yellow-800", "text-yellow-300") 53 48 } 54 49 55 50 html.div( ··· 59 54 ), 60 55 ], 61 56 [ 62 - html.div([attribute.class("flex items-center gap-3")], [ 63 - html.span([attribute.class("text-lg " <> text_class)], [ 64 - element.text(icon), 65 - ]), 66 - html.span([attribute.class("text-sm " <> text_class)], [ 67 - element.text(message <> " "), 68 - html.a( 69 - [ 70 - attribute.href(link_url), 71 - attribute.class("underline hover:no-underline"), 72 - ], 73 - [element.text(link_text)], 74 - ), 75 - ]), 57 + html.span([attribute.class("text-sm " <> text_class)], [ 58 + element.text(message <> " "), 59 + html.a( 60 + [ 61 + attribute.href(link_url), 62 + attribute.class("underline hover:no-underline"), 63 + ], 64 + [element.text(link_text)], 65 + ), 76 66 ]), 77 67 ], 78 68 )
+45
server/src/database.gleam
··· 356 356 Ok(Nil) 357 357 } 358 358 359 + /// Deletes the domain_authority config entry 360 + pub fn delete_domain_authority( 361 + conn: sqlight.Connection, 362 + ) -> Result(Nil, sqlight.Error) { 363 + let sql = "DELETE FROM config WHERE key = ?" 364 + 365 + use _ <- result.try(sqlight.query( 366 + sql, 367 + on: conn, 368 + with: [sqlight.text("domain_authority")], 369 + expecting: decode.string, 370 + )) 371 + Ok(Nil) 372 + } 373 + 374 + /// Deletes all lexicons from the database 375 + pub fn delete_all_lexicons( 376 + conn: sqlight.Connection, 377 + ) -> Result(Nil, sqlight.Error) { 378 + let sql = "DELETE FROM lexicon" 379 + 380 + sqlight.exec(sql, conn) 381 + |> result.map(fn(_) { Nil }) 382 + } 383 + 384 + /// Deletes all records from the database 385 + pub fn delete_all_records( 386 + conn: sqlight.Connection, 387 + ) -> Result(Nil, sqlight.Error) { 388 + let sql = "DELETE FROM record" 389 + 390 + sqlight.exec(sql, conn) 391 + |> result.map(fn(_) { Nil }) 392 + } 393 + 394 + /// Deletes all actors from the database 395 + pub fn delete_all_actors( 396 + conn: sqlight.Connection, 397 + ) -> Result(Nil, sqlight.Error) { 398 + let sql = "DELETE FROM actor" 399 + 400 + sqlight.exec(sql, conn) 401 + |> result.map(fn(_) { Nil }) 402 + } 403 + 359 404 // ===== Record Functions ===== 360 405 361 406 /// Inserts or updates a record in the database
+59 -5
server/src/pages/settings.gleam
··· 12 12 pub fn view( 13 13 db: sqlight.Connection, 14 14 current_user: Option(#(String, String)), 15 - _is_admin: Bool, 15 + is_admin: Bool, 16 16 flash_kind: Option(String), 17 17 flash_message: Option(String), 18 18 ) -> Element(msg) { 19 19 let data = fetch_settings(db) 20 - render(data, current_user, flash_kind, flash_message) 20 + render(data, current_user, is_admin, flash_kind, flash_message) 21 21 } 22 22 23 23 /// Settings data ··· 39 39 fn render( 40 40 data: SettingsData, 41 41 current_user: Option(#(String, String)), 42 + is_admin: Bool, 42 43 flash_kind: Option(String), 43 44 flash_message: Option(String), 44 45 ) -> Element(msg) { ··· 49 50 element.text("Settings"), 50 51 ]), 51 52 alert.maybe_alert(flash_kind, flash_message), 52 - render_settings_form(data), 53 + render_settings_form(data, is_admin), 53 54 ], 54 55 current_user: current_user, 55 56 domain_authority: option.None, ··· 57 58 } 58 59 59 60 /// Render the settings form 60 - fn render_settings_form(data: SettingsData) -> Element(msg) { 61 + fn render_settings_form(data: SettingsData, is_admin: Bool) -> Element(msg) { 61 62 html.div([attribute.class("max-w-2xl space-y-6")], [ 62 63 // Domain Authority Section 63 64 html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ ··· 133 134 ], 134 135 ), 135 136 ]), 136 - // Sign Out Section 137 + // Danger Zone Section (admin only) 138 + case is_admin { 139 + True -> 140 + html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 141 + html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 142 + element.text("Danger Zone"), 143 + ]), 144 + html.p([attribute.class("text-sm text-zinc-400 mb-4")], [ 145 + element.text("This will clear all indexed data:"), 146 + ]), 147 + html.ul([attribute.class("text-sm text-zinc-400 mb-4 ml-4 list-disc")], [ 148 + html.li([], [element.text("Domain authority configuration")]), 149 + html.li([], [element.text("All lexicon definitions")]), 150 + html.li([], [element.text("All indexed records")]), 151 + html.li([], [element.text("All actors")]), 152 + ]), 153 + html.p([attribute.class("text-sm text-zinc-400 mb-4")], [ 154 + element.text("Records can be re-indexed via backfill."), 155 + ]), 156 + html.form( 157 + [ 158 + attribute.method("post"), 159 + attribute.action("/settings"), 160 + ], 161 + [ 162 + html.input([ 163 + attribute.type_("hidden"), 164 + attribute.name("action"), 165 + attribute.value("reset"), 166 + ]), 167 + input.form_text_input( 168 + label: "Type RESET to confirm", 169 + name: "confirm", 170 + value: "", 171 + placeholder: "RESET", 172 + required: True, 173 + ), 174 + html.div([attribute.class("flex gap-3")], [ 175 + html.button( 176 + [ 177 + attribute.type_("submit"), 178 + attribute.class( 179 + "font-mono px-4 py-2 text-sm text-red-400 border border-red-900 hover:bg-red-900/30 rounded transition-colors cursor-pointer", 180 + ), 181 + ], 182 + [element.text("Reset Everything")], 183 + ), 184 + ]), 185 + ], 186 + ), 187 + ]) 188 + False -> element.none() 189 + }, 190 + // Account Section 137 191 html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 138 192 html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 139 193 element.text("Account"),
+12 -183
server/src/server.gleam
··· 24 24 import oauth/handlers 25 25 import oauth/session 26 26 import pages/index 27 - import pages/settings 28 27 import pubsub 29 - import simplifile 28 + import settings_handler 30 29 import sqlight 31 30 import upload_handler 32 31 import wisp 33 - import wisp_flash 34 - import zip_helper 35 32 import wisp/wisp_mist 36 33 import xrpc_handlers 37 34 import xrpc_router ··· 459 456 case segments { 460 457 [] -> index_route(req, ctx) 461 458 ["health"] -> handle_health_check(ctx) 462 - ["settings"] -> settings_route(req, ctx) 459 + ["settings"] -> 460 + settings_handler.handle( 461 + req, 462 + settings_handler.Context( 463 + db: ctx.db, 464 + oauth_config: ctx.oauth_config, 465 + admin_dids: ctx.admin_dids, 466 + config: ctx.config, 467 + jetstream_consumer: ctx.jetstream_consumer, 468 + ), 469 + ) 463 470 ["oauth", "authorize"] -> 464 471 handlers.handle_oauth_authorize(req, ctx.db, ctx.oauth_config) 465 472 ["oauth", "callback"] -> ··· 656 663 index.view(ctx.db, current_user, user_is_admin, domain_authority) 657 664 |> element.to_document_string 658 665 |> wisp.html_response(200) 659 - } 660 - 661 - fn settings_route(req: wisp.Request, ctx: Context) -> wisp.Response { 662 - // Get current user from session (with automatic token refresh) 663 - let refresh_fn = fn(refresh_token) { 664 - handlers.refresh_access_token(ctx.oauth_config, refresh_token) 665 - } 666 - 667 - let #(current_user, user_is_admin) = case 668 - session.get_current_user(req, ctx.db, refresh_fn) 669 - { 670 - Ok(#(did, handle, _access_token)) -> { 671 - let admin = is_admin(did, ctx.admin_dids) 672 - #(option.Some(#(did, handle)), admin) 673 - } 674 - Error(_) -> #(option.None, False) 675 - } 676 - 677 - case req.method { 678 - gleam_http.Get -> { 679 - // Extract flash messages if present 680 - use flash_kind, flash_message <- wisp_flash.get_flash(req) 681 - 682 - settings.view(ctx.db, current_user, user_is_admin, flash_kind, flash_message) 683 - |> element.to_document_string 684 - |> wisp.html_response(200) 685 - } 686 - gleam_http.Post -> { 687 - // Handle form submission (domain authority or lexicons upload) 688 - use form_data <- wisp.require_form(req) 689 - 690 - // Check if this is a lexicons upload 691 - case list.key_find(form_data.files, "lexicons_zip") { 692 - Ok(uploaded_file) -> { 693 - // Handle lexicons ZIP upload 694 - handle_lexicons_upload(req, uploaded_file, ctx) 695 - } 696 - Error(_) -> { 697 - // Not a file upload, check for domain_authority field 698 - case list.key_find(form_data.values, "domain_authority") { 699 - Ok(domain_authority) -> { 700 - // Save domain_authority to database and update cache 701 - case config.set_domain_authority(ctx.config, ctx.db, domain_authority) { 702 - Ok(_) -> { 703 - wisp.redirect("/settings") 704 - |> wisp_flash.set_flash(req, "success", "Domain authority saved successfully") 705 - } 706 - Error(_) -> { 707 - logging.log(logging.Error, "[settings] Failed to save domain_authority") 708 - wisp.redirect("/settings") 709 - |> wisp_flash.set_flash(req, "error", "Failed to save domain authority") 710 - } 711 - } 712 - } 713 - Error(_) -> { 714 - logging.log(logging.Warning, "[settings] No form data received") 715 - wisp.redirect("/settings") 716 - } 717 - } 718 - } 719 - } 720 - } 721 - _ -> { 722 - wisp.response(405) 723 - |> wisp.set_header("content-type", "text/html") 724 - |> wisp.set_body(wisp.Text("<h1>Method Not Allowed</h1>")) 725 - } 726 - } 727 - } 728 - 729 - fn handle_lexicons_upload( 730 - req: wisp.Request, 731 - uploaded_file: wisp.UploadedFile, 732 - ctx: Context, 733 - ) -> wisp.Response { 734 - logging.log(logging.Info, "[settings] Processing lexicons ZIP upload: " <> uploaded_file.file_name) 735 - 736 - // Create temporary directory for extraction with random suffix 737 - let temp_dir = "tmp/lexicon_upload_" <> wisp.random_string(16) 738 - 739 - case simplifile.create_directory_all(temp_dir) { 740 - Ok(_) -> { 741 - logging.log(logging.Info, "[settings] Created temp directory: " <> temp_dir) 742 - 743 - // Extract ZIP file to temp directory 744 - case zip_helper.extract_zip(uploaded_file.path, temp_dir) { 745 - Ok(_) -> { 746 - logging.log(logging.Info, "[settings] Extracted ZIP file to: " <> temp_dir) 747 - 748 - // Import lexicons from extracted directory 749 - case importer.import_lexicons_from_directory(temp_dir, ctx.db) { 750 - Ok(stats) -> { 751 - // Clean up temp directory 752 - let _ = simplifile.delete(temp_dir) 753 - 754 - logging.log( 755 - logging.Info, 756 - "[settings] Lexicon import complete: " 757 - <> int.to_string(stats.imported) 758 - <> " imported, " 759 - <> int.to_string(stats.failed) 760 - <> " failed", 761 - ) 762 - 763 - // Log any errors 764 - case stats.errors { 765 - [] -> Nil 766 - errors -> { 767 - list.each(errors, fn(err) { 768 - logging.log(logging.Warning, "[settings] Import error: " <> err) 769 - }) 770 - } 771 - } 772 - 773 - // Restart Jetstream consumer to pick up newly imported collections 774 - let restart_status = case ctx.jetstream_consumer { 775 - option.Some(consumer) -> { 776 - logging.log(logging.Info, "[settings] Restarting Jetstream consumer with new lexicons...") 777 - case jetstream_consumer.restart(consumer) { 778 - Ok(_) -> { 779 - logging.log(logging.Info, "[settings] Jetstream consumer restarted successfully") 780 - "success" 781 - } 782 - Error(err) -> { 783 - logging.log(logging.Error, "[settings] Failed to restart Jetstream consumer: " <> err) 784 - "failed" 785 - } 786 - } 787 - } 788 - option.None -> { 789 - logging.log(logging.Info, "[settings] Jetstream consumer not running, skipping restart") 790 - "not_running" 791 - } 792 - } 793 - 794 - // Build success message with import stats and restart status 795 - let base_message = "Imported " <> int.to_string(stats.imported) <> " lexicon(s) successfully" 796 - let message = case restart_status { 797 - "success" -> base_message <> ". Jetstream consumer restarted." 798 - "failed" -> base_message <> ". Warning: Jetstream consumer restart failed." 799 - "not_running" -> base_message <> "." 800 - _ -> base_message 801 - } 802 - 803 - let flash_kind = case restart_status { 804 - "failed" -> "warning" 805 - _ -> "success" 806 - } 807 - 808 - wisp.redirect("/settings") 809 - |> wisp_flash.set_flash(req, flash_kind, message) 810 - } 811 - Error(err) -> { 812 - // Clean up temp directory 813 - let _ = simplifile.delete(temp_dir) 814 - 815 - logging.log(logging.Error, "[settings] Failed to import lexicons: " <> err) 816 - wisp.redirect("/settings") 817 - |> wisp_flash.set_flash(req, "error", "Failed to import lexicons: " <> err) 818 - } 819 - } 820 - } 821 - Error(err) -> { 822 - // Clean up temp directory 823 - let _ = simplifile.delete(temp_dir) 824 - 825 - logging.log(logging.Error, "[settings] Failed to extract ZIP: " <> err) 826 - wisp.redirect("/settings") 827 - |> wisp_flash.set_flash(req, "error", "Failed to extract ZIP file: " <> err) 828 - } 829 - } 830 - } 831 - Error(_) -> { 832 - logging.log(logging.Error, "[settings] Failed to create temp directory") 833 - wisp.redirect("/settings") 834 - |> wisp_flash.set_flash(req, "error", "Failed to create temporary directory for upload") 835 - } 836 - } 837 666 } 838 667 839 668 fn middleware(
+331
server/src/settings_handler.gleam
··· 1 + import config 2 + import database 3 + import gleam/erlang/process 4 + import gleam/http as gleam_http 5 + import gleam/int 6 + import gleam/list 7 + import gleam/option 8 + import gleam/string 9 + import importer 10 + import jetstream_consumer 11 + import logging 12 + import lustre/element 13 + import oauth/handlers 14 + import oauth/session 15 + import pages/settings 16 + import simplifile 17 + import sqlight 18 + import wisp 19 + import wisp_flash 20 + import zip_helper 21 + 22 + pub type Context { 23 + Context( 24 + db: sqlight.Connection, 25 + oauth_config: handlers.OAuthConfig, 26 + admin_dids: List(String), 27 + config: process.Subject(config.Message), 28 + jetstream_consumer: option.Option(process.Subject(jetstream_consumer.Message)), 29 + ) 30 + } 31 + 32 + pub fn handle(req: wisp.Request, ctx: Context) -> wisp.Response { 33 + // Get current user from session (with automatic token refresh) 34 + let refresh_fn = fn(refresh_token) { 35 + handlers.refresh_access_token(ctx.oauth_config, refresh_token) 36 + } 37 + 38 + let #(current_user, user_is_admin) = case 39 + session.get_current_user(req, ctx.db, refresh_fn) 40 + { 41 + Ok(#(did, handle, _access_token)) -> { 42 + let admin = is_admin(did, ctx.admin_dids) 43 + #(option.Some(#(did, handle)), admin) 44 + } 45 + Error(_) -> #(option.None, False) 46 + } 47 + 48 + case req.method { 49 + gleam_http.Get -> { 50 + // Extract flash messages if present 51 + use flash_kind, flash_message <- wisp_flash.get_flash(req) 52 + 53 + settings.view(ctx.db, current_user, user_is_admin, flash_kind, flash_message) 54 + |> element.to_document_string 55 + |> wisp.html_response(200) 56 + } 57 + gleam_http.Post -> { 58 + // Handle form submission (domain authority or lexicons upload or reset) 59 + use form_data <- wisp.require_form(req) 60 + 61 + // Check if this is a reset action 62 + case list.key_find(form_data.values, "action") { 63 + Ok("reset") -> { 64 + // Handle reset action 65 + handle_reset(req, form_data, ctx, user_is_admin) 66 + } 67 + _ -> { 68 + // Check if this is a lexicons upload 69 + case list.key_find(form_data.files, "lexicons_zip") { 70 + Ok(uploaded_file) -> { 71 + // Handle lexicons ZIP upload 72 + handle_lexicons_upload(req, uploaded_file, ctx) 73 + } 74 + Error(_) -> { 75 + // Not a file upload, check for domain_authority field 76 + case list.key_find(form_data.values, "domain_authority") { 77 + Ok(domain_authority) -> { 78 + // Validate domain authority format 79 + case validate_domain_authority(domain_authority) { 80 + Ok(_) -> { 81 + // Save domain_authority to database and update cache 82 + case config.set_domain_authority(ctx.config, ctx.db, domain_authority) { 83 + Ok(_) -> { 84 + wisp.redirect("/settings") 85 + |> wisp_flash.set_flash(req, "success", "Domain authority saved successfully") 86 + } 87 + Error(_) -> { 88 + logging.log(logging.Error, "[settings] Failed to save domain_authority") 89 + wisp.redirect("/settings") 90 + |> wisp_flash.set_flash(req, "error", "Failed to save domain authority") 91 + } 92 + } 93 + } 94 + Error(error_message) -> { 95 + logging.log(logging.Warning, "[settings] Invalid domain authority: " <> error_message) 96 + wisp.redirect("/settings") 97 + |> wisp_flash.set_flash(req, "error", error_message) 98 + } 99 + } 100 + } 101 + Error(_) -> { 102 + logging.log(logging.Warning, "[settings] No form data received") 103 + wisp.redirect("/settings") 104 + } 105 + } 106 + } 107 + } 108 + } 109 + } 110 + } 111 + _ -> { 112 + wisp.response(405) 113 + |> wisp.set_header("content-type", "text/html") 114 + |> wisp.set_body(wisp.Text("<h1>Method Not Allowed</h1>")) 115 + } 116 + } 117 + } 118 + 119 + fn is_admin(did: String, admin_dids: List(String)) -> Bool { 120 + list.contains(admin_dids, did) 121 + } 122 + 123 + fn handle_lexicons_upload( 124 + req: wisp.Request, 125 + uploaded_file: wisp.UploadedFile, 126 + ctx: Context, 127 + ) -> wisp.Response { 128 + logging.log(logging.Info, "[settings] Processing lexicons ZIP upload: " <> uploaded_file.file_name) 129 + 130 + // Create temporary directory for extraction with random suffix 131 + let temp_dir = "tmp/lexicon_upload_" <> wisp.random_string(16) 132 + 133 + case simplifile.create_directory_all(temp_dir) { 134 + Ok(_) -> { 135 + logging.log(logging.Info, "[settings] Created temp directory: " <> temp_dir) 136 + 137 + // Extract ZIP file to temp directory 138 + case zip_helper.extract_zip(uploaded_file.path, temp_dir) { 139 + Ok(_) -> { 140 + logging.log(logging.Info, "[settings] Extracted ZIP file to: " <> temp_dir) 141 + 142 + // Import lexicons from extracted directory 143 + case importer.import_lexicons_from_directory(temp_dir, ctx.db) { 144 + Ok(stats) -> { 145 + // Clean up temp directory 146 + let _ = simplifile.delete(temp_dir) 147 + 148 + logging.log( 149 + logging.Info, 150 + "[settings] Lexicon import complete: " 151 + <> int.to_string(stats.imported) 152 + <> " imported, " 153 + <> int.to_string(stats.failed) 154 + <> " failed", 155 + ) 156 + 157 + // Log any errors 158 + case stats.errors { 159 + [] -> Nil 160 + errors -> { 161 + list.each(errors, fn(err) { 162 + logging.log(logging.Warning, "[settings] Import error: " <> err) 163 + }) 164 + } 165 + } 166 + 167 + // Restart Jetstream consumer to pick up newly imported collections 168 + let restart_status = case ctx.jetstream_consumer { 169 + option.Some(consumer) -> { 170 + logging.log(logging.Info, "[settings] Restarting Jetstream consumer with new lexicons...") 171 + case jetstream_consumer.restart(consumer) { 172 + Ok(_) -> { 173 + logging.log(logging.Info, "[settings] Jetstream consumer restarted successfully") 174 + "success" 175 + } 176 + Error(err) -> { 177 + logging.log(logging.Error, "[settings] Failed to restart Jetstream consumer: " <> err) 178 + "failed" 179 + } 180 + } 181 + } 182 + option.None -> { 183 + logging.log(logging.Info, "[settings] Jetstream consumer not running, skipping restart") 184 + "not_running" 185 + } 186 + } 187 + 188 + // Build success message with import stats and restart status 189 + let base_message = "Imported " <> int.to_string(stats.imported) <> " lexicon(s) successfully" 190 + let message = case restart_status { 191 + "success" -> base_message <> ". Jetstream consumer restarted." 192 + "failed" -> base_message <> ". Warning: Jetstream consumer restart failed." 193 + "not_running" -> base_message <> "." 194 + _ -> base_message 195 + } 196 + 197 + let flash_kind = case restart_status { 198 + "failed" -> "warning" 199 + _ -> "success" 200 + } 201 + 202 + wisp.redirect("/settings") 203 + |> wisp_flash.set_flash(req, flash_kind, message) 204 + } 205 + Error(err) -> { 206 + // Clean up temp directory 207 + let _ = simplifile.delete(temp_dir) 208 + 209 + logging.log(logging.Error, "[settings] Failed to import lexicons: " <> err) 210 + wisp.redirect("/settings") 211 + |> wisp_flash.set_flash(req, "error", "Failed to import lexicons: " <> err) 212 + } 213 + } 214 + } 215 + Error(err) -> { 216 + // Clean up temp directory 217 + let _ = simplifile.delete(temp_dir) 218 + 219 + logging.log(logging.Error, "[settings] Failed to extract ZIP: " <> err) 220 + wisp.redirect("/settings") 221 + |> wisp_flash.set_flash(req, "error", "Failed to extract ZIP file: " <> err) 222 + } 223 + } 224 + } 225 + Error(_) -> { 226 + logging.log(logging.Error, "[settings] Failed to create temp directory") 227 + wisp.redirect("/settings") 228 + |> wisp_flash.set_flash(req, "error", "Failed to create temporary directory for upload") 229 + } 230 + } 231 + } 232 + 233 + /// Validates domain authority format (e.g., "com.example") 234 + fn validate_domain_authority(domain_authority: String) -> Result(Nil, String) { 235 + // Check for http:// or https:// 236 + case 237 + string.contains(domain_authority, "http://") 238 + || string.contains(domain_authority, "https://") 239 + { 240 + True -> 241 + Error( 242 + "Domain authority should not contain http:// or https:// (e.g., com.example)", 243 + ) 244 + False -> { 245 + // Check that it has exactly two parts separated by a dot 246 + let parts = string.split(domain_authority, ".") 247 + case list.length(parts) == 2 { 248 + False -> 249 + Error( 250 + "Domain authority must have exactly two parts separated by a dot (e.g., com.example)", 251 + ) 252 + True -> { 253 + // Check that no parts are empty 254 + case list.all(parts, fn(part) { string.length(part) > 0 }) { 255 + False -> 256 + Error("Domain authority parts cannot be empty (e.g., com.example)") 257 + True -> Ok(Nil) 258 + } 259 + } 260 + } 261 + } 262 + } 263 + } 264 + 265 + fn handle_reset( 266 + req: wisp.Request, 267 + form_data: wisp.FormData, 268 + ctx: Context, 269 + is_admin: Bool, 270 + ) -> wisp.Response { 271 + // Check admin access 272 + case is_admin { 273 + False -> { 274 + logging.log(logging.Error, "[settings] Non-admin user attempted reset") 275 + wisp.redirect("/settings") 276 + |> wisp_flash.set_flash(req, "error", "Unauthorized: Admin access required") 277 + } 278 + True -> { 279 + // Verify confirmation 280 + case list.key_find(form_data.values, "confirm") { 281 + Ok("RESET") -> { 282 + // Delete all data 283 + let domain_result = database.delete_domain_authority(ctx.db) 284 + let lexicons_result = database.delete_all_lexicons(ctx.db) 285 + let records_result = database.delete_all_records(ctx.db) 286 + let actors_result = database.delete_all_actors(ctx.db) 287 + 288 + case domain_result, lexicons_result, records_result, actors_result { 289 + Ok(_), Ok(_), Ok(_), Ok(_) -> { 290 + // Reload config cache 291 + let _ = config.reload(ctx.config, ctx.db) 292 + 293 + // Restart Jetstream consumer if it exists 294 + let restart_message = case ctx.jetstream_consumer { 295 + option.Some(consumer) -> { 296 + case jetstream_consumer.restart(consumer) { 297 + Ok(_) -> "All data has been reset successfully" 298 + Error(_) -> 299 + "Data reset completed (Jetstream consumer may need manual restart)" 300 + } 301 + } 302 + option.None -> "All data has been reset successfully" 303 + } 304 + 305 + logging.log(logging.Info, "[settings] System reset completed") 306 + wisp.redirect("/settings") 307 + |> wisp_flash.set_flash(req, "success", restart_message) 308 + } 309 + _, _, _, _ -> { 310 + logging.log(logging.Error, "[settings] Failed to reset some data") 311 + wisp.redirect("/settings") 312 + |> wisp_flash.set_flash(req, "error", "Failed to reset all data") 313 + } 314 + } 315 + } 316 + _ -> { 317 + logging.log( 318 + logging.Warning, 319 + "[settings] Reset attempted without proper confirmation", 320 + ) 321 + wisp.redirect("/settings") 322 + |> wisp_flash.set_flash( 323 + req, 324 + "error", 325 + "Confirmation failed: Please type RESET exactly", 326 + ) 327 + } 328 + } 329 + } 330 + } 331 + }