mail based rss feed aggregator
2
fork

Configure Feed

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

rename `login` to `main_ui` since the rest of the UI is staying in there anyway

add basic csrf token stuff that still needs proper impl with lustre
(probably next update)

ollie c84a92bf 865b75f5

+711 -504
-458
src/eater/ui/login.gleam
··· 1 - // eater 2 - // Copyright (C) 2026 Olivia 'nuv' Streun and contributors. [cite: 4] 3 - // 4 - // This software is licensed under the European Union Public Licence (EUPL) v1.2. 5 - // You may not use this work except in compliance with the Licence. 6 - // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 7 - // 8 - // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 9 - // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 10 - // See LICENSE file in the repository root for full details. 11 - // 12 - // 13 - // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 14 - // See the Licence for the specific language governing permissions and limitations. [cite: 6] 15 - 16 - import eater/database 17 - import eater/smtp 18 - import eater/ui/toaster 19 - import eater/user 20 - import formal/form.{type Form} 21 - import gcourier/smtp as gsmtp 22 - import glaze/oat/button 23 - import glaze/oat/card 24 - import glaze/oat/form as gform 25 - import glaze/oat/toast 26 - import gleam/bit_array 27 - import gleam/crypto 28 - import gleam/erlang/process 29 - import gleam/int 30 - import gleam/list 31 - import gleam/option 32 - import gleam/result 33 - import lustre 34 - import lustre/attribute 35 - import lustre/effect.{type Effect} 36 - import lustre/element.{type Element} 37 - import lustre/element/html 38 - import lustre/element/keyed 39 - import lustre/event 40 - import lustre/portal 41 - import sqlight 42 - 43 - // main ------------------------------------------------------------------------- 44 - 45 - pub fn component() -> lustre.App(ModelData, Model, Message) { 46 - lustre.application(init:, update:, view:) 47 - } 48 - 49 - fn init(data: ModelData) -> #(Model, Effect(Message)) { 50 - #(LoginForm(data, login_form()), effect.none()) 51 - } 52 - 53 - // model ------------------------------------------------------------------------ 54 - 55 - pub opaque type ModelData { 56 - ModelData( 57 - database: sqlight.Connection, 58 - smtp_environment: smtp.SmtpEnvironment, 59 - allow_signups: Bool, 60 - toasts: List(toaster.Toast), 61 - ) 62 - } 63 - 64 - fn update_data(data: ModelData, model: Model) -> Model { 65 - case model { 66 - LoginForm(..) -> LoginForm(..model, data:) 67 - ConfirmOneTimePassword(..) -> ConfirmOneTimePassword(..model, data:) 68 - SuccessfulLogin(..) -> SuccessfulLogin(data:) 69 - } 70 - } 71 - 72 - /// create a new `ModelData` 73 - /// 74 - pub fn new_model_data( 75 - database: sqlight.Connection, 76 - smtp_environment: smtp.SmtpEnvironment, 77 - allow_signups: Bool, 78 - ) -> ModelData { 79 - ModelData(database:, smtp_environment:, allow_signups:, toasts: []) 80 - } 81 - 82 - pub opaque type Model { 83 - LoginForm(data: ModelData, form: Form(Login)) 84 - ConfirmOneTimePassword( 85 - data: ModelData, 86 - form: Form(String), 87 - password_should_be: String, 88 - login: Login, 89 - ) 90 - SuccessfulLogin(data: ModelData) 91 - } 92 - 93 - pub opaque type Message { 94 - UserSubmittedLoginForm(result: Result(Login, Form(Login))) 95 - UserSubmittedOneTimePasswordForm(result: Result(String, Form(String))) 96 - UserLoggedIn(result: Result(user.User, sqlight.Error)) 97 - 98 - SentOneTimePasswordEmail(Result(Nil, gsmtp.Error)) 99 - TimerDismissedToast 100 - SpawnToast 101 - } 102 - 103 - // update ----------------------------------------------------------------------- 104 - 105 - fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { 106 - case message, model { 107 - // TODO: spinner while waiting for email to send 108 - UserSubmittedLoginForm(Ok(login)), LoginForm(form:, ..) -> 109 - user_submitted_ok_login_form(login, model, form) 110 - UserSubmittedLoginForm(Error(form)), _ -> #( 111 - LoginForm(model.data, form), 112 - effect.none(), 113 - ) 114 - UserSubmittedOneTimePasswordForm(Ok(user_password)), 115 - ConfirmOneTimePassword( 116 - password_should_be: correct_password, 117 - data:, 118 - login:, 119 - .., 120 - ) 121 - -> { 122 - case user_password == correct_password { 123 - True -> #(SuccessfulLogin(model.data), log_in(login, data)) 124 - False -> 125 - do_toast( 126 - toaster.Toast( 127 - title: option.Some("Incorrect"), 128 - message: "The password you entered was incorrect", 129 - options: toast.default_options(toast.Warning), 130 - ), 131 - model, 132 - ) 133 - } 134 - } 135 - SentOneTimePasswordEmail(Ok(_)), _ -> { 136 - let toast = 137 - toaster.Toast( 138 - title: option.None, 139 - message: "One time password was sent", 140 - options: toast.default_options(toast.Info), 141 - ) 142 - do_toast(toast, model) 143 - } 144 - SentOneTimePasswordEmail(Error(_)), ConfirmOneTimePassword(login:, ..) -> { 145 - let toast = 146 - toaster.Toast( 147 - title: option.Some("Error"), 148 - message: "Failed to send one time password. Try again later or contact " 149 - <> smtp.sender_email(model.data.smtp_environment), 150 - options: toast.default_options(toast.Warning), 151 - ) 152 - 153 - let #(model, effect) = do_toast(toast, model) 154 - 155 - #(LoginForm(model.data, login_form_with_data(login)), effect) 156 - } 157 - 158 - TimerDismissedToast, _ -> { 159 - // the amount of toasts should stay small enough that this isnt an issue 160 - let toasts = 161 - model.data.toasts 162 - |> list.reverse 163 - |> list.drop(1) 164 - |> list.reverse 165 - let model = ModelData(..model.data, toasts:) |> update_data(model) 166 - 167 - #(model, effect.none()) 168 - } 169 - SpawnToast, _ -> { 170 - let toast = 171 - toaster.Toast( 172 - title: option.None, 173 - message: "Toast for testing purposes", 174 - options: toast.default_options(toast.Info) 175 - |> toast.with_duration(30_000), 176 - ) 177 - 178 - do_toast(toast, model) 179 - } 180 - 181 - _, _ -> panic as "what" 182 - } 183 - } 184 - 185 - fn log_in(login: Login, data: ModelData) { 186 - use dispatch <- effect.from() 187 - 188 - { 189 - use user <- result.try(database.user_by_email(data.database, login.email)) 190 - 191 - use user <- result.try(case user { 192 - Ok(user) -> Ok(user) 193 - Error(_) -> { 194 - let user = user.new(login.email) 195 - use _ <- result.try(database.add_user(user, data.database)) 196 - // TODO: spawn new sender actor for new user 197 - Ok(user) 198 - } 199 - }) 200 - 201 - // TODO: 'log in' i.e. swap to main component (unsure how to do that atm) 202 - 203 - todo as "'log in' here" 204 - } 205 - |> UserLoggedIn 206 - |> dispatch 207 - } 208 - 209 - fn user_submitted_ok_login_form( 210 - login: Login, 211 - model: Model, 212 - form: Form(Login), 213 - ) -> #(Model, Effect(Message)) { 214 - let one_time_password = fn() { 215 - crypto.strong_random_bytes(16) |> bit_array.base64_encode(True) 216 - } 217 - 218 - let send_password = fn(one_time_password) { 219 - send_one_time_password( 220 - one_time_password, 221 - login, 222 - model.data.smtp_environment, 223 - ) 224 - } 225 - 226 - { 227 - use user <- result.try(database.user_by_email( 228 - model.data.database, 229 - login.email, 230 - )) 231 - 232 - case user, model.data.allow_signups { 233 - // the user exists 234 - // or it doesnt, but signups are enabled 235 - Ok(_), _ | Error(_), True -> { 236 - let one_time_password = one_time_password() 237 - #( 238 - ConfirmOneTimePassword( 239 - form: one_time_password_form(), 240 - password_should_be: one_time_password, 241 - login:, 242 - data: model.data, 243 - ), 244 - send_password(one_time_password), 245 - ) 246 - } 247 - // the user doesnt exist and signups are disabled 248 - Error(_), False -> { 249 - let toast = 250 - toaster.Toast( 251 - title: option.Some("Invalid email address"), 252 - message: "This email doesn't exist and registrations are disabled", 253 - options: toast.default_options(toast.Warning), 254 - ) 255 - let #(model, effect) = do_toast(toast, model) 256 - 257 - #(LoginForm(model.data, form:), effect) 258 - } 259 - } 260 - |> Ok 261 - } 262 - |> result.unwrap({ 263 - let toast = 264 - toaster.Toast( 265 - title: option.Some("Something went wrong"), 266 - message: "I was unable to reach the database", 267 - options: toast.default_options(toast.Warning), 268 - ) 269 - do_toast(toast, model) 270 - }) 271 - } 272 - 273 - fn send_one_time_password( 274 - one_time_password: String, 275 - login: Login, 276 - smtp_environment: smtp.SmtpEnvironment, 277 - ) -> Effect(Message) { 278 - use dispatch <- effect.from() 279 - 280 - smtp.one_time_password(email: login.email, one_time_password:) 281 - |> smtp.send_message(smtp_environment) 282 - |> SentOneTimePasswordEmail 283 - |> dispatch 284 - } 285 - 286 - // view ------------------------------------------------------------------------- 287 - 288 - fn view(model: Model) -> Element(Message) { 289 - html.main([], [ 290 - case model { 291 - LoginForm(form:, ..) -> view_login_form(form) 292 - ConfirmOneTimePassword(form:, ..) -> view_confirm_one_time_password(form) 293 - SuccessfulLogin(data:) -> todo as "add successful login view" 294 - }, 295 - // button.button([event.on_click(SpawnToast)], [element.text("spawn toast")]), 296 - portal.to("body", [], [ 297 - keyed.fragment({ 298 - use #(key, toasts) <- list.map(toaster.view_toasts(model.data.toasts)) 299 - 300 - #(key, toasts) 301 - }), 302 - ]), 303 - ]) 304 - } 305 - 306 - fn view_login_form(form: Form(Login)) -> Element(Message) { 307 - let submitted = fn(fields) { 308 - form 309 - |> form.add_values(fields) 310 - |> form.run 311 - |> UserSubmittedLoginForm 312 - } 313 - 314 - html.div([attribute.class("container vstack")], [ 315 - html.div([attribute.class("row")], [ 316 - card.card([attribute.class("col-4 offset-4")], [ 317 - card.header([], [html.h3([], [element.text("Login")])]), 318 - gform.form( 319 - [ 320 - attribute.method("POST"), 321 - event.on_submit(submitted), 322 - ], 323 - [ 324 - field_input(form, name: "email", kind: "text", label: "Email"), 325 - html.div([], [html.input([attribute.type_("submit")])]), 326 - ], 327 - ), 328 - ]), 329 - ]), 330 - ]) 331 - } 332 - 333 - fn view_confirm_one_time_password(form: Form(String)) -> Element(Message) { 334 - let submitted = fn(fields) { 335 - form 336 - |> form.add_values(fields) 337 - |> form.run 338 - |> UserSubmittedOneTimePasswordForm 339 - } 340 - 341 - // TODO: clear password field on first load (apparently it thinks the email and password field input are the same) 342 - html.div([attribute.class("container vstack")], [ 343 - html.div([attribute.class("row")], [ 344 - card.card([attribute.class("col-4 offset-4")], [ 345 - card.header([], [html.h3([], [element.text("Password")])]), 346 - gform.form([attribute.method("POST"), event.on_submit(submitted)], [ 347 - field_input( 348 - form, 349 - name: "password", 350 - kind: "text", 351 - label: "One time password", 352 - ), 353 - html.div([], [html.input([attribute.type_("submit")])]), 354 - ]), 355 - ]), 356 - ]), 357 - ]) 358 - } 359 - 360 - // formal ----------------------------------------------------------------------- 361 - 362 - pub opaque type Login { 363 - Login(email: String) 364 - } 365 - 366 - fn login_form_with_data(login: Login) -> Form(Login) { 367 - login_form() 368 - |> form.add_string("email", login.email) 369 - } 370 - 371 - fn login_form() -> Form(Login) { 372 - form.new({ 373 - use email <- form.field("email", { form.parse_email }) 374 - 375 - form.success(Login(email: email)) 376 - }) 377 - } 378 - 379 - fn one_time_password_form() -> Form(String) { 380 - form.new({ 381 - use password <- form.field("password", form.parse_string) 382 - form.success(password) 383 - }) 384 - } 385 - 386 - /// Render a single HTML form field. 387 - /// 388 - /// If the field already has a value then it is used as the HTML input value. 389 - /// If the field has an error it is displayed. 390 - /// 391 - fn field_input( 392 - form: Form(t), 393 - name name: String, 394 - kind kind: String, 395 - label label_text: String, 396 - ) -> Element(a) { 397 - let errors = form.field_error_messages(form, name) 398 - 399 - gform.label([attribute.for(name)], [ 400 - // The label text, for the user to read 401 - element.text(label_text), 402 - // The input, for the user to type into 403 - gform.input([ 404 - attribute.type_(kind), 405 - attribute.name(name), 406 - attribute.default_value(form.field_value(form, name)), 407 - ..case errors { 408 - [] -> [attribute.none()] 409 - _ -> [gform.invalid(), gform.described_by(name <> "-hint")] 410 - } 411 - ]), 412 - // Any errors presented below 413 - ..list.map(errors, fn(msg) { 414 - html.small([attribute.id(name <> "-hint"), gform.hint(), gform.error()], [ 415 - element.text(msg), 416 - ]) 417 - }) 418 - ]) 419 - } 420 - 421 - // helpers ---------------------------------------------------------------------- 422 - 423 - /// dispatch a given message after a given timeout 424 - /// 425 - pub fn schedule_message( 426 - dispatch message: a, 427 - after timeout: Int, 428 - ) -> effect.Effect(a) { 429 - use dispatch <- effect.from 430 - 431 - use <- run_after(timeout) 432 - 433 - dispatch(message) 434 - } 435 - 436 - /// run the callback after a given timeout 437 - /// 438 - fn run_after(timeout: Int, run: fn() -> Nil) -> Nil { 439 - process.spawn(fn() { 440 - process.sleep(timeout) 441 - run() 442 - }) 443 - Nil 444 - } 445 - 446 - /// update the model and schedule dismissal of a new toast 447 - /// 448 - fn do_toast(toast: toaster.Toast, model: Model) { 449 - let data = ModelData(..model.data, toasts: [toast, ..model.data.toasts]) 450 - 451 - let effect = 452 - schedule_message( 453 - dispatch: TimerDismissedToast, 454 - after: toast.options.duration_ms, 455 - ) 456 - 457 - #(update_data(data, model), effect) 458 - }
+615
src/eater/ui/main_ui.gleam
··· 1 + // eater 2 + // Copyright (C) 2026 Olivia 'nuv' Streun and contributors. [cite: 4] 3 + // 4 + // This software is licensed under the European Union Public Licence (EUPL) v1.2. 5 + // You may not use this work except in compliance with the Licence. 6 + // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 7 + // 8 + // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 9 + // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 10 + // See LICENSE file in the repository root for full details. 11 + // 12 + // 13 + // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 14 + // See the Licence for the specific language governing permissions and limitations. [cite: 6] 15 + 16 + import eater/database 17 + import eater/smtp 18 + import eater/ui/toaster 19 + import eater/user 20 + import formal/form.{type Form} 21 + import gcourier/smtp as gsmtp 22 + import glaze/oat/card 23 + import glaze/oat/form as gform 24 + import glaze/oat/toast 25 + import gleam/bit_array 26 + import gleam/crypto 27 + import gleam/erlang/process 28 + import gleam/list 29 + import gleam/option.{Some} 30 + import gleam/result 31 + import gleam/string 32 + import lustre 33 + import lustre/attribute 34 + import lustre/effect.{type Effect} 35 + import lustre/element.{type Element} 36 + import lustre/element/html 37 + import lustre/element/keyed 38 + import lustre/event 39 + import lustre/portal 40 + import sqlight 41 + import woof 42 + import youid/uuid 43 + 44 + // main ------------------------------------------------------------------------- 45 + 46 + pub fn component() -> lustre.App(ModelData, Model, Message) { 47 + lustre.application(init:, update:, view:) 48 + } 49 + 50 + fn init(data: ModelData) -> #(Model, Effect(Message)) { 51 + #(EmailForm(data, login_form()), effect.none()) 52 + } 53 + 54 + // model ------------------------------------------------------------------------ 55 + 56 + pub opaque type ModelData { 57 + ModelData( 58 + csrf_token: String, 59 + database: sqlight.Connection, 60 + smtp_environment: smtp.SmtpEnvironment, 61 + allow_signups: Bool, 62 + toasts: List(toaster.Toast), 63 + logger: woof.Logger, 64 + ) 65 + } 66 + 67 + fn update_data(data: ModelData, model: Model) -> Model { 68 + case model { 69 + EmailForm(..) -> EmailForm(..model, data:) 70 + EmailSending(..) -> EmailSending(..model, data:) 71 + PasswordForm(..) -> PasswordForm(..model, data:) 72 + LoggingIn(..) -> LoggingIn(..model, data:) 73 + LoggedIn(..) -> LoggedIn(..model, data:) 74 + } 75 + } 76 + 77 + /// create a new `ModelData` 78 + /// 79 + pub fn new_model_data( 80 + csrf_token csrf_token: String, 81 + database database: sqlight.Connection, 82 + smtp_environment smtp_environment: smtp.SmtpEnvironment, 83 + allow_signups allow_signups: Bool, 84 + ) -> ModelData { 85 + ModelData( 86 + csrf_token:, 87 + database:, 88 + smtp_environment:, 89 + allow_signups:, 90 + toasts: [], 91 + logger: woof.new("UI-" <> uuid.v7_string() |> string.slice(28, 6)), 92 + ) 93 + } 94 + 95 + /// The `normal` login flow looks like this 96 + /// 97 + /// `EmailForm` -(user enters valid email)-> `EmailSending` -(email gets sent)------------v 98 + /// `LoggedIn` <-(get user / make new one)- `LoggingIn` <-(user enters valid password)- `PasswordForm` 99 + /// 100 + /// 101 + pub opaque type Model { 102 + EmailForm(data: ModelData, form: Form(Login)) 103 + EmailSending(data: ModelData, login: Login, password: String) 104 + PasswordForm( 105 + data: ModelData, 106 + form: Form(String), 107 + password_should_be: String, 108 + login: Login, 109 + ) 110 + LoggingIn(data: ModelData, password: String) 111 + LoggedIn(data: ModelData, user: user.User) 112 + } 113 + 114 + /// describe a model using structured data 115 + /// 116 + fn describe_model(model: Model) { 117 + case model { 118 + EmailForm(data: _, form:) -> [ 119 + woof.field("model", "EmailForm"), 120 + woof.field("form-content", form.field_value(form, "email")), 121 + ] 122 + EmailSending(data: _, login:, password: _) -> [ 123 + woof.field("model", "EmailSending"), 124 + woof.field("to", login.email), 125 + ] 126 + PasswordForm(data: _, form:, password_should_be:, login:) -> [ 127 + woof.field("model", "PasswordForm"), 128 + woof.field("form-content", form.field_value(form, "password")), 129 + woof.field("password-should-be", password_should_be), 130 + woof.field("email", login.email), 131 + ] 132 + LoggingIn(data: _, password: _) -> [woof.field("model", "LoggingIn")] 133 + LoggedIn(data: _, user:) -> [ 134 + woof.field("model", "LoggedIn"), 135 + woof.field("user-email", user.email), 136 + ] 137 + } 138 + } 139 + 140 + pub opaque type Message { 141 + UserSubmittedEmail(result: Result(Login, Form(Login))) 142 + UserSubmittedPassword(result: Result(String, Form(String))) 143 + UserLoggedIn(result: Result(user.User, sqlight.Error)) 144 + 145 + ServerSentPassword(Result(Nil, gsmtp.Error)) 146 + TimerDismissedToast 147 + } 148 + 149 + /// describe a message using structured data 150 + /// 151 + fn describe_message(message: Message) { 152 + case message { 153 + UserSubmittedEmail(Ok(login)) -> [ 154 + woof.field("message", "UserSubmittedEmail"), 155 + woof.field("status", "ok"), 156 + woof.field("email", login.email), 157 + ] 158 + UserSubmittedEmail(Error(_)) -> [ 159 + woof.field("message", "UserSubmittedEmail"), 160 + woof.field("status", "error"), 161 + ] 162 + UserSubmittedPassword(Ok(_)) -> [ 163 + woof.field("message", "UserSubmittedPassword"), 164 + woof.field("status", "ok"), 165 + ] 166 + UserSubmittedPassword(Error(_)) -> [ 167 + woof.field("message", "UserSubmittedPassword"), 168 + woof.field("status", "error"), 169 + ] 170 + UserLoggedIn(Ok(user)) -> [ 171 + woof.field("message", "UserLoggedIn"), 172 + woof.field("status", "ok"), 173 + woof.field("email", user.email), 174 + ] 175 + UserLoggedIn(Error(db_error)) -> [ 176 + woof.field("message", "UserLoggedIn"), 177 + woof.field("status", "error"), 178 + woof.field("details", string.inspect(db_error)), 179 + ] 180 + ServerSentPassword(Ok(_)) -> [ 181 + woof.field("message", "ServerSentPassword"), 182 + woof.field("status", "ok"), 183 + ] 184 + ServerSentPassword(Error(smtp_error)) -> [ 185 + woof.field("message", "ServerSentPassword"), 186 + woof.field("status", "error"), 187 + woof.field("details", string.inspect(smtp_error)), 188 + ] 189 + TimerDismissedToast -> [] 190 + } 191 + } 192 + 193 + // update ----------------------------------------------------------------------- 194 + 195 + fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { 196 + // log all messages while debugging 197 + case woof.is_enabled(woof.Debug) { 198 + True -> log_update(model, message) 199 + False -> Nil 200 + } 201 + 202 + case message, model { 203 + // login - enter email ------------------------------------------------------ 204 + UserSubmittedEmail(Ok(login)), EmailForm(form:, ..) -> 205 + user_submitted_ok_login_form(login, model, form) 206 + UserSubmittedEmail(Error(form)), _ -> #( 207 + EmailForm(model.data, form), 208 + effect.none(), 209 + ) 210 + 211 + // login - send email ------------------------------------------------------- 212 + ServerSentPassword(Ok(_)), EmailSending(data: _, login:, password:) -> { 213 + let toast = 214 + toaster.Toast( 215 + title: option.None, 216 + message: "One time password was sent", 217 + options: toast.default_options(toast.Info), 218 + ) 219 + 220 + let #(model, effect) = do_toast(toast, model) 221 + let model = 222 + PasswordForm( 223 + form: one_time_password_form(), 224 + password_should_be: password, 225 + login:, 226 + data: model.data, 227 + ) 228 + 229 + #(model, effect) 230 + } 231 + ServerSentPassword(Error(_)), EmailSending(login:, ..) -> { 232 + let toast = 233 + toaster.Toast( 234 + title: Some("Error"), 235 + message: "Failed to send one time password. Try again later or contact " 236 + <> smtp.sender_email(model.data.smtp_environment) 237 + <> " if the problem persists.", 238 + options: toast.default_options(toast.Warning), 239 + ) 240 + 241 + let #(model, effect) = do_toast(toast, model) 242 + 243 + #(EmailForm(model.data, login_form_with_data(login)), effect) 244 + } 245 + 246 + // login - password --------------------------------------------------------- 247 + UserSubmittedPassword(Error(form)), PasswordForm(..) as model -> #( 248 + PasswordForm(..model, form:), 249 + effect.none(), 250 + ) 251 + UserSubmittedPassword(Ok(user_password)), 252 + PasswordForm(password_should_be: correct_password, data:, login:, ..) 253 + -> { 254 + case user_password == correct_password { 255 + True -> #(LoggingIn(model.data, user_password), log_in(login, data)) 256 + False -> 257 + do_toast( 258 + toaster.Toast( 259 + title: Some("Incorrect"), 260 + message: "The password you entered was incorrect", 261 + options: toast.default_options(toast.Warning), 262 + ), 263 + model, 264 + ) 265 + } 266 + } 267 + 268 + // login - logged in -------------------------------------------------------- 269 + UserLoggedIn(Ok(user)), _ -> #(LoggedIn(model.data, user), effect.none()) 270 + UserLoggedIn(Error(_)), _ -> { 271 + let toast = 272 + toaster.Toast( 273 + title: Some("Login failed!"), 274 + message: "Try again later or contact " 275 + <> smtp.sender_email(model.data.smtp_environment) 276 + <> " if the problem persists.", 277 + options: toast.default_options(toast.Danger), 278 + ) 279 + do_toast(toast, EmailForm(data: model.data, form: login_form())) 280 + } 281 + 282 + // user ui ------------------------------------------------------------------ 283 + // toasts ------------------------------------------------------------------- 284 + TimerDismissedToast, _ -> { 285 + // the amount of toasts should stay small enough that this isnt an issue 286 + let toasts = 287 + model.data.toasts 288 + |> list.reverse 289 + |> list.drop(1) 290 + |> list.reverse 291 + let model = ModelData(..model.data, toasts:) |> update_data(model) 292 + 293 + #(model, effect.none()) 294 + } 295 + 296 + _, _ -> panic as "what" 297 + } 298 + } 299 + 300 + /// log a given model+message combo to `model.data.logger` 301 + /// 302 + fn log_update(model: Model, message: Message) -> Nil { 303 + let description = 304 + list.append(describe_model(model), describe_message(message)) 305 + 306 + model.data.logger 307 + |> woof.log(woof.Debug, "New message", description) 308 + } 309 + 310 + fn log_in(login: Login, data: ModelData) { 311 + use dispatch <- effect.from() 312 + 313 + { 314 + use user <- result.try(database.user_by_email(data.database, login.email)) 315 + 316 + case user { 317 + Ok(user) -> Ok(user) 318 + Error(_) -> { 319 + let user = user.new(login.email) 320 + use _ <- result.try(database.add_user(user, data.database)) 321 + // TODO: spawn new sender actor for new user 322 + Ok(user) 323 + } 324 + } 325 + } 326 + |> UserLoggedIn 327 + |> dispatch 328 + } 329 + 330 + fn user_submitted_ok_login_form( 331 + login: Login, 332 + model: Model, 333 + form: Form(Login), 334 + ) -> #(Model, Effect(Message)) { 335 + let one_time_password = fn() { 336 + crypto.strong_random_bytes(16) |> bit_array.base64_encode(True) 337 + } 338 + 339 + let send_password = fn(one_time_password) { 340 + send_one_time_password( 341 + one_time_password, 342 + login, 343 + model.data.smtp_environment, 344 + ) 345 + } 346 + 347 + { 348 + use user <- result.try(database.user_by_email( 349 + model.data.database, 350 + login.email, 351 + )) 352 + 353 + case user, model.data.allow_signups { 354 + // the user exists 355 + // or doesnt, but signups are enabled 356 + Ok(_), _ | Error(_), True -> { 357 + let password = one_time_password() 358 + #( 359 + EmailSending(data: model.data, login:, password:), 360 + send_password(password), 361 + ) 362 + } 363 + // the user doesnt exist and signups are disabled 364 + Error(_), False -> { 365 + let toast = 366 + toaster.Toast( 367 + title: Some("Invalid email address"), 368 + message: "This email doesn't exist and registrations are disabled", 369 + options: toast.default_options(toast.Warning), 370 + ) 371 + let #(model, effect) = do_toast(toast, model) 372 + 373 + #(EmailForm(model.data, form:), effect) 374 + } 375 + } 376 + |> Ok 377 + } 378 + |> result.unwrap({ 379 + let toast = 380 + toaster.Toast( 381 + title: Some("Something went wrong"), 382 + message: "I was unable to reach the database", 383 + options: toast.default_options(toast.Warning), 384 + ) 385 + do_toast(toast, model) 386 + }) 387 + } 388 + 389 + fn send_one_time_password( 390 + one_time_password: String, 391 + login: Login, 392 + smtp_environment: smtp.SmtpEnvironment, 393 + ) -> Effect(Message) { 394 + use dispatch <- effect.from() 395 + 396 + smtp.one_time_password(email: login.email, one_time_password:) 397 + |> smtp.send_message(smtp_environment) 398 + |> ServerSentPassword 399 + |> dispatch 400 + } 401 + 402 + // view ------------------------------------------------------------------------- 403 + 404 + fn view(model: Model) -> Element(Message) { 405 + html.main([], [ 406 + case model { 407 + EmailForm(form:, ..) -> view_login_form(form:, busy: False) 408 + EmailSending(login:, ..) -> 409 + view_login_form(login_form_with_data(login), busy: True) 410 + PasswordForm(form:, ..) -> 411 + view_confirm_one_time_password(form:, busy: False) 412 + LoggingIn(data: _, password:) -> 413 + view_confirm_one_time_password( 414 + form: one_time_password_form_with_data(password), 415 + busy: True, 416 + ) 417 + LoggedIn(data:, user:) -> view_logged_in(data, user) 418 + }, 419 + // button.button([event.on_click(SpawnToast)], [element.text("spawn toast")]), 420 + portal.to("body", [], [ 421 + keyed.fragment({ 422 + use #(key, toasts) <- list.map(toaster.view_toasts(model.data.toasts)) 423 + 424 + #(key, toasts) 425 + }), 426 + ]), 427 + ]) 428 + } 429 + 430 + fn view_logged_in(_data: ModelData, _user: user.User) -> Element(Message) { 431 + html.div([], [ 432 + html.h1([], [element.text("*hacker voice* im in!")]), 433 + ]) 434 + } 435 + 436 + fn view_login_form(form form: Form(Login), busy busy: Bool) -> Element(Message) { 437 + let submitted = fn(fields) { 438 + form 439 + |> form.add_values(fields) 440 + |> form.run 441 + |> UserSubmittedEmail 442 + } 443 + 444 + // TODO: fix minified oat missing 445 + // @layer components { 446 + // [aria-busy="true"] { 447 + // border: 2px solid var(--muted); 448 + 449 + html.div( 450 + [ 451 + attribute.class("container vstack"), 452 + attribute.aria_busy(busy), 453 + attribute.attribute("data-spinner", "large overlay"), 454 + ], 455 + [ 456 + html.div([attribute.class("row")], [ 457 + card.card([attribute.class("col-4 offset-4")], [ 458 + card.header([], [html.h3([], [element.text("Login")])]), 459 + gform.form( 460 + [ 461 + attribute.method("POST"), 462 + event.on_submit(submitted), 463 + ], 464 + [ 465 + field_input(form, name: "email", kind: "text", label: "Email"), 466 + html.div([], [html.input([attribute.type_("submit")])]), 467 + ], 468 + ), 469 + ]), 470 + ]), 471 + ], 472 + ) 473 + } 474 + 475 + fn view_confirm_one_time_password( 476 + form form: Form(String), 477 + busy busy: Bool, 478 + ) -> Element(Message) { 479 + let submitted = fn(fields) { 480 + form 481 + |> form.add_values(fields) 482 + |> form.run 483 + |> UserSubmittedPassword 484 + } 485 + 486 + // TODO: clear password field on first load (apparently it thinks the email and password field input are the same) 487 + html.div( 488 + [ 489 + attribute.class("container vstack"), 490 + attribute.aria_busy(busy), 491 + attribute.attribute("data-spinner", "large overlay"), 492 + ], 493 + [ 494 + html.div([attribute.class("row")], [ 495 + card.card([attribute.class("col-4 offset-4")], [ 496 + card.header([], [html.h3([], [element.text("Password")])]), 497 + gform.form([attribute.method("POST"), event.on_submit(submitted)], [ 498 + field_input( 499 + form, 500 + name: "password", 501 + kind: "text", 502 + label: "One time password", 503 + ), 504 + html.div([], [html.input([attribute.type_("submit")])]), 505 + ]), 506 + ]), 507 + ]), 508 + ], 509 + ) 510 + } 511 + 512 + // formal ----------------------------------------------------------------------- 513 + 514 + pub opaque type Login { 515 + Login(email: String) 516 + } 517 + 518 + fn login_form_with_data(login: Login) -> Form(Login) { 519 + login_form() 520 + |> form.add_string("email", login.email) 521 + } 522 + 523 + fn login_form() -> Form(Login) { 524 + form.new({ 525 + use email <- form.field("email", { form.parse_email }) 526 + 527 + form.success(Login(email: email)) 528 + }) 529 + } 530 + 531 + fn one_time_password_form_with_data(password: String) -> Form(String) { 532 + one_time_password_form() 533 + |> form.add_string("password", password) 534 + } 535 + 536 + fn one_time_password_form() -> Form(String) { 537 + form.new({ 538 + use password <- form.field("password", form.parse_string) 539 + form.success(password) 540 + }) 541 + } 542 + 543 + /// Render a single HTML form field. 544 + /// 545 + /// If the field already has a value then it is used as the HTML input value. 546 + /// If the field has an error it is displayed. 547 + /// 548 + fn field_input( 549 + form: Form(t), 550 + name name: String, 551 + kind kind: String, 552 + label label_text: String, 553 + ) -> Element(a) { 554 + let errors = form.field_error_messages(form, name) 555 + 556 + gform.label([attribute.for(name)], [ 557 + // The label text, for the user to read 558 + element.text(label_text), 559 + // The input, for the user to type into 560 + gform.input([ 561 + attribute.type_(kind), 562 + attribute.name(name), 563 + attribute.default_value(form.field_value(form, name)), 564 + ..case errors { 565 + [] -> [attribute.none()] 566 + _ -> [gform.invalid(), gform.described_by(name <> "-hint")] 567 + } 568 + ]), 569 + // Any errors presented below 570 + ..list.map(errors, fn(msg) { 571 + html.small([attribute.id(name <> "-hint"), gform.hint(), gform.error()], [ 572 + element.text(msg), 573 + ]) 574 + }) 575 + ]) 576 + } 577 + 578 + // helpers ---------------------------------------------------------------------- 579 + 580 + /// dispatch a given message after a given timeout 581 + /// 582 + pub fn schedule_message( 583 + dispatch message: a, 584 + after timeout: Int, 585 + ) -> effect.Effect(a) { 586 + use dispatch <- effect.from 587 + 588 + use <- run_after(timeout) 589 + 590 + dispatch(message) 591 + } 592 + 593 + /// run the callback after a given timeout 594 + /// 595 + fn run_after(timeout: Int, run: fn() -> Nil) -> Nil { 596 + process.spawn(fn() { 597 + process.sleep(timeout) 598 + run() 599 + }) 600 + Nil 601 + } 602 + 603 + /// update the model and schedule dismissal of a new toast 604 + /// 605 + fn do_toast(toast: toaster.Toast, model: Model) { 606 + let data = ModelData(..model.data, toasts: [toast, ..model.data.toasts]) 607 + 608 + let effect = 609 + schedule_message( 610 + dispatch: TimerDismissedToast, 611 + after: toast.options.duration_ms, 612 + ) 613 + 614 + #(update_data(data, model), effect) 615 + }
+96 -46
src/eater/webserver.gleam
··· 1 1 import eater/smtp 2 - import eater/ui/login 2 + import eater/ui/main_ui 3 3 import ewe 4 4 import gleam/bytes_tree 5 5 import gleam/erlang/application ··· 7 7 import gleam/http/request.{type Request} 8 8 import gleam/http/response.{type Response} 9 9 import gleam/json 10 + import gleam/list 10 11 import gleam/option.{None, Some} 12 + import gleam/result 11 13 import group_registry 12 14 import lustre 13 15 import lustre/attribute.{attribute} ··· 15 17 import lustre/element/html.{html} 16 18 import lustre/server_component 17 19 import sqlight 20 + import youid/uuid 18 21 19 22 pub fn supervised( 20 23 database database, 21 24 registry registry, 22 25 smtp_environment smtp_environment, 23 26 ) { 24 - // TODO: CSRF protection 25 - 27 + // TODO: proper CSRF protection with unique token per connection 28 + let csrf_token = uuid.v4_string() 26 29 let request_handler = fn(request: Request(ewe.Connection)) -> ewe.Response { 27 30 case request.path_segments(request) { 28 - [] -> serve_html() 31 + [] -> serve_html(csrf_token) 29 32 ["lustre", "runtime.mjs"] -> serve_runtime() 30 33 ["lustre", "portal.mjs"] -> serve_portal() 31 34 ["static", "oat.js"] -> serve_oat_js() 32 35 ["static", "oat.css"] -> serve_oat_css() 33 - ["ws"] -> serve_login(request, registry, database, smtp_environment) 36 + ["ws"] -> 37 + serve_component( 38 + request, 39 + registry, 40 + database, 41 + smtp_environment, 42 + csrf_token, 43 + ) 34 44 _ -> response.new(404) |> response.set_body(ewe.Empty) 35 45 } 36 46 } ··· 43 53 44 54 // HTML ------------------------------------------------------------------------ 45 55 46 - fn serve_html() -> ewe.Response { 56 + fn serve_html(csrf_token: String) -> ewe.Response { 47 57 let html = 48 58 html([attribute.lang("en")], [ 49 59 html.head([], [ ··· 52 62 attribute.name("viewport"), 53 63 attribute.content("width=device-width, initial-scale=1"), 54 64 ]), 65 + html.meta([ 66 + attribute.name("csrf-token"), 67 + // Embed the CSRF token in a meta tag. Lustre's server component runtime 68 + // will automatically read this token and include it in the WebSocket URL 69 + // when establishing the connection. 70 + attribute.content(csrf_token), 71 + ]), 55 72 // TODO: add icon 56 73 html.title([], "eater - email based rss feed aggregator"), 57 74 // lustre runtime ··· 59 76 [attribute.type_("module"), attribute.src("/lustre/runtime.mjs")], 60 77 "", 61 78 ), 79 + 62 80 // lustre portal 63 81 html.script( 64 82 [attribute.type_("module"), attribute.src("/lustre/portal.mjs")], 65 83 "", 66 84 ), 67 - 68 85 // oat 69 86 html.link([ 70 87 attribute.rel("stylesheet"), ··· 77 94 ], 78 95 "", 79 96 ), 80 - html.link([ 81 - attribute.rel("stylesheet"), 82 - attribute.href("/static/utils.css"), 83 - ]), 84 97 ]), 85 98 html.body([attribute.style("height", "100dvh")], [ 86 - server_component.element([server_component.route("/ws")], []), 99 + server_component.element( 100 + // TODO: remove once lustre runtime can embed these itself (probably next update) 101 + [server_component.route("/ws?csrf-token=" <> csrf_token)], 102 + [], 103 + ), 87 104 ]), 88 105 ]) 89 106 |> element.to_document_string_tree ··· 98 115 99 116 fn serve_runtime() -> ewe.Response { 100 117 let assert Ok(lustre_priv) = application.priv_directory("lustre") 101 - let file_path = lustre_priv <> "/static/lustre-server-component.min.mjs" 118 + let file_path = lustre_priv <> "/static/lustre-server-component.mjs" 102 119 103 120 case ewe.file(file_path, offset: None, limit: None) { 104 121 Ok(file) -> ··· 113 130 } 114 131 115 132 fn serve_portal() -> Response(ewe.ResponseBody) { 116 - let assert Ok(lustre_priv) = application.priv_directory("lustre") 133 + let assert Ok(lustre_priv) = application.priv_directory("lustre_portal") 117 134 let file_path = lustre_priv <> "/static/lustre-portal.min.mjs" 118 135 119 136 case ewe.file(file_path, offset: None, limit: None) { ··· 163 180 // WEBSOCKET ------------------------------------------------------------------- 164 181 165 182 type LoginSocket { 166 - LoginSocket( 167 - component: lustre.Runtime(login.Message), 168 - self: process.Subject(server_component.ClientMessage(login.Message)), 183 + UiSocket( 184 + component: lustre.Runtime(main_ui.Message), 185 + self: process.Subject(server_component.ClientMessage(main_ui.Message)), 169 186 ) 170 187 } 171 188 172 - fn serve_login( 189 + fn serve_component( 173 190 request: Request(ewe.Connection), 174 - registry: process.Name(group_registry.Message(_)), 191 + _registry: process.Name(group_registry.Message(_)), 175 192 database: sqlight.Connection, 176 193 smtp_environment: smtp.SmtpEnvironment, 194 + expected_csrf_token: String, 177 195 ) -> Response(ewe.ResponseBody) { 178 - let login_init = fn(websocket: ewe.WebsocketConnection, selector) { 179 - let login = login.component() 196 + // Extract the CSRF token from the query parameters of the initial WebSocket 197 + // connection request. Browsers do not allow JavaScript to set custom headers 198 + // on WebSocket connections, so the token must be included in the URL directly 199 + // instead. 200 + // 201 + // The server component client runtime will automatically detect the presence 202 + // of a `<meta>` tag with the name `"csrf-token"` and include its value as a 203 + // `csrf-token` query parameter in the WebSocket URL when establishing the 204 + // connection. 205 + let provided_csrf_token = 206 + request 207 + |> request.get_query 208 + |> result.try(list.key_find(_, "csrf-token")) 180 209 181 - let assert Ok(component) = 182 - lustre.start_server_component( 183 - login, 184 - with: login.new_model_data(database, smtp_environment, True), 185 - ) 210 + case provided_csrf_token { 211 + // token matches what we expect, upgrade 212 + Ok(token) if token == expected_csrf_token -> { 213 + let on_init = fn(_websocket: ewe.WebsocketConnection, selector) { 214 + let login = main_ui.component() 186 215 187 - let self = process.new_subject() 188 - let selector = process.select(selector, self) 216 + let assert Ok(component) = 217 + lustre.start_server_component( 218 + login, 219 + with: main_ui.new_model_data( 220 + csrf_token: token, 221 + database:, 222 + smtp_environment:, 223 + // TODO: make this configurable with an environment variable 224 + allow_signups: False, 225 + ), 226 + ) 189 227 190 - server_component.register_subject(self) 191 - |> lustre.send(to: component) 228 + let self = process.new_subject() 229 + let selector = process.select(selector, self) 230 + 231 + server_component.register_subject(self) 232 + |> lustre.send(to: component) 233 + 234 + #(UiSocket(component:, self:), selector) 235 + } 236 + 237 + ewe.upgrade_websocket( 238 + request, 239 + on_init:, 240 + handler: handle_login_websocket_message, 241 + on_close: fn(_connection, state) { 242 + // When the websocket connection closes, we need to also shut down the server 243 + // component runtime. If we forget to do this we'll end up with a memory leak 244 + // and a zombie process! 245 + lustre.shutdown() 246 + |> lustre.send(to: state.component) 247 + }, 248 + ) 249 + } 192 250 193 - #(LoginSocket(component:, self:), selector) 251 + // token doesnt match or is missing 252 + // reject with 403 253 + Ok(_) | Error(_) -> 254 + response.new(403) 255 + |> response.set_header("content-type", "text/plain") 256 + |> response.set_body(ewe.BytesData(bytes_tree.from_string("Forbidden"))) 194 257 } 195 - 196 - ewe.upgrade_websocket( 197 - request, 198 - on_init: login_init, 199 - handler: handle_login_websocket_message, 200 - on_close: fn(_connection, state) { 201 - // When the websocket connection closes, we need to also shut down the server 202 - // component runtime. If we forget to do this we'll end up with a memory leak 203 - // and a zombie process! 204 - lustre.shutdown() 205 - |> lustre.send(to: state.component) 206 - }, 207 - ) 208 258 } 209 259 210 260 fn handle_login_websocket_message( 211 261 connection: ewe.WebsocketConnection, 212 262 state: LoginSocket, 213 - message: ewe.WebsocketMessage(server_component.ClientMessage(login.Message)), 263 + message: ewe.WebsocketMessage(server_component.ClientMessage(main_ui.Message)), 214 264 ) -> ewe.WebsocketNext( 215 265 LoginSocket, 216 - server_component.ClientMessage(login.Message), 266 + server_component.ClientMessage(main_ui.Message), 217 267 ) { 218 268 case message { 219 269 ewe.Text(json) -> {