Auto-indexing service and GraphQL API for AT Protocol Records

update header nav, move config/lexicon import to settings page, settings can be changed at runtime

+1318 -158
+2
.gitignore
··· 8 8 9 9 .DS_Store 10 10 *.env 11 + 12 + tmp
-3
docs/deployment.md
··· 10 10 | `HOST` | No | `127.0.0.1` | Server bind address. Set to `0.0.0.0` for containers | 11 11 | `PORT` | No | `8000` | Server port | 12 12 | `SECRET_KEY_BASE` | Recommended | Auto-generated | Session encryption key (64+ chars). **Must persist across restarts** | 13 - | `DOMAIN_AUTHORITY` | Optional | - | Domain authority for lexicons (e.g., `xyz.statusphere`) | 14 13 | `ADMIN_DIDS` | Optional | - | Comma-separated DIDs for admin access (e.g., `did:plc:abc,did:plc:xyz`) | 15 14 | `OAUTH_CLIENT_ID` | Optional | - | OAuth client ID | 16 15 | `OAUTH_CLIENT_SECRET` | Optional | - | OAuth client secret | ··· 122 121 123 122 Optional variables: 124 123 ``` 125 - DOMAIN_AUTHORITY=your.domain 126 124 ADMIN_DIDS=did:plc:your_did 127 125 OAUTH_CLIENT_ID=your_client_id 128 126 OAUTH_CLIENT_SECRET=your_client_secret ··· 186 184 - PORT=8000 187 185 - DATABASE_URL=/data/quickslice.db 188 186 - SECRET_KEY_BASE=${SECRET_KEY_BASE} 189 - - DOMAIN_AUTHORITY=your.domain 190 187 - ADMIN_DIDS=${ADMIN_DIDS} 191 188 restart: unless-stopped 192 189 healthcheck:
-1
example/README.md
··· 85 85 86 86 - `HOST` - Server host (default: `0.0.0.0`) 87 87 - `PORT` - Server port (default: `8000`) 88 - - `DOMAIN_AUTHORITY` - Domain authority (default: `xyz.statusphere`) 89 88 90 89 ## GraphQL Endpoint 91 90
-1
example/docker-compose.yml
··· 11 11 environment: 12 12 - HOST=0.0.0.0 13 13 - PORT=8000 14 - - DOMAIN_AUTHORITY=xyz.statusphere 15 14 restart: on-failure:5
-1
example/fly.toml
··· 12 12 DATABASE_URL = '/data/quickslice.db' 13 13 HOST = '0.0.0.0' 14 14 PORT = '8080' 15 - DOMAIN_AUTHORITY = 'xyz.statusphere' 16 15 17 16 [http_service] 18 17 internal_port = 8080
-3
server/.env.example
··· 24 24 # PORT: The port to listen on 25 25 PORT=8000 26 26 27 - # DOMAIN_AUTHORITY: The domain authority for this instance 28 - DOMAIN_AUTHORITY=xyz.statusphere 29 - 30 27 # Database Configuration 31 28 DATABASE_URL=quickslice.db 32 29
+1
server/gleam.toml
··· 19 19 gleam_stdlib = ">= 0.60.0 and < 1.0.0" 20 20 mist = ">= 5.0.3 and < 6.0.0" 21 21 wisp = ">= 2.1.0 and < 3.0.0" 22 + wisp_flash = ">= 2.0.0 and < 3.0.0" 22 23 gleam_erlang = ">= 1.0.0 and < 2.0.0" 23 24 gleam_otp = ">= 1.2.0 and < 2.0.0" 24 25 gleam_http = ">= 4.0.0 and < 5.0.0"
+2
server/manifest.toml
··· 49 49 { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, 50 50 { name = "unicode_util_compat", version = "0.7.1", build_tools = ["rebar3"], requirements = [], otp_app = "unicode_util_compat", source = "hex", outer_checksum = "B3A917854CE3AE233619744AD1E0102E05673136776FB2FA76234F3E03B23642" }, 51 51 { name = "wisp", version = "2.1.0", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "362BDDD11BF48EB38CDE51A73BC7D1B89581B395CA998E3F23F11EC026151C54" }, 52 + { name = "wisp_flash", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "wisp"], otp_app = "wisp_flash", source = "hex", outer_checksum = "C67A295E42030EEB8A7D31CD64971790EC010FA2644CBDC0835BF88D97DE998D" }, 52 53 ] 53 54 54 55 [requirements] ··· 78 79 sqlight = { version = ">= 1.0.0 and < 2.0.0" } 79 80 thoas = { version = ">= 1.0.0 and < 2.0.0" } 80 81 wisp = { version = ">= 2.1.0 and < 3.0.0" } 82 + wisp_flash = { version = ">= 2.0.0 and < 3.0.0" }
+4 -9
server/src/backfill.gleam
··· 84 84 85 85 /// Check if an NSID matches the configured domain authority 86 86 /// NSID format is like "com.example.post" where "com.example" is the authority 87 - pub fn nsid_matches_domain_authority(nsid: String) -> Bool { 88 - case envoy.get("DOMAIN_AUTHORITY") { 89 - Error(_) -> False 90 - Ok(domain_authority) -> { 91 - // NSID format: authority.name (e.g., "com.example.post") 92 - // We need to check if the NSID starts with the domain authority 93 - string.starts_with(nsid, domain_authority <> ".") 94 - } 95 - } 87 + pub fn nsid_matches_domain_authority(nsid: String, domain_authority: String) -> Bool { 88 + // NSID format: authority.name (e.g., "com.example.post") 89 + // We need to check if the NSID starts with the domain authority 90 + string.starts_with(nsid, domain_authority <> ".") 96 91 } 97 92 98 93 /// Resolve a DID to get ATP data (PDS endpoint and handle)
+98
server/src/components/alert.gleam
··· 1 + import gleam/option.{type Option} 2 + import lustre/attribute 3 + import lustre/element.{type Element} 4 + import lustre/element/html 5 + 6 + pub type AlertKind { 7 + Success 8 + Error 9 + Info 10 + Warning 11 + } 12 + 13 + /// Render an alert message with appropriate styling 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", "⚠") 20 + } 21 + 22 + html.div( 23 + [ 24 + attribute.class( 25 + "mb-6 p-4 rounded border " <> bg_class <> " " <> border_class, 26 + ), 27 + ], 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 + ]), 36 + ]), 37 + ], 38 + ) 39 + } 40 + 41 + /// Render an alert message with a link 42 + pub fn alert_with_link( 43 + kind: AlertKind, 44 + message: String, 45 + link_text: String, 46 + link_url: String, 47 + ) -> 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", "⚠") 53 + } 54 + 55 + html.div( 56 + [ 57 + attribute.class( 58 + "mb-6 p-4 rounded border " <> bg_class <> " " <> border_class, 59 + ), 60 + ], 61 + [ 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 + ]), 76 + ]), 77 + ], 78 + ) 79 + } 80 + 81 + /// Helper to conditionally render alert based on optional kind and message 82 + pub fn maybe_alert( 83 + kind: Option(String), 84 + message: Option(String), 85 + ) -> Element(msg) { 86 + case kind, message { 87 + option.Some(k), option.Some(m) -> { 88 + let alert_kind = case k { 89 + "success" -> Success 90 + "error" -> Error 91 + "warning" -> Warning 92 + _ -> Info 93 + } 94 + alert(alert_kind, m) 95 + } 96 + _, _ -> element.none() 97 + } 98 + }
+16 -3
server/src/components/backfill_button.gleam
··· 1 1 import backfill 2 2 import backfill_state 3 3 import components/button 4 + import config 4 5 import database 5 6 import gleam/erlang/process 6 7 import gleam/json 7 8 import gleam/list 9 + import gleam/option 8 10 import gleam/otp/actor 9 11 import lustre 10 12 import lustre/attribute ··· 19 21 pub fn component( 20 22 db: sqlight.Connection, 21 23 backfill_state_subject: process.Subject(backfill_state.Message), 24 + config_subject: process.Subject(config.Message), 22 25 ) { 23 - lustre.application(init(db, backfill_state_subject, _), update, view) 26 + lustre.application(init(db, backfill_state_subject, config_subject, _), update, view) 24 27 } 25 28 26 29 // MODEL ··· 31 34 is_admin: Bool, 32 35 db: sqlight.Connection, 33 36 backfill_state: process.Subject(backfill_state.Message), 37 + config: process.Subject(config.Message), 34 38 ) 35 39 } 36 40 37 41 fn init( 38 42 db: sqlight.Connection, 39 43 backfill_state_subject: process.Subject(backfill_state.Message), 44 + config_subject: process.Subject(config.Message), 40 45 flags: #(Bool, Bool), 41 46 ) -> #(Model, effect.Effect(Msg)) { 42 47 let #(is_admin, backfilling) = flags ··· 51 56 is_admin: is_admin, 52 57 db: db, 53 58 backfill_state: backfill_state_subject, 59 + config: config_subject, 54 60 ), 55 61 initial_effect, 56 62 ) ··· 68 74 UserClickedBackfill -> #( 69 75 Model(..model, backfilling: True), 70 76 effect.batch([ 71 - do_backfill(model.db, model.backfill_state), 77 + do_backfill(model.db, model.backfill_state, model.config), 72 78 start_polling(), 73 79 ]), 74 80 ) ··· 103 109 fn do_backfill( 104 110 db: sqlight.Connection, 105 111 backfill_state_subject: process.Subject(backfill_state.Message), 112 + config_subject: process.Subject(config.Message), 106 113 ) -> effect.Effect(Msg) { 107 114 effect.from(fn(_dispatch) { 108 115 // Update global state to indicate backfill is starting 109 116 process.send(backfill_state_subject, backfill_state.StartBackfill) 110 117 118 + // Get domain authority from config 119 + let domain_authority = case config.get_domain_authority(config_subject) { 120 + option.Some(authority) -> authority 121 + option.None -> "" 122 + } 123 + 111 124 // Spawn async process to run backfill without blocking the UI 112 125 let _ = 113 126 process.spawn_unlinked(fn() { ··· 117 130 let #(collections, external_collections) = 118 131 lexicons 119 132 |> list.partition(fn(lex) { 120 - backfill.nsid_matches_domain_authority(lex.id) 133 + backfill.nsid_matches_domain_authority(lex.id, domain_authority) 121 134 }) 122 135 123 136 let collection_ids = list.map(collections, fn(lex) { lex.id })
+105
server/src/components/input.gleam
··· 1 + import lustre/attribute 2 + import lustre/element.{type Element} 3 + import lustre/element/html 4 + import lustre/event 5 + 6 + /// Standard input styling used throughout the app 7 + const input_classes = "font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-900 border border-zinc-800 rounded focus:outline-none focus:border-zinc-700 w-full" 8 + 9 + /// Standard label styling 10 + const label_classes = "block text-sm text-zinc-400 mb-2" 11 + 12 + /// Render a text input field with label 13 + pub fn text_input( 14 + label label: String, 15 + name name: String, 16 + value value: String, 17 + placeholder placeholder: String, 18 + on_input on_input: fn(String) -> msg, 19 + ) -> Element(msg) { 20 + html.div([attribute.class("mb-4")], [ 21 + html.label([attribute.class(label_classes), attribute.for(name)], [ 22 + element.text(label), 23 + ]), 24 + html.input([ 25 + attribute.type_("text"), 26 + attribute.name(name), 27 + attribute.id(name), 28 + attribute.value(value), 29 + attribute.placeholder(placeholder), 30 + attribute.class(input_classes), 31 + event.on_input(on_input), 32 + ]), 33 + ]) 34 + } 35 + 36 + /// Render a text input field with label for forms (no event handler) 37 + pub fn form_text_input( 38 + label label: String, 39 + name name: String, 40 + value value: String, 41 + placeholder placeholder: String, 42 + required required: Bool, 43 + ) -> Element(msg) { 44 + let required_attr = case required { 45 + True -> [attribute.attribute("required", "")] 46 + False -> [] 47 + } 48 + 49 + html.div([attribute.class("mb-4")], [ 50 + html.label([attribute.class(label_classes), attribute.for(name)], [ 51 + element.text(label), 52 + case required { 53 + True -> 54 + html.span([attribute.class("text-red-500 ml-1")], [element.text("*")]) 55 + False -> element.none() 56 + }, 57 + ]), 58 + html.input( 59 + [ 60 + attribute.type_("text"), 61 + attribute.name(name), 62 + attribute.id(name), 63 + attribute.value(value), 64 + attribute.placeholder(placeholder), 65 + attribute.class(input_classes), 66 + ..required_attr 67 + ], 68 + ), 69 + ]) 70 + } 71 + 72 + /// Render a file input field with label for forms 73 + pub fn form_file_input( 74 + label label: String, 75 + name name: String, 76 + accept accept: String, 77 + required required: Bool, 78 + ) -> Element(msg) { 79 + let required_attr = case required { 80 + True -> [attribute.attribute("required", "")] 81 + False -> [] 82 + } 83 + 84 + html.div([attribute.class("mb-4")], [ 85 + html.label([attribute.class(label_classes), attribute.for(name)], [ 86 + element.text(label), 87 + case required { 88 + True -> 89 + html.span([attribute.class("text-red-500 ml-1")], [element.text("*")]) 90 + False -> element.none() 91 + }, 92 + ]), 93 + html.input( 94 + [ 95 + attribute.type_("file"), 96 + attribute.name(name), 97 + attribute.id(name), 98 + attribute.attribute("accept", accept), 99 + attribute.class(input_classes), 100 + ..required_attr 101 + ], 102 + ), 103 + ]) 104 + } 105 +
+175 -1
server/src/components/layout.gleam
··· 1 + import components/logo 2 + import gleam/option.{type Option} 1 3 import lustre/attribute 2 4 import lustre/element.{type Element} 3 5 import lustre/element/html 6 + 7 + /// Renders a complete HTML page with unified header 8 + pub fn page_with_header( 9 + title title: String, 10 + content content: List(Element(msg)), 11 + current_user current_user: Option(#(String, String)), 12 + domain_authority domain_authority: Option(String), 13 + ) -> Element(msg) { 14 + html.html([attribute.class("h-full")], [ 15 + head(title), 16 + html.body([attribute.class("bg-zinc-950 text-zinc-300 font-mono min-h-screen")], [ 17 + html.div([attribute.class("max-w-4xl mx-auto px-6 py-12")], [ 18 + render_header(current_user, domain_authority), 19 + ..content 20 + ]), 21 + ]), 22 + ]) 23 + } 4 24 5 25 /// Renders a complete HTML page with the given title and content 6 26 pub fn page(title title: String, content content: List(Element(msg))) -> Element(msg) { ··· 55 75 ]) 56 76 } 57 77 58 - /// Renders the HTML body with a max-width container 78 + /// Renders the HTML body with a max-width container and navigation 59 79 fn body(content: List(Element(msg))) -> Element(msg) { 60 80 html.body([attribute.class("bg-zinc-950 text-zinc-300 font-mono min-h-screen")], [ 81 + nav_header(), 61 82 html.div([attribute.class("max-w-4xl mx-auto px-6 py-12")], content), 62 83 ]) 63 84 } 85 + 86 + /// Renders the navigation header 87 + fn nav_header() -> Element(msg) { 88 + html.nav([attribute.class("bg-zinc-900 border-b border-zinc-800")], [ 89 + html.div([attribute.class("max-w-4xl mx-auto px-6 py-4")], [ 90 + html.div([attribute.class("flex items-center justify-between")], [ 91 + html.a( 92 + [ 93 + attribute.href("/"), 94 + attribute.class("text-zinc-300 hover:text-zinc-100 transition-colors"), 95 + ], 96 + [element.text("quickslice")], 97 + ), 98 + html.div([attribute.class("flex gap-4")], [ 99 + html.a( 100 + [ 101 + attribute.href("/"), 102 + attribute.class( 103 + "text-zinc-400 hover:text-zinc-200 transition-colors text-sm", 104 + ), 105 + ], 106 + [element.text("Home")], 107 + ), 108 + html.a( 109 + [ 110 + attribute.href("/settings"), 111 + attribute.class( 112 + "text-zinc-400 hover:text-zinc-200 transition-colors text-sm", 113 + ), 114 + ], 115 + [element.text("Settings")], 116 + ), 117 + ]), 118 + ]), 119 + ]), 120 + ]) 121 + } 122 + 123 + /// Renders the unified header with logo, nav links, and user section 124 + fn render_header( 125 + current_user: Option(#(String, String)), 126 + domain_authority: Option(String), 127 + ) -> Element(msg) { 128 + html.div([attribute.class("border-b border-zinc-800 pb-4 mb-8")], [ 129 + html.div([attribute.class("flex items-end justify-between")], [ 130 + // Left: Brand with logo 131 + html.a( 132 + [ 133 + attribute.href("/"), 134 + attribute.class( 135 + "flex items-center gap-3 hover:opacity-80 transition-opacity", 136 + ), 137 + ], 138 + [ 139 + logo.view("w-10 h-10"), 140 + html.div([], [ 141 + html.h1( 142 + [ 143 + attribute.class( 144 + "text-xs font-medium uppercase tracking-wider text-zinc-500", 145 + ), 146 + ], 147 + [element.text("quickslice")], 148 + ), 149 + case domain_authority { 150 + option.Some(value) -> 151 + case value { 152 + "" -> element.none() 153 + _ -> 154 + html.p([attribute.class("text-xs text-zinc-600 mt-1")], [ 155 + element.text(value), 156 + ]) 157 + } 158 + option.None -> element.none() 159 + }, 160 + ]), 161 + ], 162 + ), 163 + // Right: Navigation + User section 164 + case current_user { 165 + option.Some(_) -> { 166 + html.div([attribute.class("flex gap-4 text-xs items-center")], [ 167 + html.a( 168 + [ 169 + attribute.href("/"), 170 + attribute.class( 171 + "px-2 py-1 text-zinc-400 hover:text-zinc-300 transition-colors", 172 + ), 173 + ], 174 + [element.text("Home")], 175 + ), 176 + html.a( 177 + [ 178 + attribute.href("/settings"), 179 + attribute.class( 180 + "px-2 py-1 text-zinc-400 hover:text-zinc-300 transition-colors", 181 + ), 182 + ], 183 + [element.text("Settings")], 184 + ), 185 + render_user_section(current_user), 186 + ]) 187 + } 188 + option.None -> { 189 + html.div([attribute.class("flex items-center")], [ 190 + render_user_section(current_user), 191 + ]) 192 + } 193 + }, 194 + ]), 195 + ]) 196 + } 197 + 198 + /// Renders the user section showing login or user info 199 + fn render_user_section(current_user: Option(#(String, String))) -> Element(msg) { 200 + case current_user { 201 + option.Some(#(_did, handle)) -> { 202 + html.span([attribute.class("px-2 py-1 text-zinc-400")], [ 203 + element.text("@" <> handle), 204 + ]) 205 + } 206 + option.None -> { 207 + // Show inline login form 208 + html.form( 209 + [ 210 + attribute.method("post"), 211 + attribute.action("/oauth/authorize"), 212 + attribute.class("flex items-center gap-2"), 213 + ], 214 + [ 215 + html.input([ 216 + attribute.type_("text"), 217 + attribute.name("loginHint"), 218 + attribute.placeholder("your-handle.bsky.social"), 219 + attribute.class( 220 + "px-3 py-2 bg-zinc-900 border border-zinc-800 rounded text-xs text-zinc-300 focus:outline-none focus:border-zinc-700", 221 + ), 222 + attribute.attribute("required", ""), 223 + ]), 224 + html.button( 225 + [ 226 + attribute.type_("submit"), 227 + attribute.class( 228 + "px-3 py-2 text-xs text-zinc-300 bg-zinc-800 hover:bg-zinc-700 rounded transition-colors cursor-pointer", 229 + ), 230 + ], 231 + [element.text("Login")], 232 + ), 233 + ], 234 + ) 235 + } 236 + } 237 + }
+91
server/src/components/logo.gleam
··· 1 + import lustre/attribute 2 + import lustre/element.{type Element} 3 + import lustre/element/svg 4 + 5 + /// Render the Slices logo SVG 6 + pub fn view(class: String) -> Element(msg) { 7 + svg.svg( 8 + [ 9 + attribute.attribute("viewBox", "0 0 60 60"), 10 + attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), 11 + attribute.class(class), 12 + ], 13 + [ 14 + // Define gradients 15 + svg.defs( 16 + [], 17 + [ 18 + svg.linear_gradient( 19 + [ 20 + attribute.id("board1"), 21 + attribute.attribute("x1", "0%"), 22 + attribute.attribute("y1", "0%"), 23 + attribute.attribute("x2", "100%"), 24 + attribute.attribute("y2", "100%"), 25 + ], 26 + [ 27 + svg.stop([ 28 + attribute.attribute("offset", "0%"), 29 + attribute.attribute("stop-color", "#FF6347"), 30 + attribute.attribute("stop-opacity", "1"), 31 + ]), 32 + svg.stop([ 33 + attribute.attribute("offset", "100%"), 34 + attribute.attribute("stop-color", "#FF4500"), 35 + attribute.attribute("stop-opacity", "1"), 36 + ]), 37 + ], 38 + ), 39 + svg.linear_gradient( 40 + [ 41 + attribute.id("board2"), 42 + attribute.attribute("x1", "0%"), 43 + attribute.attribute("y1", "0%"), 44 + attribute.attribute("x2", "100%"), 45 + attribute.attribute("y2", "100%"), 46 + ], 47 + [ 48 + svg.stop([ 49 + attribute.attribute("offset", "0%"), 50 + attribute.attribute("stop-color", "#00CED1"), 51 + attribute.attribute("stop-opacity", "1"), 52 + ]), 53 + svg.stop([ 54 + attribute.attribute("offset", "100%"), 55 + attribute.attribute("stop-color", "#4682B4"), 56 + attribute.attribute("stop-opacity", "1"), 57 + ]), 58 + ], 59 + ), 60 + ], 61 + ), 62 + // Surfboard/skateboard deck shapes stacked 63 + svg.g([attribute.attribute("transform", "translate(30, 30)")], [ 64 + // Top board slice 65 + svg.ellipse([ 66 + attribute.attribute("cx", "0"), 67 + attribute.attribute("cy", "-8"), 68 + attribute.attribute("rx", "15"), 69 + attribute.attribute("ry", "6"), 70 + attribute.attribute("fill", "url(#board1)"), 71 + ]), 72 + // Middle board slice 73 + svg.ellipse([ 74 + attribute.attribute("cx", "0"), 75 + attribute.attribute("cy", "0"), 76 + attribute.attribute("rx", "18"), 77 + attribute.attribute("ry", "6"), 78 + attribute.attribute("fill", "url(#board2)"), 79 + ]), 80 + // Bottom board slice 81 + svg.ellipse([ 82 + attribute.attribute("cx", "0"), 83 + attribute.attribute("cy", "8"), 84 + attribute.attribute("rx", "12"), 85 + attribute.attribute("ry", "6"), 86 + attribute.attribute("fill", "#32CD32"), 87 + ]), 88 + ]), 89 + ], 90 + ) 91 + }
+103
server/src/config.gleam
··· 1 + import database 2 + import gleam/erlang/process 3 + import gleam/option.{type Option, None, Some} 4 + import gleam/otp/actor 5 + import logging 6 + import sqlight 7 + 8 + // ===== Config Cache Actor ===== 9 + 10 + pub type ConfigCache { 11 + ConfigCache(domain_authority: Option(String)) 12 + } 13 + 14 + pub type Message { 15 + GetDomainAuthority(reply_with: process.Subject(Option(String))) 16 + SetDomainAuthority(value: String, reply_with: process.Subject(Nil)) 17 + Reload(db: sqlight.Connection, reply_with: process.Subject(Nil)) 18 + } 19 + 20 + fn handle_message( 21 + state: ConfigCache, 22 + message: Message, 23 + ) -> actor.Next(ConfigCache, Message) { 24 + case message { 25 + GetDomainAuthority(client) -> { 26 + process.send(client, state.domain_authority) 27 + actor.continue(state) 28 + } 29 + SetDomainAuthority(value, client) -> { 30 + process.send(client, Nil) 31 + actor.continue(ConfigCache(domain_authority: Some(value))) 32 + } 33 + Reload(db, client) -> { 34 + let new_state = case database.get_config(db, "domain_authority") { 35 + Ok(value) -> ConfigCache(domain_authority: Some(value)) 36 + Error(_) -> ConfigCache(domain_authority: None) 37 + } 38 + process.send(client, Nil) 39 + actor.continue(new_state) 40 + } 41 + } 42 + } 43 + 44 + /// Start the config cache actor 45 + pub fn start(db: sqlight.Connection) -> Result(process.Subject(Message), actor.StartError) { 46 + // Load initial domain authority from database 47 + let initial_state = case database.get_config(db, "domain_authority") { 48 + Ok(value) -> ConfigCache(domain_authority: Some(value)) 49 + Error(_) -> { 50 + logging.log( 51 + logging.Info, 52 + "[config] No domain_authority found in database", 53 + ) 54 + ConfigCache(domain_authority: None) 55 + } 56 + } 57 + 58 + let result = 59 + actor.new(initial_state) 60 + |> actor.on_message(handle_message) 61 + |> actor.start 62 + 63 + case result { 64 + Ok(started) -> Ok(started.data) 65 + Error(err) -> Error(err) 66 + } 67 + } 68 + 69 + /// Get the current domain authority from cache 70 + pub fn get_domain_authority( 71 + config: process.Subject(Message), 72 + ) -> Option(String) { 73 + actor.call(config, waiting: 100, sending: GetDomainAuthority) 74 + } 75 + 76 + /// Set the domain authority (updates both database and cache) 77 + pub fn set_domain_authority( 78 + config: process.Subject(Message), 79 + db: sqlight.Connection, 80 + value: String, 81 + ) -> Result(Nil, sqlight.Error) { 82 + // Update database first 83 + case database.set_config(db, "domain_authority", value) { 84 + Ok(_) -> { 85 + // Update cache 86 + actor.call(config, waiting: 100, sending: SetDomainAuthority(value, _)) 87 + logging.log( 88 + logging.Info, 89 + "[config] Updated domain_authority: " <> value, 90 + ) 91 + Ok(Nil) 92 + } 93 + Error(err) -> Error(err) 94 + } 95 + } 96 + 97 + /// Reload config from database (useful after external updates) 98 + pub fn reload( 99 + config: process.Subject(Message), 100 + db: sqlight.Connection, 101 + ) -> Nil { 102 + actor.call(config, waiting: 100, sending: Reload(db, _)) 103 + }
+78 -4
server/src/database.gleam
··· 237 237 Ok(Nil) 238 238 } 239 239 240 + /// Migration v2: Add config table 241 + fn migration_v2(conn: sqlight.Connection) -> Result(Nil, sqlight.Error) { 242 + logging.log(logging.Info, "Running migration v2 (config table)...") 243 + 244 + let create_table_sql = 245 + " 246 + CREATE TABLE IF NOT EXISTS config ( 247 + key TEXT PRIMARY KEY NOT NULL, 248 + value TEXT NOT NULL, 249 + updated_at TEXT NOT NULL DEFAULT (datetime('now')) 250 + ) 251 + " 252 + 253 + sqlight.exec(create_table_sql, conn) 254 + } 255 + 240 256 /// Runs all pending migrations based on current schema version 241 257 fn run_migrations(conn: sqlight.Connection) -> Result(Nil, sqlight.Error) { 242 258 use current_version <- result.try(get_current_version(conn)) ··· 249 265 // Apply migrations sequentially based on current version 250 266 case current_version { 251 267 // Fresh database or pre-migration database - run v1 252 - 0 -> apply_migration(conn, 1, migration_v1) 268 + 0 -> { 269 + use _ <- result.try(apply_migration(conn, 1, migration_v1)) 270 + apply_migration(conn, 2, migration_v2) 271 + } 272 + 273 + // Run v2 migration 274 + 1 -> apply_migration(conn, 2, migration_v2) 253 275 254 276 // Already at latest version 255 - 1 -> { 256 - logging.log(logging.Info, "Schema is up to date (v1)") 277 + 2 -> { 278 + logging.log(logging.Info, "Schema is up to date (v2)") 257 279 Ok(Nil) 258 280 } 259 281 260 282 // Future versions would be handled here: 261 - // 1 -> apply_migration(conn, 2, migration_v2) 262 283 // 2 -> apply_migration(conn, 3, migration_v3) 284 + // 3 -> apply_migration(conn, 4, migration_v4) 263 285 _ -> { 264 286 logging.log( 265 287 logging.Error, ··· 283 305 logging.log(logging.Info, "Database initialized at: " <> path) 284 306 Ok(conn) 285 307 } 308 + 309 + // ===== Config Functions ===== 310 + 311 + /// Get a config value by key 312 + pub fn get_config( 313 + conn: sqlight.Connection, 314 + key: String, 315 + ) -> Result(String, sqlight.Error) { 316 + let sql = 317 + " 318 + SELECT value 319 + FROM config 320 + WHERE key = ? 321 + " 322 + 323 + let decoder = { 324 + use value <- decode.field(0, decode.string) 325 + decode.success(value) 326 + } 327 + 328 + case sqlight.query(sql, on: conn, with: [sqlight.text(key)], expecting: decoder) { 329 + Ok([value, ..]) -> Ok(value) 330 + Ok([]) -> Error(sqlight.SqlightError(sqlight.ConstraintForeignkey, "Config key not found", -1)) 331 + Error(err) -> Error(err) 332 + } 333 + } 334 + 335 + /// Set or update a config value 336 + pub fn set_config( 337 + conn: sqlight.Connection, 338 + key: String, 339 + value: String, 340 + ) -> Result(Nil, sqlight.Error) { 341 + let sql = 342 + " 343 + INSERT INTO config (key, value, updated_at) 344 + VALUES (?, ?, datetime('now')) 345 + ON CONFLICT(key) DO UPDATE SET 346 + value = excluded.value, 347 + updated_at = datetime('now') 348 + " 349 + 350 + use _ <- result.try(sqlight.query( 351 + sql, 352 + on: conn, 353 + with: [sqlight.text(key), sqlight.text(value)], 354 + expecting: decode.string, 355 + )) 356 + Ok(Nil) 357 + } 358 + 359 + // ===== Record Functions ===== 286 360 287 361 /// Inserts or updates a record in the database 288 362 pub fn insert_record(
+11 -1
server/src/graphql_gleam.gleam
··· 3 3 /// This module provides GraphQL schema building and query execution using 4 4 /// pure Gleam code, replacing the previous Elixir FFI implementation. 5 5 import backfill 6 + import config 6 7 import cursor 7 8 import database 8 9 import gleam/dict ··· 31 32 db: sqlight.Connection, 32 33 auth_base_url: String, 33 34 plc_url: String, 35 + domain_authority: String, 34 36 ) -> Result(schema.Schema, String) { 35 37 // Step 1: Fetch lexicons from database 36 38 use lexicon_records <- result.try( ··· 345 347 let external_collection_ids = 346 348 parsed_lexicons 347 349 |> list.filter_map(fn(lex) { 348 - case backfill.nsid_matches_domain_authority(lex.id) { 350 + case backfill.nsid_matches_domain_authority(lex.id, domain_authority) { 349 351 True -> Error(Nil) // Local collection, skip 350 352 False -> Ok(lex.id) // External collection, include 351 353 } ··· 407 409 auth_base_url: String, 408 410 plc_url: String, 409 411 ) -> Result(String, String) { 412 + // Start config cache actor to get domain authority 413 + let assert Ok(config_subject) = config.start(db) 414 + let domain_authority = case config.get_domain_authority(config_subject) { 415 + option.Some(authority) -> authority 416 + option.None -> "" 417 + } 418 + 410 419 // Build the schema 411 420 use graphql_schema <- result.try(build_schema_from_db( 412 421 db, 413 422 auth_base_url, 414 423 plc_url, 424 + domain_authority, 415 425 )) 416 426 417 427 // Create context with auth token if provided
+2
server/src/graphql_ws_handler.gleam
··· 180 180 db: sqlight.Connection, 181 181 auth_base_url: String, 182 182 plc_url: String, 183 + domain_authority: String, 183 184 ) -> response.Response(ResponseData) { 184 185 mist.websocket( 185 186 request: req, ··· 191 192 db, 192 193 auth_base_url, 193 194 plc_url, 195 + domain_authority, 194 196 ) { 195 197 Ok(schema) -> schema 196 198 Error(err) -> {
+121 -6
server/src/jetstream_consumer.gleam
··· 1 1 import backfill 2 + import config 2 3 import database 3 4 import envoy 4 5 import event_handler 5 6 import gleam/dynamic/decode 6 7 import gleam/erlang/process 7 8 import gleam/int 9 + import gleam/otp/actor 8 10 import logging 9 11 import gleam/list 10 12 import gleam/option ··· 12 14 import goose 13 15 import sqlight 14 16 15 - /// Start the Jetstream consumer in a background process 16 - pub fn start(db: sqlight.Connection) -> Result(Nil, String) { 17 + /// Messages that can be sent to the Jetstream consumer actor 18 + pub type Message { 19 + Stop(reply_with: process.Subject(Nil)) 20 + Restart(reply_with: process.Subject(Result(Nil, String))) 21 + } 22 + 23 + /// Internal state of the Jetstream consumer actor 24 + type State { 25 + State( 26 + db: sqlight.Connection, 27 + consumer_pid: option.Option(process.Pid), 28 + ) 29 + } 30 + 31 + /// Start the Jetstream consumer actor 32 + pub fn start(db: sqlight.Connection) -> Result(process.Subject(Message), String) { 33 + case start_consumer_process(db) { 34 + Ok(consumer_pid) -> { 35 + let initial_state = State(db: db, consumer_pid: option.Some(consumer_pid)) 36 + 37 + let result = 38 + actor.new(initial_state) 39 + |> actor.on_message(handle_message) 40 + |> actor.start 41 + 42 + case result { 43 + Ok(started) -> Ok(started.data) 44 + Error(err) -> Error("Failed to start consumer actor: " <> string.inspect(err)) 45 + } 46 + } 47 + Error(err) -> { 48 + // Consumer failed to start, but we still create the actor so it can be restarted later 49 + logging.log(logging.Warning, "[jetstream] " <> err) 50 + let initial_state = State(db: db, consumer_pid: option.None) 51 + 52 + let result = 53 + actor.new(initial_state) 54 + |> actor.on_message(handle_message) 55 + |> actor.start 56 + 57 + case result { 58 + Ok(started) -> Ok(started.data) 59 + Error(actor_err) -> Error("Failed to start consumer actor: " <> string.inspect(actor_err)) 60 + } 61 + } 62 + } 63 + } 64 + 65 + /// Stop the Jetstream consumer 66 + pub fn stop(consumer: process.Subject(Message)) -> Nil { 67 + actor.call(consumer, waiting: 1000, sending: Stop) 68 + } 69 + 70 + /// Restart the Jetstream consumer with fresh lexicon data 71 + pub fn restart( 72 + consumer: process.Subject(Message), 73 + ) -> Result(Nil, String) { 74 + actor.call(consumer, waiting: 5000, sending: Restart) 75 + } 76 + 77 + /// Handle messages sent to the consumer actor 78 + fn handle_message(state: State, message: Message) -> actor.Next(State, Message) { 79 + case message { 80 + Stop(client) -> { 81 + // Stop the consumer if it's running 82 + case state.consumer_pid { 83 + option.Some(pid) -> { 84 + logging.log(logging.Info, "[jetstream] Stopping consumer...") 85 + process.kill(pid) 86 + process.send(client, Nil) 87 + actor.continue(State(..state, consumer_pid: option.None)) 88 + } 89 + option.None -> { 90 + process.send(client, Nil) 91 + actor.continue(state) 92 + } 93 + } 94 + } 95 + 96 + Restart(client) -> { 97 + // Stop old consumer if running 98 + case state.consumer_pid { 99 + option.Some(pid) -> { 100 + logging.log(logging.Info, "[jetstream] Stopping old consumer...") 101 + process.kill(pid) 102 + } 103 + option.None -> Nil 104 + } 105 + 106 + // Start new consumer with fresh lexicon data 107 + case start_consumer_process(state.db) { 108 + Ok(new_pid) -> { 109 + process.send(client, Ok(Nil)) 110 + actor.continue(State(..state, consumer_pid: option.Some(new_pid))) 111 + } 112 + Error(err) -> { 113 + process.send(client, Error(err)) 114 + actor.continue(State(..state, consumer_pid: option.None)) 115 + } 116 + } 117 + } 118 + } 119 + } 120 + 121 + /// Start the actual consumer process (extracted from original start function) 122 + fn start_consumer_process(db: sqlight.Connection) -> Result(process.Pid, String) { 17 123 logging.log(logging.Info, "") 18 124 logging.log(logging.Info, "[jetstream] Starting Jetstream consumer...") 19 125 ··· 23 129 Error(_) -> "https://plc.directory" 24 130 } 25 131 132 + // Start config cache actor to get domain authority 133 + let assert Ok(config_subject) = config.start(db) 134 + 135 + // Get domain authority from config 136 + let domain_authority = case config.get_domain_authority(config_subject) { 137 + option.Some(authority) -> authority 138 + option.None -> "" 139 + } 140 + 26 141 // Get all record-type lexicons from the database 27 142 case database.get_record_type_lexicons(db) { 28 143 Ok(lexicons) -> { ··· 30 145 let #(local_lexicons, external_lexicons) = 31 146 lexicons 32 147 |> list.partition(fn(lex) { 33 - backfill.nsid_matches_domain_authority(lex.id) 148 + backfill.nsid_matches_domain_authority(lex.id, domain_authority) 34 149 }) 35 150 36 151 let local_collection_ids = list.map(local_lexicons, fn(lex) { lex.id }) ··· 46 161 logging.log(logging.Warning, "[jetstream] No collections found - skipping Jetstream consumer") 47 162 logging.log(logging.Info, "[jetstream] Import lexicons first") 48 163 logging.log(logging.Info, "") 49 - Ok(Nil) 164 + Error("No collections found") 50 165 } 51 166 _ -> { 52 167 logging.log( ··· 103 218 104 219 // Start the unified consumer 105 220 let ext_collections = external_collection_ids 106 - process.spawn_unlinked(fn() { 221 + let pid = process.spawn_unlinked(fn() { 107 222 goose.start_consumer(unified_config, fn(event_json) { 108 223 // Spawn each event into its own process so they don't block each other 109 224 let _pid = process.spawn_unlinked(fn() { ··· 122 237 logging.log(logging.Info, "[jetstream] Jetstream consumer started") 123 238 logging.log(logging.Info, "") 124 239 125 - Ok(Nil) 240 + Ok(pid) 126 241 } 127 242 } 128 243 }
+5 -2
server/src/lustre_handlers.gleam
··· 5 5 /// managing component WebSocket connections. 6 6 import backfill_state 7 7 import components/backfill_button 8 + import config 8 9 import gleam/bytes_tree 9 10 import gleam/erlang/application 10 11 import gleam/erlang/process ··· 44 45 req: request.Request(mist.Connection), 45 46 db: sqlight.Connection, 46 47 backfill_state_subject: process.Subject(backfill_state.Message), 48 + config_subject: process.Subject(config.Message), 47 49 ) -> response.Response(mist.ResponseData) { 48 50 mist.websocket( 49 51 request: req, 50 - on_init: init_backfill_button_socket(db, backfill_state_subject, _), 52 + on_init: init_backfill_button_socket(db, backfill_state_subject, config_subject, _), 51 53 handler: loop_backfill_button_socket, 52 54 on_close: close_backfill_button_socket, 53 55 ) ··· 69 71 fn init_backfill_button_socket( 70 72 db: sqlight.Connection, 71 73 backfill_state_subject: process.Subject(backfill_state.Message), 74 + config_subject: process.Subject(config.Message), 72 75 _connection: mist.WebsocketConnection, 73 76 ) -> BackfillButtonSocketInit { 74 77 // TODO: Get is_admin from session ··· 81 84 sending: backfill_state.IsBackfilling, 82 85 ) 83 86 84 - let component = backfill_button.component(db, backfill_state_subject) 87 + let component = backfill_button.component(db, backfill_state_subject, config_subject) 85 88 let assert Ok(runtime) = 86 89 lustre.start_server_component(component, #(is_admin, backfilling)) 87 90
+49 -83
server/src/pages/index.gleam
··· 1 + import components/alert 1 2 import components/button 2 3 import components/collection_table 3 4 import components/layout ··· 28 29 db: sqlight.Connection, 29 30 current_user: Option(#(String, String)), 30 31 is_admin: Bool, 32 + domain_authority: Option(String), 31 33 ) -> Element(msg) { 32 34 let data = fetch_data(db) 33 - render(data, current_user, is_admin) 35 + render(data, current_user, is_admin, domain_authority) 34 36 } 35 37 36 38 /// Fetch all data needed for the index page ··· 80 82 data: IndexData, 81 83 current_user: Option(#(String, String)), 82 84 is_admin: Bool, 85 + domain_authority: Option(String), 83 86 ) -> Element(msg) { 84 - layout.page( 87 + layout.page_with_header( 85 88 title: "ATProto Database Stats", 86 89 content: [ 87 - render_header(current_user, is_admin), 90 + render_alerts(domain_authority, data.lexicon_count), 91 + render_action_buttons(current_user), 88 92 render_stats_section(data.record_count, data.lexicon_count, data.actor_count), 89 93 render_activity_section(data.record_activity), 90 94 render_collections_section( ··· 93 97 is_admin, 94 98 ), 95 99 ], 100 + current_user: current_user, 101 + domain_authority: domain_authority, 96 102 ) 97 103 } 98 104 99 - /// Render the page header with title and action buttons 100 - fn render_header( 101 - current_user: Option(#(String, String)), 102 - _is_admin: Bool, 105 + /// Render configuration alerts if domain authority is missing or no lexicons loaded 106 + fn render_alerts( 107 + domain_authority: Option(String), 108 + lexicon_count: Int, 103 109 ) -> Element(msg) { 104 - let action_buttons = case current_user { 105 - option.Some(_) -> { 106 - let common_buttons = [ 107 - button.link(href: "/graphiql", text: "Open GraphiQL"), 108 - button.link(href: "/upload", text: "Upload Blob"), 109 - ] 110 + let domain_alert = case domain_authority { 111 + option.None -> 112 + alert.alert_with_link( 113 + alert.Warning, 114 + "No domain authority configured.", 115 + "Settings", 116 + "/settings", 117 + ) 118 + option.Some(value) -> 119 + case value { 120 + "" -> 121 + alert.alert_with_link( 122 + alert.Warning, 123 + "No domain authority configured.", 124 + "Settings", 125 + "/settings", 126 + ) 127 + _ -> element.none() 128 + } 129 + } 110 130 111 - [ 112 - html.div([attribute.class("flex gap-3")], common_buttons), 113 - ] 114 - } 115 - option.None -> [] 131 + let lexicon_alert = case lexicon_count { 132 + 0 -> 133 + alert.alert_with_link( 134 + alert.Info, 135 + "No lexicons loaded.", 136 + "Settings", 137 + "/settings", 138 + ) 139 + _ -> element.none() 116 140 } 117 141 118 - html.div([attribute.class("mb-8")], [ 119 - // Title and user info row 120 - html.div([attribute.class("flex justify-between items-center mb-4")], [ 121 - html.h1([attribute.class("text-4xl font-bold text-zinc-200")], [ 122 - element.text("quickslice"), 123 - ]), 124 - render_user_section(current_user), 125 - ]), 126 - ..action_buttons 127 - ]) 142 + html.div([], [domain_alert, lexicon_alert]) 128 143 } 129 144 130 - /// Render the user section showing login or user info 131 - fn render_user_section(current_user: Option(#(String, String))) -> Element(msg) { 145 + /// Render action buttons for authenticated users 146 + fn render_action_buttons(current_user: Option(#(String, String))) -> Element(msg) { 132 147 case current_user { 133 - option.Some(#(_did, handle)) -> { 134 - // User is logged in 135 - html.div([attribute.class("flex items-center gap-3")], [ 136 - html.span([attribute.class("text-zinc-300")], [ 137 - element.text("Logged in as "), 138 - html.span([attribute.class("font-semibold text-zinc-200")], [ 139 - element.text("@" <> handle), 140 - ]), 141 - ]), 142 - html.form( 143 - [attribute.method("post"), attribute.action("/logout"), attribute.class("inline")], 144 - [ 145 - html.button( 146 - [ 147 - attribute.type_("submit"), 148 - attribute.class( 149 - "px-4 py-2 text-sm text-zinc-400 border border-zinc-800 hover:border-zinc-700 hover:text-zinc-300 rounded transition-colors cursor-pointer", 150 - ), 151 - ], 152 - [element.text("Logout")], 153 - ), 154 - ], 155 - ), 148 + option.Some(_) -> { 149 + html.div([attribute.class("mb-8 flex gap-3")], [ 150 + button.link(href: "/graphiql", text: "Open GraphiQL"), 156 151 ]) 157 152 } 158 - option.None -> { 159 - // User is not logged in - show login form 160 - html.form( 161 - [ 162 - attribute.method("post"), 163 - attribute.action("/oauth/authorize"), 164 - attribute.class("flex items-center gap-2"), 165 - ], 166 - [ 167 - html.input([ 168 - attribute.type_("text"), 169 - attribute.name("loginHint"), 170 - attribute.placeholder("your-handle.bsky.social"), 171 - attribute.class( 172 - "px-3 py-2 bg-zinc-900 border border-zinc-800 rounded text-sm text-zinc-300 focus:outline-none focus:border-zinc-700", 173 - ), 174 - attribute.attribute("required", ""), 175 - ]), 176 - html.button( 177 - [ 178 - attribute.type_("submit"), 179 - attribute.class( 180 - "px-4 py-2 text-sm text-zinc-300 bg-zinc-800 hover:bg-zinc-700 rounded transition-colors cursor-pointer", 181 - ), 182 - ], 183 - [element.text("Login")], 184 - ), 185 - ], 186 - ) 187 - } 153 + option.None -> element.none() 188 154 } 189 155 } 190 156
+157
server/src/pages/settings.gleam
··· 1 + import components/alert 2 + import components/input 3 + import components/layout 4 + import database 5 + import gleam/option.{type Option} 6 + import lustre/attribute 7 + import lustre/element.{type Element} 8 + import lustre/element/html 9 + import sqlight 10 + 11 + /// Main view function that renders the settings page 12 + pub fn view( 13 + db: sqlight.Connection, 14 + current_user: Option(#(String, String)), 15 + _is_admin: Bool, 16 + flash_kind: Option(String), 17 + flash_message: Option(String), 18 + ) -> Element(msg) { 19 + let data = fetch_settings(db) 20 + render(data, current_user, flash_kind, flash_message) 21 + } 22 + 23 + /// Settings data 24 + pub type SettingsData { 25 + SettingsData(domain_authority: String) 26 + } 27 + 28 + /// Fetch current settings 29 + fn fetch_settings(db: sqlight.Connection) -> SettingsData { 30 + let domain_authority = case database.get_config(db, "domain_authority") { 31 + Ok(authority) -> authority 32 + Error(_) -> "" 33 + } 34 + 35 + SettingsData(domain_authority: domain_authority) 36 + } 37 + 38 + /// Render the complete settings page 39 + fn render( 40 + data: SettingsData, 41 + current_user: Option(#(String, String)), 42 + flash_kind: Option(String), 43 + flash_message: Option(String), 44 + ) -> Element(msg) { 45 + layout.page_with_header( 46 + title: "Settings - quickslice", 47 + content: [ 48 + html.h1([attribute.class("text-2xl font-semibold text-zinc-300 mb-8")], [ 49 + element.text("Settings"), 50 + ]), 51 + alert.maybe_alert(flash_kind, flash_message), 52 + render_settings_form(data), 53 + ], 54 + current_user: current_user, 55 + domain_authority: option.None, 56 + ) 57 + } 58 + 59 + /// Render the settings form 60 + fn render_settings_form(data: SettingsData) -> Element(msg) { 61 + html.div([attribute.class("max-w-2xl space-y-6")], [ 62 + // Domain Authority Section 63 + html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 64 + html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 65 + element.text("Domain Authority"), 66 + ]), 67 + html.form( 68 + [ 69 + attribute.method("post"), 70 + attribute.action("/settings"), 71 + ], 72 + [ 73 + input.form_text_input( 74 + label: "Domain Authority", 75 + name: "domain_authority", 76 + value: data.domain_authority, 77 + placeholder: "e.g. com.example", 78 + required: True, 79 + ), 80 + html.p([attribute.class("text-sm text-zinc-500 mb-4")], [ 81 + element.text( 82 + "The domain authority is used to determine which collections are considered \"primary\" vs \"external\" when backfilling records. For example, if the authority is \"xyz.statusphere\", then \"xyz.statusphere.status\" is treated as primary and \"app.bsky.actor.profile\" is external.", 83 + ), 84 + ]), 85 + html.div([attribute.class("flex gap-3")], [ 86 + html.button( 87 + [ 88 + attribute.type_("submit"), 89 + attribute.class( 90 + "font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-800 hover:bg-zinc-700 rounded transition-colors cursor-pointer", 91 + ), 92 + ], 93 + [element.text("Save")], 94 + ), 95 + ]), 96 + ], 97 + ), 98 + ]), 99 + // Lexicons Upload Section 100 + html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 101 + html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 102 + element.text("Lexicons"), 103 + ]), 104 + html.form( 105 + [ 106 + attribute.method("post"), 107 + attribute.action("/settings"), 108 + attribute.attribute("enctype", "multipart/form-data"), 109 + ], 110 + [ 111 + input.form_file_input( 112 + label: "Upload Lexicons (ZIP)", 113 + name: "lexicons_zip", 114 + accept: ".zip", 115 + required: False, 116 + ), 117 + html.p([attribute.class("text-sm text-zinc-500 mb-4")], [ 118 + element.text( 119 + "Upload a ZIP file containing lexicon JSON files. The ZIP file will be extracted and all .json files will be imported into the database. This replaces the need to manually place lexicons in the priv/lexicons directory.", 120 + ), 121 + ]), 122 + html.div([attribute.class("flex gap-3")], [ 123 + html.button( 124 + [ 125 + attribute.type_("submit"), 126 + attribute.class( 127 + "font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-800 hover:bg-zinc-700 rounded transition-colors cursor-pointer", 128 + ), 129 + ], 130 + [element.text("Upload")], 131 + ), 132 + ]), 133 + ], 134 + ), 135 + ]), 136 + // Sign Out Section 137 + html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 138 + html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 139 + element.text("Account"), 140 + ]), 141 + html.form( 142 + [attribute.method("post"), attribute.action("/logout")], 143 + [ 144 + html.button( 145 + [ 146 + attribute.type_("submit"), 147 + attribute.class( 148 + "font-mono px-4 py-2 text-sm text-zinc-400 border border-zinc-700 hover:border-zinc-600 hover:text-zinc-300 rounded transition-colors cursor-pointer", 149 + ), 150 + ], 151 + [element.text("Sign Out")], 152 + ), 153 + ], 154 + ), 155 + ]), 156 + ]) 157 + }
+241 -40
server/src/server.gleam
··· 1 1 import argv 2 2 import backfill 3 3 import backfill_state 4 + import config 4 5 import database 5 6 import dotenv_gleam 6 7 import envoy ··· 23 24 import oauth/handlers 24 25 import oauth/session 25 26 import pages/index 27 + import pages/settings 26 28 import pubsub 29 + import simplifile 27 30 import sqlight 28 31 import upload_handler 29 32 import wisp 33 + import wisp_flash 34 + import zip_helper 30 35 import wisp/wisp_mist 31 36 import xrpc_handlers 32 37 import xrpc_router ··· 39 44 oauth_config: handlers.OAuthConfig, 40 45 admin_dids: List(String), 41 46 backfill_state: process.Subject(backfill_state.Message), 47 + config: process.Subject(config.Message), 48 + jetstream_consumer: option.Option(process.Subject(jetstream_consumer.Message)), 42 49 ) 43 50 } 44 51 ··· 111 118 // Initialize the database 112 119 let assert Ok(db) = database.initialize(database_url) 113 120 121 + // Start config cache actor 122 + let assert Ok(config_subject) = config.start(db) 123 + 124 + // Get domain authority from config 125 + let domain_authority = case config.get_domain_authority(config_subject) { 126 + option.Some(authority) -> authority 127 + option.None -> { 128 + logging.log( 129 + logging.Warning, 130 + "No domain_authority configured. All collections will be treated as external.", 131 + ) 132 + "" 133 + } 134 + } 135 + 114 136 // Get all record-type lexicons 115 137 logging.log(logging.Info, "Fetching record-type lexicons from database...") 116 138 case database.get_record_type_lexicons(db) { ··· 131 153 let #(local_lexicons, external_lexicons) = 132 154 lexicons 133 155 |> list.partition(fn(lex) { 134 - backfill.nsid_matches_domain_authority(lex.id) 156 + backfill.nsid_matches_domain_authority(lex.id, domain_authority) 135 157 }) 136 158 137 159 let collections = list.map(local_lexicons, fn(lex) { lex.id }) ··· 199 221 // Initialize the database 200 222 let assert Ok(db) = database.initialize(database_url) 201 223 202 - // Auto-import lexicons from priv/lexicons if directory exists 203 - logging.log(logging.Info, "") 204 - logging.log( 205 - logging.Info, 206 - "[server] Checking for lexicons in priv/lexicons...", 207 - ) 208 - case importer.import_lexicons_from_directory("priv/lexicons", db) { 209 - Ok(stats) -> { 210 - case stats.imported { 211 - 0 -> logging.log(logging.Info, "[server] No lexicons found to import") 212 - _ -> { 213 - logging.log( 214 - logging.Info, 215 - "[server] Imported " 216 - <> int.to_string(stats.imported) 217 - <> " lexicon(s)", 218 - ) 219 - } 220 - } 221 - } 222 - Error(_) -> { 223 - logging.log( 224 - logging.Info, 225 - "[server] No priv/lexicons directory found, skipping import", 226 - ) 227 - } 228 - } 224 + // Note: Lexicon import has been moved to the settings page (ZIP upload) 225 + // Use the /settings page to upload a ZIP file containing lexicon JSON files 229 226 230 227 // Initialize PubSub registry for subscriptions 231 228 pubsub.start() 232 229 logging.log(logging.Info, "[server] PubSub registry initialized") 233 230 234 231 // Start Jetstream consumer in background 235 - case jetstream_consumer.start(db) { 236 - Ok(_) -> Nil 232 + let jetstream_subject = case jetstream_consumer.start(db) { 233 + Ok(subject) -> option.Some(subject) 237 234 Error(err) -> { 238 235 logging.log( 239 236 logging.Error, ··· 243 240 logging.Warning, 244 241 "[server] Server will continue without real-time indexing", 245 242 ) 243 + option.None 246 244 } 247 245 } 248 246 ··· 251 249 logging.log(logging.Info, "") 252 250 253 251 // Start server immediately (this blocks) 254 - start_server(db) 252 + start_server(db, jetstream_subject) 255 253 } 256 254 257 - fn start_server(db: sqlight.Connection) { 255 + fn start_server( 256 + db: sqlight.Connection, 257 + jetstream_subject: option.Option(process.Subject(jetstream_consumer.Message)), 258 + ) { 258 259 wisp.configure_logger() 259 260 260 261 // Get secret_key_base from environment or generate one ··· 347 348 let assert Ok(backfill_state_subject) = backfill_state.start() 348 349 logging.log(logging.Info, "[server] Backfill state actor initialized") 349 350 351 + // Start config cache actor 352 + let assert Ok(config_subject) = config.start(db) 353 + logging.log(logging.Info, "[server] Config cache actor initialized") 354 + 350 355 let ctx = 351 356 Context( 352 357 db: db, ··· 355 360 oauth_config: oauth_config, 356 361 admin_dids: admin_dids, 357 362 backfill_state: backfill_state_subject, 363 + config: config_subject, 364 + jetstream_consumer: jetstream_subject, 358 365 ) 359 366 360 367 let handler = fn(req) { handle_request(req, ctx) } ··· 387 394 req, 388 395 ctx.db, 389 396 ctx.backfill_state, 397 + ctx.config, 390 398 ) 391 399 } 392 400 _ -> wisp_handler(req) ··· 406 414 logging.Info, 407 415 "[server] Handling WebSocket upgrade for /graphql", 408 416 ) 417 + let domain_authority = case config.get_domain_authority(ctx.config) { 418 + option.Some(authority) -> authority 419 + option.None -> "" 420 + } 409 421 graphql_ws_handler.handle_websocket( 410 422 req, 411 423 ctx.db, 412 424 ctx.auth_base_url, 413 425 ctx.plc_url, 426 + domain_authority, 414 427 ) 415 428 } 416 429 _ -> wisp_handler(req) ··· 446 459 case segments { 447 460 [] -> index_route(req, ctx) 448 461 ["health"] -> handle_health_check(ctx) 462 + ["settings"] -> settings_route(req, ctx) 449 463 ["oauth", "authorize"] -> 450 464 handlers.handle_oauth_authorize(req, ctx.db, ctx.oauth_config) 451 465 ["oauth", "callback"] -> 452 466 handlers.handle_oauth_callback(req, ctx.db, ctx.oauth_config) 453 467 ["logout"] -> handlers.handle_logout(req, ctx.db) 454 - ["backfill"] -> handle_backfill_request(req, ctx.db) 468 + ["backfill"] -> handle_backfill_request(req, ctx) 455 469 ["graphql"] -> 456 470 graphql_handler.handle_graphql_request( 457 471 req, ··· 524 538 525 539 fn handle_backfill_request( 526 540 req: wisp.Request, 527 - db: sqlight.Connection, 541 + ctx: Context, 528 542 ) -> wisp.Response { 529 543 case req.method { 530 544 gleam_http.Post -> { 545 + // Get domain authority from config 546 + let domain_authority = case config.get_domain_authority(ctx.config) { 547 + option.Some(authority) -> authority 548 + option.None -> "" 549 + } 550 + 531 551 // Get all record-type lexicons 532 - case database.get_record_type_lexicons(db) { 552 + case database.get_record_type_lexicons(ctx.db) { 533 553 Ok(lexicons) -> { 534 554 case lexicons { 535 555 [] -> { ··· 544 564 let #(collections, external_collections) = 545 565 lexicons 546 566 |> list.partition(fn(lex) { 547 - backfill.nsid_matches_domain_authority(lex.id) 567 + backfill.nsid_matches_domain_authority(lex.id, domain_authority) 548 568 }) 549 569 550 570 let collection_ids = list.map(collections, fn(lex) { lex.id }) ··· 552 572 list.map(external_collections, fn(lex) { lex.id }) 553 573 554 574 // Run backfill in background process 555 - let config = backfill.default_config() 575 + let backfill_config = backfill.default_config() 556 576 process.spawn_unlinked(fn() { 557 577 backfill.backfill_collections( 558 578 [], 559 579 collection_ids, 560 580 external_collection_ids, 561 - config, 562 - db, 581 + backfill_config, 582 + ctx.db, 563 583 ) 564 584 }) 565 585 ··· 630 650 Error(_) -> #(option.None, False) 631 651 } 632 652 633 - index.view(ctx.db, current_user, user_is_admin) 653 + // Get domain authority from config cache 654 + let domain_authority = config.get_domain_authority(ctx.config) 655 + 656 + index.view(ctx.db, current_user, user_is_admin, domain_authority) 634 657 |> element.to_document_string 635 658 |> 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 + } 636 837 } 637 838 638 839 fn middleware(
+35
server/src/zip_helper.gleam
··· 1 + /// FFI wrapper around Erlang's :zip module for extracting ZIP files 2 + import gleam/dynamic.{type Dynamic} 3 + import gleam/dynamic/decode 4 + import gleam/string 5 + 6 + /// Extract a ZIP file to a destination directory 7 + /// 8 + /// Returns Ok(Nil) on success, Error(String) on failure 9 + pub fn extract_zip( 10 + zip_path: String, 11 + destination: String, 12 + ) -> Result(Nil, String) { 13 + case do_extract_zip(zip_path, destination) { 14 + Ok(_) -> Ok(Nil) 15 + Error(err) -> Error(dynamic_to_string(err)) 16 + } 17 + } 18 + 19 + /// Erlang FFI to unzip a file 20 + /// Uses :zip.unzip/2 with the :cwd option to specify extraction directory 21 + @external(erlang, "zip_helper_ffi", "unzip_file") 22 + fn do_extract_zip(zip_path: String, destination: String) -> Result(Dynamic, Dynamic) 23 + 24 + /// Convert a dynamic error to a string for error reporting 25 + fn dynamic_to_string(value: Dynamic) -> String { 26 + case decode.run(value, decode.string) { 27 + Ok(str) -> str 28 + Error(_) -> { 29 + // Try to convert to string representation 30 + case string.inspect(value) { 31 + str -> str 32 + } 33 + } 34 + } 35 + }
+22
server/src/zip_helper_ffi.erl
··· 1 + -module(zip_helper_ffi). 2 + -export([unzip_file/2]). 3 + 4 + %% Extract a ZIP file to a destination directory 5 + %% Uses Erlang's built-in :zip.unzip/2 function 6 + unzip_file(ZipPath, Destination) -> 7 + %% Convert Gleam strings (binaries) to Erlang strings (lists) 8 + ZipPathList = binary_to_list(ZipPath), 9 + DestinationList = binary_to_list(Destination), 10 + Options = [{cwd, DestinationList}], 11 + case zip:unzip(ZipPathList, Options) of 12 + {ok, _FileList} -> {ok, nil}; 13 + {error, Reason} -> {error, format_error(Reason)} 14 + end. 15 + 16 + %% Format error reason as a binary string 17 + format_error(Reason) when is_atom(Reason) -> 18 + list_to_binary(atom_to_list(Reason)); 19 + format_error(Reason) when is_list(Reason) -> 20 + list_to_binary(Reason); 21 + format_error(Reason) -> 22 + list_to_binary(io_lib:format("~p", [Reason])).