mail based rss feed aggregator
2
fork

Configure Feed

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

add ewe webserver and basic login server component

ollie 3fca8734 bd029b75

+731
+5
gleam.toml
··· 28 28 gleam_httpc = ">= 5.0.0 and < 6.0.0" 29 29 gleam_http = ">= 4.3.0 and < 5.0.0" 30 30 woof = ">= 1.2.0 and < 2.0.0" 31 + ewe = ">= 3.0.7 and < 4.0.0" 32 + gleam_json = ">= 3.1.0 and < 4.0.0" 33 + formal = ">= 3.0.1 and < 4.0.0" 34 + gleam_crypto = ">= 1.5.1 and < 2.0.0" 31 35 glaze_oat = ">= 3.0.0 and < 4.0.0" 36 + lustre_portal = ">= 1.0.1 and < 2.0.0" 32 37 33 38 [dev_dependencies] 34 39 gleeunit = ">= 1.0.0 and < 2.0.0"
+12
manifest.toml
··· 4 4 packages = [ 5 5 { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 6 { name = "certifi", version = "2.15.0", build_tools = ["rebar3"], requirements = [], otp_app = "certifi", source = "hex", outer_checksum = "B147ED22CE71D72EAFDAD94F055165C1C182F61A2FF49DF28BCC71D1D5B94A60" }, 7 + { name = "compresso", version = "0.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_stdlib", "gleam_yielder", "logging"], otp_app = "compresso", source = "hex", outer_checksum = "8BE29A1EDA42F70826ED148EAE40C46BB3FC18E78FE472663DB01DD4A38172D4" }, 7 8 { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, 8 9 { name = "esqlite", version = "0.9.0", build_tools = ["rebar3"], requirements = [], otp_app = "esqlite", source = "hex", outer_checksum = "CCF72258A4EE152EC7AD92AA9A03552EB6CA1B06B65C93AD5B6E55C302E05855" }, 10 + { name = "ewe", version = "3.0.7", build_tools = ["gleam"], requirements = ["compresso", "exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "logging", "websocks"], otp_app = "ewe", source = "hex", outer_checksum = "5679A3763B79376C0846B23E42C60091441524FF6A6B5DD022ACB2BCB3F35BEC" }, 11 + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, 9 12 { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 13 + { name = "formal", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "formal", source = "hex", outer_checksum = "8FBEB42758F90ACAA82A8B6B8FE11B4A3B2A2B290E97B4DDD4B7DCE98DEB885C" }, 10 14 { name = "gcourier", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_hackney", "gleam_http", "gleam_stdlib", "gleam_time", "shellout", "simplifile", "youid"], source = "git", repo = "git@github.com:nnuuvv/gcourier.git", commit = "0c9e91e30bb97beebef18dd344deb3457a6f4edd" }, 11 15 { name = "glaze_oat", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "lustre"], otp_app = "glaze_oat", source = "hex", outer_checksum = "B1373CA720EBB9D7FBDA4D2E28CAAD2B1928F6845313570AE72CA1D93B069706" }, 12 16 { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, ··· 18 22 { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, 19 23 { name = "gleam_stdlib", version = "0.71.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "702F3BC2A14793906880B1078B19A6165F87323AEE8D0C4A34085846336FCAAE" }, 20 24 { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, 25 + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 21 26 { name = "glearray", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "5E272F7CB278CC05A929C58DEB58F5D5AC6DB5B879A681E71138658D0061C38A" }, 22 27 { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 28 + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, 23 29 { name = "group_registry", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "group_registry", source = "hex", outer_checksum = "BC798A53D6F2406DB94E27CB45C57052CB56B32ACF7CC16EA20F6BAEC7E36B90" }, 24 30 { name = "hackney", version = "1.25.0", build_tools = ["rebar3"], requirements = ["certifi", "idna", "metrics", "mimerl", "parse_trans", "ssl_verify_fun", "unicode_util_compat"], otp_app = "hackney", source = "hex", outer_checksum = "7209BFD75FD1F42467211FF8F59EA74D6F2A9E81CBCEE95A56711EE79FD6B1D4" }, 25 31 { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, 26 32 { name = "idna", version = "6.1.1", build_tools = ["rebar3"], requirements = ["unicode_util_compat"], otp_app = "idna", source = "hex", outer_checksum = "92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA" }, 27 33 { name = "logging", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "BC5F18CE5DD9686100229FE5409BDC3DD5C46D5A7DF2F804AD2D8F0DD6C5060E" }, 28 34 { name = "lustre", version = "5.6.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "EE558CD4DB9F09FCC16417ADF0183A3C2DAC3E4B21ED3AC0CAE859792AB810CA" }, 35 + { name = "lustre_portal", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "lustre_portal", source = "hex", outer_checksum = "7531667B9321B644E139F5BE42077932829431BE74BC7239471CEFC68943A661" }, 29 36 { name = "metrics", version = "1.0.1", build_tools = ["rebar3"], requirements = [], otp_app = "metrics", source = "hex", outer_checksum = "69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16" }, 30 37 { name = "mimerl", version = "1.5.0", build_tools = ["rebar3"], requirements = [], otp_app = "mimerl", source = "hex", outer_checksum = "DB648CE065BAE14EA84CA8B5DD123F42F49417CEF693541110BF6F9E9BE9ECC4" }, 31 38 { name = "parrot", version = "2.2.1", build_tools = ["gleam"], requirements = ["argv", "envoy", "filepath", "gleam_crypto", "gleam_httpc", "gleam_json", "gleam_stdlib", "gleam_time", "glearray", "repeatedly", "simplifile", "tom"], otp_app = "parrot", source = "hex", outer_checksum = "BD114893BFA9564D8F3109AB388EC4BA162E8429A4DE689AF7C5624FF010CBEF" }, ··· 38 45 { name = "ssl_verify_fun", version = "1.1.7", build_tools = ["mix", "rebar3", "make"], requirements = [], otp_app = "ssl_verify_fun", source = "hex", outer_checksum = "FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8" }, 39 46 { name = "tom", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "234A842F3D087D35737483F5DFB6DE9839E3366EF0CAF8726D2D094210227670" }, 40 47 { name = "unicode_util_compat", version = "0.7.1", build_tools = ["rebar3"], requirements = [], otp_app = "unicode_util_compat", source = "hex", outer_checksum = "B3A917854CE3AE233619744AD1E0102E05673136776FB2FA76234F3E03B23642" }, 48 + { name = "websocks", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_stdlib"], otp_app = "websocks", source = "hex", outer_checksum = "C70340E5B6C3390383ADA17029DCA6F8903863A7AD8CD8E1520EDCC4FE70D6FD" }, 41 49 { name = "woof", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "woof", source = "hex", outer_checksum = "A5DC2FCB04F23B0F440978885A167A91450B88F7760B969127187C57E05D489C" }, 42 50 { name = "youid", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "7A3ABA44B1B38BC2BDCB5474C5317AA372BE58DFBC649815EE08B03526DDA18D" }, 43 51 ] 44 52 45 53 [requirements] 46 54 envoy = { version = ">= 1.1.0 and < 2.0.0" } 55 + ewe = { version = ">= 3.0.7 and < 4.0.0" } 56 + formal = { version = ">= 3.0.1 and < 4.0.0" } 47 57 gcourier = { git = "git@github.com:nnuuvv/gcourier.git", ref = "main" } 48 58 glaze_oat = { version = ">= 3.0.0 and < 4.0.0" } 59 + gleam_crypto = { version = ">= 1.5.1 and < 2.0.0" } 49 60 gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } 50 61 gleam_http = { version = ">= 4.3.0 and < 5.0.0" } 51 62 gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } ··· 56 67 group_registry = { version = ">= 1.0.0 and < 2.0.0" } 57 68 logging = { version = ">= 1.3.0 and < 2.0.0" } 58 69 lustre = { version = ">= 5.6.0 and < 6.0.0" } 70 + lustre_portal = { version = ">= 1.0.1 and < 2.0.0" } 59 71 parrot = { version = ">= 2.2.1 and < 3.0.0" } 60 72 parsed_it = { version = ">= 0.1.1 and < 1.0.0" } 61 73 sqlight = { version = ">= 1.0.3 and < 2.0.0" }
+2
src/eater.gleam
··· 16 16 import eater/fetcher 17 17 import eater/sender 18 18 import eater/smtp 19 + import eater/webserver 19 20 import envoy 20 21 import gleam/erlang/process 21 22 import gleam/otp/static_supervisor as supervisor ··· 47 48 |> supervisor.add(group_registry.supervised(registry)) 48 49 |> supervisor.add(fetcher.factory(fetcher_factory)) 49 50 |> supervisor.add(sender.factory(sender_factory)) 51 + |> supervisor.add(webserver.supervised(database, registry, smtp_environment)) 50 52 |> supervisor.start() 51 53 52 54 woof.log(logger, woof.Info, "Finished starting supervisor", [])
+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 + }
+254
src/eater/webserver.gleam
··· 1 + import eater/smtp 2 + import eater/ui/login 3 + import ewe 4 + import gleam/bytes_tree 5 + import gleam/erlang/application 6 + import gleam/erlang/process.{type Selector} 7 + import gleam/http/request.{type Request} 8 + import gleam/http/response.{type Response} 9 + import gleam/json 10 + import gleam/option.{None, Some} 11 + import group_registry 12 + import lustre 13 + import lustre/attribute.{attribute} 14 + import lustre/element 15 + import lustre/element/html.{html} 16 + import lustre/server_component 17 + import sqlight 18 + 19 + pub fn supervised( 20 + database database, 21 + registry registry, 22 + smtp_environment smtp_environment, 23 + ) { 24 + // TODO: CSRF protection 25 + 26 + let request_handler = fn(request: Request(ewe.Connection)) -> ewe.Response { 27 + case request.path_segments(request) { 28 + [] -> serve_html() 29 + ["lustre", "runtime.mjs"] -> serve_runtime() 30 + ["lustre", "portal.mjs"] -> serve_portal() 31 + ["static", "oat.js"] -> serve_oat_js() 32 + ["static", "oat.css"] -> serve_oat_css() 33 + ["static", "utils.css"] -> serve_utils_css() 34 + ["ws"] -> serve_login(request, registry, database, smtp_environment) 35 + _ -> response.new(404) |> response.set_body(ewe.Empty) 36 + } 37 + } 38 + 39 + ewe.new(request_handler) 40 + |> ewe.bind("localhost") 41 + |> ewe.listening(1234) 42 + |> ewe.supervised() 43 + } 44 + 45 + // HTML ------------------------------------------------------------------------ 46 + 47 + fn serve_html() -> ewe.Response { 48 + let html = 49 + html([attribute.lang("en")], [ 50 + html.head([], [ 51 + html.meta([attribute.charset("utf-8")]), 52 + html.meta([ 53 + attribute.name("viewport"), 54 + attribute.content("width=device-width, initial-scale=1"), 55 + ]), 56 + // TODO: add icon 57 + html.title([], "eater - email based rss feed aggregator"), 58 + // lustre runtime 59 + html.script( 60 + [attribute.type_("module"), attribute.src("/lustre/runtime.mjs")], 61 + "", 62 + ), 63 + // lustre portal 64 + html.script( 65 + [attribute.type_("module"), attribute.src("/lustre/portal.mjs")], 66 + "", 67 + ), 68 + 69 + // oat 70 + html.link([ 71 + attribute.rel("stylesheet"), 72 + attribute.href("/static/oat.css"), 73 + ]), 74 + html.script( 75 + [ 76 + attribute("defer", "defer"), 77 + attribute.src("/static/oat.js"), 78 + ], 79 + "", 80 + ), 81 + html.link([ 82 + attribute.rel("stylesheet"), 83 + attribute.href("/static/utils.css"), 84 + ]), 85 + ]), 86 + html.body([attribute.style("height", "100dvh")], [ 87 + server_component.element([server_component.route("/ws")], []), 88 + ]), 89 + ]) 90 + |> element.to_document_string_tree 91 + |> bytes_tree.from_string_tree 92 + 93 + response.new(200) 94 + |> response.set_body(ewe.BytesData(html)) 95 + |> response.set_header("content-type", "text/html") 96 + } 97 + 98 + // JAVASCRIPT ------------------------------------------------------------------ 99 + 100 + fn serve_runtime() -> ewe.Response { 101 + let assert Ok(lustre_priv) = application.priv_directory("lustre") 102 + let file_path = lustre_priv <> "/static/lustre-server-component.min.mjs" 103 + 104 + case ewe.file(file_path, offset: None, limit: None) { 105 + Ok(file) -> 106 + response.new(200) 107 + |> response.prepend_header("content-type", "application/javascript") 108 + |> response.set_body(file) 109 + 110 + Error(_) -> 111 + response.new(404) 112 + |> response.set_body(ewe.Empty) 113 + } 114 + } 115 + 116 + fn serve_portal() -> Response(ewe.ResponseBody) { 117 + let assert Ok(lustre_priv) = application.priv_directory("lustre") 118 + let file_path = lustre_priv <> "/static/lustre-portal.min.mjs" 119 + 120 + case ewe.file(file_path, offset: None, limit: None) { 121 + Ok(file) -> 122 + response.new(200) 123 + |> response.prepend_header("content-type", "application/javascript") 124 + |> response.set_body(file) 125 + 126 + Error(_) -> 127 + response.new(404) 128 + |> response.set_body(ewe.Empty) 129 + } 130 + } 131 + 132 + fn serve_oat_js() -> Response(ewe.ResponseBody) { 133 + let assert Ok(priv) = application.priv_directory("eater") 134 + let file_path = priv <> "/oat.min.js" 135 + 136 + case ewe.file(file_path, offset: None, limit: None) { 137 + Ok(file) -> 138 + response.new(200) 139 + |> response.prepend_header("content-type", "application/javascript") 140 + |> response.set_body(file) 141 + 142 + Error(_) -> 143 + response.new(404) 144 + |> response.set_body(ewe.Empty) 145 + } 146 + } 147 + 148 + fn serve_oat_css() -> Response(ewe.ResponseBody) { 149 + let assert Ok(priv) = application.priv_directory("eater") 150 + let file_path = priv <> "/oat.min.css" 151 + 152 + case ewe.file(file_path, offset: None, limit: None) { 153 + Ok(file) -> 154 + response.new(200) 155 + |> response.prepend_header("content-type", "text/css") 156 + |> response.set_body(file) 157 + 158 + Error(_) -> 159 + response.new(404) 160 + |> response.set_body(ewe.Empty) 161 + } 162 + } 163 + 164 + fn serve_utils_css() -> Response(ewe.ResponseBody) { 165 + let assert Ok(priv) = application.priv_directory("eater") 166 + let file_path = priv <> "/utils.css" 167 + 168 + case ewe.file(file_path, offset: None, limit: None) { 169 + Ok(file) -> 170 + response.new(200) 171 + |> response.prepend_header("content-type", "text/css") 172 + |> response.set_body(file) 173 + 174 + Error(_) -> 175 + response.new(404) 176 + |> response.set_body(ewe.Empty) 177 + } 178 + } 179 + 180 + // WEBSOCKET ------------------------------------------------------------------- 181 + 182 + type LoginSocket { 183 + LoginSocket( 184 + component: lustre.Runtime(login.Message), 185 + self: process.Subject(server_component.ClientMessage(login.Message)), 186 + ) 187 + } 188 + 189 + fn serve_login( 190 + request: Request(ewe.Connection), 191 + registry: process.Name(group_registry.Message(_)), 192 + database: sqlight.Connection, 193 + smtp_environment: smtp.SmtpEnvironment, 194 + ) -> Response(ewe.ResponseBody) { 195 + let login_init = fn(websocket: ewe.WebsocketConnection, selector) { 196 + let login = login.component() 197 + 198 + let assert Ok(component) = 199 + lustre.start_server_component( 200 + login, 201 + with: login.new_model_data(database, smtp_environment, True), 202 + ) 203 + 204 + let self = process.new_subject() 205 + let selector = process.select(selector, self) 206 + 207 + server_component.register_subject(self) 208 + |> lustre.send(to: component) 209 + 210 + #(LoginSocket(component:, self:), selector) 211 + } 212 + 213 + ewe.upgrade_websocket( 214 + request, 215 + on_init: login_init, 216 + handler: handle_login_websocket_message, 217 + on_close: fn(_connection, state) { 218 + // When the websocket connection closes, we need to also shut down the server 219 + // component runtime. If we forget to do this we'll end up with a memory leak 220 + // and a zombie process! 221 + lustre.shutdown() 222 + |> lustre.send(to: state.component) 223 + }, 224 + ) 225 + } 226 + 227 + fn handle_login_websocket_message( 228 + connection: ewe.WebsocketConnection, 229 + state: LoginSocket, 230 + message: ewe.WebsocketMessage(server_component.ClientMessage(login.Message)), 231 + ) -> ewe.WebsocketNext( 232 + LoginSocket, 233 + server_component.ClientMessage(login.Message), 234 + ) { 235 + case message { 236 + ewe.Text(json) -> { 237 + case json.parse(json, server_component.runtime_message_decoder()) { 238 + Ok(runtime_message) -> lustre.send(state.component, runtime_message) 239 + // This case will only be hit if something other than Lustre's client 240 + // runtime sends us a message. 241 + Error(_) -> Nil 242 + } 243 + 244 + ewe.websocket_continue(state) 245 + } 246 + ewe.Binary(_) -> ewe.websocket_continue(state) 247 + ewe.User(client_message) -> { 248 + let json = server_component.client_message_to_json(client_message) 249 + let assert Ok(_) = ewe.send_text_frame(connection, json.to_string(json)) 250 + 251 + ewe.websocket_continue(state) 252 + } 253 + } 254 + }