Compare commits

...

39 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
9f207a0f5c handle static images in ciphers
put in static folder
2025-10-09 19:55:07 +02:00
a897d5943b admin: add routes 2025-10-09 19:42:10 +02:00
3ca41ec9da admin: qr kody 2025-10-09 19:08:44 +02:00
a343f14f1e admin: pozice 2025-10-09 17:59:17 +02:00
68c081891f admin: sifry 2025-10-09 17:25:25 +02:00
045c91c4a0 admin: tymy, pridani a mazani 2025-10-09 16:19:44 +02:00
f6075c5eb6 admin: urovne obtiznosti 2025-10-09 15:48:59 +02:00
2aa4b580c5 copy gsp to clipboard, order task separately 2025-10-08 10:54:20 +02:00
6c489b8d05 admin redirect to login 2025-09-18 22:49:53 +02:00
2c864b67e3 task order is unique 2025-09-18 22:47:13 +02:00
dae0d1cd51 isAdmin 2025-09-18 21:40:16 +02:00
32d25037c2 admin view routes and tasks 2025-09-18 20:20:18 +02:00
e9efea447b add admin teams view 2025-09-18 16:55:07 +02:00
cd6728ac97 refactor
switch, join SQL commands
2025-09-18 14:38:24 +02:00
5cf1cfedbc admin login 2025-09-17 22:22:44 +02:00
2cfd23cdb1 team info 2025-09-17 21:51:17 +02:00
c9c717f1e3 handle answers 2025-09-17 19:08:35 +02:00
17 changed files with 1823 additions and 128 deletions

672
admin.go Normal file
View File

