For now? I'm experimenting on an old concept.
1

Configure Feed

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

feat: Session cookies are now signed

This signs all session cookies using a globally shared
secret, which is kept inside lumina's data folder.

Signed-off-by: MLC Bloeiman <mar@strawmelonjuice.com>

MLC Bloeiman (Jul 16, 2026, 1:21 AM +0200) 53c2af31 df25df76

+148 -17
+83 -3
lumina/backend/src/lumina_server/data.gleam
··· 20 20 21 21 // Imports ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 22 22 import argus 23 + import envoy 23 24 import gleam/bit_array 24 25 import gleam/bool 25 26 import gleam/crypto 26 27 import gleam/erlang/process 27 - import gleam/option.{type Option, Some} 28 + import gleam/option.{type Option, None, Some} 28 29 import gleam/order 29 30 import gleam/otp/actor 30 31 import gleam/otp/supervision 32 + import gleam/pair 31 33 import gleam/result 32 34 import gleam/string 33 35 import gleam/time/duration ··· 39 41 import pog 40 42 import rasa/queue.{type Queue} 41 43 import rasa/table.{type Table} 44 + import simplifile 42 45 import witness 43 46 import youid/uuid 44 47 45 48 // Globals ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 46 49 pub type Globals { 47 50 Globals( 51 + secrets: GlobalSecrets, 48 52 sessions: SessionsStore, 49 53 app_registries: #( 50 54 group_registry.GroupRegistry(GlobalMessage), ··· 54 58 ) 55 59 } 56 60 61 + pub type GlobalSecrets { 62 + GlobalSecrets(cookie_secret: BitArray) 63 + } 64 + 57 65 /// Messages sent between either server components or from the server to it's components. 58 66 pub type GlobalMessage { 59 67 /// Broadcasts the creation of a new user globally, for now this has no use. ··· 80 88 ) 81 89 } 82 90 91 + let secrets = 92 + GlobalSecrets(cookie_secret: { 93 + let secretfile = absname_join(data_dir(), "./session-cookie-secret") 94 + case 95 + simplifile.read(secretfile) 96 + |> result.map_error(Some) 97 + |> result.try(fn(string) { 98 + string.split_once(string, "\n") |> result.replace_error(None) 99 + }) 100 + |> result.map(pair.second) 101 + |> result.try(fn(string) { 102 + bit_array.base64_decode(string) 103 + |> result.replace_error(None) 104 + }) 105 + { 106 + Ok(res) -> res 107 + Error(is_fs) -> { 108 + use <- bool.lazy_guard( 109 + is_fs |> option.is_some 110 + && simplifile.is_file(secretfile) == Ok(True), 111 + fn() { 112 + witness.this( 113 + logging.Critical, 114 + "Could not read cookie secrets file", 115 + [ 116 + witness.string("path", secretfile), 117 + ], 118 + ) 119 + panic as "Could not read cookie secrets file." 120 + }, 121 + ) 122 + witness.this(logging.Notice, "Writing new cookie secrets file.", [ 123 + witness.string("path", secretfile), 124 + ]) 125 + let new_secret = crypto.strong_random_bytes(300) 126 + case 127 + simplifile.write( 128 + to: secretfile, 129 + contents: string.join( 130 + [ 131 + "This file contains the secret used to sign session cookies with, please don't ever edit it! If you want to invalidate all cookies, deleting this file is better.", 132 + new_secret |> bit_array.base64_encode(False), 133 + ], 134 + with: "\n", 135 + ), 136 + ) 137 + { 138 + Ok(Nil) -> new_secret 139 + Error(e) -> { 140 + witness.this( 141 + logging.Critical, 142 + "Could not write cookie secrets file", 143 + [ 144 + witness.string("path", secretfile), 145 + witness.string("error", simplifile.describe_error(e)), 146 + ], 147 + ) 148 + panic as "Could not write cookie secrets file." 149 + } 150 + } 151 + } 152 + } 153 + }) 154 + 83 155 let app_registries = { 84 156 let assert Ok(actor.Started(data: global_app_registry, ..)) = 85 157 group_registry.start(process.new_name("global-app-registry")) ··· 87 159 group_registry.start(process.new_name("session-app-registry")) 88 160 #(global_app_registry, session_app_registry) 89 161 } 90 - Globals(sessions:, app_registries:, postgres_pool_name:) 162 + Globals(sessions:, app_registries:, postgres_pool_name:, secrets:) 91 163 } 92 164 93 165 // Sessions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ··· 194 266 // Postgres interaction ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 195 267 196 268 /// Actor managing the Postgres database pool in the background. 197 - /// Supervised by main process. 269 + /// Supervised by main process 198 270 pub fn db_child(pool_name: process.Name(pog.Message)) { 199 271 let db_url = config.database_url() 200 272 case pog.url_config(pool_name, db_url) { ··· 346 418 |> bit_array.base64_url_encode(False) 347 419 |> string.slice(0, length) 348 420 } 421 + 422 + @external(erlang, "filename", "absname_join") 423 + fn absname_join(dir: String, file: String) -> String 424 + 425 + fn data_dir() -> String { 426 + envoy.get("LUMINA_DATA_DIR") 427 + |> result.unwrap("/data/data") 428 + }
+65 -14
lumina/backend/src/lumina_server/server.gleam
··· 21 21 // Imports ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 22 22 import ewe.{type Request, type Response} 23 23 import gleam/bytes_tree 24 + import gleam/crypto 24 25 import gleam/erlang/application 25 26 import gleam/erlang/process 26 27 import gleam/http ··· 45 46 import youid/uuid 46 47 47 48 // Router ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 48 - pub fn child(global: data.Globals) { 49 + pub fn child(global_context: data.Globals) { 49 50 witness.set_process_fields([ 50 51 witness.string("Process", "Webserver - main"), 51 52 ]) ··· 60 61 |> case req.method, request.path_segments(req) { 61 62 http.Get, [] | http.Get, ["app"] | http.Get, ["app", ..] -> serves_spa( 62 63 _, 63 - global.sessions, 64 + global_context:, 64 65 ) 65 66 http.Get, ["static", "lumina", "lumina.svg"] 66 67 | http.Get, ["favicon.ico"] 67 68 | http.Get, ["lumina.svg"] 68 69 -> serves_priv_file( 69 70 _, 71 + global_context:, 70 72 application: "lumina_server", 71 73 path: "/static/lumina.svg", 72 74 mime: "image/svg+xml; charset=utf-8", 73 75 ) 74 76 http.Get, ["static", "lumina", "lumina.min.css"] -> serves_priv_file( 75 77 _, 78 + global_context:, 76 79 application: "lumina_server", 77 80 path: "/lumina.min.css", 78 81 mime: "text/css; charset=utf-8", 79 82 ) 80 83 http.Get, ["static", "lumina", "lumina.css"] -> serves_priv_file( 81 84 _, 85 + global_context:, 82 86 application: "lumina_server", 83 87 path: "/lumina.css", 84 88 mime: "text/css; charset=utf-8", 85 89 ) 86 90 http.Get, ["static", "lumina", "client.min.js"] -> serves_priv_file( 87 91 _, 92 + global_context:, 88 93 application: "lumina_server", 89 94 path: "/client.min.js", 90 95 mime: "application/javascript; charset=utf-8", 91 96 ) 92 97 http.Get, ["static", "lumina", "client.js"] -> serves_priv_file( 93 98 _, 99 + global_context:, 94 100 application: "lumina_server", 95 101 path: "/client.js", 96 102 mime: "application/javascript; charset=utf-8", 97 103 ) 98 - http.Get, ["api", "3.1", "session", "auth-status"] -> api_auth_status 104 + http.Get, ["api", "3.1", "session", "auth-status"] -> api_auth_status( 105 + _, 106 + global_context, 107 + ) 99 108 http.Get, ["ws", "web", "login"] -> serve_component( 100 109 _, 101 - global, 110 + global_context, 102 111 components.login, 103 112 ) 104 113 // Legals ··· 109 118 | _, ["license"] 110 119 -> serves_priv_file( 111 120 _, 121 + global_context:, 112 122 application: "lumina_server", 113 123 path: "/licence", 114 124 mime: "text/plain", ··· 163 173 ) -> response.Response(ewe.ResponseBody), 164 174 ) -> response.Response(ewe.ResponseBody), 165 175 ) -> response.Response(ewe.ResponseBody) { 166 - use session_id <- with_session(request:) 176 + use session_id <- with_session(request:, global_context: global) 167 177 let consumption = 168 178 shared.ComponentInitialisation(session_id:, global_context: global) 169 179 component(consumption, fn(value, value_2, value_3) { ··· 173 183 174 184 fn api_auth_status( 175 185 request: request.Request(ewe.Connection), 186 + global_context: data.Globals, 176 187 ) -> response.Response(ewe.ResponseBody) { 177 - use session <- with_session(request:) 188 + use session <- with_session(request:, global_context:) 178 189 witness.this(logging.Info, "Request answered with hardcoded answer", [ 179 190 witness.int("HTTP CODE", 200), 180 191 ]) ··· 190 201 191 202 fn serves_priv_file( 192 203 request: Request, 204 + global_context global_context: data.Globals, 193 205 application application: String, 194 206 path path: String, 195 207 mime mime: String, 196 208 ) -> Response { 197 - use _ <- with_session(request:) 209 + use _ <- with_session(request:, global_context:) 198 210 use dir <- try_404(application.priv_directory(application)) 199 211 let resolved = absname_join(dir, string.remove_prefix(path, "/")) 200 212 case string.starts_with(resolved, dir <> "/") { ··· 222 234 |> response.set_body(ewe.TextData("Could not find that!")) 223 235 } 224 236 225 - fn serves_spa(request: Request, session_store: SessionsStore) -> Response { 226 - use session_id <- with_session(request:) 237 + fn serves_spa( 238 + request: Request, 239 + global_context global_context: data.Globals, 240 + ) -> Response { 241 + let session_store = global_context.sessions 242 + use session_id <- with_session(request:, global_context:) 227 243 let csrf_token = csrf_token(session_id, session_store) 228 244 let html = 229 245 html.html([attribute.lang("en")], [ ··· 403 419 404 420 // Helpers ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 405 421 406 - fn with_session(then: fn(String) -> Response, request req: Request) { 422 + fn with_session( 423 + then: fn(String) -> Response, 424 + request req: Request, 425 + global_context globals: data.Globals, 426 + ) { 407 427 let session = 408 428 request.get_cookies(req) 409 429 |> list.key_find("session-set") 410 - |> result.lazy_unwrap(uuid.v4_string) 411 - witness.add_process_fields([witness.string("session cookie", session)]) 412 - then(session) 430 + |> result.map(fn(unverified) { 431 + case 432 + crypto.verify_signed_message(unverified, globals.secrets.cookie_secret) 433 + { 434 + Ok(verified) -> uuid.from_bit_array(verified) 435 + Error(Nil) -> { 436 + witness.this( 437 + logging.Warning, 438 + "Tampered session cookie found, new session is generated.", 439 + [], 440 + ) 441 + uuid.v4() 442 + |> Ok 443 + } 444 + } 445 + }) 446 + |> result.flatten() 447 + |> result.lazy_unwrap(fn() { 448 + witness.this( 449 + logging.Error, 450 + "Could not decode session cookie, new session is generated.", 451 + [], 452 + ) 453 + uuid.v4() 454 + }) 455 + let session_stringified = session |> uuid.to_string 456 + witness.add_process_fields([ 457 + witness.string("session cookie", session_stringified), 458 + ]) 459 + then(session_stringified) 413 460 |> response.set_cookie( 414 461 "session-set", 415 - session, 462 + crypto.sign_message( 463 + session |> uuid.to_bit_array, 464 + globals.secrets.cookie_secret, 465 + crypto.Sha512, 466 + ), 416 467 cookie.Attributes( 417 468 ..cookie.defaults(req.scheme), 418 469 http_only: True,