Compare commits

...

10 Commits

Author SHA1 Message Date
Vojtěch Káně
699cbfa79e Use DynamicUser in the provided SystemD service
Some checks failed
CI / build (push) Has been cancelled
2021-11-19 13:39:16 +01:00
Vojtěch Káně
64126b1fc0 Merge pull request #1 from vojta001/socket
Support listening on a unix socket
2021-11-01 07:48:59 +01:00
Vojtěch Káně
04563d3c28 Support listening on a unix socket 2021-11-01 00:21:37 +01:00
Vojtěch Káně
08c6afeecd Add tooltips on homepage 2021-09-24 01:25:04 +02:00
Vojtěch Káně
46ccc879da Add help page explaining how to create a new quiz 2021-07-03 16:42:36 +02:00
Vojtěch Káně
b0e8a43331 Show user friendly error messages on homepage. 2021-07-03 16:22:17 +02:00
Vojtěch Káně
08b8aad09d Improve README.md 2021-07-03 15:20:48 +02:00
Vojtěch Káně
c121593938 Clarify homepage wording 2021-07-03 14:19:56 +02:00
Vojtěch Káně
51c559b7e0 Filter source in Nix build to prevent spurious rebuilds 2021-05-04 10:51:12 +02:00
Vojtěch Káně
f1ca80cbae Compare codes case insensitive 2021-05-03 17:50:54 +02:00
10 changed files with 268 additions and 48 deletions

View File

@@ -38,7 +38,7 @@ Few of them send back HTML. That's accomplished by filling in an intermediary st
### pkg/model ### pkg/model
Model hosts all business logic, that is, all actions the user can make regardless of the protocol of invocation. Each exported method represent a logical action on its own and must depend on the model struct and its arguments only. This is crucial for testing. Model hosts all business logic, that is, all actions the user can make regardless of the protocol of invocation. Each exported method represents a logical action on its own and must depend on the model struct and its arguments only. This is crucial for testing.
Except for edge cases, each method shall accept context.Context and use it to pass cancellation signals database calls etc. Except for edge cases, each method shall accept context.Context and use it to pass cancellation signals database calls etc.
@@ -56,10 +56,18 @@ Thanks to the strict separation of different parts of MVC, the model can be test
Brief end-to-end testing may be added later. Brief end-to-end testing may be added later.
### Security
Tinyquiz can hardly be viewed as critical application or as containing sensitive information, therefore certain trade-offs were accepted.
There is no classical authentication; your identity is determined by the id contained in URL. This is usually viewed as bad practice, because the token gets saved in your browsing history, but in Tinyquiz, it becomes effectively worthless as soon as the quiz ends. On the other hand, it enables you to play multiple games in different browser tabs at the same time and to reaload the tabs anytime without loosing state - all while keeping the implementation very simple.
Games and quizzes aren't protected by any password, just the code. It is pretty secure though, the code is made by concatenating a sequential part (to prevent collisions) and a random part (to make it difficult to guess).
Unlike the previous part, no trade-offs were accepted in the server security. Go is a GCed language doing its best to prevent memory corruption bugs. All database queries are assembled by passing the user supplied input separately thus preventing SQL injection. HTML output is handled by the well tested `html/template` standard library which automatically context-aware escapes included content thus preventing XSS.
## Building and running ## Building and running
*Go 1.16 is expected to be released in February 2021. It will bring static files embedding into the binary. Until it happens, the binary has to be run from the root of the project to be able to find the `ui` directory.* The reference way to build the app is in the `flake.nix`. For non-Nix users, building shall be pretty trivial though. Just obtain a new enough version of Go (see `go.mod`) and build the runnable package of your choice (usually `go build ./cmd/web`). You may have to run `go generate ./...` to build the ORM files etc.
The reference way to build the app is in the `flake.nix`. For non-Nix users, building shall be pretty trivial though. Just obtain a new enough version of Go (see `go.mod`) and build the runnable package of your choice (usually `go build ./cmd/web`).
Tinyquiz requires Postgresql, though other database systems might be added later thanks to ent. Postgresql configuration is currently hardcoded in the binary. Tinyquiz requires Postgresql, though other database systems might be added later thanks to ent. Postgresql configuration is currently hardcoded in the binary.