@@ -0,0 +1,672 @@
package main
import (
"database/sql"
"encoding/base64"
"net/http"
"regexp"
)
type difficultyLevel struct {
ID int
LevelName string
}
func adminLoginHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
err := adminLoginTemplate.Execute(w, false)
if err != nil {
http.Error(w, "Could not render template", http.StatusInternalServerError)
}
case http.MethodPost:
if err := r.ParseForm(); err != nil {
http.Error(w, "Error parsing form", http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
err := db.QueryRow(
"SELECT 1 FROM admins WHERE username=? AND PASSWORD=?",
username, hashPassword(password),
).Scan(new(int))
switch {
case err == sql.ErrNoRows:
err := adminLoginTemplate.Execute(w, true)
if err != nil {
http.Error(w, "Could not render template", http.StatusInternalServerError)
}
case err != nil:
http.Error(w, "Database error", http.StatusInternalServerError)
default:
http.SetCookie(w, &http.Cookie{
Name: "admin_session",
Value: base64.StdEncoding.EncodeToString([]byte(username + ":" + hashPassword(password))),
Path: "/admin/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: true,
})
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func adminLogoutHandler(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "admin_session", Value: "", Path: "/admin/", HttpOnly: true, MaxAge: -1})
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
func isAdmin(r *http.Request) bool {
cookie, err := r.Cookie("admin_session")
if err != nil {
return false
}
decoded, err := base64.StdEncoding.DecodeString(cookie.Value)
if err != nil {
return false
}
var username, passwordHash string
regex := regexp.MustCompile(`^([^:]+):([a-f0-9]+)$`)
matches := regex.FindStringSubmatch(string(decoded))
if len(matches) != 3 {
return false
}
username = matches[1]
passwordHash = matches[2]
err = db.QueryRow("SELECT 1 FROM admins WHERE username=? AND PASSWORD=?", username, passwordHash).Scan(new(int))
if err != sql.ErrNoRows && err == nil {
return true
}
return false
}
func adminHandler(w http.ResponseWriter, r *http.Request) {
if !isAdmin(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
http.ServeFileFS(w, r, templatesFS, "templates/adminPanel.html")
}
func adminTeamsHandler(w http.ResponseWriter, r *http.Request) {
if !isAdmin(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
http.Error(w, "Error parsing form", http.StatusBadRequest)
return
}
// Deleting an existing team
if r.PostForm.Has("delete") {
teamName := r.FormValue("delete")
_, err := db.Exec("DELETE FROM teams WHERE name = ?", teamName)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/teams", http.StatusSeeOther)
return
}
// Adding a new team
teamName := r.FormValue("name")
difficulty := r.FormValue("difficulty")
password := r.FormValue("password")
if teamName == "" || difficulty == "" || password == "" {
http.Error(w, "All fields are required", http.StatusBadRequest)
return
}
_, err := db.Exec("INSERT INTO teams (name, difficulty_level, password, last_cipher, penalty) VALUES (?, ?, ?, 1, 0)", teamName, difficulty, hashPassword(password))
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/teams", http.StatusSeeOther)
return
}
// Fetch all teams with their difficulty levels
// Teams
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 {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var teams []TeamTemplateS
for rows.Next() {
var team TeamTemplateS
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)
return
}
teams = append(teams, team)
}
if err := rows.Err(); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
// Difficulty levels for the dropdown
rows, err = db.Query("SELECT id, level_name FROM difficulty_levels ORDER BY id")
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var difficultyLevels []DifficultyLevelS
for rows.Next() {
var level DifficultyLevelS
if err := rows.Scan(&level.ID, &level.Name); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
difficultyLevels = append(difficultyLevels, level)
}
if err := rows.Err(); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
teamsData := TeamsTemplateS{
Teams: teams,
Difficulties: difficultyLevels,
}
if err := AdminTeamsTemplate.Execute(w, teamsData); err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
}
func AdminStartHandler(w http.ResponseWriter, r *http.Request) {
if !isAdmin(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
_, err := db.Exec("UPDATE teams SET last_cipher = 1, last_loaded_cipher = 0, penalty = 0")
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
_, err = db.Exec("DELETE FROM penalties")
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
func AdminRouteHandler(w http.ResponseWriter, r *http.Request) {
if !isAdmin(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
http.Error(w, "Error parsing form", http.StatusBadRequest)
return
}
// Deleting an existing route point
if r.PostForm.Has("delete") {
cipherID := r.FormValue("delete")
_, err := db.Exec("DELETE FROM tasks WHERE id = ?", cipherID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/routes", http.StatusSeeOther)
return
}
// Adding a new route point
order := r.FormValue("order")
level := r.FormValue("level")
position := r.FormValue("position")
cipher := r.FormValue("cipher")
endClue := r.FormValue("endclue")
if order == "" || level == "" || position == "" || cipher == "" || endClue == "" {
http.Error(w, "All fields are required", http.StatusBadRequest)
return
}
_, err := db.Exec("INSERT INTO tasks (order_num, difficulty_level, position_id, cipher_id, end_clue) VALUES (?, ?, ?, ?, ?)", order, level, position, cipher, endClue)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/routes", http.StatusSeeOther)
return
}
// Fetch all ciphers for the dropdown
rows, err := db.Query("SELECT id FROM ciphers ORDER BY id")
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var ciphers []int
for rows.Next() {
var cipher int
if err := rows.Scan(&cipher); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
ciphers = append(ciphers, cipher)
}
if err := rows.Err(); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
// Fetch all positions for the dropdown
rows, err = db.Query("SELECT id FROM positions ORDER BY id")
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var positions []int
for rows.Next() {
var position int
if err := rows.Scan(&position); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
positions = append(positions, position)
}
if err := rows.Err(); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
// Levels for the dropdown
rows, err = db.Query("SELECT id FROM difficulty_levels ORDER BY id")
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var levels []int
for rows.Next() {
var level int
if err := rows.Scan(&level); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
levels = append(levels, level)
}
if err := rows.Err(); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
// Fetch all difficulty levels
rows, err = db.Query("SELECT id, level_name FROM difficulty_levels ORDER BY id")
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var difficultyLevels []difficultyLevel
for rows.Next() {
var level difficultyLevel
if err := rows.Scan(&level.ID, &level.LevelName); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
difficultyLevels = append(difficultyLevels, level)
}
// For each difficulty level, fetch the corresponding tasks and their details
var routes []AdminRouteTemplateS
for _, level := range difficultyLevels {
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, 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 {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
route.Name = level.LevelName
for rows.Next() {
var cipher CipherTemplateS
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)
return
}
cipher.URL = domain + "/qr/" + cipher.URL
route.Ciphers = append(route.Ciphers, cipher)
}
if err := rows.Err(); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
routes = append(routes, route)
}
// Prepare data for the template
data := AdminRoutesTemplateS{
Routes: routes,
Levels: levels,
Positions: positions,
Ciphers: ciphers,
}
// Render the template
if err := AdminRoutesTemplate.Execute(w, data); err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
}
func AdminLevelHandler(w http.ResponseWriter, r *http.Request) {
if !isAdmin(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
http.Error(w, "Error parsing form", http.StatusBadRequest)
return
}
// Deleting an existing difficulty level
if r.PostForm.Has("delete") {
id := r.FormValue("delete")
_, err := db.Exec("DELETE FROM difficulty_levels WHERE id = ?", id)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/levels", http.StatusSeeOther)
return
}
// Adding a new difficulty level
levelName := r.FormValue("name")
if levelName == "" {
http.Error(w, "Level name cannot be empty", http.StatusBadRequest)
return
}
_, err := db.Exec("INSERT INTO difficulty_levels (level_name) VALUES (?)", levelName)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/levels", http.StatusSeeOther)
return
}
rows, err := db.Query("SELECT id, level_name FROM difficulty_levels ORDER BY id")
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var difficultyLevels []AdminLevelTemplateS
for rows.Next() {
var level AdminLevelTemplateS
if err := rows.Scan(&level.ID, &level.Name); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
difficultyLevels = append(difficultyLevels, level)
}
if err := rows.Err(); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if err := AdminLevelTemplate.Execute(w, difficultyLevels); err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
}
func AdminCipherHandler(w http.ResponseWriter, r *http.Request) {
if !isAdmin(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
http.Error(w, "Error parsing form", http.StatusBadRequest)
return
}
// Deleting an existing cipher
if r.PostForm.Has("delete") {
cipherID := r.FormValue("delete")
_, err := db.Exec("DELETE FROM ciphers WHERE id = ?", cipherID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/cipher", http.StatusSeeOther)
return
}
// Adding a new cipher
assignment := r.FormValue("assignment")
solution := r.FormValue("solution")
clue := r.FormValue("clue")
if assignment == "" || solution == "" || clue == "" {
http.Error(w, "All fields are required", http.StatusBadRequest)
return
}
_, err := db.Exec("INSERT INTO ciphers (assignment, solution, clue) VALUES (?, ?, ?)", assignment, solution, clue)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/cipher", http.StatusSeeOther)
return
}
rows, err := db.Query("SELECT id, assignment, solution, clue FROM ciphers ORDER BY id")
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var ciphers []AdminCipherTemplateS
for rows.Next() {
var cipher AdminCipherTemplateS
if err := rows.Scan(&cipher.ID, &cipher.Assignment, &cipher.Solution, &cipher.Clue); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
ciphers = append(ciphers, cipher)
}
if err := rows.Err(); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if err := AdminCipherTemplate.Execute(w, ciphers); err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
}
func AdminPositionsHandler(w http.ResponseWriter, r *http.Request) {
if !isAdmin(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
http.Error(w, "Error parsing form", http.StatusBadRequest)
return
}
// Deleting an existing position
if r.PostForm.Has("delete") {
positionID := r.FormValue("delete")
_, err := db.Exec("DELETE FROM positions WHERE id = ?", positionID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/positions", http.StatusSeeOther)
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
gps := r.FormValue("gps")
clue := r.FormValue("clue")
if gps == "" || clue == "" {
http.Error(w, "All fields are required", http.StatusBadRequest)
return
}
_, err := db.Exec("INSERT INTO positions (gps, clue) VALUES (?, ?)", gps, clue)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/positions", http.StatusSeeOther)
return
}
rows, err := db.Query("SELECT positions.id, gps, clue, COALESCE(uid, '') FROM positions LEFT JOIN QR_CODES ON positions.id = QR_CODES.position_id ORDER BY positions.id")
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var positions []AdminPositionsTemplateS
for rows.Next() {
var position AdminPositionsTemplateS
if err := rows.Scan(&position.ID, &position.GPS, &position.Clue, &position.URL); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
position.URL = domain + "/qr/" + position.URL
positions = append(positions, position)
}
if err := rows.Err(); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if err := AdminPositionsTemplate.Execute(w, positions); err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
}
func AdminQRHandler(w http.ResponseWriter, r *http.Request) {
if !isAdmin(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
http.Error(w, "Error parsing form", http.StatusBadRequest)
return
}
// Deleting an existing QR code
if r.PostForm.Has("delete") {
qrID := r.FormValue("delete")
_, err := db.Exec("DELETE FROM qr_codes WHERE id = ?", qrID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/qr", http.StatusSeeOther)
return
}
// Adding a new QR code
positionID := r.FormValue("position")
uid := r.FormValue("uid")
if positionID == "" || uid == "" {
http.Error(w, "All fields are required", http.StatusBadRequest)
return
}
_, err := db.Exec("INSERT INTO qr_codes (position_id, uid) VALUES (?, ?)", positionID, uid)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/qr", http.StatusSeeOther)
return
}
// Fetch all QR codes with their associated positions
rows, err := db.Query("SELECT qr_codes.id, qr_codes.uid, COALESCE(position_id, ''), COALESCE(gps, '') FROM qr_codes LEFT JOIN positions ON qr_codes.position_id = positions.id ORDER BY qr_codes.id")
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var qrs []AdminQRsTemplateS
for rows.Next() {
var qr AdminQRsTemplateS
if err := rows.Scan(&qr.ID, &qr.URL, &qr.Position, &qr.GPS); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
qr.URL = domain + "/qr/" + qr.URL
qrs = append(qrs, qr)
}
if err := rows.Err(); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
// Fetch all positions for the dropdown
rows, err = db.Query("SELECT id FROM positions ORDER BY id")
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var positions []int
for rows.Next() {
var position int
if err := rows.Scan(&position); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
positions = append(positions, position)
}
if err := rows.Err(); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if err := AdminQRsTemplate.Execute(w, AdminQRTemplateS{QRs: qrs, Positions: positions}); err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
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

@@ -10,10 +10,10 @@ DROP TABLE IF EXISTS PENALTIES;
CREATE TABLE TEAMS (
id INTEGER PRIMARY KEY,
name VARCHAR(100) NOT NULL,
city VARCHAR(100) NOT NULL,
difficulty_level INTEGER NOT NULL,
password VARCHAR(255) NOT NULL,
last_cipher INTEGER DEFAULT 0,
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,
FOREIGN KEY (difficulty_level) REFERENCES DIFFICULTY_LEVELS(id)
);
@@ -25,7 +25,7 @@ CREATE TABLE POSITIONS (
CREATE TABLE QR_CODES (
id INTEGER PRIMARY KEY,
position_id INTEGER NOT NULL,
uid VARCHAR(100) NOT NULL,
uid VARCHAR(100) UNIQUE NOT NULL,
FOREIGN KEY (position_id) REFERENCES positions(id)
);
CREATE TABLE CIPHERS (

View File

@@ -5,10 +5,10 @@ INSERT INTO DIFFICULTY_LEVELS (id, level_name) VALUES
(3, 'Těžká');
-- Vložení týmů: heslo1, heslo2, heslo3
INSERT INTO TEAMS (id, name, city, difficulty_level, password, last_cipher, penalty) VALUES
(1, 'Rychlé šípy', 'Praha', 1, '4bc2ef0648cdf275032c83bb1e87dd554d47f4be293670042212c8a01cc2ccbe', 0, 0),
(2, 'Vlčí smečka', 'Brno', 2, '274efeaa827a33d7e35be9a82cd6150b7caf98f379a4252aa1afce45664dcbe1', 0, 10),
(3, 'Orli', 'Ostrava', 3, '05af533c6614544a704c4cf51a45be5c10ff19bd10b7aa1dfe47efc0fd059ede', 1, 5);
INSERT INTO TEAMS (id, name, difficulty_level, password, last_cipher, last_loaded_cipher, penalty) VALUES
(1, 'Rychlé šípy', 1, '4bc2ef0648cdf275032c83bb1e87dd554d47f4be293670042212c8a01cc2ccbe', 1, 0, 0),
(2, 'Vlčí smečka', 2, '274efeaa827a33d7e35be9a82cd6150b7caf98f379a4252aa1afce45664dcbe1', 1, 0, 10),
(3, 'Orli', 3, '05af533c6614544a704c4cf51a45be5c10ff19bd10b7aa1dfe47efc0fd059ede', 1, 0, 5);
-- Vložení pozic
INSERT INTO POSITIONS (id, gps, clue) VALUES
@@ -32,10 +32,10 @@ INSERT INTO CIPHERS (id, assignment, solution, clue) VALUES
-- Vložení úkolů
INSERT INTO TASKS (id, cipher_id, position_id, difficulty_level, order_num, end_clue) VALUES
(1, 1, 1, 1, 1, 'Pokračuj k dalšímu stanovišti.'),
(2, 2, 2, 2, 2, 'Hledej QR kód u stromu.'),
(3, 3, 3, 3, 3, 'Gratulujeme, jsi v cíli!'),
(2, 2, 2, 2, 1, 'Hledej QR kód u stromu.'),
(3, 3, 3, 3, 1, 'Gratulujeme, jsi v cíli!'),
(4, 1, 4, 1, 2, 'To je vše, děkujeme za účast!');
-- Vložení admina: heslo
-- Vložení admin: heslo
INSERT INTO ADMINS (id, username, password) VALUES
(1, 'admin', '56b1db8133d9eb398aabd376f07bf8ab5fc584ea0b8bd6a1770200cb613ca005');

224
klice.go
View File

@@ -6,14 +6,21 @@ import (
"encoding/hex"
"fmt"
"html/template"
"io"
"net/http"
"os"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
const domain = "https://klice.h21.cz"
const dbFile = "./klice.db"
const (
smallHelpPenalty = 5
giveUpPenalty = 30
)
var db *sql.DB
func hashPassword(password string) string {
@@ -22,51 +29,54 @@ func hashPassword(password string) string {
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
// Handle login logic here
switch r.Method {
case http.MethodPost:
if err := r.ParseForm(); err != nil {
http.Error(w, "Could not parse form", http.StatusBadRequest)
return
}
password := r.FormValue("password")
hashedPassword := hashPassword(password)
var teamID int
err := db.QueryRow("SELECT id FROM teams WHERE password = ?", hashedPassword).Scan(&teamID)
if err == sql.ErrNoRows {
http.Error(w, "No team found", http.StatusUnauthorized)
err := db.QueryRow("SELECT 1 FROM teams WHERE password = ?", hashedPassword).Scan(new(int))
switch {
case err == sql.ErrNoRows:
err = LoginTemplate.Execute(w, true)
if err != nil {
http.Error(w, "Could not render template", http.StatusInternalServerError)
}
return
} else if err != nil {
case err != nil:
http.Error(w, "Could not retrieve team", http.StatusInternalServerError)
return
}
default:
sessionID := hashedPassword
cookie := &http.Cookie{
Name: "session_id",
Value: sessionID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: true,
}
http.SetCookie(w, cookie)
var sessionID string
sessionID = hashedPassword
cookie := &http.Cookie{
Name: "session_id",
Value: sessionID,
Path: "/",
redir, err := r.Cookie("url")
if err == nil {
redir.MaxAge = -1
http.SetCookie(w, redir)
http.Redirect(w, r, safeRedirectURL(redir.Value), http.StatusSeeOther)
} else {
http.Redirect(w, r, "/team", http.StatusSeeOther)
}
}
http.SetCookie(w, cookie)
redir, err := r.Cookie("url")
if err == nil {
redir.MaxAge = -1
http.SetCookie(w, redir)
http.Redirect(w, r, redir.Value, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/team", http.StatusSeeOther)
}
} else if r.Method == http.MethodGet {
loginPage, err := os.Open("templates/login.html")
case http.MethodGet:
err := LoginTemplate.Execute(w, false)
if err != nil {
http.Error(w, "Could not open login page", http.StatusInternalServerError)
return
http.Error(w, "Could not render template", http.StatusInternalServerError)
}
defer loginPage.Close()
io.Copy(w, loginPage)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
@@ -107,6 +117,7 @@ func isLoggedIn(w http.ResponseWriter, r *http.Request) (bool, int) {
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: true,
}
http.SetCookie(w, redir)
http.Redirect(w, r, "/login", http.StatusSeeOther)
@@ -117,19 +128,35 @@ func isLoggedIn(w http.ResponseWriter, r *http.Request) (bool, int) {
func teamInfoHandler(w http.ResponseWriter, r *http.Request) {
if loggedIn, teamID := isLoggedIn(w, r); loggedIn {
var teamName, city, last_cipher, penalty string
err := db.QueryRow("SELECT name, city, last_cipher, penalty FROM teams WHERE id = ?", teamID).Scan(&teamName, &city, &last_cipher, &penalty)
var teamName string
var difficultyLevel string
var lastCipher int
var penalty int
err := db.QueryRow("SELECT name, level_name, last_cipher, penalty FROM teams JOIN difficulty_levels ON teams.difficulty_level = difficulty_levels.id WHERE teams.id = ?", teamID).Scan(&teamName, &difficultyLevel, &lastCipher, &penalty)
if err != nil {
http.Error(w, "Could not retrieve team information", http.StatusInternalServerError)
http.Error(w, "Could not retrieve team info", http.StatusInternalServerError)
return
}
TeamTemplateData := TeamTemplateS{
TeamName: teamName,
Difficulty: difficultyLevel,
LastCipher: lastCipher,
Penalties: penalty,
}
err = TeamTemplate.Execute(w, TeamTemplateData)
if err != nil {
http.Error(w, "Could not render template", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "Team Name: %s, City: %s, Last Cipher: %s, Penalty: %s", teamName, city, last_cipher, penalty)
}
}
func qrHandler(w http.ResponseWriter, r *http.Request) {
uid, found := strings.CutPrefix(r.URL.Path, "/qr/")
if !found || uid == "" {
uid := r.PathValue("qr")
if uid == "" {
http.Error(w, "Invalid QR code", http.StatusBadRequest)
return
}
@@ -173,19 +200,19 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
return
}
// check if the task is available for the team
// if order > last_cipher + 1, task is not yet available
// if order == last_cipher + 1, task is now available, update last_cipher
// if order > last_cipher, task is not yet available
// if order == last_cipher, task is now available
// if order <= last_cipher, task has been already visited, allow viewing
if order > last_cipher+1 {
if order > last_cipher {
http.Error(w, "This task is not yet available", http.StatusForbidden)
return
} else if order == last_cipher+1 {
last_cipher = order
_, err = db.Exec("UPDATE teams SET last_cipher = ? WHERE id = ?", order, teamID)
} else if order == last_cipher {
_, err := db.Exec("UPDATE teams SET last_loaded_cipher = ? WHERE id = ?", order, teamID)
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
}
} else if order < last_cipher {
help = 2
}
@@ -200,27 +227,30 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
}
CipherTemplateData := CipherTemplateS{
Order: uint(cipherID),
Order: uint(order),
Assignment: template.HTML(assignment),
HelpText: "",
FinalClue: "",
Coordinates: "",
Solution: "",
Wrong: false,
}
// 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 err == sql.ErrNoRows {
penalty = 0
} else if err != nil {
http.Error(w, "Could not retrieve penalties", http.StatusInternalServerError)
return
}
// determine help level based on penalties
if penalty > 0 && penalty < 15 {
help = 1
} else if penalty >= 15 {
help = 2
if help == 0 {
err = db.QueryRow("SELECT minutes FROM penalties WHERE team_id = ? AND task_id = ?", teamID, taskID).Scan(&penalty)
if err == sql.ErrNoRows {
penalty = 0
} else if err != nil {
http.Error(w, "Could not retrieve penalties", http.StatusInternalServerError)
return
}
// determine help level based on penalties
if penalty > 0 && penalty <= smallHelpPenalty {
help = 1
} else if penalty > smallHelpPenalty {
help = 2
}
}
// handle answer and help form submission
@@ -231,18 +261,32 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
}
if r.FormValue("help") == "1" && help == 0 { // small help
help = 1
db.Exec("INSERT INTO penalties (team_id, task_id, minutes) VALUES (?, ?, 5)", teamID, taskID)
db.Exec("UPDATE teams SET penalty = penalty + 5 WHERE id = ?", teamID)
db.Exec("INSERT INTO penalties (team_id, task_id, minutes) VALUES (?, ?, ?)", teamID, taskID, smallHelpPenalty)
db.Exec("UPDATE teams SET penalty = penalty + ? WHERE id = ?", smallHelpPenalty, teamID)
} else if r.FormValue("help") == "2" && help == 1 { // give up
help = 2
db.Exec("UPDATE penalties SET minutes = 30 WHERE team_id = ? AND task_id = ?", teamID, taskID)
db.Exec("UPDATE teams SET penalty = penalty + 30 WHERE id = ?", teamID)
db.Exec("UPDATE teams SET last_cipher = ? WHERE id = ?", order+1, teamID)
db.Exec("UPDATE penalties SET minutes = minutes + ? WHERE team_id = ? AND task_id = ?", giveUpPenalty, teamID, taskID)
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
var correctAnswer string
err = db.QueryRow("SELECT solution FROM CIPHERS WHERE id = ?", cipherID).Scan(&correctAnswer)
if err != nil {
http.Error(w, "Could not retrieve solution", http.StatusInternalServerError)
return
}
if strings.EqualFold(strings.TrimSpace(answer), strings.TrimSpace(correctAnswer)) {
// correct answer, move to next task
db.Exec("UPDATE teams SET last_cipher = ? WHERE id = ?", order+1, teamID)
help = 2
} else {
CipherTemplateData.Wrong = true
}
}
}
// find which clues to show
if help == 1 { // small help
switch help {
case 1: // small help
var helpText string
err = db.QueryRow("SELECT clue FROM CIPHERS WHERE id = ?", cipherID).Scan(&helpText)
if err == sql.ErrNoRows {
@@ -251,8 +295,8 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Could not retrieve help text", http.StatusInternalServerError)
return
}
CipherTemplateData.HelpText = helpText
} else if help == 2 { // next cipher
CipherTemplateData.HelpText = template.HTML(helpText)
case 2: // next cipher
// get end clue
var endClue string
err = db.QueryRow("SELECT end_clue FROM TASKS WHERE id = ?", taskID).Scan(&endClue)
@@ -264,15 +308,17 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
}
CipherTemplateData.FinalClue = endClue
// get coordinates
var coordinates 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)
var coordinates, positionHint string
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 {
coordinates = ""
coordinates = "Konec, vraťte se."
positionHint = "KONEC"
} else if err != nil {
http.Error(w, "Could not retrieve coordinates", http.StatusInternalServerError)
return
}
CipherTemplateData.Coordinates = coordinates
CipherTemplateData.PositionHint = positionHint
// get solution
var solution string
err = db.QueryRow("SELECT solution FROM CIPHERS WHERE id = ?", cipherID).Scan(&solution)
@@ -294,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() {
var err error
db, err = sql.Open("sqlite3", "./klice.db?_fk=on")
db, err = sql.Open("sqlite3", dbFile+"?_fk=on")
if err != nil {
fmt.Println("Error opening database:", err)
return
@@ -304,11 +357,36 @@ func main() {
defer db.Close()
db.SetMaxOpenConns(1)
// klice app
http.HandleFunc("/login", loginHandler)
http.HandleFunc("/logout", logoutHandler)
http.HandleFunc("/team", teamInfoHandler)
http.HandleFunc("/qr/", qrHandler)
http.HandleFunc("/qr/{qr...}", qrHandler)
// admin app
http.HandleFunc("/admin/login", adminLoginHandler)
http.HandleFunc("/admin/logout", adminLogoutHandler)
http.HandleFunc("/admin/", adminHandler)
http.HandleFunc("/admin/teams", adminTeamsHandler)
http.HandleFunc("/admin/start", AdminStartHandler)
http.HandleFunc("/admin/routes", AdminRouteHandler)
http.HandleFunc("/admin/levels", AdminLevelHandler)
http.HandleFunc("/admin/cipher", AdminCipherHandler)
http.HandleFunc("/admin/positions", AdminPositionsHandler)
http.HandleFunc("/admin/qr", AdminQRHandler)
http.HandleFunc("/admin/penalties", AdminPenaltiesHandler)
// static files
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")
http.ListenAndServe(":8080", nil)
srv.ListenAndServe()
}

View File

@@ -1,17 +1,103 @@
package main
import (
"embed"
"html/template"
)
//go:embed templates/*.html
var templatesFS embed.FS
type CipherTemplateS struct {
Order uint
Assignment template.HTML
HelpText string
FinalClue string
Coordinates string
Solution string
Help int
ID int
Order uint
Assignment template.HTML
HelpText template.HTML
FinalClue string
Coordinates string
PositionHint string
Solution string
Help int
Wrong bool
URL string
}
var CipherTemplate = template.Must(template.ParseFiles("templates/assignment.html"))
type TeamTemplateS struct {
ID int
TeamName string
Difficulty string
LastCipher int
LastLoadedCipher int
Penalties int
}
type DifficultyLevelS struct {
ID int
Name string
}
type TeamsTemplateS struct {
Teams []TeamTemplateS
Difficulties []DifficultyLevelS
}
type AdminRouteTemplateS struct {
Name string
Ciphers []CipherTemplateS
}
type AdminRoutesTemplateS struct {
Routes []AdminRouteTemplateS
Levels []int
Positions []int
Ciphers []int
}
type AdminCipherTemplateS struct {
ID int
Assignment template.HTML
Solution string
Clue template.HTML
}
type AdminPositionsTemplateS struct {
ID int
GPS string
Clue string
URL string
}
type AdminQRsTemplateS struct {
URL string
Position string
GPS string
ID int
}
type AdminQRTemplateS struct {
QRs []AdminQRsTemplateS
Positions []int
}
type AdminLevelTemplateS struct {
ID int
Name string
}
type AdminPenaltiesTemplateS struct {
TeamName string
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

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Šifry</title>
</head>
<body>
<h1>Šifry</h1>
<table border="1">
<tr>
<th>ID</th>
<th>Zadání</th>
<th>Řešení</th>
<th>Nápověda</th>
<th>Smazat</th>
</tr>
{{range .}}
<tr>
<td>{{.ID}}</td>
<td>{{.Assignment}}</td>
<td>{{.Solution}}</td>
<td>{{.Clue}}</td>
<td>
<form method="post" action="/admin/cipher">
<input type="hidden" name="delete" value="{{.ID}}">
<input type="submit" value="Smazat">
</form>
</td>
</tr>
{{end}}
</table>
<hr>
<h2>Nová šifra</h2>
<form action="/admin/cipher" method="post">
<label for="assignment">Zadání:</label>
<textarea id="assignment" name="assignment" cols="40" rows="5" required></textarea>
<label for="solution">Řešení:</label>
<input type="text" id="solution" name="solution" required>
<label for="clue">Nápověda:</label>
<textarea id="clue" name="clue" cols="40" rows="5" required></textarea>
<input type="submit" value="Přidat šifru">
</form>
<hr>
<a href="/admin">Zpět na admin panel</a>
</body>
</html>

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Úrovně obtížnosti</title>
</head>
<body>
<h1>Úrovně obtížnosti</h1>
<table border="1">
<tr>
<th>ID</th>
<th>Jméno</th>
<th>Smazat</th>
</tr>
{{range .}}
<tr>
<td>{{.ID}}</td>
<td>{{.Name}}</td>
<td>
<form action="/admin/levels" method="post">
<input type="hidden" name="delete" value="{{.ID}}">
<button type="submit">Smazat</button>
</form>
</td>
</tr>
{{end}}
</table>
<hr>
<h2>Přidat novou úroveň</h2>
<form action="/admin/levels" method="post">
Jméno: <input type="text" name="name" required>
<button type="submit">Přidat úroveň</button>
</form>
<hr>
<a href="/admin">Zpět na admin panel</a>
</body>
</html>

34
templates/adminLogin.html Normal file
View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin — přihlášení</title>
</head>
<body>
<h1>Admin Login</h1>
{{if .}}
<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">
</div>
</form>
<p><a href="/admin">Zpět na admin panel</a></p>
</body>
</html>

24
templates/adminPanel.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Admin Panel</title>
</head>
<body>
<h1>Admin Panel</h1>
<a href="/admin/teams">Týmy</a> <br>
<a href="/admin/routes">Trasy</a> <br>
<a href="/admin/levels">Úrovně</a> <br>
<a href="/admin/cipher">Šifry</a> <br>
<a href="/admin/positions">Pozice</a> <br>
<a href="/admin/qr">QR Kódy</a> <br>
<a href="/admin/penalties">Penalizace týmů</a> <br>
<hr>
<form method="post" action="/admin/logout">
<input type="submit" value="Logout">
</form>
</body>
</html>

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

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Pozice</title>
</head>
<body>
<h1>Pozice</h1>
<table border="1">
<tr>
<th>ID</th>
<th>GPS</th>
<th>Nápověda</th>
<th>URL</th>
<th>Smazat</th>
</tr>
{{range .}}
<tr>
<td>{{.ID}}</td>
<form action="/admin/positions" method="post">
<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>
<form action="/admin/positions" method="post">
<input type="hidden" name="delete" value="{{.ID}}">
<input type="submit" value="Smazat">
</form>
</td>
</tr>
{{end}}
</table>
<hr>
<h2>Přidat pozici</h2>
<form method="post" action="/admin/positions">
<label for="gps">GPS:</label>
<input type="text" id="gps" name="gps" required><br>
<label for="clue">Nápověda:</label>
<input type="text" id="clue" name="clue" required><br>
<input type="submit" value="Přidat pozici">
</form>
<hr>
<a href="/admin">Zpět na admin panel</a>
</body>
</html>

47
templates/adminQR.html Normal file
View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>QR</title>
</head>
<body>
<h1>QR</h1>
<table border="1">
<tr>
<th>URL</th>
<th>Pozice</th>
<th>GPS</th>
<th>Smazat</th>
</tr>
{{range .QRs}}
<tr>
<td><a href="{{.URL}}">{{.URL}}</a></td>
<td>{{.Position}}</td>
<td>{{.GPS}}</td>
<td>
<form action="/admin/qr" method="post">
<input type="hidden" name="delete" value="{{.ID}}">
<button type="submit">Smazat</button>
</form>
</td>
</tr>
{{end}}
</table>
<hr>
<h2>Přidat nový QR kód</h2>
<form action="/admin/qr" method="post">
URL - jenom poslední část: <input type="text" name="uid" required>
Pozice: <select name="position" required>
{{range .Positions}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select>
<button type="submit">Přidat QR kód</button>
</form>
<hr>
<a href="/admin">Zpět na admin panel</a>
</body>
</html>

View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Trasy</title>
</head>
<body>
<h1>Trasy</h1>
{{range .Routes}}
<h2>{{.Name}}</h2>
<table border="1">
<tr>
<th>ID</th>
<th>Pořadí</th>
<th>Souřadnice</th>
<th>Nápověda pozice</th>
<th>Zadání</th>
<th>Nápověda</th>
<th>Řešení</th>
<th>Cílová indicie</th>
<th>URL</th>
<th>Smazat</th>
</tr>
{{range .Ciphers}}
<tr>
<td>{{.ID}}</td>
<td>{{.Order}}</td>
<td>{{.Coordinates}}</td>
<td>{{.PositionHint}}</td>
<td>{{.Assignment}}</td>
<td>{{.HelpText}}</td>
<td>{{.Solution}}</td>
<td>{{.FinalClue}}</td>
<td><a href="{{.URL}}">{{.URL}}</a></td>
<td>
<form action="/admin/routes" method="post">
<input type="hidden" name="delete" value="{{.ID}}">
<button type="submit">Smazat</button>
</form>
</td>
</tr>
{{end}}
</table>
<hr>
{{end}}
<h2>Přidat bod trasy</h2>
<form action="/admin/routes" method="post">
Pořadí: <input type="number" name="order" required> <br>
Obtížnost: <select name="level" required>
{{range .Levels}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select> <br>
Šifra: <select name="cipher" required>
{{range .Ciphers}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select> <br>
Pozice : <select name="position" required>
{{range .Positions}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select> <br>
Koncová indicie: <input type="text" name="endclue">
<button type="submit">Přidat bod trasy</button>
</form>
<hr>
<a href="/admin">Zpět na admin panel</a>
</body>
</html>

58
templates/adminTeams.html Normal file
View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Týmy</title>
</head>
<body>
<h1>Týmy</h1>
<table border="1">
<tr>
<th>ID</th>
<th>Název týmu</th>
<th>Obtížnost</th>
<th>Právě řešená šifra</th>
<th>Poslední načtená šifra</th>
<th>Penalizace (minuty)</th>
<th>Smazat tým</th>
</tr>
{{range .Teams}}
<tr>
<td>{{.ID}}</td>
<td>{{.TeamName}}</td>
<td>{{.Difficulty}}</td>
<td>{{.LastCipher}}</td>
<td>{{.LastLoadedCipher}}</td>
<td>{{.Penalties}}</td>
<td>
<form action="/admin/teams" method="post">
<input type="hidden" name="delete" value="{{.TeamName}}">
<button type="submit">Smazat</button>
</form>
</td>
</tr>
{{end}}
</table>
<hr>
<p>Vynulování penalizací a posledních šifer.</p>
<a href="/admin/start">Start Závodu</a>
<hr>
<h2>Přidat tým</h2>
<form method="post" action="/admin/teams">
Název týmu: <input type="text" name="name" required>
Obtížnost:
<select name="difficulty">
{{range .Difficulties}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
Heslo: <input type="text" name="password" required>
<input type="submit" value="Přidat tým">
</form>
<hr>
<a href="/admin">Zpět na admin panel</a>
</body>
</html>

View File

@@ -2,39 +2,269 @@
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<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>
<body>
<h1>Zadání šifry {{.Order}}</h1>
<p>{{.Assignment}}</p>
<hr>
{{if eq .Help 0}}
<p>Požádat o malou nápovědu.</p>
<form method="post">
<input type="hidden" name="help" value="1">
<input type="submit" value="Zobrazit nápovědu">
</form>
{{else if eq .Help 1}}
<p>Nápověda: {{.HelpText}}</p>
<p>Vzdát se a ukázat pozici další šifry.</p>
<form method="post">
<input type="hidden" name="help" value="2">
<input type="submit" value="Vzdát se">
</form>
{{else}}
<p>Řešení: {{.Solution}}</p>
{{end}}
<hr>
{{if ne .Help 2}}
<form method="post">
Řešení: <input type="text" name="assignment"><br>
<input type="submit" value="Odeslat">
</form>
{{else}}
<p>Souřadnice další šifry: {{.Coordinates}}</p>
<p>Nápověda k nalezení cíle: {{.FinalClue}}</p>
{{end}}
<div class="wrap">
<div class="card">
<h1>Zadání šifry {{.Order}}</h1>
<div class="meta">Aktuální úkol</div>
<div class="assignment">
{{.Assignment}}
</div>
<div class="controls">
{{if eq .Help 0}}
<div>
<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 class="btn" type="submit" value="Zobrazit nápovědu">
</form>
</div>
{{else if eq .Help 1}}
<div style="flex:1">
<div class="helpbox">Nápověda: {{.HelpText}}</div>
<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 class="btn warn" type="submit" value="Vzdát se">
</form>
</div>
</div>
{{else}}
<div style="flex:1">
<div class="solution">Řešení: {{.Solution}}</div>
</div>
{{end}}
</div>
<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>
</html>

View File

@@ -3,17 +3,123 @@
<head>
<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>
<body>
<h1>Login</h1>
<form action="/login" method="post">
<label for="password">Heslo:</label>
<input type="password" id="password" name="password" required>
<br>
<button type="submit">Přihlásit se</button>
</form>
<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">
<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>
</html>

114
templates/team.html Normal file
View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<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>
<body>
<div class="wrap">
<div class="card">
<h1>Tým: {{.TeamName}}</h1>
<div class="meta">Přehled týmu</div>
<div class="info">
<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">
<button class="logout" type="submit">Odhlásit</button>
</form>
</div>
</div>
</div>
</body>
</html>