Compare commits

...

48 Commits

Author SHA1 Message Date
cc759ec694 Fix csrf 2025-11-02 09:50:50 +00:00
f0fc70eac6 Add in mark as reaad button to list view, use ajax to preform the action without page refresh. 2025-11-02 09:11:48 +00:00
61ad033520 Fix archiving and unarchiving functionality. 2025-11-01 22:37:47 +00:00
9dc01f925a Changes to pagination and fixing archive messages in progress 2025-10-31 22:55:04 +00:00
8529116ad2 Messages now sending/loading and populating on message dropdown 2025-10-31 12:08:38 +00:00
776ea53a66 Formatting 2025-10-31 12:00:43 +00:00
5880d1ca43 Fix reading of messages. 2025-10-31 12:00:08 +00:00
da365aa9ef Remove unused functions. 2025-10-31 11:57:39 +00:00
5177194895 Add sender 2025-10-31 09:45:20 +00:00
a7a5169c67 Fix model issues. 2025-10-30 22:19:48 +00:00
262536135d Still working through messages and notifications. 2025-10-30 17:22:52 +00:00
8650b1fd63 Continued work on messages and notifications. 2025-10-30 11:11:22 +00:00
b41e92629b Continued work around getting messages and notifications cleaned up since moving to MySQL and changing to Gin, SCS, NoSurf. 2025-10-29 15:22:05 +00:00
0b2883a494 todo comment 2025-10-29 15:21:20 +00:00
5520685504 minor update to footer. 2025-10-29 15:21:07 +00:00
e2b30c0234 minor formatting and text 2025-10-29 15:19:24 +00:00
07f7a50b77 ToDo job 2025-10-29 15:19:07 +00:00
f458250d3a correct package name 2025-10-29 11:38:05 +00:00
f2cb283158 todo for a later date 2025-10-29 11:37:50 +00:00
b9bc29d5bc fix loading of ticket add page 2025-10-29 11:37:35 +00:00
b6b5207d43 Fleshing out some routes from notifications and messages 2025-10-29 10:43:48 +00:00
34918d770f Fix for tim.Time change to tickets model includes date helper. 2025-10-29 10:00:58 +00:00
eba25a4fb5 comment model. 2025-10-29 09:47:51 +00:00
e6654fc1b4 User specific lottery ticket creation 2025-10-29 09:47:35 +00:00
ddafdd0468 current duplicate check uses IS ? which is fragile in MySQL. Using the NULL-safe equality operator <=> instead. 2025-10-29 09:29:51 +00:00
5fcb4fb016 change the field to time.Time for correctness 2025-10-29 09:29:10 +00:00
71c8d4d06c fix typo 2025-10-29 08:54:19 +00:00
244b882f11 Code documentation 2025-10-29 08:36:10 +00:00
8d2ce27a74 commented code 2025-10-28 22:46:11 +00:00
72e655674f Remove redunant ping and update comments. 2025-10-28 22:42:58 +00:00
f1e16fbc52 Logged-in users don’t see login/signup pages 2025-10-28 22:26:15 +00:00
aec8022439 Add additional columns to aufit_login for session tokens. fixed requireAuth for loading of some pages as requireauth was threating a valid session as not logged in. 2025-10-28 22:22:17 +00:00
e1fa6c502e Centralize audit SQL + writers 2025-10-28 15:26:43 +00:00
aa20652abc Moved admin only to middleware. 2025-10-28 15:24:25 +00:00
c9f3863a25 Fix: apply schema: Error 1064 (42000) 2025-10-28 14:38:50 +00:00
76cdb96966 update signup html 2025-10-28 14:37:21 +00:00
29cb50bb34 Update users table and fix potential panic as fk references users before its created. 2025-10-28 14:37:05 +00:00
ffcc340034 remove flash from layout. 2025-10-28 13:49:55 +00:00
af581a4def Fix ob: type not registered for interface: map[string]string & superfluous response.WriteHeader, as well as wired up to go to custom 500 messages. 2025-10-28 13:16:29 +00:00
e0b063fab0 Remove comments and update path. 2025-10-28 12:43:41 +00:00
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
132 changed files with 4857 additions and 2072 deletions

3
.gitignore vendored
View File

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

223
README.md Normal file
View File

@@ -0,0 +1,223 @@
# Platform Architecture & Tech Stack
Internal developer documentation for the SynLotto platform infrastructure, covering the core platform modules where comments were updated and maintained. This serves as the reference for how the runtime environment is constructed and how foundational systems interact.
> **Current as of: Oct 29, 2025**
---
## Platform Initialization Overview
At startup the platform initializes and wires the systems required for HTTP request routing, security, session management, and database persistence.
Boot sequence executed from bootstrap:
### Config Load
→ MySQL Connect + Validate
→ EnsureInitialSchema (Embedded SQL, idempotent)
→ Register gob types for session data
→ Initialize SessionManager (SCS)
→ Create Gin Router (Logging, static assets)
→ Inject *App into Gin context for handler access
→ Route Handler → SCS LoadAndSave wrapping
→ CSRF Wrapping (NoSurf)
→ http.Server construction (graceful shutdown capable)
Application boot from main.go:
### Initialize template helpers
→ Attach global middleware (Auth → Remember)
→ Register route groups (Home, Account, Admin, Syndicate, Statistics)
→ Start serving HTTP requests
→ Graceful shutdown on SIGINT/SIGTERM
## Platform Files & Responsibilities
***internal/platform/bootstrap/loader.go***
The **application kernel** constructor.
Creates and wires:
- Config (loaded externally)
- MySQL DB connection (with pooling + UTF8MB4 + UTC)
- Idempotent initial schema application
- SCS SessionManager
- Gin router with logging
- Static mount: /static → ./web/static
- App → Gin context injection (c.Set("app", app))
- Custom NoRoute/NoMethod/Recovery error pages
- Final HTTP handler wrapping: Gin → SCS → CSRF
Orchestrates: stability of middleware order, security primitives, and transport-level behavior.
***cmd/api/main.go***
Top-level runtime control.
- Initializes template helpers (session manager + site meta)
- Applies Auth and Remember middleware
- Registers route groups
- Starts server in goroutine
- Uses timed graceful shutdown
No business logic or boot infrastructure allowed here.
***internal/platform/config/types.go***
Strongly typed runtime settings including:
Config Sections:
- Database (server, pool settings, credentials)
- HTTP server settings
- Session lifetimes + cookie names
- CSRF cookie name
- External API licensing
- Site metadata
Durations are strings — validated and parsed in platform/session.
***internal/platform/config/load.go***
Loads JSON configuration into Config struct.
- Pure function
- No mutation of global state
- Errors propagate to bootstrap
***internal/platform/config/config.go***
Singleton wrapper for global configuration access.
- Init ensures config is assigned only once
- Get allows consumers to retrieve config object
Used sparingly — dependency injection via App is primary recommended path.
***internal/platform/session/session.go***
Creates and configures SCS session manager.
Configured behaviors:
- Absolute lifetime (default 12h if invalid config)
- Idle timeout enforcement
- Cookie security:
- - HttpOnly = true
- - SameSite = Lax
- - Secure = based on productionMode
Responsible only for platform session settings — not auth behavior or token rotation.
***internal/platform/csrf/csrf.go***
Applies NoSurf global CSRF protection.
- Cookie name from config
- HttpOnly always
- Secure cookie in production
- SameSite = Lax
- Wraps after SCS to access stored session data
Requires template integration for token distribution.
***internal/platform/database/schema.go***
Ensures base DB schema exists using embedded SQL.
Behavior:
- Probes users table
- If any rows exist → assume schema complete
- Otherwise → executes InitialSchema in a single TX
Future: schema versioning required for incremental changes.
## Tech Stack Summary
|Concern | Technology |
| ------ | ------ |
|Web Framework|Gin|
|Session Manager|SCS (server-side)|
|CSRF Protection|NoSurf|
|Database|MySQL|
|Migrations|Embedded SQL applied on startup|
|Templates|Go html/template|
|Static Files|Served via Gin from web/static|
|Authentication|Cookie-based session auth|
|Error Views|Custom 404, 405, Recovery|
|Config Source|JSON configuration file|
|Routing|Grouped per feature under internal/http/routes|
## Security Behavior Summary
|Protection| Current Status|
| ------ | ------ |
|CSRF enforced globally|Yes|
|Session cookies HttpOnly|Yes|
|Secure cookie in production|Yes|
|SameSite policy|Lax|
|Idle timeout enforcement|Enabled|
|Session rotation on login|Enabled|
|DB foreign keys|Enabled|
|Secrets managed via JSON config|Temporary measure|
Security improvements tracked separately.
## Architectural Rules
|Layer |May Access|Must Not Access|
| ------ | ------ | ------ |
|Platform|DB, Session, Config|Handlers, routes|
|Handlers|App, DB, SessionManager, helpers|Bootstrap|
|Template helpers|Pure logic only|DB, HTTP|
|Middleware|Session, App, routing|Template rendering|
|Error pages|No DB or session dependency|Bootstrap internals|
These boundaries are currently enforced in code.
## Known Technical Debt
- Duration parsing and validation improvements
- Environment variable support for secret fields
- CSRF token auto-injection in templates
- Versioned DB migrations
- Replace remaining global config reads
- Add structured logging for platform initialization
- Expanded session store options (persistent)
Documented in developer backlog for scheduling.

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

@@ -0,0 +1,92 @@
// Path: /cmd/api
// File: main.go
//
// Purpose
// Application entrypoint. Wires the bootstrapped App into HTTP runtime concerns:
// - Initializes template helpers with session + site meta
// - Mounts global middleware that require *App (Auth, Remember)
// - Registers all route groups outside of bootstrap
// - Starts the HTTP server and performs graceful shutdown on SIGINT/SIGTERM
//
// Responsibilities (as implemented here)
// 1) Build the application kernel via bootstrap.Load(configPath).
// 2) Initialize template helpers with SessionManager and site metadata.
// 3) Attach global middleware that depend on App (Auth first, then Remember).
// 4) Register route groups (Home, Account, Admin, Syndicate, Statistics).
// 5) Start http.Server in a goroutine and log the bound address.
// 6) Block on OS signals and perform a 10s graceful shutdown.
//
// Notes (code-accurate)
// - Config path uses a backslash; consider using forward slashes or filepath.Join
// to be OS-neutral (Go accepts forward slashes cross-platform).
// - Middleware order matters and matches the master reference: Auth → Remember
// (CSRF is already applied inside bootstrap handler wrapping).
// - ListenAndServe error handling correctly ignores http.ErrServerClosed.
// - Shutdown uses a fixed 10s timeout; consider making this configurable.
//
// TODOs
// - Replace panic on bootstrap/startup with structured logging and exit codes.
// - Move config path to env/flag for deploy-time configurability.
// - If background workers are added, coordinate their shutdown with the same context.
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() {
// Build application kernel (config → DB → schema → sessions → router → CSRF → server)
app, err := bootstrap.Load("internal\\platform\\config\\config.json")
if err != nil {
panic(fmt.Errorf("bootstrap: %w", err))
}
// Initialize template helpers that require session + site metadata
templateHelpers.InitSessionManager(app.SessionManager)
templateHelpers.InitSiteMeta(app.Config.Site.SiteName, app.Config.Site.CopyrightYearStart, 0)
// Global middleware that depends on *App
// Order is important: AuthMiddleware (idle timeout/last activity) → RememberMiddleware (optional)
app.Router.Use(middleware.AuthMiddleware())
app.Router.Use(middleware.RememberMiddleware(app)) // rotation optional; security hardening TBD
// Route registration lives OUTSIDE bootstrap (keeps bootstrap infra-only)
routes.RegisterHomeRoutes(app)
routes.RegisterAccountRoutes(app)
routes.RegisterAdminRoutes(app)
routes.RegisterSyndicateRoutes(app)
routes.RegisterStatisticsRoutes(app)
// Start the HTTP server
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)
// Graceful shutdown on SIGINT/SIGTERM
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) // best-effort; log if needed
}

52
go.mod
View File

@@ -3,28 +3,44 @@ module synlotto-website
go 1.24.1 go 1.24.1
require ( require (
github.com/gorilla/csrf v1.7.2 github.com/alexedwards/scs/v2 v2.9.0
github.com/gorilla/sessions v1.4.0 github.com/gin-gonic/gin v1.11.0
golang.org/x/crypto v0.36.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 golang.org/x/time v0.11.0
modernc.org/sqlite v1.36.1
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/golang-migrate/migrate/v4 v4.19.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/hashicorp/go-multierror v1.1.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/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
golang.org/x/sys v0.31.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect
modernc.org/libc v1.61.13 // indirect github.com/quic-go/quic-go v0.54.0 // indirect
modernc.org/mathutil v1.7.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
modernc.org/memory v1.8.2 // 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 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= github.com/justinas/nosurf v1.2.0 h1:yMs1bSRrNiwXk4AS6n8vL2Ssgpb9CB25T/4xrixaK0s=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/justinas/nosurf v1.2.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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/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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 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.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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=

View File

@@ -0,0 +1,25 @@
package domainMessages
import (
"synlotto-website/internal/models"
)
type Message = models.Message
type CreateMessageInput struct {
SenderID int64
RecipientID int64 `form:"recipientId" binding:"required,numeric"`
Subject string `form:"subject" binding:"required,max=200"`
Body string `form:"body" binding:"required"`
}
type MessageService interface {
ListInbox(userID int64) ([]Message, error)
ListArchived(userID int64) ([]Message, error)
GetByID(userID, id int64) (*Message, error)
Create(userID int64, in CreateMessageInput) (int64, error)
Archive(userID, id int64) error
Unarchive(userID, id int64) error
MarkRead(userID, id int64) error
//MarkUnread(userID, id int64) error
}

View File

@@ -0,0 +1,14 @@
package domainMessages
import (
"synlotto-website/internal/models"
)
// ToDo: Should be taken from model.
type Notification = models.Notification
// ToDo: Should interfaces be else where?
type NotificationService interface {
List(userID int64) ([]Notification, error)
GetByID(userID, id int64) (*Notification, error)
}

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,84 @@
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"
"synlotto-website/internal/platform/sessionkeys"
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(), sessionkeys.Username, user.Username)
sm.Put(r.Context(), sessionkeys.IsAdmin, user.IsAdmin)
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,152 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: archive.go
package accountMessageHandler
import (
"bytes"
"database/sql"
"errors"
"net/http"
"strconv"
templateHandlers "synlotto-website/internal/handlers/template"
templateHelpers "synlotto-website/internal/helpers/template"
httpErrors "synlotto-website/internal/http/error"
"synlotto-website/internal/logging"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
// GET /account/messages/archived
// Renders: web/templates/account/messages/archived.html
func (h *AccountMessageHandlers) ArchivedList(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
// pagination
page := 1
if ps := c.Query("page"); ps != "" {
if n, err := strconv.Atoi(ps); err == nil && n > 0 {
page = n
}
}
pageSize := 20
totalPages, totalCount, err := templateHelpers.GetTotalPages(
c.Request.Context(),
app.DB,
"user_messages",
"recipientId = ? AND is_archived = TRUE",
[]any{userID},
pageSize,
)
if err != nil {
logging.Info("❌ count archived error: %v", err)
c.String(http.StatusInternalServerError, "Failed to load archived messages")
return
}
if page > totalPages {
page = totalPages
}
msgsAll, err := h.Svc.ListArchived(userID)
if err != nil {
logging.Info("❌ list archived error: %v", err)
c.String(http.StatusInternalServerError, "Failed to load archived messages")
return
}
// slice in-memory for now
start := (page - 1) * pageSize
if start > len(msgsAll) {
start = len(msgsAll)
}
end := start + pageSize
if end > len(msgsAll) {
end = len(msgsAll)
}
msgs := msgsAll[start:end]
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
ctx["Flash"] = f
}
ctx["CSRFToken"] = nosurf.Token(c.Request)
ctx["Title"] = "Archived Messages"
ctx["Messages"] = msgs
ctx["CurrentPage"] = page
ctx["TotalPages"] = totalPages
ctx["TotalCount"] = totalCount
ctx["PageRange"] = templateHelpers.MakePageRange(1, totalPages)
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/archived.html")
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout", ctx); err != nil {
logging.Info("❌ Template render error: %v", err)
c.String(http.StatusInternalServerError, "Error rendering archived messages")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
}
// POST /account/messages/archive
func (h *AccountMessageHandlers) ArchivePost(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
idStr := c.PostForm("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
httpErrors.RenderStatus(c, sm, http.StatusBadRequest)
return
}
if err := h.Svc.Archive(userID, id); err != nil {
logging.Info("❌ Archive error: %v", err)
sm.Put(c.Request.Context(), "flash", "Could not archive message.")
c.Redirect(http.StatusSeeOther, "/account/messages")
return
}
sm.Put(c.Request.Context(), "flash", "Message archived.")
c.Redirect(http.StatusSeeOther, "/account/messages")
}
// POST /account/messages/archived
func (h *AccountMessageHandlers) RestoreArchived(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
idStr := c.PostForm("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
sm.Put(c.Request.Context(), "flash", "Invalid message id.")
c.Redirect(http.StatusSeeOther, "/account/messages/archive")
return
}
if err := h.Svc.Unarchive(userID, id); err != nil {
logging.Info("❌ restore/unarchive error: %v", err)
// If no rows affected, show friendly flash; otherwise generic message.
if errors.Is(err, sql.ErrNoRows) {
sm.Put(c.Request.Context(), "flash", "Message not found or not permitted.")
} else {
sm.Put(c.Request.Context(), "flash", "Could not restore message.")
}
c.Redirect(http.StatusSeeOther, "/account/messages/archive")
return
}
sm.Put(c.Request.Context(), "flash", "Message restored.")
c.Redirect(http.StatusSeeOther, "/account/messages/archive")
}

View File

@@ -0,0 +1,20 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: list.go
// ToDo: helpers for reading getting messages shouldn't really be here. ---
package accountMessageHandler
import (
"github.com/gin-gonic/gin"
)
func mustUserID(c *gin.Context) int64 {
if v, ok := c.Get("userID"); ok {
if id, ok2 := v.(int64); ok2 {
return id
}
}
// Fallback for stubs:
return 1
}

View File

@@ -0,0 +1,173 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: read.go
// ToDo: Remove SQL
package accountMessageHandler
import (
"bytes"
"database/sql"
"net/http"
"strconv"
templateHandlers "synlotto-website/internal/handlers/template"
templateHelpers "synlotto-website/internal/helpers/template"
errors "synlotto-website/internal/http/error"
"synlotto-website/internal/logging"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
// GET /account/messages
// Renders: web/templates/account/messages/index.html
func (h *AccountMessageHandlers) List(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
// --- Pagination ---
page := 1
if ps := c.Query("page"); ps != "" {
if n, err := strconv.Atoi(ps); err == nil && n > 0 {
page = n
}
}
pageSize := 20
totalPages, totalCount, err := templateHelpers.GetTotalPages(
c.Request.Context(),
app.DB,
"user_messages",
"recipientId = ? AND is_archived = FALSE",
[]any{userID},
pageSize,
)
if err != nil {
logging.Info("❌ count inbox error: %v", err)
c.String(http.StatusInternalServerError, "Failed to load messages")
return
}
if page > totalPages {
page = totalPages
}
// --- Data ---
msgsAll, err := h.Svc.ListInbox(userID)
if err != nil {
logging.Info("❌ list inbox error: %v", err)
c.String(http.StatusInternalServerError, "Failed to load messages")
return
}
// Temporary in-memory slice (until LIMIT/OFFSET is added)
start := (page - 1) * pageSize
if start > len(msgsAll) {
start = len(msgsAll)
}
end := start + pageSize
if end > len(msgsAll) {
end = len(msgsAll)
}
msgs := msgsAll[start:end]
// --- Template context ---
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
ctx["Flash"] = f
}
ctx["CSRFToken"] = nosurf.Token(c.Request)
ctx["Title"] = "Messages"
ctx["Messages"] = msgs
ctx["CurrentPage"] = page
ctx["TotalPages"] = totalPages
ctx["TotalCount"] = totalCount
ctx["PageRange"] = templateHelpers.MakePageRange(1, totalPages)
// --- Render ---
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/index.html")
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout", ctx); err != nil {
logging.Info("❌ Template render error: %v", err)
c.String(http.StatusInternalServerError, "Error rendering messages page")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
}
// GET /account/messages/read?id=123
// Renders: web/templates/account/messages/read.html
func (h *AccountMessageHandlers) ReadGet(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
idStr := c.Query("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
errors.RenderStatus(c, sm, http.StatusNotFound)
return
}
msg, err := h.Svc.GetByID(userID, id)
if err != nil || msg == nil {
errors.RenderStatus(c, sm, http.StatusNotFound)
return
}
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
ctx["CSRFToken"] = nosurf.Token(c.Request)
ctx["Title"] = msg.Subject
ctx["Message"] = msg
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/read.html")
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout", ctx); err != nil {
logging.Info("❌ Template render error: %v", err)
c.String(http.StatusInternalServerError, "Error rendering message")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
}
func (h *AccountMessageHandlers) MarkReadPost(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
idStr := c.PostForm("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
sm.Put(c.Request.Context(), "flash", "Invalid message id.")
c.Redirect(http.StatusSeeOther, c.Request.Referer()) // back to where they came from
return
}
if err := h.Svc.MarkRead(userID, id); err != nil {
logging.Info("❌ MarkRead error: %v", err)
if err == sql.ErrNoRows {
sm.Put(c.Request.Context(), "flash", "Message not found or not permitted.")
} else {
sm.Put(c.Request.Context(), "flash", "Could not mark message as read.")
}
c.Redirect(http.StatusSeeOther, "/account/messages")
return
}
sm.Put(c.Request.Context(), "flash", "Message marked as read.")
// Redirect back to referer when possible so UX is smooth.
if ref := c.Request.Referer(); ref != "" {
c.Redirect(http.StatusSeeOther, ref)
} else {
c.Redirect(http.StatusSeeOther, "/account/messages")
}
}

View File

@@ -0,0 +1,104 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: send.go
package accountMessageHandler
import (
"net/http"
domain "synlotto-website/internal/domain/messages"
templateHandlers "synlotto-website/internal/handlers/template"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/logging"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
// GET /account/messages/send
// Renders: web/templates/account/messages/send.html
func (h *AccountMessageHandlers) SendGet(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
ctx["Flash"] = f
}
ctx["CSRFToken"] = nosurf.Token(c.Request)
ctx["Title"] = "Send Message"
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/send.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 send message page")
}
}
// POST /account/messages/send
func (h *AccountMessageHandlers) SendPost(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
var in domain.CreateMessageInput
if err := c.ShouldBind(&in); err != nil {
// Re-render form with validation errors
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
ctx["Flash"] = f
}
ctx["CSRFToken"] = nosurf.Token(c.Request)
ctx["Title"] = "Send Message"
ctx["Error"] = "Please correct the errors below."
ctx["Form"] = in
tmpl := templateHelpers.LoadTemplateFiles(
"layout.html",
"web/templates/account/messages/send.html",
)
c.Status(http.StatusBadRequest)
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
logging.Info("❌ Template render error: %v", err)
c.String(http.StatusInternalServerError, "Error rendering send message page")
}
return
}
if _, err := h.Svc.Create(userID, in); err != nil {
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)
ctx["Title"] = "Send Message"
ctx["Error"] = "Could not send message."
ctx["Form"] = in
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/send.html")
c.Status(http.StatusInternalServerError)
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
logging.Info("❌ Template render error: %v", err)
c.String(http.StatusInternalServerError, "Error rendering send message page")
}
return
}
sm.Put(c.Request.Context(), "flash", "Message sent!")
// Redirect back to inbox
c.Redirect(http.StatusSeeOther, "/account/messages")
}

