mail based rss feed aggregator
2
fork

Configure Feed

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

use `configuration` in main_ui and make main_ui actually use effects properly

ollie 8258ea7e 7e3e6b4c

+204 -159
+6
README.md
··· 24 24 - [x] (pub-sub) send feeds to user actors 25 25 - [x] incremental timeouts on failure 26 26 - [ ] test that 27 + - [ ] make sure they get published in the order they were posted in 27 28 - [x] `sender_factory`_supervisor 28 29 - [x] `sender` actor per user 29 30 - [x] spawn on startup ··· 92 93 defaults to `SMTP_USERNAME` when unset 93 94 - `SMTP_SENDER_NAME` 94 95 the 'name' of the sender to use 96 + 97 + - `ALLOW_SIGNUPS` 98 + whether new users need to first be added by an admin (false) or can simply log in (true) 99 + defaults to `false` 100 + 95 101 96 102 97 103
+13 -4
src/eater.gleam
··· 16 16 // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 17 // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 18 19 + import eater/configuration 19 20 import eater/fetcher 20 21 import eater/sender 21 22 import eater/smtp ··· 36 37 37 38 woof.log(logger, woof.Info, "Starting", []) 38 39 40 + let configuration = configuration.from_environment(logger) 41 + 39 42 let assert Ok(smtp_environment) = smtp.environment() 40 43 as "Failed to get smtp environment" 41 44 42 - let assert Ok(database) = database_config() as "Failed to get database config" 45 + let assert Ok(database) = database_config(logger) 46 + as "Failed to get database config" 43 47 use database <- sqlight.with_connection(database) 44 48 45 49 let registry = process.new_name("registry") ··· 51 55 |> supervisor.add(group_registry.supervised(registry)) 52 56 |> supervisor.add(fetcher.factory(fetcher_factory)) 53 57 |> supervisor.add(sender.factory(sender_factory)) 54 - |> supervisor.add(webserver.supervised(database, registry, smtp_environment)) 58 + |> supervisor.add(webserver.supervised( 59 + database, 60 + registry, 61 + smtp_environment, 62 + configuration, 63 + )) 55 64 |> supervisor.start() 56 65 57 66 woof.log(logger, woof.Info, "Finished starting supervisor", []) ··· 100 109 101 110 // startup stuff ---------------------------------------------------------------- 102 111 103 - fn database_config() { 104 - logging.log(logging.Debug, "Getting DATABASE_URL variable") 112 + fn database_config(logger: woof.Logger) -> Result(String, String) { 113 + woof.log(logger, woof.Info, "Getting DATABASE_URL", []) 105 114 106 115 use database <- result.try( 107 116 envoy.get("DATABASE_URL")
+5 -4
src/eater/database.gleam
··· 21 21 import eater/user 22 22 import gleam/dynamic/decode 23 23 import gleam/list 24 - import gleam/option 24 + import gleam/option.{type Option, None, Some} 25 25 import gleam/result 26 26 import parrot/dev 27 27 import sqlight ··· 143 143 } 144 144 145 145 /// gets a specific user using the associated email 146 + /// the nested error contains the provided email 146 147 /// 147 148 pub fn user_by_email( 148 149 in on: sqlight.Connection, 149 150 email email: String, 150 - ) -> Result(Result(user.User, Nil), sqlight.Error) { 151 + ) -> Result(Result(user.User, String), sqlight.Error) { 151 152 let #(sql, with, expecting) = sql.user_by_email(email:) 152 153 153 154 let with = list.map(with, parrot_to_sqlight) ··· 155 156 use user <- result.try(sqlight.query(sql, on:, with:, expecting:)) 156 157 157 158 case user { 158 - [] -> Error(Nil) 159 + [] -> Error(email) 159 160 [user, ..] -> { 160 161 let assert Ok(id) = uuid.from_bit_array(user.id) 161 162 as "invalid UUID from db UUID column?!" ··· 257 258 use skip_n_times <- option.then(subscription.feed_skip) 258 259 259 260 rss.Location(id:, link:, failed_n_times:, skip_n_times:) 260 - |> option.Some 261 + |> Some 261 262 } 262 263 |> option.to_result(Nil) 263 264 })
+175 -149
src/eater/ui/main_ui.gleam
··· 26 26 import glaze/oat/form as gform 27 27 import glaze/oat/toast 28 28 import gleam/bit_array 29 + import gleam/bool 29 30 import gleam/crypto 30 31 import gleam/erlang/process 31 32 import gleam/list 32 - import gleam/option.{Some} 33 + import gleam/option.{None, Some} 33 34 import gleam/result 34 35 import gleam/string 35 36 import lustre ··· 72 73 EmailForm(..) -> EmailForm(..model, data:) 73 74 EmailSending(..) -> EmailSending(..model, data:) 74 75 PasswordForm(..) -> PasswordForm(..model, data:) 75 - LoggingIn(..) -> LoggingIn(..model, data:) 76 76 LoggedIn(..) -> LoggedIn(..model, data:) 77 77 } 78 78 } ··· 80 80 /// create a new `ModelData` 81 81 /// 82 82 pub fn new_model_data( 83 - csrf_token csrf_token: String, 83 + csrf_token _csrf_token: String, 84 84 database database: sqlight.Connection, 85 85 smtp_environment smtp_environment: smtp.SmtpEnvironment, 86 86 allow_signups allow_signups: Bool, 87 87 ) -> ModelData { 88 + // for some amount of per-session traceability; 89 + // this should really be replaced with the csrf token, once that is properly implemented 90 + let session_id = uuid.v7_string() |> string.slice(28, 6) 91 + 88 92 ModelData( 89 - csrf_token:, 93 + csrf_token: session_id, 90 94 database:, 91 95 smtp_environment:, 92 96 allow_signups:, 93 97 toasts: [], 94 - // for some amount of per-session traceability 95 - logger: woof.new("UI-" <> uuid.v7_string() |> string.slice(28, 6)), 98 + logger: woof.new("UI-" <> session_id), 96 99 ) 97 100 } 98 101 99 - /// The `normal` login flow looks like this 100 - /// 101 - /// `EmailForm` -(user enters valid email)-> `EmailSending` -(email gets sent)------------v 102 - /// `LoggedIn` <-(get user / make new one)- `LoggingIn` <-(user enters valid password)- `PasswordForm` 103 - /// 104 102 /// 105 103 pub opaque type Model { 106 104 EmailForm(data: ModelData, form: Form(Login)) 107 - EmailSending(data: ModelData, login: Login, password: String) 105 + EmailSending(data: ModelData, user: user.User) 108 106 PasswordForm( 109 107 data: ModelData, 110 108 form: Form(String), 111 109 password_should_be: String, 112 - login: Login, 110 + user: user.User, 113 111 ) 114 - LoggingIn(data: ModelData, password: String) 115 112 LoggedIn(data: ModelData, user: user.User) 116 113 } 117 114 ··· 123 120 woof.field("model", "EmailForm"), 124 121 woof.field("form-content", form.field_value(form, "email")), 125 122 ] 126 - EmailSending(data: _, login:, password: _) -> [ 123 + EmailSending(data: _, user:) -> [ 127 124 woof.field("model", "EmailSending"), 128 - woof.field("to", login.email), 125 + woof.field("to", user.email), 129 126 ] 130 - PasswordForm(data: _, form:, password_should_be:, login:) -> [ 127 + PasswordForm(data: _, form:, password_should_be:, user:) -> [ 131 128 woof.field("model", "PasswordForm"), 132 129 woof.field("form-content", form.field_value(form, "password")), 133 130 woof.field("password-should-be", password_should_be), 134 - woof.field("email", login.email), 131 + woof.field("email", user.email), 135 132 ] 136 - LoggingIn(data: _, password: _) -> [woof.field("model", "LoggingIn")] 137 133 LoggedIn(data: _, user:) -> [ 138 134 woof.field("model", "LoggedIn"), 139 135 woof.field("user-email", user.email), ··· 143 139 144 140 pub opaque type Message { 145 141 UserSubmittedEmail(result: Result(Login, Form(Login))) 142 + /// 143 + ///```gleam 144 + ///Result(Result(user.User, email), db_error) 145 + ///``` 146 + DatabaseReturnedUser(Result(Result(user.User, String), sqlight.Error)) 147 + DatabaseAddedNewUser(Result(user.User, sqlight.Error)) 146 148 UserSubmittedPassword(result: Result(String, Form(String))) 147 - UserLoggedIn(result: Result(user.User, sqlight.Error)) 148 149 149 - ServerSentPassword(Result(Nil, gsmtp.Error)) 150 + ServerGeneratedPassword(password: String) 151 + ServerSentPassword(Result(String, gsmtp.Error)) 150 152 TimerDismissedToast 151 153 } 152 154 ··· 163 165 woof.field("message", "UserSubmittedEmail"), 164 166 woof.field("status", "error"), 165 167 ] 166 - UserSubmittedPassword(Ok(_)) -> [ 167 - woof.field("message", "UserSubmittedPassword"), 168 + DatabaseReturnedUser(Ok(Ok(_))) -> [ 169 + woof.field("message", "DatabaseReturnedUser"), 168 170 woof.field("status", "ok"), 171 + woof.field("details", "found"), 169 172 ] 170 - UserSubmittedPassword(Error(_)) -> [ 171 - woof.field("message", "UserSubmittedPassword"), 173 + DatabaseReturnedUser(Ok(Error(_))) -> [ 174 + woof.field("message", "DatabaseReturnedUser"), 175 + woof.field("status", "ok"), 176 + woof.field("status", "not-found"), 177 + ] 178 + DatabaseReturnedUser(Error(_)) -> [ 179 + woof.field("message", "DatabaseReturnedUser"), 172 180 woof.field("status", "error"), 173 181 ] 174 - UserLoggedIn(Ok(user)) -> [ 175 - woof.field("message", "UserLoggedIn"), 182 + DatabaseAddedNewUser(Ok(_)) -> [ 183 + woof.field("message", "DatabaseAddedNewUser"), 176 184 woof.field("status", "ok"), 177 - woof.field("email", user.email), 185 + ] 186 + DatabaseAddedNewUser(Error(_)) -> [ 187 + woof.field("message", "DatabaseAddedNewUser"), 188 + woof.field("status", "error"), 189 + ] 190 + ServerGeneratedPassword(password) -> [ 191 + woof.field("message", "ServerGeneratedPassword"), 192 + woof.field("password", password), 193 + ] 194 + UserSubmittedPassword(Ok(_)) -> [ 195 + woof.field("message", "UserSubmittedPassword"), 196 + woof.field("status", "ok"), 178 197 ] 179 - UserLoggedIn(Error(db_error)) -> [ 180 - woof.field("message", "UserLoggedIn"), 198 + UserSubmittedPassword(Error(_)) -> [ 199 + woof.field("message", "UserSubmittedPassword"), 181 200 woof.field("status", "error"), 182 - woof.field("details", string.inspect(db_error)), 183 201 ] 184 202 ServerSentPassword(Ok(_)) -> [ 185 203 woof.field("message", "ServerSentPassword"), ··· 199 217 fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { 200 218 // log all messages while debugging 201 219 case woof.is_enabled(woof.Debug) { 202 - True -> log_update(model:, message:, at: woof.Debug, with: "New message") 220 + True -> 221 + log_model_n_message(model:, message:, at: woof.Debug, with: "New message") 203 222 False -> Nil 204 223 } 205 224 206 225 case message, model { 207 226 // login - enter email ------------------------------------------------------ 208 - UserSubmittedEmail(Ok(login)), EmailForm(form:, ..) -> 209 - user_submitted_ok_email_form(login, model, form) 227 + UserSubmittedEmail(Ok(login)), EmailForm(..) -> #( 228 + model, 229 + fetch_user_from_database(model.data, login), 230 + ) 210 231 UserSubmittedEmail(Error(form)), _ -> #( 211 232 EmailForm(model.data, form), 212 233 effect.none(), ··· 214 235 // should never happen 215 236 UserSubmittedEmail(_), _ -> #(model, effect.none()) 216 237 217 - // login - send email ------------------------------------------------------- 218 - ServerSentPassword(Ok(_)), EmailSending(data: _, login:, password:) -> { 238 + DatabaseReturnedUser(Ok(Ok(user))), _ -> #( 239 + EmailSending(model.data, user), 240 + generate_one_time_password(), 241 + ) 242 + 243 + DatabaseReturnedUser(Ok(Error(email))), _ -> { 244 + // signups are enabled, so we make a new user 245 + use <- bool.guard(model.data.allow_signups, return: #( 246 + model, 247 + make_user_from_email(model.data, email), 248 + )) 249 + 250 + // signups arnt enabled, tell the user 251 + let toast = 252 + toaster.Toast( 253 + title: Some("Invalid email address"), 254 + message: "This email doesn't exist and signups are disabled", 255 + options: toast.default_options(toast.Warning), 256 + ) 257 + do_toast(toast, model) 258 + } 259 + DatabaseReturnedUser(Error(db_error)), _ -> { 260 + model.data.logger 261 + |> woof.log(woof.Error, "Fetching user from database failed", [ 262 + woof.field("details", string.inspect(db_error)), 263 + ]) 264 + #(model, effect.none()) 265 + } 266 + 267 + // new user was created, lets generate a one time password for them 268 + // so they may log in 269 + DatabaseAddedNewUser(Ok(user)), _ -> #( 270 + EmailSending(model.data, user), 271 + generate_one_time_password(), 272 + ) 273 + 274 + // creating a new user failed 275 + DatabaseAddedNewUser(Error(db_error)), _ -> { 276 + model.data.logger 277 + |> woof.log(woof.Error, "Adding user to database failed", [ 278 + woof.field("details", string.inspect(db_error)), 279 + ]) 280 + #(model, effect.none()) 281 + } 282 + 283 + // login - generate password ------------------------------------------------ 284 + ServerGeneratedPassword(password), EmailSending(..) -> { 285 + #( 286 + model, 287 + send_one_time_password( 288 + password, 289 + model.user, 290 + model.data.smtp_environment, 291 + ), 292 + ) 293 + } 294 + // should never happen 295 + ServerGeneratedPassword(_), _ -> #(model, effect.none()) 296 + 297 + // login - send password ---------------------------------------------------- 298 + ServerSentPassword(Ok(password)), EmailSending(data: _, user:) -> { 219 299 let toast = 220 300 toaster.Toast( 221 - title: option.None, 301 + title: None, 222 302 message: "One time password was sent", 223 303 options: toast.default_options(toast.Info), 224 304 ) ··· 228 308 PasswordForm( 229 309 form: one_time_password_form(), 230 310 password_should_be: password, 231 - login:, 311 + user:, 232 312 data: model.data, 233 313 ) 234 314 235 315 #(model, effect) 236 316 } 237 - ServerSentPassword(Error(_)), EmailSending(login:, ..) -> { 317 + 318 + ServerSentPassword(Error(_)), EmailSending(user:, ..) -> { 238 319 let toast = 239 320 toaster.Toast( 240 321 title: Some("Error"), 241 322 message: "Failed to send one time password. Try again later or contact " 242 323 <> smtp.sender_email(model.data.smtp_environment) 324 + <> " with " 325 + <> model.data.csrf_token 243 326 <> " if the problem persists.", 244 327 options: toast.default_options(toast.Warning), 245 328 ) 246 329 247 330 let #(model, effect) = do_toast(toast, model) 248 331 249 - #(EmailForm(model.data, email_form_with_data(login)), effect) 332 + #(EmailForm(model.data, email_form_with_data(user.email)), effect) 250 333 } 251 334 // should never happen 252 335 ServerSentPassword(_), _ -> #(model, effect.none()) ··· 257 340 effect.none(), 258 341 ) 259 342 UserSubmittedPassword(Ok(user_password)), 260 - PasswordForm(password_should_be: correct_password, data:, login:, ..) 343 + PasswordForm(password_should_be: correct_password, user:, ..) 261 344 -> { 262 345 case user_password == correct_password { 263 - True -> #(LoggingIn(model.data, user_password), log_in(login, data)) 346 + True -> #(LoggedIn(model.data, user), effect.none()) 264 347 False -> 265 348 do_toast( 266 349 toaster.Toast( ··· 275 358 // should never happen 276 359 UserSubmittedPassword(_), _ -> #(model, effect.none()) 277 360 278 - // login - logged in -------------------------------------------------------- 279 - UserLoggedIn(Ok(user)), _ -> #(LoggedIn(model.data, user), effect.none()) 280 - UserLoggedIn(Error(_)), _ -> { 281 - let toast = 282 - toaster.Toast( 283 - title: Some("Login failed!"), 284 - message: "Try again later or contact " 285 - <> smtp.sender_email(model.data.smtp_environment) 286 - <> " if the problem persists.", 287 - options: toast.default_options(toast.Danger), 288 - ) 289 - do_toast(toast, EmailForm(data: model.data, form: email_form())) 290 - } 291 - 292 361 // user ui ------------------------------------------------------------------ 293 362 // toasts ------------------------------------------------------------------- 294 363 TimerDismissedToast, _ -> { ··· 305 374 } 306 375 } 307 376 377 + /// Create and add a new user to the database 378 + /// 379 + /// `DatabaseAddedNewUser` 380 + /// 381 + fn make_user_from_email(data: ModelData, email: String) -> Effect(Message) { 382 + use dispatch <- effect.from 383 + 384 + let user = user.new(email) 385 + 386 + database.add_user(user:, into: data.database) 387 + |> result.replace(user) 388 + |> DatabaseAddedNewUser 389 + |> dispatch 390 + } 391 + 392 + /// fetch a user from the database using an email 393 + /// 394 + /// `DatabaseReturnedUser` 395 + /// 396 + fn fetch_user_from_database(data: ModelData, login: Login) -> Effect(Message) { 397 + use dispatch <- effect.from 398 + 399 + database.user_by_email(data.database, login.email) 400 + |> DatabaseReturnedUser 401 + |> dispatch 402 + } 403 + 308 404 /// log a given model+message combo to `model.data.logger` 309 405 /// 310 - fn log_update( 406 + fn log_model_n_message( 311 407 model model: Model, 312 408 message message: Message, 313 409 at level: woof.Level, ··· 320 416 |> woof.log(level, text, description) 321 417 } 322 418 323 - fn log_in(login: Login, data: ModelData) { 324 - use dispatch <- effect.from() 325 - 326 - { 327 - use user <- result.try(database.user_by_email(data.database, login.email)) 419 + /// generate a one time password 420 + /// 421 + /// `ServerGeneratedPassword` 422 + /// 423 + fn generate_one_time_password() { 424 + use dispatch <- effect.from 328 425 329 - case user { 330 - Ok(user) -> Ok(user) 331 - Error(_) -> { 332 - let user = user.new(login.email) 333 - use _ <- result.try(database.add_user(user, data.database)) 334 - // TODO: spawn new sender actor for new user 335 - Ok(user) 336 - } 337 - } 338 - } 339 - |> UserLoggedIn 426 + crypto.strong_random_bytes(6) 427 + |> bit_array.base16_encode() 428 + |> ServerGeneratedPassword 340 429 |> dispatch 341 430 } 342 431 343 - fn user_submitted_ok_email_form( 344 - login: Login, 345 - model: Model, 346 - form: Form(Login), 347 - ) -> #(Model, Effect(Message)) { 348 - let one_time_password = fn() { 349 - crypto.strong_random_bytes(16) |> bit_array.base64_encode(True) 350 - } 351 - 352 - let send_password = fn(one_time_password) { 353 - send_one_time_password( 354 - one_time_password, 355 - login, 356 - model.data.smtp_environment, 357 - ) 358 - } 359 - 360 - { 361 - use user <- result.try(database.user_by_email( 362 - model.data.database, 363 - login.email, 364 - )) 365 - 366 - case user, model.data.allow_signups { 367 - // the user exists 368 - // or doesnt, but signups are enabled 369 - Ok(_), _ | Error(_), True -> { 370 - let password = one_time_password() 371 - #( 372 - EmailSending(data: model.data, login:, password:), 373 - send_password(password), 374 - ) 375 - } 376 - // the user doesnt exist and signups are disabled 377 - Error(_), False -> { 378 - let toast = 379 - toaster.Toast( 380 - title: Some("Invalid email address"), 381 - message: "This email doesn't exist and registrations are disabled", 382 - options: toast.default_options(toast.Warning), 383 - ) 384 - let #(model, effect) = do_toast(toast, model) 385 - 386 - #(EmailForm(model.data, form:), effect) 387 - } 388 - } 389 - |> Ok 390 - } 391 - |> result.unwrap({ 392 - let toast = 393 - toaster.Toast( 394 - title: Some("Something went wrong"), 395 - message: "I was unable to reach the database", 396 - options: toast.default_options(toast.Warning), 397 - ) 398 - do_toast(toast, model) 399 - }) 400 - } 401 - 432 + /// Send an email containing a given one time password to a given user 433 + /// 434 + /// `ServerSentPassword` 435 + /// 402 436 fn send_one_time_password( 403 - one_time_password: String, 404 - login: Login, 437 + password: String, 438 + user: user.User, 405 439 smtp_environment: smtp.SmtpEnvironment, 406 440 ) -> Effect(Message) { 407 441 use dispatch <- effect.from() 408 442 409 - smtp.one_time_password(email: login.email, one_time_password:) 443 + smtp.one_time_password(email: user.email, one_time_password: password) 410 444 |> smtp.send_message(smtp_environment) 445 + |> result.replace(password) 411 446 |> ServerSentPassword 412 447 |> dispatch 413 448 } ··· 418 453 html.main([], [ 419 454 case model { 420 455 EmailForm(form:, ..) -> view_email_form(form:, busy: False) 421 - EmailSending(login:, ..) -> 422 - view_email_form(email_form_with_data(login), busy: True) 456 + EmailSending(user:, ..) -> 457 + view_email_form(email_form_with_data(user.email), busy: True) 458 + // TODO: empty form cause apparently it doesnt ?! 423 459 PasswordForm(form:, ..) -> 424 460 view_confirm_one_time_password(form:, busy: False) 425 - LoggingIn(data: _, password:) -> 426 - view_confirm_one_time_password( 427 - form: one_time_password_form_with_data(password), 428 - busy: True, 429 - ) 430 461 LoggedIn(data:, user:) -> view_logged_in(data, user) 431 462 }, 432 463 // button.button([event.on_click(SpawnToast)], [element.text("spawn toast")]), ··· 528 559 Login(email: String) 529 560 } 530 561 531 - fn email_form_with_data(login: Login) -> Form(Login) { 562 + fn email_form_with_data(email: String) -> Form(Login) { 532 563 email_form() 533 - |> form.add_string("email", login.email) 564 + |> form.add_string("email", email) 534 565 } 535 566 536 567 fn email_form() -> Form(Login) { ··· 539 570 540 571 form.success(Login(email: email)) 541 572 }) 542 - } 543 - 544 - fn one_time_password_form_with_data(password: String) -> Form(String) { 545 - one_time_password_form() 546 - |> form.add_string("password", password) 547 573 } 548 574 549 575 fn one_time_password_form() -> Form(String) {
+5 -2
src/eater/webserver.gleam
··· 1 1 //// webserver 2 2 //// the webserver used to serve the lustre ui `main_ui` 3 3 4 + import eater/configuration 4 5 import eater/smtp 5 6 import eater/ui/main_ui 6 7 import ewe ··· 26 27 database database, 27 28 registry registry, 28 29 smtp_environment smtp_environment, 30 + configuration configuration: configuration.AppConfig, 29 31 ) { 30 32 // TODO: proper CSRF protection with unique token per connection 31 33 let csrf_token = uuid.v4_string() ··· 42 44 registry, 43 45 database, 44 46 smtp_environment, 47 + configuration, 45 48 csrf_token, 46 49 ) 47 50 _ -> response.new(404) |> response.set_body(ewe.Empty) ··· 194 197 _registry: process.Name(group_registry.Message(_)), 195 198 database: sqlight.Connection, 196 199 smtp_environment: smtp.SmtpEnvironment, 200 + configuration: configuration.AppConfig, 197 201 expected_csrf_token: String, 198 202 ) -> Response(ewe.ResponseBody) { 199 203 // Extract the CSRF token from the query parameters of the initial WebSocket ··· 223 227 csrf_token: token, 224 228 database:, 225 229 smtp_environment:, 226 - // TODO: make this configurable with an environment variable 227 - allow_signups: False, 230 + allow_signups: configuration.allow_signups, 228 231 ), 229 232 ) 230 233