Compare commits

...

8 Commits

Author SHA1 Message Date
4a6bfad880 readme file. 2025-10-28 12:01:52 +00:00
04c3cb3851 Current config structure 2025-10-28 11:59:04 +00:00
c911bf9151 Ignore main.exe 2025-10-28 11:57:27 +00:00
86be6479f1 Stack of changes to get gin, scs, nosurf running. 2025-10-28 11:56:42 +00:00
07117ba35e No longer required. 2025-10-24 13:19:55 +01:00
ac1f6e9399 refactor(template): delegate handler-level RenderError to helpers package
- Moved core RenderError logic to internal/helpers/template/error.go
- Added thin wrapper method in internal/handlers/template/error.go
- Simplified function signature (no config args, uses InitSiteMeta)
- Preserved architecture: handlers own HTTP layer, helpers supply logic
2025-10-24 13:15:12 +01:00
fb07c4a5eb Refactoring for Gin, NoSurf and SCS continues. 2025-10-24 13:08:53 +01:00
7276903733 refactor(config): move Config struct from business layer to platform/config
Moved the Config struct (previously in internal/models/config.go) into internal/platform/config/types.go to align with clean architecture principles.

This change decouples runtime/infrastructure configuration from domain models:
- Configuration is an application/platform concern, not part of the business domain.
- Prevents potential circular imports between models and platform packages.
- Simplifies future integration with platform components (SCS sessions, CSRF, DB).

No functional changes to configuration loading structure and JSON schema remain the same; only the package location and imports were updated.
2025-10-24 08:35:39 +01:00
101 changed files with 2214 additions and 1825 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
main.exe
synlotto-website.exe
synlotto.db

0
README.md Normal file
View File

59
cmd/api/main.go Normal file
View File

@@ -0,0 +1,59 @@
// Path /cmd/api
// File: main.go
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/http/middleware"
"synlotto-website/internal/http/routes"
"synlotto-website/internal/platform/bootstrap"
)
func main() {
app, err := bootstrap.Load("internal\\platform\\config\\config.json")
if err != nil {
panic(fmt.Errorf("bootstrap: %w", err))
}
templateHelpers.InitSessionManager(app.SessionManager)
templateHelpers.InitSiteMeta(app.Config.Site.SiteName, app.Config.Site.CopyrightYearStart, 0)
// Global middleware that depends on *App
app.Router.Use(middleware.AuthMiddleware())
app.Router.Use(middleware.RememberMiddleware(app))
// 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", srv.Addr)
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
fmt.Println("Shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
}

52
go.mod
View File

@@ -3,28 +3,44 @@ module synlotto-website
go 1.24.1
require (
github.com/gorilla/csrf v1.7.2
github.com/gorilla/sessions v1.4.0
golang.org/x/crypto v0.36.0
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/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 (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/golang-migrate/migrate/v4 v4.19.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
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/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
github.com/go-playground/universal-translator v0.18.1 // indirect
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/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/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
golang.org/x/sys v0.31.0 // indirect
modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // 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/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/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)

152
go.sum
View File

@@ -1,72 +1,98 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
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/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=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
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/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/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-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=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
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/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/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
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/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/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/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=
github.com/justinas/nosurf v1.2.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
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/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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
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.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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/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/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/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=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
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.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/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=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
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=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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=

View File

@@ -1,141 +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"
"synlotto-website/internal/storage"
"github.com/gorilla/csrf"
)
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["csrfField"] = csrf.TemplateField(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 := storage.GetUserByUsername(db, username)
if user == nil {
logging.Info("❌ User not found: %s", username)
storage.LogLoginAttempt(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)
storage.LogLoginAttempt(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)
storage.LogLoginAttempt(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)
}
func Signup(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
tmpl := templateHelpers.LoadTemplateFiles("signup.html", "templates/account/signup.html")
tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{
"csrfField": csrf.TemplateField(r),
})
return
}
username := r.FormValue("username")
password := r.FormValue("password")
hashed, err := securityHelpers.HashPassword(password)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
err = models.CreateUser(username, hashed)
if err != nil {
http.Error(w, "Could not create user", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
}

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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, ".")
}

View File

@@ -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)
}
})
}
}

View File