View File

@@ -0,0 +1,11 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: types.go
package accountMessageHandler
import domain "synlotto-website/internal/domain/messages"
type AccountMessageHandlers struct {
Svc domain.MessageService
}

View File

@@ -0,0 +1,75 @@
package accountNotificationHandler
import (
"net/http"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/logging"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
// ToDo: functional also in messages needs to come out
func mustUserID(c *gin.Context) int64 {
// Pull from your auth middleware/session. Panic-unsafe alternative:
if v, ok := c.Get("userID"); ok {
if id, ok2 := v.(int64); ok2 {
return id
}
}
// Fallback for stubs:
return 1
}
// ToDo: functional also in messages needs to come out
func atoi64(s string) (int64, error) {
// small helper to keep imports focused
// replace with strconv.ParseInt in real code
var n int64
for _, ch := range []byte(s) {
if ch < '0' || ch > '9' {
return 0, &strconvNumErr{}
}
n = n*10 + int64(ch-'0')
}
return n, nil
}
type strconvNumErr struct{}
func (e *strconvNumErr) Error() string { return "invalid number" }
// GET /account/notifications/:id
// Renders: web/templates/account/notifications/read.html
func (h *AccountNotificationHandlers) List(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
notes, err := h.Svc.List(userID) // or ListAll/ListUnread use your method name
if err != nil {
logging.Info("❌ list notifications error: %v", err)
c.String(http.StatusInternalServerError, "Failed to load notifications")
return
}
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)
ctx["Title"] = "Notifications"
ctx["Notifications"] = notes
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/notifications/index.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 notifications page")
}
}

View File

@@ -0,0 +1,58 @@
// internal/handlers/account/notifications/read.go
package accountNotificationHandler
import (
"net/http"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/logging"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
// ToDo: functional also in messages needs to come out
func parseIDParam(c *gin.Context, name string) (int64, error) {
// typical atoi wrapper
// (implement: strconv.ParseInt(c.Param(name), 10, 64))
return atoi64(c.Param(name))
}
func (h *AccountNotificationHandlers) ReadGet(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
id, err := parseIDParam(c, "id")
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
n, err := h.Svc.GetByID(userID, id)
if err != nil || n == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
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)
ctx["Title"] = n.Title // or Subject/Heading depending on your struct
ctx["Notification"] = n
tmpl := templateHelpers.LoadTemplateFiles(
"layout.html",
"web/templates/account/notifications/read.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 notification")
}
}

View File

@@ -0,0 +1,7 @@
package accountNotificationHandler
import domain "synlotto-website/internal/domain/notifications"
type AccountNotificationHandlers struct {
Svc domain.NotificationService
}

View File

@@ -0,0 +1,157 @@
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"
)
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)
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
}
}
tmpl := templateHelpers.LoadTemplateFiles("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",
}
errMap := validateRegisterForm(db, form)
if len(errMap) > 0 {
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", errMap)
sm.Put(r.Context(), "flash", "Please fix the highlighted errors.")
c.Redirect(http.StatusSeeOther, "/account/signup")
c.Abort()
return
}
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
}
id, err := usersStorage.CreateUser(db, form.Username, form.Email, hash)
if err != nil {
logging.Info("❌ CreateUser error: %v", err)
sm.Put(r.Context(), "flash", "That username or email is already taken.")
c.Redirect(http.StatusSeeOther, "/account/signup")
c.Abort()
return
}
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 {
return strings.Count(s, "@") == 1 && strings.Contains(s, ".")
}

View File

@@ -0,0 +1,161 @@
// Package accountTicketHandlers
// Path: /internal/handlers/account/tickets/
// File: add.go
//
// Purpose
// Renders & processes the Add Ticket form for authenticated users.
//
// Responsibilities
// 1) Validate user input (game type, draw date, balls and optional bonuses)
// 2) Convert string form values into typed model fields
// 3) Save through storage layer (InsertTicket)
// 4) Prevent DB access from unauthenticated contexts
// 5) Use PRG pattern (POST/Redirect/GET)
//
// Notes
// - No direct SQL here — storage package enforces constraints
// - CSRF provided via nosurf
// - TODO: Replace inline session key with central sessionkeys.UserID
package accountTicketHandlers
import (
"net/http"
"strconv"
"time"
templateHandlers "synlotto-website/internal/handlers/template"
templateHelpers "synlotto-website/internal/helpers/template"
ticketStorage "synlotto-website/internal/storage/tickets"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
// TODO: Replace with centralized key from sessionkeys package
const sessionKeyUserID = "UserID"
func AddGet(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/tickets/add_ticket.html")
c.Header("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(c.Writer, "account/tickets/add_ticket.html", ctx); err != nil {
c.String(http.StatusInternalServerError, "render error: %v", err)
return
}
c.Status(http.StatusOK)
}
func AddPost(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
var f addForm
_ = c.ShouldBind(&f)
f.Errors = map[string]string{}
// Validate required fields
if f.GameType == "" {
f.Errors["game"] = "Game type is required."
}
if f.DrawDate == "" {
f.Errors["draw_date"] = "Draw date is required."
}
balls, ballErrs := parseBalls(f.Ball1, f.Ball2, f.Ball3, f.Ball4, f.Ball5)
for k, v := range ballErrs {
f.Errors[k] = v
}
var drawDate time.Time
if f.DrawDate != "" {
if d, err := time.Parse("2006-01-02", f.DrawDate); err == nil {
drawDate = d
} else {
f.Errors["draw_date"] = "Invalid date (use YYYY-MM-DD)."
}
}
var bonus1Ptr, bonus2Ptr *int
if f.Bonus1 != "" {
if n, err := strconv.Atoi(f.Bonus1); err == nil {
bonus1Ptr = &n
} else {
f.Errors["bonus1"] = "Bonus 1 must be a number."
}
}
if f.Bonus2 != "" {
if n, err := strconv.Atoi(f.Bonus2); err == nil {
bonus2Ptr = &n
} else {
f.Errors["bonus2"] = "Bonus 2 must be a number."
}
}
if len(f.Errors) > 0 {
f.CSRFToken = nosurf.Token(c.Request)
c.HTML(http.StatusUnprocessableEntity, "account/tickets/add_ticket.html", gin.H{
"title": "Add Ticket",
"form": f,
})
return
}
// Build the ticket model expected by ticketStorage.InsertTicket
ticket := models.Ticket{
GameType: f.GameType,
DrawDate: drawDate,
Ball1: balls[0],
Ball2: balls[1],
Ball3: balls[2],
Ball4: balls[3],
Ball5: balls[4],
Bonus1: bonus1Ptr,
Bonus2: bonus2Ptr,
// TODO: populate UserID from session when per-user tickets enabled
}
if err := ticketStorage.InsertTicket(app.DB, ticket); err != nil {
// optional: set flash and re-render
f.Errors["form"] = "Could not save ticket. Please try again."
f.CSRFToken = nosurf.Token(c.Request)
c.HTML(http.StatusInternalServerError, "account/tickets/add_ticket.html", gin.H{
"title": "Add Ticket",
"form": f,
})
return
}
c.Redirect(http.StatusSeeOther, "/account/tickets")
}
// helpers
func parseBalls(b1, b2, b3, b4, b5 string) ([5]int, map[string]string) {
errs := map[string]string{}
toInt := func(name, v string) (int, bool) {
n, err := strconv.Atoi(v)
if err != nil {
errs[name] = "Must be a number."
return 0, false
}
return n, true
}
var out [5]int
ok := true
if out[0], ok = toInt("ball1", b1); !ok {
}
if out[1], ok = toInt("ball2", b2); !ok {
}
if out[2], ok = toInt("ball3", b3); !ok {
}
if out[3], ok = toInt("ball4", b4); !ok {
}
if out[4], ok = toInt("ball5", b5); !ok {
}
return out, errs
}

View File

@@ -0,0 +1,69 @@
// Package accountTicketHandlers
// Path: /internal/handlers/account/tickets/
// File: list.go
//
// Purpose
// List all tickets belonging to the currently authenticated user.
//
// Responsibilities
// - Validate session context
// - Query DB for tickets filtered by user_id
// - Transform rows into template-safe values
//
// TODO
// - Move SQL query into storage layer (read model)
// - Support pagination or date filtering
package accountTicketHandlers
import (
"net/http"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
func List(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userIDAny := sm.Get(c.Request.Context(), sessionKeyUserID)
userID, ok := userIDAny.(int64)
if !ok || userID == 0 {
c.Redirect(http.StatusSeeOther, "/account/login")
return
}
rows, err := app.DB.QueryContext(c.Request.Context(), `
SELECT id, numbers, game, price, purchased_at, created_at
FROM my_tickets
WHERE userId = ?
ORDER BY purchased_at DESC, id DESC
`, userID)
if err != nil {
c.HTML(http.StatusInternalServerError, "account/tickets/my_tickets.html", gin.H{
"title": "My Tickets",
"err": "Could not load your tickets.",
})
return
}
defer rows.Close()
var items []ticketRow
for rows.Next() {
var t ticketRow
if err := rows.Scan(&t.ID, &t.Numbers, &t.Game, &t.Price, &t.PurchasedAt, &t.CreatedAt); err != nil {
continue
}
items = append(items, t)
}
view := gin.H{
"title": "My Tickets",
"tickets": items,
"csrfToken": nosurf.Token(c.Request), // useful if list page has inline delete in future
}
c.HTML(http.StatusOK, "account/tickets/my_tickets.html", view)
}

View File

@@ -0,0 +1,39 @@
// Package accountTicketHandlers
// Path: /internal/handlers/account/tickets/
// File: types.go
//
// Purpose
// Form and view models for ticket create + list flows.
// These types are not persisted directly.
//
// Notes
// Mapping exists only from request → model → template
package accountTicketHandlers
import "time"
// Add Ticket form structure
type addForm struct {
GameType string `form:"game"` // e.g. "Lotto", "EuroMillions"
DrawDate string `form:"draw_date"` // yyyy-mm-dd from <input type="date">
Ball1 string `form:"ball1"`
Ball2 string `form:"ball2"`
Ball3 string `form:"ball3"`
Ball4 string `form:"ball4"`
Ball5 string `form:"ball5"`
Bonus1 string `form:"bonus1"` // optional
Bonus2 string `form:"bonus2"` // optional
Errors map[string]string
CSRFToken string
}
// Ticket list renderer (subset of DB ticket fields)
type ticketRow struct {
ID int64
Numbers string
Game *string
Price *string
PurchasedAt time.Time
CreatedAt time.Time
}

View File

@@ -7,7 +7,6 @@ import (
templateHelpers "synlotto-website/internal/helpers/template" templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/http/middleware"
"synlotto-website/internal/models" "synlotto-website/internal/models"
) )
@@ -20,7 +19,7 @@ type AdminLogEntry struct {
} }
func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc { 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{} data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data) context := templateHelpers.TemplateContext(w, r, data)
@@ -37,7 +36,7 @@ func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc {
} }
defer rows.Close() defer rows.Close()
var logs []AdminLogEntry // ToDo should be in models var logs []AdminLogEntry // ToDo: move to models ?
for rows.Next() { for rows.Next() {
var entry AdminLogEntry var entry AdminLogEntry
if err := rows.Scan(&entry.AccessedAt, &entry.UserID, &entry.Path, &entry.IP, &entry.UserAgent); err != nil { 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 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) _ = tmpl.ExecuteTemplate(w, "layout", context)
}) }
} }
func AuditLogHandler(db *sql.DB) http.HandlerFunc { 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{} data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data) context := templateHelpers.TemplateContext(w, r, data)
@@ -75,8 +73,7 @@ func AuditLogHandler(db *sql.DB) http.HandlerFunc {
var logs []models.AuditEntry var logs []models.AuditEntry
for rows.Next() { for rows.Next() {
var entry models.AuditEntry var entry models.AuditEntry
err := rows.Scan(&entry.Timestamp, &entry.UserID, &entry.Action, &entry.IP, &entry.UserAgent) if err := rows.Scan(&entry.Timestamp, &entry.UserID, &entry.Action, &entry.IP, &entry.UserAgent); err != nil {
if err != nil {
log.Println("⚠️ Failed to scan row:", err) log.Println("⚠️ Failed to scan row:", err)
continue continue
} }
@@ -85,12 +82,10 @@ func AuditLogHandler(db *sql.DB) http.HandlerFunc {
context["AuditLogs"] = logs context["AuditLogs"] = logs
tmpl := templateHelpers.LoadTemplateFiles("audit.html", "templates/admin/logs/audit.html") tmpl := templateHelpers.LoadTemplateFiles("audit.html", "web/templates/admin/logs/audit.html")
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
err = tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Println("❌ Failed to render audit page:", err) log.Println("❌ Failed to render audit page:", err)
http.Error(w, "Template error", http.StatusInternalServerError) http.Error(w, "Template error", http.StatusInternalServerError)
} }
}) }
} }

View File

@@ -1,76 +1,96 @@
// internal/handlers/admin/dashboard.go
package handlers package handlers
// ToDo: move SQL into storage layer
import ( import (
"database/sql"
"log" "log"
"net/http" "net/http"
httpHelpers "synlotto-website/internal/helpers/http" templateHandlers "synlotto-website/internal/handlers/template"
securityHelpers "synlotto-website/internal/helpers/security" security "synlotto-website/internal/helpers/security"
templateHelpers "synlotto-website/internal/helpers/template" templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/platform/bootstrap"
"synlotto-website/internal/models" usersStorage "synlotto-website/internal/storage/users"
"synlotto-website/internal/storage"
) )
var ( func AdminDashboardHandler(app *bootstrap.App) http.HandlerFunc {
total, winners int return func(w http.ResponseWriter, r *http.Request) {
prizeSum float64 userID, ok := security.GetCurrentUserID(app.SessionManager, r)
)
func AdminDashboardHandler(db *sql.DB) http.HandlerFunc {
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok { if !ok {
http.Redirect(w, r, "/account/login", http.StatusSeeOther) http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return return
} }
user := storage.GetUserByID(db, userID) user := usersStorage.GetUserByID(app.DB, userID)
if user == nil { if user == nil {
http.Error(w, "User not found", http.StatusUnauthorized) http.Error(w, "User not found", http.StatusUnauthorized)
return 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 := templateHelpers.TemplateContext(w, r, data)
context["User"] = user context["User"] = user
context["IsAdmin"] = user.IsAdmin 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{}{ context["Stats"] = map[string]interface{}{
"TotalTickets": total, "TotalTickets": total,
"TotalWinners": winners, "TotalWinners": winners,
"TotalPrizeAmount": prizeSum, "TotalPrizeAmount": prizeSum,
} }
rows, err := db.Query(` // Recent matcher logs (limit 10)
SELECT run_at, triggered_by, tickets_matched, winners_found, COALESCE(notes, '') rows, err := app.DB.Query(`
FROM log_ticket_matching SELECT run_at, triggered_by, tickets_matched, winners_found, COALESCE(notes, '')
ORDER BY run_at DESC LIMIT 10 FROM log_ticket_matching
ORDER BY run_at DESC
LIMIT 10
`) `)
if err != nil { if err != nil {
log.Println("⚠️ Failed to load logs:", err) log.Println("⚠️ Failed to load logs:", err)
} } else {
defer rows.Close() defer rows.Close()
var logs []struct {
var logs []models.MatchLog RunAt any
for rows.Next() { TriggeredBy string
var logEntry models.MatchLog TicketsMatched int
err := rows.Scan(&logEntry.RunAt, &logEntry.TriggeredBy, &logEntry.TicketsMatched, &logEntry.WinnersFound, &logEntry.Notes) WinnersFound int
if err != nil { Notes string
log.Println("⚠️ Failed to scan log row:", err)
continue
} }
logs = append(logs, logEntry) for rows.Next() {
var e struct {
RunAt any
TriggeredBy string
TicketsMatched int
WinnersFound int
Notes string
}
if err := rows.Scan(&e.RunAt, &e.TriggeredBy, &e.TicketsMatched, &e.WinnersFound, &e.Notes); err != nil {
log.Println("⚠️ Failed to scan log row:", err)
continue
}
logs = append(logs, e)
}
context["MatchLogs"] = logs
} }
context["MatchLogs"] = logs
tmpl := templateHelpers.LoadTemplateFiles("dashboard.html", "templates/admin/dashboard.html") tmpl := templateHelpers.LoadTemplateFiles("dashboard.html", "web/templates/admin/dashboard.html")
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
err = tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
http.Error(w, "Failed to render dashboard", http.StatusInternalServerError) http.Error(w, "Failed to render dashboard", http.StatusInternalServerError)
return
} }
}) }
} }

View File

