expand on admin functionality, hardening still needs to be implemented.

This commit is contained in:
2025-03-31 10:52:12 +01:00
parent b466c351da
commit c3a7480c65
15 changed files with 447 additions and 13 deletions

View File

@@ -0,0 +1,68 @@
package handlers
import (
"database/sql"
"html/template"
"log"
"net/http"
helpers "synlotto-website/helpers"
"synlotto-website/models"
)
func AdminDashboardHandler(db *sql.DB) http.HandlerFunc {
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
// userID, ok := helpers.GetCurrentUserID(r)
// if !ok {
// http.Redirect(w, r, "/login", http.StatusSeeOther)
// return
// }
// TODO: check is_admin from users table here
context := helpers.TemplateContext(w, r)
// Total ticket stats
var total, winners int
var prizeSum float64
db.QueryRow(`SELECT COUNT(*), SUM(CASE WHEN is_winner THEN 1 ELSE 0 END), SUM(prize_amount) FROM my_tickets`).Scan(&total, &winners, &prizeSum)
context["Stats"] = map[string]interface{}{
"TotalTickets": total,
"TotalWinners": winners,
"TotalPrizeAmount": prizeSum,
}
// Match run log
rows, err := db.Query(`
SELECT run_at, triggered_by, tickets_matched, winners_found, COALESCE(notes, '')
FROM log_ticket_matching
ORDER BY run_at DESC LIMIT 10
`)
if err != nil {
log.Println("⚠️ Failed to load logs:", err)
}
defer rows.Close()
var logs []models.MatchLog
for rows.Next() {
var logEntry models.MatchLog
err := rows.Scan(&logEntry.RunAt, &logEntry.TriggeredBy, &logEntry.TicketsMatched, &logEntry.WinnersFound, &logEntry.Notes)
if err != nil {
log.Println("⚠️ Failed to scan log row:", err)
continue
}
logs = append(logs, logEntry)
}
context["MatchLogs"] = logs
tmpl := template.Must(template.New("").Funcs(helpers.TemplateFuncs()).ParseFiles(
"templates/layout.html",
"templates/admin/dashboard.html",
))
err = tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
http.Error(w, "Failed to render dashboard", http.StatusInternalServerError)
}
})
}

72
handlers/admin/draws.go Normal file
View File

@@ -0,0 +1,72 @@
package handlers
import (
"database/sql"
"html/template"
"net/http"
helpers "synlotto-website/helpers"
)
func NewDrawHandler(db *sql.DB) http.HandlerFunc {
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
context := helpers.TemplateContext(w, r)
if r.Method == http.MethodPost {
game := r.FormValue("game_type")
date := r.FormValue("draw_date")
machine := r.FormValue("machine")
ballset := r.FormValue("ball_set")
_, err := db.Exec(`INSERT INTO results_thunderball (game_type, draw_date, machine, ball_set) VALUES (?, ?, ?, ?)`,
game, date, machine, ballset)
if err != nil {
http.Error(w, "Failed to add draw", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
return
}
tmpl := template.Must(template.New("new_draw").Funcs(helpers.TemplateFuncs()).ParseFiles(
"templates/layout.html",
"templates/admin/draws/new_draw.html",
))
tmpl.ExecuteTemplate(w, "layout", context)
})
}
func ModifyDrawHandler(db *sql.DB) http.HandlerFunc {
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
id := r.FormValue("id")
_, err := db.Exec(`UPDATE results_thunderball SET game_type=?, draw_date=?, ball_set=?, machine=? WHERE id=?`,
r.FormValue("game_type"), r.FormValue("draw_date"), r.FormValue("ball_set"), r.FormValue("machine"), id)
if err != nil {
http.Error(w, "Update failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
return
}
// For GET: load draw by ID (pseudo-code)
// id := r.URL.Query().Get("id")
// query DB, pass into context.Draw
})
}
func DeleteDrawHandler(db *sql.DB) http.HandlerFunc {
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
id := r.FormValue("id")
_, err := db.Exec(`DELETE FROM results_thunderball WHERE id = ?`, id)
if err != nil {
http.Error(w, "Delete failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
return
}
})
}

70
handlers/admin/prizes.go Normal file
View File

