extract secrets to env, use docker compose

This commit is contained in:
Lett Osprey 2026-04-15 20:04:56 +02:00
parent 14ba148284
commit c8300f5978
16 changed files with 186 additions and 133 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@
**/dist
/server/priv/static/client.js
/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 ./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

View file

@ -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:

View file

@ -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/<room_id> | Create room with given room_id (max 200 rooms) |
| /board/<room_id> | Join a game with the given room_id |
| /board/<room_id>/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/<room_id> | Create room with given room_id (max 200 rooms) |
| /board/<room_id> | Join a game with the given room_id |
| /board/<room_id>/control | Join a game with the given room_id with more control options |
| Ingame example | Idle player |
|--------------------------|--------------------------|
| ![Screenshot](game1.png) | ![Screenshot](game2.png) |

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.element(
[server_component.route("/socket/control/" <> room <> "/" <> pin)],
[],
),
])
}

8
docker-compose.yml Normal file
View file

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

2
quizterm.env.example Normal file
View file

@ -0,0 +1,2 @@
SHAED_API_KEY=d6722bd5f433b342bd51901e51c1bfebb9f5da2462be2212ea03b9597c88dbed
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"
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" }

View file

@ -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" }

View file

@ -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

View file

@ -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),

View file

@ -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)
}
}

View file

@ -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()
},
])
}

View file

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

View file

@ -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, <<key:utf8>>))
== 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(

View file

@ -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"