View File

@@ -14,12 +14,36 @@ import (
"vkane.cz/tinyquiz/pkg/rtcomm" "vkane.cz/tinyquiz/pkg/rtcomm"
) )
func (app *application) home(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func (app *application) homeSuccess(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
app.home(w, r, homeForm{}, http.StatusOK)
}
type homeForm struct {
Join struct {
Code string
Name string
Errors []string
}
NewSession struct {
Code string
Name string
Errors []string
}
NewGame struct {
Title string
Name string
Errors []string
}
}
func (app *application) home(w http.ResponseWriter, r *http.Request, formData homeForm, status int) {
type homeData struct { type homeData struct {
Stats model.Stats Stats model.Stats
Form homeForm
templateData templateData
} }
td := &homeData{} td := &homeData{}
td.Form = formData
setDefaultTemplateData(&td.templateData) setDefaultTemplateData(&td.templateData)
if stats, err := app.model.GetStats(r.Context()); err == nil { if stats, err := app.model.GetStats(r.Context()); err == nil {
@@ -28,10 +52,14 @@ func (app *application) home(w http.ResponseWriter, r *http.Request, params http
app.serverError(w, err) app.serverError(w, err)
return return
} }
w.WriteHeader(status)
app.render(w, r, "home.page.tmpl.html", td) app.render(w, r, "home.page.tmpl.html", td)
} }
func (app *application) help(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
app.render(w, r, "help.page.tmpl.html", nil)
}
func (app *application) play(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func (app *application) play(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
app.clientError(w, http.StatusBadRequest) app.clientError(w, http.StatusBadRequest)
@@ -40,9 +68,13 @@ func (app *application) play(w http.ResponseWriter, r *http.Request, params http
var code = strings.TrimSpace(params.ByName("code")) var code = strings.TrimSpace(params.ByName("code"))
var player = strings.ToLower(strings.TrimSpace(r.PostForm.Get("player"))) var player = strings.ToLower(strings.TrimSpace(r.PostForm.Get("player")))
var form homeForm
form.Join.Code = code
form.Join.Name = player
if len(player) < 1 { if len(player) < 1 {
app.clientError(w, http.StatusBadRequest) form.Join.Errors = []string{"Zadejte jméno hráče"}
app.home(w, r, form, http.StatusBadRequest)
return return
} }
@@ -61,10 +93,12 @@ func (app *application) play(w http.ResponseWriter, r *http.Request, params http
http.Redirect(w, r, "/game/"+url.PathEscape(player.ID.String()), http.StatusSeeOther) http.Redirect(w, r, "/game/"+url.PathEscape(player.ID.String()), http.StatusSeeOther)
return return
} else if errors.Is(err, model.NoSuchEntity) { } else if errors.Is(err, model.NoSuchEntity) {
app.clientError(w, http.StatusNotFound) form.Join.Errors = []string{"Hra s tímto kódem nebyla nalezena"}
app.home(w, r, form, http.StatusNotFound)
return return
} else if errors.Is(err, model.ConstraintViolation) { } else if errors.Is(err, model.ConstraintViolation) {
app.clientError(w, http.StatusForbidden) form.Join.Errors = []string{"Hráč s tímto jménem již existuje"}
app.home(w, r, form, http.StatusForbidden)
return return
} else { } else {
app.serverError(w, err) app.serverError(w, err)
@@ -81,9 +115,13 @@ func (app *application) createSession(w http.ResponseWriter, r *http.Request, pa
var code = strings.TrimSpace(r.PostForm.Get("code")) var code = strings.TrimSpace(r.PostForm.Get("code"))
var player = strings.ToLower(strings.TrimSpace(r.PostForm.Get("organiser"))) var player = strings.ToLower(strings.TrimSpace(r.PostForm.Get("organiser")))
var form homeForm
form.NewSession.Code = code
form.NewSession.Name = player
if len(player) < 1 { if len(player) < 1 {
app.clientError(w, http.StatusBadRequest) form.NewSession.Errors = []string{"Zadejte jméno organizátora"}
app.home(w, r, form, http.StatusBadRequest)
return return
} }
@@ -97,7 +135,8 @@ func (app *application) createSession(w http.ResponseWriter, r *http.Request, pa
return return
} }
} else if errors.Is(err, model.NoSuchEntity) { } else if errors.Is(err, model.NoSuchEntity) {
app.clientError(w, http.StatusNotFound) form.NewSession.Errors = []string{"Hra s tímto kódem nebyla nalezena"}
app.home(w, r, form, http.StatusNotFound)
return return
} else { } else {
app.serverError(w, err) app.serverError(w, err)
@@ -253,9 +292,13 @@ func (app *application) createGame(w http.ResponseWriter, r *http.Request, param
} }
var name = r.PostFormValue("name") var name = r.PostFormValue("name")
var author = r.PostFormValue("author") var author = r.PostFormValue("author")
var form homeForm
form.NewGame.Title = name
form.NewGame.Name = author
file, _, err := r.FormFile("game") file, _, err := r.FormFile("game")
if err != nil { if err != nil {
app.clientError(w, http.StatusBadRequest) form.NewGame.Errors = []string{"Nahrajte soubor s otázkami"}
app.home(w, r, form, http.StatusBadRequest)
return return
} }
@@ -267,11 +310,9 @@ func (app *application) createGame(w http.ResponseWriter, r *http.Request, param
app.serverError(w, err) app.serverError(w, err)
return return
} }
} else if errors.Is(err, gameCreator.ErrInvalidSyntax) || errors.Is(err, gameCreator.ErrTooManyQuestions) || errors.Is(err, gameCreator.ErrTooManyChoices) {
app.clientError(w, http.StatusBadRequest)
return
} else { } else {
app.clientError(w, http.StatusBadRequest) form.NewGame.Errors = []string{"Soubor s otázkami není v pořádku"}
app.home(w, r, form, http.StatusBadRequest)
return return
} }
} }

