Compare commits

...

2 commits

Author SHA1 Message Date
Lett Osprey
20f37abbfd More fixes 2026-04-15 21:02:13 +02:00
Lett Osprey
c8300f5978 extract secrets to env, use docker compose 2026-04-15 20:04:56 +02:00
17 changed files with 220 additions and 143 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@
**/dist **/dist
/server/priv/static/client.js /server/priv/static/client.js
/server/priv/static/index.html /server/priv/static/index.html
/quizterm.env

View file

@ -8,6 +8,8 @@ COPY ./server/src /quizterm/server/src
COPY ./server/gleam.toml /quizterm/server/ COPY ./server/gleam.toml /quizterm/server/
COPY ./client/src /quizterm/client/src COPY ./client/src /quizterm/client/src
COPY ./client/gleam.toml /quizterm/client/ 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 RUN cd /quizterm/server && gleam deps download

View file

@ -1,6 +1,6 @@
MIT License 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: 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:

View file

@ -1,5 +1,61 @@
### Welcome to QUIZTerm ### Welcome to QUIZTerm
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 build and start write
```
docker compose up
```
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
```
docker compose down
```
#### Running the init script
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
QUIZTerm is a simple online "quiz answering" game. It provides a way for contestants to provide answers to questions, 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. and reveal the answers for everyone at the same time.
@ -21,26 +77,3 @@ Endpoints explained
|--------------------------|--------------------------| |--------------------------|--------------------------|
| ![Screenshot](game1.png) | ![Screenshot](game2.png) | | ![Screenshot](game1.png) | ![Screenshot](game2.png) |
### 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:
```
docker build . -t quizterm:1
```
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.
Start server on port 4321:
```
docker run -p 4321:1234 quizterm:1
```
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.
Open web browser and access http://localhost:4321

View file

@ -1,5 +1,5 @@
#!/bin/sh #!/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 export URL=http://localhost:1234
echo $URL echo $URL

View file

@ -75,10 +75,6 @@ fn view_join_live(room: String, pin: String) -> Element(Msg) {
[server_component.route("/socket/live/" <> room <> "/" <> pin)], [server_component.route("/socket/live/" <> room <> "/" <> pin)],
[], [],
), ),
server_component.element(
[server_component.route("/socket/control/" <> room <> "/" <> pin)],
[],
),
]) ])
} }

8
docker-compose.yml Normal file
View file

@ -0,0 +1,8 @@
services:
quizterm:
image: quizterm2
build: .
ports:
- "1234:1234"
env_file:
- quizterm.env

2
quizterm.env.example Normal file
View file

@ -0,0 +1,2 @@
SHAED_API_KEY=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
QTERM_SECRET=AVerySecretSentence

View file

@ -12,4 +12,5 @@ lustre = ">= 5.3.5 and < 6.0.0"
gleam_crypto = ">= 1.5.1 and < 2.0.0" gleam_crypto = ">= 1.5.1 and < 2.0.0"
group_registry = ">= 1.0.0 and < 2.0.0" group_registry = ">= 1.0.0 and < 2.0.0"
wisp = ">= 2.2.1 and < 3.0.0" wisp = ">= 2.2.1 and < 3.0.0"
envoy = ">= 1.1.0 and < 2.0.0"
shared = { path = "../shared" } shared = { path = "../shared" }

View file

