Compare commits

...

11 Commits

Author SHA1 Message Date
d3a71e1d0f prettier ui and better login handling 2025-11-06 22:19:20 +01:00
09e65803c9 skip penalties check when already solved 2025-11-06 12:01:35 +01:00
c38a33a7aa admin cipher clue can be html 2025-11-06 10:44:24 +01:00
53b673f86d clue can be html 2025-11-05 18:29:54 +01:00
b6c2506bd4 copilot security fixes 2025-10-28 18:42:24 +01:00
9ee7281b6b better http server settings 2025-10-28 17:27:26 +01:00
3c18473588 use embed and new mux 2025-10-28 17:22:32 +01:00
cbe7ddc51b last loaded cipher 2025-10-28 17:01:20 +01:00
94440e78de admin penalizace 2025-10-28 16:46:11 +01:00
9491495395 penalty konstanty 2025-10-28 16:38:56 +01:00
c4b404e7a9 admin team ID 2025-10-28 16:34:48 +01:00
13 changed files with 674 additions and 124 deletions

View File

@@ -15,7 +15,10 @@ type difficultyLevel struct {
func adminLoginHandler(w http.ResponseWriter, r *http.Request) { func adminLoginHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
http.ServeFile(w, r, "templates/adminLogin.html") err := adminLoginTemplate.Execute(w, false)
if err != nil {
http.Error(w, "Could not render template", http.StatusInternalServerError)
}
case http.MethodPost: case http.MethodPost:
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
http.Error(w, "Error parsing form", http.StatusBadRequest) http.Error(w, "Error parsing form", http.StatusBadRequest)
@@ -29,7 +32,10 @@ func adminLoginHandler(w http.ResponseWriter, r *http.Request) {
).Scan(new(int)) ).Scan(new(int))
switch { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
http.Error(w, "Invalid credentials", http.StatusUnauthorized) err := adminLoginTemplate.Execute(w, true)
if err != nil {
http.Error(w, "Could not render template", http.StatusInternalServerError)
}
case err != nil: case err != nil:
http.Error(w, "Database error", http.StatusInternalServerError) http.Error(w, "Database error", http.StatusInternalServerError)
default: default:
@@ -38,6 +44,8 @@ func adminLoginHandler(w http.ResponseWriter, r *http.Request) {
Value: base64.StdEncoding.EncodeToString([]byte(username + ":" + hashPassword(password))), Value: base64.StdEncoding.EncodeToString([]byte(username + ":" + hashPassword(password))),
Path: "/admin/", Path: "/admin/",
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: true,
}) })
http.Redirect(w, r, "/admin/", http.StatusSeeOther) http.Redirect(w, r, "/admin/", http.StatusSeeOther)
} }
@@ -61,8 +69,8 @@ func isAdmin(r *http.Request) bool {
return false return false
} }
var username, passwordHash string var username, passwordHash string
regexp := regexp.MustCompile(`^([^:]+):([a-f0-9]+)$`) regex := regexp.MustCompile(`^([^:]+):([a-f0-9]+)$`)
matches := regexp.FindStringSubmatch(string(decoded)) matches := regex.FindStringSubmatch(string(decoded))
if len(matches) != 3 { if len(matches) != 3 {
return false return false
} }
@@ -80,7 +88,7 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther) http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return return
} }
http.ServeFile(w, r, "templates/adminPanel.html") http.ServeFileFS(w, r, templatesFS, "templates/adminPanel.html")
} }
func adminTeamsHandler(w http.ResponseWriter, r *http.Request) { func adminTeamsHandler(w http.ResponseWriter, r *http.Request) {
@@ -122,7 +130,7 @@ func adminTeamsHandler(w http.ResponseWriter, r *http.Request) {
} }
// Fetch all teams with their difficulty levels // Fetch all teams with their difficulty levels
// Teams // Teams
rows, err := db.Query("SELECT name, difficulty_levels.level_name, last_cipher, penalty FROM teams JOIN difficulty_levels ON teams.difficulty_level = difficulty_levels.id ORDER BY teams.difficulty_level, teams.name") rows, err := db.Query("SELECT teams.id, name, difficulty_levels.level_name, last_cipher, last_loaded_cipher, penalty FROM teams JOIN difficulty_levels ON teams.difficulty_level = difficulty_levels.id ORDER BY teams.difficulty_level, teams.name")
if err != nil { if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError) http.Error(w, "Database error", http.StatusInternalServerError)
return return
@@ -131,7 +139,7 @@ func adminTeamsHandler(w http.ResponseWriter, r *http.Request) {
var teams []TeamTemplateS var teams []TeamTemplateS
for rows.Next() { for rows.Next() {
var team TeamTemplateS var team TeamTemplateS
if err := rows.Scan(&team.TeamName, &team.Difficulty, &team.LastCipher, &team.Penalties); err != nil { if err := rows.Scan(&team.ID, &team.TeamName, &team.Difficulty, &team.LastCipher, &team.LastLoadedCipher, &team.Penalties); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError) http.Error(w, "Database error", http.StatusInternalServerError)
return return
} }
@@ -176,7 +184,7 @@ func AdminStartHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther) http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return return
} }
_, err := db.Exec("UPDATE teams SET last_cipher = 1, penalty = 0") _, err := db.Exec("UPDATE teams SET last_cipher = 1, last_loaded_cipher = 0, penalty = 0")
if err != nil { if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError) http.Error(w, "Database error", http.StatusInternalServerError)
return return
@@ -631,3 +639,34 @@ func AdminQRHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
func AdminPenaltiesHandler(w http.ResponseWriter, r *http.Request) {
if !isAdmin(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Fetch all penalties with team names and task orders
rows, err := db.Query("SELECT teams.name, tasks.order_num, penalties.minutes FROM penalties JOIN teams ON penalties.team_id = teams.id JOIN tasks ON penalties.task_id = tasks.id ORDER BY teams.name, tasks.order_num")
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var penalties []AdminPenaltiesTemplateS
for rows.Next() {
var penalty AdminPenaltiesTemplateS
if err := rows.Scan(&penalty.TeamName, &penalty.TaskOrder, &penalty.Minutes); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
penalties = append(penalties, penalty)
}
if err := rows.Err(); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if err := AdminPenaltiesTemplate.Execute(w, penalties); err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
}

View File

@@ -13,6 +13,7 @@ CREATE TABLE TEAMS (
difficulty_level INTEGER NOT NULL, difficulty_level INTEGER NOT NULL,
password VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
last_cipher INTEGER DEFAULT 0, -- index of cipher which team is solving or searching now last_cipher INTEGER DEFAULT 0, -- index of cipher which team is solving or searching now
last_loaded_cipher INTEGER DEFAULT 0, -- index of cipher which team has loaded last time
penalty INTEGER DEFAULT 0, penalty INTEGER DEFAULT 0,
FOREIGN KEY (difficulty_level) REFERENCES DIFFICULTY_LEVELS(id) FOREIGN KEY (difficulty_level) REFERENCES DIFFICULTY_LEVELS(id)
); );

