From 86be6479f17445b492f79354b1798b862faac865 Mon Sep 17 00:00:00 2001 From: H3ALY Date: Tue, 28 Oct 2025 11:56:42 +0000 Subject: [PATCH] Stack of changes to get gin, scs, nosurf running. --- cmd/api/main.go | 48 ++-- go.mod | 12 - go.sum | 95 ------- internal/handlers/account/authentication.go | 151 ----------- internal/handlers/account/login.go | 81 ++++++ internal/handlers/account/logout.go | 19 ++ internal/handlers/account/signup.go | 170 +++++++++++++ internal/handlers/admin/audit.go | 23 +- internal/handlers/admin/dashboard.go | 96 ++++--- internal/handlers/admin/draws.go | 62 ++--- internal/handlers/admin/manualtriggers.go | 3 +- internal/handlers/admin/prizes.go | 29 +-- internal/handlers/home.go | 22 +- .../handlers/lottery/draws/draw_handler.go | 2 +- .../handlers/lottery/syndicate/syndicate.go | 138 +++++----- .../lottery/syndicate/syndicate_invites.go | 192 +++++++------- .../lottery/tickets/ticket_handler.go | 134 ++++++---- internal/handlers/messages.go | 143 +++++------ internal/handlers/notifications.go | 42 ++-- internal/handlers/results.go | 2 +- internal/handlers/statistics/thunderball.go | 18 +- internal/handlers/template/error.go | 41 ++- internal/handlers/template/render.go | 18 +- internal/handlers/template/templatedata.go | 60 +++-- internal/helpers/database/statements.go | 68 +++++ internal/helpers/http/request.go | 19 ++ .../helpers/security/{token.go => tokens.go} | 0 internal/helpers/security/users.go | 15 +- internal/helpers/session/loader.go | 20 -- internal/helpers/session/remember.go | 69 +++++ internal/helpers/template/build.go | 34 ++- internal/helpers/template/error.go | 2 +- internal/helpers/template/pagination.go | 2 +- internal/http/error/errors.go | 55 ++++ internal/http/middleware/auth.go | 113 ++++++--- internal/http/middleware/remember.go | 66 +++++ internal/http/middleware/sessiontimeout.go | 41 --- internal/http/routes/accountroutes.go | 34 ++- internal/http/routes/adminroutes.go | 41 +-- internal/http/routes/home.go | 10 + internal/http/routes/statisticroutes.go | 17 +- internal/http/routes/syndicateroutes.go | 40 +-- internal/models/user.go | 5 +- internal/platform/bootstrap/loader.go | 162 ++++++++++-- internal/platform/config/config.go | 8 +- internal/platform/config/types.go | 7 +- internal/platform/database/schema.go | 35 +++ internal/platform/session/session.go | 20 +- internal/platform/sessionkeys/keys.go | 8 + internal/services/tickets/ticketmatching.go | 64 ++--- internal/storage/auditlog/create.go | 87 ++++--- internal/storage/db.go | 78 ------ .../migrations/0001_initial_create.up.sql | 14 ++ internal/storage/migrations/embed.go | 6 + internal/storage/migrations/read.go | 6 + internal/storage/schema.go | 238 ------------------ internal/storage/syndicate/read.go | 29 +++ internal/storage/syndicate/syndicate.go | 125 --------- internal/storage/syndicate/update.go | 89 +++++++ internal/storage/users/create.go | 31 ++- internal/storage/users/read.go | 74 ++++-- internal/storage/users/types.go | 5 + web/templates/account/login.html | 4 +- web/templates/account/signup.html | 48 +++- web/templates/main/topbar.html | 3 +- 65 files changed, 1890 insertions(+), 1503 deletions(-) delete mode 100644 internal/handlers/account/authentication.go create mode 100644 internal/handlers/account/login.go create mode 100644 internal/handlers/account/logout.go create mode 100644 internal/handlers/account/signup.go create mode 100644 internal/helpers/database/statements.go create mode 100644 internal/helpers/http/request.go rename internal/helpers/security/{token.go => tokens.go} (100%) delete mode 100644 internal/helpers/session/loader.go create mode 100644 internal/helpers/session/remember.go create mode 100644 internal/http/error/errors.go create mode 100644 internal/http/middleware/remember.go delete mode 100644 internal/http/middleware/sessiontimeout.go create mode 100644 internal/http/routes/home.go create mode 100644 internal/platform/database/schema.go create mode 100644 internal/platform/sessionkeys/keys.go delete mode 100644 internal/storage/db.go create mode 100644 internal/storage/migrations/embed.go create mode 100644 internal/storage/migrations/read.go delete mode 100644 internal/storage/schema.go delete mode 100644 internal/storage/syndicate/syndicate.go create mode 100644 internal/storage/users/types.go diff --git a/cmd/api/main.go b/cmd/api/main.go index f35d7b6..ca7fec4 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1,3 +1,6 @@ +// Path /cmd/api +// File: main.go + package main import ( @@ -9,46 +12,41 @@ import ( "syscall" "time" - middleware "synlotto-website/internal/http/middleware" + templateHelpers "synlotto-website/internal/helpers/template" - "synlotto-website/internal/platform/config" - "synlotto-website/internal/platform/csrf" - "synlotto-website/internal/platform/session" - - "github.com/gin-gonic/gin" + "synlotto-website/internal/http/middleware" + "synlotto-website/internal/http/routes" + "synlotto-website/internal/platform/bootstrap" ) func main() { - cfg, err := config.Load("config.json") + app, err := bootstrap.Load("internal\\platform\\config\\config.json") if err != nil { - panic(fmt.Errorf("load config: %w", err)) + panic(fmt.Errorf("bootstrap: %w", err)) } - sessions := session.New(cfg) + templateHelpers.InitSessionManager(app.SessionManager) + templateHelpers.InitSiteMeta(app.Config.Site.SiteName, app.Config.Site.CopyrightYearStart, 0) - router := gin.New() - router.Use(gin.Logger(), gin.Recovery()) - router.Use(middleware.Session(sessions)) + // Global middleware that depends on *App + app.Router.Use(middleware.AuthMiddleware()) + app.Router.Use(middleware.RememberMiddleware(app)) - router.GET("/healthz", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) - - handler := csrf.Wrap(router, cfg) - - addr := fmt.Sprintf("%s:%d", cfg.HttpServer.Address, cfg.HttpServer.Port) - srv := &http.Server{ - Addr: addr, - Handler: handler, - ReadHeaderTimeout: 10 * time.Second, - } + // Route registration lives OUTSIDE bootstrap + routes.RegisterHomeRoutes(app) + routes.RegisterAccountRoutes(app) + routes.RegisterAdminRoutes(app) + routes.RegisterSyndicateRoutes(app) + routes.RegisterStatisticsRoutes(app) + srv := app.Server go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { panic(err) } }() - fmt.Printf("Server running on http://%s\n", addr) + + fmt.Printf("Server running on http://%s\n", srv.Addr) stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) diff --git a/go.mod b/go.mod index f966c15..5a93413 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,9 @@ require ( github.com/alexedwards/scs/v2 v2.9.0 github.com/gin-gonic/gin v1.11.0 github.com/go-sql-driver/mysql v1.9.3 - github.com/golang-migrate/migrate/v4 v4.19.0 github.com/justinas/nosurf v1.2.0 golang.org/x/crypto v0.40.0 golang.org/x/time v0.11.0 - modernc.org/sqlite v1.36.1 ) require ( @@ -18,7 +16,6 @@ require ( github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -26,25 +23,19 @@ require ( github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect @@ -52,7 +43,4 @@ require ( golang.org/x/text v0.27.0 // indirect golang.org/x/tools v0.34.0 // indirect google.golang.org/protobuf v1.36.9 // indirect - modernc.org/libc v1.61.13 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.8.2 // indirect ) diff --git a/go.sum b/go.sum index 0691f0f..4587e06 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,5 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= @@ -12,37 +8,15 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= -github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= -github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= -github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= -github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= -github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -57,24 +31,9 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= -github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/justinas/nosurf v1.2.0 h1:yMs1bSRrNiwXk4AS6n8vL2Ssgpb9CB25T/4xrixaK0s= @@ -83,38 +42,20 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= -github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -128,24 +69,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= -golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= @@ -167,27 +96,3 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= -modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= -modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo= -modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw= -modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= -modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= -modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.36.1 h1:bDa8BJUH4lg6EGkLbahKe/8QqoF8p9gArSc6fTqYhyQ= -modernc.org/sqlite v1.36.1/go.mod h1:7MPwH7Z6bREicF9ZVUR78P1IKuxfZ8mRIDHD0iD+8TU= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/handlers/account/authentication.go b/internal/handlers/account/authentication.go deleted file mode 100644 index fa97ba7..0000000 --- a/internal/handlers/account/authentication.go +++ /dev/null @@ -1,151 +0,0 @@ -package handlers - -import ( - "database/sql" - "log" - "net/http" - "time" - - httpHelpers "synlotto-website/internal/helpers/http" - securityHelpers "synlotto-website/internal/helpers/security" - templateHelpers "synlotto-website/internal/helpers/template" - "synlotto-website/internal/logging" - "synlotto-website/internal/models" - auditlogStorage "synlotto-website/internal/storage/auditlog" - usersStorage "synlotto-website/internal/storage/users" - - "github.com/gorilla/csrf" - "github.com/justinas/nosurf" -) - -func Login(db *sql.DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - session, _ := httpHelpers.GetSession(w, r) - if _, ok := session.Values["user_id"].(int); ok { - http.Redirect(w, r, "/", http.StatusSeeOther) - return - } - - tmpl := templateHelpers.LoadTemplateFiles("login.html", "templates/account/login.html") - data := models.TemplateData{} - context := templateHelpers.TemplateContext(w, r, data) - context["CSRFToken"] = nosurf.Token(r) - - if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil { - logging.Info("❌ Template render error:", err) - http.Error(w, "Error rendering login page", http.StatusInternalServerError) - } - return - } - - username := r.FormValue("username") - password := r.FormValue("password") - - // ToDo: this outputs password in clear text remove or obscure! - logging.Info("πŸ” Login attempt - Username: %s, Password: %s", username, password) - - user := usersStorage.GetUserByUsername(db, username) - if user == nil { - logging.Info("❌ User not found: %s", username) - auditlogStorage.LogLoginAttempt(db, r, username, false) - - session, _ := httpHelpers.GetSession(w, r) - session.Values["flash"] = "Invalid username or password." - session.Save(r, w) - http.Redirect(w, r, "/account/login", http.StatusSeeOther) - return - } - - if !securityHelpers.CheckPasswordHash(user.PasswordHash, password) { - logging.Info("❌ Password mismatch for user: %s", username) - auditlogStorage.LogLoginAttempt(db, r, username, false) - - session, _ := httpHelpers.GetSession(w, r) - session.Values["flash"] = "Invalid username or password." - session.Save(r, w) - log.Printf("login has did it") - http.Redirect(w, r, "/account/login", http.StatusSeeOther) - return - } - - logging.Info("βœ… Login successful for user: %s", username) - auditlogStorage.LogLoginAttempt(db, r, username, true) - - session, _ := httpHelpers.GetSession(w, r) - for k := range session.Values { - delete(session.Values, k) - } - - session.Values["user_id"] = user.Id - session.Values["last_activity"] = time.Now().UTC() - - if r.FormValue("remember") == "on" { - session.Options.MaxAge = 60 * 60 * 24 * 30 - } else { - session.Options.MaxAge = 0 - } - - if err := session.Save(r, w); err != nil { - logging.Info("❌ Failed to save session: %v", err) - } else { - logging.Info("βœ… Session saved for user: %s", username) - } - - http.Redirect(w, r, "/", http.StatusSeeOther) - } -} - -func Logout(w http.ResponseWriter, r *http.Request) { - session, _ := httpHelpers.GetSession(w, r) - - for k := range session.Values { - delete(session.Values, k) - } - - session.Values["flash"] = "You've been logged out." - session.Options.MaxAge = 5 - - err := session.Save(r, w) - if err != nil { - logging.Error("❌ Logout session save failed:", err) - } - http.Redirect(w, r, "/account/login", http.StatusSeeOther) -} - -// ToDo: opted to inject the repo which is better for tests/DI rather than taking the *sql.DB -func Signup(usersRepo *usersStorage.UsersRepo) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - tmpl := templateHelpers.LoadTemplateFiles("signup.html", "templates/account/signup.html") - if err := tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{ - "csrfField": csrf.TemplateField(r), // ToDo: this is the olf Gorilla thing - }); err != nil { - logging.Info("❌ Template render error: %v", err) - http.Error(w, "Error rendering signup page", http.StatusInternalServerError) - } - return - } - - username := r.FormValue("username") - password := r.FormValue("password") - - if username == "" || password == "" { - http.Error(w, "Username and password are required", http.StatusBadRequest) - return - } - - hashed, err := securityHelpers.HashPassword(password) - if err != nil { - http.Error(w, "Server error", http.StatusInternalServerError) - return - } - - if err := usersRepo.Create(r.Context(), username, hashed, false); err != nil { - http.Error(w, "Could not create user", http.StatusInternalServerError) - return - } - - http.Redirect(w, r, "/account/login", http.StatusSeeOther) - } -} diff --git a/internal/handlers/account/login.go b/internal/handlers/account/login.go new file mode 100644 index 0000000..357bba9 --- /dev/null +++ b/internal/handlers/account/login.go @@ -0,0 +1,81 @@ +package accountHandler + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/justinas/nosurf" + + securityHelpers "synlotto-website/internal/helpers/security" + templateHelpers "synlotto-website/internal/helpers/template" + "synlotto-website/internal/logging" + "synlotto-website/internal/models" + "synlotto-website/internal/platform/bootstrap" + auditlogStorage "synlotto-website/internal/storage/auditlog" + usersStorage "synlotto-website/internal/storage/users" +) + +func LoginGet(c *gin.Context) { + app := c.MustGet("app").(*bootstrap.App) + sm := app.SessionManager + + ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{}) + + if f := sm.PopString(c.Request.Context(), "flash"); f != "" { + ctx["Flash"] = f + } + + ctx["CSRFToken"] = nosurf.Token(c.Request) + + tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/login.html") + + c.Status(http.StatusOK) + if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil { + logging.Info("❌ Template render error: %v", err) + c.String(http.StatusInternalServerError, "Error rendering login page") + } +} + +func LoginPost(c *gin.Context) { + app := c.MustGet("app").(*bootstrap.App) + sm := app.SessionManager + db := app.DB + + r := c.Request + w := c.Writer + + username := r.FormValue("username") + password := r.FormValue("password") + + logging.Info("πŸ” Login attempt - Username: %s", username) + + user := usersStorage.GetUserByUsername(db, username) + if user == nil { + logging.Info("❌ User not found: %s", username) + auditlogStorage.LogLoginAttempt(db, r.RemoteAddr, r.UserAgent(), username, false) + sm.Put(r.Context(), "flash", "Invalid username or password.") + c.Redirect(http.StatusSeeOther, "/account/login") + return + } + + if !securityHelpers.CheckPasswordHash(user.PasswordHash, password) { + logging.Info("❌ Password mismatch for user: %s", username) + auditlogStorage.LogLoginAttempt(db, r.RemoteAddr, r.UserAgent(), username, false) + sm.Put(r.Context(), "flash", "Invalid username or password.") + c.Redirect(http.StatusSeeOther, "/account/login") + return + } + + logging.Info("βœ… Login successful for user: %s", username) + auditlogStorage.LogLoginAttempt(db, r.RemoteAddr, r.UserAgent(), username, true) + + _ = sm.Destroy(r.Context()) + _ = sm.RenewToken(r.Context()) + + sm.Put(r.Context(), "user_id", user.Id) + sm.Put(r.Context(), "last_activity", time.Now().UTC()) + sm.Put(r.Context(), "flash", "Welcome back, "+user.Username+"!") + + http.Redirect(w, r, "/", http.StatusSeeOther) +} diff --git a/internal/handlers/account/logout.go b/internal/handlers/account/logout.go new file mode 100644 index 0000000..0af6910 --- /dev/null +++ b/internal/handlers/account/logout.go @@ -0,0 +1,19 @@ +package accountHandler + +import ( + "net/http" + "synlotto-website/internal/platform/bootstrap" + + "github.com/gin-gonic/gin" +) + +func Logout(c *gin.Context) { + app := c.MustGet("app").(*bootstrap.App) + sm := app.SessionManager + + _ = sm.Destroy(c.Request.Context()) + _ = sm.RenewToken(c.Request.Context()) + sm.Put(c.Request.Context(), "flash", "You've been logged out.") + + c.Redirect(http.StatusSeeOther, "/account/login") +} diff --git a/internal/handlers/account/signup.go b/internal/handlers/account/signup.go new file mode 100644 index 0000000..84fb462 --- /dev/null +++ b/internal/handlers/account/signup.go @@ -0,0 +1,170 @@ +// internal/handlers/account/signup.go +package accountHandler + +import ( + "database/sql" + "net/http" + "strings" + + httphelpers "synlotto-website/internal/helpers/http" + securityHelpers "synlotto-website/internal/helpers/security" + templateHelpers "synlotto-website/internal/helpers/template" + auditlogStorage "synlotto-website/internal/storage/auditlog" + usersStorage "synlotto-website/internal/storage/users" + + "synlotto-website/internal/logging" + "synlotto-website/internal/models" + "synlotto-website/internal/platform/bootstrap" + + "github.com/gin-gonic/gin" + "github.com/justinas/nosurf" +) + +// kept for handler-local parsing only (NOT stored in session) +type registerForm struct { + Username string + Email string + Password string + PasswordConfirm string + AcceptTerms bool +} + +func SignupGet(c *gin.Context) { + app := c.MustGet("app").(*bootstrap.App) + sm := app.SessionManager + + ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{}) + if f := sm.PopString(c.Request.Context(), "flash"); f != "" { + ctx["Flash"] = f + } + ctx["CSRFToken"] = nosurf.Token(c.Request) + + // Rehydrate maps (not structs) from session for sticky form + field errors + if v := sm.Pop(c.Request.Context(), "register.form"); v != nil { + if fm, ok := v.(map[string]string); ok { + ctx["Form"] = fm + } + } + if v := sm.Pop(c.Request.Context(), "register.errors"); v != nil { + if errs, ok := v.(map[string]string); ok { + ctx["Errors"] = errs + } + } + + // layout-first, finalized path + tmpl := templateHelpers.LoadTemplateFiles( + "web/templates/layout.html", + "web/templates/account/signup.html", + ) + + c.Status(http.StatusOK) + if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil { + logging.Info("❌ Template render error (register): %v", err) + c.String(http.StatusInternalServerError, "Error rendering register page") + } +} + +func SignupPost(c *gin.Context) { + app := c.MustGet("app").(*bootstrap.App) + sm := app.SessionManager + db := app.DB + + r := c.Request + + form := registerForm{ + Username: strings.TrimSpace(r.FormValue("username")), + Email: strings.TrimSpace(r.FormValue("email")), + Password: r.FormValue("password"), + PasswordConfirm: r.FormValue("password_confirm"), + AcceptTerms: r.FormValue("accept_terms") == "on", + } + + errors := validateRegisterForm(db, form) + if len(errors) > 0 { + // βœ… Stash maps instead of a struct β†’ gob-safe with SCS + formMap := map[string]string{ + "username": form.Username, + "email": form.Email, + "accept_terms": func() string { + if form.AcceptTerms { + return "on" + } + return "" + }(), + } + sm.Put(r.Context(), "register.form", formMap) + sm.Put(r.Context(), "register.errors", errors) + sm.Put(r.Context(), "flash", "Please fix the highlighted errors.") + + c.Redirect(http.StatusSeeOther, "/account/signup") + c.Abort() + return + } + + // Hash password + hash, err := securityHelpers.HashPassword(form.Password) + if err != nil { + logging.Info("❌ Hash error: %v", err) + sm.Put(r.Context(), "flash", "Something went wrong. Please try again.") + c.Redirect(http.StatusSeeOther, "/account/signup") + c.Abort() + return + } + + // Create user + id, err := usersStorage.CreateUser(db, form.Username, form.Email, hash) + if err != nil { + logging.Info("❌ CreateUser error: %v", err) + // Unique constraints might still trip here + sm.Put(r.Context(), "flash", "That username or email is already taken.") + c.Redirect(http.StatusSeeOther, "/account/signup") + c.Abort() + return + } + + // Audit registration + auditlogStorage.LogSignup( + db, + id, + form.Username, + form.Email, + httphelpers.ClientIP(r), + r.UserAgent(), + ) + + sm.Put(r.Context(), "flash", "Account created. You can log in now.") + c.Redirect(http.StatusSeeOther, "/account/login") + c.Abort() +} + +func validateRegisterForm(db *sql.DB, f registerForm) map[string]string { + errs := make(map[string]string) + + if f.Username == "" || len(f.Username) < 3 { + errs["username"] = "Username must be at least 3 characters." + } else if usersStorage.UsernameExists(db, f.Username) { + errs["username"] = "Username is already in use." + } + + if f.Email == "" || !looksLikeEmail(f.Email) { + errs["email"] = "Please enter a valid email." + } else if usersStorage.EmailExists(db, f.Email) { + errs["email"] = "Email is already registered." + } + + if len(f.Password) < 8 { + errs["password"] = "Password must be at least 8 characters." + } + if f.Password != f.PasswordConfirm { + errs["password_confirm"] = "Passwords do not match." + } + if !f.AcceptTerms { + errs["accept_terms"] = "You must accept the terms." + } + return errs +} + +func looksLikeEmail(s string) bool { + // Keep it simple; you can swap for a stricter validator later + return strings.Count(s, "@") == 1 && strings.Contains(s, ".") +} diff --git a/internal/handlers/admin/audit.go b/internal/handlers/admin/audit.go index 25bb9bf..21f8c9a 100644 --- a/internal/handlers/admin/audit.go +++ b/internal/handlers/admin/audit.go @@ -7,7 +7,6 @@ import ( templateHelpers "synlotto-website/internal/helpers/template" - "synlotto-website/internal/http/middleware" "synlotto-website/internal/models" ) @@ -20,7 +19,7 @@ type AdminLogEntry struct { } func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc { - return middleware.Auth(true)(func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { data := models.TemplateData{} context := templateHelpers.TemplateContext(w, r, data) @@ -37,7 +36,7 @@ func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc { } defer rows.Close() - var logs []AdminLogEntry // ToDo should be in models + var logs []AdminLogEntry // ToDo: move to models ? for rows.Next() { var entry AdminLogEntry if err := rows.Scan(&entry.AccessedAt, &entry.UserID, &entry.Path, &entry.IP, &entry.UserAgent); err != nil { @@ -48,14 +47,13 @@ func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc { } context["AuditLogs"] = logs - tmpl := templateHelpers.LoadTemplateFiles("access_log.html", "templates/admin/logs/access_log.html") - + tmpl := templateHelpers.LoadTemplateFiles("access_log.html", "web/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) { + return func(w http.ResponseWriter, r *http.Request) { data := models.TemplateData{} context := templateHelpers.TemplateContext(w, r, data) @@ -75,8 +73,7 @@ func AuditLogHandler(db *sql.DB) http.HandlerFunc { 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 { + if err := rows.Scan(&entry.Timestamp, &entry.UserID, &entry.Action, &entry.IP, &entry.UserAgent); err != nil { log.Println("⚠️ Failed to scan row:", err) continue } @@ -85,12 +82,10 @@ func AuditLogHandler(db *sql.DB) http.HandlerFunc { context["AuditLogs"] = logs - tmpl := templateHelpers.LoadTemplateFiles("audit.html", "templates/admin/logs/audit.html") - - err = tmpl.ExecuteTemplate(w, "layout", context) - if err != nil { + tmpl := templateHelpers.LoadTemplateFiles("audit.html", "web/templates/admin/logs/audit.html") + if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil { log.Println("❌ Failed to render audit page:", err) http.Error(w, "Template error", http.StatusInternalServerError) } - }) + } } diff --git a/internal/handlers/admin/dashboard.go b/internal/handlers/admin/dashboard.go index bddbf1b..386d109 100644 --- a/internal/handlers/admin/dashboard.go +++ b/internal/handlers/admin/dashboard.go @@ -1,76 +1,96 @@ +// internal/handlers/admin/dashboard.go package handlers +// ToDo: move SQL into storage layer import ( - "database/sql" "log" "net/http" - httpHelpers "synlotto-website/internal/helpers/http" - securityHelpers "synlotto-website/internal/helpers/security" + templateHandlers "synlotto-website/internal/handlers/template" + security "synlotto-website/internal/helpers/security" templateHelpers "synlotto-website/internal/helpers/template" - - "synlotto-website/internal/models" + "synlotto-website/internal/platform/bootstrap" usersStorage "synlotto-website/internal/storage/users" ) -var ( - total, winners int - prizeSum float64 -) - -func AdminDashboardHandler(db *sql.DB) http.HandlerFunc { - return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { - userID, ok := securityHelpers.GetCurrentUserID(r) +func AdminDashboardHandler(app *bootstrap.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := security.GetCurrentUserID(app.SessionManager, r) if !ok { http.Redirect(w, r, "/account/login", http.StatusSeeOther) return } - user := usersStorage.GetUserByID(db, userID) + user := usersStorage.GetUserByID(app.DB, userID) if user == nil { http.Error(w, "User not found", http.StatusUnauthorized) return } - data := models.TemplateData{} + // Shared template data (loads user, notifications, counts, etc.) + data := templateHandlers.BuildTemplateData(app, w, r) context := templateHelpers.TemplateContext(w, r, data) context["User"] = user context["IsAdmin"] = user.IsAdmin - // ToDo: Missing messages, notifications, potentially syndicate notifictions if that becomes a new top bar icon. - db.QueryRow(`SELECT COUNT(*), SUM(CASE WHEN is_winner THEN 1 ELSE 0 END), SUM(prize_amount) FROM my_tickets`).Scan(&total, &winners, &prizeSum) + + // Quick stats (keep here for now; move to storage soon) + var ( + total, winners int + prizeSum float64 + ) + if err := app.DB.QueryRow(` + SELECT COUNT(*), + SUM(CASE WHEN is_winner THEN 1 ELSE 0 END), + COALESCE(SUM(prize_amount), 0) + FROM my_tickets + `).Scan(&total, &winners, &prizeSum); err != nil { + log.Println("⚠️ Failed to load ticket stats:", err) + } context["Stats"] = map[string]interface{}{ "TotalTickets": total, "TotalWinners": winners, "TotalPrizeAmount": prizeSum, } - 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 + // Recent matcher logs (limit 10) + rows, err := app.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 + } else { + defer rows.Close() + var logs []struct { + RunAt any + TriggeredBy string + TicketsMatched int + WinnersFound int + Notes string } - logs = append(logs, logEntry) + for rows.Next() { + var e struct { + RunAt any + TriggeredBy string + TicketsMatched int + WinnersFound int + Notes string + } + if err := rows.Scan(&e.RunAt, &e.TriggeredBy, &e.TicketsMatched, &e.WinnersFound, &e.Notes); err != nil { + log.Println("⚠️ Failed to scan log row:", err) + continue + } + logs = append(logs, e) + } + context["MatchLogs"] = logs } - context["MatchLogs"] = logs - tmpl := templateHelpers.LoadTemplateFiles("dashboard.html", "templates/admin/dashboard.html") - - err = tmpl.ExecuteTemplate(w, "layout", context) - if err != nil { + tmpl := templateHelpers.LoadTemplateFiles("dashboard.html", "web/templates/admin/dashboard.html") + if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil { http.Error(w, "Failed to render dashboard", http.StatusInternalServerError) + return } - }) + } } diff --git a/internal/handlers/admin/draws.go b/internal/handlers/admin/draws.go index ab4fbf7..5622a7b 100644 --- a/internal/handlers/admin/draws.go +++ b/internal/handlers/admin/draws.go @@ -1,20 +1,19 @@ package handlers +// ToDo: move SQL into storage layer import ( "database/sql" "log" "net/http" - httpHelpers "synlotto-website/internal/helpers/http" templateHelpers "synlotto-website/internal/helpers/template" - "synlotto-website/internal/models" ) func NewDrawHandler(db *sql.DB) http.HandlerFunc { - return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { data := models.TemplateData{} - context := templateHelpers.TemplateContext(w, r, data) + ctx := templateHelpers.TemplateContext(w, r, data) if r.Method == http.MethodPost { game := r.FormValue("game_type") @@ -22,29 +21,35 @@ func NewDrawHandler(db *sql.DB) http.HandlerFunc { 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) + _, 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 := templateHelpers.LoadTemplateFiles("new_draw", "templates/admin/draws/new_draw.html") - - tmpl.ExecuteTemplate(w, "layout", context) - }) + tmpl := templateHelpers.LoadTemplateFiles("new_draw", "web/templates/admin/draws/new_draw.html") + _ = tmpl.ExecuteTemplate(w, "layout", ctx) + } } func ModifyDrawHandler(db *sql.DB) http.HandlerFunc { - return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { + return 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) + _, 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 @@ -52,33 +57,30 @@ func ModifyDrawHandler(db *sql.DB) http.HandlerFunc { 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 - }) + // For GET: load draw by ID if needed and render a form/template + } } func DeleteDrawHandler(db *sql.DB) http.HandlerFunc { - return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { + return 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 { + if _, err := db.Exec(`DELETE FROM results_thunderball WHERE id = ?`, id); err != nil { http.Error(w, "Delete failed", http.StatusInternalServerError) return } http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther) return } - }) + } } func ListDrawsHandler(db *sql.DB) http.HandlerFunc { - return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { data := models.TemplateData{} - context := templateHelpers.TemplateContext(w, r, data) - draws := []models.DrawSummary{} + ctx := templateHelpers.TemplateContext(w, r, data) + var 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 @@ -101,11 +103,9 @@ func ListDrawsHandler(db *sql.DB) http.HandlerFunc { d.PrizeSet = prizeFlag > 0 draws = append(draws, d) } + ctx["Draws"] = draws - context["Draws"] = draws - - tmpl := templateHelpers.LoadTemplateFiles("list.html", "templates/admin/draws/list.html") - - tmpl.ExecuteTemplate(w, "layout", context) - }) + tmpl := templateHelpers.LoadTemplateFiles("list.html", "web/templates/admin/draws/list.html") + _ = tmpl.ExecuteTemplate(w, "layout", ctx) + } } diff --git a/internal/handlers/admin/manualtriggers.go b/internal/handlers/admin/manualtriggers.go index e7b8150..dd2703e 100644 --- a/internal/handlers/admin/manualtriggers.go +++ b/internal/handlers/admin/manualtriggers.go @@ -14,6 +14,7 @@ import ( "synlotto-website/internal/models" ) +// ToDo: need to fix flash messages from new gin context func AdminTriggersHandler(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { data := models.TemplateData{} @@ -73,7 +74,7 @@ func AdminTriggersHandler(db *sql.DB) http.HandlerFunc { return } - tmpl := templateHelpers.LoadTemplateFiles("triggers.html", "templates/admin/triggers.html") + tmpl := templateHelpers.LoadTemplateFiles("triggers.html", "web/templates/admin/triggers.html") err := tmpl.ExecuteTemplate(w, "layout", context) if err != nil { diff --git a/internal/handlers/admin/prizes.go b/internal/handlers/admin/prizes.go index 7305078..dbc73f6 100644 --- a/internal/handlers/admin/prizes.go +++ b/internal/handlers/admin/prizes.go @@ -6,23 +6,23 @@ import ( "net/http" "strconv" - httpHelpers "synlotto-website/internal/helpers/http" templateHelpers "synlotto-website/internal/helpers/template" - "synlotto-website/internal/models" ) +// ToDo: move SQL into the storage layer. + func AddPrizesHandler(db *sql.DB) http.HandlerFunc { - return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { data := models.TemplateData{} if r.Method == http.MethodGet { - tmpl := templateHelpers.LoadTemplateFiles("add_prizes.html", "templates/admin/draws/prizes/add_prizes.html") - tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data)) + tmpl := templateHelpers.LoadTemplateFiles("add_prizes.html", "web/templates/admin/draws/prizes/add_prizes.html") + _ = tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data)) return } drawDate := r.FormValue("draw_date") - values := make([]interface{}, 0) + values := make([]interface{}, 0, 9) for i := 1; i <= 9; i++ { val, _ := strconv.Atoi(r.FormValue(fmt.Sprintf("prize%d_per_winner", i))) values = append(values, val) @@ -34,23 +34,21 @@ func AddPrizesHandler(db *sql.DB) http.HandlerFunc { prize7_per_winner, prize8_per_winner, prize9_per_winner ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - _, err := db.Exec(stmt, append([]interface{}{drawDate}, values...)...) - if err != nil { + if _, err := db.Exec(stmt, append([]interface{}{drawDate}, values...)...); 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 httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { data := models.TemplateData{} if r.Method == http.MethodGet { - tmpl := templateHelpers.LoadTemplateFiles("modify_prizes.html", "templates/admin/draws/prizes/modify_prizes.html") - - tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data)) + tmpl := templateHelpers.LoadTemplateFiles("modify_prizes.html", "web/templates/admin/draws/prizes/modify_prizes.html") + _ = tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data)) return } @@ -58,13 +56,12 @@ func ModifyPrizesHandler(db *sql.DB) http.HandlerFunc { 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 { + if _, err := db.Exec("UPDATE prizes_thunderball SET "+key+" = ? WHERE draw_date = ?", val, drawDate); err != nil { http.Error(w, "Update failed: "+err.Error(), http.StatusInternalServerError) return } } http.Redirect(w, r, "/admin/draws", http.StatusSeeOther) - }) + } } diff --git a/internal/handlers/home.go b/internal/handlers/home.go index 82e4957..b3fea98 100644 --- a/internal/handlers/home.go +++ b/internal/handlers/home.go @@ -1,25 +1,29 @@ package handlers import ( - "database/sql" "log" "net/http" templateHandlers "synlotto-website/internal/handlers/template" templateHelpers "synlotto-website/internal/helpers/template" + + "synlotto-website/internal/platform/bootstrap" + + "github.com/gin-gonic/gin" ) -func Home(db *sql.DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - data := templateHandlers.BuildTemplateData(db, w, r) - context := templateHelpers.TemplateContext(w, r, data) +func Home(app *bootstrap.App) gin.HandlerFunc { + return func(c *gin.Context) { + data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request) + ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data) - tmpl := templateHelpers.LoadTemplateFiles("index.html", "templates/index.html") + tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/index.html") - err := tmpl.ExecuteTemplate(w, "layout", context) - if err != nil { + c.Header("Content-Type", "text/html; charset=utf-8") + if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil { log.Println("❌ Template render error:", err) - http.Error(w, "Error rendering homepage", http.StatusInternalServerError) + c.String(http.StatusInternalServerError, "Template render error: %v", err) + return } } } diff --git a/internal/handlers/lottery/draws/draw_handler.go b/internal/handlers/lottery/draws/draw_handler.go index 22c6e56..779b433 100644 --- a/internal/handlers/lottery/draws/draw_handler.go +++ b/internal/handlers/lottery/draws/draw_handler.go @@ -18,7 +18,7 @@ func NewDraw(db *sql.DB) http.HandlerFunc { context["Page"] = "new_draw" context["Data"] = nil - tmpl := templateHelpers.LoadTemplateFiles("new_draw.html", "templates/admin/draws/new_draw.html") // ToDo: may need removing or moving add draw should be admin functionality and only when manually required. Potential live drawing of numbers in the future. + tmpl := templateHelpers.LoadTemplateFiles("new_draw.html", "web/templates/admin/draws/new_draw.html") // ToDo: may need removing or moving add draw should be admin functionality and only when manually required. Potential live drawing of numbers in the future. err := tmpl.ExecuteTemplate(w, "layout", context) if err != nil { diff --git a/internal/handlers/lottery/syndicate/syndicate.go b/internal/handlers/lottery/syndicate/syndicate.go index caa6b6a..e81f6f7 100644 --- a/internal/handlers/lottery/syndicate/syndicate.go +++ b/internal/handlers/lottery/syndicate/syndicate.go @@ -1,7 +1,7 @@ +// internal/handlers/lottery/syndicate/syndicate.go package handlers import ( - "database/sql" "fmt" "log" "net/http" @@ -14,34 +14,34 @@ import ( "synlotto-website/internal/helpers" "synlotto-website/internal/models" + "synlotto-website/internal/platform/bootstrap" ) -func CreateSyndicateHandler(db *sql.DB) http.HandlerFunc { +func CreateSyndicateHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - data := templateHandlers.BuildTemplateData(db, w, r) - context := templateHelpers.TemplateContext(w, r, data) - tmpl := templateHelpers.LoadTemplateFiles("create-syndicate.html", "templates/syndicate/create.html") - tmpl.ExecuteTemplate(w, "layout", context) + data := templateHandlers.BuildTemplateData(app, w, r) + ctx := templateHelpers.TemplateContext(w, r, data) + tmpl := templateHelpers.LoadTemplateFiles("create-syndicate.html", "web/templates/syndicate/create.html") + _ = tmpl.ExecuteTemplate(w, "layout", ctx) case http.MethodPost: name := r.FormValue("name") description := r.FormValue("description") - userId, ok := securityHelpers.GetCurrentUserID(r) + userId, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok || name == "" { - templateHelpers.SetFlash(w, r, "Invalid data submitted") + templateHelpers.SetFlash(r, "Invalid data submitted") http.Redirect(w, r, "/syndicate/create", http.StatusSeeOther) return } - _, err := syndicateStorage.CreateSyndicate(db, userId, name, description) - if err != nil { + if _, err := syndicateStorage.CreateSyndicate(app.DB, userId, name, description); err != nil { log.Printf("❌ CreateSyndicate failed: %v", err) - templateHelpers.SetFlash(w, r, "Failed to create syndicate") + templateHelpers.SetFlash(r, "Failed to create syndicate") } else { - templateHelpers.SetFlash(w, r, "Syndicate created successfully") + templateHelpers.SetFlash(r, "Syndicate created successfully") } http.Redirect(w, r, "/syndicate", http.StatusSeeOther) @@ -51,18 +51,18 @@ func CreateSyndicateHandler(db *sql.DB) http.HandlerFunc { } } -func ListSyndicatesHandler(db *sql.DB) http.HandlerFunc { +func ListSyndicatesHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { - templateHelpers.RenderError(w, r, 403) // ToDo need to make this use the handler so i dont need to define errors. + templateHelpers.RenderError(w, r, http.StatusForbidden) return } - managed := syndicateStorage.GetSyndicatesByOwner(db, userID) - member := syndicateStorage.GetSyndicatesByMember(db, userID) + managed := syndicateStorage.GetSyndicatesByOwner(app.DB, userID) + member := syndicateStorage.GetSyndicatesByMember(app.DB, userID) - managedMap := make(map[int]bool) + managedMap := make(map[int]bool, len(managed)) for _, s := range managed { managedMap[s.ID] = true } @@ -74,131 +74,131 @@ func ListSyndicatesHandler(db *sql.DB) http.HandlerFunc { } } - data := templateHandlers.BuildTemplateData(db, w, r) - context := templateHelpers.TemplateContext(w, r, data) - context["ManagedSyndicates"] = managed - context["JoinedSyndicates"] = filteredJoined + data := templateHandlers.BuildTemplateData(app, w, r) + ctx := templateHelpers.TemplateContext(w, r, data) + ctx["ManagedSyndicates"] = managed + ctx["JoinedSyndicates"] = filteredJoined - tmpl := templateHelpers.LoadTemplateFiles("syndicates.html", "templates/syndicate/index.html") - tmpl.ExecuteTemplate(w, "layout", context) + tmpl := templateHelpers.LoadTemplateFiles("syndicates.html", "web/templates/syndicate/index.html") + _ = tmpl.ExecuteTemplate(w, "layout", ctx) } } -func ViewSyndicateHandler(db *sql.DB) http.HandlerFunc { +func ViewSyndicateHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { - templateHelpers.RenderError(w, r, 403) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } syndicateID := helpers.Atoi(r.URL.Query().Get("id")) - syndicate, err := syndicateStorage.GetSyndicateByID(db, syndicateID) + syndicate, err := syndicateStorage.GetSyndicateByID(app.DB, syndicateID) if err != nil || syndicate == nil { - templateHelpers.RenderError(w, r, 404) + templateHelpers.RenderError(w, r, http.StatusNotFound) return } isManager := userID == syndicate.OwnerID - isMember := syndicateStorage.IsSyndicateMember(db, syndicateID, userID) - + isMember := syndicateStorage.IsSyndicateMember(app.DB, syndicateID, userID) if !isManager && !isMember { - templateHelpers.RenderError(w, r, 403) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } - members := syndicateStorage.GetSyndicateMembers(db, syndicateID) + members := syndicateStorage.GetSyndicateMembers(app.DB, syndicateID) - data := templateHandlers.BuildTemplateData(db, w, r) - context := templateHelpers.TemplateContext(w, r, data) - context["Syndicate"] = syndicate - context["Members"] = members - context["IsManager"] = isManager + data := templateHandlers.BuildTemplateData(app, w, r) + ctx := templateHelpers.TemplateContext(w, r, data) + ctx["Syndicate"] = syndicate + ctx["Members"] = members + ctx["IsManager"] = isManager - tmpl := templateHelpers.LoadTemplateFiles("syndicate-view.html", "templates/syndicate/view.html") - tmpl.ExecuteTemplate(w, "layout", context) + tmpl := templateHelpers.LoadTemplateFiles("syndicate-view.html", "web/templates/syndicate/view.html") + _ = tmpl.ExecuteTemplate(w, "layout", ctx) } } -func SyndicateLogTicketHandler(db *sql.DB) http.HandlerFunc { +func SyndicateLogTicketHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { - templateHelpers.RenderError(w, r, 403) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } syndicateId := helpers.Atoi(r.URL.Query().Get("id")) - syndicate, err := syndicateStorage.GetSyndicateByID(db, syndicateId) + syndicate, err := syndicateStorage.GetSyndicateByID(app.DB, syndicateId) if err != nil || syndicate.OwnerID != userID { - templateHelpers.RenderError(w, r, 403) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } switch r.Method { case http.MethodGet: - data := templateHandlers.BuildTemplateData(db, w, r) - context := templateHelpers.TemplateContext(w, r, data) - context["Syndicate"] = syndicate + data := templateHandlers.BuildTemplateData(app, w, r) + ctx := templateHelpers.TemplateContext(w, r, data) + ctx["Syndicate"] = syndicate - tmpl := templateHelpers.LoadTemplateFiles("syndicate-log-ticket.html", "templates/syndicate/log_ticket.html") - tmpl.ExecuteTemplate(w, "layout", context) + tmpl := templateHelpers.LoadTemplateFiles("syndicate-log-ticket.html", "web/templates/syndicate/log_ticket.html") + _ = tmpl.ExecuteTemplate(w, "layout", ctx) case http.MethodPost: gameType := r.FormValue("game_type") drawDate := r.FormValue("draw_date") method := r.FormValue("purchase_method") - err := ticketStorage.InsertTicket(db, models.Ticket{ + err := ticketStorage.InsertTicket(app.DB, models.Ticket{ UserId: userID, GameType: gameType, DrawDate: drawDate, PurchaseMethod: method, SyndicateId: &syndicateId, - // ToDo image path }) - if err != nil { - templateHelpers.SetFlash(w, r, "Failed to add ticket.") + templateHelpers.SetFlash(r, "Failed to add ticket.") } else { - templateHelpers.SetFlash(w, r, "Ticket added for syndicate.") + templateHelpers.SetFlash(r, "Ticket added for syndicate.") } http.Redirect(w, r, fmt.Sprintf("/syndicate/view?id=%d", syndicateId), http.StatusSeeOther) default: - templateHelpers.RenderError(w, r, 405) + templateHelpers.RenderError(w, r, http.StatusMethodNotAllowed) } } } -func SyndicateTicketsHandler(db *sql.DB) http.HandlerFunc { +func SyndicateTicketsHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { - templateHelpers.RenderError(w, r, 403) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } syndicateID := helpers.Atoi(r.URL.Query().Get("id")) if syndicateID == 0 { - templateHelpers.RenderError(w, r, 400) + templateHelpers.RenderError(w, r, http.StatusBadRequest) return } - if !syndicateStorage.IsSyndicateMember(db, syndicateID, userID) { - templateHelpers.RenderError(w, r, 403) + if !syndicateStorage.IsSyndicateMember(app.DB, syndicateID, userID) { + templateHelpers.RenderError(w, r, http.StatusForbidden) return } - tickets := ticketStorage.GetSyndicateTickets(db, syndicateID) + // You said GetSyndicateTickets lives in storage/syndicate: + tickets := syndicateStorage.GetSyndicateTickets(app.DB, syndicateID) + // If you later move it into tickets storage, switch to: + // tickets := ticketStorage.GetSyndicateTickets(app.DB, syndicateID) - data := templateHandlers.BuildTemplateData(db, w, r) - context := templateHelpers.TemplateContext(w, r, data) - context["SyndicateID"] = syndicateID - context["Tickets"] = tickets + data := templateHandlers.BuildTemplateData(app, w, r) + ctx := templateHelpers.TemplateContext(w, r, data) + ctx["SyndicateID"] = syndicateID + ctx["Tickets"] = tickets - tmpl := templateHelpers.LoadTemplateFiles("syndicate-tickets.html", "templates/syndicate/tickets.html") - tmpl.ExecuteTemplate(w, "layout", context) + tmpl := templateHelpers.LoadTemplateFiles("syndicate-tickets.html", "web/templates/syndicate/tickets.html") + _ = tmpl.ExecuteTemplate(w, "layout", ctx) } } diff --git a/internal/handlers/lottery/syndicate/syndicate_invites.go b/internal/handlers/lottery/syndicate/syndicate_invites.go index b812c68..adbca9a 100644 --- a/internal/handlers/lottery/syndicate/syndicate_invites.go +++ b/internal/handlers/lottery/syndicate/syndicate_invites.go @@ -1,183 +1,196 @@ +// internal/handlers/lottery/syndicate/syndicate_invites.go package handlers import ( - "database/sql" "fmt" "net/http" "strconv" "time" templateHandlers "synlotto-website/internal/handlers/template" + "synlotto-website/internal/helpers" securityHelpers "synlotto-website/internal/helpers/security" templateHelpers "synlotto-website/internal/helpers/template" - storage "synlotto-website/internal/storage/syndicate" + "synlotto-website/internal/platform/bootstrap" syndicateStorage "synlotto-website/internal/storage/syndicate" - - "synlotto-website/internal/helpers" ) -func SyndicateInviteHandler(db *sql.DB) http.HandlerFunc { +// GET /syndicate/invite?id= +// POST /syndicate/invite (syndicate_id, username) +func SyndicateInviteHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { - templateHandlers.RenderError(w, r, http.StatusForbidden) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } switch r.Method { case http.MethodGet: syndicateID := helpers.Atoi(r.URL.Query().Get("id")) - data := templateHandlers.BuildTemplateData(db, w, r) - context := templateHelpers.TemplateContext(w, r, data) - context["SyndicateID"] = syndicateID + data := templateHandlers.BuildTemplateData(app, w, r) + ctx := templateHelpers.TemplateContext(w, r, data) + ctx["SyndicateID"] = syndicateID - tmpl := templateHelpers.LoadTemplateFiles("invite-syndicate.html", "templates/syndicate/invite.html") - err := tmpl.ExecuteTemplate(w, "layout", context) - if err != nil { - templateHandlers.RenderError(w, r, 500) + tmpl := templateHelpers.LoadTemplateFiles("invite-syndicate.html", "web/templates/syndicate/invite.html") + if err := tmpl.ExecuteTemplate(w, "layout", ctx); err != nil { + templateHelpers.RenderError(w, r, http.StatusInternalServerError) } + case http.MethodPost: syndicateID := helpers.Atoi(r.FormValue("syndicate_id")) username := r.FormValue("username") - err := syndicateStorage.InviteToSyndicate(db, userID, syndicateID, username) - if err != nil { - templateHelpers.SetFlash(w, r, "Failed to send invite: "+err.Error()) + if err := syndicateStorage.InviteToSyndicate(app.DB, userID, syndicateID, username); err != nil { + templateHelpers.SetFlash(r, "Failed to send invite: "+err.Error()) } else { - templateHelpers.SetFlash(w, r, "Invite sent successfully.") + templateHelpers.SetFlash(r, "Invite sent successfully.") } http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther) default: - templateHandlers.RenderError(w, r, http.StatusMethodNotAllowed) + templateHelpers.RenderError(w, r, http.StatusMethodNotAllowed) } } } -func ViewInvitesHandler(db *sql.DB) http.HandlerFunc { +// GET /syndicate/invites +func ViewInvitesHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { - templateHandlers.RenderError(w, r, 403) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } - invites := syndicateStorage.GetPendingInvites(db, userID) - data := templateHandlers.BuildTemplateData(db, w, r) - context := templateHelpers.TemplateContext(w, r, data) - context["Invites"] = invites + invites := syndicateStorage.GetPendingSyndicateInvites(app.DB, userID) - tmpl := templateHelpers.LoadTemplateFiles("invites.html", "templates/syndicate/invites.html") - tmpl.ExecuteTemplate(w, "layout", context) + data := templateHandlers.BuildTemplateData(app, w, r) + ctx := templateHelpers.TemplateContext(w, r, data) + ctx["Invites"] = invites + + tmpl := templateHelpers.LoadTemplateFiles("invites.html", "web/templates/syndicate/invites.html") + _ = tmpl.ExecuteTemplate(w, "layout", ctx) } } -func AcceptInviteHandler(db *sql.DB) http.HandlerFunc { +// POST /syndicate/invites/accept?id= +func AcceptInviteHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { inviteID := helpers.Atoi(r.URL.Query().Get("id")) - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { - templateHandlers.RenderError(w, r, 403) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } - err := syndicateStorage.AcceptInvite(db, inviteID, userID) - if err != nil { - templateHelpers.SetFlash(w, r, "Failed to accept invite") + if err := syndicateStorage.AcceptInvite(app.DB, inviteID, userID); err != nil { + templateHelpers.SetFlash(r, "Failed to accept invite") } else { - templateHelpers.SetFlash(w, r, "You have joined the syndicate") + templateHelpers.SetFlash(r, "You have joined the syndicate") } http.Redirect(w, r, "/syndicate", http.StatusSeeOther) } } -func DeclineInviteHandler(db *sql.DB) http.HandlerFunc { +// POST /syndicate/invites/decline?id= +func DeclineInviteHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { inviteID := helpers.Atoi(r.URL.Query().Get("id")) - _ = syndicateStorage.UpdateInviteStatus(db, inviteID, "declined") + _ = syndicateStorage.UpdateInviteStatus(app.DB, inviteID, "declined") http.Redirect(w, r, "/syndicate/invites", http.StatusSeeOther) } } -func CreateInviteToken(db *sql.DB, syndicateID, invitedByID int, ttlHours int) (string, error) { +// ===== Invite Tokens ======================================================== +// (Consider moving these two helpers to internal/storage/syndicate) + +// Create an invite token that expires after ttlHours. +func CreateInviteToken(app *bootstrap.App, syndicateID, invitedByID int, ttlHours int) (string, error) { token, err := securityHelpers.GenerateSecureToken() if err != nil { return "", err } - expires := time.Now().Add(time.Duration(ttlHours) * time.Hour) - _, err = db.Exec(` + _, err = app.DB.Exec(` INSERT INTO syndicate_invite_tokens (syndicate_id, token, invited_by_user_id, expires_at) VALUES (?, ?, ?, ?) `, syndicateID, token, invitedByID, expires) - return token, err } -// ToDo: Whys is there SQL in here??? Shouldn't be in handlers -func AcceptInviteToken(db *sql.DB, token string, userID int) error { +// Validate + consume a token to join a syndicate. +func AcceptInviteToken(app *bootstrap.App, token string, userID int) error { var syndicateID int - var expiresAt, acceptedAt sql.NullTime - err := db.QueryRow(` - SELECT syndicate_id, expires_at, accepted_at - FROM syndicate_invite_tokens - WHERE token = ? - `, token).Scan(&syndicateID, &expiresAt, &acceptedAt) - if err != nil { + var expiresAt, acceptedAt struct { + Valid bool + Time time.Time + } + + // Note: using separate variables to avoid importing database/sql here. + row := app.DB.QueryRow(` + SELECT syndicate_id, expires_at, accepted_at + FROM syndicate_invite_tokens + WHERE token = ? + `, token) + if err := row.Scan(&syndicateID, &expiresAt.Time, &acceptedAt.Time); err != nil { return fmt.Errorf("invalid or expired token") } - if acceptedAt.Valid || expiresAt.Time.Before(time.Now()) { + // If driver returns zero time when NULL, treat missing as invalid.Valid=false + expiresAt.Valid = !expiresAt.Time.IsZero() + acceptedAt.Valid = !acceptedAt.Time.IsZero() + + if acceptedAt.Valid || (expiresAt.Valid && expiresAt.Time.Before(time.Now())) { return fmt.Errorf("token already used or expired") } - _, err = db.Exec(` - INSERT INTO syndicate_members (syndicate_id, user_id, role, status, joined_at) - VALUES (?, ?, 'member', 'active', CURRENT_TIMESTAMP) - `, syndicateID, userID) - if err != nil { + if _, err := app.DB.Exec(` + INSERT INTO syndicate_members (syndicate_id, user_id, role, status, joined_at) + VALUES (?, ?, 'member', 'active', CURRENT_TIMESTAMP) + `, syndicateID, userID); err != nil { return err } - _, err = db.Exec(` - UPDATE syndicate_invite_tokens - SET accepted_by_user_id = ?, accepted_at = CURRENT_TIMESTAMP - WHERE token = ? + _, err := app.DB.Exec(` + UPDATE syndicate_invite_tokens + SET accepted_by_user_id = ?, accepted_at = CURRENT_TIMESTAMP + WHERE token = ? `, userID, token) return err } -func GenerateInviteLinkHandler(db *sql.DB) http.HandlerFunc { +// GET /syndicate/invite/token?id= +func GenerateInviteLinkHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { templateHelpers.RenderError(w, r, http.StatusForbidden) return } syndicateID := helpers.Atoi(r.URL.Query().Get("id")) - token, err := CreateInviteToken(db, syndicateID, userID, 48) + token, err := CreateInviteToken(app, syndicateID, userID, 48) if err != nil { - templateHelpers.SetFlash(w, r, "Failed to generate invite link.") + templateHelpers.SetFlash(r, "Failed to generate invite link.") http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther) return } - origin := r.Host + scheme := "http://" if r.TLS != nil { - origin = "https://" + origin - } else { - origin = "http://" + origin + scheme = "https://" } - inviteLink := fmt.Sprintf("%s/syndicate/join?token=%s", origin, token) + inviteLink := fmt.Sprintf("%s%s/syndicate/join?token=%s", scheme, r.Host, token) - templateHelpers.SetFlash(w, r, "Invite link created: "+inviteLink) + templateHelpers.SetFlash(r, "Invite link created: "+inviteLink) http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther) } } -func JoinSyndicateWithTokenHandler(db *sql.DB) http.HandlerFunc { +// GET /syndicate/join?token= +func JoinSyndicateWithTokenHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { templateHelpers.RenderError(w, r, http.StatusForbidden) return @@ -185,44 +198,43 @@ func JoinSyndicateWithTokenHandler(db *sql.DB) http.HandlerFunc { token := r.URL.Query().Get("token") if token == "" { - templateHelpers.SetFlash(w, r, "Invalid or missing invite token.") + templateHelpers.SetFlash(r, "Invalid or missing invite token.") http.Redirect(w, r, "/syndicate", http.StatusSeeOther) return } - err := AcceptInviteToken(db, token, userID) - if err != nil { - templateHelpers.SetFlash(w, r, "Failed to join syndicate: "+err.Error()) + if err := AcceptInviteToken(app, token, userID); err != nil { + templateHelpers.SetFlash(r, "Failed to join syndicate: "+err.Error()) } else { - templateHelpers.SetFlash(w, r, "You have joined the syndicate!") + templateHelpers.SetFlash(r, "You have joined the syndicate!") } http.Redirect(w, r, "/syndicate", http.StatusSeeOther) } } -func ManageInviteTokensHandler(db *sql.DB) http.HandlerFunc { +// GET /syndicate/invite/tokens?id= +func ManageInviteTokensHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { - templateHelpers.RenderError(w, r, 403) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } syndicateID := helpers.Atoi(r.URL.Query().Get("id")) - - if !storage.IsSyndicateManager(db, syndicateID, userID) { - templateHelpers.RenderError(w, r, 403) + if !syndicateStorage.IsSyndicateManager(app.DB, syndicateID, userID) { + templateHelpers.RenderError(w, r, http.StatusForbidden) return } - tokens := storage.GetInviteTokensForSyndicate(db, syndicateID) + tokens := syndicateStorage.GetInviteTokensForSyndicate(app.DB, syndicateID) - data := templateHandlers.BuildTemplateData(db, w, r) - context := templateHelpers.TemplateContext(w, r, data) - context["Tokens"] = tokens - context["SyndicateID"] = syndicateID + data := templateHandlers.BuildTemplateData(app, w, r) + ctx := templateHelpers.TemplateContext(w, r, data) + ctx["Tokens"] = tokens + ctx["SyndicateID"] = syndicateID - tmpl := templateHelpers.LoadTemplateFiles("invite-links.html", "templates/syndicate/invite_links.html") - tmpl.ExecuteTemplate(w, "layout", context) + tmpl := templateHelpers.LoadTemplateFiles("invite-links.html", "web/templates/syndicate/invite_links.html") + _ = tmpl.ExecuteTemplate(w, "layout", ctx) } } diff --git a/internal/handlers/lottery/tickets/ticket_handler.go b/internal/handlers/lottery/tickets/ticket_handler.go index 3e24427..6a30e92 100644 --- a/internal/handlers/lottery/tickets/ticket_handler.go +++ b/internal/handlers/lottery/tickets/ticket_handler.go @@ -1,3 +1,4 @@ +// internal/handlers/lottery/tickets/ticket_handler.go package handlers import ( @@ -10,21 +11,23 @@ import ( "strconv" "time" - httpHelpers "synlotto-website/internal/helpers/http" + templateHandlers "synlotto-website/internal/handlers/template" securityHelpers "synlotto-website/internal/helpers/security" templateHelpers "synlotto-website/internal/helpers/template" draws "synlotto-website/internal/services/draws" "synlotto-website/internal/helpers" "synlotto-website/internal/models" + "synlotto-website/internal/platform/bootstrap" "github.com/justinas/nosurf" ) -func AddTicket(db *sql.DB) http.HandlerFunc { - return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { +// AddTicket renders the add-ticket form (GET) and handles multi-line ticket submission (POST). +func AddTicket(app *bootstrap.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { - rows, err := db.Query(` + rows, err := app.DB.Query(` SELECT DISTINCT draw_date FROM results_thunderball ORDER BY draw_date DESC @@ -44,29 +47,27 @@ func AddTicket(db *sql.DB) http.HandlerFunc { } } - data := models.TemplateData{} + // Use shared template data builder (expects *bootstrap.App) + data := templateHandlers.BuildTemplateData(app, w, r) context := templateHelpers.TemplateContext(w, r, data) context["CSRFToken"] = nosurf.Token(r) context["DrawDates"] = drawDates - tmpl := templateHelpers.LoadTemplateFiles("add_ticket.html", "templates/account/tickets/add_ticket.html") - - err = tmpl.ExecuteTemplate(w, "layout", context) - if err != nil { + tmpl := templateHelpers.LoadTemplateFiles("add_ticket.html", "web/templates/account/tickets/add_ticket.html") + if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil { log.Println("❌ Template render error:", err) http.Error(w, "Error rendering form", http.StatusInternalServerError) } return } - err := r.ParseMultipartForm(10 << 20) - if err != nil { + if err := r.ParseMultipartForm(10 << 20); err != nil { http.Error(w, "Invalid form", http.StatusBadRequest) log.Println("❌ Failed to parse form:", err) return } - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { http.Redirect(w, r, "/account/login", http.StatusSeeOther) return @@ -77,7 +78,6 @@ func AddTicket(db *sql.DB) http.HandlerFunc { purchaseMethod := r.FormValue("purchase_method") purchaseDate := r.FormValue("purchase_date") purchaseTime := r.FormValue("purchase_time") - if purchaseTime != "" { purchaseDate += "T" + purchaseTime } @@ -90,7 +90,7 @@ func AddTicket(db *sql.DB) http.HandlerFunc { out, err := os.Create(filename) if err == nil { defer out.Close() - io.Copy(out, file) + _, _ = io.Copy(out, file) imagePath = filename } } @@ -157,7 +157,7 @@ func AddTicket(db *sql.DB) http.HandlerFunc { continue } - _, err := db.Exec(` + if _, err := app.DB.Exec(` INSERT INTO my_tickets ( userId, game_type, draw_date, ball1, ball2, ball3, ball4, ball5, ball6, @@ -169,27 +169,26 @@ func AddTicket(db *sql.DB) http.HandlerFunc { b[0], b[1], b[2], b[3], b[4], b[5], bo[0], bo[1], purchaseMethod, purchaseDate, imagePath, - ) - if err != nil { + ); err != nil { log.Println("❌ Failed to insert ticket line:", err) } else { - log.Printf("βœ… Ticket line %d saved", i+1) // ToDo create audit + log.Printf("βœ… Ticket line %d saved", i+1) } } http.Redirect(w, r, "/tickets", http.StatusSeeOther) - }) + } } -func SubmitTicket(db *sql.DB) http.HandlerFunc { - return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { - err := r.ParseMultipartForm(10 << 20) - if err != nil { +// SubmitTicket handles alternate multipart ticket submission (POST-only). +func SubmitTicket(app *bootstrap.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(10 << 20); err != nil { http.Error(w, "Invalid form", http.StatusBadRequest) return } - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { http.Redirect(w, r, "/account/login", http.StatusSeeOther) return @@ -200,7 +199,6 @@ func SubmitTicket(db *sql.DB) http.HandlerFunc { purchaseMethod := r.FormValue("purchase_method") purchaseDate := r.FormValue("purchase_date") purchaseTime := r.FormValue("purchase_time") - if purchaseTime != "" { purchaseDate += "T" + purchaseTime } @@ -213,13 +211,13 @@ func SubmitTicket(db *sql.DB) http.HandlerFunc { out, err := os.Create(filename) if err == nil { defer out.Close() - io.Copy(out, file) + _, _ = io.Copy(out, file) imagePath = filename } } - ballCount := 6 - bonusCount := 2 + const ballCount = 6 + const bonusCount = 2 balls := make([][]int, ballCount) bonuses := make([][]int, bonusCount) @@ -247,7 +245,7 @@ func SubmitTicket(db *sql.DB) http.HandlerFunc { } } - _, err := db.Exec(` + if _, err := app.DB.Exec(` INSERT INTO my_tickets ( user_id, game_type, draw_date, ball1, ball2, ball3, ball4, ball5, ball6, @@ -259,30 +257,30 @@ func SubmitTicket(db *sql.DB) http.HandlerFunc { b[0], b[1], b[2], b[3], b[4], b[5], bo[0], bo[1], purchaseMethod, purchaseDate, imagePath, - ) - if err != nil { + ); err != nil { log.Println("❌ Insert failed:", err) } } http.Redirect(w, r, "/tickets", http.StatusSeeOther) - }) + } } -func GetMyTickets(db *sql.DB) http.HandlerFunc { - return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { - data := models.TemplateData{} - var tickets []models.Ticket +// GetMyTickets lists the current user's tickets. +func GetMyTickets(app *bootstrap.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Use shared template data builder (ensures user/flash/notifications present) + data := templateHandlers.BuildTemplateData(app, w, r) context := templateHelpers.TemplateContext(w, r, data) - context["Tickets"] = tickets - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { http.Redirect(w, r, "/account/login", http.StatusSeeOther) return } - rows, err := db.Query(` + var tickets []models.Ticket + rows, err := app.DB.Query(` SELECT id, game_type, draw_date, ball1, ball2, ball3, ball4, ball5, ball6, bonus1, bonus2, @@ -308,19 +306,18 @@ func GetMyTickets(db *sql.DB) http.HandlerFunc { var prizeLabel sql.NullString var prizeAmount sql.NullFloat64 - err := rows.Scan( + if err := rows.Scan( &t.Id, &t.GameType, &t.DrawDate, &b1, &b2, &b3, &b4, &b5, &b6, &bo1, &bo2, &t.PurchaseMethod, &t.PurchaseDate, &t.ImagePath, &t.Duplicate, &matchedMain, &matchedBonus, &prizeTier, &isWinner, &prizeLabel, &prizeAmount, - ) - if err != nil { + ); err != nil { log.Println("⚠️ Failed to scan ticket row:", err) continue } - // Build primary number + bonus fields + // Normalize fields t.Ball1 = int(b1.Int64) t.Ball2 = int(b2.Int64) t.Ball3 = int(b3.Int64) @@ -348,28 +345,55 @@ func GetMyTickets(db *sql.DB) http.HandlerFunc { if prizeAmount.Valid { t.PrizeAmount = prizeAmount.Float64 } - // Build balls slices (for template use) + + // Derived fields for templates t.Balls = helpers.BuildBallsSlice(t) t.BonusBalls = helpers.BuildBonusSlice(t) - // 🎯 Get the actual draw info (used to show which numbers matched) - draw := draws.GetDrawResultForTicket(db, t.GameType, t.DrawDate) + // Fetch matching draw info + draw := draws.GetDrawResultForTicket(app.DB, t.GameType, t.DrawDate) t.MatchedDraw = draw - // βœ… DEBUG - log.Printf("βœ… Ticket #%d", t.Id) - log.Printf("Balls: %v", t.Balls) - log.Printf("DrawResult: %+v", draw) - tickets = append(tickets, t) } - tmpl := templateHelpers.LoadTemplateFiles("my_tickets.html", "templates/account/tickets/my_tickets.html") + context["Tickets"] = tickets - err = tmpl.ExecuteTemplate(w, "layout", context) - if err != nil { + tmpl := templateHelpers.LoadTemplateFiles("my_tickets.html", "web/templates/account/tickets/my_tickets.html") + if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil { log.Println("❌ Template error:", err) http.Error(w, "Error rendering page", http.StatusInternalServerError) } - }) + } } + +// ToDo +// http: superfluous response.WriteHeader call (from SCS) + +//This happens when headers are written twice in a request. With SCS, it sets cookies in WriteHeader. If something else already wrote the headers (or wrote them again), you see this warning. + +//Common culprits & fixes: + +//Use Gin’s redirect instead of the stdlib one: + +// Replace: +//http.Redirect(w, r, "/account/login", http.StatusSeeOther) + +// With: +//c.Redirect(http.StatusSeeOther, "/account/login") +//c.Abort() // stop further handlers writing + +//Do this everywhere you redirect (signup, login, logout). + +//Don’t call two status-writes. For template GETs, this is fine: + +//c.Status(http.StatusOK) +//_ = tmpl.ExecuteTemplate(c.Writer, "layout", ctx) // writes body once + +//Just make sure you never write another header after that. + +//Keep your wrapping order as you have it (it’s correct): + +//Gin β†’ SCS.LoadAndSave β†’ NoSurf β†’ http.Server + +//If you still get the warning after switching to c.Redirect + c.Abort(), tell me which handler it’s coming from and I’ll point to the exact double-write. diff --git a/internal/handlers/messages.go b/internal/handlers/messages.go index b294526..9f23797 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -1,27 +1,24 @@ package handlers import ( - "database/sql" "log" "net/http" templateHandlers "synlotto-website/internal/handlers/template" - httpHelpers "synlotto-website/internal/helpers/http" securityHelpers "synlotto-website/internal/helpers/security" - - // ToDo multi storage references need handler? templateHelpers "synlotto-website/internal/helpers/template" messagesStorage "synlotto-website/internal/storage/messages" - storage "synlotto-website/internal/storage/messages" "synlotto-website/internal/helpers" + "synlotto-website/internal/platform/bootstrap" ) -func MessagesInboxHandler(db *sql.DB) http.HandlerFunc { +// Inbox: paginated list of messages +func MessagesInboxHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { - templateHelpers.RenderError(w, r, 403) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } @@ -31,86 +28,82 @@ func MessagesInboxHandler(db *sql.DB) http.HandlerFunc { } perPage := 10 - totalCount := messagesStorage.GetInboxMessageCount(db, userID) + totalCount := messagesStorage.GetInboxMessageCount(app.DB, userID) totalPages := (totalCount + perPage - 1) / perPage if totalPages == 0 { totalPages = 1 } - messages := messagesStorage.GetInboxMessages(db, userID, page, perPage) + messages := messagesStorage.GetInboxMessages(app.DB, userID, page, perPage) - data := templateHandlers.BuildTemplateData(db, w, r) - context := templateHelpers.TemplateContext(w, r, data) + data := templateHandlers.BuildTemplateData(app, w, r) + ctx := templateHelpers.TemplateContext(w, r, data) + ctx["Messages"] = messages + ctx["CurrentPage"] = page + ctx["TotalPages"] = totalPages + ctx["PageRange"] = templateHelpers.PageRange(page, totalPages) - context["Messages"] = messages - context["CurrentPage"] = page - context["TotalPages"] = totalPages - context["PageRange"] = templateHelpers.PageRange(page, totalPages) - - tmpl := templateHelpers.LoadTemplateFiles("messages.html", "templates/account/messages/index.html") - - if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil { - // ToDo: Make this load all error pages without defining explictly. - templateHelpers.RenderError(w, r, 500) + tmpl := templateHelpers.LoadTemplateFiles("messages.html", "web/templates/account/messages/index.html") + if err := tmpl.ExecuteTemplate(w, "layout", ctx); err != nil { + templateHelpers.RenderError(w, r, http.StatusInternalServerError) } } } -func ReadMessageHandler(db *sql.DB) http.HandlerFunc { +// Read a single message (marks as read) +func ReadMessageHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - idStr := r.URL.Query().Get("id") - messageID := helpers.Atoi(idStr) + id := helpers.Atoi(r.URL.Query().Get("id")) - session, _ := httpHelpers.GetSession(w, r) - userID, ok := session.Values["user_id"].(int) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { - templateHelpers.RenderError(w, r, 403) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } - message, err := storage.GetMessageByID(db, userID, messageID) + message, err := messagesStorage.GetMessageByID(app.DB, userID, id) if err != nil { log.Printf("❌ Message not found: %v", err) message = nil - } else if !message.IsRead { - _ = storage.MarkMessageAsRead(db, messageID, userID) + } else if message != nil && !message.IsRead { + _ = messagesStorage.MarkMessageAsRead(app.DB, id, userID) } - data := templateHandlers.BuildTemplateData(db, w, r) - context := templateHelpers.TemplateContext(w, r, data) - context["Message"] = message + data := templateHandlers.BuildTemplateData(app, w, r) + ctx := templateHelpers.TemplateContext(w, r, data) + ctx["Message"] = message - tmpl := templateHelpers.LoadTemplateFiles("read-message.html", "templates/account/messages/read.html") - - tmpl.ExecuteTemplate(w, "layout", context) + tmpl := templateHelpers.LoadTemplateFiles("read-message.html", "web/templates/account/messages/read.html") + _ = tmpl.ExecuteTemplate(w, "layout", ctx) } } -func ArchiveMessageHandler(db *sql.DB) http.HandlerFunc { +// Archive a message +func ArchiveMessageHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := helpers.Atoi(r.URL.Query().Get("id")) - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { - templateHelpers.RenderError(w, r, 403) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } - err := messagesStorage.ArchiveMessage(db, userID, id) - if err != nil { - templateHelpers.SetFlash(w, r, "Failed to archive message.") + if err := messagesStorage.ArchiveMessage(app.DB, userID, id); err != nil { + templateHelpers.SetFlash(r, "Failed to archive message.") } else { - templateHelpers.SetFlash(w, r, "Message archived.") + templateHelpers.SetFlash(r, "Message archived.") } http.Redirect(w, r, "/account/messages", http.StatusSeeOther) } } -func ArchivedMessagesHandler(db *sql.DB) http.HandlerFunc { +// List archived messages (paged) +func ArchivedMessagesHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { - templateHelpers.RenderError(w, r, 403) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } @@ -120,35 +113,35 @@ func ArchivedMessagesHandler(db *sql.DB) http.HandlerFunc { } perPage := 10 - messages := messagesStorage.GetArchivedMessages(db, userID, page, perPage) + messages := messagesStorage.GetArchivedMessages(app.DB, userID, page, perPage) hasMore := len(messages) == perPage - data := templateHandlers.BuildTemplateData(db, w, r) - context := templateHelpers.TemplateContext(w, r, data) - context["Messages"] = messages - context["Page"] = page - context["HasMore"] = hasMore + data := templateHandlers.BuildTemplateData(app, w, r) + ctx := templateHelpers.TemplateContext(w, r, data) + ctx["Messages"] = messages + ctx["Page"] = page + ctx["HasMore"] = hasMore - tmpl := templateHelpers.LoadTemplateFiles("archived.html", "templates/account/messages/archived.html") - tmpl.ExecuteTemplate(w, "layout", context) + tmpl := templateHelpers.LoadTemplateFiles("archived.html", "web/templates/account/messages/archived.html") + _ = tmpl.ExecuteTemplate(w, "layout", ctx) } } -func SendMessageHandler(db *sql.DB) http.HandlerFunc { +// Compose & send message +func SendMessageHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - data := templateHandlers.BuildTemplateData(db, w, r) - context := templateHelpers.TemplateContext(w, r, data) - tmpl := templateHelpers.LoadTemplateFiles("send-message.html", "templates/account/messages/send.html") - - if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil { - templateHelpers.RenderError(w, r, 500) + data := templateHandlers.BuildTemplateData(app, w, r) + ctx := templateHelpers.TemplateContext(w, r, data) + tmpl := templateHelpers.LoadTemplateFiles("send-message.html", "web/templates/account/messages/send.html") + if err := tmpl.ExecuteTemplate(w, "layout", ctx); err != nil { + templateHelpers.RenderError(w, r, http.StatusInternalServerError) } case http.MethodPost: - senderID, ok := securityHelpers.GetCurrentUserID(r) + senderID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { - templateHelpers.RenderError(w, r, 403) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } @@ -156,32 +149,32 @@ func SendMessageHandler(db *sql.DB) http.HandlerFunc { subject := r.FormValue("subject") body := r.FormValue("message") - if err := messagesStorage.SendMessage(db, senderID, recipientID, subject, body); err != nil { - templateHelpers.SetFlash(w, r, "Failed to send message.") + if err := messagesStorage.SendMessage(app.DB, senderID, recipientID, subject, body); err != nil { + templateHelpers.SetFlash(r, "Failed to send message.") } else { - templateHelpers.SetFlash(w, r, "Message sent.") + templateHelpers.SetFlash(r, "Message sent.") } http.Redirect(w, r, "/account/messages", http.StatusSeeOther) default: - templateHelpers.RenderError(w, r, 405) + templateHelpers.RenderError(w, r, http.StatusMethodNotAllowed) } } } -func RestoreMessageHandler(db *sql.DB) http.HandlerFunc { +// Restore an archived message +func RestoreMessageHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := helpers.Atoi(r.URL.Query().Get("id")) - userID, ok := securityHelpers.GetCurrentUserID(r) + userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r) if !ok { - templateHelpers.RenderError(w, r, 403) + templateHelpers.RenderError(w, r, http.StatusForbidden) return } - err := storage.RestoreMessage(db, userID, id) - if err != nil { - templateHelpers.SetFlash(w, r, "Failed to restore message.") + if err := messagesStorage.RestoreMessage(app.DB, userID, id); err != nil { + templateHelpers.SetFlash(r, "Failed to restore message.") } else { - templateHelpers.SetFlash(w, r, "Message restored.") + templateHelpers.SetFlash(r, "Message restored.") } http.Redirect(w, r, "/account/messages/archived", http.StatusSeeOther) diff --git a/internal/handlers/notifications.go b/internal/handlers/notifications.go index adcc2af..8b726cf 100644 --- a/internal/handlers/notifications.go +++ b/internal/handlers/notifications.go @@ -1,69 +1,73 @@ package handlers import ( - "database/sql" "log" "net/http" "strconv" templateHandlers "synlotto-website/internal/handlers/template" - httpHelpers "synlotto-website/internal/helpers/http" templateHelpers "synlotto-website/internal/helpers/template" + "synlotto-website/internal/platform/bootstrap" + "synlotto-website/internal/platform/sessionkeys" notificationsStorage "synlotto-website/internal/storage/notifications" ) -func NotificationsHandler(db *sql.DB) http.HandlerFunc { +// NotificationsHandler serves the notifications index page. +// New signature: accept *bootstrap.App (not *sql.DB) +func NotificationsHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - data := templateHandlers.BuildTemplateData(db, w, r) + data := templateHandlers.BuildTemplateData(app, w, r) context := templateHelpers.TemplateContext(w, r, data) - tmpl := templateHelpers.LoadTemplateFiles("index.html", "templates/account/notifications/index.html") + tmpl := templateHelpers.LoadTemplateFiles("index.html", "web/templates/account/notifications/index.html") - err := tmpl.ExecuteTemplate(w, "layout", context) - if err != nil { + if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil { log.Println("❌ Template render error:", err) http.Error(w, "Error rendering notifications page", http.StatusInternalServerError) + return } } } -func MarkNotificationReadHandler(db *sql.DB) http.HandlerFunc { +// MarkNotificationReadHandler shows a single notification (and marks unread ones as read). +// New signature: accept *bootstrap.App; read user id from SCS session. +func MarkNotificationReadHandler(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { notificationIDStr := r.URL.Query().Get("id") notificationID, err := strconv.Atoi(notificationIDStr) - if err != nil { + if err != nil || notificationID <= 0 { http.Error(w, "Invalid notification ID", http.StatusBadRequest) return } - session, _ := httpHelpers.GetSession(w, r) - userID, ok := session.Values["user_id"].(int) - if !ok { + // SCS-native session access + userID := app.SessionManager.GetInt(r.Context(), sessionkeys.UserID) + if userID == 0 { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - notification, err := notificationsStorage.GetNotificationByID(db, userID, notificationID) + // Load + mark-as-read (if needed) + notification, err := notificationsStorage.GetNotificationByID(app.DB, userID, notificationID) if err != nil { log.Printf("❌ Notification not found or belongs to another user: %v", err) notification = nil } else if !notification.IsRead { - err = notificationsStorage.MarkNotificationAsRead(db, userID, notificationID) - if err != nil { + if err := notificationsStorage.MarkNotificationAsRead(app.DB, userID, notificationID); err != nil { log.Printf("⚠️ Failed to mark as read: %v", err) } } - data := templateHandlers.BuildTemplateData(db, w, r) + data := templateHandlers.BuildTemplateData(app, w, r) context := templateHelpers.TemplateContext(w, r, data) context["Notification"] = notification - tmpl := templateHelpers.LoadTemplateFiles("read.html", "templates/account/notifications/read.html") + tmpl := templateHelpers.LoadTemplateFiles("read.html", "web/templates/account/notifications/read.html") - err = tmpl.ExecuteTemplate(w, "layout", context) - if err != nil { + if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil { log.Printf("❌ Template render error: %v", err) http.Error(w, "Template render error", http.StatusInternalServerError) + return } } } diff --git a/internal/handlers/results.go b/internal/handlers/results.go index 1bd6c72..0cc6991 100644 --- a/internal/handlers/results.go +++ b/internal/handlers/results.go @@ -113,7 +113,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc { noResultsMsg = "No results found for \"" + query + "\"" } - tmpl := templateHelpers.LoadTemplateFiles("thunderball.html", "templates/results/thunderball.html") + tmpl := templateHelpers.LoadTemplateFiles("thunderball.html", "web/templates/results/thunderball.html") err = tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{ "Results": results, diff --git a/internal/handlers/statistics/thunderball.go b/internal/handlers/statistics/thunderball.go index 2bd1972..b321702 100644 --- a/internal/handlers/statistics/thunderball.go +++ b/internal/handlers/statistics/thunderball.go @@ -1,36 +1,34 @@ +// internal/handlers/statistics/thunderball.go package handlers import ( - "database/sql" "log" "net" "net/http" templateHandlers "synlotto-website/internal/handlers/template" templateHelpers "synlotto-website/internal/helpers/template" - "synlotto-website/internal/http/middleware" + "synlotto-website/internal/platform/bootstrap" ) -func StatisticsThunderball(db *sql.DB) http.HandlerFunc { +func StatisticsThunderball(app *bootstrap.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ip, _, _ := net.SplitHostPort(r.RemoteAddr) limiter := middleware.GetVisitorLimiter(ip) - if !limiter.Allow() { http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) return } - data := templateHandlers.BuildTemplateData(db, w, r) + data := templateHandlers.BuildTemplateData(app, w, r) context := templateHelpers.TemplateContext(w, r, data) - tmpl := templateHelpers.LoadTemplateFiles("statistics.html", "templates/statistics/thunderball.html") - - err := tmpl.ExecuteTemplate(w, "layout", context) - if err != nil { + tmpl := templateHelpers.LoadTemplateFiles("statistics.html", "web/templates/statistics/thunderball.html") + if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil { log.Println("❌ Template render error:", err) - http.Error(w, "Error rendering homepage", http.StatusInternalServerError) + http.Error(w, "Error rendering Thunderball statistics page", http.StatusInternalServerError) + return } } } diff --git a/internal/handlers/template/error.go b/internal/handlers/template/error.go index b516c86..2ef0230 100644 --- a/internal/handlers/template/error.go +++ b/internal/handlers/template/error.go @@ -1,4 +1,5 @@ -package handlers +// internal/handlers/template/error.go +package templateHandler // ToDo not nessisarily an issue with this file but βœ… internal/handlers/template/ //β†’ For anything that handles HTTP rendering (RenderError, RenderPage) @@ -12,14 +13,44 @@ package handlers //Helpers / Utilities Pure, stateless logic β€” e.g. template functions, math, formatters. Shared logic, no config, no HTTP handlers. //Handlers Own an HTTP concern β€” e.g. routes, rendering responses, returning templates or JSON. Injected dependencies (cfg, db, etc.). Should use helpers, not vice versa. +// ToDo: duplicated work of internal/http/error/errors.go? import ( + "fmt" "net/http" + "os" templateHelpers "synlotto-website/internal/helpers/template" + + "synlotto-website/internal/models" + + "github.com/alexedwards/scs/v2" + "github.com/gin-gonic/gin" ) -// RenderError delegates to the helper's RenderError, ensuring handlers remain -// the entry point for rendering HTTP responses. -func (h *Handler) RenderError(w http.ResponseWriter, r *http.Request, statusCode int) { - templateHelpers.RenderError(w, r, statusCode) +func RenderError(c *gin.Context, sessions *scs.SessionManager, status int) { + // Base context + ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{}) + + // Flash + if f := sessions.PopString(c.Request.Context(), "flash"); f != "" { + ctx["Flash"] = f + } + + // Correct template paths + pagePath := fmt.Sprintf("web/templates/error/%d.html", status) + if _, err := os.Stat(pagePath); err != nil { + c.String(status, http.StatusText(status)) + return + } + + tmpl := templateHelpers.LoadTemplateFiles( + "web/templates/layout.html", + pagePath, + ) + + c.Status(status) + if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil { + c.String(status, http.StatusText(status)) + return + } } diff --git a/internal/handlers/template/render.go b/internal/handlers/template/render.go index b3d115f..352977a 100644 --- a/internal/handlers/template/render.go +++ b/internal/handlers/template/render.go @@ -1,11 +1,19 @@ -package handlers +package templateHandler -import "synlotto-website/internal/platform/config" +import ( + "synlotto-website/internal/platform/config" + + "github.com/alexedwards/scs/v2" +) type Handler struct { - cfg config.Config + cfg config.Config + Sessions *scs.SessionManager } -func New(cfg config.Config) *Handler { - return &Handler{cfg: cfg} +func New(cfg config.Config, sessions *scs.SessionManager) *Handler { + return &Handler{ + cfg: cfg, + Sessions: sessions, + } } diff --git a/internal/handlers/template/templatedata.go b/internal/handlers/template/templatedata.go index 8fc5d39..e66e481 100644 --- a/internal/handlers/template/templatedata.go +++ b/internal/handlers/template/templatedata.go @@ -1,40 +1,52 @@ -package handlers +// internal/handlers/template/templatedata.go +package templateHandler import ( - "database/sql" - "log" "net/http" - httpHelper "synlotto-website/internal/helpers/http" - // ToDo: again, need to check if i should be using multiple stotage entries like this or if this si even correct would it not be a helper? messageStorage "synlotto-website/internal/storage/messages" notificationStorage "synlotto-website/internal/storage/notifications" usersStorage "synlotto-website/internal/storage/users" "synlotto-website/internal/models" + "synlotto-website/internal/platform/bootstrap" + "synlotto-website/internal/platform/sessionkeys" ) -func BuildTemplateData(db *sql.DB, w http.ResponseWriter, r *http.Request) models.TemplateData { - session, err := httpHelper.GetSession(w, r) - if err != nil { - log.Printf("Session error: %v", err) - } +// BuildTemplateData aggregates common UI data (user, notifications, messages) +// from the current SCS session + DB. +func BuildTemplateData(app *bootstrap.App, w http.ResponseWriter, r *http.Request) models.TemplateData { + sm := app.SessionManager + ctx := r.Context() - var user *models.User - var isAdmin bool - var notificationCount int - var notifications []models.Notification - var messageCount int - var messages []models.Message + var ( + user *models.User + isAdmin bool + notificationCount int + notifications []models.Notification + messageCount int + messages []models.Message + ) - if userId, ok := session.Values["user_id"].(int); ok { - user = usersStorage.GetUserByID(db, userId) - if user != nil { - isAdmin = user.IsAdmin - notificationCount = notificationStorage.GetNotificationCount(db, user.Id) - notifications = notificationStorage.GetRecentNotifications(db, user.Id, 15) - messageCount, _ = messageStorage.GetMessageCount(db, user.Id) - messages = messageStorage.GetRecentMessages(db, user.Id, 15) + // Read user_id from SCS (may be int or int64 depending on writes) + if v := sm.Get(ctx, sessionkeys.UserID); v != nil { + var uid int64 + switch t := v.(type) { + case int64: + uid = t + case int: + uid = int64(t) + } + + if uid > 0 { + if u := usersStorage.GetUserByID(app.DB, int(uid)); u != nil { + user = u + isAdmin = u.IsAdmin + notificationCount = notificationStorage.GetNotificationCount(app.DB, int(u.Id)) + notifications = notificationStorage.GetRecentNotifications(app.DB, int(u.Id), 15) + messageCount, _ = messageStorage.GetMessageCount(app.DB, int(u.Id)) + messages = messageStorage.GetRecentMessages(app.DB, int(u.Id), 15) + } } } diff --git a/internal/helpers/database/statements.go b/internal/helpers/database/statements.go new file mode 100644 index 0000000..82b16f9 --- /dev/null +++ b/internal/helpers/database/statements.go @@ -0,0 +1,68 @@ +package databaseHelpers + +import ( + "bufio" + "database/sql" + "strings" +) + +// ExecScript executes a multi-statement SQL script. +// It only requires that statements end with ';' and ignores '--' comments. +// (Good for simple DDL/DML. If you add routines/triggers, upgrade later.) +func ExecScript(tx *sql.Tx, script string) error { + sc := bufio.NewScanner(strings.NewReader(script)) + sc.Split(splitStatements) + + for sc.Scan() { + stmt := strings.TrimSpace(sc.Text()) + if stmt == "" { + continue + } + if _, err := tx.Exec(stmt); err != nil { + return err + } + } + return sc.Err() +} + +// splitStatements separates statements at ';' +// and strips whitespace and '--' comments. +func splitStatements(data []byte, atEOF bool) (advance int, token []byte, err error) { + // skip whitespace and comments + start := 0 + for { + // whitespace + for start < len(data) { + switch data[start] { + case ' ', '\t', '\n', '\r': + start++ + continue + } + break + } + // '-- comment' + if start+1 < len(data) && data[start] == '-' && data[start+1] == '-' { + i := start + 2 + for i < len(data) && data[i] != '\n' { + i++ + } + if i >= len(data) { + return len(data), nil, nil + } + start = i + 1 + continue + } + break + } + + // detect semicolon termination + for i := start; i < len(data); i++ { + if data[i] == ';' { + return i + 1, data[start:i], nil + } + } + if atEOF && start < len(data) { + return len(data), data[start:], nil + } + return 0, nil, nil +} diff --git a/internal/helpers/http/request.go b/internal/helpers/http/request.go new file mode 100644 index 0000000..facad68 --- /dev/null +++ b/internal/helpers/http/request.go @@ -0,0 +1,19 @@ +package httpHelpers + +import ( + "net" + "net/http" + "strings" +) + +func ClientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + return strings.TrimSpace(parts[0]) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/internal/helpers/security/token.go b/internal/helpers/security/tokens.go similarity index 100% rename from internal/helpers/security/token.go rename to internal/helpers/security/tokens.go diff --git a/internal/helpers/security/users.go b/internal/helpers/security/users.go index 2df9a20..efe2a0d 100644 --- a/internal/helpers/security/users.go +++ b/internal/helpers/security/users.go @@ -3,15 +3,12 @@ package security import ( "net/http" - httpHelpers "synlotto-website/internal/helpers/http" + "synlotto-website/internal/platform/sessionkeys" + + "github.com/alexedwards/scs/v2" ) -func GetCurrentUserID(r *http.Request) (int, bool) { - session, err := httpHelpers.GetSession(nil, r) - if err != nil { - return 0, false - } - - id, ok := session.Values["user_id"].(int) - return id, ok +func GetCurrentUserID(sm *scs.SessionManager, r *http.Request) (int, bool) { + userID := sm.GetInt(r.Context(), sessionkeys.UserID) + return userID, userID != 0 } diff --git a/internal/helpers/session/loader.go b/internal/helpers/session/loader.go deleted file mode 100644 index d1153f0..0000000 --- a/internal/helpers/session/loader.go +++ /dev/null @@ -1,20 +0,0 @@ -package helpers - -import ( - "bytes" - "os" -) - -func LoadKeyFromFile(path string) ([]byte, error) { - key, err := os.ReadFile(path) - if err != nil { - return nil, err - } - return bytes.TrimSpace(key), nil -} - -func ZeroBytes(b []byte) { - for i := range b { - b[i] = 0 - } -} diff --git a/internal/helpers/session/remember.go b/internal/helpers/session/remember.go new file mode 100644 index 0000000..b164a5e --- /dev/null +++ b/internal/helpers/session/remember.go @@ -0,0 +1,69 @@ +package helpers + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "time" +) + +func randomBase64(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func HashVerifier(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// StoreToken inserts a new token row +func StoreToken(db *sql.DB, userID int64, selector, verifierHash string, expiresAt time.Time) error { + _, err := db.Exec(` + INSERT INTO remember_tokens (user_id, selector, verifier_hash, issued_at, expires_at) + VALUES ($1,$2,$3,NOW(),$4)`, userID, selector, verifierHash, expiresAt) + return err +} + +// FindToken fetches selector+hash +func FindToken(db *sql.DB, selector string) (userID int64, verifierHash string, expiresAt time.Time, revokedAt *time.Time, err error) { + err = db.QueryRow(`SELECT user_id, verifier_hash, expires_at, revoked_at FROM remember_tokens WHERE selector=$1`, selector). + Scan(&userID, &verifierHash, &expiresAt, &revokedAt) + return +} + +// RevokeToken marks token as revoked +func RevokeToken(db *sql.DB, selector string) error { + _, err := db.Exec(`UPDATE remember_tokens SET revoked_at=NOW() WHERE selector=$1`, selector) + return err +} + +// GenerateAndStore creates a new remember-me token, stores it server-side, +// and returns the cookie-safe plaintext value to set on the client +func GenerateAndStore(db *sql.DB, userID int64, duration time.Duration) (string, time.Time, error) { + selector, err := randomBase64(16) + if err != nil { + return "", time.Time{}, err + } + + verifier, err := randomBase64(32) + if err != nil { + return "", time.Time{}, err + } + + hash := HashVerifier(verifier) + expires := time.Now().Add(duration) + + if err := StoreToken(db, userID, selector, hash, expires); err != nil { + return "", time.Time{}, err + } + + // The client cookie value contains selector + verifier + cookieVal := selector + ":" + verifier + + return cookieVal, expires, nil +} diff --git a/internal/helpers/template/build.go b/internal/helpers/template/build.go index 67a0c82..a2e2348 100644 --- a/internal/helpers/template/build.go +++ b/internal/helpers/template/build.go @@ -1,4 +1,4 @@ -package helpers +package templateHelper import ( "html/template" @@ -6,9 +6,9 @@ import ( "strings" "time" - httpHelpers "synlotto-website/internal/helpers/http" "synlotto-website/internal/models" + "github.com/alexedwards/scs/v2" "github.com/justinas/nosurf" ) @@ -27,19 +27,15 @@ func InitSiteMeta(name string, yearStart, yearEnd int) { } } +var sm *scs.SessionManager + +func InitSessionManager(manager *scs.SessionManager) { + sm = manager +} + func TemplateContext(w http.ResponseWriter, r *http.Request, data models.TemplateData) map[string]interface{} { - session, _ := httpHelpers.GetSession(w, r) - - var flash string - if f, ok := session.Values["flash"].(string); ok { - flash = f - delete(session.Values, "flash") - session.Save(r, w) - } - return map[string]interface{}{ "CSRFToken": nosurf.Token(r), - "Flash": flash, "User": data.User, "IsAdmin": data.IsAdmin, "NotificationCount": data.NotificationCount, @@ -105,18 +101,18 @@ func TemplateFuncs() template.FuncMap { func LoadTemplateFiles(name string, files ...string) *template.Template { shared := []string{ - "templates/main/layout.html", - "templates/main/topbar.html", - "templates/main/footer.html", + "web/templates/main/layout.html", + "web/templates/main/topbar.html", + "web/templates/main/footer.html", } all := append(shared, files...) return template.Must(template.New(name).Funcs(TemplateFuncs()).ParseFiles(all...)) } -func SetFlash(w http.ResponseWriter, r *http.Request, message string) { - session, _ := httpHelpers.GetSession(w, r) - session.Values["flash"] = message - session.Save(r, w) +func SetFlash(r *http.Request, message string) { + if sm != nil { + sm.Put(r.Context(), "flash", message) + } } func InSlice(n int, list []int) bool { diff --git a/internal/helpers/template/error.go b/internal/helpers/template/error.go index 9e9f6dd..360d236 100644 --- a/internal/helpers/template/error.go +++ b/internal/helpers/template/error.go @@ -1,4 +1,4 @@ -package helpers +package templateHelper import ( "fmt" diff --git a/internal/helpers/template/pagination.go b/internal/helpers/template/pagination.go index 972229b..fffd15a 100644 --- a/internal/helpers/template/pagination.go +++ b/internal/helpers/template/pagination.go @@ -1,4 +1,4 @@ -package helpers +package templateHelper import ( "database/sql" diff --git a/internal/http/error/errors.go b/internal/http/error/errors.go new file mode 100644 index 0000000..e8a135c --- /dev/null +++ b/internal/http/error/errors.go @@ -0,0 +1,55 @@ +package errors + +import ( + "fmt" + "net/http" + "os" + + templateHelpers "synlotto-website/internal/helpers/template" + "synlotto-website/internal/models" + + "github.com/alexedwards/scs/v2" + "github.com/gin-gonic/gin" +) + +// RenderStatus renders web/templates/error/.html inside layout.html. +func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) { + // Base context + ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{}) + + // Flash (SCS) + if f := sessions.PopString(c.Request.Context(), "flash"); f != "" { + ctx["Flash"] = f + } + + // Use your finalized paths + pagePath := fmt.Sprintf("web/templates/error/%d.html", status) + if _, err := os.Stat(pagePath); err != nil { + c.String(status, http.StatusText(status)) + return + } + + // Keep your "layout first" load order + tmpl := templateHelpers.LoadTemplateFiles( + "web/templates/layout.html", + pagePath, + ) + + c.Status(status) + if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil { + c.String(status, http.StatusText(status)) + } +} + +// Adapters so bootstrap can wire these without lambdas everywhere. +func NoRoute(sessions *scs.SessionManager) gin.HandlerFunc { + return func(c *gin.Context) { RenderStatus(c, sessions, http.StatusNotFound) } +} +func NoMethod(sessions *scs.SessionManager) gin.HandlerFunc { + return func(c *gin.Context) { RenderStatus(c, sessions, http.StatusMethodNotAllowed) } +} +func Recovery(sessions *scs.SessionManager) gin.RecoveryFunc { + return func(c *gin.Context, _ interface{}) { + RenderStatus(c, sessions, http.StatusInternalServerError) + } +} diff --git a/internal/http/middleware/auth.go b/internal/http/middleware/auth.go index fb636c1..08530eb 100644 --- a/internal/http/middleware/auth.go +++ b/internal/http/middleware/auth.go @@ -1,50 +1,97 @@ package middleware -// ToDo: will no doubt need to fix as now using new session not the olf gorilla one import ( "net/http" + "strings" "time" - httpHelpers "synlotto-website/internal/helpers/http" + sessionHelper "synlotto-website/internal/helpers/session" + "synlotto-website/internal/platform/bootstrap" + "synlotto-website/internal/platform/sessionkeys" - "synlotto-website/internal/platform/constants" + "github.com/gin-gonic/gin" ) -func Auth(required bool) func(http.HandlerFunc) http.HandlerFunc { - return func(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - session, _ := httpHelpers.GetSession(w, r) +// Tracks idle timeout using LastActivity; redirects on timeout. +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + app := c.MustGet("app").(*bootstrap.App) + sm := app.SessionManager + ctx := c.Request.Context() - _, ok := session.Values["user_id"].(int) - - if required && !ok { - http.Redirect(w, r, "/account/login", http.StatusSeeOther) + if v := sm.Get(ctx, sessionkeys.LastActivity); v != nil { + if last, ok := v.(time.Time); ok && time.Since(last) > sm.Lifetime { + _ = sm.RenewToken(ctx) + sm.Put(ctx, sessionkeys.Flash, "Your session has timed out.") + c.Redirect(http.StatusSeeOther, "/account/login") + c.Abort() return } - - if ok { - last, hasLast := session.Values["last_activity"].(time.Time) - if hasLast && time.Since(last) > constants.SessionDuration { - session.Options.MaxAge = -1 - session.Save(r, w) - - newSession, _ := httpHelpers.GetSession(w, r) - newSession.Values["flash"] = "Your session has timed out." - newSession.Save(r, w) - - http.Redirect(w, r, "/account/login", http.StatusSeeOther) - return - } - - session.Values["last_activity"] = time.Now() - session.Save(r, w) - } - - next(w, r) } + + sm.Put(ctx, sessionkeys.LastActivity, time.Now().UTC()) + c.Next() } } -func Protected(h http.HandlerFunc) http.HandlerFunc { - return Auth(true)(SessionTimeout(h)) +// Optional remember-me using selector:verifier token pair. +func RememberMiddleware(app *bootstrap.App) gin.HandlerFunc { + return func(c *gin.Context) { + sm := app.SessionManager + ctx := c.Request.Context() + + // Already logged in? Skip. + if sm.Exists(ctx, sessionkeys.UserID) { + c.Next() + return + } + + cookie, err := c.Request.Cookie(app.Config.Session.RememberCookieName) + if err != nil { + c.Next() + return + } + + parts := strings.SplitN(cookie.Value, ":", 2) + if len(parts) != 2 { + c.Next() + return + } + selector, verifier := parts[0], parts[1] + + userID, hash, expires, revokedAt, err := sessionHelper.FindToken(app.DB, selector) + if err != nil || revokedAt != nil || time.Now().After(expires) { + c.Next() + return + } + + if sessionHelper.HashVerifier(verifier) != hash { + // Tampered token – revoke for safety. + _ = sessionHelper.RevokeToken(app.DB, selector) + c.Next() + return + } + + // Success β†’ create fresh SCS session + _ = sm.RenewToken(ctx) + sm.Put(ctx, sessionkeys.UserID, userID) + sm.Put(ctx, sessionkeys.LastActivity, time.Now().UTC()) + + c.Next() + } +} + +// Blocks anonymous users; redirects to login. +func RequireAuth() gin.HandlerFunc { + return func(c *gin.Context) { + app := c.MustGet("app").(*bootstrap.App) + sm := app.SessionManager + + if sm.GetInt(c.Request.Context(), sessionkeys.UserID) == 0 { + c.Redirect(http.StatusSeeOther, "/account/login") + c.Abort() + return + } + c.Next() + } } diff --git a/internal/http/middleware/remember.go b/internal/http/middleware/remember.go new file mode 100644 index 0000000..5a731c9 --- /dev/null +++ b/internal/http/middleware/remember.go @@ -0,0 +1,66 @@ +package middleware + +import ( + "strings" + "time" + + "github.com/gin-gonic/gin" + + sessionHelper "synlotto-website/internal/helpers/session" + "synlotto-website/internal/platform/bootstrap" + "synlotto-website/internal/platform/sessionkeys" +) + +// Remember checks if a remember-me cookie exists and restores the session if valid. +func Remember(app *bootstrap.App) gin.HandlerFunc { + return func(c *gin.Context) { + sm := app.SessionManager + ctx := c.Request.Context() + + // Already logged in? skip. + if sm.Exists(ctx, sessionkeys.UserID) { + c.Next() + return + } + + // Look for remember-me cookie + cookie, err := c.Request.Cookie(app.Config.Session.RememberCookieName) + if err != nil { + c.Next() + return + } + + parts := strings.SplitN(cookie.Value, ":", 2) + if len(parts) != 2 { + c.Next() + return + } + selector, verifier := parts[0], parts[1] + if selector == "" || verifier == "" { + c.Next() + return + } + + userID, hash, expiresAt, revokedAt, err := sessionHelper.FindToken(app.DB, selector) + if err != nil || revokedAt != nil || time.Now().After(expiresAt) { + c.Next() + return + } + + // Constant-time compare via hashing the verifier + if sessionHelper.HashVerifier(verifier) != hash { + _ = sessionHelper.RevokeToken(app.DB, selector) // tampered β†’ revoke + c.Next() + return + } + + // βœ… Valid token β†’ create a new session for the user + _ = sm.RenewToken(ctx) + sm.Put(ctx, sessionkeys.UserID, userID) + sm.Put(ctx, sessionkeys.LastActivity, time.Now().UTC()) + + // (Optional TODO): rotate token and set a fresh cookie. + + c.Next() + } +} diff --git a/internal/http/middleware/sessiontimeout.go b/internal/http/middleware/sessiontimeout.go deleted file mode 100644 index 476824f..0000000 --- a/internal/http/middleware/sessiontimeout.go +++ /dev/null @@ -1,41 +0,0 @@ -package middleware - -// ToDo: This is more than likele now redunant with the session change -import ( - "log" - "net/http" - "time" - - session "synlotto-website/internal/handlers/session" - - "synlotto-website/internal/platform/constants" -) - -func SessionTimeout(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - sess, err := session.GetSession(w, r) - if err != nil { - http.Redirect(w, r, "/account/login", http.StatusSeeOther) - return - } - - last, ok := sess.Values["last_activity"].(time.Time) - if !ok || time.Since(last) > constants.SessionDuration { - sess.Options.MaxAge = -1 - _ = sess.Save(r, w) - - newSession, _ := session.GetSession(w, r) - newSession.Values["flash"] = "Your session has timed out." - _ = newSession.Save(r, w) - - log.Printf("Session timeout triggered") - http.Redirect(w, r, "/account/login", http.StatusSeeOther) - return - } - - sess.Values["last_activity"] = time.Now().UTC() - _ = sess.Save(r, w) - - next(w, r) - } -} diff --git a/internal/http/routes/accountroutes.go b/internal/http/routes/accountroutes.go index 27bfc7b..3e6ad6f 100644 --- a/internal/http/routes/accountroutes.go +++ b/internal/http/routes/accountroutes.go @@ -1,28 +1,24 @@ package routes import ( - "database/sql" - "net/http" - accountHandlers "synlotto-website/internal/handlers/account" - lotteryDrawHandlers "synlotto-website/internal/handlers/lottery/tickets" - "synlotto-website/internal/handlers" "synlotto-website/internal/http/middleware" + "synlotto-website/internal/platform/bootstrap" ) -func SetupAccountRoutes(mux *http.ServeMux, db *sql.DB) { - mux.HandleFunc("/account/login", accountHandlers.Login(db)) - mux.HandleFunc("/account/logout", middleware.Protected(accountHandlers.Logout)) - mux.HandleFunc("/account/signup", accountHandlers.Signup) - mux.HandleFunc("/account/tickets/add_ticket", lotteryDrawHandlers.AddTicket(db)) - mux.HandleFunc("/account/tickets/my_tickets", lotteryDrawHandlers.GetMyTickets(db)) - mux.HandleFunc("/account/messages", middleware.Protected(handlers.MessagesInboxHandler(db))) - mux.HandleFunc("/account/messages/read", middleware.Protected(handlers.ReadMessageHandler(db))) - mux.HandleFunc("/account/messages/archive", middleware.Protected(handlers.ArchiveMessageHandler(db))) - mux.HandleFunc("/account/messages/archived", middleware.Protected(handlers.ArchivedMessagesHandler(db))) - mux.HandleFunc("/account/messages/restore", middleware.Protected(handlers.RestoreMessageHandler(db))) - mux.HandleFunc("/account/messages/send", middleware.Protected(handlers.SendMessageHandler(db))) - mux.HandleFunc("/account/notifications", middleware.Protected(handlers.NotificationsHandler(db))) - mux.HandleFunc("/account/notifications/read", middleware.Protected(handlers.MarkNotificationReadHandler(db))) +func RegisterAccountRoutes(app *bootstrap.App) { + r := app.Router + + acc := r.Group("/account") + acc.GET("/login", accountHandlers.LoginGet) + acc.POST("/login", accountHandlers.LoginPost) + acc.GET("/signup", accountHandlers.SignupGet) + acc.POST("/signup", accountHandlers.SignupPost) + + // Protected logout + accAuth := r.Group("/account") + accAuth.Use(middleware.AuthMiddleware(), middleware.RequireAuth()) + accAuth.POST("/logout", accountHandlers.Logout) + accAuth.GET("/logout", accountHandlers.Logout) //ToDo: keep if you still support GET? } diff --git a/internal/http/routes/adminroutes.go b/internal/http/routes/adminroutes.go index 07bf87e..6741da0 100644 --- a/internal/http/routes/adminroutes.go +++ b/internal/http/routes/adminroutes.go @@ -1,27 +1,38 @@ package routes import ( - "database/sql" - "net/http" - admin "synlotto-website/internal/handlers/admin" + "synlotto-website/internal/http/middleware" + "synlotto-website/internal/platform/bootstrap" + + "github.com/gin-gonic/gin" ) -func SetupAdminRoutes(mux *http.ServeMux, db *sql.DB) { - mux.HandleFunc("/admin/access", middleware.Protected(admin.AdminAccessLogHandler(db))) - mux.HandleFunc("/admin/audit", middleware.Protected(admin.AuditLogHandler(db))) - mux.HandleFunc("/admin/dashboard", middleware.Protected(admin.AdminDashboardHandler(db))) - mux.HandleFunc("/admin/triggers", middleware.Protected(admin.AdminTriggersHandler(db))) +func RegisterAdminRoutes(app *bootstrap.App) { + r := app.Router + + adminGroup := r.Group("/admin") + adminGroup.Use(middleware.AuthMiddleware(), middleware.RequireAuth()) + + // Logs + adminGroup.GET("/access", gin.WrapH(admin.AdminAccessLogHandler(app.DB))) + adminGroup.GET("/audit", gin.WrapH(admin.AuditLogHandler(app.DB))) + + // Dashboard + //adminGroup.GET("/dashboard", gin.WrapH(admin.AdminDashboardHandler(app.DB))) + + // Triggers + adminGroup.GET("/triggers", gin.WrapH(admin.AdminTriggersHandler(app.DB))) // Draw management - mux.HandleFunc("/admin/draws", middleware.Protected(admin.ListDrawsHandler(db))) - // mux.HandleFunc("/admin/draws/new", middleware.AdminOnly(db, admin.RenderNewDrawForm(db))) - // mux.HandleFunc("/admin/draws/submit", middleware.AdminOnly(db, admin.CreateDrawHandler(db))) - mux.HandleFunc("/admin/draws/modify", middleware.Protected(admin.ModifyDrawHandler(db))) - mux.HandleFunc("/admin/draws/delete", middleware.Protected(admin.DeleteDrawHandler(db))) + adminGroup.GET("/draws", gin.WrapH(admin.ListDrawsHandler(app.DB))) + // adminGroup.GET("/draws/new", gin.WrapH(admin.RenderNewDrawForm(app.DB))) // if/when you re-enable AdminOnly + // adminGroup.POST("/draws", gin.WrapH(admin.CreateDrawHandler(app.DB))) // example submit route + adminGroup.POST("/draws/modify", gin.WrapH(admin.ModifyDrawHandler(app.DB))) + adminGroup.POST("/draws/delete", gin.WrapH(admin.DeleteDrawHandler(app.DB))) // Prize management - mux.HandleFunc("/admin/draws/prizes/add", middleware.Protected(admin.AddPrizesHandler(db))) - mux.HandleFunc("/admin/draws/prizes/modify", middleware.Protected(admin.ModifyPrizesHandler(db))) + adminGroup.POST("/draws/prizes/add", gin.WrapH(admin.AddPrizesHandler(app.DB))) + adminGroup.POST("/draws/prizes/modify", gin.WrapH(admin.ModifyPrizesHandler(app.DB))) } diff --git a/internal/http/routes/home.go b/internal/http/routes/home.go new file mode 100644 index 0000000..1d95bfe --- /dev/null +++ b/internal/http/routes/home.go @@ -0,0 +1,10 @@ +package routes + +import ( + "synlotto-website/internal/handlers" + "synlotto-website/internal/platform/bootstrap" +) + +func RegisterHomeRoutes(app *bootstrap.App) { + app.Router.GET("/", handlers.Home(app)) +} diff --git a/internal/http/routes/statisticroutes.go b/internal/http/routes/statisticroutes.go index 083e3f1..beafaac 100644 --- a/internal/http/routes/statisticroutes.go +++ b/internal/http/routes/statisticroutes.go @@ -1,13 +1,20 @@ package routes import ( - "database/sql" - "net/http" + stats "synlotto-website/internal/handlers/statistics" - handlers "synlotto-website/internal/handlers/statistics" "synlotto-website/internal/http/middleware" + "synlotto-website/internal/platform/bootstrap" + + "github.com/gin-gonic/gin" ) -func SetupStatisticsRoutes(mux *http.ServeMux, db *sql.DB) { - mux.HandleFunc("/statistics/thunderball", middleware.Auth(true)(handlers.StatisticsThunderball(db))) +// RegisterStatisticsRoutes mounts protected statistics endpoints under /statistics. +func RegisterStatisticsRoutes(app *bootstrap.App) { + r := app.Router + + group := r.Group("/statistics") + group.Use(middleware.AuthMiddleware(), middleware.RequireAuth()) + + group.GET("/thunderball", gin.WrapH(stats.StatisticsThunderball(app))) } diff --git a/internal/http/routes/syndicateroutes.go b/internal/http/routes/syndicateroutes.go index 878a4cb..072fea3 100644 --- a/internal/http/routes/syndicateroutes.go +++ b/internal/http/routes/syndicateroutes.go @@ -1,25 +1,33 @@ package routes import ( - "database/sql" - "net/http" - - lotterySyndicateHandlers "synlotto-website/internal/handlers/lottery/syndicate" + s "synlotto-website/internal/handlers/lottery/syndicate" "synlotto-website/internal/http/middleware" + "synlotto-website/internal/platform/bootstrap" + + "github.com/gin-gonic/gin" ) -func SetupSyndicateRoutes(mux *http.ServeMux, db *sql.DB) { - mux.HandleFunc("/syndicate", middleware.Auth(true)(lotterySyndicateHandlers.ListSyndicatesHandler(db))) - mux.HandleFunc("/syndicate/create", middleware.Auth(true)(lotterySyndicateHandlers.CreateSyndicateHandler(db))) - mux.HandleFunc("/syndicate/view", middleware.Auth(true)(lotterySyndicateHandlers.ViewSyndicateHandler(db))) - mux.HandleFunc("/syndicate/tickets", middleware.Auth(true)(lotterySyndicateHandlers.SyndicateTicketsHandler(db))) - mux.HandleFunc("/syndicate/tickets/new", middleware.Auth(true)(lotterySyndicateHandlers.SyndicateLogTicketHandler(db))) - mux.HandleFunc("/syndicate/invites", middleware.Auth(true)(lotterySyndicateHandlers.ViewInvitesHandler(db))) - mux.HandleFunc("/syndicate/invites/accept", middleware.Auth(true)(lotterySyndicateHandlers.AcceptInviteHandler(db))) - mux.HandleFunc("/syndicate/invites/decline", middleware.Auth(true)(lotterySyndicateHandlers.DeclineInviteHandler(db))) - mux.HandleFunc("/syndicate/invite/token", middleware.Auth(true)(lotterySyndicateHandlers.GenerateInviteLinkHandler(db))) - mux.HandleFunc("/syndicate/invite/tokens", middleware.Auth(true)(lotterySyndicateHandlers.ManageInviteTokensHandler(db))) - mux.HandleFunc("/syndicate/join", middleware.Auth(true)(lotterySyndicateHandlers.JoinSyndicateWithTokenHandler(db))) +// RegisterSyndicateRoutes mounts all /syndicate routes. +// Protection is enforced at the group level via AuthMiddleware + RequireAuth. +func RegisterSyndicateRoutes(app *bootstrap.App) { + r := app.Router + syn := r.Group("/syndicate") + syn.Use(middleware.AuthMiddleware(), middleware.RequireAuth()) + + // Use Any to preserve old ServeMux behavior (accepts both GET/POST where applicable). + // You can refine methods later (e.g., GET for views, POST for mutate actions). + syn.Any("", gin.WrapH(s.ListSyndicatesHandler(app))) + syn.Any("/create", gin.WrapH(s.CreateSyndicateHandler(app))) + syn.Any("/view", gin.WrapH(s.ViewSyndicateHandler(app))) + syn.Any("/tickets", gin.WrapH(s.SyndicateTicketsHandler(app))) + syn.Any("/tickets/new", gin.WrapH(s.SyndicateLogTicketHandler(app))) + syn.Any("/invites", gin.WrapH(s.ViewInvitesHandler(app))) + syn.Any("/invites/accept", gin.WrapH(s.AcceptInviteHandler(app))) + syn.Any("/invites/decline", gin.WrapH(s.DeclineInviteHandler(app))) + syn.Any("/invite/token", gin.WrapH(s.GenerateInviteLinkHandler(app))) + syn.Any("/invite/tokens", gin.WrapH(s.ManageInviteTokensHandler(app))) + syn.Any("/join", gin.WrapH(s.JoinSyndicateWithTokenHandler(app))) } diff --git a/internal/models/user.go b/internal/models/user.go index e4e6128..f49960e 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -5,10 +5,13 @@ import ( ) type User struct { - Id int + Id int64 Username string + Email string PasswordHash string IsAdmin bool + CreatedAt time.Time + UpdatedAt time.Time } // ToDo: should be in a notification model? diff --git a/internal/platform/bootstrap/loader.go b/internal/platform/bootstrap/loader.go index 4b86d43..d3a0450 100644 --- a/internal/platform/bootstrap/loader.go +++ b/internal/platform/bootstrap/loader.go @@ -1,30 +1,160 @@ +// Package bootstrap +// Path /internal/platform/bootstrap +// File: loader.go +// +// Purpose: +// Centralized application initializer (the β€œapplication kernel”). +// This constructs and wires together the core runtime graph used by the +// entire system: configuration, database, session manager (SCS), router (Gin), +// CSRF wrapper (nosurf), and the HTTP server. +// +// Responsibilities: +// 1) Load strongly-typed configuration. +// 2) Initialize long-lived infrastructure (DB, sessions). +// 3) Build the Gin router and mount global middleware and routes. +// 4) Wrap the router with SCS (LoadAndSave) and CSRF in the correct order. +// 5) Construct the http.Server and expose the assembled components via App. +// +// HTTP stack order (important): +// Gin Router β†’ SCS LoadAndSave β†’ CSRF Wrapper β†’ http.Server +// +// Design guarantees: +// - Single source of truth via the App struct. +// - Stable middleware order (SCS must wrap Gin before CSRF). +// - Gin handlers can access *App via c.MustGet("app"). +// - Extensible: add infra (cache/mailer/metrics) here. +// +// Change log: +// [2025-10-24] Migrated to SCS-first wrapping and explicit App wiring. + package bootstrap import ( - "encoding/json" + "context" + "database/sql" "fmt" - "os" + "net/http" + "time" + + weberr "synlotto-website/internal/http/error" + databasePlatform "synlotto-website/internal/platform/database" "synlotto-website/internal/platform/config" + "synlotto-website/internal/platform/csrf" + "synlotto-website/internal/platform/session" + + "github.com/alexedwards/scs/v2" + "github.com/gin-gonic/gin" + + _ "github.com/go-sql-driver/mysql" ) -type AppState struct { - Config *config.Config +type App struct { + Config config.Config + DB *sql.DB + SessionManager *scs.SessionManager + Router *gin.Engine + Handler http.Handler + Server *http.Server } -func LoadAppState(configPath string) (*AppState, error) { - file, err := os.Open(configPath) +func Load(configPath string) (*App, error) { + cfg, err := config.Load(configPath) if err != nil { - return nil, fmt.Errorf("open config: %w", err) - } - defer file.Close() - - var config config.Config - if err := json.NewDecoder(file).Decode(&config); err != nil { - return nil, fmt.Errorf("decode config: %w", err) + return nil, fmt.Errorf("load config: %w", err) } - return &AppState{ - Config: &config, - }, nil + db, err := openMySQL(cfg) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("mysql ping: %w", err) + } + + if err := databasePlatform.EnsureInitialSchema(db); err != nil { + return nil, fmt.Errorf("ensure schema: %w", err) + } + + sessions := session.New(cfg) + + router := gin.New() + router.Use(gin.Logger(), gin.Recovery()) + router.Static("/static", "./web/static") + router.StaticFile("/favicon.ico", "./web/static/favicon.ico") + + app := &App{ + Config: cfg, + DB: db, + SessionManager: sessions, + Router: router, + } + + router.Use(func(c *gin.Context) { + c.Set("app", app) + c.Next() + }) + + router.NoRoute(weberr.NoRoute(app.SessionManager)) + router.NoMethod(weberr.NoMethod(app.SessionManager)) + router.Use(gin.CustomRecovery(weberr.Recovery(app.SessionManager))) + + handler := sessions.LoadAndSave(router) + handler = csrf.Wrap(handler, cfg) + + addr := fmt.Sprintf("%s:%d", cfg.HttpServer.Address, cfg.HttpServer.Port) + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, // ToDo: consider moving to config + } + + app.Handler = handler + app.Server = srv + + return app, nil +} + +func openMySQL(cfg config.Config) (*sql.DB, error) { + dbCfg := cfg.Database + + escapedUser := dbCfg.Username + escapedPass := dbCfg.Password + + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4,utf8&loc=UTC", + escapedUser, + escapedPass, + dbCfg.Server, + dbCfg.Port, + dbCfg.DatabaseNamed, + ) + + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, fmt.Errorf("mysql open: %w", err) + } + + if dbCfg.MaxOpenConnections > 0 { + db.SetMaxOpenConns(dbCfg.MaxOpenConnections) + } + if dbCfg.MaxIdleConnections > 0 { + db.SetMaxIdleConns(dbCfg.MaxIdleConnections) + } + if dbCfg.ConnectionMaxLifetime != "" { + if d, err := time.ParseDuration(dbCfg.ConnectionMaxLifetime); err == nil { + db.SetConnMaxLifetime(d) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("mysql ping: %w", err) + } + + return db, nil } diff --git a/internal/platform/config/config.go b/internal/platform/config/config.go index eac0fd9..77a6139 100644 --- a/internal/platform/config/config.go +++ b/internal/platform/config/config.go @@ -2,21 +2,19 @@ package config import ( "sync" - - "synlotto-website/internal/platform/config" ) var ( - appConfig *config.Config + appConfig *Config once sync.Once ) -func Init(config *config.Config) { +func Init(config *Config) { once.Do(func() { appConfig = config }) } -func Get() *config.Config { +func Get() *Config { return appConfig } diff --git a/internal/platform/config/types.go b/internal/platform/config/types.go index 1b8dc0a..85be7aa 100644 --- a/internal/platform/config/types.go +++ b/internal/platform/config/types.go @@ -28,8 +28,11 @@ type Config struct { } `json:"license"` Session struct { - Name string `json:"name"` - Lifetime string `json:"lifetime"` + CookieName string `json:"cookieName"` + Lifetime string `json:"lifetime"` + IdleTimeout string `json:"idleTimeout"` + RememberCookieName string `json:"rememberCookieName"` + RememberDuration string `json:"rememberDuration"` } `json:"session"` Site struct { diff --git a/internal/platform/database/schema.go b/internal/platform/database/schema.go new file mode 100644 index 0000000..b32d8ee --- /dev/null +++ b/internal/platform/database/schema.go @@ -0,0 +1,35 @@ +package databasePlatform + +import ( + "database/sql" + "fmt" + + databaseHelpers "synlotto-website/internal/helpers/database" + migrationSQL "synlotto-website/internal/storage/migrations" +) + +func EnsureInitialSchema(db *sql.DB) error { + fmt.Println("βœ… EnsureInitialSchema called") // temp debug + + var cnt int + if err := db.QueryRow(migrationSQL.ProbeUsersTable).Scan(&cnt); err != nil { + return fmt.Errorf("probe users table failed: %w", err) + } + fmt.Printf("πŸ‘€ Probe users count = %d\n", cnt) // temp debug + if cnt > 0 { + return nil + } + + // sanity: show embedded SQL length so we know it actually embedded + fmt.Printf("πŸ“¦ Initial SQL bytes: %d\n", len(migrationSQL.InitialSchema)) // temp debug + + tx, err := db.Begin() + if err != nil { + return err + } + if err := databaseHelpers.ExecScript(tx, migrationSQL.InitialSchema); err != nil { + _ = tx.Rollback() + return fmt.Errorf("apply schema: %w", err) + } + return tx.Commit() +} diff --git a/internal/platform/session/session.go b/internal/platform/session/session.go index b246b41..ebc2f77 100644 --- a/internal/platform/session/session.go +++ b/internal/platform/session/session.go @@ -1,6 +1,7 @@ package session import ( + "encoding/gob" "net/http" "time" @@ -10,16 +11,25 @@ import ( ) func New(cfg config.Config) *scs.SessionManager { - lifetime := 12 * time.Hour + gob.Register(time.Time{}) + s := scs.New() + + // Lifetime (absolute max age) if d, err := time.ParseDuration(cfg.Session.Lifetime); err == nil && d > 0 { - lifetime = d + s.Lifetime = d + } else { + s.Lifetime = 12 * time.Hour } - s := scs.New() - s.Lifetime = lifetime - s.Cookie.Name = cfg.Session.Name + // Idle timeout (expire after inactivity) + if d, err := time.ParseDuration(cfg.Session.IdleTimeout); err == nil && d > 0 { + s.IdleTimeout = d + } + + s.Cookie.Name = cfg.Session.CookieName s.Cookie.HttpOnly = true s.Cookie.SameSite = http.SameSiteLaxMode s.Cookie.Secure = cfg.HttpServer.ProductionMode + return s } diff --git a/internal/platform/sessionkeys/keys.go b/internal/platform/sessionkeys/keys.go new file mode 100644 index 0000000..84e18c8 --- /dev/null +++ b/internal/platform/sessionkeys/keys.go @@ -0,0 +1,8 @@ +package sessionkeys + +//ToDo: Is this just putting in "user_id" rather than the users ID? +const ( + UserID = "user_id" + LastActivity = "last_activity" + Flash = "flash" +) diff --git a/internal/services/tickets/ticketmatching.go b/internal/services/tickets/ticketmatching.go index 5ba01cb..61c2aaf 100644 --- a/internal/services/tickets/ticketmatching.go +++ b/internal/services/tickets/ticketmatching.go @@ -5,16 +5,15 @@ import ( "fmt" "log" - lotteryTicketHandlers "synlotto-website/internal/handlers/lottery/tickets" thunderballrules "synlotto-website/internal/rules/thunderball" - services "synlotto-website/internal/services/draws" - lotteryTicketService "synlotto-website/internal/services/tickets" + drawsSvc "synlotto-website/internal/services/draws" "synlotto-website/internal/helpers" "synlotto-website/internal/models" ) -// ToDo: SQL in here needs to me moved out the handler! +// RunTicketMatching finds unmatched tickets, matches them to draw results, +// updates match/prize fields, and writes a summary log entry. func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, error) { stats := models.MatchRunStats{} @@ -31,11 +30,9 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er defer rows.Close() var pending []models.Ticket - for rows.Next() { var t models.Ticket var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64 - if err := rows.Scan( &t.Id, &t.GameType, &t.DrawDate, &b1, &b2, &b3, &b4, &b5, &b6, @@ -43,7 +40,6 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er ); err != nil { continue } - t.Ball1 = int(b1.Int64) t.Ball2 = int(b2.Int64) t.Ball3 = int(b3.Int64) @@ -58,32 +54,32 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er for _, t := range pending { matchTicket := models.MatchTicket{ - ID: t.Id, - GameType: t.GameType, - DrawDate: t.DrawDate, Balls: helpers.BuildBallsSlice(t), BonusBalls: helpers.BuildBonusSlice(t), } - draw := services.GetDrawResultForTicket(db, t.GameType, t.DrawDate) - result := lotteryTicketHandlers.MatchTicketToDraw(matchTicket, draw, thunderballrules.ThunderballPrizeRules) - - if result.MatchedDrawID == 0 { + draw := drawsSvc.GetDrawResultForTicket(db, t.GameType, t.DrawDate) + if draw.DrawID == 0 { + // No draw yet β†’ skip continue } - _, err := db.Exec(` + mainMatches := helpers.CountMatches(matchTicket.Balls, draw.Balls) + bonusMatches := helpers.CountMatches(matchTicket.BonusBalls, draw.BonusBalls) + prizeTier := GetPrizeTier(matchTicket.GameType, mainMatches, bonusMatches, thunderballrules.ThunderballPrizeRules) + isWinner := prizeTier != "" + + if _, err := db.Exec(` UPDATE my_tickets SET matched_main = ?, matched_bonus = ?, prize_tier = ?, is_winner = ? WHERE id = ? - `, result.MatchedMain, result.MatchedBonus, result.PrizeTier, result.IsWinner, t.Id) - if err != nil { + `, mainMatches, bonusMatches, prizeTier, isWinner, t.Id); err != nil { log.Println("⚠️ Failed to update ticket match:", err) continue } stats.TicketsMatched++ - if result.IsWinner { + if isWinner { stats.WinnersFound++ } } @@ -96,6 +92,7 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er return stats, nil } +// UpdateMissingPrizes fills in prize labels/amounts for already-matched winners that lack labels. func UpdateMissingPrizes(db *sql.DB) error { type TicketInfo struct { ID int @@ -140,8 +137,7 @@ func UpdateMissingPrizes(db *sql.DB) error { query := fmt.Sprintf(`SELECT prize%d_per_winner FROM prizes_thunderball WHERE draw_date = ?`, idx) var amount int - err := db.QueryRow(query, t.DrawDate).Scan(&amount) - if err != nil { + if err := db.QueryRow(query, t.DrawDate).Scan(&amount); err != nil { log.Printf("❌ Prize lookup failed for ticket %d: %v", t.ID, err) continue } @@ -151,11 +147,9 @@ func UpdateMissingPrizes(db *sql.DB) error { label = fmt.Sprintf("Β£%.2f", float64(amount)) } - _, err = db.Exec(` + if _, err := db.Exec(` UPDATE my_tickets SET prize_amount = ?, prize_label = ? WHERE id = ? - `, float64(amount), label, t.ID) - - if err != nil { + `, float64(amount), label, t.ID); err != nil { log.Printf("❌ Failed to update ticket %d: %v", t.ID, err) } else { log.Printf("βœ… Updated ticket %d β†’ %s", t.ID, label) @@ -165,6 +159,7 @@ func UpdateMissingPrizes(db *sql.DB) error { return nil } +// RefreshTicketPrizes recomputes and writes prize info for all tickets. func RefreshTicketPrizes(db *sql.DB) error { type TicketRow struct { ID int @@ -200,13 +195,11 @@ func RefreshTicketPrizes(db *sql.DB) error { for _, row := range tickets { matchTicket := models.MatchTicket{ - GameType: row.GameType, - DrawDate: row.DrawDate, Balls: helpers.BuildBallsFromNulls(row.B1, row.B2, row.B3, row.B4, row.B5, row.B6), BonusBalls: helpers.BuildBonusFromNulls(row.Bonus1, row.Bonus2), } - draw := services.GetDrawResultForTicket(db, row.GameType, row.DrawDate) + draw := drawsSvc.GetDrawResultForTicket(db, row.GameType, row.DrawDate) if draw.DrawID == 0 { log.Printf("❌ No draw result for %s (%s)", row.DrawDate, row.GameType) continue @@ -214,19 +207,16 @@ func RefreshTicketPrizes(db *sql.DB) error { mainMatches := helpers.CountMatches(matchTicket.Balls, draw.Balls) bonusMatches := helpers.CountMatches(matchTicket.BonusBalls, draw.BonusBalls) - // ToDo: this isn't a lottery ticket service really its a draw one - prizeTier := lotteryTicketService.GetPrizeTier(row.GameType, mainMatches, bonusMatches, thunderballrules.ThunderballPrizeRules) + prizeTier := GetPrizeTier(row.GameType, mainMatches, bonusMatches, thunderballrules.ThunderballPrizeRules) isWinner := prizeTier != "" var label string var amount float64 if row.GameType == "Thunderball" { - idx, ok := thunderballrules.GetThunderballPrizeIndex(mainMatches, bonusMatches) - if ok { + if idx, ok := thunderballrules.GetThunderballPrizeIndex(mainMatches, bonusMatches); ok { query := fmt.Sprintf(`SELECT prize%d_per_winner FROM prizes_thunderball WHERE draw_date = ?`, idx) var val int - err := db.QueryRow(query, row.DrawDate).Scan(&val) - if err == nil { + if err := db.QueryRow(query, row.DrawDate).Scan(&val); err == nil { amount = float64(val) if val > 0 { label = fmt.Sprintf("Β£%.2f", amount) @@ -245,15 +235,15 @@ func RefreshTicketPrizes(db *sql.DB) error { SET matched_main = ?, matched_bonus = ?, prize_tier = ?, is_winner = ?, prize_amount = ?, prize_label = ? WHERE id = ? `, mainMatches, bonusMatches, prizeTier, isWinner, amount, label, row.ID) - if err != nil { log.Printf("❌ Failed to update ticket %d: %v", row.ID, err) continue } - rowsAffected, _ := res.RowsAffected() - log.Printf("βœ… Ticket %d updated β€” rows affected: %d | Tier: %s | Label: %s | Matches: %d+%d", - row.ID, rowsAffected, prizeTier, label, mainMatches, bonusMatches) + if rowsAffected, _ := res.RowsAffected(); rowsAffected > 0 { + log.Printf("βœ… Ticket %d updated β€” rows affected: %d | Tier: %s | Label: %s | Matches: %d+%d", + row.ID, rowsAffected, prizeTier, label, mainMatches, bonusMatches) + } } return nil diff --git a/internal/storage/auditlog/create.go b/internal/storage/auditlog/create.go index 7dd38d1..82b86c0 100644 --- a/internal/storage/auditlog/create.go +++ b/internal/storage/auditlog/create.go @@ -1,57 +1,84 @@ package storage import ( + "context" "database/sql" - "log" "net/http" "time" securityHelpers "synlotto-website/internal/helpers/security" - templateHelpers "synlotto-website/internal/helpers/template" - "synlotto-website/internal/logging" - "synlotto-website/internal/http/middleware" + "synlotto-website/internal/logging" + "synlotto-website/internal/platform/bootstrap" + + "github.com/gin-gonic/gin" ) -func AdminOnly(db *sql.DB, next http.HandlerFunc) http.HandlerFunc { - return middleware.Auth(true)(func(w http.ResponseWriter, r *http.Request) { - userID, ok := securityHelpers.GetCurrentUserID(r) - if !ok || !securityHelpers.IsAdmin(db, userID) { - log.Printf("⛔️ Unauthorized admin attempt: user_id=%v, IP=%s, Path=%s", userID, r.RemoteAddr, r.URL.Path) - templateHelpers.RenderError(w, r, http.StatusForbidden) +const insertRegistrationSQL = ` + INSERT INTO audit_registration + (user_id, username, email, ip, user_agent, timestamp) + VALUES (?, ?, ?, ?, ?, ?) + ` + +func AdminOnly() gin.HandlerFunc { + return func(c *gin.Context) { + app := c.MustGet("app").(*bootstrap.App) + sm := app.SessionManager + ctx := c.Request.Context() + + // Require logged in (assumes RequireAuth already ran; this is a safety net) + v := sm.Get(ctx, "user_id") + var uid int64 + switch t := v.(type) { + case int64: + uid = t + case int: + uid = int64(t) + default: + c.Redirect(http.StatusSeeOther, "/account/login") + c.Abort() return } - ip := r.RemoteAddr - ua := r.UserAgent() - path := r.URL.Path - - _, 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) + // Check admin + if !securityHelpers.IsAdmin(app.DB, int(uid)) { + // Optional: log access attempt here or in a helper + c.String(http.StatusForbidden, "Forbidden") + c.Abort() + return } - log.Printf("πŸ›‘οΈ Admin access: user_id=%d IP=%s Path=%s", userID, ip, path) + // Optionally record access (moved here from storage) + _, _ = app.DB.Exec(` + INSERT INTO admin_access_log (user_id, path, ip, user_agent, accessed_at) + VALUES ($1, $2, $3, $4, $5) + `, uid, c.Request.URL.Path, c.ClientIP(), c.Request.UserAgent(), time.Now().UTC()) - next(w, r) - }) + c.Next() + } } // Todo has to add in - db *sql.DB to make this work should this not be an import as all functions use it, more importantly no functions in storage just sql? -func LogLoginAttempt(db *sql.DB, r *http.Request, username string, success bool) { - ip := r.RemoteAddr - userAgent := r.UserAgent() - +// Handler Call - auditlogStorage.LogLoginAttempt(db, r.RemoteAddr, r.UserAgent(), username, ok) +func LogLoginAttempt(db *sql.DB, rIP, rUA, username string, success bool) { _, err := db.Exec( `INSERT INTO audit_login (username, success, ip, user_agent, timestamp) - VALUES (?, ?, ?, ?, ?)`, - username, success, ip, userAgent, time.Now().UTC(), + VALUES ($1, $2, $3, $4, $5)`, + username, success, rIP, rUA, time.Now().UTC(), ) if err != nil { logging.Info("❌ Failed to log login:", err) } } + +func LogSignup(db *sql.DB, userID int64, username, email, ip, userAgent string) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := db.ExecContext(ctx, insertRegistrationSQL, + userID, username, email, ip, userAgent, time.Now().UTC(), + ) + if err != nil { + logging.Info("❌ Failed to log registration: %v", err) + } +} diff --git a/internal/storage/db.go b/internal/storage/db.go deleted file mode 100644 index aec8947..0000000 --- a/internal/storage/db.go +++ /dev/null @@ -1,78 +0,0 @@ -package storage - -import ( - "database/sql" - "embed" - "fmt" - "log" - "os" - - _ "github.com/go-sql-driver/mysql" - "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database/mysql" - iofs "github.com/golang-migrate/migrate/v4/source/iofs" -) - -//go:embed migrations/*.sql -var migrationFiles embed.FS - -var DB *sql.DB - -// InitDB connects to MySQL, runs migrations, and returns the DB handle. -func InitDB() *sql.DB { - cfg := getDSNFromEnv() - - db, err := sql.Open("mysql", cfg) - if err != nil { - log.Fatalf("❌ Failed to connect to MySQL: %v", err) - } - - if err := db.Ping(); err != nil { - log.Fatalf("❌ MySQL not reachable: %v", err) - } - - if err := runMigrations(db); err != nil { - log.Fatalf("❌ Migration failed: %v", err) - } - - DB = db - return db -} - -// runMigrations applies any pending .sql files in migrations/ -func runMigrations(db *sql.DB) error { - driver, err := mysql.WithInstance(db, &mysql.Config{}) - if err != nil { - return err - } - - src, err := iofs.New(migrationFiles, "migrations") - if err != nil { - return err - } - - m, err := migrate.NewWithInstance("iofs", src, "mysql", driver) - if err != nil { - return err - } - - err = m.Up() - if err == migrate.ErrNoChange { - log.Println("βœ… Database schema up to date.") - return nil - } - return err -} - -func getDSNFromEnv() string { - user := os.Getenv("DB_USER") - pass := os.Getenv("DB_PASS") - host := os.Getenv("DB_HOST") // e.g. localhost or 127.0.0.1 - port := os.Getenv("DB_PORT") // e.g. 3306 - name := os.Getenv("DB_NAME") // e.g. synlotto - params := "parseTime=true&multiStatements=true" - - dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?%s", - user, pass, host, port, name, params) - return dsn -} diff --git a/internal/storage/migrations/0001_initial_create.up.sql b/internal/storage/migrations/0001_initial_create.up.sql index a6dbc0d..a3d7256 100644 --- a/internal/storage/migrations/0001_initial_create.up.sql +++ b/internal/storage/migrations/0001_initial_create.up.sql @@ -4,6 +4,20 @@ -- - utf8mb4 for full Unicode -- Booleans are TINYINT(1). Dates use DATE/DATETIME/TIMESTAMP as appropriate. +CREATE TABLE audit_registration ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + username VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + ip VARCHAR(45) NOT NULL, + user_agent VARCHAR(500), + timestamp DATETIME NOT NULL, + INDEX (user_id), + CONSTRAINT fk_audit_registration_users + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE +); + -- USERS CREATE TABLE IF NOT EXISTS users ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, diff --git a/internal/storage/migrations/embed.go b/internal/storage/migrations/embed.go new file mode 100644 index 0000000..1c00c25 --- /dev/null +++ b/internal/storage/migrations/embed.go @@ -0,0 +1,6 @@ +package migrations + +import _ "embed" + +//go:embed 0001_initial_create.up.sql +var InitialSchema string diff --git a/internal/storage/migrations/read.go b/internal/storage/migrations/read.go new file mode 100644 index 0000000..cda64b4 --- /dev/null +++ b/internal/storage/migrations/read.go @@ -0,0 +1,6 @@ +package migrations + +const ProbeUsersTable = ` +SELECT COUNT(*) +FROM information_schema.tables +WHERE table_schema = DATABASE() AND table_name = 'users'` diff --git a/internal/storage/schema.go b/internal/storage/schema.go deleted file mode 100644 index e80c90b..0000000 --- a/internal/storage/schema.go +++ /dev/null @@ -1,238 +0,0 @@ -package storage - -const SchemaUsers = ` -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - is_admin BOOLEAN -);` - -const SchemaThunderballResults = ` -CREATE TABLE IF NOT EXISTS results_thunderball ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - draw_date TEXT NOT NULL UNIQUE, - draw_id INTEGER NOT NULL UNIQUE, - machine TEXT, - ballset TEXT, - ball1 INTEGER, - ball2 INTEGER, - ball3 INTEGER, - ball4 INTEGER, - ball5 INTEGER, - thunderball INTEGER -);` - -const SchemaThunderballPrizes = ` -CREATE TABLE IF NOT EXISTS prizes_thunderball ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - draw_id INTEGER NOT NULL, - draw_date TEXT, - prize1 TEXT, - prize1_winners INTEGER, - prize1_per_winner INTEGER, - prize1_fund INTEGER, - prize2 TEXT, - prize2_winners INTEGER, - prize2_per_winner INTEGER, - prize2_fund INTEGER, - prize3 TEXT, - prize3_winners INTEGER, - prize3_per_winner INTEGER, - prize3_fund INTEGER, - prize4 TEXT, - prize4_winners INTEGER, - prize4_per_winner INTEGER, - prize4_fund INTEGER, - prize5 TEXT, - prize5_winners INTEGER, - prize5_per_winner INTEGER, - prize5_fund INTEGER, - prize6 TEXT, - prize6_winners INTEGER, - prize6_per_winner INTEGER, - prize6_fund INTEGER, - prize7 TEXT, - prize7_winners INTEGER, - prize7_per_winner INTEGER, - prize7_fund INTEGER, - prize8 TEXT, - prize8_winners INTEGER, - prize8_per_winner INTEGER, - prize8_fund INTEGER, - prize9 TEXT, - prize9_winners INTEGER, - prize9_per_winner INTEGER, - prize9_fund INTEGER, - total_winners INTEGER, - total_prize_fund INTEGER, - FOREIGN KEY (draw_date) REFERENCES results_thunderball(draw_date) -);` - -const SchemaLottoResults = ` -CREATE TABLE IF NOT EXISTS results_lotto ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - draw_date TEXT NOT NULL UNIQUE, - draw_id INTEGER NOT NULL UNIQUE, - machine TEXT, - ballset TEXT, - ball1 INTEGER, - ball2 INTEGER, - ball3 INTEGER, - ball4 INTEGER, - ball5 INTEGER, - ball6 INTEGER, - bonusball INTEGER -);` - -const SchemaMyTickets = ` -CREATE TABLE IF NOT EXISTS my_tickets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - userId INTEGER NOT NULL, - game_type TEXT NOT NULL, - draw_date TEXT NOT NULL, - ball1 INTEGER, - ball2 INTEGER, - ball3 INTEGER, - ball4 INTEGER, - ball5 INTEGER, - ball6 INTEGER, - bonus1 INTEGER, - bonus2 INTEGER, - duplicate BOOLEAN DEFAULT 0, - purchase_date TEXT, - purchase_method TEXT, - image_path TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - matched_main INTEGER, - matched_bonus INTEGER, - prize_tier TEXT, - is_winner BOOLEAN, - prize_amount INTEGER, - prize_label TEXT, - syndicate_id INTEGER, - FOREIGN KEY (userId) REFERENCES users(id) -);` - -const SchemaUsersMessages = ` -CREATE TABLE IF NOT EXISTS users_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - senderId INTEGER NOT NULL REFERENCES users(id), - recipientId INTEGER NOT NULL REFERENCES users(id), - subject TEXT NOT NULL, - message TEXT, - is_read BOOLEAN DEFAULT FALSE, - is_archived BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - archived_at TIMESTAMP -);` - -const SchemaUsersNotifications = ` -CREATE TABLE IF NOT EXISTS users_notification ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id), - subject TEXT, - body TEXT, - is_read BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -);` - -const SchemaAuditLog = ` -CREATE TABLE IF NOT EXISTS auditlog ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT, - success INTEGER, - timestamp TEXT -);` - -const SchemaLogTicketMatching = ` -CREATE TABLE IF NOT EXISTS log_ticket_matching ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - triggered_by TEXT, - run_at DATETIME DEFAULT CURRENT_TIMESTAMP, - tickets_matched INTEGER, - winners_found INTEGER, - notes TEXT -);` - -const SchemaAdminAccessLog = ` -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 -);` - -const SchemaNewAuditLog = ` -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 -);` - -const SchemaAuditLogin = ` - CREATE TABLE IF NOT EXISTS audit_login ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT, - success BOOLEAN, - ip TEXT, - user_agent TEXT, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP - );` - -const SchemaSyndicates = ` -CREATE TABLE IF NOT EXISTS syndicates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - description TEXT, - owner_id INTEGER NOT NULL, - join_code TEXT UNIQUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (owner_id) REFERENCES users(id) -);` - -const SchemaSyndicateMembers = ` -CREATE TABLE IF NOT EXISTS syndicate_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - syndicate_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - role TEXT DEFAULT 'member', -- owner, manager, member - status TEXT DEFAULT 'active', - joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (syndicate_id) REFERENCES syndicates(id), - FOREIGN KEY (user_id) REFERENCES users(id) -);` - -const SchemaSyndicateInvites = ` -CREATE TABLE IF NOT EXISTS syndicate_invites ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - syndicate_id INTEGER NOT NULL, - invited_user_id INTEGER NOT NULL, - sent_by_user_id INTEGER NOT NULL, - status TEXT DEFAULT 'pending', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(syndicate_id) REFERENCES syndicates(id), - FOREIGN KEY(invited_user_id) REFERENCES users(id) -);` - -const SchemaSyndicateInviteTokens = ` -CREATE TABLE IF NOT EXISTS syndicate_invite_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - syndicate_id INTEGER NOT NULL, - token TEXT NOT NULL UNIQUE, - invited_by_user_id INTEGER NOT NULL, - accepted_by_user_id INTEGER, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - accepted_at TIMESTAMP, - expires_at TIMESTAMP, - FOREIGN KEY (syndicate_id) REFERENCES syndicates(id), - FOREIGN KEY (invited_by_user_id) REFERENCES users(id), - FOREIGN KEY (accepted_by_user_id) REFERENCES users(id) -);` diff --git a/internal/storage/syndicate/read.go b/internal/storage/syndicate/read.go index c9dc81c..7403fa3 100644 --- a/internal/storage/syndicate/read.go +++ b/internal/storage/syndicate/read.go @@ -130,6 +130,35 @@ func GetSyndicateMembers(db *sql.DB, syndicateID int) []models.SyndicateMember { return members } +func GetSyndicateTickets(db *sql.DB, syndicateID int) []models.Ticket { + rows, err := db.Query(` + SELECT id, userId, syndicateId, game_type, draw_date, ball1, ball2, ball3, ball4, ball5, ball6, + bonus1, bonus2, matched_main, matched_bonus, prize_tier, prize_amount, prize_label, is_winner + FROM my_tickets + WHERE syndicateId = ? + ORDER BY draw_date DESC + `, syndicateID) + if err != nil { + return nil + } + defer rows.Close() + + var tickets []models.Ticket + for rows.Next() { + var t models.Ticket + err := rows.Scan( + &t.Id, &t.UserId, &t.SyndicateId, &t.GameType, &t.DrawDate, + &t.Ball1, &t.Ball2, &t.Ball3, &t.Ball4, &t.Ball5, &t.Ball6, + &t.Bonus1, &t.Bonus2, &t.MatchedMain, &t.MatchedBonus, + &t.PrizeTier, &t.PrizeAmount, &t.PrizeLabel, &t.IsWinner, + ) + if err == nil { + tickets = append(tickets, t) + } + } + return tickets +} + func IsSyndicateManager(db *sql.DB, syndicateID, userID int) bool { var count int err := db.QueryRow(` diff --git a/internal/storage/syndicate/syndicate.go b/internal/storage/syndicate/syndicate.go deleted file mode 100644 index 3e5b160..0000000 --- a/internal/storage/syndicate/syndicate.go +++ /dev/null @@ -1,125 +0,0 @@ -package storage - -import ( - "database/sql" - "fmt" - "synlotto-website/internal/models" - "time" -) - -// todo should be a ticket function? -func GetSyndicateTickets(db *sql.DB, syndicateID int) []models.Ticket { - rows, err := db.Query(` - SELECT id, userId, syndicateId, game_type, draw_date, ball1, ball2, ball3, ball4, ball5, ball6, - bonus1, bonus2, matched_main, matched_bonus, prize_tier, prize_amount, prize_label, is_winner - FROM my_tickets - WHERE syndicateId = ? - ORDER BY draw_date DESC - `, syndicateID) - if err != nil { - return nil - } - defer rows.Close() - - var tickets []models.Ticket - for rows.Next() { - var t models.Ticket - err := rows.Scan( - &t.Id, &t.UserId, &t.SyndicateId, &t.GameType, &t.DrawDate, - &t.Ball1, &t.Ball2, &t.Ball3, &t.Ball4, &t.Ball5, &t.Ball6, - &t.Bonus1, &t.Bonus2, &t.MatchedMain, &t.MatchedBonus, - &t.PrizeTier, &t.PrizeAmount, &t.PrizeLabel, &t.IsWinner, - ) - if err == nil { - tickets = append(tickets, t) - } - } - return tickets -} - -// both a read and inset break up -func AcceptInvite(db *sql.DB, inviteID, userID int) error { - var syndicateID int - err := db.QueryRow(` - SELECT syndicate_id FROM syndicate_invites - WHERE id = ? AND invited_user_id = ? AND status = 'pending' - `, inviteID, userID).Scan(&syndicateID) - if err != nil { - return err - } - - if err := UpdateInviteStatus(db, inviteID, "accepted"); err != nil { - return err - } - - _, err = db.Exec(` - INSERT INTO syndicate_members (syndicate_id, user_id, joined_at) - VALUES (?, ?, CURRENT_TIMESTAMP) - `, syndicateID, userID) - return err -} - -func CreateSyndicate(db *sql.DB, ownerID int, name, description string) (int64, error) { - tx, err := db.Begin() - if err != nil { - return 0, err - } - defer tx.Rollback() - - result, err := tx.Exec(` - INSERT INTO syndicates (name, description, owner_id, created_at) - VALUES (?, ?, ?, ?) - `, name, description, ownerID, time.Now()) - if err != nil { - return 0, fmt.Errorf("failed to create syndicate: %w", err) - } - - syndicateID, err := result.LastInsertId() - if err != nil { - return 0, fmt.Errorf("failed to get syndicate ID: %w", err) - } - - _, err = tx.Exec(` - INSERT INTO syndicate_members (syndicate_id, user_id, role, joined_at) - VALUES (?, ?, 'manager', CURRENT_TIMESTAMP) - `, syndicateID, ownerID) - if err != nil { - return 0, fmt.Errorf("failed to add owner as member: %w", err) - } - - if err := tx.Commit(); err != nil { - return 0, fmt.Errorf("commit failed: %w", err) - } - - return syndicateID, nil -} - -func InviteToSyndicate(db *sql.DB, inviterID, syndicateID int, username string) error { - var inviteeID int - err := db.QueryRow(` - SELECT id FROM users WHERE username = ? - `, username).Scan(&inviteeID) - if err == sql.ErrNoRows { - return fmt.Errorf("user not found") - } else if err != nil { - return err - } - - var count int - err = db.QueryRow(` - SELECT COUNT(*) FROM syndicate_members - WHERE syndicate_id = ? AND user_id = ? - `, syndicateID, inviteeID).Scan(&count) - if err != nil { - return err - } - if count > 0 { - return fmt.Errorf("user already a member or invited") - } - - _, err = db.Exec(` - INSERT INTO syndicate_members (syndicate_id, user_id, is_manager, status) - VALUES (?, ?, 0, 'invited') - `, syndicateID, inviteeID) - return err -} diff --git a/internal/storage/syndicate/update.go b/internal/storage/syndicate/update.go index d0160c0..ab69089 100644 --- a/internal/storage/syndicate/update.go +++ b/internal/storage/syndicate/update.go @@ -2,6 +2,8 @@ package storage import ( "database/sql" + "fmt" + "time" ) func UpdateInviteStatus(db *sql.DB, inviteID int, status string) error { @@ -12,3 +14,90 @@ func UpdateInviteStatus(db *sql.DB, inviteID int, status string) error { `, status, inviteID) return err } + +// ToDo: both a read and inset break up +func AcceptInvite(db *sql.DB, inviteID, userID int) error { + var syndicateID int + err := db.QueryRow(` + SELECT syndicate_id FROM syndicate_invites + WHERE id = ? AND invited_user_id = ? AND status = 'pending' + `, inviteID, userID).Scan(&syndicateID) + if err != nil { + return err + } + + if err := UpdateInviteStatus(db, inviteID, "accepted"); err != nil { + return err + } + + _, err = db.Exec(` + INSERT INTO syndicate_members (syndicate_id, user_id, joined_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + `, syndicateID, userID) + return err +} + +func CreateSyndicate(db *sql.DB, ownerID int, name, description string) (int64, error) { + tx, err := db.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + + result, err := tx.Exec(` + INSERT INTO syndicates (name, description, owner_id, created_at) + VALUES (?, ?, ?, ?) + `, name, description, ownerID, time.Now()) + if err != nil { + return 0, fmt.Errorf("failed to create syndicate: %w", err) + } + + syndicateID, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get syndicate ID: %w", err) + } + + _, err = tx.Exec(` + INSERT INTO syndicate_members (syndicate_id, user_id, role, joined_at) + VALUES (?, ?, 'manager', CURRENT_TIMESTAMP) + `, syndicateID, ownerID) + if err != nil { + return 0, fmt.Errorf("failed to add owner as member: %w", err) + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("commit failed: %w", err) + } + + return syndicateID, nil +} + +func InviteToSyndicate(db *sql.DB, inviterID, syndicateID int, username string) error { + var inviteeID int + err := db.QueryRow(` + SELECT id FROM users WHERE username = ? + `, username).Scan(&inviteeID) + if err == sql.ErrNoRows { + return fmt.Errorf("user not found") + } else if err != nil { + return err + } + + var count int + err = db.QueryRow(` + SELECT COUNT(*) FROM syndicate_members + WHERE syndicate_id = ? AND user_id = ? + `, syndicateID, inviteeID).Scan(&count) + if err != nil { + return err + } + if count > 0 { + return fmt.Errorf("user already a member or invited") + } + + _, err = db.Exec(` + INSERT INTO syndicate_members (syndicate_id, user_id, is_manager, status) + VALUES (?, ?, 0, 'invited') + `, syndicateID, inviteeID) + return err +} diff --git a/internal/storage/users/create.go b/internal/storage/users/create.go index c83eed4..34e9bf9 100644 --- a/internal/storage/users/create.go +++ b/internal/storage/users/create.go @@ -1,22 +1,27 @@ -package storage +package usersStorage -// ToDo.. "errors" should this not be using my custom log wrapper import ( "context" "database/sql" + "errors" + "time" ) -type UsersRepo struct{ db *sql.DB } +const CreateUserSQL = ` + INSERT INTO users (username, email, password_hash, created_at, updated_at) + VALUES (?, ?, ?, UTC_TIMESTAMP(), UTC_TIMESTAMP())` -func NewUsersRepo(db *sql.DB) *UsersRepo { - return &UsersRepo{db: db} -} +func CreateUser(db *sql.DB, username, email, passwordHash string) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() -// ToDo: should the function be in sql? -func (r *UsersRepo) Create(ctx context.Context, username, passwordHash string, isAdmin bool) error { - _, err := r.db.ExecContext(ctx, - `INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)`, - username, passwordHash, isAdmin, - ) - return err + res, err := db.ExecContext(ctx, CreateUserSQL, username, email, passwordHash) + if err != nil { + return 0, err + } + id, err := res.LastInsertId() + if err != nil || id == 0 { + return 0, errors.New("could not get insert id") + } + return id, nil } diff --git a/internal/storage/users/read.go b/internal/storage/users/read.go index b32ca82..0f1452c 100644 --- a/internal/storage/users/read.go +++ b/internal/storage/users/read.go @@ -1,34 +1,72 @@ -package storage +package usersStorage import ( + "context" "database/sql" - "synlotto-website/internal/logging" - "synlotto-website/internal/models" + "time" ) -func GetUserByID(db *sql.DB, id int) *models.User { - row := db.QueryRow("SELECT id, username, password_hash, is_admin FROM users WHERE id = ?", id) +const ( + UsernameExistsSQL = ` + SELECT EXISTS(SELECT 1 FROM users WHERE username = ? LIMIT 1)` - var user models.User - err := row.Scan(&user.Id, &user.Username, &user.PasswordHash, &user.IsAdmin) - if err != nil { - if err != sql.ErrNoRows { - logging.Error("DB error:", err) - } - return nil - } + EmailExistsSQL = ` + SELECT EXISTS(SELECT 1 FROM users WHERE email = ? LIMIT 1)` - return &user + GetByUsernameSQL = ` + SELECT id, username, email, password_hash, created_at, updated_at + FROM users + WHERE username = ? + LIMIT 1` + + GetByIDSQL = ` + SELECT id, username, email, password_hash, is_admin, created_at, updated_at + FROM users + WHERE id = ? + LIMIT 1` +) + +func UsernameExists(db *sql.DB, username string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var exists bool + _ = db.QueryRowContext(ctx, UsernameExistsSQL, username).Scan(&exists) + return exists } -func GetUserByUsername(db *sql.DB, username string) *models.User { - row := db.QueryRow(`SELECT id, username, password_hash, is_admin FROM users WHERE username = ?`, username) +func EmailExists(db *sql.DB, email string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var exists bool + _ = db.QueryRowContext(ctx, EmailExistsSQL, email).Scan(&exists) + return exists +} - var u models.User - err := row.Scan(&u.Id, &u.Username, &u.PasswordHash, &u.IsAdmin) +func GetUserByUsername(db *sql.DB, username string) *User { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var u User + err := db.QueryRowContext(ctx, GetByUsernameSQL, username). + Scan(&u.Id, &u.Username, &u.Email, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt) if err != nil { return nil } return &u } + +func GetUserByID(db *sql.DB, id int) *User { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + var u User + err := db.QueryRowContext(ctx, GetByIDSQL, id). + Scan(&u.Id, &u.Username, &u.Email, &u.PasswordHash, &u.IsAdmin, &u.CreatedAt, &u.UpdatedAt) + if err != nil { + if err != sql.ErrNoRows { + logging.Error("GetUserByID: %v", err) + } + return nil + } + return &u +} diff --git a/internal/storage/users/types.go b/internal/storage/users/types.go new file mode 100644 index 0000000..ee6435c --- /dev/null +++ b/internal/storage/users/types.go @@ -0,0 +1,5 @@ +package usersStorage + +import "synlotto-website/internal/models" + +type User = models.User diff --git a/web/templates/account/login.html b/web/templates/account/login.html index 3b4c83e..46f7496 100644 --- a/web/templates/account/login.html +++ b/web/templates/account/login.html @@ -1,7 +1,7 @@ {{ define "content" }}

