diff --git a/handlers/admin/audit.go b/handlers/admin/audit.go new file mode 100644 index 0000000..b778968 --- /dev/null +++ b/handlers/admin/audit.go @@ -0,0 +1,54 @@ +package handlers + +import ( + "database/sql" + "html/template" + "log" + "net/http" + "synlotto-website/helpers" + "synlotto-website/middleware" +) + +type AdminLogEntry struct { + AccessedAt string + UserID int + Path string + IP string + UserAgent string +} + +func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc { + return middleware.Auth(true)(func(w http.ResponseWriter, r *http.Request) { + context := helpers.TemplateContext(w, r) + + rows, err := db.Query(` + SELECT accessed_at, user_id, path, ip, user_agent + FROM admin_access_log + ORDER BY accessed_at DESC + LIMIT 100 + `) + if err != nil { + log.Println("⚠️ Failed to load admin access logs:", err) + http.Error(w, "Error loading logs", http.StatusInternalServerError) + return + } + defer rows.Close() + + var logs []AdminLogEntry + for rows.Next() { + var entry AdminLogEntry + if err := rows.Scan(&entry.AccessedAt, &entry.UserID, &entry.Path, &entry.IP, &entry.UserAgent); err != nil { + log.Println("⚠️ Scan failed:", err) + continue + } + logs = append(logs, entry) + } + context["AuditLogs"] = logs + + tmpl := template.Must(template.New("").Funcs(helpers.TemplateFuncs()).ParseFiles( + "templates/layout.html", + "templates/admin/access_log.html", + )) + _ = tmpl.ExecuteTemplate(w, "layout", context) + }) +} diff --git a/handlers/admin/draws.go b/handlers/admin/draws.go index 1cbfa0a..341875e 100644 --- a/handlers/admin/draws.go +++ b/handlers/admin/draws.go @@ -3,9 +3,11 @@ package handlers import ( "database/sql" "html/template" + "log" "net/http" helpers "synlotto-website/helpers" + "synlotto-website/models" ) func NewDrawHandler(db *sql.DB) http.HandlerFunc { @@ -70,3 +72,41 @@ func DeleteDrawHandler(db *sql.DB) http.HandlerFunc { } }) } + +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) + }) +} diff --git a/handlers/draw_handler.go b/handlers/draw_handler.go index 0138b48..99c3221 100644 --- a/handlers/draw_handler.go +++ b/handlers/draw_handler.go @@ -107,41 +107,3 @@ 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) - }) -} diff --git a/helpers/database.go b/helpers/database.go new file mode 100644 index 0000000..9b66e56 --- /dev/null +++ b/helpers/database.go @@ -0,0 +1,16 @@ +package helpers + +import ( + "database/sql" + "log" +) + +func IsAdmin(db *sql.DB, userID int) bool { + var isAdmin bool + err := db.QueryRow(`SELECT is_admin FROM users WHERE id = ?`, userID).Scan(&isAdmin) + if err != nil { + log.Printf("⚠️ Failed to check is_admin for user %d: %v", userID, err) + return false + } + return isAdmin +} diff --git a/helpers/pages.go b/helpers/pages.go new file mode 100644 index 0000000..33a6c4a --- /dev/null +++ b/helpers/pages.go @@ -0,0 +1,17 @@ +package helpers + +// ToDo should be a handler? +import ( + "html/template" + "net/http" +) + +func Render403(w http.ResponseWriter, r *http.Request) { + context := TemplateContext(w, r) + tmpl := template.Must(template.New("").Funcs(TemplateFuncs()).ParseFiles( + "templates/layout.html", + "templates/error/403.html", + )) + + tmpl.ExecuteTemplate(w, "layout", context) +} diff --git a/main.go b/main.go index 7b59cdf..59b8786 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,8 @@ func main() { db := storage.InitDB("synlotto.db") models.SetDB(db) + var isProduction = false + csrfMiddleware := csrf.Protect( []byte("abcdefghijklmnopqrstuvwx12345678"), // TodO: Make Global csrf.Secure(true), @@ -33,22 +35,28 @@ func main() { mux.HandleFunc("/", handlers.Home(db)) + wrapped := helpers.RateLimit(csrfMiddleware(mux)) + wrapped = middleware.EnforceHTTPS(wrapped, isProduction) + wrapped = middleware.SecureHeaders(wrapped) + wrapped = middleware.Recover(wrapped) + log.Println("🌐 Running on http://localhost:8080") - http.ListenAndServe(":8080", helpers.RateLimit(csrfMiddleware(mux))) + http.ListenAndServe(":8080", wrapped) } func setupAdminRoutes(mux *http.ServeMux, db *sql.DB) { - mux.HandleFunc("/admin/dashboard", admin.AdminDashboardHandler(db)) - mux.HandleFunc("/admin/triggers", admin.AdminTriggersHandler(db)) + mux.HandleFunc("/admin/access", middleware.AdminOnly(db, admin.AdminAccessLogHandler(db))) + mux.HandleFunc("/admin/dashboard", middleware.AdminOnly(db, admin.AdminDashboardHandler(db))) + mux.HandleFunc("/admin/triggers", middleware.AdminOnly(db, 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)) + mux.HandleFunc("/admin/draws/new", middleware.AdminOnly(db, admin.NewDrawHandler(db))) + mux.HandleFunc("/admin/draws/modify", middleware.AdminOnly(db, admin.ModifyDrawHandler(db))) + mux.HandleFunc("/admin/draws/delete", middleware.AdminOnly(db, admin.DeleteDrawHandler(db))) // Prize management - mux.HandleFunc("/admin/draws/prizes/add", admin.AddPrizesHandler(db)) - mux.HandleFunc("/admin/draws/prizes/modify", admin.ModifyPrizesHandler(db)) + mux.HandleFunc("/admin/draws/prizes/add", middleware.AdminOnly(db, admin.AddPrizesHandler(db))) + mux.HandleFunc("/admin/draws/prizes/modify", middleware.AdminOnly(db, admin.ModifyPrizesHandler(db))) } func setupAccountRoutes(mux *http.ServeMux, db *sql.DB) { diff --git a/middleware/admin.go b/middleware/admin.go new file mode 100644 index 0000000..0e76150 --- /dev/null +++ b/middleware/admin.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "database/sql" + "log" + "net/http" + "synlotto-website/helpers" +) + +// Wraps an existing handler but checks is_admin before executing +func AdminOnly(db *sql.DB, next http.HandlerFunc) http.HandlerFunc { + return Auth(true)(func(w http.ResponseWriter, r *http.Request) { + userID, ok := helpers.GetCurrentUserID(r) + if !ok || !helpers.IsAdmin(db, userID) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + ip := r.RemoteAddr + ua := r.UserAgent() + path := r.URL.Path + + // Store log entry in DB + _, err := db.Exec(` + INSERT INTO admin_access_log (user_id, path, ip, user_agent) + VALUES (?, ?, ?, ?)`, + userID, path, ip, ua, + ) + if err != nil { + log.Printf("⚠️ Failed to log admin access: %v", err) + } + + // Optional: still log to console + log.Printf("🛡️ Admin access: user_id=%d IP=%s Path=%s", userID, ip, path) + + next(w, r) + }) +} diff --git a/middleware/recover.go b/middleware/recover.go new file mode 100644 index 0000000..3b075e6 --- /dev/null +++ b/middleware/recover.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "log" + "net/http" + "runtime/debug" +) + +func Recover(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.Printf("🔥 Recovered from panic: %v\n%s", rec, debug.Stack()) + + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} diff --git a/middleware/security.go b/middleware/security.go new file mode 100644 index 0000000..79bde08 --- /dev/null +++ b/middleware/security.go @@ -0,0 +1,24 @@ +package middleware + +import "net/http" + +// Redirects all HTTP to HTTPS (only in production) +func EnforceHTTPS(next http.Handler, enabled bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if enabled && r.Header.Get("X-Forwarded-Proto") != "https" && r.TLS == nil { + http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusMovedPermanently) + return + } + next.ServeHTTP(w, r) + }) +} + +func SecureHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", "default-src 'self'") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "1; mode=block") + next.ServeHTTP(w, r) + }) +} diff --git a/storage/db.go b/storage/db.go index 5917112..05f9bcf 100644 --- a/storage/db.go +++ b/storage/db.go @@ -137,7 +137,8 @@ func InitDB(filepath string) *sql.DB { CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL + password_hash TEXT NOT NULL, + is_admin BOOLEAN );` if _, err := db.Exec(createUsersTable); err != nil { @@ -170,5 +171,18 @@ func InitDB(filepath string) *sql.DB { log.Fatal("❌ Failed to create ticket matching log table:", err) } + createAdminAccessLogTable := ` + CREATE TABLE IF NOT EXISTS admin_access_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + accessed_at DATETIME DEFAULT CURRENT_TIMESTAMP, + path TEXT, + ip TEXT, + user_agent TEXT + );` + + if _, err := db.Exec(createAdminAccessLogTable); err != nil { + log.Fatal("❌ Failed to create admin access log table:", err) + } return db } diff --git a/templates/admin/logs/access_logs.html b/templates/admin/logs/access_logs.html new file mode 100644 index 0000000..26d4be8 --- /dev/null +++ b/templates/admin/logs/access_logs.html @@ -0,0 +1,26 @@ +{{ define "content" }} +

Admin Access Log

+ + + + + + + + + + + + + {{ range .AuditLogs }} + + + + + + + + {{ end }} + +
TimeUser IDPathIPUser Agent
{{ .AccessedAt }}{{ .UserID }}{{ .Path }}{{ .IP }}{{ .UserAgent }}
+{{ end }} diff --git a/templates/error/403.html b/templates/error/403.html new file mode 100644 index 0000000..5667338 --- /dev/null +++ b/templates/error/403.html @@ -0,0 +1,5 @@ +{{ define "content" }} +

🚫 Forbidden

+

You do not have permission to access this page.

+Return to Home +{{ end }} \ No newline at end of file diff --git a/templates/error/404.html b/templates/error/404.html new file mode 100644 index 0000000..aab8f37 --- /dev/null +++ b/templates/error/404.html @@ -0,0 +1,3 @@ +{{ define "content" }} +

Not Found

The page doesn't exist.

+{{ end }} \ No newline at end of file