View File

@@ -5,10 +5,10 @@ INSERT INTO DIFFICULTY_LEVELS (id, level_name) VALUES
(3, 'Těžká'); (3, 'Těžká');
-- Vložení týmů: heslo1, heslo2, heslo3 -- Vložení týmů: heslo1, heslo2, heslo3
INSERT INTO TEAMS (id, name, difficulty_level, password, last_cipher, penalty) VALUES INSERT INTO TEAMS (id, name, difficulty_level, password, last_cipher, last_loaded_cipher, penalty) VALUES
(1, 'Rychlé šípy', 1, '4bc2ef0648cdf275032c83bb1e87dd554d47f4be293670042212c8a01cc2ccbe', 1, 0), (1, 'Rychlé šípy', 1, '4bc2ef0648cdf275032c83bb1e87dd554d47f4be293670042212c8a01cc2ccbe', 1, 0, 0),
(2, 'Vlčí smečka', 2, '274efeaa827a33d7e35be9a82cd6150b7caf98f379a4252aa1afce45664dcbe1', 1, 10), (2, 'Vlčí smečka', 2, '274efeaa827a33d7e35be9a82cd6150b7caf98f379a4252aa1afce45664dcbe1', 1, 0, 10),
(3, 'Orli', 3, '05af533c6614544a704c4cf51a45be5c10ff19bd10b7aa1dfe47efc0fd059ede', 1, 5); (3, 'Orli', 3, '05af533c6614544a704c4cf51a45be5c10ff19bd10b7aa1dfe47efc0fd059ede', 1, 0, 5);
-- Vložení pozic -- Vložení pozic
INSERT INTO POSITIONS (id, gps, clue) VALUES INSERT INTO POSITIONS (id, gps, clue) VALUES

View File

