Compare commits

..

22 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
68cb9ac73e fix 4
2 napoveda 35 minut penalizace
2025-10-23 19:22:14 +02:00
dc4c306817 fix 3
check help value when parsing solution
2025-10-23 19:13:14 +02:00
930319d143 konec 2025-10-23 11:15:24 +02:00
dc31d4edb4 uprava i napovedy u pozice 2025-10-23 10:27:15 +02:00
335aff579c uprava pozice 2025-10-23 10:19:08 +02:00
00f2953e7f fix 2 2025-10-18 21:32:14 +02:00
2c9da94143 fix 1 2025-10-18 21:13:20 +02:00
5dfffcbcb7 textarea 2025-10-17 13:36:01 +02:00
57583e1436 zmena url 2025-10-17 12:52:25 +02:00
6623d747ab admin: obtiznosti + ID 2025-10-12 16:14:06 +02:00
8fe76406d8 napoveda k poloze 2025-10-09 20:15:48 +02:00
17 changed files with 745 additions and 147 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,7 +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,
MaxAge: 3600, SameSite: http.SameSiteStrictMode,
Secure: true,
}) })
http.Redirect(w, r, "/admin/", http.StatusSeeOther) http.Redirect(w, r, "/admin/", http.StatusSeeOther)
} }
@@ -62,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
} }
@@ -81,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) {
@@ -123,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
@@ -132,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
} }
@@ -177,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
@@ -309,7 +316,7 @@ func AdminRouteHandler(w http.ResponseWriter, r *http.Request) {
var routes []AdminRouteTemplateS var routes []AdminRouteTemplateS
for _, level := range difficultyLevels { for _, level := range difficultyLevels {
var route AdminRouteTemplateS var route AdminRouteTemplateS
rows, err := db.Query("SELECT tasks.id, tasks.order_num, CIPHERS.assignment, CIPHERS.clue, tasks.end_clue, POSITIONS.gps, POSITIONS.clue, CIPHERS.solution FROM TASKS JOIN CIPHERS ON TASKS.cipher_id = ciphers.id JOIN POSITIONS on TASKS.position_id = POSITIONS.id WHERE TASKS.difficulty_level=? ORDER BY TASKS.order_num;", level.ID) rows, err := db.Query("SELECT tasks.id, tasks.order_num, CIPHERS.assignment, CIPHERS.clue, tasks.end_clue, POSITIONS.gps, POSITIONS.clue, CIPHERS.solution, COALESCE(QR_CODES.uid, '') FROM TASKS JOIN CIPHERS ON TASKS.cipher_id = ciphers.id JOIN POSITIONS on TASKS.position_id = POSITIONS.id LEFT JOIN QR_CODES ON QR_CODES.position_id = POSITIONS.id WHERE TASKS.difficulty_level=? ORDER BY TASKS.order_num;", level.ID)
if err != nil { if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError) http.Error(w, "Database error", http.StatusInternalServerError)
return return
@@ -318,10 +325,11 @@ func AdminRouteHandler(w http.ResponseWriter, r *http.Request) {
route.Name = level.LevelName route.Name = level.LevelName
for rows.Next() { for rows.Next() {
var cipher CipherTemplateS var cipher CipherTemplateS
if err := rows.Scan(&cipher.ID, &cipher.Order, &cipher.Assignment, &cipher.HelpText, &cipher.FinalClue, &cipher.Coordinates, &cipher.PositionHint, &cipher.Solution); err != nil { if err := rows.Scan(&cipher.ID, &cipher.Order, &cipher.Assignment, &cipher.HelpText, &cipher.FinalClue, &cipher.Coordinates, &cipher.PositionHint, &cipher.Solution, &cipher.URL); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError) http.Error(w, "Database error", http.StatusInternalServerError)
return return
} }
cipher.URL = domain + "/qr/" + cipher.URL
route.Ciphers = append(route.Ciphers, cipher) route.Ciphers = append(route.Ciphers, cipher)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -356,8 +364,8 @@ func AdminLevelHandler(w http.ResponseWriter, r *http.Request) {
} }
// Deleting an existing difficulty level // Deleting an existing difficulty level
if r.PostForm.Has("delete") { if r.PostForm.Has("delete") {
levelName := r.FormValue("delete") id := r.FormValue("delete")
_, err := db.Exec("DELETE FROM difficulty_levels WHERE level_name = ?", levelName) _, err := db.Exec("DELETE FROM difficulty_levels WHERE id = ?", id)
if err != nil { if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError) http.Error(w, "Database error", http.StatusInternalServerError)
return return
@@ -379,16 +387,16 @@ func AdminLevelHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/levels", http.StatusSeeOther) http.Redirect(w, r, "/admin/levels", http.StatusSeeOther)
return return
} }
rows, err := db.Query("SELECT level_name FROM difficulty_levels ORDER BY id") rows, err := db.Query("SELECT id, level_name FROM difficulty_levels ORDER BY id")
if err != nil { if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError) http.Error(w, "Database error", http.StatusInternalServerError)
return return
} }
defer rows.Close() defer rows.Close()
var difficultyLevels []string var difficultyLevels []AdminLevelTemplateS
for rows.Next() { for rows.Next() {
var level string var level AdminLevelTemplateS
if err := rows.Scan(&level); err != nil { if err := rows.Scan(&level.ID, &level.Name); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError) http.Error(w, "Database error", http.StatusInternalServerError)
return return
} }
@@ -487,6 +495,27 @@ func AdminPositionsHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/positions", http.StatusSeeOther) http.Redirect(w, r, "/admin/positions", http.StatusSeeOther)
return return
} }
if r.PostForm.Has("update") {
// Updating an existing position
positionID := r.FormValue("update")
gps := r.FormValue("gps")
if gps == "" {
http.Error(w, "GPS field cannot be empty", http.StatusBadRequest)
return
}
clue := r.FormValue("clue")
if clue == "" {
http.Error(w, "Clue field cannot be empty", http.StatusBadRequest)
return
}
_, err := db.Exec("UPDATE positions SET gps = ?, clue = ? WHERE id = ?", gps, clue, positionID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/positions", http.StatusSeeOther)
return
}
// Adding a new position // Adding a new position
gps := r.FormValue("gps") gps := r.FormValue("gps")
clue := r.FormValue("clue") clue := r.FormValue("clue")
@@ -610,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,11 +8,18 @@ import (
"html/template" "html/template"
"net/http" "net/http"
"strings" "strings"
"time"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
const domain = "http://localhost:8080" const domain = "https://klice.h21.cz"
const dbFile = "./klice.db"
const (
smallHelpPenalty = 5
giveUpPenalty = 30
)
var db *sql.DB var db *sql.DB
@@ -34,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)
@@ -45,6 +55,9 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
Name: "session_id", Name: "session_id",
Value: sessionID, Value: sessionID,
Path: "/", Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: true,
} }
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
@@ -52,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)
} }
@@ -101,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)
@@ -138,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
} }
@@ -190,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
} }
@@ -210,7 +227,7 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
} }
CipherTemplateData := CipherTemplateS{ CipherTemplateData := CipherTemplateS{
Order: uint(cipherID), Order: uint(order),
Assignment: template.HTML(assignment), Assignment: template.HTML(assignment),
HelpText: "", HelpText: "",
FinalClue: "", FinalClue: "",
@@ -220,6 +237,7 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
} }
// get penalties for this task and team // get penalties for this task and team
if help == 0 {
err = db.QueryRow("SELECT minutes FROM penalties WHERE team_id = ? AND task_id = ?", teamID, taskID).Scan(&penalty) err = db.QueryRow("SELECT minutes FROM penalties WHERE team_id = ? AND task_id = ?", teamID, taskID).Scan(&penalty)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
penalty = 0 penalty = 0
@@ -228,11 +246,12 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// determine help level based on penalties // determine help level based on penalties
if penalty > 0 && penalty < 15 { if penalty > 0 && penalty <= smallHelpPenalty {
help = 1 help = 1
} else if penalty >= 15 { } else if penalty > smallHelpPenalty {
help = 2 help = 2
} }
}
// handle answer and help form submission // handle answer and help form submission
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
@@ -242,13 +261,13 @@ 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 = 30 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 != "" { // 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)
if err != nil { if err != nil {
@@ -276,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
@@ -289,15 +308,17 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
} }
CipherTemplateData.FinalClue = endClue CipherTemplateData.FinalClue = endClue
// get coordinates // get coordinates
var coordinates string var coordinates, positionHint string
err = db.QueryRow("SELECT gps FROM POSITIONS WHERE id = (SELECT position_id FROM TASKS WHERE id = (SELECT id FROM TASKS WHERE order_num = ? AND difficulty_level = (SELECT difficulty_level FROM teams WHERE id = ?)))", order+1, teamID).Scan(&coordinates) err = db.QueryRow("SELECT gps, clue FROM POSITIONS WHERE id = (SELECT position_id FROM TASKS WHERE id = (SELECT id FROM TASKS WHERE order_num = ? AND difficulty_level = (SELECT difficulty_level FROM teams WHERE id = ?)))", order+1, teamID).Scan(&coordinates, &positionHint)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
coordinates = "" coordinates = "Konec, vraťte se."
positionHint = "KONEC"
} else if err != nil { } else if err != nil {
http.Error(w, "Could not retrieve coordinates", http.StatusInternalServerError) http.Error(w, "Could not retrieve coordinates", http.StatusInternalServerError)
return return
} }
CipherTemplateData.Coordinates = coordinates CipherTemplateData.Coordinates = coordinates
CipherTemplateData.PositionHint = positionHint
// get solution // get solution
var solution string var solution string
err = db.QueryRow("SELECT solution FROM CIPHERS WHERE id = ?", cipherID).Scan(&solution) err = db.QueryRow("SELECT solution FROM CIPHERS WHERE id = ?", cipherID).Scan(&solution)
@@ -319,9 +340,16 @@ 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", "./klice.db?_fk=on") db, err = sql.Open("sqlite3", dbFile+"?_fk=on")
if err != nil { if err != nil {
fmt.Println("Error opening database:", err) fmt.Println("Error opening database:", err)
return return
@@ -333,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)
@@ -345,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,26 +1,33 @@
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
Solution string Solution string
Help int Help int
Wrong bool Wrong bool
URL string
} }
type TeamTemplateS struct { type TeamTemplateS struct {
ID int
TeamName string TeamName string
Difficulty string Difficulty string
LastCipher int LastCipher int
LastLoadedCipher int
Penalties int Penalties int
} }
@@ -50,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 {
@@ -72,11 +79,25 @@ type AdminQRTemplateS struct {
Positions []int Positions []int
} }
var CipherTemplate = template.Must(template.ParseFiles("templates/assignment.html")) type AdminLevelTemplateS struct {
var TeamTemplate = template.Must(template.ParseFiles("templates/team.html")) ID int
var AdminTeamsTemplate = template.Must(template.ParseFiles("templates/adminTeams.html")) Name string
var AdminRoutesTemplate = template.Must(template.ParseFiles("templates/adminRoutes.html")) }
var AdminLevelTemplate = template.Must(template.ParseFiles("templates/adminLevels.html"))
var AdminCipherTemplate = template.Must(template.ParseFiles("templates/adminCiphers.html")) type AdminPenaltiesTemplateS struct {
var AdminPositionsTemplate = template.Must(template.ParseFiles("templates/adminPositions.html")) TeamName string
var AdminQRsTemplate = template.Must(template.ParseFiles("templates/adminQR.html")) TaskOrder uint
Minutes int
}
var CipherTemplate = template.Must(template.ParseFS(templatesFS, "templates/assignment.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

@@ -35,11 +35,11 @@
<h2>Nová šifra</h2> <h2>Nová šifra</h2>
<form action="/admin/cipher" method="post"> <form action="/admin/cipher" method="post">
<label for="assignment">Zadání:</label> <label for="assignment">Zadání:</label>
<input type="text" id="assignment" name="assignment" required> <textarea id="assignment" name="assignment" cols="40" rows="5" required></textarea>
<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

@@ -10,15 +10,17 @@
<h1>Úrovně obtížnosti</h1> <h1>Úrovně obtížnosti</h1>
<table border="1"> <table border="1">
<tr> <tr>
<th>ID</th>
<th>Jméno</th> <th>Jméno</th>
<th>Smazat</th> <th>Smazat</th>
</tr> </tr>
{{range .}} {{range .}}
<tr> <tr>
<td>{{.}}</td> <td>{{.ID}}</td>
<td>{{.Name}}</td>
<td> <td>
<form action="/admin/levels" method="post"> <form action="/admin/levels" method="post">
<input type="hidden" name="delete" value="{{.}}"> <input type="hidden" name="delete" value="{{.ID}}">
<button type="submit">Smazat</button> <button type="submit">Smazat</button>
</form> </form>
</td> </td>

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

@@ -19,8 +19,16 @@
{{range .}} {{range .}}
<tr> <tr>
<td>{{.ID}}</td> <td>{{.ID}}</td>
<td>{{.GPS}}</td> <form action="/admin/positions" method="post">
<td>{{.Clue}}</td> <input type="hidden" name="update" value="{{.ID}}">
<td>
<input type="text" name="gps" value="{{.GPS}}">
</td>
<td>
<input type="text" name="clue" value="{{.Clue}}">
<input type="submit" value="Upravit">
</td>
</form>
<td><a href="{{.URL}}">{{.URL}}</a></td> <td><a href="{{.URL}}">{{.URL}}</a></td>
<td> <td>
<form action="/admin/positions" method="post"> <form action="/admin/positions" method="post">

View File

@@ -17,7 +17,7 @@
</tr> </tr>
{{range .QRs}} {{range .QRs}}
<tr> <tr>
<td>{{.URL}}</td> <td><a href="{{.URL}}">{{.URL}}</a></td>
<td>{{.Position}}</td> <td>{{.Position}}</td>
<td>{{.GPS}}</td> <td>{{.GPS}}</td>
<td> <td>

View File

@@ -12,6 +12,7 @@
<h2>{{.Name}}</h2> <h2>{{.Name}}</h2>
<table border="1"> <table border="1">
<tr> <tr>
<th>ID</th>
<th>Pořadí</th> <th>Pořadí</th>
<th>Souřadnice</th> <th>Souřadnice</th>
<th>Nápověda pozice</th> <th>Nápověda pozice</th>
@@ -19,10 +20,12 @@
<th>Nápověda</th> <th>Nápověda</th>
<th>Řešení</th> <th>Řešení</th>
<th>Cílová indicie</th> <th>Cílová indicie</th>
<th>URL</th>
<th>Smazat</th> <th>Smazat</th>
</tr> </tr>
{{range .Ciphers}} {{range .Ciphers}}
<tr> <tr>
<td>{{.ID}}</td>
<td>{{.Order}}</td> <td>{{.Order}}</td>
<td>{{.Coordinates}}</td> <td>{{.Coordinates}}</td>
<td>{{.PositionHint}}</td> <td>{{.PositionHint}}</td>
@@ -30,6 +33,7 @@
<td>{{.HelpText}}</td> <td>{{.HelpText}}</td>
<td>{{.Solution}}</td> <td>{{.Solution}}</td>
<td>{{.FinalClue}}</td> <td>{{.FinalClue}}</td>
<td><a href="{{.URL}}">{{.URL}}</a></td>
<td> <td>
<form action="/admin/routes" method="post"> <form action="/admin/routes" method="post">
<input type="hidden" name="delete" value="{{.ID}}"> <input type="hidden" name="delete" value="{{.ID}}">

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,54 +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>
<div class="wrap">
<div class="card">
<h1>Zadání šifry {{.Order}}</h1> <h1>Zadání šifry {{.Order}}</h1>
<p>{{.Assignment}}</p> <div class="meta">Aktuální úkol</div>
<hr>
<div class="assignment">
{{.Assignment}}
</div>
<div class="controls">
{{if eq .Help 0}} {{if eq .Help 0}}
<p>Požádat o malou nápovědu.</p> <div>
<form method="post"> <div class="muted">Požádat o malou nápovědu</div>
<form method="post" style="margin-top:8px;">
<input type="hidden" name="help" value="1"> <input type="hidden" name="help" value="1">
<input type="submit" value="Zobrazit nápovědu"> <input class="btn" type="submit" value="Zobrazit nápovědu">
</form> </form>
</div>
{{else if eq .Help 1}} {{else if eq .Help 1}}
<p>Nápověda: {{.HelpText}}</p> <div style="flex:1">
<p>Vzdát se a ukázat pozici další šifry.</p> <div class="helpbox">Nápověda: {{.HelpText}}</div>
<form method="post"> <div style="margin-top:12px">
<div class="muted">Pokud se chcete vzdát a získat pozici další šifry:</div>
<form method="post" style="margin-top:8px;">
<input type="hidden" name="help" value="2"> <input type="hidden" name="help" value="2">
<input type="submit" value="Vzdát se"> <input class="btn warn" type="submit" value="Vzdát se">
</form> </form>
</div>
</div>
{{else}} {{else}}
<p>Řešení: {{.Solution}}</p> <div style="flex:1">
<div class="solution">Řešení: {{.Solution}}</div>
</div>
{{end}} {{end}}
</div>
<hr> <hr>
{{if ne .Help 2}} {{if ne .Help 2}}
{{if .Wrong}}<p style="color:red;">Špatné řešení, zkus to znovu.</p>{{end}} {{if .Wrong}}<p class="error">Špatné řešení, zkus to znovu.</p>{{end}}
<form method="post"> <form method="post" style="margin-top:12px;">
Řešení: <input type="text" name="solution"><br> <label for="solution">Zadejte řešení</label>
<input type="submit" value="Odeslat"> <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> </form>
{{else}} {{else}}
<p> <div>
Souřadnice další šifry: <p class="muted">Souřadnice další šifry</p>
<input id="gps" value="{{.Coordinates}}" readonly /> <div class="row">
<br> <div class="gps" style="flex:1">
<button onclick="copyToClipboard()">Zkopírovat do schránky</button> <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>
<p>Nápověda k nalezení cíle: {{.FinalClue}}</p> <p class="muted">Nápověda k nalezení cíle: <strong>{{.FinalClue}}</strong></p>
</div>
{{end}} {{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">
<div class="card">
<h1>Přihlášení do týmu</h1>
<div class="sub">Zadejte své týmové heslo</div>
{{if .}}
<div class="error">Chybné heslo — zkuste to prosím znovu.</div>
{{end}}
<form action="/login" method="post"> <form action="/login" method="post">
<label for="password">Heslo:</label> <div>
<input type="password" id="password" name="password" required> <label for="password">Heslo</label>
<br> <input id="password" name="password" type="password" required autocomplete="current-password">
<button type="submit">Přihlásit se</button> </div>
<div>
<button class="btn" type="submit">Přihlásit se</button>
</div>
</form> </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>
<div class="wrap">
<div class="card">
<h1>Tým: {{.TeamName}}</h1> <h1>Tým: {{.TeamName}}</h1>
Obtížnost: {{.Difficulty}}<br> <div class="meta">Přehled týmu</div>
Poslední šifra : {{.LastCipher}}<br>
Penalizace: {{.Penalties}}<br> <div class="info">
<hr> <div class="details">
<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"> <form action="/logout" method="get">
<input type="submit" value="Odhlásit"> <button class="logout" type="submit">Odhlásit</button>
</form> </form>
</div>
</div>
</div>
</body> </body>
</html> </html>