View File

@@ -5,9 +5,11 @@ import (
"html/template" "html/template"
"io/fs" "io/fs"
"log" "log"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"strings"
"time" "time"
"vkane.cz/tinyquiz/pkg/model" "vkane.cz/tinyquiz/pkg/model"
"vkane.cz/tinyquiz/pkg/model/ent" "vkane.cz/tinyquiz/pkg/model/ent"
@@ -35,8 +37,14 @@ func setDefaultTemplateData(td *templateData) {
func main() { func main() {
var addr string var addr string
var socket bool
if env, ok := os.LookupEnv("TINYQUIZ_LISTEN"); ok { if env, ok := os.LookupEnv("TINYQUIZ_LISTEN"); ok {
addr = env addr = env
const unixPrefix = "unix:"
if strings.HasPrefix(addr, unixPrefix) {
socket = true
addr = strings.TrimPrefix(addr, unixPrefix)
}
} else { } else {
addr = "[::1]:8080" addr = "[::1]:8080"
} }
@@ -101,7 +109,7 @@ func main() {
}() }()
mux := httprouter.New() mux := httprouter.New()
mux.GET("/", app.home) mux.GET("/", app.homeSuccess)
mux.POST("/play/:code", app.play) mux.POST("/play/:code", app.play)
mux.POST("/session", app.createSession) mux.POST("/session", app.createSession)
mux.GET("/game/:playerUid", app.game) mux.GET("/game/:playerUid", app.game)
@@ -111,6 +119,7 @@ func main() {
mux.GET("/template", app.downloadTemplate) mux.GET("/template", app.downloadTemplate)
mux.POST("/game", app.createGame) mux.POST("/game", app.createGame)
mux.GET("/quiz/:gameUid", app.showGame) mux.GET("/quiz/:gameUid", app.showGame)
mux.GET("/help", app.help)
mux.GET("/ws/:playerUid", app.processWebSocket) mux.GET("/ws/:playerUid", app.processWebSocket)
@@ -126,6 +135,13 @@ func main() {
Handler: mux, Handler: mux,
} }
log.Printf("Starting server on %s\n", addr) log.Printf("Starting server on %s\n", addr)
err := srv.ListenAndServe() if socket {
log.Fatal(err) if listener, err := net.Listen("unix", addr); err == nil {
log.Fatal(srv.Serve(listener))
} else {
errorLog.Fatal(err)
}
} else {
errorLog.Fatal(srv.ListenAndServe())
}
} }

17
flake.lock generated
View File

@@ -1,5 +1,21 @@
{ {
"nodes": { "nodes": {
"nix-filter": {
"locked": {
"lastModified": 1619956448,
"narHash": "sha256-4OZjkK1vot9vdg+8jj9+hm4c6mSEc9/OJDQzNvLHBKE=",
"owner": "numtide",
"repo": "nix-filter",
"rev": "cf06efe613a06f1505f187712a307c05b3901884",
"type": "github"
},
"original": {
"owner": "numtide",
"ref": "master",
"repo": "nix-filter",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1618886496, "lastModified": 1618886496,
@@ -18,6 +34,7 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"nix-filter": "nix-filter",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
} }

View File

@@ -1,16 +1,18 @@
{ {
description = "Tinyquiz an open source online quiz platform"; description = "Tinyquiz an open source online quiz platform";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.nix-filter.url = "github:numtide/nix-filter/master";
outputs = { self, nixpkgs }: outputs = { self, nixpkgs, nix-filter }:
let let
pkgs = nixpkgs.legacyPackages.x86_64-linux; pkgs = nixpkgs.legacyPackages.x86_64-linux;
buildCmd = name: buildCmd = name:
(pkgs.buildGoPackage { (pkgs.buildGoPackage {
pname = "tinyquiz-${name}"; pname = "tinyquiz-${name}";
version = "dev"; version = "dev";
src = ./.; src = with nix-filter.lib; filter { root = ./.; include = [ (inDirectory "cmd") (inDirectory "pkg") (inDirectory "ui") "go.mod" "go.sum" ]; };
goDeps = ./deps.nix; goDeps = ./deps.nix;
preBuild = "go generate vkane.cz/tinyquiz/..."; preBuild = "go generate vkane.cz/tinyquiz/...";
checkPhase = "go test -race vkane.cz/tinyquiz/..."; checkPhase = "go test -race vkane.cz/tinyquiz/...";
@@ -57,12 +59,6 @@
}; };
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
users.groups.tinyquiz = {};
users.users.tinyquiz = {
description = "Tinyquiz service user";
group = "tinyquiz";
isSystemUser = true;
};
systemd.services.tinyquiz = { systemd.services.tinyquiz = {
description = "Tinyquiz service"; description = "Tinyquiz service";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
@@ -71,6 +67,7 @@
serviceConfig = { serviceConfig = {
ExecStart = "${self.packages.x86_64-linux.tinyquiz-web}/bin/web"; ExecStart = "${self.packages.x86_64-linux.tinyquiz-web}/bin/web";
User = "tinyquiz"; User = "tinyquiz";
DynamicUser = true;
}; };
environment = cfg.config; environment = cfg.config;
}; };

View File

@@ -72,7 +72,7 @@ func (m *Model) RegisterPlayer(playerName string, sessionCode string, now time.T
} }
defer tx.Commit() defer tx.Commit()
if s, err := tx.Session.Query().Where(session.Code(sessionCode)).Only(c); ent.IsNotFound(err) { if s, err := tx.Session.Query().Where(session.CodeEqualFold(sessionCode)).Only(c); ent.IsNotFound(err) {
return nil, NoSuchEntity return nil, NoSuchEntity
} else if err != nil { } else if err != nil {
return nil, err return nil, err
@@ -96,7 +96,7 @@ func (m *Model) CreateSession(organiserName string, gameCode string, now time.Ti
} }
defer tx.Rollback() defer tx.Rollback()
if gameId, err := tx.Game.Query().Where(game.Code(gameCode)).OnlyID(c); err == nil { if gameId, err := tx.Game.Query().Where(game.CodeEqualFold(gameCode)).OnlyID(c); err == nil {
if incremental, err := m.getCodeIncremental(c); err == nil { if incremental, err := m.getCodeIncremental(c); err == nil {
if code, err := codeGenerator.GenerateRandomCode(incremental, codeRandomPartLength); err == nil { if code, err := codeGenerator.GenerateRandomCode(incremental, codeRandomPartLength); err == nil {
if s, err := tx.Session.Create().SetID(uuid.New()).SetCreated(now).SetCode(string(code)).SetGameID(gameId).Save(c); err == nil { if s, err := tx.Session.Create().SetID(uuid.New()).SetCreated(now).SetCode(string(code)).SetGameID(gameId).Save(c); err == nil {

View File

@@ -0,0 +1,24 @@
{{- template "base" . -}}
{{- define "additional-css" -}}
<link rel="stylesheet" href="/static/home.css">
{{ end -}}
{{- define "additional-js" -}}
<script src="/static/home.js"></script>
{{ end -}}
{{- define "header" }}
<h1>Popis souboru s otázkami</h1>
{{ end -}}
{{- define "main" }}
<div style="max-width: 75vw;">
<p>
Soubor je CSV (oddělovačem je čárka, kódování UTF-8) bez záhlaví. Lze jej otevřít a upravit obvyklými kancelářskými programy (Microsoft Excel, LibreOffice…).
</p>
<p>
Každý neprázdný řádek odpovídá buďto otázce, nebo odpovědi. Otázka má svůj nadpis v prvním sloupci. Odpověď má první sloupec prázný, svůj nadpis má ve druhém sloupci a váže se k nejbližší předcházející otázce. Otázky mohou volitelně (krom první) ve druhém sloupci uvést čas na odpověď v milisekundách, jinak se použije hodnota předchozí otázky. Odpovědi, které mají ve třetím sloupci číslo 1 se považují za správné.
</p>
</div>
{{ end -}}

View File

@@ -22,28 +22,89 @@
</section> </section>
<section> <section>
<h1>Připojit se ke hře</h1> <h1>Připojit se ke hře</h1>
<form id="join" method="post"> {{- with .Form.Join }}
<label>Kód hry: <input type="text" name="code" placeholder="Kód hry"></label> {{- with .Errors }}
<label>Jméno hráče: <input type="text" name="player" placeholder="Jméno"></label> <ul class="error">
<input type="submit" value="Připojit do hry"> {{ range . }}<li>{{ . }}</li>{{ end }}
</form> </ul>
{{- end }}
<form id="join" method="post">
<label>Kód hry: <input type="text" name="code" placeholder="Kód hry" required value="{{ .Code }}"></label>
<label>Jméno hráče: <input type="text" name="player" placeholder="Jméno" required value="{{ .Name }}"></label>
<input type="submit" value="Připojit do hry">
</form>
{{- end }}
<button class="help" aria-label="Zobrazit nápovědu"></button>
<div class="message">
<p>
Takto se připojíte k již založené (i probíhající) hře.
</p>
<p>
<strong>Kód hry</strong> získáte od jejího organizátora (vidíte-li na sdílený monitor, je napsán v závorkách za názvem kvízu).
</p>
<p>
<strong>Jméno hráče</strong> se zobrazuje ostatním hráčům při hře i následně na výsledkovce. Doporučuje se volit jej s ohledem na dobré mravy a případné nároky na svou anonymitu, ochranu osobních údajů apod.
</p>
</div>
</section> </section>
<section> <section>
<h1>Začít hrát</h1> <h1>Zorganizovat novou hru</h1>
<form id="play" method="post" action="/session"> {{- with .Form.NewSession }}
<label>Kód kvízu: <input type="text" name="code" placeholder="Kód kvizu"></label> {{- with .Errors }}
<label>Jméno organizátora: <input type="text" name="organiser" placeholder="Jméno"></label> <ul class="error">
<input type="submit" value="Začit hrát"> {{ range . }}<li>{{ . }}</li>{{ end }}
</form> </ul>
{{- end }}
<form id="play" method="post" action="/session">
<label>Kód kvízu: <input type="text" name="code" placeholder="Kód kvizu" required value="{{ .Code }}"></label>
<label>Jméno organizátora: <input type="text" name="organiser" placeholder="Jméno" required value="{{ .Name }}"></label>
<input type="submit" value="Začit hrát">
</form>
{{- end }}
<button class="help" aria-label="Zobrazit nápovědu"></button>
<div class="message">
<p>
Takto se stanete organizátorem hry. Sami nemůžete odpovídat, ale získáte kód pro připojení ostatních hráčů.
</p>
<p>
<strong>Kód kvízu</strong> získáte od jeho autora, který jej získal při vytvoření.
</p>
<p>
<strong>Jméno organizátora</strong> se zobrazuje ostatním hráčům při hře. Doporučuje se volit jej s ohledem na případné nároky na svou anonymitu, ochranu osobních údajů apod.
</p>
</div>
</section> </section>
<section> <section>
<h1>Vytvořit nový kvíz</h1> {{- with .Form.NewGame }}
<p>Stáhnout <a href="/template" download>šablonu nového kvízu</a></p> {{- with .Errors }}
<form id="new" enctype="multipart/form-data" method="post" action="/game"> <ul class="error">
<label>Jméno kvízu: <input type="text" name="name" placeholder="Jméno kvízu"></label> {{ range . }}<li>{{ . }}</li>{{ end }}
<label>Jméno autora: <input type="text" name="author" placeholder="Jméno"></label> </ul>
<label>Kvíz: <input type="file" name="game" accept="text/csv"></label> {{- end }}
<input type="submit" value="Vytvořit"> <h1>Vytvořit nový kvíz</h1>
</form> <p><a href="/template" download>Šablona nového kvízu</a>.</p>
<p><a href="/help">Popis formátu</a></p>
<form id="new" enctype="multipart/form-data" method="post" action="/game">
<label>Jméno kvízu: <input type="text" name="name" placeholder="Jméno kvízu" required value="{{ .Title }}"></label>
<label>Jméno autora: <input type="text" name="author" placeholder="Jméno" required value="{{ .Name }}"></label>
<label>Kvíz: <input type="file" name="game" accept="text/csv" required></label>
<input type="submit" value="Vytvořit">
</form>
{{- end }}
<button class="help" aria-label="Zobrazit nápovědu"></button>
<div class="message">
<p>
Takto vytvoříte nový kvíz (sadu otázek a odpovědí), který následně můžete použít při zakládání hry.
</p>
<p>
<strong>Jméno kvízu</strong> se zobrazí organizátorům i hráčům při hře.
</p>
<p>
<strong>Jméno autora</strong> je doplňkový údaj na podrobnostech kvízu; při následné hře vidět není. Přesto se doporučuje volit jej s ohledem na případné nároky na svou anonymitu, ochranu osobních údajů apod.
</p>
<p>
<strong>Kvíz</strong> je CSV soubor formátu popsaného na samostatné stránce. Nejpohodlnější je vyjít z dodané šablony.
</p>
</div>
</section> </section>
{{ end -}} {{ end -}}

View File

@@ -10,9 +10,52 @@ section {
border: 2px solid black; border: 2px solid black;
margin: 2rem; margin: 2rem;
padding: 1rem; padding: 1rem;
position: relative;
} }
#join, #play, #new { #join, #play, #new {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.error {
color: red;
font-weight: bold;
}
.help::after {
content: "?";
font-weight: bold;
line-height: 1.5rem;
}
.help {
border: 2px solid;
border-radius: 50%;
display: flex;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
position: absolute;
right: 0;
top: 0;
padding: 0;
background: none;
cursor: pointer;
}
.message {
position: absolute;
right: 1.5rem;
top: 0;
display: none;
z-index: 1;
}
.message.show {
display: block;
background-color: white;
border: 2px dotted black;
width: 50vw;
max-width: 20rem;
}

View File

@@ -4,4 +4,17 @@ document.addEventListener("DOMContentLoaded", () => {
const code = joinForm.querySelector("input[name=\"code\"]").value; const code = joinForm.querySelector("input[name=\"code\"]").value;
joinForm.action = "/play/" + encodeURIComponent(code); joinForm.action = "/play/" + encodeURIComponent(code);
}); });
document.body.addEventListener("click", () => {
for (const help of document.querySelectorAll(".message.show")) {
help.classList.remove("show");
}
});
const helps = document.getElementsByClassName("help");
for (const help of helps) {
help.addEventListener("click", (e) => {
e.stopPropagation();
console.log(e.target);
e.target.parentElement.querySelector(".message").classList.toggle("show");
})
}
}); });