@@ -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/storage"
"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 := storage.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
// 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(`
// 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
ORDER BY run_at DESC
LIMIT 10
`)
if err != nil {
log.Println("⚠️ Failed to load logs:", err)
}
} else {
defer rows.Close()
var logs []models.MatchLog
var logs []struct {
RunAt any
TriggeredBy string
TicketsMatched int
WinnersFound int
Notes string
}
for rows.Next() {
var logEntry models.MatchLog
err := rows.Scan(&logEntry.RunAt, &logEntry.TriggeredBy, &logEntry.TicketsMatched, &logEntry.WinnersFound, &logEntry.Notes)
if err != nil {
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, logEntry)
logs = append(logs, e)
}
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
}
}
})
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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)
})
}
}

View File

@@ -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
}
}
}

View File

@@ -5,11 +5,10 @@ import (
"log"
"net/http"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/helpers"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/models"
"synlotto-website/internal/storage"
resultsThunderballStorage "synlotto-website/internal/storage/results/thunderball"
)
func NewDraw(db *sql.DB) http.HandlerFunc {
@@ -19,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 {
@@ -45,7 +44,7 @@ func Submit(db *sql.DB, w http.ResponseWriter, r *http.Request) {
Thunderball: helpers.Atoi(r.FormValue("thunderball")),
}
err := storage.InsertThunderballResult(db, draw)
err := resultsThunderballStorage.InsertThunderballResult(db, draw)
if err != nil {
log.Println("❌ Failed to insert draw:", err)
http.Error(w, "Failed to save draw", http.StatusInternalServerError)

View File

@@ -1,7 +1,7 @@
// internal/handlers/lottery/syndicate/syndicate.go
package handlers
import (
"database/sql"
"fmt"
"log"
"net/http"
@@ -9,38 +9,39 @@ import (
templateHandlers "synlotto-website/internal/handlers/template"
securityHelpers "synlotto-website/internal/helpers/security"
templateHelpers "synlotto-website/internal/helpers/template"
syndicateStorage "synlotto-website/internal/storage/syndicate"
ticketStorage "synlotto-website/internal/storage/tickets"
"synlotto-website/internal/helpers"
"synlotto-website/internal/models"
"synlotto-website/internal/storage"
"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 := storage.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)
@@ -50,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 := storage.GetSyndicatesByOwner(db, userID)
member := storage.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
}
@@ -73,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 := storage.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 := storage.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 := storage.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 := storage.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 := storage.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 !storage.IsSyndicateMember(db, syndicateID, userID) {
templateHelpers.RenderError(w, r, 403)
if !syndicateStorage.IsSyndicateMember(app.DB, syndicateID, userID) {
templateHelpers.RenderError(w, r, http.StatusForbidden)
return
}
tickets := storage.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)
}
}

View File

@@ -1,23 +1,25 @@
// 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"
"synlotto-website/internal/helpers"
"synlotto-website/internal/storage"
"synlotto-website/internal/platform/bootstrap"
syndicateStorage "synlotto-website/internal/storage/syndicate"
)
func SyndicateInviteHandler(db *sql.DB) http.HandlerFunc {
// GET /syndicate/invite?id=<syndicate_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 {
templateHelpers.RenderError(w, r, http.StatusForbidden)
return
@@ -26,24 +28,23 @@ func SyndicateInviteHandler(db *sql.DB) http.HandlerFunc {
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 {
templateHelpers.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 := storage.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)
@@ -53,90 +54,104 @@ func SyndicateInviteHandler(db *sql.DB) http.HandlerFunc {
}
}
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 {
templateHelpers.RenderError(w, r, 403)
templateHelpers.RenderError(w, r, http.StatusForbidden)
return
}
invites := storage.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=<invite_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 {
templateHelpers.RenderError(w, r, 403)
templateHelpers.RenderError(w, r, http.StatusForbidden)
return
}
err := storage.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=<invite_id>
func DeclineInviteHandler(app *bootstrap.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
inviteID := helpers.Atoi(r.URL.Query().Get("id"))
_ = storage.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
}
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(`
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).Scan(&syndicateID, &expiresAt, &acceptedAt)
if err != nil {
`, 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(`
if _, err := app.DB.Exec(`
INSERT INTO syndicate_members (syndicate_id, user_id, role, status, joined_at)
VALUES (?, ?, 'member', 'active', CURRENT_TIMESTAMP)
`, syndicateID, userID)
if err != nil {
`, syndicateID, userID); err != nil {
return err
}
_, err = db.Exec(`
_, err := app.DB.Exec(`
UPDATE syndicate_invite_tokens
SET accepted_by_user_id = ?, accepted_at = CURRENT_TIMESTAMP
WHERE token = ?
@@ -144,38 +159,38 @@ func AcceptInviteToken(db *sql.DB, token string, userID int) error {
return err
}
func GenerateInviteLinkHandler(db *sql.DB) http.HandlerFunc {
// GET /syndicate/invite/token?id=<syndicate_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=<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
@@ -183,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=<syndicate_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)
}
}

View File

@@ -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/gorilla/csrf"
"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["csrfField"] = csrf.TemplateField(r)
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 Gins 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).
//Dont 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 (its correct):
//Gin → SCS.LoadAndSave → NoSurf → http.Server
//If you still get the warning after switching to c.Redirect + c.Abort(), tell me which handler its coming from and Ill point to the exact double-write.

View File

@@ -1,24 +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"
templateHelpers "synlotto-website/internal/helpers/template"
messagesStorage "synlotto-website/internal/storage/messages"
"synlotto-website/internal/helpers"
storage "synlotto-website/internal/storage/mysql"
"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
}
@@ -28,86 +28,82 @@ func MessagesInboxHandler(db *sql.DB) http.HandlerFunc {
}
perPage := 10
totalCount := storage.GetInboxMessageCount(db, userID)
totalCount := messagesStorage.GetInboxMessageCount(app.DB, userID)
totalPages := (totalCount + perPage - 1) / perPage
if totalPages == 0 {
totalPages = 1
}
messages := storage.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 := storage.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
}
@@ -117,35 +113,35 @@ func ArchivedMessagesHandler(db *sql.DB) http.HandlerFunc {
}
perPage := 10
messages := storage.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
}
@@ -153,32 +149,32 @@ func SendMessageHandler(db *sql.DB) http.HandlerFunc {
subject := r.FormValue("subject")
body := r.FormValue("message")
if err := storage.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)

View File

@@ -1,70 +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/storage"
"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 := storage.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 = storage.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
}
}
}

View File

@@ -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,

View File

@@ -1,23 +0,0 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gorilla/sessions"
)
var (
SessionStore *sessions.CookieStore
Name string
)
func GetSession(w http.ResponseWriter, r *http.Request) (*sessions.Session, error) {
if SessionStore == nil {
return nil, fmt.Errorf("session store not initialized")
}
if Name == "" {
return nil, fmt.Errorf("session name not configured")
}
return SessionStore.Get(r, Name)
}

View File

@@ -1,32 +0,0 @@
package handlers
import (
"net/http"
"github.com/gorilla/securecookie"
)
var (
authKey []byte
encryptKey []byte
)
func SecureCookie(w http.ResponseWriter, name, value string, isProduction bool) error {
s := securecookie.New(authKey, encryptKey)
encoded, err := s.Encode(name, value)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: name,
Value: encoded,
Path: "/",
HttpOnly: true,
Secure: isProduction,
SameSite: http.SameSiteStrictMode,
})
return nil
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,56 @@
// 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)
//✅ internal/helpers/template/
//→ For anything that helps render (TemplateContext, pagination, funcs)
// there for bear usages between helpers and handlers
//In clean Go architecture (especially following “Package by responsibility”):
//Type Responsibility Should access
//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"
)
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
}
}

View File

@@ -0,0 +1,19 @@
package templateHandler
import (
"synlotto-website/internal/platform/config"
"github.com/alexedwards/scs/v2"
)
type Handler struct {
cfg config.Config
Sessions *scs.SessionManager
}
func New(cfg config.Config, sessions *scs.SessionManager) *Handler {
return &Handler{
cfg: cfg,
Sessions: sessions,
}
}

View File

@@ -1,37 +1,52 @@
package handlers
// internal/handlers/template/templatedata.go
package templateHandler
import (
"database/sql"
"log"
"net/http"
httpHelper "synlotto-website/internal/helpers/http"
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/storage"
"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
isAdmin bool
notificationCount int
notifications []models.Notification
messageCount int
messages []models.Message
)
// 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)
}
var user *models.User
var isAdmin bool
var notificationCount int
var notifications []models.Notification
var messageCount int
var messages []models.Message
if userId, ok := session.Values["user_id"].(int); ok {
user = storage.GetUserByID(db, userId)
if user != nil {
isAdmin = user.IsAdmin
notificationCount = storage.GetNotificationCount(db, user.Id)
notifications = storage.GetRecentNotifications(db, user.Id, 15)
messageCount, _ = storage.GetMessageCount(db, user.Id)
messages = storage.GetRecentMessages(db, user.Id, 15)
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)
}
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -1,51 +0,0 @@
package helpers
import (
"net/http"
"time"
session "synlotto-website/internal/handlers/session"
"synlotto-website/internal/platform/constants"
"github.com/gorilla/sessions"
)
func GetSession(w http.ResponseWriter, r *http.Request) (*sessions.Session, error) {
return session.GetSession(w, r)
}
func IsSessionExpired(session *sessions.Session) bool {
last, ok := session.Values["last_activity"].(time.Time)
if !ok {
return false
}
return time.Since(last) > constants.SessionDuration
}
func UpdateSessionActivity(session *sessions.Session, r *http.Request, w http.ResponseWriter) {
session.Values["last_activity"] = time.Now().UTC()
session.Save(r, w)
}
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, _ := GetSession(w, r)
if IsSessionExpired(session) {
session.Options.MaxAge = -1
session.Save(r, w)
newSession, _ := GetSession(w, r)
newSession.Values["flash"] = "Your session has timed out."
newSession.Save(r, w)
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
UpdateSessionActivity(session, r, w)
next(w, r)
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -1,44 +1,49 @@
package helpers
package templateHelper
import (
"html/template"
"log"
"net/http"
"strings"
"time"
helpers "synlotto-website/internal/helpers/http"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/config"
"github.com/gorilla/csrf"
"github.com/alexedwards/scs/v2"
"github.com/justinas/nosurf"
)
// ToDo should these structs be here?
type siteMeta struct {
Name string
CopyrightYearStart int
}
var meta siteMeta
func InitSiteMeta(name string, yearStart, yearEnd int) {
meta = siteMeta{
Name: name,
CopyrightYearStart: yearStart,
}
}
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{} {
cfg := config.Get()
if cfg == nil {
log.Println("⚠️ Config not initialized!")
}
session, _ := helpers.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{}{
"CSRFField": csrf.TemplateField(r),
"Flash": flash,
"CSRFToken": nosurf.Token(r),
"User": data.User,
"IsAdmin": data.IsAdmin,
"NotificationCount": data.NotificationCount,
"Notifications": data.Notifications,
"MessageCount": data.MessageCount,
"Messages": data.Messages,
"SiteName": cfg.Site.SiteName,
"CopyrightYearStart": cfg.Site.CopyrightYearStart,
"SiteName": meta.Name,
"CopyrightYearStart": meta.CopyrightYearStart,
}
}
@@ -57,9 +62,8 @@ func TemplateFuncs() template.FuncMap {
"min": func(a, b int) int {
if a < b {
return a
} else {
return b
}
return b
},
"intVal": func(p *int) int {
if p == nil {
@@ -97,19 +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, _ := helpers.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 {

View File

@@ -1,4 +1,4 @@
package helpers
package templateHelper
import (
"fmt"
@@ -9,10 +9,12 @@ import (
"synlotto-website/internal/models"
)
// RenderError renders an HTML error page (e.g., 404.html, 500.html).
// It uses TemplateContext which reads site meta from InitSiteMeta().
func RenderError(w http.ResponseWriter, r *http.Request, statusCode int) {
log.Printf("⚙️ RenderError called with status: %d", statusCode)
context := TemplateContext(w, r, models.TemplateData{})
ctx := TemplateContext(w, r, models.TemplateData{})
pagePath := fmt.Sprintf("templates/error/%d.html", statusCode)
log.Printf("📄 Checking for template file: %s", pagePath)
@@ -23,17 +25,14 @@ func RenderError(w http.ResponseWriter, r *http.Request, statusCode int) {
return
}
log.Println("✅ Template file found, loading...")
tmpl := LoadTemplateFiles(fmt.Sprintf("%d.html", statusCode), pagePath)
w.WriteHeader(statusCode)
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
if err := tmpl.ExecuteTemplate(w, "layout", ctx); err != nil {
log.Printf("❌ Failed to render error page layout: %v", err)
http.Error(w, http.StatusText(statusCode), statusCode)
return
}
log.Println("✅ Successfully rendered error page") // ToDo: log these to database
log.Println("✅ Successfully rendered error page")
}

View File

@@ -1,9 +1,10 @@
package helpers
package templateHelper
import (
"database/sql"
)
// ToDo: Sql shouldnt be here.
func GetTotalPages(db *sql.DB, tableName, whereClause string, args []interface{}, pageSize int) (totalPages, totalCount int) {
query := "SELECT COUNT(*) FROM " + tableName + " " + whereClause
row := db.QueryRow(query, args...)

View File

@@ -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/<status>.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)
}
}

View File

@@ -2,48 +2,96 @@ package middleware
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 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 required && !ok {
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
sm.Put(ctx, sessionkeys.LastActivity, time.Now().UTC())
c.Next()
}
}
// 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
}
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)
cookie, err := c.Request.Cookie(app.Config.Session.RememberCookieName)
if err != nil {
c.Next()
return
}
session.Values["last_activity"] = time.Now()
session.Save(r, w)
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
}
next(w, r)
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()
}
}
func Protected(h http.HandlerFunc) http.HandlerFunc {
return Auth(true)(SessionTimeout(h))
// 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()
}
}

View File

@@ -1,5 +1,6 @@
package middleware
// ToDo: make sure im using with gin
import "net/http"
func EnforceHTTPS(next http.Handler, enabled bool) http.Handler {

View File

@@ -1,5 +1,6 @@
package middleware
// ToDo: make sure im using with gin
import (
"net"
"net/http"

View File

@@ -1,5 +1,6 @@
package middleware
// ToDo: make sure im using with gin not to be confused with gins recovery but may do the same?
import (
"log"
"net/http"

View File

@@ -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()
}
}

View File

@@ -0,0 +1,18 @@
package middleware
import (
"net/http"
"github.com/alexedwards/scs/v2"
"github.com/gin-gonic/gin"
)
func Session(sm *scs.SessionManager) gin.HandlerFunc {
return func(c *gin.Context) {
handler := sm.LoadAndSave(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c.Request = r
c.Next()
}))
handler.ServeHTTP(c.Writer, c.Request)
}
}

View File

@@ -1,40 +0,0 @@
package middleware
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)
}
}

View File

@@ -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?
}

View File

@@ -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)))
}

View File

@@ -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))
}

View File

@@ -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)))
}

View File

@@ -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)))
}

View File

@@ -4,14 +4,11 @@ import (
"encoding/json"
"log"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/config"
)
func LogConfig(config *models.Config) {
func LogConfig(config *config.Config) {
safeConfig := *config
safeConfig.CSRF.CSRFKey = "[REDACTED]"
safeConfig.Session.AuthKeyPath = "[REDACTED]"
safeConfig.Session.EncryptionKeyPath = "[REDACTED]"
cfg, err := json.MarshalIndent(safeConfig, "", " ")
if err != nil {

View File

@@ -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?

View File

@@ -1,26 +0,0 @@
package bootstrap
import (
"fmt"
"net/http"
"github.com/gorilla/csrf"
)
var CSRFMiddleware func(http.Handler) http.Handler
func InitCSRFProtection(csrfKey []byte, isProduction bool) error {
if len(csrfKey) != 32 {
return fmt.Errorf("csrf key must be 32 bytes, got %d", len(csrfKey))
}
CSRFMiddleware = csrf.Protect(
csrfKey,
csrf.Secure(isProduction),
csrf.SameSite(csrf.SameSiteStrictMode),
csrf.Path("/"),
csrf.HttpOnly(true),
)
return nil
}

View File

@@ -5,12 +5,12 @@ import (
"time"
internal "synlotto-website/internal/licensecheck"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/config"
)
var globalChecker *internal.LicenseChecker
func InitLicenseChecker(config *models.Config) error {
func InitLicenseChecker(config *config.Config) error {
checker := &internal.LicenseChecker{
LicenseAPIURL: config.License.APIURL,
APIKey: config.License.APIKey,

View File

@@ -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"
"synlotto-website/internal/models"
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 *models.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 models.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
}

View File

@@ -1,115 +0,0 @@
package bootstrap
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/gob"
"fmt"
"net/http"
"os"
"time"
sessionHandlers "synlotto-website/internal/handlers/session"
sessionHelpers "synlotto-website/internal/helpers/session"
"synlotto-website/internal/logging"
"synlotto-website/internal/models"
"github.com/gorilla/sessions"
)
var (
sessionStore *sessions.CookieStore
Name string
authKey []byte
encryptKey []byte
)
func InitSession(cfg *models.Config) error {
gob.Register(time.Time{})
authPath := cfg.Session.AuthKeyPath
encPath := cfg.Session.EncryptionKeyPath
if _, err := os.Stat(authPath); os.IsNotExist(err) {
logging.Info("⚠️ Auth key not found, creating: %s", authPath)
key, err := generateRandomBytes(32)
if err != nil {
return err
}
encoded := sessionHelpers.EncodeKey(key)
err = os.WriteFile(authPath, []byte(encoded), 0600)
if err != nil {
return err
}
}
if _, err := os.Stat(encPath); os.IsNotExist(err) {
logging.Info("⚠️ Encryption key not found, creating: %s", encPath)
key, err := generateRandomBytes(32)
if err != nil {
return err
}
encoded := sessionHelpers.EncodeKey(key)
err = os.WriteFile(encPath, []byte(encoded), 0600)
if err != nil {
return err
}
}
return loadSessionKeys(
authPath,
encPath,
cfg.Session.Name,
cfg.HttpServer.ProductionMode,
)
}
func generateRandomBytes(length int) ([]byte, error) {
b := make([]byte, length)
_, err := rand.Read(b)
if err != nil {
logging.Error("failed to generate random bytes: %w", err)
return nil, err
}
return b, nil
}
func loadSessionKeys(authPath, encryptionPath, name string, isProduction bool) error {
var err error
rawAuth, err := os.ReadFile(authPath)
if err != nil {
return fmt.Errorf("error reading auth key: %w", err)
}
authKey, err = base64.StdEncoding.DecodeString(string(bytes.TrimSpace(rawAuth)))
if err != nil {
return fmt.Errorf("error decoding auth key: %w", err)
}
rawEnc, err := os.ReadFile(encryptionPath)
if err != nil {
return fmt.Errorf("error reading encryption key: %w", err)
}
encryptKey, err = base64.StdEncoding.DecodeString(string(bytes.TrimSpace(rawEnc)))
if err != nil {
return fmt.Errorf("error decoding encryption key: %w", err)
}
if len(authKey) != 32 || len(encryptKey) != 32 {
return fmt.Errorf("auth and encryption keys must be 32 bytes each (got auth=%d, enc=%d)", len(authKey), len(encryptKey))
}
sessionHandlers.SessionStore = sessions.NewCookieStore(authKey, encryptKey)
sessionHandlers.SessionStore.Options = &sessions.Options{
Path: "/",
MaxAge: 86400,
HttpOnly: true,
Secure: isProduction,
SameSite: http.SameSiteLaxMode,
}
sessionHandlers.Name = name
return nil
}

View File

@@ -2,21 +2,19 @@ package config
import (
"sync"
"synlotto-website/internal/models"
)
var (
appConfig *models.Config
appConfig *Config
once sync.Once
)
func Init(config *models.Config) {
func Init(config *Config) {
once.Do(func() {
appConfig = config
})
}
func Get() *models.Config {
func Get() *Config {
return appConfig
}

View File

@@ -0,0 +1,35 @@
{
"csrf": {
"cookieName": ""
},
"database": {
"server": "",
"port": 3306,
"databaseName": "",
"maxOpenConnections": 10,
"maxIdleConnections": 5,
"connectionMaxLifetime": "",
"username": "",
"password":""
},
"httpServer": {
"port": 8082,
"address": "",
"productionMode": false
},
"license": {
"apiUrl": "",
"apiKey": ""
},
"session": {
"cookieName": "",
"lifetime": "",
"idleTimeout": "",
"rememberCookieName": "",
"rememberDuration": ""
},
"site": {
"siteName": "",
"copyrightYearStart": 0
}
}

View File

@@ -0,0 +1,21 @@
package config
import (
"encoding/json"
"os"
)
func Load(path string) (Config, error) {
var cfg Config
data, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, err
}
return cfg, nil
}

View File

@@ -1,8 +1,8 @@
package models
package config
type Config struct {
CSRF struct {
CSRFKey string `json:"csrfKey"`
CookieName string `json:"cookieName"`
} `json:"csrf"`
Database struct {
@@ -28,9 +28,11 @@ type Config struct {
} `json:"license"`
Session struct {
AuthKeyPath string `json:"authKeyPath"`
EncryptionKeyPath string `json:"encryptionKeyPath"`
Name string `json:"name"`
CookieName string `json:"cookieName"`
Lifetime string `json:"lifetime"`
IdleTimeout string `json:"idleTimeout"`
RememberCookieName string `json:"rememberCookieName"`
RememberDuration string `json:"rememberDuration"`
} `json:"session"`
Site struct {

View File

@@ -1,5 +0,0 @@
package constants
import "time"
const SessionDuration = 30 * time.Minute

View File

@@ -0,0 +1,21 @@
package csrf
import (
"net/http"
"synlotto-website/internal/platform/config"
"github.com/justinas/nosurf"
)
func Wrap(h http.Handler, cfg config.Config) http.Handler {
cs := nosurf.New(h)
cs.SetBaseCookie(http.Cookie{
Name: cfg.CSRF.CookieName,
Path: "/",
HttpOnly: true,
Secure: cfg.HttpServer.ProductionMode,
SameSite: http.SameSiteLaxMode,
})
return cs
}

View File

@@ -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()
}

View File

@@ -0,0 +1,35 @@
package session
import (
"encoding/gob"
"net/http"
"time"
"synlotto-website/internal/platform/config"
"github.com/alexedwards/scs/v2"
)
func New(cfg config.Config) *scs.SessionManager {
gob.Register(time.Time{})
s := scs.New()
// Lifetime (absolute max age)
if d, err := time.ParseDuration(cfg.Session.Lifetime); err == nil && d > 0 {
s.Lifetime = d
} else {
s.Lifetime = 12 * time.Hour
}
// 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
}

View File

@@ -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"
)

View File

@@ -5,14 +5,15 @@ import (
"fmt"
"log"
lotteryTicketHandlers "synlotto-website/internal/handlers/lottery/tickets"
thunderballrules "synlotto-website/internal/rules/thunderball"
services "synlotto-website/internal/services/draws"
drawsSvc "synlotto-website/internal/services/draws"
"synlotto-website/internal/helpers"
"synlotto-website/internal/models"
)
// 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{}
@@ -29,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,
@@ -41,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)
@@ -56,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++
}
}
@@ -94,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
@@ -138,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
}
@@ -149,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)
@@ -163,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
@@ -198,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
@@ -212,18 +207,16 @@ func RefreshTicketPrizes(db *sql.DB) error {
mainMatches := helpers.CountMatches(matchTicket.Balls, draw.Balls)
bonusMatches := helpers.CountMatches(matchTicket.BonusBalls, draw.BonusBalls)
prizeTier := matcher.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)
@@ -242,16 +235,16 @@ 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()
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
}

View File

@@ -0,0 +1,84 @@
package storage
import (
"context"
"database/sql"
"net/http"
"time"
securityHelpers "synlotto-website/internal/helpers/security"
"synlotto-website/internal/logging"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
)
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
}
// 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
}
// 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())
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?
// 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 ($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)
}
}

View File

@@ -0,0 +1,3 @@
// Currently no delete functions, only archiving to remove from user
// view but they can pull them back. Consider a soft delete which hides them from being unarchived for 5 years? then systematically delete after 5 years? or delete sooner but retain backup
package storage

View File

@@ -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,

View File

@@ -0,0 +1,6 @@
package migrations
import _ "embed"
//go:embed 0001_initial_create.up.sql
var InitialSchema string

View File

@@ -0,0 +1,6 @@
package migrations
const ProbeUsersTable = `
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'users'`

View File

@@ -1,56 +0,0 @@
package storage
import (
"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"
)
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)
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)
}
log.Printf("🛡️ Admin access: user_id=%d IP=%s Path=%s", userID, ip, path)
next(w, r)
})
}
func LogLoginAttempt(r *http.Request, username string, success bool) {
ip := r.RemoteAddr
userAgent := r.UserAgent()
_, err := db.Exec(
`INSERT INTO audit_login (username, success, ip, user_agent, timestamp)
VALUES (?, ?, ?, ?, ?)`,
username, success, ip, userAgent, time.Now().UTC(),
)
if err != nil {
logging.Info("❌ Failed to log login:", err)
}
}

View File

@@ -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
}

View File

@@ -1,3 +0,0 @@
// Currently no delete functions, only archiving to remove from user
// view but they can pull them back. Consider a soft delete which hides them from being unarchived for 5 years?
// Then systematically delete after 5 years? or delete sooner but retain backup

View File

@@ -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)
);`

View File

@@ -1,54 +0,0 @@
package storage
import (
"database/sql"
"log"
"synlotto-website/internal/logging"
"synlotto-website/internal/platform/config"
// ToDo: remove sqlite
_ "modernc.org/sqlite"
)
var db *sql.DB
func InitDB(filepath string) *sql.DB {
var err error
cfg := config.Get()
db, err = sql.Open("sqlite", filepath)
if err != nil {
log.Fatal("❌ Failed to open DB:", err)
}
schemas := []string{
SchemaUsers,
SchemaThunderballResults,
SchemaThunderballPrizes,
SchemaLottoResults,
SchemaMyTickets,
SchemaUsersMessages,
SchemaUsersNotifications,
SchemaAuditLog,
SchemaAuditLogin,
SchemaLogTicketMatching,
SchemaAdminAccessLog,
SchemaNewAuditLog,
SchemaSyndicates,
SchemaSyndicateMembers,
SchemaSyndicateInvites,
SchemaSyndicateInviteTokens,
}
if cfg == nil {
logging.Error("❌ config is nil — did config.Init() run before InitDB?")
panic("config not ready")
}
for _, stmt := range schemas {
if _, err := db.Exec(stmt); err != nil {
log.Fatalf("❌ Failed to apply schema: %v", err)
}
}
return db
}

View File

@@ -1,14 +0,0 @@
package storage
import (
"database/sql"
)
func UpdateInviteStatus(db *sql.DB, inviteID int, status string) error {
_, err := db.Exec(`
UPDATE syndicate_invites
SET status = ?
WHERE id = ?
`, status, inviteID)
return err
}

View File

@@ -1,22 +0,0 @@
package storage
// ToDo.. "errors" should this not be using my custom log wrapper
import (
"context"
"database/sql"
"errors"
"synlotto-website/internal/models"
)
type UsersRepo struct{ db *sql.DB}
func NewUsersRepo(db *.sql.DB) *UsersRepo { return &UsersRepo{db: db} }
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
}

View File

@@ -1,34 +0,0 @@
package storage
import (
"database/sql"
"synlotto-website/internal/logging"
"synlotto-website/internal/models"
)
func GetUserByID(db *sql.DB, id int) *models.User {
row := db.QueryRow("SELECT id, username, password_hash, is_admin FROM users WHERE id = ?", id)
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
}
return &user
}
func GetUserByUsername(db *sql.DB, username string) *models.User {
row := db.QueryRow(`SELECT id, username, password_hash, is_admin FROM users WHERE username = ?`, username)
var u models.User
err := row.Scan(&u.Id, &u.Username, &u.PasswordHash, &u.IsAdmin)
if err != nil {
return nil
}
return &u
}

View File

@@ -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(`

View File

@@ -3,41 +3,19 @@ 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
func UpdateInviteStatus(db *sql.DB, inviteID int, status string) error {
_, err := db.Exec(`
UPDATE syndicate_invites
SET status = ?
WHERE id = ?
`, status, inviteID)
return err
}
// both a read and inset break up
// ToDo: both a read and inset break up
func AcceptInvite(db *sql.DB, inviteID, userID int) error {
var syndicateID int
err := db.QueryRow(`

View File

@@ -0,0 +1,27 @@
package usersStorage
import (
"context"
"database/sql"
"errors"
"time"
)
const CreateUserSQL = `
INSERT INTO users (username, email, password_hash, created_at, updated_at)
VALUES (?, ?, ?, UTC_TIMESTAMP(), UTC_TIMESTAMP())`
func CreateUser(db *sql.DB, username, email, passwordHash string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
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
}

View File

@@ -0,0 +1,72 @@
package usersStorage
import (
"context"
"database/sql"
"synlotto-website/internal/logging"
"time"
)
const (
UsernameExistsSQL = `
SELECT EXISTS(SELECT 1 FROM users WHERE username = ? LIMIT 1)`
EmailExistsSQL = `
SELECT EXISTS(SELECT 1 FROM users WHERE email = ? LIMIT 1)`
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 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
}
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
}

View File

@@ -0,0 +1,5 @@
package usersStorage
import "synlotto-website/internal/models"
type User = models.User

88
main.go
View File

@@ -1,88 +0,0 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"time"
"synlotto-website/bootstrap"
"synlotto-website/config"
"synlotto-website/handlers"
"synlotto-website/logging"
"synlotto-website/middleware"
"synlotto-website/models"
"synlotto-website/routes"
"synlotto-website/storage"
)
func main() {
appState, err := bootstrap.LoadAppState("config/config.json")
if err != nil {
logging.Error("Failed to load app state: %v", err)
}
config.Init(appState.Config)
logging.LogConfig(appState.Config)
db := storage.InitDB("synlotto.db")
models.SetDB(db) // ToDo: Should be in storage not models.
err = bootstrap.InitSession(appState.Config)
if err != nil {
logging.Error("❌ Failed to init session: %v", err)
}
// ToDo: if err := bootstrap.InitLicenseChecker(appState.Config); err != nil {
// logging.Error("❌ Invalid license: %v", err)
// }
err = bootstrap.InitCSRFProtection([]byte(appState.Config.CSRF.CSRFKey), appState.Config.HttpServer.ProductionMode)
if err != nil {
logging.Error("Failed to init CSRF: %v", err)
}
mux := http.NewServeMux()
routes.SetupAdminRoutes(mux, db)
routes.SetupAccountRoutes(mux, db)
routes.SetupResultRoutes(mux, db)
routes.SetupSyndicateRoutes(mux, db)
routes.SetupStatisticsRoutes(mux, db)
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
mux.HandleFunc("/", handlers.Home(db))
wrapped := bootstrap.CSRFMiddleware(mux)
wrapped = middleware.RateLimit(wrapped)
wrapped = middleware.EnforceHTTPS(wrapped, appState.Config.HttpServer.ProductionMode)
wrapped = middleware.SecureHeaders(wrapped)
wrapped = middleware.Recover(wrapped)
addr := fmt.Sprintf("%s:%d", appState.Config.HttpServer.Address, appState.Config.HttpServer.Port)
srv := &http.Server{
Addr: addr,
Handler: wrapped,
}
go func() {
logging.Info("Server running at %s\n", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logging.Error("Server error: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
logging.Info("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logging.Error("Forced shutdown: %v", err)
}
logging.Info("Server shutdown complete")
}

View File

@@ -1,7 +1,7 @@
{{ define "content" }}
<h2>Login</h2>
<form method="POST" action="/account/login" class="form">
{{ .CSRFField }}
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="mb-3">
<label for="username">Username:</label>

View File

@@ -1,9 +1,43 @@
{{ define "content" }}
<h2>Sign Up</h2>
<form method="POST" action="/account/signup">
{{ .csrfField }}
<label>Username: <input type="text" name="username" required></label><br>
<label>Password: <input type="password" name="password" required></label><br>
<button type="submit">Sign Up</button>
<h2>Create your account</h2>
{{ if .Flash }}<div class="flash">{{ .Flash }}</div>{{ end }}
<form method="POST" action="/account/signup" class="form">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="mb-3">
<label for="username">Username</label>
<input type="text" name="username" id="username" required class="form-control"
value="{{ with .Form }}{{ .Username }}{{ end }}">
{{ with .Errors }}{{ with index . "username" }}<div class="error">{{ . }}</div>{{ end }}{{ end }}
</div>
<div class="mb-3">
<label for="email">Email</label>
<input type="email" name="email" id="email" required class="form-control"
value="{{ with .Form }}{{ .Email }}{{ end }}">
{{ with .Errors }}{{ with index . "email" }}<div class="error">{{ . }}</div>{{ end }}{{ end }}
</div>
<div class="mb-3">
<label for="password">Password</label>
<input type="password" name="password" id="password" required class="form-control">
{{ with .Errors }}{{ with index . "password" }}<div class="error">{{ . }}</div>{{ end }}{{ end }}
</div>
<div class="mb-3">
<label for="password_confirm">Confirm Password</label>
<input type="password" name="password_confirm" id="password_confirm" required class="form-control">
{{ with .Errors }}{{ with index . "password_confirm" }}<div class="error">{{ . }}</div>{{ end }}{{ end }}
</div>
<div class="form-check mb-3">
<input type="checkbox" name="accept_terms" id="accept_terms" class="form-check-input"
{{ with .Form }}{{ if .AcceptTerms }}checked{{ end }}{{ end }}>
<label for="accept_terms" class="form-check-label">I accept the terms</label>
{{ with .Errors }}{{ with index . "accept_terms" }}<div class="error">{{ . }}</div>{{ end }}{{ end }}
</div>
<button type="submit" class="btn btn-primary">Create account</button>
</form>
{{ end }}

Some files were not shown because too many files have changed in this diff Show More