···2424 - [x] (pub-sub) send feeds to user actors
2525 - [x] incremental timeouts on failure
2626 - [ ] test that
2727+ - [ ] make sure they get published in the order they were posted in
2728- [x] `sender_factory`_supervisor
2829- [x] `sender` actor per user
2930 - [x] spawn on startup
···9293defaults to `SMTP_USERNAME` when unset
9394- `SMTP_SENDER_NAME`
9495the 'name' of the sender to use
9696+9797+- `ALLOW_SIGNUPS`
9898+whether new users need to first be added by an admin (false) or can simply log in (true)
9999+defaults to `false`
100100+951019610297103
+13-4
src/eater.gleam
···1616// This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5]
1717// See the Licence for the specific language governing permissions and limitations. [cite: 6]
18181919+import eater/configuration
1920import eater/fetcher
2021import eater/sender
2122import eater/smtp
···36373738 woof.log(logger, woof.Info, "Starting", [])
38394040+ let configuration = configuration.from_environment(logger)
4141+3942 let assert Ok(smtp_environment) = smtp.environment()
4043 as "Failed to get smtp environment"
41444242- let assert Ok(database) = database_config() as "Failed to get database config"
4545+ let assert Ok(database) = database_config(logger)
4646+ as "Failed to get database config"
4347 use database <- sqlight.with_connection(database)
44484549 let registry = process.new_name("registry")
···5155 |> supervisor.add(group_registry.supervised(registry))
5256 |> supervisor.add(fetcher.factory(fetcher_factory))
5357 |> supervisor.add(sender.factory(sender_factory))
5454- |> supervisor.add(webserver.supervised(database, registry, smtp_environment))
5858+ |> supervisor.add(webserver.supervised(
5959+ database,
6060+ registry,
6161+ smtp_environment,
6262+ configuration,
6363+ ))
5564 |> supervisor.start()
56655766 woof.log(logger, woof.Info, "Finished starting supervisor", [])
···100109101110// startup stuff ----------------------------------------------------------------
102111103103-fn database_config() {
104104- logging.log(logging.Debug, "Getting DATABASE_URL variable")
112112+fn database_config(logger: woof.Logger) -> Result(String, String) {
113113+ woof.log(logger, woof.Info, "Getting DATABASE_URL", [])
105114106115 use database <- result.try(
107116 envoy.get("DATABASE_URL")
+5-4
src/eater/database.gleam
···2121import eater/user
2222import gleam/dynamic/decode
2323import gleam/list
2424-import gleam/option
2424+import gleam/option.{type Option, None, Some}
2525import gleam/result
2626import parrot/dev
2727import sqlight
···143143}
144144145145/// gets a specific user using the associated email
146146+/// the nested error contains the provided email
146147///
147148pub fn user_by_email(
148149 in on: sqlight.Connection,
149150 email email: String,
150150-) -> Result(Result(user.User, Nil), sqlight.Error) {
151151+) -> Result(Result(user.User, String), sqlight.Error) {
151152 let #(sql, with, expecting) = sql.user_by_email(email:)
152153153154 let with = list.map(with, parrot_to_sqlight)
···155156 use user <- result.try(sqlight.query(sql, on:, with:, expecting:))
156157157158 case user {
158158- [] -> Error(Nil)
159159+ [] -> Error(email)
159160 [user, ..] -> {
160161 let assert Ok(id) = uuid.from_bit_array(user.id)
161162 as "invalid UUID from db UUID column?!"
···257258 use skip_n_times <- option.then(subscription.feed_skip)
258259259260 rss.Location(id:, link:, failed_n_times:, skip_n_times:)
260260- |> option.Some
261261+ |> Some
261262 }
262263 |> option.to_result(Nil)
263264 })
+175-149
src/eater/ui/main_ui.gleam
···2626import glaze/oat/form as gform
2727import glaze/oat/toast
2828import gleam/bit_array
2929+import gleam/bool
2930import gleam/crypto
3031import gleam/erlang/process
3132import gleam/list
3232-import gleam/option.{Some}
3333+import gleam/option.{None, Some}
3334import gleam/result
3435import gleam/string
3536import lustre
···7273 EmailForm(..) -> EmailForm(..model, data:)
7374 EmailSending(..) -> EmailSending(..model, data:)
7475 PasswordForm(..) -> PasswordForm(..model, data:)
7575- LoggingIn(..) -> LoggingIn(..model, data:)
7676 LoggedIn(..) -> LoggedIn(..model, data:)
7777 }
7878}
···8080/// create a new `ModelData`
8181///
8282pub fn new_model_data(
8383- csrf_token csrf_token: String,
8383+ csrf_token _csrf_token: String,
8484 database database: sqlight.Connection,
8585 smtp_environment smtp_environment: smtp.SmtpEnvironment,
8686 allow_signups allow_signups: Bool,
8787) -> ModelData {
8888+ // for some amount of per-session traceability;
8989+ // this should really be replaced with the csrf token, once that is properly implemented
9090+ let session_id = uuid.v7_string() |> string.slice(28, 6)
9191+8892 ModelData(
8989- csrf_token:,
9393+ csrf_token: session_id,
9094 database:,
9195 smtp_environment:,
9296 allow_signups:,
9397 toasts: [],
9494- // for some amount of per-session traceability
9595- logger: woof.new("UI-" <> uuid.v7_string() |> string.slice(28, 6)),
9898+ logger: woof.new("UI-" <> session_id),
9699 )
97100}
981019999-/// The `normal` login flow looks like this
100100-///
101101-/// `EmailForm` -(user enters valid email)-> `EmailSending` -(email gets sent)------------v
102102-/// `LoggedIn` <-(get user / make new one)- `LoggingIn` <-(user enters valid password)- `PasswordForm`
103103-///
104102///
105103pub opaque type Model {
106104 EmailForm(data: ModelData, form: Form(Login))
107107- EmailSending(data: ModelData, login: Login, password: String)
105105+ EmailSending(data: ModelData, user: user.User)
108106 PasswordForm(
109107 data: ModelData,
110108 form: Form(String),
111109 password_should_be: String,
112112- login: Login,
110110+ user: user.User,
113111 )
114114- LoggingIn(data: ModelData, password: String)
115112 LoggedIn(data: ModelData, user: user.User)
116113}
117114···123120 woof.field("model", "EmailForm"),
124121 woof.field("form-content", form.field_value(form, "email")),
125122 ]
126126- EmailSending(data: _, login:, password: _) -> [
123123+ EmailSending(data: _, user:) -> [
127124 woof.field("model", "EmailSending"),
128128- woof.field("to", login.email),
125125+ woof.field("to", user.email),
129126 ]
130130- PasswordForm(data: _, form:, password_should_be:, login:) -> [
127127+ PasswordForm(data: _, form:, password_should_be:, user:) -> [
131128 woof.field("model", "PasswordForm"),
132129 woof.field("form-content", form.field_value(form, "password")),
133130 woof.field("password-should-be", password_should_be),
134134- woof.field("email", login.email),
131131+ woof.field("email", user.email),
135132 ]
136136- LoggingIn(data: _, password: _) -> [woof.field("model", "LoggingIn")]
137133 LoggedIn(data: _, user:) -> [
138134 woof.field("model", "LoggedIn"),
139135 woof.field("user-email", user.email),
···143139144140pub opaque type Message {
145141 UserSubmittedEmail(result: Result(Login, Form(Login)))
142142+ ///
143143+ ///```gleam
144144+ ///Result(Result(user.User, email), db_error)
145145+ ///```
146146+ DatabaseReturnedUser(Result(Result(user.User, String), sqlight.Error))
147147+ DatabaseAddedNewUser(Result(user.User, sqlight.Error))
146148 UserSubmittedPassword(result: Result(String, Form(String)))
147147- UserLoggedIn(result: Result(user.User, sqlight.Error))
148149149149- ServerSentPassword(Result(Nil, gsmtp.Error))
150150+ ServerGeneratedPassword(password: String)
151151+ ServerSentPassword(Result(String, gsmtp.Error))
150152 TimerDismissedToast
151153}
152154···163165 woof.field("message", "UserSubmittedEmail"),
164166 woof.field("status", "error"),
165167 ]
166166- UserSubmittedPassword(Ok(_)) -> [
167167- woof.field("message", "UserSubmittedPassword"),
168168+ DatabaseReturnedUser(Ok(Ok(_))) -> [
169169+ woof.field("message", "DatabaseReturnedUser"),
168170 woof.field("status", "ok"),
171171+ woof.field("details", "found"),
169172 ]
170170- UserSubmittedPassword(Error(_)) -> [
171171- woof.field("message", "UserSubmittedPassword"),
173173+ DatabaseReturnedUser(Ok(Error(_))) -> [
174174+ woof.field("message", "DatabaseReturnedUser"),
175175+ woof.field("status", "ok"),
176176+ woof.field("status", "not-found"),
177177+ ]
178178+ DatabaseReturnedUser(Error(_)) -> [
179179+ woof.field("message", "DatabaseReturnedUser"),
172180 woof.field("status", "error"),
173181 ]
174174- UserLoggedIn(Ok(user)) -> [
175175- woof.field("message", "UserLoggedIn"),
182182+ DatabaseAddedNewUser(Ok(_)) -> [
183183+ woof.field("message", "DatabaseAddedNewUser"),
176184 woof.field("status", "ok"),
177177- woof.field("email", user.email),
185185+ ]
186186+ DatabaseAddedNewUser(Error(_)) -> [
187187+ woof.field("message", "DatabaseAddedNewUser"),
188188+ woof.field("status", "error"),
189189+ ]
190190+ ServerGeneratedPassword(password) -> [
191191+ woof.field("message", "ServerGeneratedPassword"),
192192+ woof.field("password", password),
193193+ ]
194194+ UserSubmittedPassword(Ok(_)) -> [
195195+ woof.field("message", "UserSubmittedPassword"),
196196+ woof.field("status", "ok"),
178197 ]
179179- UserLoggedIn(Error(db_error)) -> [
180180- woof.field("message", "UserLoggedIn"),
198198+ UserSubmittedPassword(Error(_)) -> [
199199+ woof.field("message", "UserSubmittedPassword"),
181200 woof.field("status", "error"),
182182- woof.field("details", string.inspect(db_error)),
183201 ]
184202 ServerSentPassword(Ok(_)) -> [
185203 woof.field("message", "ServerSentPassword"),
···199217fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {
200218 // log all messages while debugging
201219 case woof.is_enabled(woof.Debug) {
202202- True -> log_update(model:, message:, at: woof.Debug, with: "New message")
220220+ True ->
221221+ log_model_n_message(model:, message:, at: woof.Debug, with: "New message")
203222 False -> Nil
204223 }
205224206225 case message, model {
207226 // login - enter email ------------------------------------------------------
208208- UserSubmittedEmail(Ok(login)), EmailForm(form:, ..) ->
209209- user_submitted_ok_email_form(login, model, form)
227227+ UserSubmittedEmail(Ok(login)), EmailForm(..) -> #(
228228+ model,
229229+ fetch_user_from_database(model.data, login),
230230+ )
210231 UserSubmittedEmail(Error(form)), _ -> #(
211232 EmailForm(model.data, form),
212233 effect.none(),
···214235 // should never happen
215236 UserSubmittedEmail(_), _ -> #(model, effect.none())
216237217217- // login - send email -------------------------------------------------------
218218- ServerSentPassword(Ok(_)), EmailSending(data: _, login:, password:) -> {
238238+ DatabaseReturnedUser(Ok(Ok(user))), _ -> #(
239239+ EmailSending(model.data, user),
240240+ generate_one_time_password(),
241241+ )
242242+243243+ DatabaseReturnedUser(Ok(Error(email))), _ -> {
244244+ // signups are enabled, so we make a new user
245245+ use <- bool.guard(model.data.allow_signups, return: #(
246246+ model,
247247+ make_user_from_email(model.data, email),
248248+ ))
249249+250250+ // signups arnt enabled, tell the user
251251+ let toast =
252252+ toaster.Toast(
253253+ title: Some("Invalid email address"),
254254+ message: "This email doesn't exist and signups are disabled",
255255+ options: toast.default_options(toast.Warning),
256256+ )
257257+ do_toast(toast, model)
258258+ }
259259+ DatabaseReturnedUser(Error(db_error)), _ -> {
260260+ model.data.logger
261261+ |> woof.log(woof.Error, "Fetching user from database failed", [
262262+ woof.field("details", string.inspect(db_error)),
263263+ ])
264264+ #(model, effect.none())
265265+ }
266266+267267+ // new user was created, lets generate a one time password for them
268268+ // so they may log in
269269+ DatabaseAddedNewUser(Ok(user)), _ -> #(
270270+ EmailSending(model.data, user),
271271+ generate_one_time_password(),
272272+ )
273273+274274+ // creating a new user failed
275275+ DatabaseAddedNewUser(Error(db_error)), _ -> {
276276+ model.data.logger
277277+ |> woof.log(woof.Error, "Adding user to database failed", [
278278+ woof.field("details", string.inspect(db_error)),
279279+ ])
280280+ #(model, effect.none())
281281+ }
282282+283283+ // login - generate password ------------------------------------------------
284284+ ServerGeneratedPassword(password), EmailSending(..) -> {
285285+ #(
286286+ model,
287287+ send_one_time_password(
288288+ password,
289289+ model.user,
290290+ model.data.smtp_environment,
291291+ ),
292292+ )
293293+ }
294294+ // should never happen
295295+ ServerGeneratedPassword(_), _ -> #(model, effect.none())
296296+297297+ // login - send password ----------------------------------------------------
298298+ ServerSentPassword(Ok(password)), EmailSending(data: _, user:) -> {
219299 let toast =
220300 toaster.Toast(
221221- title: option.None,
301301+ title: None,
222302 message: "One time password was sent",
223303 options: toast.default_options(toast.Info),
224304 )
···228308 PasswordForm(
229309 form: one_time_password_form(),
230310 password_should_be: password,
231231- login:,
311311+ user:,
232312 data: model.data,
233313 )
234314235315 #(model, effect)
236316 }
237237- ServerSentPassword(Error(_)), EmailSending(login:, ..) -> {
317317+318318+ ServerSentPassword(Error(_)), EmailSending(user:, ..) -> {
238319 let toast =
239320 toaster.Toast(
240321 title: Some("Error"),
241322 message: "Failed to send one time password. Try again later or contact "
242323 <> smtp.sender_email(model.data.smtp_environment)
324324+ <> " with "
325325+ <> model.data.csrf_token
243326 <> " if the problem persists.",
244327 options: toast.default_options(toast.Warning),
245328 )
246329247330 let #(model, effect) = do_toast(toast, model)
248331249249- #(EmailForm(model.data, email_form_with_data(login)), effect)
332332+ #(EmailForm(model.data, email_form_with_data(user.email)), effect)
250333 }
251334 // should never happen
252335 ServerSentPassword(_), _ -> #(model, effect.none())
···257340 effect.none(),
258341 )
259342 UserSubmittedPassword(Ok(user_password)),
260260- PasswordForm(password_should_be: correct_password, data:, login:, ..)
343343+ PasswordForm(password_should_be: correct_password, user:, ..)
261344 -> {
262345 case user_password == correct_password {
263263- True -> #(LoggingIn(model.data, user_password), log_in(login, data))
346346+ True -> #(LoggedIn(model.data, user), effect.none())
264347 False ->
265348 do_toast(
266349 toaster.Toast(
···275358 // should never happen
276359 UserSubmittedPassword(_), _ -> #(model, effect.none())
277360278278- // login - logged in --------------------------------------------------------
279279- UserLoggedIn(Ok(user)), _ -> #(LoggedIn(model.data, user), effect.none())
280280- UserLoggedIn(Error(_)), _ -> {
281281- let toast =
282282- toaster.Toast(
283283- title: Some("Login failed!"),
284284- message: "Try again later or contact "
285285- <> smtp.sender_email(model.data.smtp_environment)
286286- <> " if the problem persists.",
287287- options: toast.default_options(toast.Danger),
288288- )
289289- do_toast(toast, EmailForm(data: model.data, form: email_form()))
290290- }
291291-292361 // user ui ------------------------------------------------------------------
293362 // toasts -------------------------------------------------------------------
294363 TimerDismissedToast, _ -> {
···305374 }
306375}
307376377377+/// Create and add a new user to the database
378378+///
379379+/// `DatabaseAddedNewUser`
380380+///
381381+fn make_user_from_email(data: ModelData, email: String) -> Effect(Message) {
382382+ use dispatch <- effect.from
383383+384384+ let user = user.new(email)
385385+386386+ database.add_user(user:, into: data.database)
387387+ |> result.replace(user)
388388+ |> DatabaseAddedNewUser
389389+ |> dispatch
390390+}
391391+392392+/// fetch a user from the database using an email
393393+///
394394+/// `DatabaseReturnedUser`
395395+///
396396+fn fetch_user_from_database(data: ModelData, login: Login) -> Effect(Message) {
397397+ use dispatch <- effect.from
398398+399399+ database.user_by_email(data.database, login.email)
400400+ |> DatabaseReturnedUser
401401+ |> dispatch
402402+}
403403+308404/// log a given model+message combo to `model.data.logger`
309405///
310310-fn log_update(
406406+fn log_model_n_message(
311407 model model: Model,
312408 message message: Message,
313409 at level: woof.Level,
···320416 |> woof.log(level, text, description)
321417}
322418323323-fn log_in(login: Login, data: ModelData) {
324324- use dispatch <- effect.from()
325325-326326- {
327327- use user <- result.try(database.user_by_email(data.database, login.email))
419419+/// generate a one time password
420420+///
421421+/// `ServerGeneratedPassword`
422422+///
423423+fn generate_one_time_password() {
424424+ use dispatch <- effect.from
328425329329- case user {
330330- Ok(user) -> Ok(user)
331331- Error(_) -> {
332332- let user = user.new(login.email)
333333- use _ <- result.try(database.add_user(user, data.database))
334334- // TODO: spawn new sender actor for new user
335335- Ok(user)
336336- }
337337- }
338338- }
339339- |> UserLoggedIn
426426+ crypto.strong_random_bytes(6)
427427+ |> bit_array.base16_encode()
428428+ |> ServerGeneratedPassword
340429 |> dispatch
341430}
342431343343-fn user_submitted_ok_email_form(
344344- login: Login,
345345- model: Model,
346346- form: Form(Login),
347347-) -> #(Model, Effect(Message)) {
348348- let one_time_password = fn() {
349349- crypto.strong_random_bytes(16) |> bit_array.base64_encode(True)
350350- }
351351-352352- let send_password = fn(one_time_password) {
353353- send_one_time_password(
354354- one_time_password,
355355- login,
356356- model.data.smtp_environment,
357357- )
358358- }
359359-360360- {
361361- use user <- result.try(database.user_by_email(
362362- model.data.database,
363363- login.email,
364364- ))
365365-366366- case user, model.data.allow_signups {
367367- // the user exists
368368- // or doesnt, but signups are enabled
369369- Ok(_), _ | Error(_), True -> {
370370- let password = one_time_password()
371371- #(
372372- EmailSending(data: model.data, login:, password:),
373373- send_password(password),
374374- )
375375- }
376376- // the user doesnt exist and signups are disabled
377377- Error(_), False -> {
378378- let toast =
379379- toaster.Toast(
380380- title: Some("Invalid email address"),
381381- message: "This email doesn't exist and registrations are disabled",
382382- options: toast.default_options(toast.Warning),
383383- )
384384- let #(model, effect) = do_toast(toast, model)
385385-386386- #(EmailForm(model.data, form:), effect)
387387- }
388388- }
389389- |> Ok
390390- }
391391- |> result.unwrap({
392392- let toast =
393393- toaster.Toast(
394394- title: Some("Something went wrong"),
395395- message: "I was unable to reach the database",
396396- options: toast.default_options(toast.Warning),
397397- )
398398- do_toast(toast, model)
399399- })
400400-}
401401-432432+/// Send an email containing a given one time password to a given user
433433+///
434434+/// `ServerSentPassword`
435435+///
402436fn send_one_time_password(
403403- one_time_password: String,
404404- login: Login,
437437+ password: String,
438438+ user: user.User,
405439 smtp_environment: smtp.SmtpEnvironment,
406440) -> Effect(Message) {
407441 use dispatch <- effect.from()
408442409409- smtp.one_time_password(email: login.email, one_time_password:)
443443+ smtp.one_time_password(email: user.email, one_time_password: password)
410444 |> smtp.send_message(smtp_environment)
445445+ |> result.replace(password)
411446 |> ServerSentPassword
412447 |> dispatch
413448}
···418453 html.main([], [
419454 case model {
420455 EmailForm(form:, ..) -> view_email_form(form:, busy: False)
421421- EmailSending(login:, ..) ->
422422- view_email_form(email_form_with_data(login), busy: True)
456456+ EmailSending(user:, ..) ->
457457+ view_email_form(email_form_with_data(user.email), busy: True)
458458+ // TODO: empty form cause apparently it doesnt ?!
423459 PasswordForm(form:, ..) ->
424460 view_confirm_one_time_password(form:, busy: False)
425425- LoggingIn(data: _, password:) ->
426426- view_confirm_one_time_password(
427427- form: one_time_password_form_with_data(password),
428428- busy: True,
429429- )
430461 LoggedIn(data:, user:) -> view_logged_in(data, user)
431462 },
432463 // button.button([event.on_click(SpawnToast)], [element.text("spawn toast")]),
···528559 Login(email: String)
529560}
530561531531-fn email_form_with_data(login: Login) -> Form(Login) {
562562+fn email_form_with_data(email: String) -> Form(Login) {
532563 email_form()
533533- |> form.add_string("email", login.email)
564564+ |> form.add_string("email", email)
534565}
535566536567fn email_form() -> Form(Login) {
···539570540571 form.success(Login(email: email))
541572 })
542542-}
543543-544544-fn one_time_password_form_with_data(password: String) -> Form(String) {
545545- one_time_password_form()
546546- |> form.add_string("password", password)
547573}
548574549575fn one_time_password_form() -> Form(String) {
+5-2
src/eater/webserver.gleam
···11//// webserver
22//// the webserver used to serve the lustre ui `main_ui`
3344+import eater/configuration
45import eater/smtp
56import eater/ui/main_ui
67import ewe
···2627 database database,
2728 registry registry,
2829 smtp_environment smtp_environment,
3030+ configuration configuration: configuration.AppConfig,
2931) {
3032 // TODO: proper CSRF protection with unique token per connection
3133 let csrf_token = uuid.v4_string()
···4244 registry,
4345 database,
4446 smtp_environment,
4747+ configuration,
4548 csrf_token,
4649 )
4750 _ -> response.new(404) |> response.set_body(ewe.Empty)
···194197 _registry: process.Name(group_registry.Message(_)),
195198 database: sqlight.Connection,
196199 smtp_environment: smtp.SmtpEnvironment,
200200+ configuration: configuration.AppConfig,
197201 expected_csrf_token: String,
198202) -> Response(ewe.ResponseBody) {
199203 // Extract the CSRF token from the query parameters of the initial WebSocket
···223227 csrf_token: token,
224228 database:,
225229 smtp_environment:,
226226- // TODO: make this configurable with an environment variable
227227- allow_signups: False,
230230+ allow_signups: configuration.allow_signups,
228231 ),
229232 )
230233