@@ -0,0 +1,70 @@
package handlers
import (
"database/sql"
"fmt"
"html/template"
"net/http"
"strconv"
"synlotto-website/helpers"
)
func AddPrizesHandler(db *sql.DB) http.HandlerFunc {
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
tmpl := template.Must(template.New("").Funcs(helpers.TemplateFuncs()).ParseFiles(
"templates/layout.html",
"templates/admin/draws/prizes/add_prizes.html",
))
tmpl.ExecuteTemplate(w, "layout", helpers.TemplateContext(w, r))
return
}
drawDate := r.FormValue("draw_date")
values := make([]interface{}, 0)
for i := 1; i <= 9; i++ {
val, _ := strconv.Atoi(r.FormValue(fmt.Sprintf("prize%d_per_winner", i)))
values = append(values, val)
}
stmt := `INSERT INTO prizes_thunderball (
draw_date, prize1_per_winner, prize2_per_winner, prize3_per_winner,
prize4_per_winner, prize5_per_winner, prize6_per_winner,
prize7_per_winner, prize8_per_winner, prize9_per_winner
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := db.Exec(stmt, append([]interface{}{drawDate}, values...)...)
if err != nil {
http.Error(w, "Insert failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/draws", http.StatusSeeOther)
})
}
func ModifyPrizesHandler(db *sql.DB) http.HandlerFunc {
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
tmpl := template.Must(template.New("").Funcs(helpers.TemplateFuncs()).ParseFiles(
"templates/layout.html",
"templates/admin/draws/prizes/modify_prizes.html",
))
tmpl.ExecuteTemplate(w, "layout", helpers.TemplateContext(w, r))
return
}
drawDate := r.FormValue("draw_date")
for i := 1; i <= 9; i++ {
key := fmt.Sprintf("prize%d_per_winner", i)
val, _ := strconv.Atoi(r.FormValue(key))
_, err := db.Exec("UPDATE prizes_thunderball SET "+key+" = ? WHERE draw_date = ?", val, drawDate)
if err != nil {
http.Error(w, "Update failed: "+err.Error(), http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, "/admin/draws", http.StatusSeeOther)
})
}

View File

@@ -107,3 +107,41 @@ func Submit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func ListDrawsHandler(db *sql.DB) http.HandlerFunc {
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
context := helpers.TemplateContext(w, r)
draws := []models.DrawSummary{}
rows, err := db.Query(`
SELECT r.id, r.game_type, r.draw_date, r.ball_set, r.machine,
(SELECT COUNT(1) FROM prizes_thunderball p WHERE p.draw_date = r.draw_date) as prize_exists
FROM results_thunderball r
ORDER BY r.draw_date DESC
`)
if err != nil {
http.Error(w, "Failed to query draws", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var d models.DrawSummary
var prizeFlag int
if err := rows.Scan(&d.Id, &d.GameType, &d.DrawDate, &d.BallSet, &d.Machine, &prizeFlag); err != nil {
log.Println("⚠️ Draw scan failed:", err)
continue
}
d.PrizeSet = prizeFlag > 0
draws = append(draws, d)
}
context["Draws"] = draws
tmpl := template.Must(template.New("draw_list").Funcs(helpers.TemplateFuncs()).ParseFiles(
"templates/layout.html",
"templates/admin/draws/list.html",
))
tmpl.ExecuteTemplate(w, "layout", context)
})
}

40
main.go
View File

@@ -1,10 +1,11 @@
package main
import (
"database/sql"
"log"
"net/http"
"synlotto-website/handlers"
services "synlotto-website/handlers/admin"
admin "synlotto-website/handlers/admin"
"synlotto-website/helpers"
"synlotto-website/middleware"
"synlotto-website/models"
@@ -24,27 +25,40 @@ func main() {
)
mux := http.NewServeMux()
setupAdminRoutes(mux, db)
setupAccountRoutes(mux, db)
setupResultRoutes(mux, db)
// Styling
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
mux.HandleFunc("/", handlers.Home(db))
mux.HandleFunc("/new", handlers.NewDraw) // ToDo: needs to be wrapped in admin auth
mux.HandleFunc("/submit", handlers.Submit)
// Result pages
mux.HandleFunc("/results/thunderball", handlers.ResultsThunderball(db))
log.Println("🌐 Running on http://localhost:8080")
http.ListenAndServe(":8080", helpers.RateLimit(csrfMiddleware(mux)))
}
// Account Pages
func setupAdminRoutes(mux *http.ServeMux, db *sql.DB) {
mux.HandleFunc("/admin/dashboard", admin.AdminDashboardHandler(db))
mux.HandleFunc("/admin/triggers", admin.AdminTriggersHandler(db))
// Draw management
mux.HandleFunc("/admin/draws/new", admin.NewDrawHandler(db))
mux.HandleFunc("/admin/draws/modify", admin.ModifyDrawHandler(db))
mux.HandleFunc("/admin/draws/delete", admin.DeleteDrawHandler(db))
// Prize management
mux.HandleFunc("/admin/draws/prizes/add", admin.AddPrizesHandler(db))
mux.HandleFunc("/admin/draws/prizes/modify", admin.ModifyPrizesHandler(db))
}
func setupAccountRoutes(mux *http.ServeMux, db *sql.DB) {
mux.HandleFunc("/login", middleware.Auth(false)(handlers.Login))
mux.HandleFunc("/logout", handlers.Logout)
mux.HandleFunc("/signup", middleware.Auth(false)(handlers.Signup))
mux.HandleFunc("/account/tickets/add_ticket", handlers.AddTicket(db))
mux.HandleFunc("/account/tickets/my_tickets", handlers.GetMyTickets(db))
// Admin Pages
mux.HandleFunc("/admin/triggers", services.AdminTriggersHandler(db))
log.Println("🌐 Running on http://localhost:8080")
http.ListenAndServe(":8080", helpers.RateLimit(csrfMiddleware(mux)))
}
func setupResultRoutes(mux *http.ServeMux, db *sql.DB) {
mux.HandleFunc("/results/thunderball", handlers.ResultsThunderball(db))
}

View File

@@ -1,5 +1,14 @@
package models
type DrawSummary struct {
Id int
GameType string
DrawDate string
BallSet string
Machine string
PrizeSet bool
}
type ThunderballResult struct {
Id int
DrawDate string

View File

@@ -38,3 +38,12 @@ type MatchRunStats struct {
TicketsMatched int
WinnersFound int
}
type MatchLog struct {
ID int
TriggeredBy string
RunAt string
TicketsMatched int
WinnersFound int
Notes string
}

View File

@@ -0,0 +1,49 @@
{{ define "content" }}
<h2>📊 Admin Dashboard</h2>
<p class="text-sm text-gray-600">Welcome back, admin.</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-6">
<div class="bg-white rounded-xl p-4 shadow">
<h3 class="text-sm text-gray-500">Total Tickets</h3>
<p class="text-2xl font-bold">{{ .Stats.TotalTickets }}</p>
</div>
<div class="bg-white rounded-xl p-4 shadow">
<h3 class="text-sm text-gray-500">Total Winners</h3>
<p class="text-2xl font-bold text-green-600">{{ .Stats.TotalWinners }}</p>
</div>
<div class="bg-white rounded-xl p-4 shadow">
<h3 class="text-sm text-gray-500">Prize Fund Awarded</h3>
<p class="text-2xl font-bold text-blue-600">£{{ printf "%.2f" .Stats.TotalPrizeAmount }}</p>
</div>
</div>
<div class="my-6">
<h3 class="text-lg font-semibold mb-2">Recent Ticket Matches</h3>
<table class="w-full text-sm border">
<thead>
<tr class="bg-gray-100">
<th class="px-2 py-1">Draw Date</th>
<th class="px-2 py-1">Triggered By</th>
<th class="px-2 py-1">Matched</th>
<th class="px-2 py-1">Winners</th>
<th class="px-2 py-1">Notes</th>
</tr>
</thead>
<tbody>
{{ range .MatchLogs }}
<tr class="border-t">
<td class="px-2 py-1">{{ .RunAt }}</td>
<td class="px-2 py-1">{{ .TriggeredBy }}</td>
<td class="px-2 py-1">{{ .TicketsMatched }}</td>
<td class="px-2 py-1">{{ .WinnersFound }}</td>
<td class="px-2 py-1 text-xs text-gray-500">{{ .Notes }}</td>
</tr>
{{ else }}
<tr>
<td colspan="5" class="text-center py-2 italic text-gray-400">No match history found</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ end }}

View File

@@ -0,0 +1,9 @@
{{ define "delete_draw" }}
<h2 class="text-xl font-semibold mb-4">Delete Draw</h2>
<form method="POST" action="/admin/draws/delete">
{{ .CSRFField }}
<input type="hidden" name="id" value="{{ .Draw.ID }}">
<p>Are you sure you want to delete the draw on <strong>{{ .Draw.DrawDate }}</strong>?</p>
<button class="btn bg-red-600 hover:bg-red-700">Delete</button>
</form>
{{ end }}

View File

@@ -0,0 +1,46 @@
{{ define "draw_list" }}
<h2 class="text-xl font-bold mb-4">Draws Overview</h2>
<table class="w-full table-auto border">
<thead class="bg-gray-100">
<tr>
<th>ID</th>
<th>Game</th>
<th>Date</th>
<th>Ball Set</th>
<th>Machine</th>
<th>Prizes?</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .Draws }}
<tr class="border-t">
<td>{{ .ID }}</td>
<td>{{ .GameType }}</td>
<td>{{ .DrawDate }}</td>
<td>{{ .BallSet }}</td>
<td>{{ .Machine }}</td>
<td>
{{ if .PrizeSet }}
{{ else }}
{{ end }}
</td>
<td>
<a href="/admin/draws/modify?id={{ .ID }}" class="text-blue-600">Edit</a> |
<a href="/admin/draws/delete?id={{ .ID }}" class="text-red-600">Delete</a> |
{{ if .PrizeSet }}
<a href="/admin/draws/prizes/modify?draw_date={{ .DrawDate }}" class="text-green-600">Modify Prizes</a>
{{ else }}
<a href="/admin/draws/prizes/add?draw_date={{ .DrawDate }}" class="text-gray-600">Add Prizes</a>
{{ end }}
</td>
</tr>
{{ else }}
<tr><td colspan="7" class="text-center text-gray-500">No draws available.</td></tr>
{{ end }}
</tbody>
</table>
{{ end }}

View File

@@ -0,0 +1,12 @@
{{ define "modify_draw" }}
<h2 class="text-xl font-semibold mb-4">Modify Draw</h2>
<form method="POST" action="/admin/draws/modify">
{{ .CSRFField }}
<input type="hidden" name="id" value="{{ .Draw.ID }}">
<label class="block">Game Type: <input name="game_type" class="input" value="{{ .Draw.GameType }}"></label>
<label class="block">Draw Date: <input name="draw_date" type="date" class="input" value="{{ .Draw.DrawDate }}"></label>
<label class="block">Ball Set: <input name="ball_set" class="input" value="{{ .Draw.BallSet }}"></label>
<label class="block">Machine: <input name="machine" class="input" value="{{ .Draw.Machine }}"></label>
<button class="btn">Update Draw</button>
</form>
{{ end }}

View File

@@ -0,0 +1,11 @@
{{ define "new_draw" }}
<h2 class="text-xl font-semibold mb-4">Add New Draw</h2>
<form method="POST" action="/admin/draws/new">
{{ .CSRFField }}
<label class="block">Game Type: <input name="game_type" class="input"></label>
<label class="block">Draw Date: <input name="draw_date" type="date" class="input"></label>
<label class="block">Ball Set: <input name="ball_set" class="input"></label>
<label class="block">Machine: <input name="machine" class="input"></label>
<button class="btn">Create Draw</button>
</form>
{{ end }}

View File

@@ -0,0 +1,13 @@
{{ define "add_prizes" }}
<h2 class="text-xl font-semibold mb-4">Add Prize Breakdown</h2>
<form method="POST" action="/admin/draws/prizes/add">
{{ .CSRFField }}
<input type="hidden" name="draw_date" value="{{ .DrawDate }}">
{{ range $i, $ := .PrizeLabels }}
<label class="block">{{ . }}
<input name="prize{{ add $i 1 }}_per_winner" class="input">
</label>
{{ end }}
<button class="btn">Save Prizes</button>
</form>
{{ end }}

View File

@@ -0,0 +1,13 @@
{{ define "modify_prizes" }}
<h2 class="text-xl font-semibold mb-4">Modify Prize Breakdown</h2>
<form method="POST" action="/admin/draws/prizes/modify">
{{ .CSRFField }}
<input type="hidden" name="draw_date" value="{{ .DrawDate }}">
{{ range $i, $ := .PrizeLabels }}
<label class="block">{{ . }}
<input name="prize{{ add $i 1 }}_per_winner" class="input" value="{{ index $.Prizes (print "prize" (add $i 1) "_per_winner") }}">
</label>
{{ end }}
<button class="btn">Update Prizes</button>
</form>
{{ end }}

View File

@@ -15,6 +15,7 @@
<p><a href="/login">Login</a></p>
{{ end }}
</div>
<a href="/admin/dashboard" class="hover:underline">Dashboard</a>
{{ if .Flash }}
<div class="flash">{{ .Flash }}</div>