Compare commits
2 Commits
c3a7480c65
...
aaf90b55da
| Author | SHA1 | Date | |
|---|---|---|---|
| aaf90b55da | |||
| 7eefb9ced0 |
98
handlers/admin/audit.go
Normal file
98
handlers/admin/audit.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"synlotto-website/helpers"
|
||||||
|
"synlotto-website/middleware"
|
||||||
|
"synlotto-website/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
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/logs/access_log.html",
|
||||||
|
))
|
||||||
|
_ = tmpl.ExecuteTemplate(w, "layout", context)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuditLogHandler(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 timestamp, user_id, action, ip, user_agent
|
||||||
|
FROM audit_log
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 100
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("❌ Failed to load audit log:", err)
|
||||||
|
http.Error(w, "Could not load audit log", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var logs []models.AuditEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var entry models.AuditEntry
|
||||||
|
err := rows.Scan(&entry.Timestamp, &entry.UserID, &entry.Action, &entry.IP, &entry.UserAgent)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("⚠️ Failed to scan row:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logs = append(logs, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
context["AuditLogs"] = logs
|
||||||
|
|
||||||
|
tmpl := template.Must(template.New("").Funcs(helpers.TemplateFuncs()).ParseFiles(
|
||||||
|
"templates/layout.html",
|
||||||
|
"templates/admin/logs/audit.html",
|
||||||
|
))
|
||||||
|
|
||||||
|
err = tmpl.ExecuteTemplate(w, "layout", context)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("❌ Failed to render audit page:", err)
|
||||||
|
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
helpers "synlotto-website/helpers"
|
helpers "synlotto-website/helpers"
|
||||||
|
"synlotto-website/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDrawHandler(db *sql.DB) http.HandlerFunc {
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,41 +107,3 @@ func Submit(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
16
helpers/database.go
Normal file
16
helpers/database.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
17
helpers/pages.go
Normal file
17
helpers/pages.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -49,20 +49,24 @@ func TemplateContext(w http.ResponseWriter, r *http.Request) map[string]interfac
|
|||||||
}
|
}
|
||||||
|
|
||||||
var currentUser *models.User
|
var currentUser *models.User
|
||||||
|
var isAdmin bool
|
||||||
|
|
||||||
switch v := session.Values["user_id"].(type) {
|
switch v := session.Values["user_id"].(type) {
|
||||||
case int:
|
case int:
|
||||||
currentUser = models.GetUserByID(v)
|
currentUser = models.GetUserByID(v)
|
||||||
case int64:
|
case int64:
|
||||||
currentUser = models.GetUserByID(int(v))
|
currentUser = models.GetUserByID(int(v))
|
||||||
default:
|
}
|
||||||
currentUser = nil
|
|
||||||
|
if currentUser != nil {
|
||||||
|
isAdmin = currentUser.IsAdmin
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"CSRFField": csrf.TemplateField(r),
|
"CSRFField": csrf.TemplateField(r),
|
||||||
"Flash": flash,
|
"Flash": flash,
|
||||||
"User": currentUser,
|
"User": currentUser,
|
||||||
|
"IsAdmin": isAdmin,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
main.go
25
main.go
@@ -18,6 +18,8 @@ func main() {
|
|||||||
db := storage.InitDB("synlotto.db")
|
db := storage.InitDB("synlotto.db")
|
||||||
models.SetDB(db)
|
models.SetDB(db)
|
||||||
|
|
||||||
|
var isProduction = false
|
||||||
|
|
||||||
csrfMiddleware := csrf.Protect(
|
csrfMiddleware := csrf.Protect(
|
||||||
[]byte("abcdefghijklmnopqrstuvwx12345678"), // TodO: Make Global
|
[]byte("abcdefghijklmnopqrstuvwx12345678"), // TodO: Make Global
|
||||||
csrf.Secure(true),
|
csrf.Secure(true),
|
||||||
@@ -33,22 +35,29 @@ func main() {
|
|||||||
|
|
||||||
mux.HandleFunc("/", handlers.Home(db))
|
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")
|
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) {
|
func setupAdminRoutes(mux *http.ServeMux, db *sql.DB) {
|
||||||
mux.HandleFunc("/admin/dashboard", admin.AdminDashboardHandler(db))
|
mux.HandleFunc("/admin/access", middleware.AdminOnly(db, admin.AdminAccessLogHandler(db)))
|
||||||
mux.HandleFunc("/admin/triggers", admin.AdminTriggersHandler(db))
|
mux.HandleFunc("/admin/audit", middleware.AdminOnly(db, admin.AuditLogHandler(db)))
|
||||||
|
mux.HandleFunc("/admin/dashboard", middleware.AdminOnly(db, admin.AdminDashboardHandler(db)))
|
||||||
|
mux.HandleFunc("/admin/triggers", middleware.AdminOnly(db, admin.AdminTriggersHandler(db)))
|
||||||
|
|
||||||
// Draw management
|
// Draw management
|
||||||
mux.HandleFunc("/admin/draws/new", admin.NewDrawHandler(db))
|
mux.HandleFunc("/admin/draws/new", middleware.AdminOnly(db, admin.NewDrawHandler(db)))
|
||||||
mux.HandleFunc("/admin/draws/modify", admin.ModifyDrawHandler(db))
|
mux.HandleFunc("/admin/draws/modify", middleware.AdminOnly(db, admin.ModifyDrawHandler(db)))
|
||||||
mux.HandleFunc("/admin/draws/delete", admin.DeleteDrawHandler(db))
|
mux.HandleFunc("/admin/draws/delete", middleware.AdminOnly(db, admin.DeleteDrawHandler(db)))
|
||||||
|
|
||||||
// Prize management
|
// Prize management
|
||||||
mux.HandleFunc("/admin/draws/prizes/add", admin.AddPrizesHandler(db))
|
mux.HandleFunc("/admin/draws/prizes/add", middleware.AdminOnly(db, admin.AddPrizesHandler(db)))
|
||||||
mux.HandleFunc("/admin/draws/prizes/modify", admin.ModifyPrizesHandler(db))
|
mux.HandleFunc("/admin/draws/prizes/modify", middleware.AdminOnly(db, admin.ModifyPrizesHandler(db)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupAccountRoutes(mux *http.ServeMux, db *sql.DB) {
|
func setupAccountRoutes(mux *http.ServeMux, db *sql.DB) {
|
||||||
|
|||||||
38
middleware/admin.go
Normal file
38
middleware/admin.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
20
middleware/recover.go
Normal file
20
middleware/recover.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
24
middleware/security.go
Normal file
24
middleware/security.go
Normal file
@@ -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'; style-src 'self' https://cdn.jsdelivr.net; script-src 'self' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net")
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
14
models/audit.go
Normal file
14
models/audit.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type AuditEntry struct {
|
||||||
|
ID int
|
||||||
|
UserID int
|
||||||
|
Username string
|
||||||
|
Action string
|
||||||
|
Path string
|
||||||
|
IP string
|
||||||
|
UserAgent string
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ type User struct {
|
|||||||
Id int
|
Id int
|
||||||
Username string
|
Username string
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
|
IsAdmin bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var db *sql.DB
|
var db *sql.DB
|
||||||
@@ -40,10 +41,10 @@ func GetUserByUsername(username string) *User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetUserByID(id int) *User {
|
func GetUserByID(id int) *User {
|
||||||
row := db.QueryRow("SELECT id, username, password_hash FROM users WHERE id = ?", id)
|
row := db.QueryRow("SELECT id, username, password_hash, is_admin FROM users WHERE id = ?", id)
|
||||||
|
|
||||||
var user User
|
var user User
|
||||||
err := row.Scan(&user.Id, &user.Username, &user.PasswordHash)
|
err := row.Scan(&user.Id, &user.Username, &user.PasswordHash, &user.IsAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err != sql.ErrNoRows {
|
if err != sql.ErrNoRows {
|
||||||
log.Println("DB error:", err)
|
log.Println("DB error:", err)
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
body { font-family: Arial, sans-serif; margin: 0px; }
|
||||||
table { border-collapse: collapse; width: 100%; margin-top: 20px; }
|
table { border-collapse: collapse; width: 100%; margin-top: 20px; }
|
||||||
th, td { padding: 8px 12px; border: 1px solid #ddd; text-align: center; }
|
th, td { padding: 8px 12px; border: 1px solid #ddd; text-align: center; }
|
||||||
th { background-color: #f5f5f5; }
|
th { background-color: #f5f5f5; }
|
||||||
.form-section { margin-bottom: 20px; }
|
.form-section { margin-bottom: 20px; }
|
||||||
.topbar { margin-bottom: 20px; }
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0.5rem, 0.5rem, 0.5rem, 0.5rem;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .auth p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
.flash { padding: 10px; color: green; background: #e9ffe9; border: 1px solid #c3e6c3; margin-bottom: 15px; }
|
.flash { padding: 10px; color: green; background: #e9ffe9; border: 1px solid #c3e6c3; margin-bottom: 15px; }
|
||||||
|
|
||||||
|
.dropdown-admin-box {
|
||||||
|
min-width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-notification-box {
|
||||||
|
min-width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-message-box {
|
||||||
|
min-width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-xs {
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ball Stuff */
|
/* Ball Stuff */
|
||||||
.ball {
|
.ball {
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ func InitDB(filepath string) *sql.DB {
|
|||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT NOT NULL UNIQUE,
|
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 {
|
if _, err := db.Exec(createUsersTable); err != nil {
|
||||||
@@ -170,5 +171,35 @@ func InitDB(filepath string) *sql.DB {
|
|||||||
log.Fatal("❌ Failed to create ticket matching log table:", err)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
createNewAuditLogTable := `
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
username TEXT,
|
||||||
|
action TEXT,
|
||||||
|
path TEXT,
|
||||||
|
ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);`
|
||||||
|
|
||||||
|
if _, err := db.Exec(createNewAuditLogTable); err != nil {
|
||||||
|
log.Fatal("❌ Failed to create admin access log table:", err)
|
||||||
|
}
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|||||||
26
templates/admin/logs/access_logs.html
Normal file
26
templates/admin/logs/access_logs.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<h2>Admin Access Log</h2>
|
||||||
|
|
||||||
|
<table class="table-auto w-full text-sm mt-4">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-200">
|
||||||
|
<th class="px-2 py-1 text-left">Time</th>
|
||||||
|
<th class="px-2 py-1">User ID</th>
|
||||||
|
<th class="px-2 py-1">Path</th>
|
||||||
|
<th class="px-2 py-1">IP</th>
|
||||||
|
<th class="px-2 py-1">User Agent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .AuditLogs }}
|
||||||
|
<tr class="border-b">
|
||||||
|
<td class="px-2 py-1">{{ .AccessedAt }}</td>
|
||||||
|
<td class="px-2 py-1 text-center">{{ .UserID }}</td>
|
||||||
|
<td class="px-2 py-1">{{ .Path }}</td>
|
||||||
|
<td class="px-2 py-1">{{ .IP }}</td>
|
||||||
|
<td class="px-2 py-1 text-xs text-gray-600">{{ .UserAgent }}</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{ end }}
|
||||||
27
templates/admin/logs/audit.html
Normal file
27
templates/admin/logs/audit.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<h2>Audit Log</h2>
|
||||||
|
<p>Recent sensitive admin events and system activity:</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>User ID</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>User Agent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .AuditLogs }}
|
||||||
|
<tr>
|
||||||
|
<td>{{ .Timestamp }}</td>
|
||||||
|
<td>{{ .UserID }}</td>
|
||||||
|
<td>{{ .Action }}</td>
|
||||||
|
<td>{{ .IP }}</td>
|
||||||
|
<td>{{ .UserAgent }}</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{ end }}
|
||||||
5
templates/error/403.html
Normal file
5
templates/error/403.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<h2 class="text-red-600 text-xl font-bold">🚫 Forbidden</h2>
|
||||||
|
<p class="mt-2">You do not have permission to access this page.</p>
|
||||||
|
<a href="/" class="text-blue-500 underline mt-4 inline-block">Return to Home</a>
|
||||||
|
{{ end }}
|
||||||
3
templates/error/404.html
Normal file
3
templates/error/404.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<h2>Not Found</h2> <p>The page doesn't exist.</p>
|
||||||
|
{{ end }}
|
||||||
@@ -1,32 +1,3 @@
|
|||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<a href="/new">+ Add New Draw</a>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<th>Draw Number</th>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Machine</th>
|
|
||||||
<th>Ball Set</th>
|
|
||||||
<th>Balls</th>
|
|
||||||
<th>Thunderball</th>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{{ if .Data }}
|
|
||||||
{{ range .Data }}
|
|
||||||
<tr>
|
|
||||||
<td>{{ .Id }}</td>
|
|
||||||
<td>{{ .DrawDate }}</td>
|
|
||||||
<td>{{ .Machine }}</td>
|
|
||||||
<td>{{ .BallSet }}</td>
|
|
||||||
<td>
|
|
||||||
{{ range $i, $n := .SortedBalls }}
|
|
||||||
{{ if $i }}, {{ end }}{{ $n }}
|
|
||||||
{{ end }}
|
|
||||||
</td>
|
|
||||||
<td>{{ .Thunderball }}</td>
|
|
||||||
</tr>
|
|
||||||
{{ end }}
|
|
||||||
{{ else }}
|
|
||||||
<tr><td colspan="5">No draws recorded yet.</td></tr>
|
|
||||||
{{ end }}
|
|
||||||
</table>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -1,27 +1,180 @@
|
|||||||
{{ define "layout" }}
|
{{ define "layout" }}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@3.3.5/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/static/css/site.css">
|
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>SynLotto</title>
|
<title>SynLotto</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/css/site.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
|
||||||
<div class="topbar">
|
<body class="d-flex flex-column min-vh-100">
|
||||||
|
|
||||||
|
<!-- Topbar -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-light px-3">
|
||||||
|
<a class="navbar-brand d-flex align-items-center" href="/">
|
||||||
|
<img src="/static/img/logo.png" alt="Logo" height="30" class="me-2">
|
||||||
|
<span>SynLotto</span>
|
||||||
|
</a>
|
||||||
|
<div class="ms-auto d-flex align-items-center gap-3">
|
||||||
{{ if .User }}
|
{{ if .User }}
|
||||||
<p>Hello, {{ .User.Username }} | <a href="/logout">Logout</a></p>
|
{{ if .IsAdmin }}
|
||||||
|
<!-- Admin Dropdown -->
|
||||||
|
<div class="dropdown">
|
||||||
|
<a class="nav-link text-dark" href="#" id="adminDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="bi bi-shield-lock fs-5 position-relative"></i>
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end dropdown-admin-box shadow-sm" aria-labelledby="adminDropdown">
|
||||||
|
<li class="dropdown-header text-center fw-bold">Admin Menu</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li class="text-center"><a href="/admin/dashboard" class="dropdown-item">Tools</a></li>
|
||||||
|
<li class="text-center"><a href="/admin/dashboard" class="dropdown-item">Audit Logs</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li class="text-center"><a href="/admin/dashboard" class="dropdown-item">Open Dashboard</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<!-- Notification Dropdown -->
|
||||||
|
<div class="dropdown">
|
||||||
|
<a class="nav-link text-dark" href="#" id="notificationDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="bi bi-bell fs-5 position-relative">
|
||||||
|
<span class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-warning text-dark" style="transform: translate(-40%, -50%)">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
</i>
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end dropdown-notification-box shadow-sm" aria-labelledby="notificationDropdown">
|
||||||
|
<li class="dropdown-header text-center fw-bold">Notifications</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
|
||||||
|
<!-- Example notification -->
|
||||||
|
<li class="px-3 py-2">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="bi bi-info-circle text-primary me-2 fs-4"></i>
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">System Update</div>
|
||||||
|
<small class="text-muted">A new lottery draw has been posted.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
|
||||||
|
<li class="px-3 py-2">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="bi bi-check-circle text-success me-2 fs-4"></i>
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">Sync Complete</div>
|
||||||
|
<small class="text-muted">All results are up-to-date.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li class="text-center"><a href="/notifications" class="dropdown-item">View all notifications</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Dropdown -->
|
||||||
|
<div class="dropdown">
|
||||||
|
<a class="nav-link text-dark" href="#" id="messageDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="bi bi-envelope fs-5 position-relative">
|
||||||
|
<!-- Unread badge (example: 2 messages) -->
|
||||||
|
<span class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-danger text-dark" style="transform: translate(-40%, -50%)">
|
||||||
|
2
|
||||||
|
</span>
|
||||||
|
</i>
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end dropdown-message-box shadow-sm" aria-labelledby="messageDropdown" style="min-width: 300px;">
|
||||||
|
<li class="dropdown-header text-center fw-bold">Messages</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
|
||||||
|
<!-- Example message item -->
|
||||||
|
<li class="px-3 py-2">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="bi bi-person-circle me-2 fs-4 text-secondary"></i>
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">Admin</div>
|
||||||
|
<small class="text-muted">Welcome to SynLotto!</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li class="text-center"><a href="/messages" class="dropdown-item">View all messages</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Greeting -->
|
||||||
|
<span class="navbar-text">Hello, {{ .User.Username }}</span>
|
||||||
|
<a class="btn btn-outline-danger btn-xs" href="/logout">Logout</a>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<p><a href="/login">Login</a></p>
|
<a class="btn btn-outline-primary btn-sm" href="/login">Login</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<a href="/admin/dashboard" class="hover:underline">Dashboard</a>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Layout -->
|
||||||
|
<div class="container-fluid flex-grow-1">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<nav class="col-md-2 d-none d-md-block bg-light sidebar pt-3">
|
||||||
|
<div class="position-sticky">
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link d-flex justify-content-between align-items-center" data-bs-toggle="collapse" href="#lotterySubmenu" role="button" aria-expanded="false" aria-controls="lotterySubmenu">
|
||||||
|
<strong>Lottery Results</strong>
|
||||||
|
<i class="bi bi-chevron-down small"></i>
|
||||||
|
</a>
|
||||||
|
<div class="collapse ps-3" id="lotterySubmenu">
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/lottery/today">Today's Results</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/results/lotto">Lotto</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/results/thunderball">Thunderball</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/results/euromillions">EuroMillions</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/results/Set For Life">Set For Life</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link d-flex justify-content-between align-items-center" data-bs-toggle="collapse" href="#statisticsSubmenu" role="button" aria-expanded="false" aria-controls="statisticsSubmenu">
|
||||||
|
<strong>Statistics</strong>
|
||||||
|
</a>
|
||||||
|
<div class="collapse ps-3" id="statisticsSubmenu">
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/lottery/today">Today's Results</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/lottery/history">Lotto</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/lottery/stats">Thunderball</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/lottery/history">Set For Life</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/lottery/stats">EuroMillions</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item"><strong class="nav-link">Syndicate</strong></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 pt-4">
|
||||||
{{ if .Flash }}
|
{{ if .Flash }}
|
||||||
<div class="flash">{{ .Flash }}</div>
|
<div class="alert alert-info" role="alert">
|
||||||
|
{{ .Flash }}
|
||||||
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ template "content" . }}
|
{{ template "content" . }}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-light text-center text-muted py-3 mt-auto border-top">
|
||||||
|
<small>© xxx SynLotto. All rights reserved. | <a href="/privacy">Privacy Policy</a> | <a href="/privacy">Terms & Conditions</a> | <a href="/privacy">Contact Us </a></small>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
Reference in New Issue
Block a user