@ -28,6 +28,7 @@ packages = [
] ]
[requirements] [requirements]
envoy = { version = ">= 1.1.0 and < 2.0.0" }
gleam_crypto = { version = ">= 1.5.1 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_erlang = { version = ">= 1.0.0 and < 2.0.0" }
gleam_http = { version = ">= 3.7.2 and < 5.0.0" } gleam_http = { version = ">= 3.7.2 and < 5.0.0" }

View file

@ -17,7 +17,7 @@ type State {
State( State(
question_number: Int, question_number: Int,
// id, (name (question#, answer_attempt) // 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. // int in #pair: ping counted since response back.
name_answers: List(#(String, #(Int, AnswerStatus))), name_answers: List(#(String, #(Int, AnswerStatus))),
hide_answers: Bool, hide_answers: Bool,
@ -71,16 +71,16 @@ pub fn initialize(
GiveSingleAnswer(id, question, answer) -> { GiveSingleAnswer(id, question, answer) -> {
State( State(
..state, ..state,
slow_answers: case list.key_find(state.slow_answers, id) { single_answers: case list.key_find(state.single_answers, id) {
Ok(value) -> { Ok(value) -> {
let #(name, list) = value let #(name, list) = value
list.key_set(state.slow_answers, id, #( list.key_set(state.single_answers, id, #(
name, name,
list.key_set(list, question, answer), list.key_set(list, question, answer),
)) ))
} }
Error(_) -> { 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 // Switch from "Wait for next question" to "Answer next question" mode
AnswerQuiz -> answer_quiz(state, registry) AnswerQuiz -> answer_quiz(state, registry)
message.FetchPlayers(subject:) -> { message.FetchPlayers(subject:) -> {
fetch_players(state.slow_answers, subject) fetch_players(state.single_answers, subject)
state state
} }
message.AddPlayer(name) -> 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() |> actor.continue()
}) })
@ -241,7 +241,7 @@ fn combine_lists(state: State) {
}), }),
// Second list require a bit more work Iterate over each payers answers, // Second list require a bit more work Iterate over each payers answers,
// creating user objects where question number match current question number. // 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 let #(_, #(name, answers)) = name_answers
list.filter_map(answers, fn(number_answer) { list.filter_map(answers, fn(number_answer) {
let #(answer_number, answer) = number_answer let #(answer_number, answer) = number_answer

View file

@ -34,7 +34,7 @@ pub fn serve(
} }
} }
pub fn serve_slow( pub fn serve_single(
request: Request(Connection), request: Request(Connection),
component: lustre.App( component: lustre.App(
#(List(#(String, String)), message.ClientsServer), #(List(#(String, String)), message.ClientsServer),

View file

@ -18,10 +18,14 @@ import web/components/control
import web/router import web/router
import wisp import wisp
import wisp/wisp_mist import wisp/wisp_mist
import envoy
pub fn main() { pub fn main() {
wisp.configure_logger() 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(state_handler) = statehandler.initialize()
let assert Ok(room_handler) = roomhandler.initialize(state_handler) let assert Ok(room_handler) = roomhandler.initialize(state_handler)
@ -48,7 +52,7 @@ pub fn main() {
room_handler, room_handler,
) )
["socket", "single", id, pin] -> ["socket", "single", id, pin] ->
sockethandler.serve_slow( sockethandler.serve_single(
req, req,
answerlist.component(), answerlist.component(),
id, id,
@ -58,8 +62,8 @@ pub fn main() {
) )
_ -> _ ->
wisp_mist.handler( wisp_mist.handler(
router.handle_request(room_handler, state_handler, _), router.handle_request(sha_api_key, room_handler, state_handler, _),
"very_secret", secret,
)(req) )(req)
} }
} }

View file

@ -190,14 +190,18 @@ fn view(model: Model) -> Element(Msg) {
]) ])
} }
}, },
case model.state {
Answer(_) | WaitForQuiz(_) ->
element.fragment([
html.div([class("terminal-section")], case lobby { html.div([class("terminal-section")], case lobby {
[] -> [] [] -> []
lobby -> { lobby -> {
let answered = let answered =
list.filter(lobby, fn(x) { list.filter(lobby, fn(x) {
case x.answer { case x.answer {
message.IDontKnow | message.HasAnswered | message.GivenAnswer(_) -> message.IDontKnow
True | message.HasAnswered
| message.GivenAnswer(_) -> True
_ -> False _ -> False
} }
}) })
@ -267,6 +271,13 @@ fn view(model: Model) -> Element(Msg) {
} }
}, },
), ),
server_component.element(
[server_component.route("/socket/control/TMA/PINA")],
[],
),
])
_ -> element.none()
},
]) ])
} }

View file

@ -110,6 +110,7 @@ pub fn view_players(
[click_cell_pair(Some("ENTER NEW PLAYER"), None, True, handler)], [click_cell_pair(Some("ENTER NEW PLAYER"), None, True, handler)],
), ),
), ),
]) ])
} }

View file