Login

- {{ .CSRFField }} +
@@ -20,4 +20,4 @@ -{{ end }} \ No newline at end of file +{{ end }} diff --git a/web/templates/account/signup.html b/web/templates/account/signup.html index 53b779b..77c9162 100644 --- a/web/templates/account/signup.html +++ b/web/templates/account/signup.html @@ -1,9 +1,43 @@ {{ define "content" }} -

Sign Up

-
- {{ .csrfField }} -
-
- +

Create your account

+{{ if .Flash }}
{{ .Flash }}
{{ end }} + + + + +
+ + + {{ with .Errors }}{{ with index . "username" }}
{{ . }}
{{ end }}{{ end }} +
+ +
+ + + {{ with .Errors }}{{ with index . "email" }}
{{ . }}
{{ end }}{{ end }} +
+ +
+ + + {{ with .Errors }}{{ with index . "password" }}
{{ . }}
{{ end }}{{ end }} +
+ +
+ + + {{ with .Errors }}{{ with index . "password_confirm" }}
{{ . }}
{{ end }}{{ end }} +
+ +
+ + + {{ with .Errors }}{{ with index . "accept_terms" }}
{{ . }}
{{ end }}{{ end }} +
+ +
-{{ end }} +{{ end }} \ No newline at end of file diff --git a/web/templates/main/topbar.html b/web/templates/main/topbar.html index 416f441..ffe515d 100644 --- a/web/templates/main/topbar.html +++ b/web/templates/main/topbar.html @@ -1,7 +1,7 @@ {{ define "topbar" }}