@@ -8,6 +8,7 @@ import (
"html/template" "html/template"
"net/http" "net/http"
"strings" "strings"
"time"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@@ -15,6 +16,11 @@ import (
const domain = "https://klice.h21.cz" const domain = "https://klice.h21.cz"
const dbFile = "./klice.db" const dbFile = "./klice.db"
const (
smallHelpPenalty = 5
giveUpPenalty = 30
)
var db *sql.DB var db *sql.DB
func hashPassword(password string) string { func hashPassword(password string) string {
@@ -35,7 +41,10 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
err := db.QueryRow("SELECT 1 FROM teams WHERE password = ?", hashedPassword).Scan(new(int)) err := db.QueryRow("SELECT 1 FROM teams WHERE password = ?", hashedPassword).Scan(new(int))
switch { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
http.Error(w, "No team found", http.StatusUnauthorized) err = LoginTemplate.Execute(w, true)
if err != nil {
http.Error(w, "Could not render template", http.StatusInternalServerError)
}
return return
case err != nil: case err != nil:
http.Error(w, "Could not retrieve team", http.StatusInternalServerError) http.Error(w, "Could not retrieve team", http.StatusInternalServerError)
@@ -48,6 +57,7 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
Secure: true,
} }
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
@@ -55,13 +65,16 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
if err == nil { if err == nil {
redir.MaxAge = -1 redir.MaxAge = -1
http.SetCookie(w, redir) http.SetCookie(w, redir)
http.Redirect(w, r, redir.Value, http.StatusSeeOther) http.Redirect(w, r, safeRedirectURL(redir.Value), http.StatusSeeOther)
} else { } else {
http.Redirect(w, r, "/team", http.StatusSeeOther) http.Redirect(w, r, "/team", http.StatusSeeOther)
} }
} }
case http.MethodGet: case http.MethodGet:
http.ServeFile(w, r, "templates/login.html") err := LoginTemplate.Execute(w, false)
if err != nil {
http.Error(w, "Could not render template", http.StatusInternalServerError)
}
default: default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
} }
@@ -104,6 +117,7 @@ func isLoggedIn(w http.ResponseWriter, r *http.Request) (bool, int) {
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
Secure: true,
} }
http.SetCookie(w, redir) http.SetCookie(w, redir)
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
@@ -141,8 +155,8 @@ func teamInfoHandler(w http.ResponseWriter, r *http.Request) {
} }
func qrHandler(w http.ResponseWriter, r *http.Request) { func qrHandler(w http.ResponseWriter, r *http.Request) {
uid, found := strings.CutPrefix(r.URL.Path, "/qr/") uid := r.PathValue("qr")
if !found || uid == "" { if uid == "" {
http.Error(w, "Invalid QR code", http.StatusBadRequest) http.Error(w, "Invalid QR code", http.StatusBadRequest)
return return
} }
@@ -193,12 +207,12 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "This task is not yet available", http.StatusForbidden) http.Error(w, "This task is not yet available", http.StatusForbidden)
return return
} else if order == last_cipher { } else if order == last_cipher {
last_cipher = order _, err := db.Exec("UPDATE teams SET last_loaded_cipher = ? WHERE id = ?", order, teamID)
_, err = db.Exec("UPDATE teams SET last_cipher = ? WHERE id = ?", order, teamID)
if err != nil { if err != nil {
http.Error(w, "Could not update last cipher", http.StatusInternalServerError) http.Error(w, "Could not update last loaded cipher", http.StatusInternalServerError)
return return
} }
} else if order < last_cipher { } else if order < last_cipher {
help = 2 help = 2
} }
@@ -223,18 +237,20 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
} }
// get penalties for this task and team // get penalties for this task and team
err = db.QueryRow("SELECT minutes FROM penalties WHERE team_id = ? AND task_id = ?", teamID, taskID).Scan(&penalty) if help == 0 {
if err == sql.ErrNoRows { err = db.QueryRow("SELECT minutes FROM penalties WHERE team_id = ? AND task_id = ?", teamID, taskID).Scan(&penalty)
penalty = 0 if err == sql.ErrNoRows {
} else if err != nil { penalty = 0
http.Error(w, "Could not retrieve penalties", http.StatusInternalServerError) } else if err != nil {
return http.Error(w, "Could not retrieve penalties", http.StatusInternalServerError)
} return
// determine help level based on penalties }
if penalty > 0 && penalty < 15 { // determine help level based on penalties
help = 1 if penalty > 0 && penalty <= smallHelpPenalty {
} else if penalty >= 15 { help = 1
help = 2 } else if penalty > smallHelpPenalty {
help = 2
}
} }
// handle answer and help form submission // handle answer and help form submission
@@ -245,12 +261,12 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
} }
if r.FormValue("help") == "1" && help == 0 { // small help if r.FormValue("help") == "1" && help == 0 { // small help
help = 1 help = 1
db.Exec("INSERT INTO penalties (team_id, task_id, minutes) VALUES (?, ?, 5)", teamID, taskID) db.Exec("INSERT INTO penalties (team_id, task_id, minutes) VALUES (?, ?, ?)", teamID, taskID, smallHelpPenalty)
db.Exec("UPDATE teams SET penalty = penalty + 5 WHERE id = ?", teamID) db.Exec("UPDATE teams SET penalty = penalty + ? WHERE id = ?", smallHelpPenalty, teamID)
} else if r.FormValue("help") == "2" && help == 1 { // give up } else if r.FormValue("help") == "2" && help == 1 { // give up
help = 2 help = 2
db.Exec("UPDATE penalties SET minutes = 35 WHERE team_id = ? AND task_id = ?", teamID, taskID) db.Exec("UPDATE penalties SET minutes = minutes + ? WHERE team_id = ? AND task_id = ?", giveUpPenalty, teamID, taskID)
db.Exec("UPDATE teams SET penalty = penalty + 30, last_cipher = ? WHERE id = ?", order+1, teamID) db.Exec("UPDATE teams SET penalty = penalty + ?, last_cipher = ? WHERE id = ?", giveUpPenalty, order+1, teamID)
} else if answer := r.FormValue("solution"); answer != "" && help < 2 { // answer submission } else if answer := r.FormValue("solution"); answer != "" && help < 2 { // answer submission
var correctAnswer string var correctAnswer string
err = db.QueryRow("SELECT solution FROM CIPHERS WHERE id = ?", cipherID).Scan(&correctAnswer) err = db.QueryRow("SELECT solution FROM CIPHERS WHERE id = ?", cipherID).Scan(&correctAnswer)
@@ -279,7 +295,7 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Could not retrieve help text", http.StatusInternalServerError) http.Error(w, "Could not retrieve help text", http.StatusInternalServerError)
return return
} }
CipherTemplateData.HelpText = helpText CipherTemplateData.HelpText = template.HTML(helpText)
case 2: // next cipher case 2: // next cipher
// get end clue // get end clue
var endClue string var endClue string
@@ -324,6 +340,13 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
func safeRedirectURL(u string) string {
if strings.HasPrefix(u, "/") && !strings.HasPrefix(u, "//") {
return u
}
return "/team"
}
func main() { func main() {
var err error var err error
db, err = sql.Open("sqlite3", dbFile+"?_fk=on") db, err = sql.Open("sqlite3", dbFile+"?_fk=on")
@@ -338,7 +361,7 @@ func main() {
http.HandleFunc("/login", loginHandler) http.HandleFunc("/login", loginHandler)
http.HandleFunc("/logout", logoutHandler) http.HandleFunc("/logout", logoutHandler)
http.HandleFunc("/team", teamInfoHandler) http.HandleFunc("/team", teamInfoHandler)
http.HandleFunc("/qr/", qrHandler) http.HandleFunc("/qr/{qr...}", qrHandler)
// admin app // admin app
http.HandleFunc("/admin/login", adminLoginHandler) http.HandleFunc("/admin/login", adminLoginHandler)
http.HandleFunc("/admin/logout", adminLogoutHandler) http.HandleFunc("/admin/logout", adminLogoutHandler)
@@ -350,10 +373,20 @@ func main() {
http.HandleFunc("/admin/cipher", AdminCipherHandler) http.HandleFunc("/admin/cipher", AdminCipherHandler)
http.HandleFunc("/admin/positions", AdminPositionsHandler) http.HandleFunc("/admin/positions", AdminPositionsHandler)
http.HandleFunc("/admin/qr", AdminQRHandler) http.HandleFunc("/admin/qr", AdminQRHandler)
http.HandleFunc("/admin/penalties", AdminPenaltiesHandler)
// static files // static files
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
srv := &http.Server{
Addr: ":8080",
Handler: nil,
ReadTimeout: 10 * time.Second, // zabrání Slowloris útokům
WriteTimeout: 15 * time.Second, // omezení dlouhých odpovědí
IdleTimeout: 60 * time.Second, // ukončení nečinných spojení
MaxHeaderBytes: 1 << 20, // max. 1 MB hlavičky
}
fmt.Println("Server started at :8080") fmt.Println("Server started at :8080")
http.ListenAndServe(":8080", nil) srv.ListenAndServe()
} }

View File

@@ -1,14 +1,18 @@
package main package main
import ( import (
"embed"
"html/template" "html/template"
) )
//go:embed templates/*.html
var templatesFS embed.FS
type CipherTemplateS struct { type CipherTemplateS struct {
ID int ID int
Order uint Order uint
Assignment template.HTML Assignment template.HTML
HelpText string HelpText template.HTML
FinalClue string FinalClue string
Coordinates string Coordinates string
PositionHint string PositionHint string
@@ -19,10 +23,12 @@ type CipherTemplateS struct {
} }
type TeamTemplateS struct { type TeamTemplateS struct {
TeamName string ID int
Difficulty string TeamName string
LastCipher int Difficulty string
Penalties int LastCipher int
LastLoadedCipher int
Penalties int
} }
type DifficultyLevelS struct { type DifficultyLevelS struct {
@@ -51,7 +57,7 @@ type AdminCipherTemplateS struct {
ID int ID int
Assignment template.HTML Assignment template.HTML
Solution string Solution string
Clue string Clue template.HTML
} }
type AdminPositionsTemplateS struct { type AdminPositionsTemplateS struct {
@@ -78,11 +84,20 @@ type AdminLevelTemplateS struct {
Name string Name string
} }
var CipherTemplate = template.Must(template.ParseFiles("templates/assignment.html")) type AdminPenaltiesTemplateS struct {
var TeamTemplate = template.Must(template.ParseFiles("templates/team.html")) TeamName string
var AdminTeamsTemplate = template.Must(template.ParseFiles("templates/adminTeams.html")) TaskOrder uint
var AdminRoutesTemplate = template.Must(template.ParseFiles("templates/adminRoutes.html")) Minutes int
var AdminLevelTemplate = template.Must(template.ParseFiles("templates/adminLevels.html")) }
var AdminCipherTemplate = template.Must(template.ParseFiles("templates/adminCiphers.html"))
var AdminPositionsTemplate = template.Must(template.ParseFiles("templates/adminPositions.html")) var CipherTemplate = template.Must(template.ParseFS(templatesFS, "templates/assignment.html"))
var AdminQRsTemplate = template.Must(template.ParseFiles("templates/adminQR.html")) var TeamTemplate = template.Must(template.ParseFS(templatesFS, "templates/team.html"))
var AdminTeamsTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminTeams.html"))
var AdminRoutesTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminRoutes.html"))
var AdminLevelTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminLevels.html"))
var AdminCipherTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminCiphers.html"))
var AdminPositionsTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminPositions.html"))
var AdminQRsTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminQR.html"))
var AdminPenaltiesTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminPenalties.html"))
var LoginTemplate = template.Must(template.ParseFS(templatesFS, "templates/login.html"))
var adminLoginTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminLogin.html"))

View File

@@ -39,7 +39,7 @@
<label for="solution">Řešení:</label> <label for="solution">Řešení:</label>
<input type="text" id="solution" name="solution" required> <input type="text" id="solution" name="solution" required>
<label for="clue">Nápověda:</label> <label for="clue">Nápověda:</label>
<input type="text" id="clue" name="clue" required> <textarea id="clue" name="clue" cols="40" rows="5" required></textarea>
<input type="submit" value="Přidat šifru"> <input type="submit" value="Přidat šifru">
</form> </form>
<hr> <hr>

View File

@@ -2,17 +2,33 @@
<html lang="cs"> <html lang="cs">
<head> <head>
<meta charset="UTF-8"> <meta charset="utf-8">
<title>Admin Login</title> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin — přihlášení</title>
</head> </head>
<body> <body>
<h1>Admin Login</h1> <h1>Admin Login</h1>
<form method="post">
Uživatelské jméno: <input type="text" name="username"><br> {{if .}}
Heslo: <input type="password" name="password"><br> <p style="color: red;"><strong>Chybné uživatelské jméno nebo heslo.</strong></p>
<input type="submit" value="Přihlásit se"> {{end}}
<form method="post" autocomplete="off">
<div>
<label for="username">Uživatelské jméno</label><br>
<input id="username" name="username" type="text" required>
</div>
<div>
<label for="password">Heslo</label><br>
<input id="password" name="password" type="password" required>
</div>
<div>
<input type="submit" value="Přihlásit se">
</div>
</form> </form>
<p><a href="/admin">Zpět na admin panel</a></p>
</body> </body>
</html> </html>

View File

@@ -14,6 +14,7 @@
<a href="/admin/cipher">Šifry</a> <br> <a href="/admin/cipher">Šifry</a> <br>
<a href="/admin/positions">Pozice</a> <br> <a href="/admin/positions">Pozice</a> <br>
<a href="/admin/qr">QR Kódy</a> <br> <a href="/admin/qr">QR Kódy</a> <br>
<a href="/admin/penalties">Penalizace týmů</a> <br>
<hr> <hr>
<form method="post" action="/admin/logout"> <form method="post" action="/admin/logout">
<input type="submit" value="Logout"> <input type="submit" value="Logout">

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Penalizace týmů</title>
</head>
<body>
<h1>Penalizace týmů</h1>
<table border="1">
<tr>
<th>Název týmu</th>
<th>Pořadí šifry</th>
<th>Minuty</th>
</tr>
{{range .}}
<tr>
<td>{{.TeamName}}</td>
<td>{{.TaskOrder}}</td>
<td>{{.Minutes}}</td>
</tr>
{{end}}
</table>
<hr>
<a href="/admin">Zpět na admin panel</a>
</body>
</html>

View File

@@ -10,18 +10,21 @@
<h1>Týmy</h1> <h1>Týmy</h1>
<table border="1"> <table border="1">
<tr> <tr>
<th>ID</th>
<th>Název týmu</th> <th>Název týmu</th>
<th>Obtížnost</th> <th>Obtížnost</th>
<th>Poslední šifra</th> <th>Právě řešená šifra</th>
<th>Poslední načtená šifra</th>
<th>Penalizace (minuty)</th> <th>Penalizace (minuty)</th>
<th>Smazat tým</th> <th>Smazat tým</th>
</th>
</tr> </tr>
{{range .Teams}} {{range .Teams}}
<tr> <tr>
<td>{{.ID}}</td>
<td>{{.TeamName}}</td> <td>{{.TeamName}}</td>
<td>{{.Difficulty}}</td> <td>{{.Difficulty}}</td>
<td>{{.LastCipher}}</td> <td>{{.LastCipher}}</td>
<td>{{.LastLoadedCipher}}</td>
<td>{{.Penalties}}</td> <td>{{.Penalties}}</td>
<td> <td>
<form action="/admin/teams" method="post"> <form action="/admin/teams" method="post">

View File

@@ -2,56 +2,269 @@
<html lang="cs"> <html lang="cs">
<head> <head>
<meta charset="UTF-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Zadání šifry {{.Order}}</title> <title>Zadání šifry {{.Order}}</title>
<style>
:root {
--bg: #f5f7fb;
--card: #ffffff;
--accent: #0b6efd;
--muted: #6b7280;
--danger: #ef4444;
--border: #e6e9ee;
--radius: 12px;
--gap: 14px;
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
color-scheme: light;
}
html,
body {
height: 100%;
margin: 0;
background: linear-gradient(180deg, #f8fbff, var(--bg));
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 900px;
margin: 28px auto;
padding: 20px;
}
.card {
background: var(--card);
border-radius: var(--radius);
padding: 20px;
box-shadow: 0 8px 24px rgba(20, 30, 60, 0.06);
border: 1px solid var(--border);
}
h1 {
margin: 0 0 8px 0;
font-size: 20px;
color: #0f172a;
}
.meta {
color: var(--muted);
margin-bottom: 18px;
font-size: 14px;
}
.assignment {
font-size: 16px;
line-height: 1.5;
margin-bottom: 18px;
color: #0f172a;
}
.controls {
display: flex;
gap: var(--gap);
flex-wrap: wrap;
align-items: center;
margin-bottom: 18px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(11, 110, 253, 0.12);
background: linear-gradient(180deg, var(--accent), #0654d6);
color: #fff;
text-decoration: none;
cursor: pointer;
font-weight: 600;
}
.btn.ghost {
background: #fff;
color: #0f172a;
border: 1px solid var(--border);
box-shadow: none;
}
.btn.warn {
background: var(--danger);
border-color: rgba(239, 68, 68, 0.9);
}
.helpbox {
background: #f8faff;
border: 1px solid rgba(11, 110, 253, 0.08);
padding: 12px;
border-radius: 8px;
color: #0b2540;
}
.solution {
background: #fbffef;
border: 1px solid rgba(34, 197, 94, 0.12);
padding: 12px;
border-radius: 8px;
color: #0b3a12;
}
form {
margin: 0;
}
label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 6px;
}
input[type="text"] {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
font-size: 14px;
}
input[type="submit"],
button[type="button"] {
padding: 9px 12px;
border-radius: 8px;
border: none;
cursor: pointer;
}
.row {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
}
.gps {
display: flex;
gap: 8px;
align-items: center;
}
input[readonly] {
background: #f3f4f6;
border: 1px solid var(--border);
}
.muted {
color: var(--muted);
font-size: 13px;
}
.error {
color: var(--danger);
font-weight: 600;
}
@media (max-width:640px) {
.controls {
flex-direction: column;
align-items: stretch;
}
.row {
flex-direction: column;
align-items: stretch;
}
}
</style>
</head> </head>
<body> <body>
<h1>Zadání šifry {{.Order}}</h1> <div class="wrap">
<p>{{.Assignment}}</p> <div class="card">
<hr> <h1>Zadání šifry {{.Order}}</h1>
{{if eq .Help 0}} <div class="meta">Aktuální úkol</div>
<p>Požádat o malou nápovědu.</p>
<form method="post"> <div class="assignment">
<input type="hidden" name="help" value="1"> {{.Assignment}}
<input type="submit" value="Zobrazit nápovědu"> </div>
</form>
{{else if eq .Help 1}} <div class="controls">
<p>Nápověda: {{.HelpText}}</p> {{if eq .Help 0}}
<p>Vzdát se a ukázat pozici další šifry.</p> <div>
<form method="post"> <div class="muted">Požádat o malou nápovědu</div>
<input type="hidden" name="help" value="2"> <form method="post" style="margin-top:8px;">
<input type="submit" value="Vzdát se"> <input type="hidden" name="help" value="1">
</form> <input class="btn" type="submit" value="Zobrazit nápovědu">
{{else}} </form>
<p>Řešení: {{.Solution}}</p> </div>
{{end}} {{else if eq .Help 1}}
<hr> <div style="flex:1">
{{if ne .Help 2}} <div class="helpbox">Nápověda: {{.HelpText}}</div>
{{if .Wrong}}<p style="color:red;">Špatné řešení, zkus to znovu.</p>{{end}} <div style="margin-top:12px">
<form method="post"> <div class="muted">Pokud se chcete vzdát a získat pozici další šifry:</div>
Řešení: <input type="text" name="solution"><br> <form method="post" style="margin-top:8px;">
<input type="submit" value="Odeslat"> <input type="hidden" name="help" value="2">
</form> <input class="btn warn" type="submit" value="Vzdát se">
{{else}} </form>
<p> </div>
Souřadnice další šifry: </div>
<input id="gps" value="{{.Coordinates}}" readonly /> {{else}}
<br> <div style="flex:1">
<button onclick="copyToClipboard()">Zkopírovat do schránky</button> <div class="solution">Řešení: {{.Solution}}</div>
<br> </div>
Nápověda k nalezení pozice: {{.PositionHint}} {{end}}
</p> </div>
<p>Nápověda k nalezení cíle: {{.FinalClue}}</p>
{{end}} <hr>
{{if ne .Help 2}}
{{if .Wrong}}<p class="error">Špatné řešení, zkus to znovu.</p>{{end}}
<form method="post" style="margin-top:12px;">
<label for="solution">Zadejte řešení</label>
<div class="row">
<input id="solution" name="solution" type="text" autocomplete="off"
placeholder="Zadejte odpověď...">
<input class="btn ghost" type="submit" value="Odeslat">
</div>
</form>
{{else}}
<div>
<p class="muted">Souřadnice další šifry</p>
<div class="row">
<div class="gps" style="flex:1">
<input id="gps" value="{{.Coordinates}}" readonly>
<button class="btn ghost" type="button" onclick="copyToClipboard()">Zkopírovat</button>
</div>
</div>
<p class="muted" style="margin-top:8px">Nápověda k nalezení pozice: <strong>{{.PositionHint}}</strong>
</p>
<p class="muted">Nápověda k nalezení cíle: <strong>{{.FinalClue}}</strong></p>
</div>
{{end}}
</div>
</div>
<script>
function copyToClipboard() {
const el = document.getElementById('gps');
if (!el) return;
const text = el.value || el.innerText;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => { fallbackCopy(el); });
} else {
fallbackCopy(el);
}
}
function fallbackCopy(el) {
el.select ? el.select() : null;
try {
document.execCommand('copy');
} catch (e) { /* ignore */ }
window.getSelection ? window.getSelection().removeAllRanges() : null;
}
</script>
</body> </body>
<script>
function copyToClipboard() {
let copyText = document.querySelector("#gps");
copyText.select();
document.execCommand("copy");
}
</script>
</html> </html>

View File

@@ -3,17 +3,123 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Login</title> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Přihlášení</title>
<style>
:root {
--bg: #f5f7fb;
--card: #ffffff;
--accent: #0b6efd;
--muted: #6b7280;
--radius: 12px;
--border: #e6e9ee;
--error-bg: #fff1f2;
--error-border: #fecaca;
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial;
}
html,
body {
height: 100%;
margin: 0;
background: linear-gradient(180deg, #f8fbff, var(--bg));
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 420px;
margin: 72px auto;
padding: 16px;
}
.card {
background: var(--card);
padding: 20px;
border-radius: var(--radius);
box-shadow: 0 10px 30px rgba(20, 30, 60, 0.06);
border: 1px solid var(--border);
}
h1 {
margin: 0 0 6px 0;
font-size: 20px;
color: #0f172a;
}
.sub {
color: var(--muted);
margin-bottom: 12px;
}
.error {
background: var(--error-bg);
border: 1px solid var(--error-border);
color: #9f1239;
padding: 10px 12px;
border-radius: 10px;
margin-bottom: 12px;
font-weight: 600;
}
form {
display: flex;
flex-direction: column;
gap: 12px;
}
label {
font-size: 13px;
color: var(--muted);
}
input[type="password"] {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
font-size: 15px;
}
.btn {
display: inline-block;
padding: 10px 12px;
border-radius: 10px;
background: var(--accent);
color: #fff;
text-decoration: none;
border: none;
cursor: pointer;
font-weight: 700;
}
@media (max-width:420px) {
.wrap {
margin: 32px 12px;
}
}
</style>
</head> </head>
<body> <body>
<h1>Login</h1> <div class="wrap">
<form action="/login" method="post"> <div class="card">
<label for="password">Heslo:</label> <h1>Přihlášení do týmu</h1>
<input type="password" id="password" name="password" required> <div class="sub">Zadejte své týmové heslo</div>
<br>
<button type="submit">Přihlásit se</button> {{if .}}
</form> <div class="error">Chybné heslo — zkuste to prosím znovu.</div>
{{end}}
<form action="/login" method="post">
<div>
<label for="password">Heslo</label>
<input id="password" name="password" type="password" required autocomplete="current-password">
</div>
<div>
<button class="btn" type="submit">Přihlásit se</button>
</div>
</form>
</div>
</div>
</body> </body>
</html> </html>

View File

@@ -3,18 +3,112 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Tým: {{.TeamName}}</title> <title>Tým: {{.TeamName}}</title>
<style>
:root {
--bg: #f5f7fb;
--card: #ffffff;
--accent: #0b6efd;
--muted: #6b7280;
--radius: 12px;
--border: #e6e9ee;
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial;
}
html,
body {
height: 100%;
margin: 0;
background: linear-gradient(180deg, #f8fbff, var(--bg));
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 720px;
margin: 36px auto;
padding: 16px;
}
.card {
background: var(--card);
padding: 18px;
border-radius: var(--radius);
box-shadow: 0 8px 20px rgba(20, 30, 60, 0.06);
border: 1px solid var(--border);
}
h1 {
margin: 0 0 6px 0;
font-size: 20px;
color: #0f172a;
}
.meta {
color: var(--muted);
margin-bottom: 14px;
font-size: 14px;
}
.info {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
margin-bottom: 16px;
}
.details {
font-size: 15px;
color: #0f172a;
}
.label {
color: var(--muted);
font-size: 13px;
}
.logout {
background: transparent;
border: 1px solid var(--border);
padding: 8px 12px;
border-radius: 10px;
cursor: pointer;
color: #0f172a;
font-weight: 600;
}
.logout:hover {
box-shadow: 0 4px 12px rgba(11, 110, 253, 0.06);
}
@media (max-width:560px) {
.info {
grid-template-columns: 1fr;
}
}
</style>
</head> </head>
<body> <body>
<h1>Tým: {{.TeamName}}</h1> <div class="wrap">
Obtížnost: {{.Difficulty}}<br> <div class="card">
Poslední šifra : {{.LastCipher}}<br> <h1>Tým: {{.TeamName}}</h1>
Penalizace: {{.Penalties}}<br> <div class="meta">Přehled týmu</div>
<hr>
<form action="/logout" method="get"> <div class="info">
<input type="submit" value="Odhlásit"> <div class="details">
</form> <div><span class="label">Obtížnost:</span> {{.Difficulty}}</div>
<div><span class="label">Právě řeší šifru:</span> {{.LastCipher}}</div>
<div><span class="label">Penalizace (min):</span> {{.Penalties}}</div>
</div>
<form action="/logout" method="get">
<button class="logout" type="submit">Odhlásit</button>
</form>
</div>
</div>
</div>
</body> </body>
</html> </html>