@ -1,16 +1,19 @@
import gleam/bit_array import gleam/bit_array
import gleam/crypto import gleam/crypto
import gleam/dynamic
import gleam/dynamic/decode import gleam/dynamic/decode
import gleam/erlang/process.{type Subject} import gleam/erlang/process.{type Subject}
import gleam/http import gleam/http
import gleam/int import gleam/int
import gleam/list import gleam/list
import gleam/otp/actor.{type Started} import gleam/otp/actor.{type Started}
import gleam/string
import shared/message.{type RoomControl, type StateControl} import shared/message.{type RoomControl, type StateControl}
import web/handlers/serve.{html_404} import web/handlers/serve.{html_404}
import wisp.{type Request, type Response} import wisp.{type Request, type Response}
pub fn handle_request( pub fn handle_request(
sha_api_key: String,
room_handler: Started(Subject(RoomControl)), room_handler: Started(Subject(RoomControl)),
state_handler: Started(Subject(StateControl)), state_handler: Started(Subject(StateControl)),
req: Request, req: Request,
@ -18,24 +21,28 @@ pub fn handle_request(
use req <- middleware(req) use req <- middleware(req)
case wisp.path_segments(req) { case wisp.path_segments(req) {
[] | ["index.html"] -> serve.main_html(fetch_rooms(room_handler)) [] | ["index.html"] -> serve.main_html(fetch_rooms(room_handler))
["api", "room"] -> handle_room(room_handler, req) ["api", ..path] ->
["api", ..path] -> handle_admin_api(state_handler, req, path) handle_api(sha_api_key, room_handler, state_handler, req, path)
_ -> html_404() _ -> html_404()
} }
} }
fn handle_room(room_handler: Started(Subject(RoomControl)), req: Request) { fn handle_room(
use json <- wisp.require_json(req) room_handler: Started(Subject(RoomControl)),
req: Request,
json: dynamic.Dynamic,
) {
case req.method { case req.method {
http.Post -> add_room(room_handler, json) http.Post -> add_room(room_handler, json)
_ -> #(404, "bad api path", "Resource not found") _ -> #(404, "bad api path", "Resource not found")
} }
|> serve.create_json_response
} }
fn handle_admin_api( fn handle_api(
actor: Started(Subject(StateControl)), sha_api_key: String,
room_handler: Started(Subject(RoomControl)),
state_handler: Started(Subject(StateControl)),
req: Request, req: Request,
path: List(String), path: List(String),
) { ) {
@ -43,18 +50,23 @@ fn handle_admin_api(
case list.key_find(req.headers, "x-api-key") { case list.key_find(req.headers, "x-api-key") {
Ok(key) -> { Ok(key) -> {
echo "key" <> key
echo "enc key "
<> string.lowercase(
bit_array.base16_encode(crypto.hash(crypto.Sha256, <<key:utf8>>)),
)
echo "sha" <> sha_api_key
case case
bit_array.base64_encode(crypto.hash(crypto.Sha256, <<key:utf8>>), True) string.lowercase(
== "1nIr1fQzs0K9UZAeUcG/67n12iRiviIS6gO5WXyI2+0=" bit_array.base16_encode(crypto.hash(crypto.Sha256, <<key:utf8>>)),
)
== string.lowercase(sha_api_key)
{ {
True -> True ->
case req.method, path { case path {
http.Post, ["info"] -> decode_info(actor, json) ["room"] -> handle_room(room_handler, req, json)
http.Post, ["questions"] -> [..path] -> handle_admin_api(state_handler, req, path, json)
decode_index_to_text(actor, json, message.SetQuestion) _ -> #(404, "bad api path", "Resource not found")
http.Post, ["answers"] ->
decode_index_to_text(actor, json, message.SetAnswer)
_, _ -> #(404, "bad api path", "Resource not found")
} }
False -> { False -> {
#(401, "invalid api key", "unauthorized") #(401, "invalid api key", "unauthorized")
@ -68,6 +80,22 @@ fn handle_admin_api(
|> serve.create_json_response |> serve.create_json_response
} }
fn handle_admin_api(
actor: Started(Subject(StateControl)),
req: Request,
path: List(String),
json: dynamic.Dynamic,
) {
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")
}
}
fn fetch_rooms( fn fetch_rooms(
room_handler: Started(Subject(RoomControl)), room_handler: Started(Subject(RoomControl)),
) -> List(#(String, message.RoomInfo)) { ) -> List(#(String, message.RoomInfo)) {

View file

@ -1,17 +1,6 @@
name = "shared" name = "shared"
version = "1.0.0" 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] [dependencies]
gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0"
gleam_json = ">= 3.1.0 and < 4.0.0" gleam_json = ">= 3.1.0 and < 4.0.0"