Compare commits

..

46 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
7ba33e248f help system 2025-09-14 20:40:39 +02:00
488084f1bb cipher templates 2025-09-14 19:05:41 +02:00
c0687e6bc2 only one connection to DB 2025-09-05 15:08:12 +02:00
8eea2e4d59 penalties and assignment html 2025-09-04 22:22:17 +02:00
dc12434af3 get cipher from qr 2025-09-04 21:37:37 +02:00
890f2406a7 login redirect 2025-09-04 20:10:11 +02:00
8abc610bec basic auth 2025-09-04 19:33:52 +02:00
18 changed files with 2114 additions and 15 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

@@ -1,19 +1,19 @@
DROP TABLE IF EXISTS TEAMS;
DROP TABLE IF EXISTS POSITIONS;
DROP TABLE IF EXISTS QR_CODES;
DROP INDEX IF EXISTS idx_qr_uid;
DROP TABLE IF EXISTS CIPHERS;
DROP TABLE IF EXISTS DIFFICULTY_LEVELS;
DROP TABLE IF EXISTS TASKS;
DROP TABLE IF EXISTS ADMINS;
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,10 +25,9 @@ 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 UNIQUE INDEX idx_qr_uid ON QR_codes(uid);
CREATE TABLE CIPHERS (
id INTEGER PRIMARY KEY,
assignment TEXT NOT NULL,
@@ -54,4 +53,12 @@ CREATE TABLE ADMINS (
id INTEGER PRIMARY KEY,
username VARCHAR(100) NOT NULL,
password VARCHAR(255) NOT NULL
);
CREATE TABLE PENALTIES (
id INTEGER PRIMARY KEY,
team_id INTEGER NOT NULL,
task_id INTEGER NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (team_id) REFERENCES TEAMS(id),
FOREIGN KEY (task_id) REFERENCES TASKS(id)
);

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module klice25
go 1.24.6
require github.com/mattn/go-sqlite3 v1.14.32

View File

@@ -4,17 +4,18 @@ INSERT INTO DIFFICULTY_LEVELS (id, level_name) VALUES
(2, 'Střední'),
(3, 'Těžká');
-- Vložení týmů
INSERT INTO TEAMS (id, name, city, difficulty_level, password, last_cipher, penalty) VALUES
(1, 'Rychlé šípy', 'Praha', 1, 'heslo1', 0, 0),
(2, 'Vlčí smečka', 'Brno', 2, 'heslo2', 0, 10),
(3, 'Orli', 'Ostrava', 3, 'heslo3', 1, 5);
-- Vložení týmů: heslo1, heslo2, heslo3
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
(1, '50.087451,14.420671', 'Najdi sochu uprostřed náměstí.'),
(2, '49.195061,16.606836', 'Podívej se pod lavičku.'),
(3, '49.820923,18.262524', 'Hledej u velkého stromu.');
(3, '49.820923,18.262524', 'Hledej u velkého stromu.'),
(4, '50.075538,14.437800', 'Kousek od fontány.');
-- Vložení QR kódů
INSERT INTO QR_CODES (id, position_id, uid) VALUES
@@ -31,9 +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
-- Vložení admin: heslo
INSERT INTO ADMINS (id, username, password) VALUES
(1, 'admin', 'adminheslo');
(1, 'admin', '56b1db8133d9eb398aabd376f07bf8ab5fc584ea0b8bd6a1770200cb613ca005');

392
klice.go Normal file
View File

@@ -0,0 +1,392 @@
package main
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"html/template"
"net/http"
"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 {
hash := sha256.Sum256([]byte(password))
return hex.EncodeToString(hash[:])
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
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)
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
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)
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)
}
}
case http.MethodGet:
err := LoginTemplate.Execute(w, false)
if err != nil {
http.Error(w, "Could not render template", http.StatusInternalServerError)
}
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func logoutHandler(w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{
Name: "session_id",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func isLoggedIn(w http.ResponseWriter, r *http.Request) (bool, int) {
var exist bool = true
var teamID int
cookie, err := r.Cookie("session_id")
if err != nil {
exist = false
} else {
err = db.QueryRow("SELECT id FROM teams WHERE password = ?", cookie.Value).Scan(&teamID)
if err == sql.ErrNoRows {
exist = false
} else if err != nil {
exist = false
}
}
if !exist {
redir := &http.Cookie{
Name: "url",
Value: r.URL.String(),
MaxAge: 300,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: true,
}
http.SetCookie(w, redir)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return false, 0
}
return true, teamID
}
func teamInfoHandler(w http.ResponseWriter, r *http.Request) {
if loggedIn, teamID := isLoggedIn(w, r); loggedIn {
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 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
}
}
}
func qrHandler(w http.ResponseWriter, r *http.Request) {
uid := r.PathValue("qr")
if uid == "" {
http.Error(w, "Invalid QR code", http.StatusBadRequest)
return
}
var positionID int
err := db.QueryRow("SELECT position_id FROM qr_codes WHERE uid = ?", uid).Scan(&positionID)
if err == sql.ErrNoRows {
http.Error(w, "QR code not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, "Could not retrieve position", http.StatusInternalServerError)
return
}
if loggedIn, teamID := isLoggedIn(w, r); loggedIn {
var assignment string
var cipherID int
var taskID int
var order int
var last_cipher int
var help int = 0
var penalty int = 0
// Find task for this position and team's difficulty level
err = db.QueryRow("SELECT id FROM TASKS WHERE position_id = ? AND difficulty_level = (SELECT difficulty_level FROM teams WHERE id = ?)", positionID, teamID).Scan(&taskID)
if err == sql.ErrNoRows {
http.Error(w, "No task found for this position and team", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, "Could not retrieve task", http.StatusInternalServerError)
return
}
// get task order
err = db.QueryRow("SELECT order_num FROM TASKS WHERE id = ?", taskID).Scan(&order)
if err != nil {
http.Error(w, "Could not retrieve task order", http.StatusInternalServerError)
return
}
// get last cipher visited by team
err = db.QueryRow("SELECT last_cipher FROM teams WHERE id = ?", teamID).Scan(&last_cipher)
if err != nil {
http.Error(w, "Could not retrieve last cipher", http.StatusInternalServerError)
return
}
// check if the task is available for the team
// 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 {
http.Error(w, "This task is not yet available", http.StatusForbidden)
return
} 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 loaded cipher", http.StatusInternalServerError)
return
}
} else if order < last_cipher {
help = 2
}
// get cipher assignment
err = db.QueryRow("SELECT id, assignment FROM CIPHERS WHERE id = (SELECT cipher_id FROM TASKS WHERE id = ?)", taskID).Scan(&cipherID, &assignment)
if err == sql.ErrNoRows {
http.Error(w, "No cipher found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, "Could not retrieve cipher", http.StatusInternalServerError)
return
}
CipherTemplateData := CipherTemplateS{
Order: uint(order),
Assignment: template.HTML(assignment),
HelpText: "",
FinalClue: "",
Coordinates: "",
Solution: "",
Wrong: false,
}
// 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)
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
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
http.Error(w, "Could not parse form", http.StatusBadRequest)
return
}
if r.FormValue("help") == "1" && help == 0 { // small help
help = 1
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 = 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
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 {
helpText = ""
} else if err != nil {
http.Error(w, "Could not retrieve help text", http.StatusInternalServerError)
return
}
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)
if err == sql.ErrNoRows {
endClue = ""
} else if err != nil {
http.Error(w, "Could not retrieve end clue", http.StatusInternalServerError)
return
}
CipherTemplateData.FinalClue = endClue
// get 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 = "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)
if err == sql.ErrNoRows {
solution = ""
} else if err != nil {
http.Error(w, "Could not retrieve solution", http.StatusInternalServerError)
return
}
CipherTemplateData.Solution = solution
}
CipherTemplateData.Help = help
err = CipherTemplate.Execute(w, CipherTemplateData)
if err != nil {
http.Error(w, "Could not render template", http.StatusInternalServerError)
return
}
}
}
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", dbFile+"?_fk=on")
if err != nil {
fmt.Println("Error opening database:", err)
return
}
defer db.Close()
db.SetMaxOpenConns(1)
// klice app
http.HandleFunc("/login", loginHandler)
http.HandleFunc("/logout", logoutHandler)
http.HandleFunc("/team", teamInfoHandler)
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")
srv.ListenAndServe()
}

103
templates.go Normal file
View File

@@ -0,0 +1,103 @@
package main
import (
"embed"
"html/template"
)
//go:embed templates/*.html
var templatesFS embed.FS
type CipherTemplateS struct {
ID int
Order uint
Assignment template.HTML
HelpText template.HTML
FinalClue string
Coordinates string
PositionHint string
Solution string
Help int
Wrong bool
URL string
}
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>

270
templates/assignment.html Normal file
View File

@@ -0,0 +1,270 @@
<!DOCTYPE html>
<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>
<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>

125
templates/login.html Normal file
View File

@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<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>
<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>