@@ -1,20 +1,19 @@
package handlers package handlers
// ToDo: move SQL into storage layer
import ( import (
"database/sql" "database/sql"
"log" "log"
"net/http" "net/http"
httpHelpers "synlotto-website/internal/helpers/http"
templateHelpers "synlotto-website/internal/helpers/template" templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/models" "synlotto-website/internal/models"
) )
func NewDrawHandler(db *sql.DB) http.HandlerFunc { 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{} data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data) ctx := templateHelpers.TemplateContext(w, r, data)
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
game := r.FormValue("game_type") game := r.FormValue("game_type")
@@ -22,29 +21,35 @@ func NewDrawHandler(db *sql.DB) http.HandlerFunc {
machine := r.FormValue("machine") machine := r.FormValue("machine")
ballset := r.FormValue("ball_set") ballset := r.FormValue("ball_set")
_, err := db.Exec(`INSERT INTO results_thunderball (game_type, draw_date, machine, ball_set) VALUES (?, ?, ?, ?)`, _, err := db.Exec(
game, date, machine, ballset) `INSERT INTO results_thunderball (game_type, draw_date, machine, ball_set) VALUES (?, ?, ?, ?)`,
game, date, machine, ballset,
)
if err != nil { if err != nil {
http.Error(w, "Failed to add draw", http.StatusInternalServerError) http.Error(w, "Failed to add draw", http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther) http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
return return
} }
tmpl := templateHelpers.LoadTemplateFiles("new_draw", "templates/admin/draws/new_draw.html") tmpl := templateHelpers.LoadTemplateFiles("new_draw", "web/templates/admin/draws/new_draw.html")
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
tmpl.ExecuteTemplate(w, "layout", context) }
})
} }
func ModifyDrawHandler(db *sql.DB) http.HandlerFunc { 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 { if r.Method == http.MethodPost {
id := r.FormValue("id") id := r.FormValue("id")
_, err := db.Exec(`UPDATE results_thunderball SET game_type=?, draw_date=?, ball_set=?, machine=? WHERE id=?`, _, err := db.Exec(
r.FormValue("game_type"), r.FormValue("draw_date"), r.FormValue("ball_set"), r.FormValue("machine"), id) `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 { if err != nil {
http.Error(w, "Update failed", http.StatusInternalServerError) http.Error(w, "Update failed", http.StatusInternalServerError)
return return
@@ -52,33 +57,30 @@ func ModifyDrawHandler(db *sql.DB) http.HandlerFunc {
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther) http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
return return
} }
// For GET: load draw by ID (pseudo-code) // For GET: load draw by ID if needed and render a form/template
// id := r.URL.Query().Get("id") }
// query DB, pass into context.Draw
})
} }
func DeleteDrawHandler(db *sql.DB) http.HandlerFunc { 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 { if r.Method == http.MethodPost {
id := r.FormValue("id") id := r.FormValue("id")
_, err := db.Exec(`DELETE FROM results_thunderball WHERE id = ?`, id) if _, err := db.Exec(`DELETE FROM results_thunderball WHERE id = ?`, id); err != nil {
if err != nil {
http.Error(w, "Delete failed", http.StatusInternalServerError) http.Error(w, "Delete failed", http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther) http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
return return
} }
}) }
} }
func ListDrawsHandler(db *sql.DB) http.HandlerFunc { 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{} data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data) ctx := templateHelpers.TemplateContext(w, r, data)
draws := []models.DrawSummary{}
var draws []models.DrawSummary
rows, err := db.Query(` rows, err := db.Query(`
SELECT r.id, r.game_type, r.draw_date, r.ball_set, r.machine, 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 (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 d.PrizeSet = prizeFlag > 0
draws = append(draws, d) draws = append(draws, d)
} }
ctx["Draws"] = draws
context["Draws"] = draws tmpl := templateHelpers.LoadTemplateFiles("list.html", "web/templates/admin/draws/list.html")
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
tmpl := templateHelpers.LoadTemplateFiles("list.html", "templates/admin/draws/list.html") }
tmpl.ExecuteTemplate(w, "layout", context)
})
} }

View File

@@ -14,6 +14,7 @@ import (
"synlotto-website/internal/models" "synlotto-website/internal/models"
) )
// ToDo: need to fix flash messages from new gin context
func AdminTriggersHandler(db *sql.DB) http.HandlerFunc { func AdminTriggersHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{} data := models.TemplateData{}
@@ -73,7 +74,7 @@ func AdminTriggersHandler(db *sql.DB) http.HandlerFunc {
return 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) err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil { if err != nil {

View File

@@ -6,23 +6,23 @@ import (
"net/http" "net/http"
"strconv" "strconv"
httpHelpers "synlotto-website/internal/helpers/http"
templateHelpers "synlotto-website/internal/helpers/template" templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/models" "synlotto-website/internal/models"
) )
// ToDo: move SQL into the storage layer.
func AddPrizesHandler(db *sql.DB) http.HandlerFunc { 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{} data := models.TemplateData{}
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
tmpl := templateHelpers.LoadTemplateFiles("add_prizes.html", "templates/admin/draws/prizes/add_prizes.html") tmpl := templateHelpers.LoadTemplateFiles("add_prizes.html", "web/templates/admin/draws/prizes/add_prizes.html")
tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data)) _ = tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data))
return return
} }
drawDate := r.FormValue("draw_date") drawDate := r.FormValue("draw_date")
values := make([]interface{}, 0) values := make([]interface{}, 0, 9)
for i := 1; i <= 9; i++ { for i := 1; i <= 9; i++ {
val, _ := strconv.Atoi(r.FormValue(fmt.Sprintf("prize%d_per_winner", i))) val, _ := strconv.Atoi(r.FormValue(fmt.Sprintf("prize%d_per_winner", i)))
values = append(values, val) values = append(values, val)
@@ -34,23 +34,21 @@ func AddPrizesHandler(db *sql.DB) http.HandlerFunc {
prize7_per_winner, prize8_per_winner, prize9_per_winner prize7_per_winner, prize8_per_winner, prize9_per_winner
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := db.Exec(stmt, append([]interface{}{drawDate}, values...)...) if _, err := db.Exec(stmt, append([]interface{}{drawDate}, values...)...); err != nil {
if err != nil {
http.Error(w, "Insert failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Insert failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/admin/draws", http.StatusSeeOther) http.Redirect(w, r, "/admin/draws", http.StatusSeeOther)
}) }
} }
func ModifyPrizesHandler(db *sql.DB) http.HandlerFunc { 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{} data := models.TemplateData{}
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
tmpl := templateHelpers.LoadTemplateFiles("modify_prizes.html", "templates/admin/draws/prizes/modify_prizes.html") tmpl := templateHelpers.LoadTemplateFiles("modify_prizes.html", "web/templates/admin/draws/prizes/modify_prizes.html")
_ = tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data))
tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data))
return return
} }
@@ -58,13 +56,12 @@ func ModifyPrizesHandler(db *sql.DB) http.HandlerFunc {
for i := 1; i <= 9; i++ { for i := 1; i <= 9; i++ {
key := fmt.Sprintf("prize%d_per_winner", i) key := fmt.Sprintf("prize%d_per_winner", i)
val, _ := strconv.Atoi(r.FormValue(key)) val, _ := strconv.Atoi(r.FormValue(key))
_, err := db.Exec("UPDATE prizes_thunderball SET "+key+" = ? WHERE draw_date = ?", val, drawDate) if _, err := db.Exec("UPDATE prizes_thunderball SET "+key+" = ? WHERE draw_date = ?", val, drawDate); err != nil {
if err != nil {
http.Error(w, "Update failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Update failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
} }
http.Redirect(w, r, "/admin/draws", http.StatusSeeOther) http.Redirect(w, r, "/admin/draws", http.StatusSeeOther)
}) }
} }

View File

@@ -1,25 +1,29 @@
package handlers package handlers
import ( import (
"database/sql"
"log" "log"
"net/http" "net/http"
templateHandlers "synlotto-website/internal/handlers/template" templateHandlers "synlotto-website/internal/handlers/template"
templateHelpers "synlotto-website/internal/helpers/template" templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
) )
func Home(db *sql.DB) http.HandlerFunc { func Home(app *bootstrap.App) gin.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(c *gin.Context) {
data := templateHandlers.BuildTemplateData(db, w, r) data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
context := templateHelpers.TemplateContext(w, r, data) 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) c.Header("Content-Type", "text/html; charset=utf-8")
if err != nil { if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
log.Println("❌ Template render error:", err) 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" "log"
"net/http" "net/http"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/helpers" "synlotto-website/internal/helpers"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/models" "synlotto-website/internal/models"
"synlotto-website/internal/storage" resultsThunderballStorage "synlotto-website/internal/storage/results/thunderball"
) )
func NewDraw(db *sql.DB) http.HandlerFunc { func NewDraw(db *sql.DB) http.HandlerFunc {
@@ -19,7 +18,7 @@ func NewDraw(db *sql.DB) http.HandlerFunc {
context["Page"] = "new_draw" context["Page"] = "new_draw"
context["Data"] = nil 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) err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil { if err != nil {
@@ -45,7 +44,7 @@ func Submit(db *sql.DB, w http.ResponseWriter, r *http.Request) {
Thunderball: helpers.Atoi(r.FormValue("thunderball")), Thunderball: helpers.Atoi(r.FormValue("thunderball")),
} }
err := storage.InsertThunderballResult(db, draw) err := resultsThunderballStorage.InsertThunderballResult(db, draw)
if err != nil { if err != nil {
log.Println("❌ Failed to insert draw:", err) log.Println("❌ Failed to insert draw:", err)
http.Error(w, "Failed to save draw", http.StatusInternalServerError) http.Error(w, "Failed to save draw", http.StatusInternalServerError)

View File

@@ -1,7 +1,7 @@
// internal/handlers/lottery/syndicate/syndicate.go
package handlers package handlers
import ( import (
"database/sql"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -9,38 +9,39 @@ import (
templateHandlers "synlotto-website/internal/handlers/template" templateHandlers "synlotto-website/internal/handlers/template"
securityHelpers "synlotto-website/internal/helpers/security" securityHelpers "synlotto-website/internal/helpers/security"
templateHelpers "synlotto-website/internal/helpers/template" 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/helpers"
"synlotto-website/internal/models" "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) { return func(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
data := templateHandlers.BuildTemplateData(db, w, r) data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data) ctx := templateHelpers.TemplateContext(w, r, data)
tmpl := templateHelpers.LoadTemplateFiles("create-syndicate.html", "templates/syndicate/create.html") tmpl := templateHelpers.LoadTemplateFiles("create-syndicate.html", "web/templates/syndicate/create.html")
tmpl.ExecuteTemplate(w, "layout", context) _ = tmpl.ExecuteTemplate(w, "layout", ctx)
case http.MethodPost: case http.MethodPost:
name := r.FormValue("name") name := r.FormValue("name")
description := r.FormValue("description") description := r.FormValue("description")
userId, ok := securityHelpers.GetCurrentUserID(r) userId, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok || name == "" { if !ok || name == "" {
templateHelpers.SetFlash(w, r, "Invalid data submitted") templateHelpers.SetFlash(r, "Invalid data submitted")
http.Redirect(w, r, "/syndicate/create", http.StatusSeeOther) http.Redirect(w, r, "/syndicate/create", http.StatusSeeOther)
return return
} }
_, err := storage.CreateSyndicate(db, userId, name, description) if _, err := syndicateStorage.CreateSyndicate(app.DB, userId, name, description); err != nil {
if err != nil {
log.Printf("❌ CreateSyndicate failed: %v", err) log.Printf("❌ CreateSyndicate failed: %v", err)
templateHelpers.SetFlash(w, r, "Failed to create syndicate") templateHelpers.SetFlash(r, "Failed to create syndicate")
} else { } else {
templateHelpers.SetFlash(w, r, "Syndicate created successfully") templateHelpers.SetFlash(r, "Syndicate created successfully")
} }
http.Redirect(w, r, "/syndicate", http.StatusSeeOther) 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) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { 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 return
} }
managed := storage.GetSyndicatesByOwner(db, userID) managed := syndicateStorage.GetSyndicatesByOwner(app.DB, userID)
member := storage.GetSyndicatesByMember(db, userID) member := syndicateStorage.GetSyndicatesByMember(app.DB, userID)
managedMap := make(map[int]bool) managedMap := make(map[int]bool, len(managed))
for _, s := range managed { for _, s := range managed {
managedMap[s.ID] = true managedMap[s.ID] = true
} }
@@ -73,131 +74,139 @@ func ListSyndicatesHandler(db *sql.DB) http.HandlerFunc {
} }
} }
data := templateHandlers.BuildTemplateData(db, w, r) data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data) ctx := templateHelpers.TemplateContext(w, r, data)
context["ManagedSyndicates"] = managed ctx["ManagedSyndicates"] = managed
context["JoinedSyndicates"] = filteredJoined ctx["JoinedSyndicates"] = filteredJoined
tmpl := templateHelpers.LoadTemplateFiles("syndicates.html", "templates/syndicate/index.html") tmpl := templateHelpers.LoadTemplateFiles("syndicates.html", "web/templates/syndicate/index.html")
tmpl.ExecuteTemplate(w, "layout", context) _ = 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) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
syndicateID := helpers.Atoi(r.URL.Query().Get("id")) 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 { if err != nil || syndicate == nil {
templateHelpers.RenderError(w, r, 404) templateHelpers.RenderError(w, r, http.StatusNotFound)
return return
} }
isManager := userID == syndicate.OwnerID isManager := userID == syndicate.OwnerID
isMember := storage.IsSyndicateMember(db, syndicateID, userID) isMember := syndicateStorage.IsSyndicateMember(app.DB, syndicateID, userID)
if !isManager && !isMember { if !isManager && !isMember {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
members := storage.GetSyndicateMembers(db, syndicateID) members := syndicateStorage.GetSyndicateMembers(app.DB, syndicateID)
data := templateHandlers.BuildTemplateData(db, w, r) data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data) ctx := templateHelpers.TemplateContext(w, r, data)
context["Syndicate"] = syndicate ctx["Syndicate"] = syndicate
context["Members"] = members ctx["Members"] = members
context["IsManager"] = isManager ctx["IsManager"] = isManager
tmpl := templateHelpers.LoadTemplateFiles("syndicate-view.html", "templates/syndicate/view.html") tmpl := templateHelpers.LoadTemplateFiles("syndicate-view.html", "web/templates/syndicate/view.html")
tmpl.ExecuteTemplate(w, "layout", context) _ = 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) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
syndicateId := helpers.Atoi(r.URL.Query().Get("id")) 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 { if err != nil || syndicate.OwnerID != userID {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
data := templateHandlers.BuildTemplateData(db, w, r) data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data) ctx := templateHelpers.TemplateContext(w, r, data)
context["Syndicate"] = syndicate ctx["Syndicate"] = syndicate
tmpl := templateHelpers.LoadTemplateFiles("syndicate-log-ticket.html", "templates/syndicate/log_ticket.html") tmpl := templateHelpers.LoadTemplateFiles("syndicate-log-ticket.html", "web/templates/syndicate/log_ticket.html")
tmpl.ExecuteTemplate(w, "layout", context) _ = tmpl.ExecuteTemplate(w, "layout", ctx)
case http.MethodPost: case http.MethodPost:
gameType := r.FormValue("game_type") gameType := r.FormValue("game_type")
drawDate := r.FormValue("draw_date") drawDateStr := r.FormValue("draw_date")
method := r.FormValue("purchase_method") method := r.FormValue("purchase_method")
err := storage.InsertTicket(db, models.Ticket{ dt, err := helpers.ParseDrawDate(drawDateStr)
if err != nil {
templateHelpers.SetFlash(r, "Invalid draw date")
http.Redirect(w, r, fmt.Sprintf("/syndicate/view?id=%d", syndicateId), http.StatusSeeOther)
return
}
err = ticketStorage.InsertTicket(app.DB, models.Ticket{
UserId: userID, UserId: userID,
GameType: gameType, GameType: gameType,
DrawDate: drawDate, DrawDate: dt,
PurchaseMethod: method, PurchaseMethod: method,
SyndicateId: &syndicateId, SyndicateId: &syndicateId,
// ToDo image path
}) })
if err != nil { if err != nil {
templateHelpers.SetFlash(w, r, "Failed to add ticket.") templateHelpers.SetFlash(r, "Failed to add ticket.")
} else { } 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) http.Redirect(w, r, fmt.Sprintf("/syndicate/view?id=%d", syndicateId), http.StatusSeeOther)
default: 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) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
syndicateID := helpers.Atoi(r.URL.Query().Get("id")) syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
if syndicateID == 0 { if syndicateID == 0 {
templateHelpers.RenderError(w, r, 400) templateHelpers.RenderError(w, r, http.StatusBadRequest)
return return
} }
if !storage.IsSyndicateMember(db, syndicateID, userID) { if !syndicateStorage.IsSyndicateMember(app.DB, syndicateID, userID) {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return 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) data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data) ctx := templateHelpers.TemplateContext(w, r, data)
context["SyndicateID"] = syndicateID ctx["SyndicateID"] = syndicateID
context["Tickets"] = tickets ctx["Tickets"] = tickets
tmpl := templateHelpers.LoadTemplateFiles("syndicate-tickets.html", "templates/syndicate/tickets.html") tmpl := templateHelpers.LoadTemplateFiles("syndicate-tickets.html", "web/templates/syndicate/tickets.html")
tmpl.ExecuteTemplate(w, "layout", context) _ = tmpl.ExecuteTemplate(w, "layout", ctx)
} }
} }

View File

@@ -1,23 +1,25 @@
// internal/handlers/lottery/syndicate/syndicate_invites.go
package handlers package handlers
import ( import (
"database/sql"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
templateHandlers "synlotto-website/internal/handlers/template" templateHandlers "synlotto-website/internal/handlers/template"
"synlotto-website/internal/helpers"
securityHelpers "synlotto-website/internal/helpers/security" securityHelpers "synlotto-website/internal/helpers/security"
templateHelpers "synlotto-website/internal/helpers/template" templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/platform/bootstrap"
"synlotto-website/internal/helpers" syndicateStorage "synlotto-website/internal/storage/syndicate"
"synlotto-website/internal/storage"
) )
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) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
templateHelpers.RenderError(w, r, http.StatusForbidden) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
@@ -26,24 +28,23 @@ func SyndicateInviteHandler(db *sql.DB) http.HandlerFunc {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
syndicateID := helpers.Atoi(r.URL.Query().Get("id")) syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
data := templateHandlers.BuildTemplateData(db, w, r) data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data) ctx := templateHelpers.TemplateContext(w, r, data)
context["SyndicateID"] = syndicateID ctx["SyndicateID"] = syndicateID
tmpl := templateHelpers.LoadTemplateFiles("invite-syndicate.html", "templates/syndicate/invite.html") tmpl := templateHelpers.LoadTemplateFiles("invite-syndicate.html", "web/templates/syndicate/invite.html")
err := tmpl.ExecuteTemplate(w, "layout", context) if err := tmpl.ExecuteTemplate(w, "layout", ctx); err != nil {
if err != nil { templateHelpers.RenderError(w, r, http.StatusInternalServerError)
templateHelpers.RenderError(w, r, 500)
} }
case http.MethodPost: case http.MethodPost:
syndicateID := helpers.Atoi(r.FormValue("syndicate_id")) syndicateID := helpers.Atoi(r.FormValue("syndicate_id"))
username := r.FormValue("username") username := r.FormValue("username")
err := storage.InviteToSyndicate(db, userID, syndicateID, username) if err := syndicateStorage.InviteToSyndicate(app.DB, userID, syndicateID, username); err != nil {
if err != nil { templateHelpers.SetFlash(r, "Failed to send invite: "+err.Error())
templateHelpers.SetFlash(w, r, "Failed to send invite: "+err.Error())
} else { } 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) http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther)
@@ -53,129 +54,143 @@ 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) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
invites := storage.GetPendingInvites(db, userID) invites := syndicateStorage.GetPendingSyndicateInvites(app.DB, userID)
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
context["Invites"] = invites
tmpl := templateHelpers.LoadTemplateFiles("invites.html", "templates/syndicate/invites.html") data := templateHandlers.BuildTemplateData(app, w, r)
tmpl.ExecuteTemplate(w, "layout", context) 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) { return func(w http.ResponseWriter, r *http.Request) {
inviteID := helpers.Atoi(r.URL.Query().Get("id")) inviteID := helpers.Atoi(r.URL.Query().Get("id"))
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
err := storage.AcceptInvite(db, inviteID, userID) if err := syndicateStorage.AcceptInvite(app.DB, inviteID, userID); err != nil {
if err != nil { templateHelpers.SetFlash(r, "Failed to accept invite")
templateHelpers.SetFlash(w, r, "Failed to accept invite")
} else { } 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) 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) { return func(w http.ResponseWriter, r *http.Request) {
inviteID := helpers.Atoi(r.URL.Query().Get("id")) 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) 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() token, err := securityHelpers.GenerateSecureToken()
if err != nil { if err != nil {
return "", err return "", err
} }
expires := time.Now().Add(time.Duration(ttlHours) * time.Hour) 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) INSERT INTO syndicate_invite_tokens (syndicate_id, token, invited_by_user_id, expires_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`, syndicateID, token, invitedByID, expires) `, syndicateID, token, invitedByID, expires)
return token, err 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 syndicateID int
var expiresAt, acceptedAt sql.NullTime var expiresAt, acceptedAt struct {
err := db.QueryRow(` Valid bool
SELECT syndicate_id, expires_at, accepted_at Time time.Time
FROM syndicate_invite_tokens }
WHERE token = ?
`, token).Scan(&syndicateID, &expiresAt, &acceptedAt) // Note: using separate variables to avoid importing database/sql here.
if err != nil { row := app.DB.QueryRow(`
SELECT syndicate_id, expires_at, accepted_at
FROM syndicate_invite_tokens
WHERE token = ?
`, token)
if err := row.Scan(&syndicateID, &expiresAt.Time, &acceptedAt.Time); err != nil {
return fmt.Errorf("invalid or expired token") 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") 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) INSERT INTO syndicate_members (syndicate_id, user_id, role, status, joined_at)
VALUES (?, ?, 'member', 'active', CURRENT_TIMESTAMP) VALUES (?, ?, 'member', 'active', CURRENT_TIMESTAMP)
`, syndicateID, userID) `, syndicateID, userID); err != nil {
if err != nil {
return err return err
} }
_, err = db.Exec(` _, err := app.DB.Exec(`
UPDATE syndicate_invite_tokens UPDATE syndicate_invite_tokens
SET accepted_by_user_id = ?, accepted_at = CURRENT_TIMESTAMP SET accepted_by_user_id = ?, accepted_at = CURRENT_TIMESTAMP
WHERE token = ? WHERE token = ?
`, userID, token) `, userID, token)
return err 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) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
templateHelpers.RenderError(w, r, http.StatusForbidden) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
syndicateID := helpers.Atoi(r.URL.Query().Get("id")) 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 { 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) http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther)
return return
} }
origin := r.Host scheme := "http://"
if r.TLS != nil { if r.TLS != nil {
origin = "https://" + origin scheme = "https://"
} else {
origin = "http://" + origin
} }
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) 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) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
templateHelpers.RenderError(w, r, http.StatusForbidden) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
@@ -183,44 +198,43 @@ func JoinSyndicateWithTokenHandler(db *sql.DB) http.HandlerFunc {
token := r.URL.Query().Get("token") token := r.URL.Query().Get("token")
if 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) http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
return return
} }
err := AcceptInviteToken(db, token, userID) if err := AcceptInviteToken(app, token, userID); err != nil {
if err != nil { templateHelpers.SetFlash(r, "Failed to join syndicate: "+err.Error())
templateHelpers.SetFlash(w, r, "Failed to join syndicate: "+err.Error())
} else { } 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) 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) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
syndicateID := helpers.Atoi(r.URL.Query().Get("id")) syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
if !syndicateStorage.IsSyndicateManager(app.DB, syndicateID, userID) {
if !storage.IsSyndicateManager(db, syndicateID, userID) { templateHelpers.RenderError(w, r, http.StatusForbidden)
templateHelpers.RenderError(w, r, 403)
return return
} }
tokens := storage.GetInviteTokensForSyndicate(db, syndicateID) tokens := syndicateStorage.GetInviteTokensForSyndicate(app.DB, syndicateID)
data := templateHandlers.BuildTemplateData(db, w, r) data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data) ctx := templateHelpers.TemplateContext(w, r, data)
context["Tokens"] = tokens ctx["Tokens"] = tokens
context["SyndicateID"] = syndicateID ctx["SyndicateID"] = syndicateID
tmpl := templateHelpers.LoadTemplateFiles("invite-links.html", "templates/syndicate/invite_links.html") tmpl := templateHelpers.LoadTemplateFiles("invite-links.html", "web/templates/syndicate/invite_links.html")
tmpl.ExecuteTemplate(w, "layout", context) _ = tmpl.ExecuteTemplate(w, "layout", ctx)
} }
} }

View File

@@ -1,3 +1,4 @@
// internal/handlers/lottery/tickets/ticket_handler.go
package handlers package handlers
import ( import (
@@ -10,21 +11,23 @@ import (
"strconv" "strconv"
"time" "time"
httpHelpers "synlotto-website/internal/helpers/http" templateHandlers "synlotto-website/internal/handlers/template"
securityHelpers "synlotto-website/internal/helpers/security" securityHelpers "synlotto-website/internal/helpers/security"
templateHelpers "synlotto-website/internal/helpers/template" templateHelpers "synlotto-website/internal/helpers/template"
draws "synlotto-website/internal/services/draws" draws "synlotto-website/internal/services/draws"
"synlotto-website/internal/helpers" "synlotto-website/internal/helpers"
"synlotto-website/internal/models" "synlotto-website/internal/models"
"synlotto-website/internal/platform/bootstrap"
"github.com/gorilla/csrf" "github.com/justinas/nosurf"
) )
func AddTicket(db *sql.DB) http.HandlerFunc { // AddTicket renders the add-ticket form (GET) and handles multi-line ticket submission (POST).
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { func AddTicket(app *bootstrap.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
rows, err := db.Query(` rows, err := app.DB.Query(`
SELECT DISTINCT draw_date SELECT DISTINCT draw_date
FROM results_thunderball FROM results_thunderball
ORDER BY draw_date DESC ORDER BY draw_date DESC
@@ -44,40 +47,45 @@ 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 := templateHelpers.TemplateContext(w, r, data)
context["csrfField"] = csrf.TemplateField(r) context["CSRFToken"] = nosurf.Token(r)
context["DrawDates"] = drawDates context["DrawDates"] = drawDates
tmpl := templateHelpers.LoadTemplateFiles("add_ticket.html", "templates/account/tickets/add_ticket.html") tmpl := templateHelpers.LoadTemplateFiles("add_ticket.html", "web/templates/account/tickets/add_ticket.html")
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
err = tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Println("❌ Template render error:", err) log.Println("❌ Template render error:", err)
http.Error(w, "Error rendering form", http.StatusInternalServerError) http.Error(w, "Error rendering form", http.StatusInternalServerError)
} }
return return
} }
err := r.ParseMultipartForm(10 << 20) if err := r.ParseMultipartForm(10 << 20); err != nil {
if err != nil {
http.Error(w, "Invalid form", http.StatusBadRequest) http.Error(w, "Invalid form", http.StatusBadRequest)
log.Println("❌ Failed to parse form:", err) log.Println("❌ Failed to parse form:", err)
return return
} }
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
http.Redirect(w, r, "/account/login", http.StatusSeeOther) http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return return
} }
game := r.FormValue("game_type") game := r.FormValue("game_type")
drawDate := r.FormValue("draw_date") drawDateStr := r.FormValue("draw_date")
purchaseMethod := r.FormValue("purchase_method") purchaseMethod := r.FormValue("purchase_method")
purchaseDate := r.FormValue("purchase_date") purchaseDate := r.FormValue("purchase_date")
purchaseTime := r.FormValue("purchase_time") purchaseTime := r.FormValue("purchase_time")
dt, err := helpers.ParseDrawDate(drawDateStr)
if err != nil {
http.Error(w, "Invalid draw date", http.StatusBadRequest)
return
}
drawDateDB := helpers.FormatDrawDate(dt) // "YYYY-MM-DD"
if purchaseTime != "" { if purchaseTime != "" {
purchaseDate += "T" + purchaseTime purchaseDate += "T" + purchaseTime
} }
@@ -90,7 +98,7 @@ func AddTicket(db *sql.DB) http.HandlerFunc {
out, err := os.Create(filename) out, err := os.Create(filename)
if err == nil { if err == nil {
defer out.Close() defer out.Close()
io.Copy(out, file) _, _ = io.Copy(out, file)
imagePath = filename imagePath = filename
} }
} }
@@ -157,7 +165,7 @@ func AddTicket(db *sql.DB) http.HandlerFunc {
continue continue
} }
_, err := db.Exec(` if _, err := app.DB.Exec(`
INSERT INTO my_tickets ( INSERT INTO my_tickets (
userId, game_type, draw_date, userId, game_type, draw_date,
ball1, ball2, ball3, ball4, ball5, ball6, ball1, ball2, ball3, ball4, ball5, ball6,
@@ -165,42 +173,48 @@ func AddTicket(db *sql.DB) http.HandlerFunc {
purchase_method, purchase_date, image_path purchase_method, purchase_date, image_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
userID, game, drawDate, userID, game, drawDateDB,
b[0], b[1], b[2], b[3], b[4], b[5], b[0], b[1], b[2], b[3], b[4], b[5],
bo[0], bo[1], bo[0], bo[1],
purchaseMethod, purchaseDate, imagePath, purchaseMethod, purchaseDate, imagePath,
) ); err != nil {
if err != nil {
log.Println("❌ Failed to insert ticket line:", err) log.Println("❌ Failed to insert ticket line:", err)
} else { } 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) http.Redirect(w, r, "/tickets", http.StatusSeeOther)
}) }
} }
func SubmitTicket(db *sql.DB) http.HandlerFunc { // SubmitTicket handles alternate multipart ticket submission (POST-only).
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { func SubmitTicket(app *bootstrap.App) http.HandlerFunc {
err := r.ParseMultipartForm(10 << 20) return func(w http.ResponseWriter, r *http.Request) {
if err != nil { if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "Invalid form", http.StatusBadRequest) http.Error(w, "Invalid form", http.StatusBadRequest)
return return
} }
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
http.Redirect(w, r, "/account/login", http.StatusSeeOther) http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return return
} }
game := r.FormValue("game_type") game := r.FormValue("game_type")
drawDate := r.FormValue("draw_date") drawDateStr := r.FormValue("draw_date")
purchaseMethod := r.FormValue("purchase_method") purchaseMethod := r.FormValue("purchase_method")
purchaseDate := r.FormValue("purchase_date") purchaseDate := r.FormValue("purchase_date")
purchaseTime := r.FormValue("purchase_time") purchaseTime := r.FormValue("purchase_time")
dt, err := helpers.ParseDrawDate(drawDateStr)
if err != nil {
http.Error(w, "Invalid draw date", http.StatusBadRequest)
return
}
drawDateDB := helpers.FormatDrawDate(dt)
if purchaseTime != "" { if purchaseTime != "" {
purchaseDate += "T" + purchaseTime purchaseDate += "T" + purchaseTime
} }
@@ -213,13 +227,13 @@ func SubmitTicket(db *sql.DB) http.HandlerFunc {
out, err := os.Create(filename) out, err := os.Create(filename)
if err == nil { if err == nil {
defer out.Close() defer out.Close()
io.Copy(out, file) _, _ = io.Copy(out, file)
imagePath = filename imagePath = filename
} }
} }
ballCount := 6 const ballCount = 6
bonusCount := 2 const bonusCount = 2
balls := make([][]int, ballCount) balls := make([][]int, ballCount)
bonuses := make([][]int, bonusCount) bonuses := make([][]int, bonusCount)
@@ -247,7 +261,7 @@ func SubmitTicket(db *sql.DB) http.HandlerFunc {
} }
} }
_, err := db.Exec(` if _, err := app.DB.Exec(`
INSERT INTO my_tickets ( INSERT INTO my_tickets (
user_id, game_type, draw_date, user_id, game_type, draw_date,
ball1, ball2, ball3, ball4, ball5, ball6, ball1, ball2, ball3, ball4, ball5, ball6,
@@ -255,34 +269,34 @@ func SubmitTicket(db *sql.DB) http.HandlerFunc {
purchase_method, purchase_date, image_path purchase_method, purchase_date, image_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
userID, game, drawDate, userID, game, drawDateDB,
b[0], b[1], b[2], b[3], b[4], b[5], b[0], b[1], b[2], b[3], b[4], b[5],
bo[0], bo[1], bo[0], bo[1],
purchaseMethod, purchaseDate, imagePath, purchaseMethod, purchaseDate, imagePath,
) ); err != nil {
if err != nil {
log.Println("❌ Insert failed:", err) log.Println("❌ Insert failed:", err)
} }
} }
http.Redirect(w, r, "/tickets", http.StatusSeeOther) http.Redirect(w, r, "/tickets", http.StatusSeeOther)
}) }
} }
func GetMyTickets(db *sql.DB) http.HandlerFunc { // GetMyTickets lists the current user's tickets.
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { func GetMyTickets(app *bootstrap.App) http.HandlerFunc {
data := models.TemplateData{} return func(w http.ResponseWriter, r *http.Request) {
var tickets []models.Ticket // Use shared template data builder (ensures user/flash/notifications present)
data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data) context := templateHelpers.TemplateContext(w, r, data)
context["Tickets"] = tickets
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
http.Redirect(w, r, "/account/login", http.StatusSeeOther) http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return return
} }
rows, err := db.Query(` var tickets []models.Ticket
rows, err := app.DB.Query(`
SELECT id, game_type, draw_date, SELECT id, game_type, draw_date,
ball1, ball2, ball3, ball4, ball5, ball6, ball1, ball2, ball3, ball4, ball5, ball6,
bonus1, bonus2, bonus1, bonus2,
@@ -301,6 +315,7 @@ func GetMyTickets(db *sql.DB) http.HandlerFunc {
for rows.Next() { for rows.Next() {
var t models.Ticket var t models.Ticket
var drawDateStr string // ← add
var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64 var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64
var matchedMain, matchedBonus sql.NullInt64 var matchedMain, matchedBonus sql.NullInt64
var prizeTier sql.NullString var prizeTier sql.NullString
@@ -308,19 +323,23 @@ func GetMyTickets(db *sql.DB) http.HandlerFunc {
var prizeLabel sql.NullString var prizeLabel sql.NullString
var prizeAmount sql.NullFloat64 var prizeAmount sql.NullFloat64
err := rows.Scan( if err := rows.Scan(
&t.Id, &t.GameType, &t.DrawDate, &t.Id, &t.GameType, &drawDateStr, // ← was &t.DrawDate
&b1, &b2, &b3, &b4, &b5, &b6, &b1, &b2, &b3, &b4, &b5, &b6,
&bo1, &bo2, &bo1, &bo2,
&t.PurchaseMethod, &t.PurchaseDate, &t.ImagePath, &t.Duplicate, &t.PurchaseMethod, &t.PurchaseDate, &t.ImagePath, &t.Duplicate,
&matchedMain, &matchedBonus, &prizeTier, &isWinner, &prizeLabel, &prizeAmount, &matchedMain, &matchedBonus, &prizeTier, &isWinner, &prizeLabel, &prizeAmount,
) ); err != nil {
if err != nil {
log.Println("⚠️ Failed to scan ticket row:", err) log.Println("⚠️ Failed to scan ticket row:", err)
continue continue
} }
// Build primary number + bonus fields // Parse into time.Time (UTC)
if dt, err := helpers.ParseDrawDate(drawDateStr); err == nil {
t.DrawDate = dt
}
// Normalize fields
t.Ball1 = int(b1.Int64) t.Ball1 = int(b1.Int64)
t.Ball2 = int(b2.Int64) t.Ball2 = int(b2.Int64)
t.Ball3 = int(b3.Int64) t.Ball3 = int(b3.Int64)
@@ -348,28 +367,55 @@ func GetMyTickets(db *sql.DB) http.HandlerFunc {
if prizeAmount.Valid { if prizeAmount.Valid {
t.PrizeAmount = prizeAmount.Float64 t.PrizeAmount = prizeAmount.Float64
} }
// Build balls slices (for template use)
// Derived fields for templates
t.Balls = helpers.BuildBallsSlice(t) t.Balls = helpers.BuildBallsSlice(t)
t.BonusBalls = helpers.BuildBonusSlice(t) t.BonusBalls = helpers.BuildBonusSlice(t)
// 🎯 Get the actual draw info (used to show which numbers matched) // Fetch matching draw info
draw := draws.GetDrawResultForTicket(db, t.GameType, t.DrawDate) draw := draws.GetDrawResultForTicket(app.DB, t.GameType, helpers.FormatDrawDate(t.DrawDate))
t.MatchedDraw = draw 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) 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) tmpl := templateHelpers.LoadTemplateFiles("my_tickets.html", "web/templates/account/tickets/my_tickets.html")
if err != nil { if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
log.Println("❌ Template error:", err) log.Println("❌ Template error:", err)
http.Error(w, "Error rendering page", http.StatusInternalServerError) 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 package handlers
import ( import (
"database/sql"
"log" "log"
"net/http" "net/http"
templateHandlers "synlotto-website/internal/handlers/template" templateHandlers "synlotto-website/internal/handlers/template"
httpHelpers "synlotto-website/internal/helpers/http"
securityHelpers "synlotto-website/internal/helpers/security" securityHelpers "synlotto-website/internal/helpers/security"
templateHelpers "synlotto-website/internal/helpers/template" templateHelpers "synlotto-website/internal/helpers/template"
messagesStorage "synlotto-website/internal/storage/messages"
"synlotto-website/internal/helpers" "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) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
@@ -28,86 +28,82 @@ func MessagesInboxHandler(db *sql.DB) http.HandlerFunc {
} }
perPage := 10 perPage := 10
totalCount := storage.GetInboxMessageCount(db, userID) totalCount := messagesStorage.GetInboxMessageCount(app.DB, userID)
totalPages := (totalCount + perPage - 1) / perPage totalPages := (totalCount + perPage - 1) / perPage
if totalPages == 0 { if totalPages == 0 {
totalPages = 1 totalPages = 1
} }
messages := storage.GetInboxMessages(db, userID, page, perPage) messages := messagesStorage.GetInboxMessages(app.DB, userID, page, perPage)
data := templateHandlers.BuildTemplateData(db, w, r) data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data) ctx := templateHelpers.TemplateContext(w, r, data)
ctx["Messages"] = messages
ctx["CurrentPage"] = page
ctx["TotalPages"] = totalPages
ctx["PageRange"] = templateHelpers.PageRange(page, totalPages)
context["Messages"] = messages tmpl := templateHelpers.LoadTemplateFiles("messages.html", "web/templates/account/messages/index.html")
context["CurrentPage"] = page if err := tmpl.ExecuteTemplate(w, "layout", ctx); err != nil {
context["TotalPages"] = totalPages templateHelpers.RenderError(w, r, http.StatusInternalServerError)
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)
} }
} }
} }
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) { return func(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id") id := helpers.Atoi(r.URL.Query().Get("id"))
messageID := helpers.Atoi(idStr)
session, _ := httpHelpers.GetSession(w, r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
userID, ok := session.Values["user_id"].(int)
if !ok { if !ok {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
message, err := storage.GetMessageByID(db, userID, messageID) message, err := messagesStorage.GetMessageByID(app.DB, userID, id)
if err != nil { if err != nil {
log.Printf("❌ Message not found: %v", err) log.Printf("❌ Message not found: %v", err)
message = nil message = nil
} else if !message.IsRead { } else if message != nil && !message.IsRead {
_ = storage.MarkMessageAsRead(db, messageID, userID) _ = messagesStorage.MarkMessageAsRead(app.DB, id, userID)
} }
data := templateHandlers.BuildTemplateData(db, w, r) data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data) ctx := templateHelpers.TemplateContext(w, r, data)
context["Message"] = message ctx["Message"] = message
tmpl := templateHelpers.LoadTemplateFiles("read-message.html", "templates/account/messages/read.html") tmpl := templateHelpers.LoadTemplateFiles("read-message.html", "web/templates/account/messages/read.html")
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
tmpl.ExecuteTemplate(w, "layout", context)
} }
} }
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) { return func(w http.ResponseWriter, r *http.Request) {
id := helpers.Atoi(r.URL.Query().Get("id")) id := helpers.Atoi(r.URL.Query().Get("id"))
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
err := storage.ArchiveMessage(db, userID, id) if err := messagesStorage.ArchiveMessage(app.DB, userID, id); err != nil {
if err != nil { templateHelpers.SetFlash(r, "Failed to archive message.")
templateHelpers.SetFlash(w, r, "Failed to archive message.")
} else { } else {
templateHelpers.SetFlash(w, r, "Message archived.") templateHelpers.SetFlash(r, "Message archived.")
} }
http.Redirect(w, r, "/account/messages", http.StatusSeeOther) 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) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
@@ -117,35 +113,35 @@ func ArchivedMessagesHandler(db *sql.DB) http.HandlerFunc {
} }
perPage := 10 perPage := 10
messages := storage.GetArchivedMessages(db, userID, page, perPage) messages := messagesStorage.GetArchivedMessages(app.DB, userID, page, perPage)
hasMore := len(messages) == perPage hasMore := len(messages) == perPage
data := templateHandlers.BuildTemplateData(db, w, r) data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data) ctx := templateHelpers.TemplateContext(w, r, data)
context["Messages"] = messages ctx["Messages"] = messages
context["Page"] = page ctx["Page"] = page
context["HasMore"] = hasMore ctx["HasMore"] = hasMore
tmpl := templateHelpers.LoadTemplateFiles("archived.html", "templates/account/messages/archived.html") tmpl := templateHelpers.LoadTemplateFiles("archived.html", "web/templates/account/messages/archived.html")
tmpl.ExecuteTemplate(w, "layout", context) _ = 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) { return func(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
data := templateHandlers.BuildTemplateData(db, w, r) data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data) ctx := templateHelpers.TemplateContext(w, r, data)
tmpl := templateHelpers.LoadTemplateFiles("send-message.html", "templates/account/messages/send.html") tmpl := templateHelpers.LoadTemplateFiles("send-message.html", "web/templates/account/messages/send.html")
if err := tmpl.ExecuteTemplate(w, "layout", ctx); err != nil {
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil { templateHelpers.RenderError(w, r, http.StatusInternalServerError)
templateHelpers.RenderError(w, r, 500)
} }
case http.MethodPost: case http.MethodPost:
senderID, ok := securityHelpers.GetCurrentUserID(r) senderID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
@@ -153,34 +149,34 @@ func SendMessageHandler(db *sql.DB) http.HandlerFunc {
subject := r.FormValue("subject") subject := r.FormValue("subject")
body := r.FormValue("message") body := r.FormValue("message")
if err := storage.SendMessage(db, senderID, recipientID, subject, body); err != nil { if err := messagesStorage.SendMessage(app.DB, senderID, recipientID, subject, body); err != nil {
templateHelpers.SetFlash(w, r, "Failed to send message.") templateHelpers.SetFlash(r, "Failed to send message.")
} else { } else {
templateHelpers.SetFlash(w, r, "Message sent.") templateHelpers.SetFlash(r, "Message sent.")
} }
http.Redirect(w, r, "/account/messages", http.StatusSeeOther) http.Redirect(w, r, "/account/messages", http.StatusSeeOther)
default: 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) { return func(w http.ResponseWriter, r *http.Request) {
id := helpers.Atoi(r.URL.Query().Get("id")) id := helpers.Atoi(r.URL.Query().Get("id"))
userID, ok := securityHelpers.GetCurrentUserID(r) userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok { if !ok {
templateHelpers.RenderError(w, r, 403) templateHelpers.RenderError(w, r, http.StatusForbidden)
return return
} }
err := storage.RestoreMessage(db, userID, id) if err := messagesStorage.RestoreMessage(app.DB, userID, id); err != nil {
if err != nil { templateHelpers.SetFlash(r, "Failed to restore message.")
templateHelpers.SetFlash(w, r, "Failed to restore message.")
} else { } else {
templateHelpers.SetFlash(w, r, "Message restored.") templateHelpers.SetFlash(r, "Message restored.")
} }
http.Redirect(w, r, "/account/messages/archived", http.StatusSeeOther) http.Redirect(w, r, "/account/messages/archive", http.StatusSeeOther)
} }
} }

View File

@@ -1,70 +1,73 @@
package handlers package handlers
import ( import (
"database/sql"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
templateHandlers "synlotto-website/internal/handlers/template" templateHandlers "synlotto-website/internal/handlers/template"
httpHelpers "synlotto-website/internal/helpers/http"
templateHelpers "synlotto-website/internal/helpers/template" templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/platform/bootstrap"
"synlotto-website/internal/storage" "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) { 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) 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 := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
if err != nil {
log.Println("❌ Template render error:", err) log.Println("❌ Template render error:", err)
http.Error(w, "Error rendering notifications page", http.StatusInternalServerError) 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) { return func(w http.ResponseWriter, r *http.Request) {
notificationIDStr := r.URL.Query().Get("id") notificationIDStr := r.URL.Query().Get("id")
notificationID, err := strconv.Atoi(notificationIDStr) notificationID, err := strconv.Atoi(notificationIDStr)
if err != nil { if err != nil || notificationID <= 0 {
http.Error(w, "Invalid notification ID", http.StatusBadRequest) http.Error(w, "Invalid notification ID", http.StatusBadRequest)
return return
} }
session, _ := httpHelpers.GetSession(w, r) // SCS-native session access
userID, ok := session.Values["user_id"].(int) userID := app.SessionManager.GetInt(r.Context(), sessionkeys.UserID)
if !ok { if userID == 0 {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return 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 { if err != nil {
log.Printf("❌ Notification not found or belongs to another user: %v", err) log.Printf("❌ Notification not found or belongs to another user: %v", err)
notification = nil notification = nil
} else if !notification.IsRead { } else if !notification.IsRead {
err = storage.MarkNotificationAsRead(db, userID, notificationID) if err := notificationsStorage.MarkNotificationAsRead(app.DB, userID, notificationID); err != nil {
if err != nil {
log.Printf("⚠️ Failed to mark as read: %v", err) 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 := templateHelpers.TemplateContext(w, r, data)
context["Notification"] = notification 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 := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
if err != nil {
log.Printf("❌ Template render error: %v", err) log.Printf("❌ Template render error: %v", err)
http.Error(w, "Template render error", http.StatusInternalServerError) http.Error(w, "Template render error", http.StatusInternalServerError)
return
} }
} }
} }

View File

@@ -9,9 +9,8 @@ import (
"sort" "sort"
"strconv" "strconv"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/helpers" "synlotto-website/internal/helpers"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/http/middleware" "synlotto-website/internal/http/middleware"
"synlotto-website/internal/models" "synlotto-website/internal/models"
) )
@@ -20,7 +19,6 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr) ip, _, _ := net.SplitHostPort(r.RemoteAddr)
limiter := middleware.GetVisitorLimiter(ip) limiter := middleware.GetVisitorLimiter(ip)
if !limiter.Allow() { if !limiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return return
@@ -46,7 +44,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
doSearch := isValidDate(query) || isValidNumber(query) doSearch := isValidDate(query) || isValidNumber(query)
whereClause := "WHERE 1=1" whereClause := "WHERE 1=1"
args := []interface{}{} args := []any{}
if doSearch { if doSearch {
whereClause += " AND (draw_date = ? OR id = ?)" whereClause += " AND (draw_date = ? OR id = ?)"
@@ -65,7 +63,21 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
args = append(args, ballSetFilter) args = append(args, ballSetFilter)
} }
totalPages, totalResults := templateHelpers.GetTotalPages(db, "results_thunderball", whereClause, args, pageSize) // ✅ FIX: Proper GetTotalPages call with context + correct table name
totalPages, totalResults, err := templateHelpers.GetTotalPages(
r.Context(),
db,
"results_thunderball",
whereClause,
args,
pageSize,
)
if err != nil {
log.Println("❌ Pagination count error:", err)
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if page < 1 || page > totalPages { if page < 1 || page > totalPages {
http.NotFound(w, r) http.NotFound(w, r)
return return
@@ -79,7 +91,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
LIMIT ? OFFSET ?` LIMIT ? OFFSET ?`
argsWithLimit := append(args, pageSize, offset) argsWithLimit := append(args, pageSize, offset)
rows, err := db.Query(querySQL, argsWithLimit...) rows, err := db.QueryContext(r.Context(), querySQL, argsWithLimit...)
if err != nil { if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError) http.Error(w, "Database error", http.StatusInternalServerError)
log.Println("❌ DB error:", err) log.Println("❌ DB error:", err)
@@ -113,7 +125,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
noResultsMsg = "No results found for \"" + query + "\"" noResultsMsg = "No results found for \"" + query + "\""
} }
tmpl := templateHelpers.LoadTemplateFiles("thunderball.html", "templates/results/thunderball.html") tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/results/thunderball.html")
err = tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{ err = tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{
"Results": results, "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 package handlers
import ( import (
"database/sql"
"log" "log"
"net" "net"
"net/http" "net/http"
templateHandlers "synlotto-website/internal/handlers/template" templateHandlers "synlotto-website/internal/handlers/template"
templateHelpers "synlotto-website/internal/helpers/template" templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/http/middleware" "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) { return func(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr) ip, _, _ := net.SplitHostPort(r.RemoteAddr)
limiter := middleware.GetVisitorLimiter(ip) limiter := middleware.GetVisitorLimiter(ip)
if !limiter.Allow() { if !limiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return return
} }
data := templateHandlers.BuildTemplateData(db, w, r) data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data) context := templateHelpers.TemplateContext(w, r, data)
tmpl := templateHelpers.LoadTemplateFiles("statistics.html", "templates/statistics/thunderball.html") tmpl := templateHelpers.LoadTemplateFiles("statistics.html", "web/templates/statistics/thunderball.html")
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Println("❌ Template render error:", err) 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 ( import (
"database/sql"
"log"
"net/http" "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/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 { // BuildTemplateData aggregates common UI data (user, notifications, messages)
session, err := httpHelper.GetSession(w, r) // from the current SCS session + DB.
if err != nil { func BuildTemplateData(app *bootstrap.App, w http.ResponseWriter, r *http.Request) models.TemplateData {
log.Printf("Session error: %v", err) sm := app.SessionManager
} ctx := r.Context()
var user *models.User var (
var isAdmin bool user *models.User
var notificationCount int isAdmin bool
var notifications []models.Notification notificationCount int
var messageCount int notifications []models.Notification
var messages []models.Message messageCount int
messages []models.Message
)
if userId, ok := session.Values["user_id"].(int); ok { // Read user_id from SCS (may be int or int64 depending on writes)
user = storage.GetUserByID(db, userId) if v := sm.Get(ctx, sessionkeys.UserID); v != nil {
if user != nil { var uid int64
isAdmin = user.IsAdmin switch t := v.(type) {
notificationCount = storage.GetNotificationCount(db, user.Id) case int64:
notifications = storage.GetRecentNotifications(db, user.Id, 15) uid = t
messageCount, _ = storage.GetMessageCount(db, user.Id) case int:
messages = storage.GetRecentMessages(db, user.Id, 15) uid = int64(t)
}
if uid > 0 {
if u := usersStorage.GetUserByID(app.DB, int(uid)); u != nil {
user = u
isAdmin = u.IsAdmin
notificationCount = notificationStorage.GetNotificationCount(app.DB, int(u.Id))
notifications = notificationStorage.GetRecentNotifications(app.DB, int(u.Id), 15)
messageCount, _ = messageStorage.GetMessageCount(app.DB, int(u.Id))
messages = messageStorage.GetRecentMessages(app.DB, int(u.Id), 15)
}
} }
} }

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
}

31
internal/helpers/dates.go Normal file
View File

@@ -0,0 +1,31 @@
package helpers
import (
"fmt"
"time"
)
var drawDateLayouts = []string{
time.RFC3339, // 2006-01-02T15:04:05Z07:00
"2006-01-02", // 2025-10-29
"2006-01-02 15:04", // 2025-10-29 20:30
"2006-01-02 15:04:05", // 2025-10-29 20:30:59
}
// ParseDrawDate tries multiple layouts and returns UTC.
func ParseDrawDate(s string) (time.Time, error) {
for _, l := range drawDateLayouts {
if t, err := time.ParseInLocation(l, s, time.Local); err == nil {
return t.UTC(), nil
}
}
return time.Time{}, fmt.Errorf("cannot parse draw date: %q", s)
}
// FormatDrawDate normalizes a time to the storage format you use in SQL (date only).
func FormatDrawDate(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02")
}

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 ( import (
"net/http" "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) { func GetCurrentUserID(sm *scs.SessionManager, r *http.Request) (int, bool) {
session, err := httpHelpers.GetSession(nil, r) userID := sm.GetInt(r.Context(), sessionkeys.UserID)
if err != nil { return userID, userID != 0
return 0, false
}
id, ok := session.Values["user_id"].(int)
return id, ok
} }

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,47 +1,53 @@
package helpers package templateHelper
import ( import (
"html/template" "html/template"
"log"
"net/http" "net/http"
"strings" "strings"
"time" "time"
helpers "synlotto-website/internal/helpers/http"
"synlotto-website/internal/models" "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{} { 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{}{ return map[string]interface{}{
"CSRFField": csrf.TemplateField(r), "CSRFToken": nosurf.Token(r),
"Flash": flash,
"User": data.User, "User": data.User,
"IsAdmin": data.IsAdmin, "IsAdmin": data.IsAdmin,
"NotificationCount": data.NotificationCount, "NotificationCount": data.NotificationCount,
"Notifications": data.Notifications, "Notifications": data.Notifications,
"MessageCount": data.MessageCount, "MessageCount": data.MessageCount,
"Messages": data.Messages, "Messages": data.Messages,
"SiteName": cfg.Site.SiteName, "SiteName": meta.Name,
"CopyrightYearStart": cfg.Site.CopyrightYearStart, "CopyrightYearStart": meta.CopyrightYearStart,
} }
} }
// ToDo the funcs need breaking up getting large
func TemplateFuncs() template.FuncMap { func TemplateFuncs() template.FuncMap {
return template.FuncMap{ return template.FuncMap{
"plus1": func(i int) int { return i + 1 }, "plus1": func(i int) int { return i + 1 },
@@ -57,9 +63,8 @@ func TemplateFuncs() template.FuncMap {
"min": func(a, b int) int { "min": func(a, b int) int {
if a < b { if a < b {
return a return a
} else {
return b
} }
return b
}, },
"intVal": func(p *int) int { "intVal": func(p *int) int {
if p == nil { if p == nil {
@@ -97,19 +102,18 @@ func TemplateFuncs() template.FuncMap {
func LoadTemplateFiles(name string, files ...string) *template.Template { func LoadTemplateFiles(name string, files ...string) *template.Template {
shared := []string{ shared := []string{
"templates/main/layout.html", "web/templates/main/layout.html",
"templates/main/topbar.html", "web/templates/main/topbar.html",
"templates/main/footer.html", "web/templates/main/footer.html",
} }
all := append(shared, files...) all := append(shared, files...)
return template.Must(template.New(name).Funcs(TemplateFuncs()).ParseFiles(all...)) return template.Must(template.New(name).Funcs(TemplateFuncs()).ParseFiles(all...))
} }
func SetFlash(w http.ResponseWriter, r *http.Request, message string) { func SetFlash(r *http.Request, message string) {
session, _ := helpers.GetSession(w, r) if sm != nil {
session.Values["flash"] = message sm.Put(r.Context(), "flash", message)
session.Save(r, w) }
} }
func InSlice(n int, list []int) bool { func InSlice(n int, list []int) bool {

View File

@@ -1,4 +1,4 @@
package helpers package templateHelper
import ( import (
"fmt" "fmt"
@@ -9,10 +9,12 @@ import (
"synlotto-website/internal/models" "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) { func RenderError(w http.ResponseWriter, r *http.Request, statusCode int) {
log.Printf("⚙️ RenderError called with status: %d", statusCode) 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) pagePath := fmt.Sprintf("templates/error/%d.html", statusCode)
log.Printf("📄 Checking for template file: %s", pagePath) log.Printf("📄 Checking for template file: %s", pagePath)
@@ -23,17 +25,14 @@ func RenderError(w http.ResponseWriter, r *http.Request, statusCode int) {
return return
} }
log.Println("✅ Template file found, loading...")
tmpl := LoadTemplateFiles(fmt.Sprintf("%d.html", statusCode), pagePath) tmpl := LoadTemplateFiles(fmt.Sprintf("%d.html", statusCode), pagePath)
w.WriteHeader(statusCode) w.WriteHeader(statusCode)
err := tmpl.ExecuteTemplate(w, "layout", context) if err := tmpl.ExecuteTemplate(w, "layout", ctx); err != nil {
if err != nil {
log.Printf("❌ Failed to render error page layout: %v", err) log.Printf("❌ Failed to render error page layout: %v", err)
http.Error(w, http.StatusText(statusCode), statusCode) http.Error(w, http.StatusText(statusCode), statusCode)
return return
} }
log.Println("✅ Successfully rendered error page") // ToDo: log these to database log.Println("✅ Successfully rendered error page")
} }

View File

@@ -1,26 +1,72 @@
package helpers // internal/helpers/pagination/pagination.go (move out of template/*)
package templateHelper
import ( import (
"context"
"database/sql" "database/sql"
"fmt"
"time"
) )
func GetTotalPages(db *sql.DB, tableName, whereClause string, args []interface{}, pageSize int) (totalPages, totalCount int) { // Whitelist
query := "SELECT COUNT(*) FROM " + tableName + " " + whereClause var allowedTables = map[string]struct{}{
row := db.QueryRow(query, args...) "user_messages": {},
if err := row.Scan(&totalCount); err != nil { "user_notifications": {},
return 1, 0 "results_thunderball": {},
}
// GetTotalPages counts rows and returns (totalPages, totalCount).
func GetTotalPages(ctx context.Context, db *sql.DB, table, whereClause string, args []any, pageSize int) (int, int64, error) {
if pageSize <= 0 {
pageSize = 20
} }
totalPages = (totalCount + pageSize - 1) / pageSize if _, ok := allowedTables[table]; !ok {
return 1, 0, fmt.Errorf("table not allowed: %s", table)
}
q := fmt.Sprintf("SELECT COUNT(*) FROM %s", table)
if whereClause != "" {
q += " WHERE " + whereClause
}
var totalCount int64
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := db.QueryRowContext(cctx, q, args...).Scan(&totalCount); err != nil {
return 1, 0, fmt.Errorf("count %s: %w", table, err)
}
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
if totalPages < 1 { if totalPages < 1 {
totalPages = 1 totalPages = 1
} }
return totalPages, totalCount return totalPages, totalCount, nil
} }
func MakePageRange(current, total int) []int { func MakePageRange(current, total int) []int {
var pages []int if total < 1 {
return []int{1}
}
pages := make([]int, 0, total)
for i := 1; i <= total; i++ { for i := 1; i <= total; i++ {
pages = append(pages, i) pages = append(pages, i)
} }
return pages return pages
} }
func ClampPage(p, total int) int {
if p < 1 {
return 1
}
if p > total {
return total
}
return p
}
func OffsetLimit(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
return (page - 1) * pageSize, pageSize
}

View File

@@ -0,0 +1,89 @@
package errors
import (
"fmt"
"net/http"
"os"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/sessionkeys"
"github.com/alexedwards/scs/v2"
"github.com/gin-gonic/gin"
)
// RenderStatus renders web/templates/error/<status>.html inside layout.html,
// using ONLY session data (no DB) so 404/500 pages don't crash and still
// look "logged in" when a session exists.
func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) {
r := c.Request
uid := int64(0)
if v := sessions.Get(r.Context(), sessionkeys.UserID); v != nil {
switch t := v.(type) {
case int64:
uid = t
case int:
uid = int64(t)
}
}
// --- build minimal template data from session
var data models.TemplateData
if uid > 0 {
uname := ""
if v := sessions.Get(r.Context(), sessionkeys.Username); v != nil {
if s, ok := v.(string); ok {
uname = s
}
}
isAdmin := false
if v := sessions.Get(r.Context(), sessionkeys.IsAdmin); v != nil {
if b, ok := v.(bool); ok {
isAdmin = b
}
}
data.User = &models.User{
Id: uid,
Username: uname,
IsAdmin: isAdmin,
}
data.IsAdmin = isAdmin
}
ctxMap := templateHelpers.TemplateContext(c.Writer, r, data)
if f := sessions.PopString(r.Context(), sessionkeys.Flash); f != "" {
ctxMap["Flash"] = f
}
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", ctxMap); 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, rec interface{}) {
RenderStatus(c, sessions, http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,52 @@
package middleware
import (
"net/http"
"time"
httphelpers "synlotto-website/internal/helpers/http"
securityHelpers "synlotto-website/internal/helpers/security"
auditlogStorage "synlotto-website/internal/storage/auditlog"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
)
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
ctx := c.Request.Context()
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
}
if !securityHelpers.IsAdmin(app.DB, int(uid)) {
c.String(http.StatusForbidden, "Forbidden")
c.Abort()
return
}
auditlogStorage.LogAdminAccess(
app.DB,
uid,
c.Request.URL.Path,
httphelpers.ClientIP(c.Request),
c.Request.UserAgent(),
time.Now().UTC(),
)
c.Next()
}
}

View File

@@ -2,48 +2,119 @@ package middleware
import ( import (
"net/http" "net/http"
"strings"
"time" "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 { // Tracks idle timeout using LastActivity; redirects on timeout.
return func(next http.HandlerFunc) http.HandlerFunc { func AuthMiddleware() gin.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(c *gin.Context) {
session, _ := httpHelpers.GetSession(w, r) 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 {
if required && !ok { // don't destroy here; just rotate and bounce to login with a flash
http.Redirect(w, r, "/account/login", http.StatusSeeOther) _ = sm.RenewToken(ctx)
sm.Put(ctx, sessionkeys.Flash, "Your session has timed out.")
c.Redirect(http.StatusSeeOther, "/account/login")
c.Abort()
return return
} }
if ok {
last, hasLast := session.Values["last_activity"].(time.Time)
if hasLast && time.Since(last) > constants.SessionDuration {
session.Options.MaxAge = -1
session.Save(r, w)
newSession, _ := httpHelpers.GetSession(w, r)
newSession.Values["flash"] = "Your session has timed out."
newSession.Save(r, w)
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
session.Values["last_activity"] = time.Now()
session.Save(r, w)
}
next(w, r)
} }
// if logged in, update last activity
if sm.Exists(ctx, sessionkeys.UserID) {
sm.Put(ctx, sessionkeys.LastActivity, time.Now().UTC())
}
c.Next()
} }
} }
func Protected(h http.HandlerFunc) http.HandlerFunc { // Optional remember-me using selector:verifier token pair.
return Auth(true)(SessionTimeout(h)) func RememberMiddleware(app *bootstrap.App) gin.HandlerFunc {
return func(c *gin.Context) {
sm := app.SessionManager
ctx := c.Request.Context()
// Already logged in? Skip.
if sm.Exists(ctx, sessionkeys.UserID) {
c.Next()
return
}
cookie, err := c.Request.Cookie(app.Config.Session.RememberCookieName)
if err != nil {
c.Next()
return
}
parts := strings.SplitN(cookie.Value, ":", 2)
if len(parts) != 2 {
c.Next()
return
}
selector, verifier := parts[0], parts[1]
userID, hash, expires, revokedAt, err := sessionHelper.FindToken(app.DB, selector)
if err != nil || revokedAt != nil || time.Now().After(expires) {
c.Next()
return
}
if sessionHelper.HashVerifier(verifier) != hash {
_ = sessionHelper.RevokeToken(app.DB, selector) // tampered
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())
// (Optional) if you can look up username/is_admin here, also set:
// sm.Put(ctx, sessionkeys.Username, uname)
// sm.Put(ctx, sessionkeys.IsAdmin, isAdmin)
c.Next()
}
}
// Blocks anonymous users; redirects to login.
func RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
ctx := c.Request.Context()
// ✅ Use Exists to be robust to int vs int64 storage
if !sm.Exists(ctx, sessionkeys.UserID) {
c.Redirect(http.StatusSeeOther, "/account/login")
c.Abort()
return
}
c.Next()
}
}
// Redirects authenticated users away from public auth pages.
func PublicOnly() gin.HandlerFunc {
return func(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
if sm.Exists(c.Request.Context(), sessionkeys.UserID) {
c.Redirect(http.StatusSeeOther, "/")
c.Abort()
return
}
c.Next()
}
} }

View File

@@ -0,0 +1,26 @@
// internal/http/middleware/errorlog.go
package middleware
import (
"time"
"synlotto-website/internal/logging"
"github.com/gin-gonic/gin"
)
func ErrorLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
if len(c.Errors) == 0 {
return
}
for _, e := range c.Errors {
logging.Info("❌ %s %s -> %d in %v: %v",
c.Request.Method, c.FullPath(), c.Writer.Status(),
time.Since(start), e.Err)
}
}
}

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package middleware package middleware
// ToDo: make sure im using with gin not to be confused with gins recovery but may do the same?
import ( import (
"log" "log"
"net/http" "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,94 @@
// Package routes
// Path: /internal/http/routes
// File: accountroutes.go
//
// Purpose
// Defines all /account route groups including:
//
// - Public authentication pages (login, signup)
// - Protected session actions (logout)
// - Auth-protected ticket management pages
//
// Responsibilities (as implemented here)
// 1) PublicOnly guard on login/signup pages
// 2) RequireAuth guard on logout and tickets pages
// 3) Clean REST path structure for tickets ("/account/tickets")
//
// Notes
// - AuthMiddleware must come before RequireAuth
// - Ticket routes rely on authenticated user context
package routes package routes
import ( import (
"database/sql" accountHandler "synlotto-website/internal/handlers/account"
"net/http" accountMsgHandlers "synlotto-website/internal/handlers/account/messages"
accountNotificationHandler "synlotto-website/internal/handlers/account/notifications"
accountTicketHandler "synlotto-website/internal/handlers/account/tickets"
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/http/middleware"
"synlotto-website/internal/platform/bootstrap"
) )
func SetupAccountRoutes(mux *http.ServeMux, db *sql.DB) { func RegisterAccountRoutes(app *bootstrap.App) {
mux.HandleFunc("/account/login", accountHandlers.Login(db)) r := app.Router
mux.HandleFunc("/account/logout", middleware.Protected(accountHandlers.Logout))
mux.HandleFunc("/account/signup", accountHandlers.Signup) // Instantiate handlers that have method receivers
mux.HandleFunc("/account/tickets/add_ticket", lotteryDrawHandlers.AddTicket(db)) messageSvc := app.Services.Messages
mux.HandleFunc("/account/tickets/my_tickets", lotteryDrawHandlers.GetMyTickets(db)) msgH := &accountMsgHandlers.AccountMessageHandlers{Svc: messageSvc}
mux.HandleFunc("/account/messages", middleware.Protected(handlers.MessagesInboxHandler(db)))
mux.HandleFunc("/account/messages/read", middleware.Protected(handlers.ReadMessageHandler(db))) notificationSvc := app.Services.Notifications
mux.HandleFunc("/account/messages/archive", middleware.Protected(handlers.ArchiveMessageHandler(db))) notifH := &accountNotificationHandler.AccountNotificationHandlers{Svc: notificationSvc}
mux.HandleFunc("/account/messages/archived", middleware.Protected(handlers.ArchivedMessagesHandler(db)))
mux.HandleFunc("/account/messages/restore", middleware.Protected(handlers.RestoreMessageHandler(db))) // ticketSvc := app.Services.TicketService
mux.HandleFunc("/account/messages/send", middleware.Protected(handlers.SendMessageHandler(db))) // ticketH := &accountTickets.AccountTicketHandlers{Svc: ticketSvc}
mux.HandleFunc("/account/notifications", middleware.Protected(handlers.NotificationsHandler(db)))
mux.HandleFunc("/account/notifications/read", middleware.Protected(handlers.MarkNotificationReadHandler(db))) // Public account pages
acc := r.Group("/account")
acc.Use(middleware.PublicOnly())
{
acc.GET("/login", accountHandler.LoginGet)
acc.POST("/login", accountHandler.LoginPost)
acc.GET("/signup", accountHandler.SignupGet)
acc.POST("/signup", accountHandler.SignupPost)
}
// Auth-required account actions
accAuth := r.Group("/account")
accAuth.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{
accAuth.POST("/logout", accountHandler.Logout)
accAuth.GET("/logout", accountHandler.Logout) // optional
}
// Messages (auth-required)
messages := r.Group("/account/messages")
messages.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{
messages.GET("/", msgH.List)
messages.GET("/read", msgH.ReadGet)
messages.GET("/send", msgH.SendGet)
messages.POST("/send", msgH.SendPost)
messages.GET("/archive", msgH.ArchivedList) // view archived messages
messages.POST("/archive", msgH.ArchivePost) // archive a message
messages.POST("/restore", msgH.RestoreArchived)
messages.POST("/mark-read", msgH.MarkReadPost)
}
// Notifications (auth-required)
notifications := r.Group("/account/notifications")
notifications.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{
notifications.GET("/", notifH.List)
notifications.GET("/:id", notifH.ReadGet) // renders read.html
}
// Tickets (auth-required)
tickets := r.Group("/account/tickets")
tickets.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{
tickets.GET("/", accountTicketHandler.List) // GET /account/tickets
tickets.GET("/add", accountTicketHandler.AddGet) // GET /account/tickets/add
tickets.POST("/add", accountTicketHandler.AddPost) // POST /account/tickets/add
}
} }

View File

@@ -1,27 +1,38 @@
package routes package routes
import ( import (
"database/sql"
"net/http"
admin "synlotto-website/internal/handlers/admin" admin "synlotto-website/internal/handlers/admin"
"synlotto-website/internal/http/middleware" "synlotto-website/internal/http/middleware"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
) )
func SetupAdminRoutes(mux *http.ServeMux, db *sql.DB) { func RegisterAdminRoutes(app *bootstrap.App) {
mux.HandleFunc("/admin/access", middleware.Protected(admin.AdminAccessLogHandler(db))) r := app.Router
mux.HandleFunc("/admin/audit", middleware.Protected(admin.AuditLogHandler(db)))
mux.HandleFunc("/admin/dashboard", middleware.Protected(admin.AdminDashboardHandler(db))) adminGroup := r.Group("/admin")
mux.HandleFunc("/admin/triggers", middleware.Protected(admin.AdminTriggersHandler(db))) 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 // Draw management
mux.HandleFunc("/admin/draws", middleware.Protected(admin.ListDrawsHandler(db))) adminGroup.GET("/draws", gin.WrapH(admin.ListDrawsHandler(app.DB)))
// mux.HandleFunc("/admin/draws/new", middleware.AdminOnly(db, admin.RenderNewDrawForm(db))) // adminGroup.GET("/draws/new", gin.WrapH(admin.RenderNewDrawForm(app.DB))) // if/when you re-enable AdminOnly
// mux.HandleFunc("/admin/draws/submit", middleware.AdminOnly(db, admin.CreateDrawHandler(db))) // adminGroup.POST("/draws", gin.WrapH(admin.CreateDrawHandler(app.DB))) // example submit route
mux.HandleFunc("/admin/draws/modify", middleware.Protected(admin.ModifyDrawHandler(db))) adminGroup.POST("/draws/modify", gin.WrapH(admin.ModifyDrawHandler(app.DB)))
mux.HandleFunc("/admin/draws/delete", middleware.Protected(admin.DeleteDrawHandler(db))) adminGroup.POST("/draws/delete", gin.WrapH(admin.DeleteDrawHandler(app.DB)))
// Prize management // Prize management
mux.HandleFunc("/admin/draws/prizes/add", middleware.Protected(admin.AddPrizesHandler(db))) adminGroup.POST("/draws/prizes/add", gin.WrapH(admin.AddPrizesHandler(app.DB)))
mux.HandleFunc("/admin/draws/prizes/modify", middleware.Protected(admin.ModifyPrizesHandler(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 package routes
import ( import (
"database/sql" stats "synlotto-website/internal/handlers/statistics"
"net/http"
handlers "synlotto-website/internal/handlers/statistics"
"synlotto-website/internal/http/middleware" "synlotto-website/internal/http/middleware"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
) )
func SetupStatisticsRoutes(mux *http.ServeMux, db *sql.DB) { // RegisterStatisticsRoutes mounts protected statistics endpoints under /statistics.
mux.HandleFunc("/statistics/thunderball", middleware.Auth(true)(handlers.StatisticsThunderball(db))) 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 package routes
import ( import (
"database/sql" s "synlotto-website/internal/handlers/lottery/syndicate"
"net/http"
lotterySyndicateHandlers "synlotto-website/internal/handlers/lottery/syndicate"
"synlotto-website/internal/http/middleware" "synlotto-website/internal/http/middleware"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
) )
func SetupSyndicateRoutes(mux *http.ServeMux, db *sql.DB) { // RegisterSyndicateRoutes mounts all /syndicate routes.
mux.HandleFunc("/syndicate", middleware.Auth(true)(lotterySyndicateHandlers.ListSyndicatesHandler(db))) // Protection is enforced at the group level via AuthMiddleware + RequireAuth.
mux.HandleFunc("/syndicate/create", middleware.Auth(true)(lotterySyndicateHandlers.CreateSyndicateHandler(db))) func RegisterSyndicateRoutes(app *bootstrap.App) {
mux.HandleFunc("/syndicate/view", middleware.Auth(true)(lotterySyndicateHandlers.ViewSyndicateHandler(db))) r := app.Router
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)))
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" "encoding/json"
"log" "log"
"synlotto-website/internal/models" "synlotto-website/internal/platform/config"
) )
func LogConfig(config *models.Config) { func LogConfig(config *config.Config) {
safeConfig := *config safeConfig := *config
safeConfig.CSRF.CSRFKey = "[REDACTED]"
safeConfig.Session.AuthKeyPath = "[REDACTED]"
safeConfig.Session.EncryptionKeyPath = "[REDACTED]"
cfg, err := json.MarshalIndent(safeConfig, "", " ") cfg, err := json.MarshalIndent(safeConfig, "", " ")
if err != nil { if err != nil {

View File

@@ -1,40 +0,0 @@
package models
type Config struct {
CSRF struct {
CSRFKey string `json:"csrfKey"`
} `json:"csrf"`
Database struct {
Server string `json:"server"`
Port int `json:"port"`
DatabaseNamed string `json:"databaseName"`
MaxOpenConnections int `json:"maxOpenConnections"`
MaxIdleConnections int `json:"maxIdleConnections"`
ConnectionMaxLifetime string `json:"connectionMaxLifetime"`
Username string `json:"username"`
Password string `json:"password"`
}
HttpServer struct {
Port int `json:"port"`
Address string `json:"address"`
ProductionMode bool `json:"productionMode"`
} `json:"httpServer"`
License struct {
APIURL string `json:"apiUrl"`
APIKey string `json:"apiKey"`
} `json:"license"`
Session struct {
AuthKeyPath string `json:"authKeyPath"`
EncryptionKeyPath string `json:"encryptionKeyPath"`
Name string `json:"name"`
} `json:"session"`
Site struct {
SiteName string `json:"siteName"`
CopyrightYearStart int `json:"copyrightYearStart"`
} `json:"site"`
}

View File

@@ -0,0 +1,17 @@
package models
import (
"time"
)
type Message struct {
ID int
SenderId int
RecipientId int
Subject string
Body string
IsRead bool
IsArchived bool
CreatedAt time.Time
ArchivedAt *time.Time
}

View File

@@ -0,0 +1,12 @@
package models
import "time"
type Notification struct {
ID int
UserId int
Title string
Body string
IsRead bool
CreatedAt time.Time
}

View File

@@ -1,29 +1,50 @@
// Package models
// Path: internal/models/
// File: ticket.go
//
// Purpose
// Canonical persistence model for tickets as stored in DB,
// plus display helpers populated at read time.
//
// Responsibilities
// - Represents input values for ticket creation
// - Stores normalized draw fields for comparison
// - Optional fields (bonus, syndicate) use pointer types
//
// Notes
// - Read-only display fields must not be persisted directly
// - TODO: enforce UserID presence once per-user tickets are fully enabled
package models package models
import "time"
type Ticket struct { type Ticket struct {
Id int Id int // Persistent DB primary key
UserId int UserId int // FK to users(id) when multi-user enabled
SyndicateId *int SyndicateId *int // Optional FK if purchased via syndicate
GameType string GameType string // Lottery type (e.g., Lotto)
DrawDate string DrawDate time.Time // Stored as UTC datetime to avoid timezone issues
Ball1 int Ball1 int
Ball2 int Ball2 int
Ball3 int Ball3 int
Ball4 int Ball4 int
Ball5 int Ball5 int
Ball6 int Ball6 int // Only if game type requires
// Optional bonus balls
Bonus1 *int Bonus1 *int
Bonus2 *int Bonus2 *int
PurchaseMethod string PurchaseMethod string
PurchaseDate string PurchaseDate string // TODO: convert to time.Time
ImagePath string ImagePath string
Duplicate bool Duplicate bool // Calculated during insert
MatchedMain int MatchedMain int
MatchedBonus int MatchedBonus int
PrizeTier string PrizeTier string
IsWinner bool IsWinner bool
// Used only for display these are not stored in the DB, they mirror MatchTicket structure but are populated on read. // Non-DB display helpers populated in read model
Balls []int Balls []int
BonusBalls []int BonusBalls []int
MatchedDraw DrawResult MatchedDraw DrawResult

View File

@@ -5,30 +5,11 @@ import (
) )
type User struct { type User struct {
Id int Id int64
Username string Username string
Email string
PasswordHash string PasswordHash string
IsAdmin bool IsAdmin bool
} CreatedAt time.Time
UpdatedAt time.Time
// ToDo: should be in a notification model?
type Notification struct {
ID int
UserId int
Subject string
Body string
IsRead bool
CreatedAt time.Time
}
// ToDo: should be in a message model?
type Message struct {
ID int
SenderId int
RecipientId int
Subject string
Message string
IsRead bool
CreatedAt time.Time
ArchivedAt *time.Time
} }

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" "time"
internal "synlotto-website/internal/licensecheck" internal "synlotto-website/internal/licensecheck"
"synlotto-website/internal/models" "synlotto-website/internal/platform/config"
) )
var globalChecker *internal.LicenseChecker var globalChecker *internal.LicenseChecker
func InitLicenseChecker(config *models.Config) error { func InitLicenseChecker(config *config.Config) error {
checker := &internal.LicenseChecker{ checker := &internal.LicenseChecker{
LicenseAPIURL: config.License.APIURL, LicenseAPIURL: config.License.APIURL,
APIKey: config.License.APIKey, APIKey: config.License.APIKey,

View File

@@ -1,30 +1,206 @@
// Package bootstrap
// Path: /internal/platform/bootstrap
// File: loader.go
//
// Purpose
// Centralized application initializer (the “application kernel”).
// Constructs and wires the core runtime graph used by the system:
// configuration, database, schema bootstrap, session manager (SCS), router (Gin),
// CSRF wrapper (nosurf), and the HTTP server.
//
// Responsibilities (as implemented here)
// 1) Load strongly-typed configuration from disk (config.Load).
// 2) Open MySQL with pool tuning and DSN options (parseTime, utf8mb4, UTC).
// 3) Ensure initial schema on an empty DB (databasePlatform.EnsureInitialSchema).
// 4) Register gob types needed by sessions (map[string]string, []string, time.Time).
// 5) Create an SCS session manager via platform/session.New.
// 6) Build a Gin engine, attach global middleware, static mounts, and error handlers.
// 7) Inject *App into Gin context (c.Set("app", app)) for handler access.
// 8) Wrap Gin with SCS LoadAndSave, then wrap that with CSRF (nosurf).
// 9) Construct http.Server with Handler and ReadHeaderTimeout.
//
// HTTP stack order (matches code)
// Gin Router → SCS LoadAndSave → CSRF Wrapper → http.Server
//
// Design guarantees
// - Single source of truth via the App struct (Config, DB, SessionManager, Router, Handler, Server).
// - Stable middleware order: SCS wraps Gin before CSRF.
// - Gin handlers can access *App via c.MustGet("app").
// - Error surfaces are unified via custom NoRoute/NoMethod/Recovery handlers.
// - Extensible: add infra (cache/mailer/metrics) here.
//
// Operational details observed in this file
// - MySQL DSN uses: parseTime=true, charset=utf8mb4, loc=UTC.
// - Pool sizing and conn lifetime are read from config if set.
// - Schema application is idempotent and runs on startup.
// - Static files are served at /static and /favicon.ico.
// - Logging uses gin.Logger(). Recovery uses gin.CustomRecovery with weberr.Recovery.
// - ReadHeaderTimeout is set to 10s (currently hard-coded).
//
// Notes & TODOs (code-accurate)
// - Theres a second DB ping: openMySQL() pings, and Load() pings again.
// This is harmless but redundant; consider deleting one for clarity.
// - gin.Recovery() is commented out; we use CustomRecovery instead (intentional).
// - Consider moving ReadHeaderTimeout to config to match the rest of server tuning.
//
// Change log
// [2025-10-28] Document EnsureInitialSchema bootstrap, explicit gob registrations,
// clarified middleware/handler order, and server timeout behavior.
// [2025-10-24] Migrated to SCS-first wrapping and explicit App wiring.
package bootstrap package bootstrap
import ( import (
"encoding/json" "context"
"database/sql"
"encoding/gob"
"fmt" "fmt"
"os" "net/http"
"time"
"synlotto-website/internal/models" domainMsgs "synlotto-website/internal/domain/messages"
domainNotifs "synlotto-website/internal/domain/notifications"
weberr "synlotto-website/internal/http/error"
databasePlatform "synlotto-website/internal/platform/database"
messagesvc "synlotto-website/internal/platform/services/messages"
notifysvc "synlotto-website/internal/platform/services/notifications"
"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 { type App struct {
Config *models.Config Config config.Config
DB *sql.DB
SessionManager *scs.SessionManager
Router *gin.Engine
Handler http.Handler
Server *http.Server
Services struct {
Messages domainMsgs.MessageService
Notifications domainNotifs.NotificationService
}
} }
func LoadAppState(configPath string) (*AppState, error) { func Load(configPath string) (*App, error) {
file, err := os.Open(configPath) // Load configuration
cfg, err := config.Load(configPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("open config: %w", err) return nil, fmt.Errorf("load 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 &AppState{ // Open DB
Config: &config, db, err := openMySQL(cfg)
}, nil if err != nil {
return nil, err
}
// Ensure initial schema (idempotent; safe on restarts)
if err := databasePlatform.EnsureInitialSchema(db); err != nil {
return nil, fmt.Errorf("ensure schema: %w", err)
}
// Register gob types used in session values
gob.Register(map[string]string{})
gob.Register([]string{})
gob.Register(time.Time{})
// Create SCS session manager
sessions := session.New(cfg)
// Build Gin router and global middleware
router := gin.New()
router.Use(gin.Logger())
router.Static("/static", "./web/static")
router.StaticFile("/favicon.ico", "./web/static/favicon.ico")
// Assemble App prior to injecting into context
app := &App{
Config: cfg,
DB: db,
SessionManager: sessions,
Router: router,
}
app.Services.Messages = messagesvc.New(db)
app.Services.Notifications = notifysvc.New(db)
// Inject *App into Gin context for handler access
router.Use(func(c *gin.Context) {
c.Set("app", app)
c.Next()
})
// Error handling surfaces
router.NoRoute(weberr.NoRoute(app.SessionManager))
router.NoMethod(weberr.NoMethod(app.SessionManager))
router.Use(gin.CustomRecovery(weberr.Recovery(app.SessionManager)))
// Wrap: Gin → SCS → CSRF (nosurf)
handler := sessions.LoadAndSave(router)
handler = csrf.Wrap(handler, cfg)
// 9) Build HTTP server
addr := fmt.Sprintf("%s:%d", cfg.HttpServer.Address, cfg.HttpServer.Port)
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: cfg.HttpServer.ReadHeaderTimeout,
}
app.Handler = handler
app.Server = srv
return app, nil
}
func openMySQL(cfg config.Config) (*sql.DB, error) {
dbCfg := cfg.Database
// Credentials are used as-is; escaping handled by DSN rules for mysql driver.
escapedUser := dbCfg.Username
escapedPass := dbCfg.Password
// DSN opts: parseTime=true, utf8mb4, UTC location
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4,utf8&loc=UTC",
escapedUser,
escapedPass,
dbCfg.Server,
dbCfg.Port,
dbCfg.DatabaseName,
)
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("mysql open: %w", err)
}
// Pool tuning from config (optional)
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)
}
}
// Connectivity check with timeout
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

@@ -1,22 +1,54 @@
// Package config
// Path: /internal/platform/config
// File: config.go
//
// Purpose
// Provide a safe one-time initialization and global access point for
// the application's Config object, once it has been constructed during
// bootstrap.
//
// This allows other packages to retrieve configuration without needing
// dependency injection at every call site, while still preventing
// accidental mutation after init.
//
// Responsibilities (as implemented here)
// 1) Store a single *Config instance for the lifetime of the process.
// 2) Ensure Init() can only succeed once via sync.Once.
// 3) Expose Get() as a global accessor.
//
// Design notes
// - Config is written once at startup via Init() inside bootstrap.
// - Calls to Init() after the first are ignored silently.
// - Get() may return nil if called before Init() — caller must ensure
// bootstrap has completed.
//
// TODOs (from current architectural direction)
// - Evaluate replacing global access with explicit dependency injection
// in future modules for stronger compile-time guarantees.
// - Consider panicking or logging if Get() is called before Init().
// - Move non-static configuration into runtime struct(s) owned by App.
// - Ensure immutability: avoid mutating Config fields after Init().
//
// Change log
// [2025-10-28] Documentation aligned with real runtime responsibilities.
package config package config
import ( import (
"sync" "sync"
"synlotto-website/internal/models"
) )
var ( var (
appConfig *models.Config appConfig *Config
once sync.Once once sync.Once
) )
func Init(config *models.Config) { func Init(config *Config) {
once.Do(func() { once.Do(func() {
appConfig = config appConfig = config
}) })
} }
func Get() *models.Config { func Get() *Config {
return appConfig 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,57 @@
// Package config
// Path: /internal/platform/config
// File: load.go
//
// Purpose
// Responsible solely for loading strongly-typed application configuration
// from a JSON file on disk. This is the *input* stage of configuration
// lifecycle — the resulting Config is consumed by bootstrap and may be
// optionally stored globally via config.Init().
//
// Responsibilities (as implemented here)
// 1) Read configuration JSON file from a specified path.
// 2) Deserialize into the Config struct (strongly typed).
// 3) Return the populated Config value or an error.
//
// Design notes
// - Path is caller-controlled (bootstrap decides where config.json lives).
// - No defaults or validation are enforced here — errors bubble to bootstrap.
// - Pure function: no globals mutated, safe for tests and reuse.
// - Load returns a **value**, not a pointer, avoiding accidental mutation
// unless caller explicitly stores it.
//
// TODOs (from current architecture direction)
// - Add schema validation for required config fields.
// - Add environment override support for deployment flexibility.
// - Consider merging with a future layered config system (file + env + flags).
// - Emit structured errors including path details for troubleshooting.
//
// Change log
// [2025-10-29] Documentation aligned with bootstrap integration and config.Init() use.
package config
import (
"encoding/json"
"os"
)
// Load reads the JSON configuration file located at `path`
// and unmarshals it into a Config struct.
//
// Caller is responsible for passing the result into bootstrap and/or
// config.Init() to make it globally available.
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

@@ -0,0 +1,83 @@
// Package config
// Path: /internal/platform/config
// File: types.go
//
// Purpose
// Defines the strongly-typed configuration structure for the entire system.
// Populated from JSON via config.Load() and stored in bootstrap for use by:
// - MySQL connectivity + pooling
// - HTTP server binding + security mode
// - SCS session configuration
// - CSRF cookie policy
// - External licensing API configuration
// - Template meta (site-wide branding)
//
// Design notes
// - Nested struct fields map directly to JSON blocks.
// - Types are primarily string-based for durations (parsed by bootstrap).
// - Field names reflect actual usage in the code today.
// - All configuration values are held immutable after bootstrap.Load.
//
// TODOs (observations from current design)
// - Add `json:"database"` tag to Database struct for JSON consistency
// (currently missing; loader still works due to exported field name fallback).
// - Validate required fields at bootstrap (server/port/site name).
// - Move sensitive fields (password/API key) to env-driven overrides.
// - Convert duration strings to `time.Duration` when loading (type-safe).
//
// Change log
// [2025-10-29] Documentation created to align with full settings usage.
package config
import "time"
// Config represents all runtime configuration for the application.
// Loaded from JSON and passed into bootstrap for wiring platform components.
type Config struct {
// CSRF cookie naming and storage controls
CSRF struct {
CookieName string `json:"cookieName"`
} `json:"csrf"`
// Database connection settings + tuning
Database struct {
Server string `json:"server"`
Port int `json:"port"`
DatabaseName string `json:"databaseName"`
MaxOpenConnections int `json:"maxOpenConnections"` // optional tuning
MaxIdleConnections int `json:"maxIdleConnections"` // optional tuning
ConnectionMaxLifetime string `json:"connectionMaxLifetime"` // duration as string, parsed in bootstrap
Username string `json:"username"`
Password string `json:"password"` // sensitive; consider environment secrets
}
// HTTP server exposure and security toggles
HttpServer struct {
Port int `json:"port"`
Address string `json:"address"`
ProductionMode bool `json:"productionMode"` // controls Secure cookie flag
ReadHeaderTimeout time.Duration `json:"readHeaderTimeout"` // config in nanoseconds
} `json:"httpServer"`
// Remote licensing API service configuration
License struct {
APIURL string `json:"apiUrl"`
APIKey string `json:"apiKey"` // sensitive; consider environment secrets
} `json:"license"`
// Session (SCS) configuration: cookie names, lifetime + idle timeout
Session struct {
CookieName string `json:"cookieName"`
Lifetime string `json:"lifetime"` // duration as string; parsed in platform/session
IdleTimeout string `json:"idleTimeout"` // duration as string; parsed in platform/session
RememberCookieName string `json:"rememberCookieName"`
RememberDuration string `json:"rememberDuration"` // duration as string; parsed in Remember middleware
} `json:"session"`
// Site metadata provided to templates (branding/UI only)
Site struct {
SiteName string `json:"siteName"`
CopyrightYearStart int `json:"copyrightYearStart"`
} `json:"site"`
}

View File

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

View File

@@ -0,0 +1,63 @@
// Package csrf
// Path: /internal/platform/csrf
// File: csrf.go
//
// Purpose
//
// Centralized CSRF protection wrapper using justinas/nosurf.
// Applies default CSRF protections across the entire HTTP handler tree
// after SCS session load/save wrapping.
//
// Responsibilities (as implemented here)
// 1. Construct a nosurf middleware handler over the provided http.Handler.
// 2. Configure the base CSRF cookie using values from the App configuration.
// 3. Enforce HttpOnly and SameSite=Lax defaults.
// 4. Enable Secure flag automatically in production mode.
//
// HTTP stack order (per bootstrap)
//
// Gin Router → SCS LoadAndSave → CSRF Wrapper → http.Server
//
// Design notes
// - The nosurf package automatically:
// - Inserts CSRF token into responses (e.g., via nosurf.Token(c.Request))
// - Validates token on state-changing requests (POST, PUT, etc.)
// - CSRF cookie name is configurable via config.Config.
// - Secure flag is tied to cfg.HttpServer.ProductionMode (recommended).
// - Global protection: all routed POSTs are covered automatically.
//
// TODOs (observations from current implementation)
// - Expose helper to fetch token into Gin templates via context key.
// - Consider SameSiteStrictMode once OAuth/external logins are defined.
// - Add domain and MaxAge settings for more precise control.
// - Provide per-route opt-outs if needed for webhook endpoints.
//
// Change log
//
// [2025-10-29] Documentation updated to reflect middleware position and cookie policy.
package csrf
import (
"net/http"
"synlotto-website/internal/platform/config"
"github.com/justinas/nosurf"
)
// Wrap applies nosurf CSRF middleware to the given handler,
// configuring the CSRF cookie based on App configuration.
//
// Caller must ensure this is positioned *outside* SCS LoadAndSave
// so CSRF can access session data when generating/validating tokens.
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,75 @@
// Package databasePlatform
// Path: /internal/platform/database
// File: schema.go
//
// Purpose
// Bootstrap and verify the initial application schema for MySQL using
// embedded SQL. Applies the full schema only when the target database
// is detected as "empty" via a probe query.
//
// Responsibilities (as implemented here)
// 1) Detect whether the schema has been initialized by probing the users table.
// 2) If empty, apply the embedded initial schema inside a single transaction.
// 3) Use helper ExecScript to execute multi-statement SQL safely.
// 4) Fail fast with contextual errors on probe/apply failures.
//
// Idempotency strategy
// - Uses migrationSQL.ProbeUsersTable to query a known table and count rows.
// - If count > 0, assumes schema exists and exits without applying SQL.
// - This makes startup safe to repeat across restarts.
//
// Design notes
// - InitialSchema is embedded (no external SQL files at runtime).
// - Application is all-or-nothing via a single transaction.
// - Console prints currently provide debug visibility during boot.
// - Probe focuses on the "users" table as the presence indicator.
//
// TODOs (observations from current implementation)
// - Replace debug prints with structured logging (levelled).
// - Consider probing for table existence rather than row count to avoid
// the edge case where users table exists but has zero rows.
// - Introduce a schema version table for forward migrations.
// - Expand error context to include which statement failed in ExecScript.
//
// Change log
// [2025-10-29] Documentation aligned with embedded migrations and probe logic.
package databasePlatform
import (
"database/sql"
"fmt"
databaseHelpers "synlotto-website/internal/helpers/database"
migrationSQL "synlotto-website/internal/storage/migrations"
)
// EnsureInitialSchema ensures the database contains the baseline schema.
// If the probe indicates an existing install, the function is a no-op.
func EnsureInitialSchema(db *sql.DB) error {
fmt.Println("✅ EnsureInitialSchema called") // temp debug
// Probe: if users table exists & has rows, treat schema as present.
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: visibility for embedded SQL payload size.
fmt.Printf("📦 Initial SQL bytes: %d\n", len(migrationSQL.InitialSchema)) // temp debug
// Apply full schema atomically.
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,301 @@
// Package messagesvc
// Path: /internal/platform/services/messages
// File: service.go
package messagesvc
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"synlotto-website/internal/logging"
domain "synlotto-website/internal/domain/messages"
"github.com/go-sql-driver/mysql"
)
// Service implements domain.Service.
type Service struct {
DB *sql.DB
Dialect string // "postgres", "mysql", "sqlite"
Now func() time.Time
Timeout time.Duration
}
func New(db *sql.DB, opts ...func(*Service)) *Service {
s := &Service{
DB: db,
Dialect: "mysql", // default; works with LastInsertId
Now: time.Now,
Timeout: 5 * time.Second,
}
for _, opt := range opts {
opt(s)
}
return s
}
// Ensure *Service satisfies the domain interface.
var _ domain.MessageService = (*Service)(nil)
func (s *Service) ListInbox(userID int64) ([]domain.Message, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
q := `
SELECT id, senderId, recipientId, subject, body, is_read, is_archived, created_at
FROM user_messages
WHERE recipientId = ? AND is_archived = FALSE
ORDER BY created_at DESC`
q = s.bind(q)
rows, err := s.DB.QueryContext(ctx, q, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.Message
for rows.Next() {
var m domain.Message
if err := rows.Scan(&m.ID, &m.SenderId, &m.RecipientId, &m.Subject, &m.Body, &m.IsRead, &m.IsArchived, &m.CreatedAt); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *Service) ListArchived(userID int64) ([]domain.Message, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
q := `
SELECT id, senderId, recipientId, subject, body,
is_read, is_archived, created_at, archived_at
FROM user_messages
WHERE recipientId = ? AND is_archived = TRUE
ORDER BY created_at DESC`
q = s.bind(q)
rows, err := s.DB.QueryContext(ctx, q, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.Message
for rows.Next() {
var m domain.Message
var archived sql.NullTime
if err := rows.Scan(
&m.ID,
&m.SenderId,
&m.RecipientId,
&m.Subject,
&m.Body,
&m.IsRead,
&m.IsArchived,
&m.CreatedAt,
&archived,
); err != nil {
return nil, err
}
if archived.Valid {
t := archived.Time
m.ArchivedAt = &t
} else {
m.ArchivedAt = nil
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *Service) GetByID(userID, id int64) (*domain.Message, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
q := `
SELECT id, senderId, recipientId, subject, body, is_read, is_archived, created_at
FROM user_messages
WHERE recipientId = ? AND id = ?`
q = s.bind(q)
var m domain.Message
err := s.DB.QueryRowContext(ctx, q, userID, id).
Scan(&m.ID, &m.SenderId, &m.RecipientId, &m.Subject, &m.Body, &m.IsRead, &m.IsArchived, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
func (s *Service) Create(senderID int64, in domain.CreateMessageInput) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
// ✅ make sure this matches your current table/column names
const q = `
INSERT INTO user_messages
(senderId, recipientId, subject, body, is_read, is_archived, created_at)
VALUES
(?, ?, ?, ?, 0, 0, CURRENT_TIMESTAMP)
`
// 👀 Log the SQL and arguments (truncate body in logs if you prefer)
logging.Info("🧪 SQL Exec: %s | args: senderId=%d recipientId=%d subject=%q body_len=%d", compactSQL(q), senderID, in.RecipientID, in.Subject, len(in.Body))
res, err := s.DB.ExecContext(ctx, q, senderID, in.RecipientID, in.Subject, in.Body)
if err != nil {
// Surface MySQL code/message (very helpful for FK #1452 etc.)
var me *mysql.MySQLError
if errors.As(err, &me) {
wrapped := fmt.Errorf("insert user_messages: mysql #%d %s | args(senderId=%d, recipientId=%d, subject=%q, body_len=%d)",
me.Number, me.Message, senderID, in.RecipientID, in.Subject, len(in.Body))
logging.Info("❌ %v", wrapped)
return 0, wrapped
}
wrapped := fmt.Errorf("insert user_messages: %w | args(senderId=%d, recipientId=%d, subject=%q, body_len=%d)",
err, senderID, in.RecipientID, in.Subject, len(in.Body))
logging.Info("❌ %v", wrapped)
return 0, wrapped
}
id, err := res.LastInsertId()
if err != nil {
wrapped := fmt.Errorf("lastInsertId user_messages: %w", err)
logging.Info("❌ %v", wrapped)
return 0, wrapped
}
logging.Info("✅ Inserted message id=%d", id)
return id, nil
}
func compactSQL(s string) string {
out := make([]rune, 0, len(s))
space := false
for _, r := range s {
if r == '\n' || r == '\t' || r == '\r' || r == ' ' {
if !space {
out = append(out, ' ')
space = true
}
continue
}
space = false
out = append(out, r)
}
return string(out)
}
func (s *Service) bind(q string) string {
if s.Dialect != "postgres" {
return q
}
n := 0
out := make([]byte, 0, len(q)+8)
for i := 0; i < len(q); i++ {
if q[i] == '?' {
n++
out = append(out, '$')
out = append(out, []byte(intToStr(n))...)
continue
}
out = append(out, q[i])
}
return string(out)
}
func (s *Service) Archive(userID, id int64) error {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
q := `
UPDATE user_messages
SET is_archived = 1, archived_at = CURRENT_TIMESTAMP
WHERE id = ? AND recipientId = ?
`
q = s.bind(q)
res, err := s.DB.ExecContext(ctx, q, id, userID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func intToStr(n int) string {
if n == 0 {
return "0"
}
var b [12]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
func (s *Service) Unarchive(userID, id int64) error {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
q := `
UPDATE user_messages
SET is_archived = 0, archived_at = NULL
WHERE id = ? AND recipientId = ?
`
q = s.bind(q)
res, err := s.DB.ExecContext(ctx, q, id, userID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *Service) MarkRead(userID, id int64) error {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
q := `
UPDATE user_messages
SET is_read = 1
WHERE id = ? AND recipientId = ?
`
q = s.bind(q)
res, err := s.DB.ExecContext(ctx, q, id, userID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}

View File

@@ -0,0 +1,85 @@
// Package notifysvc
// Path: /internal/platform/services/notifications
// File: service.go
// ToDo: carve out sql
package notifysvc
import (
"context"
"database/sql"
"errors"
"time"
domain "synlotto-website/internal/domain/notifications"
)
type Service struct {
DB *sql.DB
Now func() time.Time
Timeout time.Duration
}
func New(db *sql.DB, opts ...func(*Service)) *Service {
s := &Service{
DB: db,
Now: time.Now,
Timeout: 5 * time.Second,
}
for _, opt := range opts {
opt(s)
}
return s
}
func WithTimeout(d time.Duration) func(*Service) { return func(s *Service) { s.Timeout = d } }
// List returns newest-first notifications for a user.
// ToDo:table is users_notification, where as messages is plural, this table seems oto use user_id reather than userId need to unify. Do i want to prefix with users/user
func (s *Service) List(userID int64) ([]domain.Notification, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
const q = `
SELECT id, title, body, is_read, created_at
FROM users_notification
WHERE user_Id = ?
ORDER BY created_at DESC`
rows, err := s.DB.QueryContext(ctx, q, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.Notification
for rows.Next() {
var n domain.Notification
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.IsRead, &n.CreatedAt); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
func (s *Service) GetByID(userID, id int64) (*domain.Notification, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
const q = `
SELECT id, title, body, is_read, created_at
FROM notifications
WHERE userId = ? AND id = ?`
var n domain.Notification
err := s.DB.QueryRowContext(ctx, q, userID, id).
Scan(&n.ID, &n.Title, &n.Body, &n.IsRead, &n.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &n, nil
}

View File

@@ -0,0 +1,67 @@
// Package session
// Path: /internal/platform/session
// File: session.go
//
// Purpose
// Initialize and configure the SCS (Server-Side Sessions) session manager
// based on application configuration. Controls session lifetime, idle timeout,
// cookie policy, and security posture.
//
// Responsibilities (as implemented here)
// 1) Create SCS session manager used globally via bootstrap.
// 2) Parse session lifetime + idle timeout from configuration.
// 3) Apply secure cookie settings (HttpOnly, SameSite, Secure if production).
// 4) Provide sensible defaults if configuration is invalid.
//
// Design notes
// - SCS stores session data server-side (DB, file, mem, etc. — backend not set here).
// - Cookie lifespan is enforced server-side (not just client expiry).
// - Secure flag toggled via cfg.HttpServer.ProductionMode.
// - Defaults keep application functional even if config is incomplete.
//
// TODOs (observations from current implementation)
// - Add structured validation + error logging for invalid duration strings.
// - Move secure cookie flag to config for more granular environment control.
// - Consider enabling:
// • Cookie.Persist (for "keep me logged in" flows)
// • Cookie.SameSite = StrictMode by default
// - Potentially expose SCS store configuration here (DB-backed sessions).
//
// Change log
// [2025-10-29] Documentation aligned with final session architecture.
package session
import (
"net/http"
"time"
"synlotto-website/internal/platform/config"
"github.com/alexedwards/scs/v2"
)
// New constructs a new SCS SessionManager using values from Config,
// falling back to secure defaults if configuration is missing/invalid.
func New(cfg config.Config) *scs.SessionManager {
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,9 @@
package sessionkeys
const (
UserID = "user_id"
Username = "username"
IsAdmin = "is_admin"
LastActivity = "last_activity"
Flash = "flash"
)

View File

@@ -1,5 +1,6 @@
package services package services
// ToDo: these aren't really "services"
import ( import (
"database/sql" "database/sql"
"log" "log"

View File

@@ -5,14 +5,15 @@ import (
"fmt" "fmt"
"log" "log"
lotteryTicketHandlers "synlotto-website/internal/handlers/lottery/tickets"
thunderballrules "synlotto-website/internal/rules/thunderball" 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/helpers"
"synlotto-website/internal/models" "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) { func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, error) {
stats := models.MatchRunStats{} stats := models.MatchRunStats{}
@@ -29,11 +30,13 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er
defer rows.Close() defer rows.Close()
var pending []models.Ticket var pending []models.Ticket
for rows.Next() { for rows.Next() {
var t models.Ticket var t models.Ticket
var drawDateStr string
if dt, err := helpers.ParseDrawDate(drawDateStr); err == nil {
t.DrawDate = dt
}
var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64 var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64
if err := rows.Scan( if err := rows.Scan(
&t.Id, &t.GameType, &t.DrawDate, &t.Id, &t.GameType, &t.DrawDate,
&b1, &b2, &b3, &b4, &b5, &b6, &b1, &b2, &b3, &b4, &b5, &b6,
@@ -41,7 +44,6 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er
); err != nil { ); err != nil {
continue continue
} }
t.Ball1 = int(b1.Int64) t.Ball1 = int(b1.Int64)
t.Ball2 = int(b2.Int64) t.Ball2 = int(b2.Int64)
t.Ball3 = int(b3.Int64) t.Ball3 = int(b3.Int64)
@@ -56,32 +58,32 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er
for _, t := range pending { for _, t := range pending {
matchTicket := models.MatchTicket{ matchTicket := models.MatchTicket{
ID: t.Id,
GameType: t.GameType,
DrawDate: t.DrawDate,
Balls: helpers.BuildBallsSlice(t), Balls: helpers.BuildBallsSlice(t),
BonusBalls: helpers.BuildBonusSlice(t), BonusBalls: helpers.BuildBonusSlice(t),
} }
draw := services.GetDrawResultForTicket(db, t.GameType, t.DrawDate) draw := drawsSvc.GetDrawResultForTicket(db, t.GameType, helpers.FormatDrawDate(t.DrawDate))
result := lotteryTicketHandlers.MatchTicketToDraw(matchTicket, draw, thunderballrules.ThunderballPrizeRules) if draw.DrawID == 0 {
// No draw yet → skip
if result.MatchedDrawID == 0 {
continue 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 UPDATE my_tickets
SET matched_main = ?, matched_bonus = ?, prize_tier = ?, is_winner = ? SET matched_main = ?, matched_bonus = ?, prize_tier = ?, is_winner = ?
WHERE id = ? WHERE id = ?
`, result.MatchedMain, result.MatchedBonus, result.PrizeTier, result.IsWinner, t.Id) `, mainMatches, bonusMatches, prizeTier, isWinner, t.Id); err != nil {
if err != nil {
log.Println("⚠️ Failed to update ticket match:", err) log.Println("⚠️ Failed to update ticket match:", err)
continue continue
} }
stats.TicketsMatched++ stats.TicketsMatched++
if result.IsWinner { if isWinner {
stats.WinnersFound++ stats.WinnersFound++
} }
} }
@@ -94,6 +96,7 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er
return stats, nil return stats, nil
} }
// UpdateMissingPrizes fills in prize labels/amounts for already-matched winners that lack labels.
func UpdateMissingPrizes(db *sql.DB) error { func UpdateMissingPrizes(db *sql.DB) error {
type TicketInfo struct { type TicketInfo struct {
ID int ID int
@@ -138,8 +141,7 @@ func UpdateMissingPrizes(db *sql.DB) error {
query := fmt.Sprintf(`SELECT prize%d_per_winner FROM prizes_thunderball WHERE draw_date = ?`, idx) query := fmt.Sprintf(`SELECT prize%d_per_winner FROM prizes_thunderball WHERE draw_date = ?`, idx)
var amount int var amount int
err := db.QueryRow(query, t.DrawDate).Scan(&amount) if err := db.QueryRow(query, t.DrawDate).Scan(&amount); err != nil {
if err != nil {
log.Printf("❌ Prize lookup failed for ticket %d: %v", t.ID, err) log.Printf("❌ Prize lookup failed for ticket %d: %v", t.ID, err)
continue continue
} }
@@ -149,11 +151,9 @@ func UpdateMissingPrizes(db *sql.DB) error {
label = fmt.Sprintf("£%.2f", float64(amount)) label = fmt.Sprintf("£%.2f", float64(amount))
} }
_, err = db.Exec(` if _, err := db.Exec(`
UPDATE my_tickets SET prize_amount = ?, prize_label = ? WHERE id = ? UPDATE my_tickets SET prize_amount = ?, prize_label = ? WHERE id = ?
`, float64(amount), label, t.ID) `, float64(amount), label, t.ID); err != nil {
if err != nil {
log.Printf("❌ Failed to update ticket %d: %v", t.ID, err) log.Printf("❌ Failed to update ticket %d: %v", t.ID, err)
} else { } else {
log.Printf("✅ Updated ticket %d → %s", t.ID, label) log.Printf("✅ Updated ticket %d → %s", t.ID, label)
@@ -163,6 +163,7 @@ func UpdateMissingPrizes(db *sql.DB) error {
return nil return nil
} }
// RefreshTicketPrizes recomputes and writes prize info for all tickets.
func RefreshTicketPrizes(db *sql.DB) error { func RefreshTicketPrizes(db *sql.DB) error {
type TicketRow struct { type TicketRow struct {
ID int ID int
@@ -198,13 +199,11 @@ func RefreshTicketPrizes(db *sql.DB) error {
for _, row := range tickets { for _, row := range tickets {
matchTicket := models.MatchTicket{ matchTicket := models.MatchTicket{
GameType: row.GameType,
DrawDate: row.DrawDate,
Balls: helpers.BuildBallsFromNulls(row.B1, row.B2, row.B3, row.B4, row.B5, row.B6), Balls: helpers.BuildBallsFromNulls(row.B1, row.B2, row.B3, row.B4, row.B5, row.B6),
BonusBalls: helpers.BuildBonusFromNulls(row.Bonus1, row.Bonus2), 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 { if draw.DrawID == 0 {
log.Printf("❌ No draw result for %s (%s)", row.DrawDate, row.GameType) log.Printf("❌ No draw result for %s (%s)", row.DrawDate, row.GameType)
continue continue
@@ -212,18 +211,16 @@ func RefreshTicketPrizes(db *sql.DB) error {
mainMatches := helpers.CountMatches(matchTicket.Balls, draw.Balls) mainMatches := helpers.CountMatches(matchTicket.Balls, draw.Balls)
bonusMatches := helpers.CountMatches(matchTicket.BonusBalls, draw.BonusBalls) 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 != "" isWinner := prizeTier != ""
var label string var label string
var amount float64 var amount float64
if row.GameType == "Thunderball" { if row.GameType == "Thunderball" {
idx, ok := thunderballrules.GetThunderballPrizeIndex(mainMatches, bonusMatches) if idx, ok := thunderballrules.GetThunderballPrizeIndex(mainMatches, bonusMatches); ok {
if ok {
query := fmt.Sprintf(`SELECT prize%d_per_winner FROM prizes_thunderball WHERE draw_date = ?`, idx) query := fmt.Sprintf(`SELECT prize%d_per_winner FROM prizes_thunderball WHERE draw_date = ?`, idx)
var val int var val int
err := db.QueryRow(query, row.DrawDate).Scan(&val) if err := db.QueryRow(query, row.DrawDate).Scan(&val); err == nil {
if err == nil {
amount = float64(val) amount = float64(val)
if val > 0 { if val > 0 {
label = fmt.Sprintf("£%.2f", amount) label = fmt.Sprintf("£%.2f", amount)
@@ -242,15 +239,15 @@ func RefreshTicketPrizes(db *sql.DB) error {
SET matched_main = ?, matched_bonus = ?, prize_tier = ?, is_winner = ?, prize_amount = ?, prize_label = ? SET matched_main = ?, matched_bonus = ?, prize_tier = ?, is_winner = ?, prize_amount = ?, prize_label = ?
WHERE id = ? WHERE id = ?
`, mainMatches, bonusMatches, prizeTier, isWinner, amount, label, row.ID) `, mainMatches, bonusMatches, prizeTier, isWinner, amount, label, row.ID)
if err != nil { if err != nil {
log.Printf("❌ Failed to update ticket %d: %v", row.ID, err) log.Printf("❌ Failed to update ticket %d: %v", row.ID, err)
continue 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", log.Printf("✅ Ticket %d updated — rows affected: %d | Tier: %s | Label: %s | Matches: %d+%d",
row.ID, rowsAffected, prizeTier, label, mainMatches, bonusMatches) row.ID, rowsAffected, prizeTier, label, mainMatches, bonusMatches)
}
} }
return nil return nil

View File

@@ -0,0 +1,76 @@
package auditlogStorage
import (
"context"
"database/sql"
"time"
"synlotto-website/internal/logging"
)
const insertAdminAccessSQL = `
INSERT INTO admin_access_log
(user_id, path, ip, user_agent, accessed_at)
VALUES (?, ?, ?, ?, ?)
`
const insertLoginSQL = `
INSERT INTO audit_login
(user_id, username, success, ip, user_agent, timestamp)
VALUES (?, ?, ?, ?, ?, ?)
`
const insertRegistrationSQL = `
INSERT INTO audit_registration
(user_id, username, email, ip, user_agent, timestamp)
VALUES (?, ?, ?, ?, ?, ?)
`
// LogLoginAttempt stores a login attempt. Pass userID if known; otherwise it's NULL.
func LogLoginAttempt(db *sql.DB, ip, userAgent, username string, success bool, userID ...int64) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var uid sql.NullInt64
if len(userID) > 0 {
uid.Valid = true
uid.Int64 = userID[0]
}
_, err := db.ExecContext(ctx, insertLoginSQL,
uid,
username,
success,
ip,
userAgent,
time.Now().UTC(),
)
if err != nil {
logging.Info("❌ Failed to log login: %v", err)
}
}
// LogSignup stores a registration event.
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)
}
}
// LogAdminAccess stores an admin access record.
func LogAdminAccess(db *sql.DB, userID int64, path, ip, userAgent string, at time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := db.ExecContext(ctx, insertAdminAccessSQL,
userID, path, ip, userAgent, at,
)
if err != nil {
logging.Info("❌ Failed to log admin access: %v", err)
}
}

View File

@@ -4,10 +4,10 @@ import (
"database/sql" "database/sql"
) )
func SendMessage(db *sql.DB, senderID, recipientID int, subject, message string) error { func SendMessage(db *sql.DB, senderID, recipientID int, subject, body string) error {
_, err := db.Exec(` _, err := db.Exec(`
INSERT INTO users_messages (senderId, recipientId, subject, message) INSERT INTO user_messages (senderId, recipientId, subject, body)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`, senderID, recipientID, subject, message) `, senderID, recipientID, subject, body)
return err return 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

@@ -9,7 +9,7 @@ import (
func GetMessageCount(db *sql.DB, userID int) (int, error) { func GetMessageCount(db *sql.DB, userID int) (int, error) {
var count int var count int
err := db.QueryRow(` err := db.QueryRow(`
SELECT COUNT(*) FROM users_messages SELECT COUNT(*) FROM user_messages
WHERE recipientId = ? AND is_read = FALSE AND is_archived = FALSE WHERE recipientId = ? AND is_read = FALSE AND is_archived = FALSE
`, userID).Scan(&count) `, userID).Scan(&count)
return count, err return count, err
@@ -17,8 +17,8 @@ func GetMessageCount(db *sql.DB, userID int) (int, error) {
func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message { func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
rows, err := db.Query(` rows, err := db.Query(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at SELECT id, senderId, recipientId, subject, body, is_read, created_at
FROM users_messages FROM user_messages
WHERE recipientId = ? AND is_archived = FALSE WHERE recipientId = ? AND is_archived = FALSE
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ? LIMIT ?
@@ -36,7 +36,7 @@ func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
&m.SenderId, &m.SenderId,
&m.RecipientId, &m.RecipientId,
&m.Subject, &m.Subject,
&m.Message, &m.Body,
&m.IsRead, &m.IsRead,
&m.CreatedAt, &m.CreatedAt,
) )
@@ -49,13 +49,13 @@ func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
func GetMessageByID(db *sql.DB, userID, messageID int) (*models.Message, error) { func GetMessageByID(db *sql.DB, userID, messageID int) (*models.Message, error) {
row := db.QueryRow(` row := db.QueryRow(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at SELECT id, senderId, recipientId, subject, body, is_read, created_at
FROM users_messages FROM user_messages
WHERE id = ? AND recipientId = ? WHERE id = ? AND recipientId = ?
`, messageID, userID) `, messageID, userID)
var m models.Message var m models.Message
err := row.Scan(&m.ID, &m.SenderId, &m.RecipientId, &m.Subject, &m.Message, &m.IsRead, &m.CreatedAt) err := row.Scan(&m.ID, &m.SenderId, &m.RecipientId, &m.Subject, &m.Body, &m.IsRead, &m.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -65,8 +65,8 @@ func GetMessageByID(db *sql.DB, userID, messageID int) (*models.Message, error)
func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Message { func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Message {
offset := (page - 1) * perPage offset := (page - 1) * perPage
rows, err := db.Query(` rows, err := db.Query(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at, archived_at SELECT id, senderId, recipientId, subject, body, is_read, created_at, archived_at
FROM users_messages FROM user_messages
WHERE recipientId = ? AND is_archived = TRUE WHERE recipientId = ? AND is_archived = TRUE
ORDER BY archived_at DESC ORDER BY archived_at DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
@@ -81,7 +81,7 @@ func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Mes
var m models.Message var m models.Message
err := rows.Scan( err := rows.Scan(
&m.ID, &m.SenderId, &m.RecipientId, &m.ID, &m.SenderId, &m.RecipientId,
&m.Subject, &m.Message, &m.IsRead, &m.Subject, &m.Body, &m.IsRead,
&m.CreatedAt, &m.ArchivedAt, &m.CreatedAt, &m.ArchivedAt,
) )
if err == nil { if err == nil {
@@ -94,8 +94,8 @@ func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Mes
func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Message { func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Message {
offset := (page - 1) * perPage offset := (page - 1) * perPage
rows, err := db.Query(` rows, err := db.Query(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at SELECT id, senderId, recipientId, subject, body, is_read, created_at
FROM users_messages FROM user_messages
WHERE recipientId = ? AND is_archived = FALSE WHERE recipientId = ? AND is_archived = FALSE
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
@@ -110,7 +110,7 @@ func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Messag
var m models.Message var m models.Message
err := rows.Scan( err := rows.Scan(
&m.ID, &m.SenderId, &m.RecipientId, &m.ID, &m.SenderId, &m.RecipientId,
&m.Subject, &m.Message, &m.IsRead, &m.CreatedAt, &m.Subject, &m.Body, &m.IsRead, &m.CreatedAt,
) )
if err == nil { if err == nil {
messages = append(messages, m) messages = append(messages, m)
@@ -122,7 +122,7 @@ func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Messag
func GetInboxMessageCount(db *sql.DB, userID int) int { func GetInboxMessageCount(db *sql.DB, userID int) int {
var count int var count int
err := db.QueryRow(` err := db.QueryRow(`
SELECT COUNT(*) FROM users_messages SELECT COUNT(*) FROM user_messages
WHERE recipientId = ? AND is_archived = FALSE WHERE recipientId = ? AND is_archived = FALSE
`, userID).Scan(&count) `, userID).Scan(&count)
if err != nil { if err != nil {

View File

@@ -7,7 +7,7 @@ import (
func ArchiveMessage(db *sql.DB, userID, messageID int) error { func ArchiveMessage(db *sql.DB, userID, messageID int) error {
_, err := db.Exec(` _, err := db.Exec(`
UPDATE users_messages UPDATE user_messages
SET is_archived = TRUE, archived_at = CURRENT_TIMESTAMP SET is_archived = TRUE, archived_at = CURRENT_TIMESTAMP
WHERE id = ? AND recipientId = ? WHERE id = ? AND recipientId = ?
`, messageID, userID) `, messageID, userID)
@@ -16,7 +16,7 @@ func ArchiveMessage(db *sql.DB, userID, messageID int) error {
func MarkMessageAsRead(db *sql.DB, messageID, userID int) error { func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
result, err := db.Exec(` result, err := db.Exec(`
UPDATE users_messages UPDATE user_messages
SET is_read = TRUE SET is_read = TRUE
WHERE id = ? AND recipientId = ? WHERE id = ? AND recipientId = ?
`, messageID, userID) `, messageID, userID)
@@ -36,7 +36,7 @@ func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
func RestoreMessage(db *sql.DB, userID, messageID int) error { func RestoreMessage(db *sql.DB, userID, messageID int) error {
_, err := db.Exec(` _, err := db.Exec(`
UPDATE users_messages UPDATE user_messages
SET is_archived = FALSE, archived_at = NULL SET is_archived = FALSE, archived_at = NULL
WHERE id = ? AND recipientId = ? WHERE id = ? AND recipientId = ?
`, messageID, userID) `, messageID, userID)

View File

@@ -6,13 +6,30 @@
-- USERS -- USERS
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(191) NOT NULL UNIQUE, username VARCHAR(191) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
is_admin TINYINT(1) NOT NULL DEFAULT 0 is_admin TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP(),
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- THUNDERBALL RESULTS 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
);
-- THUNDERBALL RESULTS // ToDo: Ballset should be a TINYINT
CREATE TABLE IF NOT EXISTS results_thunderball ( CREATE TABLE IF NOT EXISTS results_thunderball (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
draw_date DATE NOT NULL UNIQUE, draw_date DATE NOT NULL UNIQUE,
@@ -123,20 +140,20 @@ CREATE TABLE IF NOT EXISTS my_tickets (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- USERS MESSAGES -- USERS MESSAGES
CREATE TABLE IF NOT EXISTS users_messages ( CREATE TABLE IF NOT EXISTS user_messages (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
senderId BIGINT UNSIGNED NOT NULL, senderId BIGINT UNSIGNED NOT NULL,
recipientId BIGINT UNSIGNED NOT NULL, recipientId BIGINT UNSIGNED NOT NULL,
subject VARCHAR(255) NOT NULL, subject VARCHAR(255) NOT NULL,
message MEDIUMTEXT, body MEDIUMTEXT,
is_read TINYINT(1) NOT NULL DEFAULT 0, is_read TINYINT(1) NOT NULL DEFAULT 0,
is_archived TINYINT(1) NOT NULL DEFAULT 0, is_archived TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
archived_at DATETIME NULL, archived_at DATETIME NULL,
CONSTRAINT fk_users_messages_sender CONSTRAINT fk_user_messages_sender
FOREIGN KEY (senderId) REFERENCES users(id) FOREIGN KEY (senderId) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE CASCADE, ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_users_messages_recipient CONSTRAINT fk_user_messages_recipient
FOREIGN KEY (recipientId) REFERENCES users(id) FOREIGN KEY (recipientId) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -200,14 +217,17 @@ CREATE TABLE IF NOT EXISTS audit_log (
ON UPDATE CASCADE ON DELETE SET NULL ON UPDATE CASCADE ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- AUDIT LOGIN (new)
CREATE TABLE IF NOT EXISTS audit_login ( CREATE TABLE IF NOT EXISTS audit_login (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(191), user_id BIGINT UNSIGNED NULL,
success TINYINT(1), username VARCHAR(191) NOT NULL,
ip VARCHAR(64), success TINYINT(1) NOT NULL,
user_agent VARCHAR(255), ip VARCHAR(64) NOT NULL,
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP user_agent VARCHAR(255),
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_audit_login_user_id (user_id),
CONSTRAINT fk_audit_login_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- SYNDICATES -- SYNDICATES

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
}

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