From c8300f597835b6eb68d4460109f4213ec0eb4f28 Mon Sep 17 00:00:00 2001 From: Lett Osprey Date: Wed, 15 Apr 2026 20:04:56 +0200 Subject: [PATCH 1/2] extract secrets to env, use docker compose --- .gitignore | 1 + Dockerfile | 2 + LICENSE | 2 +- README.md | 48 ++++---- client/src/view.gleam | 4 - docker-compose.yml | 8 ++ quizterm.env.example | 2 + server/gleam.toml | 1 + server/manifest.toml | 1 + server/src/backend/playerhandler.gleam | 14 +-- server/src/backend/sockethandler.gleam | 2 +- server/src/quizterm.gleam | 10 +- server/src/web/components/card.gleam | 161 +++++++++++++------------ server/src/web/components/shared.gleam | 1 + server/src/web/router.gleam | 51 ++++++-- shared/gleam.toml | 11 -- 16 files changed, 186 insertions(+), 133 deletions(-) create mode 100644 docker-compose.yml create mode 100644 quizterm.env.example diff --git a/.gitignore b/.gitignore index 4b6b3fa..ef63070 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ **/dist /server/priv/static/client.js /server/priv/static/index.html +/quizterm.env diff --git a/Dockerfile b/Dockerfile index 4486743..25066e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,8 @@ COPY ./server/src /quizterm/server/src COPY ./server/gleam.toml /quizterm/server/ COPY ./client/src /quizterm/client/src COPY ./client/gleam.toml /quizterm/client/ +COPY ./shared/src /quizterm/shared/src +COPY ./shared/gleam.toml /quizterm/shared/gleam.toml RUN cd /quizterm/server && gleam deps download diff --git a/LICENSE b/LICENSE index 5fc86da..6d251ee 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 lettosprey +Copyright (c) 2026 Steinar Eliassen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index 4b8fe41..515af29 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,5 @@ ### Welcome to QUIZTerm - -QUIZTerm is a simple online "quiz answering" game. It provides a way for contestants to provide answers to questions, -and reveal the answers for everyone at the same time. - -Cards showing who are playing, their answer status (have they answered or not?), and when revealed, what their answer -was, will show up on everyones screen. - -Not quite finished yet, it is at a point where it is "usable" enough. - -Endpoints explained - -| Endpoint | Usage | -|--------------------------|--------------------------------------------------------------| -| /room/ | Create room with given room_id (max 200 rooms) | -| /board/ | Join a game with the given room_id | -| /board//control | Join a game with the given room_id with more control options | - - -| Ingame example | Idle player | -|--------------------------|--------------------------| -| ![Screenshot](game1.png) | ![Screenshot](game2.png) | - -### Building and running +## Building and running Docker, or a compatible container manager, like podman, is required to build and run quizterm. The alternative is to install Gleam and Erlang/BEAM and run it dockerless. @@ -44,3 +22,27 @@ is the port exposed outside the container. The latter can be set to whatever port you want to use. Open web browser and access http://localhost:4321 + +## The rest of this readme is currently outdated and will be updated shortly + +QUIZTerm is a simple online "quiz answering" game. It provides a way for contestants to provide answers to questions, +and reveal the answers for everyone at the same time. + +Cards showing who are playing, their answer status (have they answered or not?), and when revealed, what their answer +was, will show up on everyones screen. + +Not quite finished yet, it is at a point where it is "usable" enough. + +Endpoints explained + +| Endpoint | Usage | +|--------------------------|--------------------------------------------------------------| +| /room/ | Create room with given room_id (max 200 rooms) | +| /board/ | Join a game with the given room_id | +| /board//control | Join a game with the given room_id with more control options | + + +| Ingame example | Idle player | +|--------------------------|--------------------------| +| ![Screenshot](game1.png) | ![Screenshot](game2.png) | + diff --git a/client/src/view.gleam b/client/src/view.gleam index 44567c8..8f24fdc 100644 --- a/client/src/view.gleam +++ b/client/src/view.gleam @@ -75,10 +75,6 @@ fn view_join_live(room: String, pin: String) -> Element(Msg) { [server_component.route("/socket/live/" <> room <> "/" <> pin)], [], ), - server_component.element( - [server_component.route("/socket/control/" <> room <> "/" <> pin)], - [], - ), ]) } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d363f8d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + quizterm: + image: quizterm + build: . + ports: + - "1234:1234" + env_file: + - quizterm.env diff --git a/quizterm.env.example b/quizterm.env.example new file mode 100644 index 0000000..9d58eb5 --- /dev/null +++ b/quizterm.env.example @@ -0,0 +1,2 @@ +SHAED_API_KEY=d6722bd5f433b342bd51901e51c1bfebb9f5da2462be2212ea03b9597c88dbed +QTERM_SECRET=AVerySecretSentence diff --git a/server/gleam.toml b/server/gleam.toml index 3ffb6ca..a884fd8 100644 --- a/server/gleam.toml +++ b/server/gleam.toml @@ -12,4 +12,5 @@ lustre = ">= 5.3.5 and < 6.0.0" gleam_crypto = ">= 1.5.1 and < 2.0.0" group_registry = ">= 1.0.0 and < 2.0.0" wisp = ">= 2.2.1 and < 3.0.0" +envoy = ">= 1.1.0 and < 2.0.0" shared = { path = "../shared" } diff --git a/server/manifest.toml b/server/manifest.toml index cfbf792..2d98908 100644 --- a/server/manifest.toml +++ b/server/manifest.toml @@ -28,6 +28,7 @@ packages = [ ] [requirements] +envoy = { version = ">= 1.1.0 and < 2.0.0" } gleam_crypto = { version = ">= 1.5.1 and < 2.0.0" } gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } gleam_http = { version = ">= 3.7.2 and < 5.0.0" } diff --git a/server/src/backend/playerhandler.gleam b/server/src/backend/playerhandler.gleam index 10f51cf..b26e5af 100644 --- a/server/src/backend/playerhandler.gleam +++ b/server/src/backend/playerhandler.gleam @@ -17,7 +17,7 @@ type State { State( question_number: Int, // id, (name (question#, answer_attempt) - slow_answers: List(#(String, #(String, List(#(String, String))))), + single_answers: List(#(String, #(String, List(#(String, String))))), // int in #pair: ping counted since response back. name_answers: List(#(String, #(Int, AnswerStatus))), hide_answers: Bool, @@ -71,16 +71,16 @@ pub fn initialize( GiveSingleAnswer(id, question, answer) -> { State( ..state, - slow_answers: case list.key_find(state.slow_answers, id) { + single_answers: case list.key_find(state.single_answers, id) { Ok(value) -> { let #(name, list) = value - list.key_set(state.slow_answers, id, #( + list.key_set(state.single_answers, id, #( name, list.key_set(list, question, answer), )) } Error(_) -> { - state.slow_answers + state.single_answers } }, ) @@ -91,11 +91,11 @@ pub fn initialize( // Switch from "Wait for next question" to "Answer next question" mode AnswerQuiz -> answer_quiz(state, registry) message.FetchPlayers(subject:) -> { - fetch_players(state.slow_answers, subject) + fetch_players(state.single_answers, subject) state } message.AddPlayer(name) -> - State(..state, slow_answers: add_player(name, state.slow_answers)) + State(..state, single_answers: add_player(name, state.single_answers)) } |> actor.continue() }) @@ -241,7 +241,7 @@ fn combine_lists(state: State) { }), // Second list require a bit more work Iterate over each payers answers, // creating user objects where question number match current question number. - list.flat_map(state.slow_answers, fn(name_answers) { + list.flat_map(state.single_answers, fn(name_answers) { let #(_, #(name, answers)) = name_answers list.filter_map(answers, fn(number_answer) { let #(answer_number, answer) = number_answer diff --git a/server/src/backend/sockethandler.gleam b/server/src/backend/sockethandler.gleam index f4ad8d4..c37856d 100644 --- a/server/src/backend/sockethandler.gleam +++ b/server/src/backend/sockethandler.gleam @@ -34,7 +34,7 @@ pub fn serve( } } -pub fn serve_slow( +pub fn serve_single( request: Request(Connection), component: lustre.App( #(List(#(String, String)), message.ClientsServer), diff --git a/server/src/quizterm.gleam b/server/src/quizterm.gleam index 62a25c9..0964974 100644 --- a/server/src/quizterm.gleam +++ b/server/src/quizterm.gleam @@ -18,10 +18,14 @@ import web/components/control import web/router import wisp import wisp/wisp_mist +import envoy pub fn main() { wisp.configure_logger() + let assert Ok(sha_api_key) = envoy.get("SHAED_API_KEY") + let assert Ok(secret) = envoy.get("QTERM_SECRET") + let assert Ok(state_handler) = statehandler.initialize() let assert Ok(room_handler) = roomhandler.initialize(state_handler) @@ -48,7 +52,7 @@ pub fn main() { room_handler, ) ["socket", "single", id, pin] -> - sockethandler.serve_slow( + sockethandler.serve_single( req, answerlist.component(), id, @@ -58,8 +62,8 @@ pub fn main() { ) _ -> wisp_mist.handler( - router.handle_request(room_handler, state_handler, _), - "very_secret", + router.handle_request(sha_api_key, room_handler, state_handler, _), + secret, )(req) } } diff --git a/server/src/web/components/card.gleam b/server/src/web/components/card.gleam index a152783..6dddc23 100644 --- a/server/src/web/components/card.gleam +++ b/server/src/web/components/card.gleam @@ -190,83 +190,94 @@ fn view(model: Model) -> Element(Msg) { ]) } }, - html.div([class("terminal-section")], case lobby { - [] -> [] - lobby -> { - let answered = - list.filter(lobby, fn(x) { - case x.answer { - message.IDontKnow | message.HasAnswered | message.GivenAnswer(_) -> - True - _ -> False + case model.state { + Answer(_) | WaitForQuiz(_) -> + element.fragment([ + html.div([class("terminal-section")], case lobby { + [] -> [] + lobby -> { + let answered = + list.filter(lobby, fn(x) { + case x.answer { + message.IDontKnow + | message.HasAnswered + | message.GivenAnswer(_) -> True + _ -> False + } + }) + |> list.length + |> int.to_string + let size = lobby |> list.length |> int.to_string + [ + html.div([attribute.class("terminal-box")], [ + html.span([attribute.class("terminal-label")], [ + html.text("[PROGRESS] "), + ]), + html.text("Answered: "), + case answered == size { + True -> html.text("Everyone!") + False -> html.text(answered <> "/" <> size) + }, + ]), + ] } - }) - |> list.length - |> int.to_string - let size = lobby |> list.length |> int.to_string - [ - html.div([attribute.class("terminal-box")], [ - html.span([attribute.class("terminal-label")], [ - html.text("[PROGRESS] "), - ]), - html.text("Answered: "), - case answered == size { - True -> html.text("Everyone!") - False -> html.text(answered <> "/" <> size) + }), + terminal_section( + lobby, + "[ACTIVE TRANSMISSIONS]", + fn(x) { + case x.answer { + message.GivenAnswer(_) | message.HasAnswered -> True + _ -> False + } }, - ]), - ] - } - }), - terminal_section( - lobby, - "[ACTIVE TRANSMISSIONS]", - fn(x) { - case x.answer { - message.GivenAnswer(_) | message.HasAnswered -> True - _ -> False - } - }, - fn(user) { - let User(name, ping_time, answer) = user - case answer { - message.GivenAnswer(answer) -> answer - message.HasAnswered -> "Answer Given" - _ -> "Odd State..." - } - |> content_cell(name, ping_time, _) - }, - ), - terminal_section( - lobby, - "[P A S S]", - fn(x) { - case x.answer { - message.IDontKnow -> True - _ -> False - } - }, - fn(user) { - let User(name, ping_time, _) = user - content_cell(name, ping_time, "P.A.S.S :(") - }, - ), - terminal_section( - lobby, - "[AWAITING RESPONSE]", - fn(x) { - case x.answer { - message.NotAnswered -> True - _ -> False - } - }, - fn(user) { - case user { - User(name, ping_time, _) -> - content_cell(name, ping_time, "Not Answered") - } - }, - ), + fn(user) { + let User(name, ping_time, answer) = user + case answer { + message.GivenAnswer(answer) -> answer + message.HasAnswered -> "Answer Given" + _ -> "Odd State..." + } + |> content_cell(name, ping_time, _) + }, + ), + terminal_section( + lobby, + "[P A S S]", + fn(x) { + case x.answer { + message.IDontKnow -> True + _ -> False + } + }, + fn(user) { + let User(name, ping_time, _) = user + content_cell(name, ping_time, "P.A.S.S :(") + }, + ), + terminal_section( + lobby, + "[AWAITING RESPONSE]", + fn(x) { + case x.answer { + message.NotAnswered -> True + _ -> False + } + }, + fn(user) { + case user { + User(name, ping_time, _) -> + content_cell(name, ping_time, "Not Answered") + } + }, + ), + server_component.element( + [server_component.route("/socket/control/TMA/PINA")], + [], + ), + ]) + _ -> element.none() + }, ]) } diff --git a/server/src/web/components/shared.gleam b/server/src/web/components/shared.gleam index d6a3206..417b6a8 100644 --- a/server/src/web/components/shared.gleam +++ b/server/src/web/components/shared.gleam @@ -110,6 +110,7 @@ pub fn view_players( [click_cell_pair(Some("ENTER NEW PLAYER"), None, True, handler)], ), ), + ]) } diff --git a/server/src/web/router.gleam b/server/src/web/router.gleam index fee332c..95beb51 100644 --- a/server/src/web/router.gleam +++ b/server/src/web/router.gleam @@ -1,5 +1,6 @@ import gleam/bit_array import gleam/crypto +import gleam/dynamic import gleam/dynamic/decode import gleam/erlang/process.{type Subject} import gleam/http @@ -11,6 +12,7 @@ import web/handlers/serve.{html_404} import wisp.{type Request, type Response} pub fn handle_request( + sha_api_key: String, room_handler: Started(Subject(RoomControl)), state_handler: Started(Subject(StateControl)), req: Request, @@ -18,19 +20,54 @@ pub fn handle_request( use req <- middleware(req) case wisp.path_segments(req) { [] | ["index.html"] -> serve.main_html(fetch_rooms(room_handler)) - ["api", "room"] -> handle_room(room_handler, req) - ["api", ..path] -> handle_admin_api(state_handler, req, path) + ["api", ..path] -> + handle_api(sha_api_key, room_handler, state_handler, req, path) + _ -> html_404() } } -fn handle_room(room_handler: Started(Subject(RoomControl)), req: Request) { - use json <- wisp.require_json(req) - +fn handle_room( + room_handler: Started(Subject(RoomControl)), + req: Request, + json: dynamic.Dynamic, +) { case req.method { http.Post -> add_room(room_handler, json) _ -> #(404, "bad api path", "Resource not found") } +} + +fn handle_api( + sha_api_key: String, + room_handler: Started(Subject(RoomControl)), + state_handler: Started(Subject(StateControl)), + req: Request, + path: List(String), +) { + use json <- wisp.require_json(req) + + case list.key_find(req.headers, "x-api-key") { + Ok(key) -> { + case + bit_array.base16_encode(crypto.hash(crypto.Sha256, <>)) + == sha_api_key + { + True -> + case path { + ["api", "room"] -> handle_room(room_handler, req, json) + ["api", ..path] -> handle_admin_api(state_handler, req, path, json) + _ -> #(404, "bad api path", "Resource not found") + } + False -> { + #(401, "invalid api key", "unauthorized") + } + } + } + Error(_) -> { + #(401, "missing api key", "unauthorized") + } + } |> serve.create_json_response } @@ -38,9 +75,8 @@ fn handle_admin_api( actor: Started(Subject(StateControl)), req: Request, path: List(String), + json: dynamic.Dynamic, ) { - use json <- wisp.require_json(req) - case list.key_find(req.headers, "x-api-key") { Ok(key) -> { case @@ -65,7 +101,6 @@ fn handle_admin_api( #(401, "missing api key", "unauthorized") } } - |> serve.create_json_response } fn fetch_rooms( diff --git a/shared/gleam.toml b/shared/gleam.toml index 018aa07..4464dd1 100644 --- a/shared/gleam.toml +++ b/shared/gleam.toml @@ -1,17 +1,6 @@ name = "shared" version = "1.0.0" -# Fill out these fields if you intend to generate HTML documentation or publish -# your project to the Hex package manager. -# -# description = "" -# licences = ["Apache-2.0"] -# repository = { type = "github", user = "", repo = "" } -# links = [{ title = "Website", href = "" }] -# -# For a full reference of all the available options, you can have a look at -# https://gleam.run/writing-gleam/gleam-toml/. - [dependencies] gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_json = ">= 3.1.0 and < 4.0.0" From 20f37abbfde536f958f5841ade901628d276a0aa Mon Sep 17 00:00:00 2001 From: Lett Osprey Date: Wed, 15 Apr 2026 21:02:13 +0200 Subject: [PATCH 2/2] More fixes --- README.md | 53 +++++++++++++++++++++++++++++-------- api-test/init.sh | 2 +- docker-compose.yml | 2 +- quizterm.env.example | 2 +- server/src/web/router.gleam | 47 ++++++++++++++------------------ 5 files changed, 65 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 515af29..76883a6 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,58 @@ ### Welcome to QUIZTerm -## Building and running + +This documentation is for building and running quizterm. You do not need to worry about this +document to be a user. + +#### Getting env variables ready + +An api-key, and a base16-encoded version of it is needed to communicate with the +endpoints. You can use the sha256 command from the "hashalot" bundle or similar. +``` +sha256 -x +Enter passphrase: +``` +The passphrase test will output +``` +9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 +``` + +This is the value provided in "quizterm.env.example". Feel free to use this for "local +testing", copy the file to "quizterm.env". For non-local testing, pick a better API key... + +The provided "init script" that sets up some "dummy examples" needs the non-hashed +version of the api-key, see the section "Running init script" after "Building and running" + +#### Building and running Docker, or a compatible container manager, like podman, is required to build and run quizterm. The alternative is to install Gleam and Erlang/BEAM and run it dockerless. Unless you plan to do Gleam development, using Docker will save a lot of hassle. -To compile project and build docker image, write: +To build and start write ``` -docker build . -t quizterm:1 +docker compose up ``` -quizterm can be whatever name you want to give the container, 1 can be -changed to whatever you want the version of the container to be. +You can now access quizterm on http://localhost:1234. If you need a different port, modify +docker-compose.yml, the number 1234 before the colon Note that it will always say +"listening on port 1234", this is the port used inside the docker image. + +Stop quizterm with -Start server on port 4321: ``` -docker run -p 4321:1234 quizterm:1 +docker compose down ``` -Port 1234 is the port used internally in the docker container, while 4321 -is the port exposed outside the container. The latter can be set to whatever -port you want to use. +#### Running the init script -Open web browser and access http://localhost:4321 +A provided init script sets up some bits for testing, it creates several "team rooms", +and generates questions and answers. + +If you used the "default" values in quizterm.env, the api-key "test" will work with the +init script. If not, edit the api-test/init.sh file and set correct api-key (non-hashed). + +``` +sh api-test/init.sh +``` ## The rest of this readme is currently outdated and will be updated shortly diff --git a/api-test/init.sh b/api-test/init.sh index f155b67..f0daa68 100644 --- a/api-test/init.sh +++ b/api-test/init.sh @@ -1,5 +1,5 @@ #!/bin/sh -export API_KEY="X-api-key: 66db96c6-97c9-419e-ac80-6cf920158844" +export API_KEY="X-api-key: test" export URL=http://localhost:1234 echo $URL diff --git a/docker-compose.yml b/docker-compose.yml index d363f8d..2fde096 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: quizterm: - image: quizterm + image: quizterm2 build: . ports: - "1234:1234" diff --git a/quizterm.env.example b/quizterm.env.example index 9d58eb5..509e4d5 100644 --- a/quizterm.env.example +++ b/quizterm.env.example @@ -1,2 +1,2 @@ -SHAED_API_KEY=d6722bd5f433b342bd51901e51c1bfebb9f5da2462be2212ea03b9597c88dbed +SHAED_API_KEY=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 QTERM_SECRET=AVerySecretSentence diff --git a/server/src/web/router.gleam b/server/src/web/router.gleam index 95beb51..7112706 100644 --- a/server/src/web/router.gleam +++ b/server/src/web/router.gleam @@ -7,6 +7,7 @@ import gleam/http import gleam/int import gleam/list import gleam/otp/actor.{type Started} +import gleam/string import shared/message.{type RoomControl, type StateControl} import web/handlers/serve.{html_404} import wisp.{type Request, type Response} @@ -49,14 +50,22 @@ fn handle_api( case list.key_find(req.headers, "x-api-key") { Ok(key) -> { + echo "key" <> key + echo "enc key " + <> string.lowercase( + bit_array.base16_encode(crypto.hash(crypto.Sha256, <>)), + ) + echo "sha" <> sha_api_key case - bit_array.base16_encode(crypto.hash(crypto.Sha256, <>)) - == sha_api_key + string.lowercase( + bit_array.base16_encode(crypto.hash(crypto.Sha256, <>)), + ) + == string.lowercase(sha_api_key) { True -> case path { - ["api", "room"] -> handle_room(room_handler, req, json) - ["api", ..path] -> handle_admin_api(state_handler, req, path, json) + ["room"] -> handle_room(room_handler, req, json) + [..path] -> handle_admin_api(state_handler, req, path, json) _ -> #(404, "bad api path", "Resource not found") } False -> { @@ -77,29 +86,13 @@ fn handle_admin_api( path: List(String), json: dynamic.Dynamic, ) { - case list.key_find(req.headers, "x-api-key") { - Ok(key) -> { - case - bit_array.base64_encode(crypto.hash(crypto.Sha256, <>), True) - == "1nIr1fQzs0K9UZAeUcG/67n12iRiviIS6gO5WXyI2+0=" - { - True -> - case req.method, path { - http.Post, ["info"] -> decode_info(actor, json) - http.Post, ["questions"] -> - decode_index_to_text(actor, json, message.SetQuestion) - http.Post, ["answers"] -> - decode_index_to_text(actor, json, message.SetAnswer) - _, _ -> #(404, "bad api path", "Resource not found") - } - False -> { - #(401, "invalid api key", "unauthorized") - } - } - } - Error(_) -> { - #(401, "missing api key", "unauthorized") - } + case req.method, path { + http.Post, ["info"] -> decode_info(actor, json) + http.Post, ["questions"] -> + decode_index_to_text(actor, json, message.SetQuestion) + http.Post, ["answers"] -> + decode_index_to_text(actor, json, message.SetAnswer) + _, _ -> #(404, "bad api path", "Resource not found") } }