Compare commits
48 Commits
0f60be448d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cc759ec694 | |||
| f0fc70eac6 | |||
| 61ad033520 | |||
| 9dc01f925a | |||
| 8529116ad2 | |||
| 776ea53a66 | |||
| 5880d1ca43 | |||
| da365aa9ef | |||
| 5177194895 | |||
| a7a5169c67 | |||
| 262536135d | |||
| 8650b1fd63 | |||
| b41e92629b | |||
| 0b2883a494 | |||
| 5520685504 | |||
| e2b30c0234 | |||
| 07f7a50b77 | |||
| f458250d3a | |||
| f2cb283158 | |||
| b9bc29d5bc | |||
| b6b5207d43 | |||
| 34918d770f | |||
| eba25a4fb5 | |||
| e6654fc1b4 | |||
| ddafdd0468 | |||
| 5fcb4fb016 | |||
| 71c8d4d06c | |||
| 244b882f11 | |||
| 8d2ce27a74 | |||
| 72e655674f | |||
| f1e16fbc52 | |||
| aec8022439 | |||
| e1fa6c502e | |||
| aa20652abc | |||
| c9f3863a25 | |||
| 76cdb96966 | |||
| 29cb50bb34 | |||
| ffcc340034 | |||
| af581a4def | |||
| e0b063fab0 | |||
| 4a6bfad880 | |||
| 04c3cb3851 | |||
| c911bf9151 | |||
| 86be6479f1 | |||
| 07117ba35e | |||
| ac1f6e9399 | |||
| fb07c4a5eb | |||
| 7276903733 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
main.exe
|
||||
synlotto-website.exe
|
||||
synlotto.db
|
||||
synlotto.db
|
||||
223
README.md
Normal file
223
README.md
Normal 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
92
cmd/api/main.go
Normal 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
52
go.mod
@@ -3,28 +3,44 @@ module synlotto-website
|
||||
go 1.24.1
|
||||
|
||||
require (
|
||||
github.com/gorilla/csrf v1.7.2
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
github.com/alexedwards/scs/v2 v2.9.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/justinas/nosurf v1.2.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/time v0.11.0
|
||||
modernc.org/sqlite v1.36.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/golang-migrate/migrate/v4 v4.19.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
modernc.org/libc v1.61.13 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.8.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
|
||||
152
go.sum
152
go.sum
@@ -1,72 +1,98 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
|
||||
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
|
||||
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/justinas/nosurf v1.2.0 h1:yMs1bSRrNiwXk4AS6n8vL2Ssgpb9CB25T/4xrixaK0s=
|
||||
github.com/justinas/nosurf v1.2.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
|
||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
|
||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
|
||||
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
|
||||
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.36.1 h1:bDa8BJUH4lg6EGkLbahKe/8QqoF8p9gArSc6fTqYhyQ=
|
||||
modernc.org/sqlite v1.36.1/go.mod h1:7MPwH7Z6bREicF9ZVUR78P1IKuxfZ8mRIDHD0iD+8TU=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
25
internal/domain/messages/domain.go
Normal file
25
internal/domain/messages/domain.go
Normal 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
|
||||
}
|
||||
14
internal/domain/notifications/domain.go
Normal file
14
internal/domain/notifications/domain.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
84
internal/handlers/account/login.go
Normal file
84
internal/handlers/account/login.go
Normal 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)
|
||||
}
|
||||
19
internal/handlers/account/logout.go
Normal file
19
internal/handlers/account/logout.go
Normal 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")
|
||||
}
|
||||
152
internal/handlers/account/messages/archive.go
Normal file
152
internal/handlers/account/messages/archive.go
Normal 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")
|
||||
}
|
||||
20
internal/handlers/account/messages/list.go
Normal file
20
internal/handlers/account/messages/list.go
Normal 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
|
||||
}
|
||||
173
internal/handlers/account/messages/read.go
Normal file
173
internal/handlers/account/messages/read.go
Normal 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")
|
||||
}
|
||||
}
|
||||
104
internal/handlers/account/messages/send.go
Normal file
104
internal/handlers/account/messages/send.go
Normal 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")
|
||||
}
|
||||
11
internal/handlers/account/messages/types.go
Normal file
11
internal/handlers/account/messages/types.go
Normal 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
|
||||
}
|
||||
75
internal/handlers/account/notifications/list.go
Normal file
75
internal/handlers/account/notifications/list.go
Normal 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")
|
||||
}
|
||||
}
|
||||
58
internal/handlers/account/notifications/read.go
Normal file
58
internal/handlers/account/notifications/read.go
Normal 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")
|
||||
}
|
||||
}
|
||||
7
internal/handlers/account/notifications/types.go
Normal file
7
internal/handlers/account/notifications/types.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package accountNotificationHandler
|
||||
|
||||
import domain "synlotto-website/internal/domain/notifications"
|
||||
|
||||
type AccountNotificationHandlers struct {
|
||||
Svc domain.NotificationService
|
||||
}
|
||||
157
internal/handlers/account/signup.go
Normal file
157
internal/handlers/account/signup.go
Normal 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, ".")
|
||||
}
|
||||
161
internal/handlers/account/tickets/add.go
Normal file
161
internal/handlers/account/tickets/add.go
Normal 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
|
||||
}
|
||||
69
internal/handlers/account/tickets/list.go
Normal file
69
internal/handlers/account/tickets/list.go
Normal 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)
|
||||
}
|
||||
39
internal/handlers/account/tickets/types.go
Normal file
39
internal/handlers/account/tickets/types.go
Normal 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
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
|
||||
"synlotto-website/internal/http/middleware"
|
||||
"synlotto-website/internal/models"
|
||||
)
|
||||
|
||||
@@ -20,7 +19,7 @@ type AdminLogEntry struct {
|
||||
}
|
||||
|
||||
func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc {
|
||||
return middleware.Auth(true)(func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := models.TemplateData{}
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
|
||||
@@ -37,7 +36,7 @@ func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []AdminLogEntry // ToDo should be in models
|
||||
var logs []AdminLogEntry // ToDo: move to models ?
|
||||
for rows.Next() {
|
||||
var entry AdminLogEntry
|
||||
if err := rows.Scan(&entry.AccessedAt, &entry.UserID, &entry.Path, &entry.IP, &entry.UserAgent); err != nil {
|
||||
@@ -48,14 +47,13 @@ func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
context["AuditLogs"] = logs
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("access_log.html", "templates/admin/logs/access_log.html")
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("access_log.html", "web/templates/admin/logs/access_log.html")
|
||||
_ = tmpl.ExecuteTemplate(w, "layout", context)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func AuditLogHandler(db *sql.DB) http.HandlerFunc {
|
||||
return middleware.Auth(true)(func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := models.TemplateData{}
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
|
||||
@@ -75,8 +73,7 @@ func AuditLogHandler(db *sql.DB) http.HandlerFunc {
|
||||
var logs []models.AuditEntry
|
||||
for rows.Next() {
|
||||
var entry models.AuditEntry
|
||||
err := rows.Scan(&entry.Timestamp, &entry.UserID, &entry.Action, &entry.IP, &entry.UserAgent)
|
||||
if err != nil {
|
||||
if err := rows.Scan(&entry.Timestamp, &entry.UserID, &entry.Action, &entry.IP, &entry.UserAgent); err != nil {
|
||||
log.Println("⚠️ Failed to scan row:", err)
|
||||
continue
|
||||
}
|
||||
@@ -85,12 +82,10 @@ func AuditLogHandler(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
context["AuditLogs"] = logs
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("audit.html", "templates/admin/logs/audit.html")
|
||||
|
||||
err = tmpl.ExecuteTemplate(w, "layout", context)
|
||||
if err != nil {
|
||||
tmpl := templateHelpers.LoadTemplateFiles("audit.html", "web/templates/admin/logs/audit.html")
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
|
||||
log.Println("❌ Failed to render audit page:", err)
|
||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +1,96 @@
|
||||
// internal/handlers/admin/dashboard.go
|
||||
package handlers
|
||||
|
||||
// ToDo: move SQL into storage layer
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
httpHelpers "synlotto-website/internal/helpers/http"
|
||||
securityHelpers "synlotto-website/internal/helpers/security"
|
||||
templateHandlers "synlotto-website/internal/handlers/template"
|
||||
security "synlotto-website/internal/helpers/security"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
|
||||
"synlotto-website/internal/models"
|
||||
"synlotto-website/internal/storage"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
usersStorage "synlotto-website/internal/storage/users"
|
||||
)
|
||||
|
||||
var (
|
||||
total, winners int
|
||||
prizeSum float64
|
||||
)
|
||||
|
||||
func AdminDashboardHandler(db *sql.DB) http.HandlerFunc {
|
||||
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
func AdminDashboardHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := security.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
user := storage.GetUserByID(db, userID)
|
||||
user := usersStorage.GetUserByID(app.DB, userID)
|
||||
if user == nil {
|
||||
http.Error(w, "User not found", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
data := models.TemplateData{}
|
||||
// Shared template data (loads user, notifications, counts, etc.)
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
context["User"] = user
|
||||
context["IsAdmin"] = user.IsAdmin
|
||||
// Missing messages, notifications, potentially syndicate notifictions if that becomes a new top bar icon.
|
||||
db.QueryRow(`SELECT COUNT(*), SUM(CASE WHEN is_winner THEN 1 ELSE 0 END), SUM(prize_amount) FROM my_tickets`).Scan(&total, &winners, &prizeSum)
|
||||
|
||||
// Quick stats (keep here for now; move to storage soon)
|
||||
var (
|
||||
total, winners int
|
||||
prizeSum float64
|
||||
)
|
||||
if err := app.DB.QueryRow(`
|
||||
SELECT COUNT(*),
|
||||
SUM(CASE WHEN is_winner THEN 1 ELSE 0 END),
|
||||
COALESCE(SUM(prize_amount), 0)
|
||||
FROM my_tickets
|
||||
`).Scan(&total, &winners, &prizeSum); err != nil {
|
||||
log.Println("⚠️ Failed to load ticket stats:", err)
|
||||
}
|
||||
context["Stats"] = map[string]interface{}{
|
||||
"TotalTickets": total,
|
||||
"TotalWinners": winners,
|
||||
"TotalPrizeAmount": prizeSum,
|
||||
}
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT run_at, triggered_by, tickets_matched, winners_found, COALESCE(notes, '')
|
||||
FROM log_ticket_matching
|
||||
ORDER BY run_at DESC LIMIT 10
|
||||
// Recent matcher logs (limit 10)
|
||||
rows, err := app.DB.Query(`
|
||||
SELECT run_at, triggered_by, tickets_matched, winners_found, COALESCE(notes, '')
|
||||
FROM log_ticket_matching
|
||||
ORDER BY run_at DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
if err != nil {
|
||||
log.Println("⚠️ Failed to load logs:", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []models.MatchLog
|
||||
for rows.Next() {
|
||||
var logEntry models.MatchLog
|
||||
err := rows.Scan(&logEntry.RunAt, &logEntry.TriggeredBy, &logEntry.TicketsMatched, &logEntry.WinnersFound, &logEntry.Notes)
|
||||
if err != nil {
|
||||
log.Println("⚠️ Failed to scan log row:", err)
|
||||
continue
|
||||
} else {
|
||||
defer rows.Close()
|
||||
var logs []struct {
|
||||
RunAt any
|
||||
TriggeredBy string
|
||||
TicketsMatched int
|
||||
WinnersFound int
|
||||
Notes string
|
||||
}
|
||||
logs = append(logs, logEntry)
|
||||
for rows.Next() {
|
||||
var e struct {
|
||||
RunAt any
|
||||
TriggeredBy string
|
||||
TicketsMatched int
|
||||
WinnersFound int
|
||||
Notes string
|
||||
}
|
||||
if err := rows.Scan(&e.RunAt, &e.TriggeredBy, &e.TicketsMatched, &e.WinnersFound, &e.Notes); err != nil {
|
||||
log.Println("⚠️ Failed to scan log row:", err)
|
||||
continue
|
||||
}
|
||||
logs = append(logs, e)
|
||||
}
|
||||
context["MatchLogs"] = logs
|
||||
}
|
||||
context["MatchLogs"] = logs
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("dashboard.html", "templates/admin/dashboard.html")
|
||||
|
||||
err = tmpl.ExecuteTemplate(w, "layout", context)
|
||||
if err != nil {
|
||||
tmpl := templateHelpers.LoadTemplateFiles("dashboard.html", "web/templates/admin/dashboard.html")
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
|
||||
http.Error(w, "Failed to render dashboard", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
package handlers
|
||||
|
||||
// ToDo: move SQL into storage layer
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
httpHelpers "synlotto-website/internal/helpers/http"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
|
||||
"synlotto-website/internal/models"
|
||||
)
|
||||
|
||||
func NewDrawHandler(db *sql.DB) http.HandlerFunc {
|
||||
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := models.TemplateData{}
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
game := r.FormValue("game_type")
|
||||
@@ -22,29 +21,35 @@ func NewDrawHandler(db *sql.DB) http.HandlerFunc {
|
||||
machine := r.FormValue("machine")
|
||||
ballset := r.FormValue("ball_set")
|
||||
|
||||
_, err := db.Exec(`INSERT INTO results_thunderball (game_type, draw_date, machine, ball_set) VALUES (?, ?, ?, ?)`,
|
||||
game, date, machine, ballset)
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO results_thunderball (game_type, draw_date, machine, ball_set) VALUES (?, ?, ?, ?)`,
|
||||
game, date, machine, ballset,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to add draw", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("new_draw", "templates/admin/draws/new_draw.html")
|
||||
|
||||
tmpl.ExecuteTemplate(w, "layout", context)
|
||||
})
|
||||
tmpl := templateHelpers.LoadTemplateFiles("new_draw", "web/templates/admin/draws/new_draw.html")
|
||||
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func ModifyDrawHandler(db *sql.DB) http.HandlerFunc {
|
||||
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
id := r.FormValue("id")
|
||||
_, err := db.Exec(`UPDATE results_thunderball SET game_type=?, draw_date=?, ball_set=?, machine=? WHERE id=?`,
|
||||
r.FormValue("game_type"), r.FormValue("draw_date"), r.FormValue("ball_set"), r.FormValue("machine"), id)
|
||||
_, err := db.Exec(
|
||||
`UPDATE results_thunderball SET game_type=?, draw_date=?, ball_set=?, machine=? WHERE id=?`,
|
||||
r.FormValue("game_type"),
|
||||
r.FormValue("draw_date"),
|
||||
r.FormValue("ball_set"),
|
||||
r.FormValue("machine"),
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "Update failed", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -52,33 +57,30 @@ func ModifyDrawHandler(db *sql.DB) http.HandlerFunc {
|
||||
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// For GET: load draw by ID (pseudo-code)
|
||||
// id := r.URL.Query().Get("id")
|
||||
// query DB, pass into context.Draw
|
||||
})
|
||||
// For GET: load draw by ID if needed and render a form/template
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteDrawHandler(db *sql.DB) http.HandlerFunc {
|
||||
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
id := r.FormValue("id")
|
||||
_, err := db.Exec(`DELETE FROM results_thunderball WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
if _, err := db.Exec(`DELETE FROM results_thunderball WHERE id = ?`, id); err != nil {
|
||||
http.Error(w, "Delete failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ListDrawsHandler(db *sql.DB) http.HandlerFunc {
|
||||
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := models.TemplateData{}
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
draws := []models.DrawSummary{}
|
||||
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||
|
||||
var draws []models.DrawSummary
|
||||
rows, err := db.Query(`
|
||||
SELECT r.id, r.game_type, r.draw_date, r.ball_set, r.machine,
|
||||
(SELECT COUNT(1) FROM prizes_thunderball p WHERE p.draw_date = r.draw_date) as prize_exists
|
||||
@@ -101,11 +103,9 @@ func ListDrawsHandler(db *sql.DB) http.HandlerFunc {
|
||||
d.PrizeSet = prizeFlag > 0
|
||||
draws = append(draws, d)
|
||||
}
|
||||
ctx["Draws"] = draws
|
||||
|
||||
context["Draws"] = draws
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("list.html", "templates/admin/draws/list.html")
|
||||
|
||||
tmpl.ExecuteTemplate(w, "layout", context)
|
||||
})
|
||||
tmpl := templateHelpers.LoadTemplateFiles("list.html", "web/templates/admin/draws/list.html")
|
||||
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"synlotto-website/internal/models"
|
||||
)
|
||||
|
||||
// ToDo: need to fix flash messages from new gin context
|
||||
func AdminTriggersHandler(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := models.TemplateData{}
|
||||
@@ -73,7 +74,7 @@ func AdminTriggersHandler(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("triggers.html", "templates/admin/triggers.html")
|
||||
tmpl := templateHelpers.LoadTemplateFiles("triggers.html", "web/templates/admin/triggers.html")
|
||||
|
||||
err := tmpl.ExecuteTemplate(w, "layout", context)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,23 +6,23 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
httpHelpers "synlotto-website/internal/helpers/http"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
|
||||
"synlotto-website/internal/models"
|
||||
)
|
||||
|
||||
// ToDo: move SQL into the storage layer.
|
||||
|
||||
func AddPrizesHandler(db *sql.DB) http.HandlerFunc {
|
||||
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := models.TemplateData{}
|
||||
if r.Method == http.MethodGet {
|
||||
tmpl := templateHelpers.LoadTemplateFiles("add_prizes.html", "templates/admin/draws/prizes/add_prizes.html")
|
||||
tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data))
|
||||
tmpl := templateHelpers.LoadTemplateFiles("add_prizes.html", "web/templates/admin/draws/prizes/add_prizes.html")
|
||||
_ = tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data))
|
||||
return
|
||||
}
|
||||
|
||||
drawDate := r.FormValue("draw_date")
|
||||
values := make([]interface{}, 0)
|
||||
values := make([]interface{}, 0, 9)
|
||||
for i := 1; i <= 9; i++ {
|
||||
val, _ := strconv.Atoi(r.FormValue(fmt.Sprintf("prize%d_per_winner", i)))
|
||||
values = append(values, val)
|
||||
@@ -34,23 +34,21 @@ func AddPrizesHandler(db *sql.DB) http.HandlerFunc {
|
||||
prize7_per_winner, prize8_per_winner, prize9_per_winner
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
_, err := db.Exec(stmt, append([]interface{}{drawDate}, values...)...)
|
||||
if err != nil {
|
||||
if _, err := db.Exec(stmt, append([]interface{}{drawDate}, values...)...); err != nil {
|
||||
http.Error(w, "Insert failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin/draws", http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ModifyPrizesHandler(db *sql.DB) http.HandlerFunc {
|
||||
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := models.TemplateData{}
|
||||
if r.Method == http.MethodGet {
|
||||
tmpl := templateHelpers.LoadTemplateFiles("modify_prizes.html", "templates/admin/draws/prizes/modify_prizes.html")
|
||||
|
||||
tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data))
|
||||
tmpl := templateHelpers.LoadTemplateFiles("modify_prizes.html", "web/templates/admin/draws/prizes/modify_prizes.html")
|
||||
_ = tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,13 +56,12 @@ func ModifyPrizesHandler(db *sql.DB) http.HandlerFunc {
|
||||
for i := 1; i <= 9; i++ {
|
||||
key := fmt.Sprintf("prize%d_per_winner", i)
|
||||
val, _ := strconv.Atoi(r.FormValue(key))
|
||||
_, err := db.Exec("UPDATE prizes_thunderball SET "+key+" = ? WHERE draw_date = ?", val, drawDate)
|
||||
if err != nil {
|
||||
if _, err := db.Exec("UPDATE prizes_thunderball SET "+key+" = ? WHERE draw_date = ?", val, drawDate); err != nil {
|
||||
http.Error(w, "Update failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin/draws", http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
templateHandlers "synlotto-website/internal/handlers/template"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Home(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
func Home(app *bootstrap.App) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
|
||||
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("index.html", "templates/index.html")
|
||||
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/index.html")
|
||||
|
||||
err := tmpl.ExecuteTemplate(w, "layout", context)
|
||||
if err != nil {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
|
||||
log.Println("❌ Template render error:", err)
|
||||
http.Error(w, "Error rendering homepage", http.StatusInternalServerError)
|
||||
c.String(http.StatusInternalServerError, "Template render error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
|
||||
"synlotto-website/internal/helpers"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
"synlotto-website/internal/models"
|
||||
"synlotto-website/internal/storage"
|
||||
resultsThunderballStorage "synlotto-website/internal/storage/results/thunderball"
|
||||
)
|
||||
|
||||
func NewDraw(db *sql.DB) http.HandlerFunc {
|
||||
@@ -19,7 +18,7 @@ func NewDraw(db *sql.DB) http.HandlerFunc {
|
||||
context["Page"] = "new_draw"
|
||||
context["Data"] = nil
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("new_draw.html", "templates/admin/draws/new_draw.html") // ToDo: may need removing or moving add draw should be admin functionality and only when manually required. Potential live drawing of numbers in the future.
|
||||
tmpl := templateHelpers.LoadTemplateFiles("new_draw.html", "web/templates/admin/draws/new_draw.html") // ToDo: may need removing or moving add draw should be admin functionality and only when manually required. Potential live drawing of numbers in the future.
|
||||
|
||||
err := tmpl.ExecuteTemplate(w, "layout", context)
|
||||
if err != nil {
|
||||
@@ -45,7 +44,7 @@ func Submit(db *sql.DB, w http.ResponseWriter, r *http.Request) {
|
||||
Thunderball: helpers.Atoi(r.FormValue("thunderball")),
|
||||
}
|
||||
|
||||
err := storage.InsertThunderballResult(db, draw)
|
||||
err := resultsThunderballStorage.InsertThunderballResult(db, draw)
|
||||
if err != nil {
|
||||
log.Println("❌ Failed to insert draw:", err)
|
||||
http.Error(w, "Failed to save draw", http.StatusInternalServerError)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// internal/handlers/lottery/syndicate/syndicate.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -9,38 +9,39 @@ import (
|
||||
templateHandlers "synlotto-website/internal/handlers/template"
|
||||
securityHelpers "synlotto-website/internal/helpers/security"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
syndicateStorage "synlotto-website/internal/storage/syndicate"
|
||||
ticketStorage "synlotto-website/internal/storage/tickets"
|
||||
|
||||
"synlotto-website/internal/helpers"
|
||||
"synlotto-website/internal/models"
|
||||
"synlotto-website/internal/storage"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
)
|
||||
|
||||
func CreateSyndicateHandler(db *sql.DB) http.HandlerFunc {
|
||||
func CreateSyndicateHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("create-syndicate.html", "templates/syndicate/create.html")
|
||||
tmpl.ExecuteTemplate(w, "layout", context)
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("create-syndicate.html", "web/templates/syndicate/create.html")
|
||||
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||
|
||||
case http.MethodPost:
|
||||
name := r.FormValue("name")
|
||||
description := r.FormValue("description")
|
||||
|
||||
userId, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userId, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok || name == "" {
|
||||
templateHelpers.SetFlash(w, r, "Invalid data submitted")
|
||||
templateHelpers.SetFlash(r, "Invalid data submitted")
|
||||
http.Redirect(w, r, "/syndicate/create", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := storage.CreateSyndicate(db, userId, name, description)
|
||||
if err != nil {
|
||||
if _, err := syndicateStorage.CreateSyndicate(app.DB, userId, name, description); err != nil {
|
||||
log.Printf("❌ CreateSyndicate failed: %v", err)
|
||||
templateHelpers.SetFlash(w, r, "Failed to create syndicate")
|
||||
templateHelpers.SetFlash(r, "Failed to create syndicate")
|
||||
} else {
|
||||
templateHelpers.SetFlash(w, r, "Syndicate created successfully")
|
||||
templateHelpers.SetFlash(r, "Syndicate created successfully")
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
|
||||
@@ -50,18 +51,18 @@ func CreateSyndicateHandler(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func ListSyndicatesHandler(db *sql.DB) http.HandlerFunc {
|
||||
func ListSyndicatesHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, 403) // ToDo need to make this use the handler so i dont need to define errors.
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
managed := storage.GetSyndicatesByOwner(db, userID)
|
||||
member := storage.GetSyndicatesByMember(db, userID)
|
||||
managed := syndicateStorage.GetSyndicatesByOwner(app.DB, userID)
|
||||
member := syndicateStorage.GetSyndicatesByMember(app.DB, userID)
|
||||
|
||||
managedMap := make(map[int]bool)
|
||||
managedMap := make(map[int]bool, len(managed))
|
||||
for _, s := range managed {
|
||||
managedMap[s.ID] = true
|
||||
}
|
||||
@@ -73,131 +74,139 @@ func ListSyndicatesHandler(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
context["ManagedSyndicates"] = managed
|
||||
context["JoinedSyndicates"] = filteredJoined
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||
ctx["ManagedSyndicates"] = managed
|
||||
ctx["JoinedSyndicates"] = filteredJoined
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("syndicates.html", "templates/syndicate/index.html")
|
||||
tmpl.ExecuteTemplate(w, "layout", context)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("syndicates.html", "web/templates/syndicate/index.html")
|
||||
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func ViewSyndicateHandler(db *sql.DB) http.HandlerFunc {
|
||||
func ViewSyndicateHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
|
||||
syndicate, err := storage.GetSyndicateByID(db, syndicateID)
|
||||
syndicate, err := syndicateStorage.GetSyndicateByID(app.DB, syndicateID)
|
||||
if err != nil || syndicate == nil {
|
||||
templateHelpers.RenderError(w, r, 404)
|
||||
templateHelpers.RenderError(w, r, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
isManager := userID == syndicate.OwnerID
|
||||
isMember := storage.IsSyndicateMember(db, syndicateID, userID)
|
||||
|
||||
isMember := syndicateStorage.IsSyndicateMember(app.DB, syndicateID, userID)
|
||||
if !isManager && !isMember {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
members := storage.GetSyndicateMembers(db, syndicateID)
|
||||
members := syndicateStorage.GetSyndicateMembers(app.DB, syndicateID)
|
||||
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
context["Syndicate"] = syndicate
|
||||
context["Members"] = members
|
||||
context["IsManager"] = isManager
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||
ctx["Syndicate"] = syndicate
|
||||
ctx["Members"] = members
|
||||
ctx["IsManager"] = isManager
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("syndicate-view.html", "templates/syndicate/view.html")
|
||||
tmpl.ExecuteTemplate(w, "layout", context)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("syndicate-view.html", "web/templates/syndicate/view.html")
|
||||
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func SyndicateLogTicketHandler(db *sql.DB) http.HandlerFunc {
|
||||
func SyndicateLogTicketHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
syndicateId := helpers.Atoi(r.URL.Query().Get("id"))
|
||||
syndicate, err := storage.GetSyndicateByID(db, syndicateId)
|
||||
syndicate, err := syndicateStorage.GetSyndicateByID(app.DB, syndicateId)
|
||||
if err != nil || syndicate.OwnerID != userID {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
context["Syndicate"] = syndicate
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||
ctx["Syndicate"] = syndicate
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("syndicate-log-ticket.html", "templates/syndicate/log_ticket.html")
|
||||
tmpl.ExecuteTemplate(w, "layout", context)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("syndicate-log-ticket.html", "web/templates/syndicate/log_ticket.html")
|
||||
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||
|
||||
case http.MethodPost:
|
||||
gameType := r.FormValue("game_type")
|
||||
drawDate := r.FormValue("draw_date")
|
||||
drawDateStr := r.FormValue("draw_date")
|
||||
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,
|
||||
GameType: gameType,
|
||||
DrawDate: drawDate,
|
||||
DrawDate: dt,
|
||||
PurchaseMethod: method,
|
||||
SyndicateId: &syndicateId,
|
||||
// ToDo image path
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
templateHelpers.SetFlash(w, r, "Failed to add ticket.")
|
||||
templateHelpers.SetFlash(r, "Failed to add ticket.")
|
||||
} else {
|
||||
templateHelpers.SetFlash(w, r, "Ticket added for syndicate.")
|
||||
templateHelpers.SetFlash(r, "Ticket added for syndicate.")
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/syndicate/view?id=%d", syndicateId), http.StatusSeeOther)
|
||||
|
||||
default:
|
||||
templateHelpers.RenderError(w, r, 405)
|
||||
templateHelpers.RenderError(w, r, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SyndicateTicketsHandler(db *sql.DB) http.HandlerFunc {
|
||||
func SyndicateTicketsHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
|
||||
if syndicateID == 0 {
|
||||
templateHelpers.RenderError(w, r, 400)
|
||||
templateHelpers.RenderError(w, r, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !storage.IsSyndicateMember(db, syndicateID, userID) {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
if !syndicateStorage.IsSyndicateMember(app.DB, syndicateID, userID) {
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
tickets := storage.GetSyndicateTickets(db, syndicateID)
|
||||
// You said GetSyndicateTickets lives in storage/syndicate:
|
||||
tickets := syndicateStorage.GetSyndicateTickets(app.DB, syndicateID)
|
||||
// If you later move it into tickets storage, switch to:
|
||||
// tickets := ticketStorage.GetSyndicateTickets(app.DB, syndicateID)
|
||||
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
context["SyndicateID"] = syndicateID
|
||||
context["Tickets"] = tickets
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||
ctx["SyndicateID"] = syndicateID
|
||||
ctx["Tickets"] = tickets
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("syndicate-tickets.html", "templates/syndicate/tickets.html")
|
||||
tmpl.ExecuteTemplate(w, "layout", context)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("syndicate-tickets.html", "web/templates/syndicate/tickets.html")
|
||||
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
// internal/handlers/lottery/syndicate/syndicate_invites.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
templateHandlers "synlotto-website/internal/handlers/template"
|
||||
"synlotto-website/internal/helpers"
|
||||
securityHelpers "synlotto-website/internal/helpers/security"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
|
||||
"synlotto-website/internal/helpers"
|
||||
"synlotto-website/internal/storage"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
syndicateStorage "synlotto-website/internal/storage/syndicate"
|
||||
)
|
||||
|
||||
func SyndicateInviteHandler(db *sql.DB) http.HandlerFunc {
|
||||
// GET /syndicate/invite?id=<syndicate_id>
|
||||
// POST /syndicate/invite (syndicate_id, username)
|
||||
func SyndicateInviteHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
@@ -26,24 +28,23 @@ func SyndicateInviteHandler(db *sql.DB) http.HandlerFunc {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
context["SyndicateID"] = syndicateID
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||
ctx["SyndicateID"] = syndicateID
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("invite-syndicate.html", "templates/syndicate/invite.html")
|
||||
err := tmpl.ExecuteTemplate(w, "layout", context)
|
||||
if err != nil {
|
||||
templateHelpers.RenderError(w, r, 500)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("invite-syndicate.html", "web/templates/syndicate/invite.html")
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", ctx); err != nil {
|
||||
templateHelpers.RenderError(w, r, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
case http.MethodPost:
|
||||
syndicateID := helpers.Atoi(r.FormValue("syndicate_id"))
|
||||
username := r.FormValue("username")
|
||||
|
||||
err := storage.InviteToSyndicate(db, userID, syndicateID, username)
|
||||
if err != nil {
|
||||
templateHelpers.SetFlash(w, r, "Failed to send invite: "+err.Error())
|
||||
if err := syndicateStorage.InviteToSyndicate(app.DB, userID, syndicateID, username); err != nil {
|
||||
templateHelpers.SetFlash(r, "Failed to send invite: "+err.Error())
|
||||
} else {
|
||||
templateHelpers.SetFlash(w, r, "Invite sent successfully.")
|
||||
templateHelpers.SetFlash(r, "Invite sent successfully.")
|
||||
}
|
||||
http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther)
|
||||
|
||||
@@ -53,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) {
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
invites := storage.GetPendingInvites(db, userID)
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
context["Invites"] = invites
|
||||
invites := syndicateStorage.GetPendingSyndicateInvites(app.DB, userID)
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("invites.html", "templates/syndicate/invites.html")
|
||||
tmpl.ExecuteTemplate(w, "layout", context)
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||
ctx["Invites"] = invites
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("invites.html", "web/templates/syndicate/invites.html")
|
||||
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func AcceptInviteHandler(db *sql.DB) http.HandlerFunc {
|
||||
// POST /syndicate/invites/accept?id=<invite_id>
|
||||
func AcceptInviteHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
inviteID := helpers.Atoi(r.URL.Query().Get("id"))
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
err := storage.AcceptInvite(db, inviteID, userID)
|
||||
if err != nil {
|
||||
templateHelpers.SetFlash(w, r, "Failed to accept invite")
|
||||
if err := syndicateStorage.AcceptInvite(app.DB, inviteID, userID); err != nil {
|
||||
templateHelpers.SetFlash(r, "Failed to accept invite")
|
||||
} else {
|
||||
templateHelpers.SetFlash(w, r, "You have joined the syndicate")
|
||||
templateHelpers.SetFlash(r, "You have joined the syndicate")
|
||||
}
|
||||
http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func DeclineInviteHandler(db *sql.DB) http.HandlerFunc {
|
||||
// POST /syndicate/invites/decline?id=<invite_id>
|
||||
func DeclineInviteHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
inviteID := helpers.Atoi(r.URL.Query().Get("id"))
|
||||
_ = storage.UpdateInviteStatus(db, inviteID, "declined")
|
||||
_ = syndicateStorage.UpdateInviteStatus(app.DB, inviteID, "declined")
|
||||
http.Redirect(w, r, "/syndicate/invites", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func CreateInviteToken(db *sql.DB, syndicateID, invitedByID int, ttlHours int) (string, error) {
|
||||
// ===== Invite Tokens ========================================================
|
||||
// (Consider moving these two helpers to internal/storage/syndicate)
|
||||
|
||||
// Create an invite token that expires after ttlHours.
|
||||
func CreateInviteToken(app *bootstrap.App, syndicateID, invitedByID int, ttlHours int) (string, error) {
|
||||
token, err := securityHelpers.GenerateSecureToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
expires := time.Now().Add(time.Duration(ttlHours) * time.Hour)
|
||||
|
||||
_, err = db.Exec(`
|
||||
_, err = app.DB.Exec(`
|
||||
INSERT INTO syndicate_invite_tokens (syndicate_id, token, invited_by_user_id, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, syndicateID, token, invitedByID, expires)
|
||||
|
||||
return token, err
|
||||
}
|
||||
|
||||
func AcceptInviteToken(db *sql.DB, token string, userID int) error {
|
||||
// Validate + consume a token to join a syndicate.
|
||||
func AcceptInviteToken(app *bootstrap.App, token string, userID int) error {
|
||||
var syndicateID int
|
||||
var expiresAt, acceptedAt sql.NullTime
|
||||
err := db.QueryRow(`
|
||||
SELECT syndicate_id, expires_at, accepted_at
|
||||
FROM syndicate_invite_tokens
|
||||
WHERE token = ?
|
||||
`, token).Scan(&syndicateID, &expiresAt, &acceptedAt)
|
||||
if err != nil {
|
||||
var expiresAt, acceptedAt struct {
|
||||
Valid bool
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// Note: using separate variables to avoid importing database/sql here.
|
||||
row := app.DB.QueryRow(`
|
||||
SELECT syndicate_id, expires_at, accepted_at
|
||||
FROM syndicate_invite_tokens
|
||||
WHERE token = ?
|
||||
`, token)
|
||||
if err := row.Scan(&syndicateID, &expiresAt.Time, &acceptedAt.Time); err != nil {
|
||||
return fmt.Errorf("invalid or expired token")
|
||||
}
|
||||
if acceptedAt.Valid || expiresAt.Time.Before(time.Now()) {
|
||||
// If driver returns zero time when NULL, treat missing as invalid.Valid=false
|
||||
expiresAt.Valid = !expiresAt.Time.IsZero()
|
||||
acceptedAt.Valid = !acceptedAt.Time.IsZero()
|
||||
|
||||
if acceptedAt.Valid || (expiresAt.Valid && expiresAt.Time.Before(time.Now())) {
|
||||
return fmt.Errorf("token already used or expired")
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO syndicate_members (syndicate_id, user_id, role, status, joined_at)
|
||||
VALUES (?, ?, 'member', 'active', CURRENT_TIMESTAMP)
|
||||
`, syndicateID, userID)
|
||||
if err != nil {
|
||||
if _, err := app.DB.Exec(`
|
||||
INSERT INTO syndicate_members (syndicate_id, user_id, role, status, joined_at)
|
||||
VALUES (?, ?, 'member', 'active', CURRENT_TIMESTAMP)
|
||||
`, syndicateID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
UPDATE syndicate_invite_tokens
|
||||
SET accepted_by_user_id = ?, accepted_at = CURRENT_TIMESTAMP
|
||||
WHERE token = ?
|
||||
_, err := app.DB.Exec(`
|
||||
UPDATE syndicate_invite_tokens
|
||||
SET accepted_by_user_id = ?, accepted_at = CURRENT_TIMESTAMP
|
||||
WHERE token = ?
|
||||
`, userID, token)
|
||||
return err
|
||||
}
|
||||
|
||||
func GenerateInviteLinkHandler(db *sql.DB) http.HandlerFunc {
|
||||
// GET /syndicate/invite/token?id=<syndicate_id>
|
||||
func GenerateInviteLinkHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
|
||||
token, err := CreateInviteToken(db, syndicateID, userID, 48)
|
||||
token, err := CreateInviteToken(app, syndicateID, userID, 48)
|
||||
if err != nil {
|
||||
templateHelpers.SetFlash(w, r, "Failed to generate invite link.")
|
||||
templateHelpers.SetFlash(r, "Failed to generate invite link.")
|
||||
http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
origin := r.Host
|
||||
scheme := "http://"
|
||||
if r.TLS != nil {
|
||||
origin = "https://" + origin
|
||||
} else {
|
||||
origin = "http://" + origin
|
||||
scheme = "https://"
|
||||
}
|
||||
inviteLink := fmt.Sprintf("%s/syndicate/join?token=%s", origin, token)
|
||||
inviteLink := fmt.Sprintf("%s%s/syndicate/join?token=%s", scheme, r.Host, token)
|
||||
|
||||
templateHelpers.SetFlash(w, r, "Invite link created: "+inviteLink)
|
||||
templateHelpers.SetFlash(r, "Invite link created: "+inviteLink)
|
||||
http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func JoinSyndicateWithTokenHandler(db *sql.DB) http.HandlerFunc {
|
||||
// GET /syndicate/join?token=<token>
|
||||
func JoinSyndicateWithTokenHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
@@ -183,44 +198,43 @@ func JoinSyndicateWithTokenHandler(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
templateHelpers.SetFlash(w, r, "Invalid or missing invite token.")
|
||||
templateHelpers.SetFlash(r, "Invalid or missing invite token.")
|
||||
http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
err := AcceptInviteToken(db, token, userID)
|
||||
if err != nil {
|
||||
templateHelpers.SetFlash(w, r, "Failed to join syndicate: "+err.Error())
|
||||
if err := AcceptInviteToken(app, token, userID); err != nil {
|
||||
templateHelpers.SetFlash(r, "Failed to join syndicate: "+err.Error())
|
||||
} else {
|
||||
templateHelpers.SetFlash(w, r, "You have joined the syndicate!")
|
||||
templateHelpers.SetFlash(r, "You have joined the syndicate!")
|
||||
}
|
||||
http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func ManageInviteTokensHandler(db *sql.DB) http.HandlerFunc {
|
||||
// GET /syndicate/invite/tokens?id=<syndicate_id>
|
||||
func ManageInviteTokensHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
|
||||
|
||||
if !storage.IsSyndicateManager(db, syndicateID, userID) {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
if !syndicateStorage.IsSyndicateManager(app.DB, syndicateID, userID) {
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
tokens := storage.GetInviteTokensForSyndicate(db, syndicateID)
|
||||
tokens := syndicateStorage.GetInviteTokensForSyndicate(app.DB, syndicateID)
|
||||
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
context["Tokens"] = tokens
|
||||
context["SyndicateID"] = syndicateID
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||
ctx["Tokens"] = tokens
|
||||
ctx["SyndicateID"] = syndicateID
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("invite-links.html", "templates/syndicate/invite_links.html")
|
||||
tmpl.ExecuteTemplate(w, "layout", context)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("invite-links.html", "web/templates/syndicate/invite_links.html")
|
||||
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// internal/handlers/lottery/tickets/ticket_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -10,21 +11,23 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
httpHelpers "synlotto-website/internal/helpers/http"
|
||||
templateHandlers "synlotto-website/internal/handlers/template"
|
||||
securityHelpers "synlotto-website/internal/helpers/security"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
draws "synlotto-website/internal/services/draws"
|
||||
|
||||
"synlotto-website/internal/helpers"
|
||||
"synlotto-website/internal/models"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/justinas/nosurf"
|
||||
)
|
||||
|
||||
func AddTicket(db *sql.DB) http.HandlerFunc {
|
||||
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
// AddTicket renders the add-ticket form (GET) and handles multi-line ticket submission (POST).
|
||||
func AddTicket(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
rows, err := db.Query(`
|
||||
rows, err := app.DB.Query(`
|
||||
SELECT DISTINCT draw_date
|
||||
FROM results_thunderball
|
||||
ORDER BY draw_date DESC
|
||||
@@ -44,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["csrfField"] = csrf.TemplateField(r)
|
||||
context["CSRFToken"] = nosurf.Token(r)
|
||||
context["DrawDates"] = drawDates
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("add_ticket.html", "templates/account/tickets/add_ticket.html")
|
||||
|
||||
err = tmpl.ExecuteTemplate(w, "layout", context)
|
||||
if err != nil {
|
||||
tmpl := templateHelpers.LoadTemplateFiles("add_ticket.html", "web/templates/account/tickets/add_ticket.html")
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
|
||||
log.Println("❌ Template render error:", err)
|
||||
http.Error(w, "Error rendering form", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err := r.ParseMultipartForm(10 << 20)
|
||||
if err != nil {
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
http.Error(w, "Invalid form", http.StatusBadRequest)
|
||||
log.Println("❌ Failed to parse form:", err)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
game := r.FormValue("game_type")
|
||||
drawDate := r.FormValue("draw_date")
|
||||
drawDateStr := r.FormValue("draw_date")
|
||||
purchaseMethod := r.FormValue("purchase_method")
|
||||
purchaseDate := r.FormValue("purchase_date")
|
||||
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 != "" {
|
||||
purchaseDate += "T" + purchaseTime
|
||||
}
|
||||
@@ -90,7 +98,7 @@ func AddTicket(db *sql.DB) http.HandlerFunc {
|
||||
out, err := os.Create(filename)
|
||||
if err == nil {
|
||||
defer out.Close()
|
||||
io.Copy(out, file)
|
||||
_, _ = io.Copy(out, file)
|
||||
imagePath = filename
|
||||
}
|
||||
}
|
||||
@@ -157,7 +165,7 @@ func AddTicket(db *sql.DB) http.HandlerFunc {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := db.Exec(`
|
||||
if _, err := app.DB.Exec(`
|
||||
INSERT INTO my_tickets (
|
||||
userId, game_type, draw_date,
|
||||
ball1, ball2, ball3, ball4, ball5, ball6,
|
||||
@@ -165,42 +173,48 @@ func AddTicket(db *sql.DB) http.HandlerFunc {
|
||||
purchase_method, purchase_date, image_path
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
userID, game, drawDate,
|
||||
userID, game, drawDateDB,
|
||||
b[0], b[1], b[2], b[3], b[4], b[5],
|
||||
bo[0], bo[1],
|
||||
purchaseMethod, purchaseDate, imagePath,
|
||||
)
|
||||
if err != nil {
|
||||
); err != nil {
|
||||
log.Println("❌ Failed to insert ticket line:", err)
|
||||
} else {
|
||||
log.Printf("✅ Ticket line %d saved", i+1) // ToDo create audit
|
||||
log.Printf("✅ Ticket line %d saved", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/tickets", http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func SubmitTicket(db *sql.DB) http.HandlerFunc {
|
||||
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseMultipartForm(10 << 20)
|
||||
if err != nil {
|
||||
// SubmitTicket handles alternate multipart ticket submission (POST-only).
|
||||
func SubmitTicket(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
http.Error(w, "Invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
game := r.FormValue("game_type")
|
||||
drawDate := r.FormValue("draw_date")
|
||||
drawDateStr := r.FormValue("draw_date")
|
||||
purchaseMethod := r.FormValue("purchase_method")
|
||||
purchaseDate := r.FormValue("purchase_date")
|
||||
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 != "" {
|
||||
purchaseDate += "T" + purchaseTime
|
||||
}
|
||||
@@ -213,13 +227,13 @@ func SubmitTicket(db *sql.DB) http.HandlerFunc {
|
||||
out, err := os.Create(filename)
|
||||
if err == nil {
|
||||
defer out.Close()
|
||||
io.Copy(out, file)
|
||||
_, _ = io.Copy(out, file)
|
||||
imagePath = filename
|
||||
}
|
||||
}
|
||||
|
||||
ballCount := 6
|
||||
bonusCount := 2
|
||||
const ballCount = 6
|
||||
const bonusCount = 2
|
||||
|
||||
balls := make([][]int, ballCount)
|
||||
bonuses := make([][]int, bonusCount)
|
||||
@@ -247,7 +261,7 @@ func SubmitTicket(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.Exec(`
|
||||
if _, err := app.DB.Exec(`
|
||||
INSERT INTO my_tickets (
|
||||
user_id, game_type, draw_date,
|
||||
ball1, ball2, ball3, ball4, ball5, ball6,
|
||||
@@ -255,34 +269,34 @@ func SubmitTicket(db *sql.DB) http.HandlerFunc {
|
||||
purchase_method, purchase_date, image_path
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
userID, game, drawDate,
|
||||
userID, game, drawDateDB,
|
||||
b[0], b[1], b[2], b[3], b[4], b[5],
|
||||
bo[0], bo[1],
|
||||
purchaseMethod, purchaseDate, imagePath,
|
||||
)
|
||||
if err != nil {
|
||||
); err != nil {
|
||||
log.Println("❌ Insert failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/tickets", http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetMyTickets(db *sql.DB) http.HandlerFunc {
|
||||
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
data := models.TemplateData{}
|
||||
var tickets []models.Ticket
|
||||
// GetMyTickets lists the current user's tickets.
|
||||
func GetMyTickets(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Use shared template data builder (ensures user/flash/notifications present)
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
context["Tickets"] = tickets
|
||||
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.Query(`
|
||||
var tickets []models.Ticket
|
||||
rows, err := app.DB.Query(`
|
||||
SELECT id, game_type, draw_date,
|
||||
ball1, ball2, ball3, ball4, ball5, ball6,
|
||||
bonus1, bonus2,
|
||||
@@ -301,6 +315,7 @@ func GetMyTickets(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
for rows.Next() {
|
||||
var t models.Ticket
|
||||
var drawDateStr string // ← add
|
||||
var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64
|
||||
var matchedMain, matchedBonus sql.NullInt64
|
||||
var prizeTier sql.NullString
|
||||
@@ -308,19 +323,23 @@ func GetMyTickets(db *sql.DB) http.HandlerFunc {
|
||||
var prizeLabel sql.NullString
|
||||
var prizeAmount sql.NullFloat64
|
||||
|
||||
err := rows.Scan(
|
||||
&t.Id, &t.GameType, &t.DrawDate,
|
||||
if err := rows.Scan(
|
||||
&t.Id, &t.GameType, &drawDateStr, // ← was &t.DrawDate
|
||||
&b1, &b2, &b3, &b4, &b5, &b6,
|
||||
&bo1, &bo2,
|
||||
&t.PurchaseMethod, &t.PurchaseDate, &t.ImagePath, &t.Duplicate,
|
||||
&matchedMain, &matchedBonus, &prizeTier, &isWinner, &prizeLabel, &prizeAmount,
|
||||
)
|
||||
if err != nil {
|
||||
); err != nil {
|
||||
log.Println("⚠️ Failed to scan ticket row:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Build primary number + bonus fields
|
||||
// Parse into time.Time (UTC)
|
||||
if dt, err := helpers.ParseDrawDate(drawDateStr); err == nil {
|
||||
t.DrawDate = dt
|
||||
}
|
||||
|
||||
// Normalize fields
|
||||
t.Ball1 = int(b1.Int64)
|
||||
t.Ball2 = int(b2.Int64)
|
||||
t.Ball3 = int(b3.Int64)
|
||||
@@ -348,28 +367,55 @@ func GetMyTickets(db *sql.DB) http.HandlerFunc {
|
||||
if prizeAmount.Valid {
|
||||
t.PrizeAmount = prizeAmount.Float64
|
||||
}
|
||||
// Build balls slices (for template use)
|
||||
|
||||
// Derived fields for templates
|
||||
t.Balls = helpers.BuildBallsSlice(t)
|
||||
t.BonusBalls = helpers.BuildBonusSlice(t)
|
||||
|
||||
// 🎯 Get the actual draw info (used to show which numbers matched)
|
||||
draw := draws.GetDrawResultForTicket(db, t.GameType, t.DrawDate)
|
||||
// Fetch matching draw info
|
||||
draw := draws.GetDrawResultForTicket(app.DB, t.GameType, helpers.FormatDrawDate(t.DrawDate))
|
||||
t.MatchedDraw = draw
|
||||
|
||||
// ✅ DEBUG
|
||||
log.Printf("✅ Ticket #%d", t.Id)
|
||||
log.Printf("Balls: %v", t.Balls)
|
||||
log.Printf("DrawResult: %+v", draw)
|
||||
|
||||
tickets = append(tickets, t)
|
||||
}
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("my_tickets.html", "templates/account/tickets/my_tickets.html")
|
||||
context["Tickets"] = tickets
|
||||
|
||||
err = tmpl.ExecuteTemplate(w, "layout", context)
|
||||
if err != nil {
|
||||
tmpl := templateHelpers.LoadTemplateFiles("my_tickets.html", "web/templates/account/tickets/my_tickets.html")
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
|
||||
log.Println("❌ Template error:", err)
|
||||
http.Error(w, "Error rendering page", http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ToDo
|
||||
// http: superfluous response.WriteHeader call (from SCS)
|
||||
|
||||
//This happens when headers are written twice in a request. With SCS, it sets cookies in WriteHeader. If something else already wrote the headers (or wrote them again), you see this warning.
|
||||
|
||||
//Common culprits & fixes:
|
||||
|
||||
//Use Gin’s redirect instead of the stdlib one:
|
||||
|
||||
// Replace:
|
||||
//http.Redirect(w, r, "/account/login", http.StatusSeeOther)
|
||||
|
||||
// With:
|
||||
//c.Redirect(http.StatusSeeOther, "/account/login")
|
||||
//c.Abort() // stop further handlers writing
|
||||
|
||||
//Do this everywhere you redirect (signup, login, logout).
|
||||
|
||||
//Don’t call two status-writes. For template GETs, this is fine:
|
||||
|
||||
//c.Status(http.StatusOK)
|
||||
//_ = tmpl.ExecuteTemplate(c.Writer, "layout", ctx) // writes body once
|
||||
|
||||
//Just make sure you never write another header after that.
|
||||
|
||||
//Keep your wrapping order as you have it (it’s correct):
|
||||
|
||||
//Gin → SCS.LoadAndSave → NoSurf → http.Server
|
||||
|
||||
//If you still get the warning after switching to c.Redirect + c.Abort(), tell me which handler it’s coming from and I’ll point to the exact double-write.
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
templateHandlers "synlotto-website/internal/handlers/template"
|
||||
httpHelpers "synlotto-website/internal/helpers/http"
|
||||
securityHelpers "synlotto-website/internal/helpers/security"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
messagesStorage "synlotto-website/internal/storage/messages"
|
||||
|
||||
"synlotto-website/internal/helpers"
|
||||
storage "synlotto-website/internal/storage/mysql"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
)
|
||||
|
||||
func MessagesInboxHandler(db *sql.DB) http.HandlerFunc {
|
||||
// Inbox: paginated list of messages
|
||||
func MessagesInboxHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -28,86 +28,82 @@ func MessagesInboxHandler(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
perPage := 10
|
||||
|
||||
totalCount := storage.GetInboxMessageCount(db, userID)
|
||||
totalCount := messagesStorage.GetInboxMessageCount(app.DB, userID)
|
||||
totalPages := (totalCount + perPage - 1) / perPage
|
||||
if totalPages == 0 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
messages := storage.GetInboxMessages(db, userID, page, perPage)
|
||||
messages := messagesStorage.GetInboxMessages(app.DB, userID, page, perPage)
|
||||
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||
ctx["Messages"] = messages
|
||||
ctx["CurrentPage"] = page
|
||||
ctx["TotalPages"] = totalPages
|
||||
ctx["PageRange"] = templateHelpers.PageRange(page, totalPages)
|
||||
|
||||
context["Messages"] = messages
|
||||
context["CurrentPage"] = page
|
||||
context["TotalPages"] = totalPages
|
||||
context["PageRange"] = templateHelpers.PageRange(page, totalPages)
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("messages.html", "templates/account/messages/index.html")
|
||||
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
|
||||
// ToDo: Make this load all error pages without defining explictly.
|
||||
templateHelpers.RenderError(w, r, 500)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("messages.html", "web/templates/account/messages/index.html")
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", ctx); err != nil {
|
||||
templateHelpers.RenderError(w, r, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ReadMessageHandler(db *sql.DB) http.HandlerFunc {
|
||||
// Read a single message (marks as read)
|
||||
func ReadMessageHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.URL.Query().Get("id")
|
||||
messageID := helpers.Atoi(idStr)
|
||||
id := helpers.Atoi(r.URL.Query().Get("id"))
|
||||
|
||||
session, _ := httpHelpers.GetSession(w, r)
|
||||
userID, ok := session.Values["user_id"].(int)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
message, err := storage.GetMessageByID(db, userID, messageID)
|
||||
message, err := messagesStorage.GetMessageByID(app.DB, userID, id)
|
||||
if err != nil {
|
||||
log.Printf("❌ Message not found: %v", err)
|
||||
message = nil
|
||||
} else if !message.IsRead {
|
||||
_ = storage.MarkMessageAsRead(db, messageID, userID)
|
||||
} else if message != nil && !message.IsRead {
|
||||
_ = messagesStorage.MarkMessageAsRead(app.DB, id, userID)
|
||||
}
|
||||
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
context["Message"] = message
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||
ctx["Message"] = message
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("read-message.html", "templates/account/messages/read.html")
|
||||
|
||||
tmpl.ExecuteTemplate(w, "layout", context)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("read-message.html", "web/templates/account/messages/read.html")
|
||||
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func ArchiveMessageHandler(db *sql.DB) http.HandlerFunc {
|
||||
// Archive a message
|
||||
func ArchiveMessageHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := helpers.Atoi(r.URL.Query().Get("id"))
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
err := storage.ArchiveMessage(db, userID, id)
|
||||
if err != nil {
|
||||
templateHelpers.SetFlash(w, r, "Failed to archive message.")
|
||||
if err := messagesStorage.ArchiveMessage(app.DB, userID, id); err != nil {
|
||||
templateHelpers.SetFlash(r, "Failed to archive message.")
|
||||
} else {
|
||||
templateHelpers.SetFlash(w, r, "Message archived.")
|
||||
templateHelpers.SetFlash(r, "Message archived.")
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/account/messages", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func ArchivedMessagesHandler(db *sql.DB) http.HandlerFunc {
|
||||
// List archived messages (paged)
|
||||
func ArchivedMessagesHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,35 +113,35 @@ func ArchivedMessagesHandler(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
perPage := 10
|
||||
|
||||
messages := storage.GetArchivedMessages(db, userID, page, perPage)
|
||||
messages := messagesStorage.GetArchivedMessages(app.DB, userID, page, perPage)
|
||||
hasMore := len(messages) == perPage
|
||||
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
context["Messages"] = messages
|
||||
context["Page"] = page
|
||||
context["HasMore"] = hasMore
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||
ctx["Messages"] = messages
|
||||
ctx["Page"] = page
|
||||
ctx["HasMore"] = hasMore
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("archived.html", "templates/account/messages/archived.html")
|
||||
tmpl.ExecuteTemplate(w, "layout", context)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("archived.html", "web/templates/account/messages/archived.html")
|
||||
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func SendMessageHandler(db *sql.DB) http.HandlerFunc {
|
||||
// Compose & send message
|
||||
func SendMessageHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("send-message.html", "templates/account/messages/send.html")
|
||||
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
|
||||
templateHelpers.RenderError(w, r, 500)
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("send-message.html", "web/templates/account/messages/send.html")
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", ctx); err != nil {
|
||||
templateHelpers.RenderError(w, r, http.StatusInternalServerError)
|
||||
}
|
||||
case http.MethodPost:
|
||||
senderID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
senderID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -153,34 +149,34 @@ func SendMessageHandler(db *sql.DB) http.HandlerFunc {
|
||||
subject := r.FormValue("subject")
|
||||
body := r.FormValue("message")
|
||||
|
||||
if err := storage.SendMessage(db, senderID, recipientID, subject, body); err != nil {
|
||||
templateHelpers.SetFlash(w, r, "Failed to send message.")
|
||||
if err := messagesStorage.SendMessage(app.DB, senderID, recipientID, subject, body); err != nil {
|
||||
templateHelpers.SetFlash(r, "Failed to send message.")
|
||||
} else {
|
||||
templateHelpers.SetFlash(w, r, "Message sent.")
|
||||
templateHelpers.SetFlash(r, "Message sent.")
|
||||
}
|
||||
http.Redirect(w, r, "/account/messages", http.StatusSeeOther)
|
||||
default:
|
||||
templateHelpers.RenderError(w, r, 405)
|
||||
templateHelpers.RenderError(w, r, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RestoreMessageHandler(db *sql.DB) http.HandlerFunc {
|
||||
// Restore an archived message
|
||||
func RestoreMessageHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := helpers.Atoi(r.URL.Query().Get("id"))
|
||||
userID, ok := securityHelpers.GetCurrentUserID(r)
|
||||
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||
if !ok {
|
||||
templateHelpers.RenderError(w, r, 403)
|
||||
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
err := storage.RestoreMessage(db, userID, id)
|
||||
if err != nil {
|
||||
templateHelpers.SetFlash(w, r, "Failed to restore message.")
|
||||
if err := messagesStorage.RestoreMessage(app.DB, userID, id); err != nil {
|
||||
templateHelpers.SetFlash(r, "Failed to restore message.")
|
||||
} else {
|
||||
templateHelpers.SetFlash(w, r, "Message restored.")
|
||||
templateHelpers.SetFlash(r, "Message restored.")
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/account/messages/archived", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/account/messages/archive", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,73 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
templateHandlers "synlotto-website/internal/handlers/template"
|
||||
httpHelpers "synlotto-website/internal/helpers/http"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
|
||||
"synlotto-website/internal/storage"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
"synlotto-website/internal/platform/sessionkeys"
|
||||
notificationsStorage "synlotto-website/internal/storage/notifications"
|
||||
)
|
||||
|
||||
func NotificationsHandler(db *sql.DB) http.HandlerFunc {
|
||||
// NotificationsHandler serves the notifications index page.
|
||||
// New signature: accept *bootstrap.App (not *sql.DB)
|
||||
func NotificationsHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("index.html", "templates/account/notifications/index.html")
|
||||
tmpl := templateHelpers.LoadTemplateFiles("index.html", "web/templates/account/notifications/index.html")
|
||||
|
||||
err := tmpl.ExecuteTemplate(w, "layout", context)
|
||||
if err != nil {
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
|
||||
log.Println("❌ Template render error:", err)
|
||||
http.Error(w, "Error rendering notifications page", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func MarkNotificationReadHandler(db *sql.DB) http.HandlerFunc {
|
||||
// MarkNotificationReadHandler shows a single notification (and marks unread ones as read).
|
||||
// New signature: accept *bootstrap.App; read user id from SCS session.
|
||||
func MarkNotificationReadHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
notificationIDStr := r.URL.Query().Get("id")
|
||||
notificationID, err := strconv.Atoi(notificationIDStr)
|
||||
if err != nil {
|
||||
if err != nil || notificationID <= 0 {
|
||||
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
session, _ := httpHelpers.GetSession(w, r)
|
||||
userID, ok := session.Values["user_id"].(int)
|
||||
if !ok {
|
||||
// SCS-native session access
|
||||
userID := app.SessionManager.GetInt(r.Context(), sessionkeys.UserID)
|
||||
if userID == 0 {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
notification, err := storage.GetNotificationByID(db, userID, notificationID)
|
||||
// Load + mark-as-read (if needed)
|
||||
notification, err := notificationsStorage.GetNotificationByID(app.DB, userID, notificationID)
|
||||
if err != nil {
|
||||
log.Printf("❌ Notification not found or belongs to another user: %v", err)
|
||||
notification = nil
|
||||
} else if !notification.IsRead {
|
||||
err = storage.MarkNotificationAsRead(db, userID, notificationID)
|
||||
if err != nil {
|
||||
if err := notificationsStorage.MarkNotificationAsRead(app.DB, userID, notificationID); err != nil {
|
||||
log.Printf("⚠️ Failed to mark as read: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
context["Notification"] = notification
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("read.html", "templates/account/notifications/read.html")
|
||||
tmpl := templateHelpers.LoadTemplateFiles("read.html", "web/templates/account/notifications/read.html")
|
||||
|
||||
err = tmpl.ExecuteTemplate(w, "layout", context)
|
||||
if err != nil {
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
|
||||
log.Printf("❌ Template render error: %v", err)
|
||||
http.Error(w, "Template render error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,8 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
|
||||
"synlotto-website/internal/helpers"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
"synlotto-website/internal/http/middleware"
|
||||
"synlotto-website/internal/models"
|
||||
)
|
||||
@@ -20,7 +19,6 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
limiter := middleware.GetVisitorLimiter(ip)
|
||||
|
||||
if !limiter.Allow() {
|
||||
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
||||
return
|
||||
@@ -46,7 +44,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
||||
doSearch := isValidDate(query) || isValidNumber(query)
|
||||
|
||||
whereClause := "WHERE 1=1"
|
||||
args := []interface{}{}
|
||||
args := []any{}
|
||||
|
||||
if doSearch {
|
||||
whereClause += " AND (draw_date = ? OR id = ?)"
|
||||
@@ -65,7 +63,21 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
||||
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 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -79,7 +91,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
||||
LIMIT ? OFFSET ?`
|
||||
argsWithLimit := append(args, pageSize, offset)
|
||||
|
||||
rows, err := db.Query(querySQL, argsWithLimit...)
|
||||
rows, err := db.QueryContext(r.Context(), querySQL, argsWithLimit...)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
log.Println("❌ DB error:", err)
|
||||
@@ -113,7 +125,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
||||
noResultsMsg = "No results found for \"" + query + "\""
|
||||
}
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("thunderball.html", "templates/results/thunderball.html")
|
||||
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/results/thunderball.html")
|
||||
|
||||
err = tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{
|
||||
"Results": results,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,36 +1,34 @@
|
||||
// internal/handlers/statistics/thunderball.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
templateHandlers "synlotto-website/internal/handlers/template"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
|
||||
"synlotto-website/internal/http/middleware"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
)
|
||||
|
||||
func StatisticsThunderball(db *sql.DB) http.HandlerFunc {
|
||||
func StatisticsThunderball(app *bootstrap.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
limiter := middleware.GetVisitorLimiter(ip)
|
||||
|
||||
if !limiter.Allow() {
|
||||
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
data := templateHandlers.BuildTemplateData(db, w, r)
|
||||
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||
context := templateHelpers.TemplateContext(w, r, data)
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("statistics.html", "templates/statistics/thunderball.html")
|
||||
|
||||
err := tmpl.ExecuteTemplate(w, "layout", context)
|
||||
if err != nil {
|
||||
tmpl := templateHelpers.LoadTemplateFiles("statistics.html", "web/templates/statistics/thunderball.html")
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
|
||||
log.Println("❌ Template render error:", err)
|
||||
http.Error(w, "Error rendering homepage", http.StatusInternalServerError)
|
||||
http.Error(w, "Error rendering Thunderball statistics page", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
56
internal/handlers/template/error.go
Normal file
56
internal/handlers/template/error.go
Normal 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
|
||||
}
|
||||
}
|
||||
19
internal/handlers/template/render.go
Normal file
19
internal/handlers/template/render.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,52 @@
|
||||
package handlers
|
||||
// internal/handlers/template/templatedata.go
|
||||
package templateHandler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
httpHelper "synlotto-website/internal/helpers/http"
|
||||
messageStorage "synlotto-website/internal/storage/messages"
|
||||
notificationStorage "synlotto-website/internal/storage/notifications"
|
||||
usersStorage "synlotto-website/internal/storage/users"
|
||||
|
||||
"synlotto-website/internal/models"
|
||||
"synlotto-website/internal/storage"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
"synlotto-website/internal/platform/sessionkeys"
|
||||
)
|
||||
|
||||
func BuildTemplateData(db *sql.DB, w http.ResponseWriter, r *http.Request) models.TemplateData {
|
||||
session, err := httpHelper.GetSession(w, r)
|
||||
if err != nil {
|
||||
log.Printf("Session error: %v", err)
|
||||
}
|
||||
// BuildTemplateData aggregates common UI data (user, notifications, messages)
|
||||
// from the current SCS session + DB.
|
||||
func BuildTemplateData(app *bootstrap.App, w http.ResponseWriter, r *http.Request) models.TemplateData {
|
||||
sm := app.SessionManager
|
||||
ctx := r.Context()
|
||||
|
||||
var user *models.User
|
||||
var isAdmin bool
|
||||
var notificationCount int
|
||||
var notifications []models.Notification
|
||||
var messageCount int
|
||||
var messages []models.Message
|
||||
var (
|
||||
user *models.User
|
||||
isAdmin bool
|
||||
notificationCount int
|
||||
notifications []models.Notification
|
||||
messageCount int
|
||||
messages []models.Message
|
||||
)
|
||||
|
||||
if userId, ok := session.Values["user_id"].(int); ok {
|
||||
user = storage.GetUserByID(db, userId)
|
||||
if user != nil {
|
||||
isAdmin = user.IsAdmin
|
||||
notificationCount = storage.GetNotificationCount(db, user.Id)
|
||||
notifications = storage.GetRecentNotifications(db, user.Id, 15)
|
||||
messageCount, _ = storage.GetMessageCount(db, user.Id)
|
||||
messages = storage.GetRecentMessages(db, user.Id, 15)
|
||||
// Read user_id from SCS (may be int or int64 depending on writes)
|
||||
if v := sm.Get(ctx, sessionkeys.UserID); v != nil {
|
||||
var uid int64
|
||||
switch t := v.(type) {
|
||||
case int64:
|
||||
uid = t
|
||||
case int:
|
||||
uid = int64(t)
|
||||
}
|
||||
|
||||
if uid > 0 {
|
||||
if u := usersStorage.GetUserByID(app.DB, int(uid)); u != nil {
|
||||
user = u
|
||||
isAdmin = u.IsAdmin
|
||||
notificationCount = notificationStorage.GetNotificationCount(app.DB, int(u.Id))
|
||||
notifications = notificationStorage.GetRecentNotifications(app.DB, int(u.Id), 15)
|
||||
messageCount, _ = messageStorage.GetMessageCount(app.DB, int(u.Id))
|
||||
messages = messageStorage.GetRecentMessages(app.DB, int(u.Id), 15)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
68
internal/helpers/database/statements.go
Normal file
68
internal/helpers/database/statements.go
Normal 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
31
internal/helpers/dates.go
Normal 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")
|
||||
}
|
||||
19
internal/helpers/http/request.go
Normal file
19
internal/helpers/http/request.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,12 @@ package security
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httpHelpers "synlotto-website/internal/helpers/http"
|
||||
"synlotto-website/internal/platform/sessionkeys"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
)
|
||||
|
||||
func GetCurrentUserID(r *http.Request) (int, bool) {
|
||||
session, err := httpHelpers.GetSession(nil, r)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
id, ok := session.Values["user_id"].(int)
|
||||
return id, ok
|
||||
func GetCurrentUserID(sm *scs.SessionManager, r *http.Request) (int, bool) {
|
||||
userID := sm.GetInt(r.Context(), sessionkeys.UserID)
|
||||
return userID, userID != 0
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
69
internal/helpers/session/remember.go
Normal file
69
internal/helpers/session/remember.go
Normal 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
|
||||
}
|
||||
@@ -1,47 +1,53 @@
|
||||
package helpers
|
||||
package templateHelper
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
helpers "synlotto-website/internal/helpers/http"
|
||||
"synlotto-website/internal/models"
|
||||
"synlotto-website/internal/platform/config"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/justinas/nosurf"
|
||||
)
|
||||
|
||||
// ToDo should these structs be here?
|
||||
type siteMeta struct {
|
||||
Name string
|
||||
CopyrightYearStart int
|
||||
}
|
||||
|
||||
var meta siteMeta
|
||||
|
||||
func InitSiteMeta(name string, yearStart, yearEnd int) {
|
||||
meta = siteMeta{
|
||||
Name: name,
|
||||
CopyrightYearStart: yearStart,
|
||||
}
|
||||
}
|
||||
|
||||
var sm *scs.SessionManager
|
||||
|
||||
func InitSessionManager(manager *scs.SessionManager) {
|
||||
sm = manager
|
||||
}
|
||||
|
||||
func TemplateContext(w http.ResponseWriter, r *http.Request, data models.TemplateData) map[string]interface{} {
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
log.Println("⚠️ Config not initialized!")
|
||||
}
|
||||
session, _ := helpers.GetSession(w, r)
|
||||
|
||||
var flash string
|
||||
if f, ok := session.Values["flash"].(string); ok {
|
||||
flash = f
|
||||
delete(session.Values, "flash")
|
||||
session.Save(r, w)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"CSRFField": csrf.TemplateField(r),
|
||||
"Flash": flash,
|
||||
"CSRFToken": nosurf.Token(r),
|
||||
"User": data.User,
|
||||
"IsAdmin": data.IsAdmin,
|
||||
"NotificationCount": data.NotificationCount,
|
||||
"Notifications": data.Notifications,
|
||||
"MessageCount": data.MessageCount,
|
||||
"Messages": data.Messages,
|
||||
"SiteName": cfg.Site.SiteName,
|
||||
"CopyrightYearStart": cfg.Site.CopyrightYearStart,
|
||||
"SiteName": meta.Name,
|
||||
"CopyrightYearStart": meta.CopyrightYearStart,
|
||||
}
|
||||
}
|
||||
|
||||
// ToDo the funcs need breaking up getting large
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"plus1": func(i int) int { return i + 1 },
|
||||
@@ -57,9 +63,8 @@ func TemplateFuncs() template.FuncMap {
|
||||
"min": func(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
} else {
|
||||
return b
|
||||
}
|
||||
return b
|
||||
},
|
||||
"intVal": func(p *int) int {
|
||||
if p == nil {
|
||||
@@ -97,19 +102,18 @@ func TemplateFuncs() template.FuncMap {
|
||||
|
||||
func LoadTemplateFiles(name string, files ...string) *template.Template {
|
||||
shared := []string{
|
||||
"templates/main/layout.html",
|
||||
"templates/main/topbar.html",
|
||||
"templates/main/footer.html",
|
||||
"web/templates/main/layout.html",
|
||||
"web/templates/main/topbar.html",
|
||||
"web/templates/main/footer.html",
|
||||
}
|
||||
all := append(shared, files...)
|
||||
|
||||
return template.Must(template.New(name).Funcs(TemplateFuncs()).ParseFiles(all...))
|
||||
}
|
||||
|
||||
func SetFlash(w http.ResponseWriter, r *http.Request, message string) {
|
||||
session, _ := helpers.GetSession(w, r)
|
||||
session.Values["flash"] = message
|
||||
session.Save(r, w)
|
||||
func SetFlash(r *http.Request, message string) {
|
||||
if sm != nil {
|
||||
sm.Put(r.Context(), "flash", message)
|
||||
}
|
||||
}
|
||||
|
||||
func InSlice(n int, list []int) bool {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package helpers
|
||||
package templateHelper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
"synlotto-website/internal/models"
|
||||
)
|
||||
|
||||
// RenderError renders an HTML error page (e.g., 404.html, 500.html).
|
||||
// It uses TemplateContext which reads site meta from InitSiteMeta().
|
||||
func RenderError(w http.ResponseWriter, r *http.Request, statusCode int) {
|
||||
log.Printf("⚙️ RenderError called with status: %d", statusCode)
|
||||
|
||||
context := TemplateContext(w, r, models.TemplateData{})
|
||||
ctx := TemplateContext(w, r, models.TemplateData{})
|
||||
|
||||
pagePath := fmt.Sprintf("templates/error/%d.html", statusCode)
|
||||
log.Printf("📄 Checking for template file: %s", pagePath)
|
||||
@@ -23,17 +25,14 @@ func RenderError(w http.ResponseWriter, r *http.Request, statusCode int) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("✅ Template file found, loading...")
|
||||
|
||||
tmpl := LoadTemplateFiles(fmt.Sprintf("%d.html", statusCode), pagePath)
|
||||
|
||||
w.WriteHeader(statusCode)
|
||||
err := tmpl.ExecuteTemplate(w, "layout", context)
|
||||
if err != nil {
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", ctx); err != nil {
|
||||
log.Printf("❌ Failed to render error page layout: %v", err)
|
||||
http.Error(w, http.StatusText(statusCode), statusCode)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("✅ Successfully rendered error page") // ToDo: log these to database
|
||||
log.Println("✅ Successfully rendered error page")
|
||||
}
|
||||
|
||||
@@ -1,26 +1,72 @@
|
||||
package helpers
|
||||
// internal/helpers/pagination/pagination.go (move out of template/*)
|
||||
package templateHelper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GetTotalPages(db *sql.DB, tableName, whereClause string, args []interface{}, pageSize int) (totalPages, totalCount int) {
|
||||
query := "SELECT COUNT(*) FROM " + tableName + " " + whereClause
|
||||
row := db.QueryRow(query, args...)
|
||||
if err := row.Scan(&totalCount); err != nil {
|
||||
return 1, 0
|
||||
// Whitelist
|
||||
var allowedTables = map[string]struct{}{
|
||||
"user_messages": {},
|
||||
"user_notifications": {},
|
||||
"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 {
|
||||
totalPages = 1
|
||||
}
|
||||
return totalPages, totalCount
|
||||
return totalPages, totalCount, nil
|
||||
}
|
||||
|
||||
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++ {
|
||||
pages = append(pages, i)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
89
internal/http/error/errors.go
Normal file
89
internal/http/error/errors.go
Normal 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)
|
||||
}
|
||||
}
|
||||
52
internal/http/middleware/admin.go
Normal file
52
internal/http/middleware/admin.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -2,48 +2,119 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
httpHelpers "synlotto-website/internal/helpers/http"
|
||||
sessionHelper "synlotto-website/internal/helpers/session"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
"synlotto-website/internal/platform/sessionkeys"
|
||||
|
||||
"synlotto-website/internal/platform/constants"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Auth(required bool) func(http.HandlerFunc) http.HandlerFunc {
|
||||
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := httpHelpers.GetSession(w, r)
|
||||
// Tracks idle timeout using LastActivity; redirects on timeout.
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
ctx := c.Request.Context()
|
||||
|
||||
_, ok := session.Values["user_id"].(int)
|
||||
|
||||
if required && !ok {
|
||||
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
|
||||
if v := sm.Get(ctx, sessionkeys.LastActivity); v != nil {
|
||||
if last, ok := v.(time.Time); ok && time.Since(last) > sm.Lifetime {
|
||||
// don't destroy here; just rotate and bounce to login with a flash
|
||||
_ = sm.RenewToken(ctx)
|
||||
sm.Put(ctx, sessionkeys.Flash, "Your session has timed out.")
|
||||
c.Redirect(http.StatusSeeOther, "/account/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if ok {
|
||||
last, hasLast := session.Values["last_activity"].(time.Time)
|
||||
if hasLast && time.Since(last) > constants.SessionDuration {
|
||||
session.Options.MaxAge = -1
|
||||
session.Save(r, w)
|
||||
|
||||
newSession, _ := httpHelpers.GetSession(w, r)
|
||||
newSession.Values["flash"] = "Your session has timed out."
|
||||
newSession.Save(r, w)
|
||||
|
||||
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
session.Values["last_activity"] = time.Now()
|
||||
session.Save(r, w)
|
||||
}
|
||||
|
||||
next(w, r)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return Auth(true)(SessionTimeout(h))
|
||||
// Optional remember-me using selector:verifier token pair.
|
||||
func RememberMiddleware(app *bootstrap.App) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sm := app.SessionManager
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Already logged in? Skip.
|
||||
if sm.Exists(ctx, sessionkeys.UserID) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := c.Request.Cookie(app.Config.Session.RememberCookieName)
|
||||
if err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(cookie.Value, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
selector, verifier := parts[0], parts[1]
|
||||
|
||||
userID, hash, expires, revokedAt, err := sessionHelper.FindToken(app.DB, selector)
|
||||
if err != nil || revokedAt != nil || time.Now().After(expires) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if sessionHelper.HashVerifier(verifier) != hash {
|
||||
_ = 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()
|
||||
}
|
||||
}
|
||||
|
||||
26
internal/http/middleware/errorlog.go
Normal file
26
internal/http/middleware/errorlog.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package middleware
|
||||
|
||||
// ToDo: make sure im using with gin
|
||||
import "net/http"
|
||||
|
||||
func EnforceHTTPS(next http.Handler, enabled bool) http.Handler {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package middleware
|
||||
|
||||
// ToDo: make sure im using with gin
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package middleware
|
||||
|
||||
// ToDo: make sure im using with gin not to be confused with gins recovery but may do the same?
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
66
internal/http/middleware/remember.go
Normal file
66
internal/http/middleware/remember.go
Normal 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()
|
||||
}
|
||||
}
|
||||
18
internal/http/middleware/scs.go
Normal file
18
internal/http/middleware/scs.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
accountHandler "synlotto-website/internal/handlers/account"
|
||||
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/platform/bootstrap"
|
||||
)
|
||||
|
||||
func SetupAccountRoutes(mux *http.ServeMux, db *sql.DB) {
|
||||
mux.HandleFunc("/account/login", accountHandlers.Login(db))
|
||||
mux.HandleFunc("/account/logout", middleware.Protected(accountHandlers.Logout))
|
||||
mux.HandleFunc("/account/signup", accountHandlers.Signup)
|
||||
mux.HandleFunc("/account/tickets/add_ticket", lotteryDrawHandlers.AddTicket(db))
|
||||
mux.HandleFunc("/account/tickets/my_tickets", lotteryDrawHandlers.GetMyTickets(db))
|
||||
mux.HandleFunc("/account/messages", middleware.Protected(handlers.MessagesInboxHandler(db)))
|
||||
mux.HandleFunc("/account/messages/read", middleware.Protected(handlers.ReadMessageHandler(db)))
|
||||
mux.HandleFunc("/account/messages/archive", middleware.Protected(handlers.ArchiveMessageHandler(db)))
|
||||
mux.HandleFunc("/account/messages/archived", middleware.Protected(handlers.ArchivedMessagesHandler(db)))
|
||||
mux.HandleFunc("/account/messages/restore", middleware.Protected(handlers.RestoreMessageHandler(db)))
|
||||
mux.HandleFunc("/account/messages/send", middleware.Protected(handlers.SendMessageHandler(db)))
|
||||
mux.HandleFunc("/account/notifications", middleware.Protected(handlers.NotificationsHandler(db)))
|
||||
mux.HandleFunc("/account/notifications/read", middleware.Protected(handlers.MarkNotificationReadHandler(db)))
|
||||
func RegisterAccountRoutes(app *bootstrap.App) {
|
||||
r := app.Router
|
||||
|
||||
// Instantiate handlers that have method receivers
|
||||
messageSvc := app.Services.Messages
|
||||
msgH := &accountMsgHandlers.AccountMessageHandlers{Svc: messageSvc}
|
||||
|
||||
notificationSvc := app.Services.Notifications
|
||||
notifH := &accountNotificationHandler.AccountNotificationHandlers{Svc: notificationSvc}
|
||||
|
||||
// ticketSvc := app.Services.TicketService
|
||||
// ticketH := &accountTickets.AccountTicketHandlers{Svc: ticketSvc}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
admin "synlotto-website/internal/handlers/admin"
|
||||
|
||||
"synlotto-website/internal/http/middleware"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetupAdminRoutes(mux *http.ServeMux, db *sql.DB) {
|
||||
mux.HandleFunc("/admin/access", middleware.Protected(admin.AdminAccessLogHandler(db)))
|
||||
mux.HandleFunc("/admin/audit", middleware.Protected(admin.AuditLogHandler(db)))
|
||||
mux.HandleFunc("/admin/dashboard", middleware.Protected(admin.AdminDashboardHandler(db)))
|
||||
mux.HandleFunc("/admin/triggers", middleware.Protected(admin.AdminTriggersHandler(db)))
|
||||
func RegisterAdminRoutes(app *bootstrap.App) {
|
||||
r := app.Router
|
||||
|
||||
adminGroup := r.Group("/admin")
|
||||
adminGroup.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
|
||||
|
||||
// Logs
|
||||
adminGroup.GET("/access", gin.WrapH(admin.AdminAccessLogHandler(app.DB)))
|
||||
adminGroup.GET("/audit", gin.WrapH(admin.AuditLogHandler(app.DB)))
|
||||
|
||||
// Dashboard
|
||||
//adminGroup.GET("/dashboard", gin.WrapH(admin.AdminDashboardHandler(app.DB)))
|
||||
|
||||
// Triggers
|
||||
adminGroup.GET("/triggers", gin.WrapH(admin.AdminTriggersHandler(app.DB)))
|
||||
|
||||
// Draw management
|
||||
mux.HandleFunc("/admin/draws", middleware.Protected(admin.ListDrawsHandler(db)))
|
||||
// mux.HandleFunc("/admin/draws/new", middleware.AdminOnly(db, admin.RenderNewDrawForm(db)))
|
||||
// mux.HandleFunc("/admin/draws/submit", middleware.AdminOnly(db, admin.CreateDrawHandler(db)))
|
||||
mux.HandleFunc("/admin/draws/modify", middleware.Protected(admin.ModifyDrawHandler(db)))
|
||||
mux.HandleFunc("/admin/draws/delete", middleware.Protected(admin.DeleteDrawHandler(db)))
|
||||
adminGroup.GET("/draws", gin.WrapH(admin.ListDrawsHandler(app.DB)))
|
||||
// adminGroup.GET("/draws/new", gin.WrapH(admin.RenderNewDrawForm(app.DB))) // if/when you re-enable AdminOnly
|
||||
// adminGroup.POST("/draws", gin.WrapH(admin.CreateDrawHandler(app.DB))) // example submit route
|
||||
adminGroup.POST("/draws/modify", gin.WrapH(admin.ModifyDrawHandler(app.DB)))
|
||||
adminGroup.POST("/draws/delete", gin.WrapH(admin.DeleteDrawHandler(app.DB)))
|
||||
|
||||
// Prize management
|
||||
mux.HandleFunc("/admin/draws/prizes/add", middleware.Protected(admin.AddPrizesHandler(db)))
|
||||
mux.HandleFunc("/admin/draws/prizes/modify", middleware.Protected(admin.ModifyPrizesHandler(db)))
|
||||
adminGroup.POST("/draws/prizes/add", gin.WrapH(admin.AddPrizesHandler(app.DB)))
|
||||
adminGroup.POST("/draws/prizes/modify", gin.WrapH(admin.ModifyPrizesHandler(app.DB)))
|
||||
}
|
||||
|
||||
10
internal/http/routes/home.go
Normal file
10
internal/http/routes/home.go
Normal 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))
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
stats "synlotto-website/internal/handlers/statistics"
|
||||
|
||||
handlers "synlotto-website/internal/handlers/statistics"
|
||||
"synlotto-website/internal/http/middleware"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetupStatisticsRoutes(mux *http.ServeMux, db *sql.DB) {
|
||||
mux.HandleFunc("/statistics/thunderball", middleware.Auth(true)(handlers.StatisticsThunderball(db)))
|
||||
// RegisterStatisticsRoutes mounts protected statistics endpoints under /statistics.
|
||||
func RegisterStatisticsRoutes(app *bootstrap.App) {
|
||||
r := app.Router
|
||||
|
||||
group := r.Group("/statistics")
|
||||
group.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
|
||||
|
||||
group.GET("/thunderball", gin.WrapH(stats.StatisticsThunderball(app)))
|
||||
}
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
lotterySyndicateHandlers "synlotto-website/internal/handlers/lottery/syndicate"
|
||||
s "synlotto-website/internal/handlers/lottery/syndicate"
|
||||
|
||||
"synlotto-website/internal/http/middleware"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetupSyndicateRoutes(mux *http.ServeMux, db *sql.DB) {
|
||||
mux.HandleFunc("/syndicate", middleware.Auth(true)(lotterySyndicateHandlers.ListSyndicatesHandler(db)))
|
||||
mux.HandleFunc("/syndicate/create", middleware.Auth(true)(lotterySyndicateHandlers.CreateSyndicateHandler(db)))
|
||||
mux.HandleFunc("/syndicate/view", middleware.Auth(true)(lotterySyndicateHandlers.ViewSyndicateHandler(db)))
|
||||
mux.HandleFunc("/syndicate/tickets", middleware.Auth(true)(lotterySyndicateHandlers.SyndicateTicketsHandler(db)))
|
||||
mux.HandleFunc("/syndicate/tickets/new", middleware.Auth(true)(lotterySyndicateHandlers.SyndicateLogTicketHandler(db)))
|
||||
mux.HandleFunc("/syndicate/invites", middleware.Auth(true)(lotterySyndicateHandlers.ViewInvitesHandler(db)))
|
||||
mux.HandleFunc("/syndicate/invites/accept", middleware.Auth(true)(lotterySyndicateHandlers.AcceptInviteHandler(db)))
|
||||
mux.HandleFunc("/syndicate/invites/decline", middleware.Auth(true)(lotterySyndicateHandlers.DeclineInviteHandler(db)))
|
||||
mux.HandleFunc("/syndicate/invite/token", middleware.Auth(true)(lotterySyndicateHandlers.GenerateInviteLinkHandler(db)))
|
||||
mux.HandleFunc("/syndicate/invite/tokens", middleware.Auth(true)(lotterySyndicateHandlers.ManageInviteTokensHandler(db)))
|
||||
mux.HandleFunc("/syndicate/join", middleware.Auth(true)(lotterySyndicateHandlers.JoinSyndicateWithTokenHandler(db)))
|
||||
// RegisterSyndicateRoutes mounts all /syndicate routes.
|
||||
// Protection is enforced at the group level via AuthMiddleware + RequireAuth.
|
||||
func RegisterSyndicateRoutes(app *bootstrap.App) {
|
||||
r := app.Router
|
||||
|
||||
syn := r.Group("/syndicate")
|
||||
syn.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
|
||||
|
||||
// Use Any to preserve old ServeMux behavior (accepts both GET/POST where applicable).
|
||||
// You can refine methods later (e.g., GET for views, POST for mutate actions).
|
||||
syn.Any("", gin.WrapH(s.ListSyndicatesHandler(app)))
|
||||
syn.Any("/create", gin.WrapH(s.CreateSyndicateHandler(app)))
|
||||
syn.Any("/view", gin.WrapH(s.ViewSyndicateHandler(app)))
|
||||
syn.Any("/tickets", gin.WrapH(s.SyndicateTicketsHandler(app)))
|
||||
syn.Any("/tickets/new", gin.WrapH(s.SyndicateLogTicketHandler(app)))
|
||||
syn.Any("/invites", gin.WrapH(s.ViewInvitesHandler(app)))
|
||||
syn.Any("/invites/accept", gin.WrapH(s.AcceptInviteHandler(app)))
|
||||
syn.Any("/invites/decline", gin.WrapH(s.DeclineInviteHandler(app)))
|
||||
syn.Any("/invite/token", gin.WrapH(s.GenerateInviteLinkHandler(app)))
|
||||
syn.Any("/invite/tokens", gin.WrapH(s.ManageInviteTokensHandler(app)))
|
||||
syn.Any("/join", gin.WrapH(s.JoinSyndicateWithTokenHandler(app)))
|
||||
}
|
||||
|
||||
@@ -4,14 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"synlotto-website/internal/models"
|
||||
"synlotto-website/internal/platform/config"
|
||||
)
|
||||
|
||||
func LogConfig(config *models.Config) {
|
||||
func LogConfig(config *config.Config) {
|
||||
safeConfig := *config
|
||||
safeConfig.CSRF.CSRFKey = "[REDACTED]"
|
||||
safeConfig.Session.AuthKeyPath = "[REDACTED]"
|
||||
safeConfig.Session.EncryptionKeyPath = "[REDACTED]"
|
||||
|
||||
cfg, err := json.MarshalIndent(safeConfig, "", " ")
|
||||
if err != nil {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
17
internal/models/message.go
Normal file
17
internal/models/message.go
Normal 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
|
||||
}
|
||||
12
internal/models/notification.go
Normal file
12
internal/models/notification.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
import "time"
|
||||
|
||||
type Ticket struct {
|
||||
Id int
|
||||
UserId int
|
||||
SyndicateId *int
|
||||
GameType string
|
||||
DrawDate string
|
||||
Ball1 int
|
||||
Ball2 int
|
||||
Ball3 int
|
||||
Ball4 int
|
||||
Ball5 int
|
||||
Ball6 int
|
||||
Id int // Persistent DB primary key
|
||||
UserId int // FK to users(id) when multi-user enabled
|
||||
SyndicateId *int // Optional FK if purchased via syndicate
|
||||
GameType string // Lottery type (e.g., Lotto)
|
||||
DrawDate time.Time // Stored as UTC datetime to avoid timezone issues
|
||||
Ball1 int
|
||||
Ball2 int
|
||||
Ball3 int
|
||||
Ball4 int
|
||||
Ball5 int
|
||||
Ball6 int // Only if game type requires
|
||||
|
||||
// Optional bonus balls
|
||||
Bonus1 *int
|
||||
Bonus2 *int
|
||||
PurchaseMethod string
|
||||
PurchaseDate string
|
||||
PurchaseDate string // TODO: convert to time.Time
|
||||
ImagePath string
|
||||
Duplicate bool
|
||||
Duplicate bool // Calculated during insert
|
||||
MatchedMain int
|
||||
MatchedBonus int
|
||||
PrizeTier string
|
||||
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
|
||||
BonusBalls []int
|
||||
MatchedDraw DrawResult
|
||||
|
||||
@@ -5,30 +5,11 @@ import (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Id int
|
||||
Id int64
|
||||
Username string
|
||||
Email string
|
||||
PasswordHash string
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// 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
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
"time"
|
||||
|
||||
internal "synlotto-website/internal/licensecheck"
|
||||
"synlotto-website/internal/models"
|
||||
"synlotto-website/internal/platform/config"
|
||||
)
|
||||
|
||||
var globalChecker *internal.LicenseChecker
|
||||
|
||||
func InitLicenseChecker(config *models.Config) error {
|
||||
func InitLicenseChecker(config *config.Config) error {
|
||||
checker := &internal.LicenseChecker{
|
||||
LicenseAPIURL: config.License.APIURL,
|
||||
APIKey: config.License.APIKey,
|
||||
|
||||
@@ -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)
|
||||
// - There’s 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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/gob"
|
||||
"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 {
|
||||
Config *models.Config
|
||||
type App struct {
|
||||
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) {
|
||||
file, err := os.Open(configPath)
|
||||
func Load(configPath string) (*App, error) {
|
||||
// Load configuration
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open config: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var config models.Config
|
||||
if err := json.NewDecoder(file).Decode(&config); err != nil {
|
||||
return nil, fmt.Errorf("decode config: %w", err)
|
||||
return nil, fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
return &AppState{
|
||||
Config: &config,
|
||||
}, nil
|
||||
// Open DB
|
||||
db, err := openMySQL(cfg)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"synlotto-website/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
appConfig *models.Config
|
||||
appConfig *Config
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func Init(config *models.Config) {
|
||||
func Init(config *Config) {
|
||||
once.Do(func() {
|
||||
appConfig = config
|
||||
})
|
||||
}
|
||||
|
||||
func Get() *models.Config {
|
||||
func Get() *Config {
|
||||
return appConfig
|
||||
}
|
||||
|
||||
35
internal/platform/config/config.json
Normal file
35
internal/platform/config/config.json
Normal 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
|
||||
}
|
||||
}
|
||||
57
internal/platform/config/load.go
Normal file
57
internal/platform/config/load.go
Normal 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
|
||||
}
|
||||
83
internal/platform/config/types.go
Normal file
83
internal/platform/config/types.go
Normal 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"`
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package constants
|
||||
|
||||
import "time"
|
||||
|
||||
const SessionDuration = 30 * time.Minute
|
||||
63
internal/platform/csrf/csrf.go
Normal file
63
internal/platform/csrf/csrf.go
Normal 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
|
||||
}
|
||||
75
internal/platform/database/schema.go
Normal file
75
internal/platform/database/schema.go
Normal 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()
|
||||
}
|
||||
301
internal/platform/services/messages/service.go
Normal file
301
internal/platform/services/messages/service.go
Normal 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
|
||||
}
|
||||
85
internal/platform/services/notifications/service.go
Normal file
85
internal/platform/services/notifications/service.go
Normal 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
|
||||
}
|
||||
67
internal/platform/session/session.go
Normal file
67
internal/platform/session/session.go
Normal 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
|
||||
}
|
||||
9
internal/platform/sessionkeys/keys.go
Normal file
9
internal/platform/sessionkeys/keys.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package sessionkeys
|
||||
|
||||
const (
|
||||
UserID = "user_id"
|
||||
Username = "username"
|
||||
IsAdmin = "is_admin"
|
||||
LastActivity = "last_activity"
|
||||
Flash = "flash"
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
package services
|
||||
|
||||
// ToDo: these aren't really "services"
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
@@ -5,14 +5,15 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
lotteryTicketHandlers "synlotto-website/internal/handlers/lottery/tickets"
|
||||
thunderballrules "synlotto-website/internal/rules/thunderball"
|
||||
services "synlotto-website/internal/services/draws"
|
||||
drawsSvc "synlotto-website/internal/services/draws"
|
||||
|
||||
"synlotto-website/internal/helpers"
|
||||
"synlotto-website/internal/models"
|
||||
)
|
||||
|
||||
// RunTicketMatching finds unmatched tickets, matches them to draw results,
|
||||
// updates match/prize fields, and writes a summary log entry.
|
||||
func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, error) {
|
||||
stats := models.MatchRunStats{}
|
||||
|
||||
@@ -29,11 +30,13 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er
|
||||
defer rows.Close()
|
||||
|
||||
var pending []models.Ticket
|
||||
|
||||
for rows.Next() {
|
||||
var t models.Ticket
|
||||
var drawDateStr string
|
||||
if dt, err := helpers.ParseDrawDate(drawDateStr); err == nil {
|
||||
t.DrawDate = dt
|
||||
}
|
||||
var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64
|
||||
|
||||
if err := rows.Scan(
|
||||
&t.Id, &t.GameType, &t.DrawDate,
|
||||
&b1, &b2, &b3, &b4, &b5, &b6,
|
||||
@@ -41,7 +44,6 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Ball1 = int(b1.Int64)
|
||||
t.Ball2 = int(b2.Int64)
|
||||
t.Ball3 = int(b3.Int64)
|
||||
@@ -56,32 +58,32 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er
|
||||
|
||||
for _, t := range pending {
|
||||
matchTicket := models.MatchTicket{
|
||||
ID: t.Id,
|
||||
GameType: t.GameType,
|
||||
DrawDate: t.DrawDate,
|
||||
Balls: helpers.BuildBallsSlice(t),
|
||||
BonusBalls: helpers.BuildBonusSlice(t),
|
||||
}
|
||||
|
||||
draw := services.GetDrawResultForTicket(db, t.GameType, t.DrawDate)
|
||||
result := lotteryTicketHandlers.MatchTicketToDraw(matchTicket, draw, thunderballrules.ThunderballPrizeRules)
|
||||
|
||||
if result.MatchedDrawID == 0 {
|
||||
draw := drawsSvc.GetDrawResultForTicket(db, t.GameType, helpers.FormatDrawDate(t.DrawDate))
|
||||
if draw.DrawID == 0 {
|
||||
// No draw yet → skip
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := db.Exec(`
|
||||
mainMatches := helpers.CountMatches(matchTicket.Balls, draw.Balls)
|
||||
bonusMatches := helpers.CountMatches(matchTicket.BonusBalls, draw.BonusBalls)
|
||||
prizeTier := GetPrizeTier(matchTicket.GameType, mainMatches, bonusMatches, thunderballrules.ThunderballPrizeRules)
|
||||
isWinner := prizeTier != ""
|
||||
|
||||
if _, err := db.Exec(`
|
||||
UPDATE my_tickets
|
||||
SET matched_main = ?, matched_bonus = ?, prize_tier = ?, is_winner = ?
|
||||
WHERE id = ?
|
||||
`, result.MatchedMain, result.MatchedBonus, result.PrizeTier, result.IsWinner, t.Id)
|
||||
if err != nil {
|
||||
`, mainMatches, bonusMatches, prizeTier, isWinner, t.Id); err != nil {
|
||||
log.Println("⚠️ Failed to update ticket match:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
stats.TicketsMatched++
|
||||
if result.IsWinner {
|
||||
if isWinner {
|
||||
stats.WinnersFound++
|
||||
}
|
||||
}
|
||||
@@ -94,6 +96,7 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// UpdateMissingPrizes fills in prize labels/amounts for already-matched winners that lack labels.
|
||||
func UpdateMissingPrizes(db *sql.DB) error {
|
||||
type TicketInfo struct {
|
||||
ID int
|
||||
@@ -138,8 +141,7 @@ func UpdateMissingPrizes(db *sql.DB) error {
|
||||
query := fmt.Sprintf(`SELECT prize%d_per_winner FROM prizes_thunderball WHERE draw_date = ?`, idx)
|
||||
|
||||
var amount int
|
||||
err := db.QueryRow(query, t.DrawDate).Scan(&amount)
|
||||
if err != nil {
|
||||
if err := db.QueryRow(query, t.DrawDate).Scan(&amount); err != nil {
|
||||
log.Printf("❌ Prize lookup failed for ticket %d: %v", t.ID, err)
|
||||
continue
|
||||
}
|
||||
@@ -149,11 +151,9 @@ func UpdateMissingPrizes(db *sql.DB) error {
|
||||
label = fmt.Sprintf("£%.2f", float64(amount))
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
if _, err := db.Exec(`
|
||||
UPDATE my_tickets SET prize_amount = ?, prize_label = ? WHERE id = ?
|
||||
`, float64(amount), label, t.ID)
|
||||
|
||||
if err != nil {
|
||||
`, float64(amount), label, t.ID); err != nil {
|
||||
log.Printf("❌ Failed to update ticket %d: %v", t.ID, err)
|
||||
} else {
|
||||
log.Printf("✅ Updated ticket %d → %s", t.ID, label)
|
||||
@@ -163,6 +163,7 @@ func UpdateMissingPrizes(db *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshTicketPrizes recomputes and writes prize info for all tickets.
|
||||
func RefreshTicketPrizes(db *sql.DB) error {
|
||||
type TicketRow struct {
|
||||
ID int
|
||||
@@ -198,13 +199,11 @@ func RefreshTicketPrizes(db *sql.DB) error {
|
||||
|
||||
for _, row := range tickets {
|
||||
matchTicket := models.MatchTicket{
|
||||
GameType: row.GameType,
|
||||
DrawDate: row.DrawDate,
|
||||
Balls: helpers.BuildBallsFromNulls(row.B1, row.B2, row.B3, row.B4, row.B5, row.B6),
|
||||
BonusBalls: helpers.BuildBonusFromNulls(row.Bonus1, row.Bonus2),
|
||||
}
|
||||
|
||||
draw := services.GetDrawResultForTicket(db, row.GameType, row.DrawDate)
|
||||
draw := drawsSvc.GetDrawResultForTicket(db, row.GameType, row.DrawDate)
|
||||
if draw.DrawID == 0 {
|
||||
log.Printf("❌ No draw result for %s (%s)", row.DrawDate, row.GameType)
|
||||
continue
|
||||
@@ -212,18 +211,16 @@ func RefreshTicketPrizes(db *sql.DB) error {
|
||||
|
||||
mainMatches := helpers.CountMatches(matchTicket.Balls, draw.Balls)
|
||||
bonusMatches := helpers.CountMatches(matchTicket.BonusBalls, draw.BonusBalls)
|
||||
prizeTier := matcher.GetPrizeTier(row.GameType, mainMatches, bonusMatches, thunderballrules.ThunderballPrizeRules)
|
||||
prizeTier := GetPrizeTier(row.GameType, mainMatches, bonusMatches, thunderballrules.ThunderballPrizeRules)
|
||||
isWinner := prizeTier != ""
|
||||
|
||||
var label string
|
||||
var amount float64
|
||||
if row.GameType == "Thunderball" {
|
||||
idx, ok := thunderballrules.GetThunderballPrizeIndex(mainMatches, bonusMatches)
|
||||
if ok {
|
||||
if idx, ok := thunderballrules.GetThunderballPrizeIndex(mainMatches, bonusMatches); ok {
|
||||
query := fmt.Sprintf(`SELECT prize%d_per_winner FROM prizes_thunderball WHERE draw_date = ?`, idx)
|
||||
var val int
|
||||
err := db.QueryRow(query, row.DrawDate).Scan(&val)
|
||||
if err == nil {
|
||||
if err := db.QueryRow(query, row.DrawDate).Scan(&val); err == nil {
|
||||
amount = float64(val)
|
||||
if val > 0 {
|
||||
label = fmt.Sprintf("£%.2f", amount)
|
||||
@@ -242,15 +239,15 @@ func RefreshTicketPrizes(db *sql.DB) error {
|
||||
SET matched_main = ?, matched_bonus = ?, prize_tier = ?, is_winner = ?, prize_amount = ?, prize_label = ?
|
||||
WHERE id = ?
|
||||
`, mainMatches, bonusMatches, prizeTier, isWinner, amount, label, row.ID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ Failed to update ticket %d: %v", row.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
rowsAffected, _ := res.RowsAffected()
|
||||
log.Printf("✅ Ticket %d updated — rows affected: %d | Tier: %s | Label: %s | Matches: %d+%d",
|
||||
row.ID, rowsAffected, prizeTier, label, mainMatches, bonusMatches)
|
||||
if rowsAffected, _ := res.RowsAffected(); rowsAffected > 0 {
|
||||
log.Printf("✅ Ticket %d updated — rows affected: %d | Tier: %s | Label: %s | Matches: %d+%d",
|
||||
row.ID, rowsAffected, prizeTier, label, mainMatches, bonusMatches)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
76
internal/storage/auditlog/create.go
Normal file
76
internal/storage/auditlog/create.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"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(`
|
||||
INSERT INTO users_messages (senderId, recipientId, subject, message)
|
||||
INSERT INTO user_messages (senderId, recipientId, subject, body)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, senderID, recipientID, subject, message)
|
||||
`, senderID, recipientID, subject, body)
|
||||
return err
|
||||
}
|
||||
3
internal/storage/messages/delete.go
Normal file
3
internal/storage/messages/delete.go
Normal 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
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
func GetMessageCount(db *sql.DB, userID int) (int, error) {
|
||||
var count int
|
||||
err := db.QueryRow(`
|
||||
SELECT COUNT(*) FROM users_messages
|
||||
SELECT COUNT(*) FROM user_messages
|
||||
WHERE recipientId = ? AND is_read = FALSE AND is_archived = FALSE
|
||||
`, userID).Scan(&count)
|
||||
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 {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, senderId, recipientId, subject, message, is_read, created_at
|
||||
FROM users_messages
|
||||
SELECT id, senderId, recipientId, subject, body, is_read, created_at
|
||||
FROM user_messages
|
||||
WHERE recipientId = ? AND is_archived = FALSE
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
@@ -36,7 +36,7 @@ func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
|
||||
&m.SenderId,
|
||||
&m.RecipientId,
|
||||
&m.Subject,
|
||||
&m.Message,
|
||||
&m.Body,
|
||||
&m.IsRead,
|
||||
&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) {
|
||||
row := db.QueryRow(`
|
||||
SELECT id, senderId, recipientId, subject, message, is_read, created_at
|
||||
FROM users_messages
|
||||
SELECT id, senderId, recipientId, subject, body, is_read, created_at
|
||||
FROM user_messages
|
||||
WHERE id = ? AND recipientId = ?
|
||||
`, messageID, userID)
|
||||
|
||||
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 {
|
||||
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 {
|
||||
offset := (page - 1) * perPage
|
||||
rows, err := db.Query(`
|
||||
SELECT id, senderId, recipientId, subject, message, is_read, created_at, archived_at
|
||||
FROM users_messages
|
||||
SELECT id, senderId, recipientId, subject, body, is_read, created_at, archived_at
|
||||
FROM user_messages
|
||||
WHERE recipientId = ? AND is_archived = TRUE
|
||||
ORDER BY archived_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
@@ -81,7 +81,7 @@ func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Mes
|
||||
var m models.Message
|
||||
err := rows.Scan(
|
||||
&m.ID, &m.SenderId, &m.RecipientId,
|
||||
&m.Subject, &m.Message, &m.IsRead,
|
||||
&m.Subject, &m.Body, &m.IsRead,
|
||||
&m.CreatedAt, &m.ArchivedAt,
|
||||
)
|
||||
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 {
|
||||
offset := (page - 1) * perPage
|
||||
rows, err := db.Query(`
|
||||
SELECT id, senderId, recipientId, subject, message, is_read, created_at
|
||||
FROM users_messages
|
||||
SELECT id, senderId, recipientId, subject, body, is_read, created_at
|
||||
FROM user_messages
|
||||
WHERE recipientId = ? AND is_archived = FALSE
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
@@ -110,7 +110,7 @@ func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Messag
|
||||
var m models.Message
|
||||
err := rows.Scan(
|
||||
&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 {
|
||||
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 {
|
||||
var count int
|
||||
err := db.QueryRow(`
|
||||
SELECT COUNT(*) FROM users_messages
|
||||
SELECT COUNT(*) FROM user_messages
|
||||
WHERE recipientId = ? AND is_archived = FALSE
|
||||
`, userID).Scan(&count)
|
||||
if err != nil {
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
func ArchiveMessage(db *sql.DB, userID, messageID int) error {
|
||||
_, err := db.Exec(`
|
||||
UPDATE users_messages
|
||||
UPDATE user_messages
|
||||
SET is_archived = TRUE, archived_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND recipientId = ?
|
||||
`, messageID, userID)
|
||||
@@ -16,7 +16,7 @@ func ArchiveMessage(db *sql.DB, userID, messageID int) error {
|
||||
|
||||
func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
|
||||
result, err := db.Exec(`
|
||||
UPDATE users_messages
|
||||
UPDATE user_messages
|
||||
SET is_read = TRUE
|
||||
WHERE id = ? AND recipientId = ?
|
||||
`, messageID, userID)
|
||||
@@ -36,7 +36,7 @@ func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
|
||||
|
||||
func RestoreMessage(db *sql.DB, userID, messageID int) error {
|
||||
_, err := db.Exec(`
|
||||
UPDATE users_messages
|
||||
UPDATE user_messages
|
||||
SET is_archived = FALSE, archived_at = NULL
|
||||
WHERE id = ? AND recipientId = ?
|
||||
`, messageID, userID)
|
||||
@@ -6,13 +6,30 @@
|
||||
|
||||
-- USERS
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(191) NOT NULL UNIQUE,
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(191) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
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;
|
||||
|
||||
-- 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 (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
draw_date DATE NOT NULL UNIQUE,
|
||||
@@ -123,20 +140,20 @@ CREATE TABLE IF NOT EXISTS my_tickets (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 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,
|
||||
senderId BIGINT UNSIGNED NOT NULL,
|
||||
recipientId BIGINT UNSIGNED NOT NULL,
|
||||
subject VARCHAR(255) NOT NULL,
|
||||
message MEDIUMTEXT,
|
||||
body MEDIUMTEXT,
|
||||
is_read TINYINT(1) NOT NULL DEFAULT 0,
|
||||
is_archived TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
archived_at DATETIME NULL,
|
||||
CONSTRAINT fk_users_messages_sender
|
||||
CONSTRAINT fk_user_messages_sender
|
||||
FOREIGN KEY (senderId) REFERENCES users(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_users_messages_recipient
|
||||
CONSTRAINT fk_user_messages_recipient
|
||||
FOREIGN KEY (recipientId) REFERENCES users(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -200,14 +217,17 @@ CREATE TABLE IF NOT EXISTS audit_log (
|
||||
ON UPDATE CASCADE ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- AUDIT LOGIN (new)
|
||||
CREATE TABLE IF NOT EXISTS audit_login (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(191),
|
||||
success TINYINT(1),
|
||||
ip VARCHAR(64),
|
||||
user_agent VARCHAR(255),
|
||||
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NULL,
|
||||
username VARCHAR(191) NOT NULL,
|
||||
success TINYINT(1) NOT NULL,
|
||||
ip VARCHAR(64) NOT NULL,
|
||||
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;
|
||||
|
||||
-- SYNDICATES
|
||||
6
internal/storage/migrations/embed.go
Normal file
6
internal/storage/migrations/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package migrations
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed 0001_initial_create.up.sql
|
||||
var InitialSchema string
|
||||
6
internal/storage/migrations/read.go
Normal file
6
internal/storage/migrations/read.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package migrations
|
||||
|
||||
const ProbeUsersTable = `
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = 'users'`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user