Compare commits
82 Commits
db5352bc9c
...
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 | |||
| 0f60be448d | |||
| 82f457c5a4 | |||
| b098915ab9 | |||
| 21ebc9c34b | |||
| d53e27eea8 | |||
| 752db0b89d | |||
| 8d06a7a962 | |||
| 7597fff8b1 | |||
| 58dd313703 | |||
| e0a2e5430e | |||
| 7d26f6cf48 | |||
| 0634876b75 | |||
| b5f1b17684 | |||
| f9853c921d | |||
| e938828a8c | |||
| 2ce810a4dd | |||
| 5c3a847900 | |||
| 05bb05d45c | |||
| 7f91771166 | |||
| f7e9fe7794 | |||
| 2440b3a668 | |||
| 4bb3b58ddb | |||
| 0a21973237 | |||
| 0a5d61ea1e | |||
| d7c15141b8 | |||
| 152c5cb18c | |||
| 8b02a3137d | |||
| f6350b1d7f | |||
| 292db01011 | |||
| 02300c69d8 | |||
| 22fbf59157 | |||
| ef4478e8a6 | |||
| df6608dda5 | |||
| 053ccf3845 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
|
main.exe
|
||||||
synlotto-website.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
|
||||||
|
}
|
||||||
49
go.mod
49
go.mod
@@ -3,23 +3,44 @@ module synlotto-website
|
|||||||
go 1.24.1
|
go 1.24.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gorilla/csrf v1.7.2
|
github.com/alexedwards/scs/v2 v2.9.0
|
||||||
github.com/gorilla/sessions v1.4.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
golang.org/x/crypto v0.36.0
|
github.com/go-sql-driver/mysql v1.9.3
|
||||||
|
github.com/justinas/nosurf v1.2.0
|
||||||
|
golang.org/x/crypto v0.40.0
|
||||||
golang.org/x/time v0.11.0
|
golang.org/x/time v0.11.0
|
||||||
modernc.org/sqlite v1.36.1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // 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/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
modernc.org/libc v1.61.13 // indirect
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
modernc.org/memory v1.8.2 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
|
golang.org/x/mod v0.25.0 // indirect
|
||||||
|
golang.org/x/net v0.42.0 // indirect
|
||||||
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
|
golang.org/x/text v0.27.0 // indirect
|
||||||
|
golang.org/x/tools v0.34.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
147
go.sum
147
go.sum
@@ -1,59 +1,98 @@
|
|||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
|
||||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||||
|
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
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=
|
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"synlotto-website/helpers"
|
|
||||||
"synlotto-website/models"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/csrf"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Login(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
session, _ := helpers.GetSession(w, r)
|
|
||||||
if _, ok := session.Values["user_id"].(int); ok {
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tmpl := helpers.LoadTemplateFiles("login.html", "templates/account/login.html")
|
|
||||||
|
|
||||||
context := helpers.TemplateContext(w, r, models.TemplateData{})
|
|
||||||
context["csrfField"] = csrf.TemplateField(r)
|
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(w, "layout", context)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("❌ Template render error:", err)
|
|
||||||
http.Error(w, "Error rendering login page", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
username := r.FormValue("username")
|
|
||||||
password := r.FormValue("password")
|
|
||||||
|
|
||||||
user := models.GetUserByUsername(username)
|
|
||||||
if user == nil || !helpers.CheckPasswordHash(user.PasswordHash, password) {
|
|
||||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session, _ := helpers.GetSession(w, r)
|
|
||||||
|
|
||||||
for k := range session.Values {
|
|
||||||
delete(session.Values, k)
|
|
||||||
}
|
|
||||||
|
|
||||||
session.Values["user_id"] = user.Id
|
|
||||||
session.Values["last_activity"] = time.Now()
|
|
||||||
|
|
||||||
remember := r.FormValue("remember") == "on"
|
|
||||||
if remember {
|
|
||||||
session.Options.MaxAge = 60 * 60 * 24 * 30
|
|
||||||
} else {
|
|
||||||
session.Options.MaxAge = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
err := session.Save(r, w)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("❌ Failed to save session:", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("✅ Login saved: user_id=%d, maxAge=%d", user.Id, session.Options.MaxAge)
|
|
||||||
for _, c := range r.Cookies() {
|
|
||||||
log.Printf("🍪 Cookie after login: %s = %s", c.Name, c.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if user == nil || !helpers.CheckPasswordHash(user.PasswordHash, password) {
|
|
||||||
models.LogLoginAttempt(username, false)
|
|
||||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
models.LogLoginAttempt(username, true)
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
session, _ := helpers.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 {
|
|
||||||
log.Println("❌ Logout session save failed:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Signup(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
tmpl := helpers.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 := helpers.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, "/login", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
helpers "synlotto-website/helpers"
|
|
||||||
"synlotto-website/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func AdminDashboardHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// userID, ok := helpers.GetCurrentUserID(r)
|
|
||||||
// if !ok {
|
|
||||||
// http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO: check is_admin from users table here
|
|
||||||
|
|
||||||
context := helpers.TemplateContext(w, r, models.TemplateData{})
|
|
||||||
|
|
||||||
// Total ticket stats
|
|
||||||
var total, winners int
|
|
||||||
var prizeSum float64
|
|
||||||
db.QueryRow(`SELECT COUNT(*), SUM(CASE WHEN is_winner THEN 1 ELSE 0 END), SUM(prize_amount) FROM my_tickets`).Scan(&total, &winners, &prizeSum)
|
|
||||||
context["Stats"] = map[string]interface{}{
|
|
||||||
"TotalTickets": total,
|
|
||||||
"TotalWinners": winners,
|
|
||||||
"TotalPrizeAmount": prizeSum,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match run log
|
|
||||||
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
|
|
||||||
`)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
logs = append(logs, logEntry)
|
|
||||||
}
|
|
||||||
context["MatchLogs"] = logs
|
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("dashboard.html", "templates/admin/dashboard.html")
|
|
||||||
|
|
||||||
err = tmpl.ExecuteTemplate(w, "layout", context)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Failed to render dashboard", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"synlotto-website/helpers"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Home(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := BuildTemplateData(db, w, r)
|
|
||||||
context := helpers.TemplateContext(w, r, data)
|
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("index.html", "templates/index.html")
|
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(w, "layout", context)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("❌ Template render error:", err)
|
|
||||||
http.Error(w, "Error rendering homepage", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"synlotto-website/helpers"
|
|
||||||
"synlotto-website/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
func MessagesInboxHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID, ok := helpers.GetCurrentUserID(r)
|
|
||||||
if !ok {
|
|
||||||
helpers.RenderError(w, r, 403)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
page := helpers.Atoi(r.URL.Query().Get("page"))
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
perPage := 10
|
|
||||||
|
|
||||||
totalCount := storage.GetInboxMessageCount(db, userID)
|
|
||||||
totalPages := (totalCount + perPage - 1) / perPage
|
|
||||||
if totalPages == 0 {
|
|
||||||
totalPages = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
messages := storage.GetInboxMessages(db, userID, page, perPage)
|
|
||||||
|
|
||||||
data := BuildTemplateData(db, w, r)
|
|
||||||
context := helpers.TemplateContext(w, r, data)
|
|
||||||
|
|
||||||
context["Messages"] = messages
|
|
||||||
context["CurrentPage"] = page
|
|
||||||
context["TotalPages"] = totalPages
|
|
||||||
context["PageRange"] = helpers.PageRange(page, totalPages)
|
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("messages.html", "templates/account/messages/index.html")
|
|
||||||
|
|
||||||
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
|
|
||||||
helpers.RenderError(w, r, 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ReadMessageHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
idStr := r.URL.Query().Get("id")
|
|
||||||
messageID := helpers.Atoi(idStr)
|
|
||||||
|
|
||||||
session, _ := helpers.GetSession(w, r)
|
|
||||||
userID, ok := session.Values["user_id"].(int)
|
|
||||||
if !ok {
|
|
||||||
helpers.RenderError(w, r, 403)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
message, err := storage.GetMessageByID(db, userID, messageID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ Message not found: %v", err)
|
|
||||||
message = nil
|
|
||||||
} else if !message.IsRead {
|
|
||||||
_ = storage.MarkMessageAsRead(db, messageID, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
data := BuildTemplateData(db, w, r)
|
|
||||||
context := helpers.TemplateContext(w, r, data)
|
|
||||||
context["Message"] = message
|
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("read-message.html", "templates/account/messages/read.html")
|
|
||||||
|
|
||||||
tmpl.ExecuteTemplate(w, "layout", context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ArchiveMessageHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := helpers.Atoi(r.URL.Query().Get("id"))
|
|
||||||
userID, ok := helpers.GetCurrentUserID(r)
|
|
||||||
if !ok {
|
|
||||||
helpers.RenderError(w, r, 403)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := storage.ArchiveMessage(db, userID, id)
|
|
||||||
if err != nil {
|
|
||||||
helpers.SetFlash(w, r, "Failed to archive message.")
|
|
||||||
} else {
|
|
||||||
helpers.SetFlash(w, r, "Message archived.")
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/account/messages", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ArchivedMessagesHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID, ok := helpers.GetCurrentUserID(r)
|
|
||||||
if !ok {
|
|
||||||
helpers.RenderError(w, r, 403)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
page := helpers.Atoi(r.URL.Query().Get("page"))
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
perPage := 10
|
|
||||||
|
|
||||||
messages := storage.GetArchivedMessages(db, userID, page, perPage)
|
|
||||||
hasMore := len(messages) == perPage
|
|
||||||
|
|
||||||
data := BuildTemplateData(db, w, r)
|
|
||||||
context := helpers.TemplateContext(w, r, data)
|
|
||||||
context["Messages"] = messages
|
|
||||||
context["Page"] = page
|
|
||||||
context["HasMore"] = hasMore
|
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("archived.html", "templates/account/messages/archived.html")
|
|
||||||
tmpl.ExecuteTemplate(w, "layout", context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func SendMessageHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
// Display the form
|
|
||||||
data := BuildTemplateData(db, w, r)
|
|
||||||
context := helpers.TemplateContext(w, r, data)
|
|
||||||
tmpl := helpers.LoadTemplateFiles("send-message.html", "templates/account/messages/send.html")
|
|
||||||
|
|
||||||
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
|
|
||||||
helpers.RenderError(w, r, 500)
|
|
||||||
}
|
|
||||||
case http.MethodPost:
|
|
||||||
// Handle form submission
|
|
||||||
senderID, ok := helpers.GetCurrentUserID(r)
|
|
||||||
if !ok {
|
|
||||||
helpers.RenderError(w, r, 403)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
recipientID := helpers.Atoi(r.FormValue("recipient_id"))
|
|
||||||
subject := r.FormValue("subject")
|
|
||||||
body := r.FormValue("message")
|
|
||||||
|
|
||||||
if err := storage.SendMessage(db, senderID, recipientID, subject, body); err != nil {
|
|
||||||
helpers.SetFlash(w, r, "Failed to send message.")
|
|
||||||
} else {
|
|
||||||
helpers.SetFlash(w, r, "Message sent.")
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/account/messages", http.StatusSeeOther)
|
|
||||||
default:
|
|
||||||
helpers.RenderError(w, r, 405)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func RestoreMessageHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := helpers.Atoi(r.URL.Query().Get("id"))
|
|
||||||
userID, ok := helpers.GetCurrentUserID(r)
|
|
||||||
if !ok {
|
|
||||||
helpers.RenderError(w, r, 403)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := storage.RestoreMessage(db, userID, id)
|
|
||||||
if err != nil {
|
|
||||||
helpers.SetFlash(w, r, "Failed to restore message.")
|
|
||||||
} else {
|
|
||||||
helpers.SetFlash(w, r, "Message restored.")
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/account/messages/archived", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"synlotto-website/helpers"
|
|
||||||
"synlotto-website/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NotificationsHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := BuildTemplateData(db, w, r)
|
|
||||||
context := helpers.TemplateContext(w, r, data)
|
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("index.html", "templates/account/notifications/index.html")
|
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(w, "layout", context)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("❌ Template render error:", err)
|
|
||||||
http.Error(w, "Error rendering notifications page", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func MarkNotificationReadHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
notificationIDStr := r.URL.Query().Get("id")
|
|
||||||
notificationID, err := strconv.Atoi(notificationIDStr)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session, _ := helpers.GetSession(w, r)
|
|
||||||
userID, ok := session.Values["user_id"].(int)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notification, err := storage.GetNotificationByID(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 {
|
|
||||||
log.Printf("⚠️ Failed to mark as read: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data := BuildTemplateData(db, w, r)
|
|
||||||
context := helpers.TemplateContext(w, r, data)
|
|
||||||
context["Notification"] = notification
|
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("read.html", "templates/account/notifications/read.html")
|
|
||||||
|
|
||||||
err = tmpl.ExecuteTemplate(w, "layout", context)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ Template render error: %v", err)
|
|
||||||
http.Error(w, "Template render error", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"net/http"
|
|
||||||
"synlotto-website/helpers"
|
|
||||||
"synlotto-website/models"
|
|
||||||
"synlotto-website/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
func BuildTemplateData(db *sql.DB, w http.ResponseWriter, r *http.Request) models.TemplateData {
|
|
||||||
session, _ := helpers.GetSession(w, r)
|
|
||||||
|
|
||||||
var user *models.User
|
|
||||||
var isAdmin bool
|
|
||||||
var notificationCount int
|
|
||||||
var notifications []models.Notification
|
|
||||||
var messageCount int
|
|
||||||
var messages []models.Message
|
|
||||||
|
|
||||||
switch v := session.Values["user_id"].(type) {
|
|
||||||
case int:
|
|
||||||
user = models.GetUserByID(v)
|
|
||||||
case int64:
|
|
||||||
user = models.GetUserByID(int(v))
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return models.TemplateData{
|
|
||||||
User: user,
|
|
||||||
IsAdmin: isAdmin,
|
|
||||||
NotificationCount: notificationCount,
|
|
||||||
Notifications: notifications,
|
|
||||||
MessageCount: messageCount,
|
|
||||||
Messages: messages,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package helpers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
totalPages = (totalCount + pageSize - 1) / pageSize
|
|
||||||
if totalPages < 1 {
|
|
||||||
totalPages = 1
|
|
||||||
}
|
|
||||||
return totalPages, totalCount
|
|
||||||
}
|
|
||||||
|
|
||||||
func MakePageRange(current, total int) []int {
|
|
||||||
var pages []int
|
|
||||||
for i := 1; i <= total; i++ {
|
|
||||||
pages = append(pages, i)
|
|
||||||
}
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package helpers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/gob"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
|
||||||
)
|
|
||||||
|
|
||||||
var authKey = []byte("12345678901234567890123456789012") // ToDo: Make env var
|
|
||||||
var encryptKey = []byte("abcdefghijklmnopqrstuvwx12345678") // ToDo: Make env var
|
|
||||||
var sessionName = "synlotto-session"
|
|
||||||
var store = sessions.NewCookieStore(authKey, encryptKey)
|
|
||||||
|
|
||||||
const SessionTimeout = 30 * time.Minute
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
gob.Register(time.Time{})
|
|
||||||
|
|
||||||
store.Options = &sessions.Options{
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: 86400 * 1,
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: false, // TODO: make env-configurable
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetSession(w http.ResponseWriter, r *http.Request) (*sessions.Session, error) {
|
|
||||||
return store.Get(r, sessionName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsSessionExpired(session *sessions.Session) bool {
|
|
||||||
last, ok := session.Values["last_activity"].(time.Time)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return time.Since(last) > SessionTimeout
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateSessionActivity(session *sessions.Session, r *http.Request, w http.ResponseWriter) {
|
|
||||||
session.Values["last_activity"] = time.Now()
|
|
||||||
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, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateSessionActivity(session, r, w)
|
|
||||||
|
|
||||||
next(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetCurrentUserID(r *http.Request) (int, bool) {
|
|
||||||
session, err := GetSession(nil, r)
|
|
||||||
if err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
id, ok := session.Values["user_id"].(int)
|
|
||||||
return id, ok
|
|
||||||
}
|
|
||||||
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)
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
@@ -4,9 +4,10 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"synlotto-website/helpers"
|
|
||||||
"synlotto-website/middleware"
|
templateHelpers "synlotto-website/internal/helpers/template"
|
||||||
"synlotto-website/models"
|
|
||||||
|
"synlotto-website/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AdminLogEntry struct {
|
type AdminLogEntry struct {
|
||||||
@@ -18,8 +19,9 @@ type AdminLogEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc {
|
func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc {
|
||||||
return middleware.Auth(true)(func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
context := helpers.TemplateContext(w, r, models.TemplateData{})
|
data := models.TemplateData{}
|
||||||
|
context := templateHelpers.TemplateContext(w, r, data)
|
||||||
|
|
||||||
rows, err := db.Query(`
|
rows, err := db.Query(`
|
||||||
SELECT accessed_at, user_id, path, ip, user_agent
|
SELECT accessed_at, user_id, path, ip, user_agent
|
||||||
@@ -34,7 +36,7 @@ func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var logs []AdminLogEntry
|
var logs []AdminLogEntry // ToDo: move to models ?
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var entry AdminLogEntry
|
var entry AdminLogEntry
|
||||||
if err := rows.Scan(&entry.AccessedAt, &entry.UserID, &entry.Path, &entry.IP, &entry.UserAgent); err != nil {
|
if err := rows.Scan(&entry.AccessedAt, &entry.UserID, &entry.Path, &entry.IP, &entry.UserAgent); err != nil {
|
||||||
@@ -45,15 +47,15 @@ func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
context["AuditLogs"] = logs
|
context["AuditLogs"] = logs
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("access_log.html", "templates/admin/logs/access_log.html")
|
tmpl := templateHelpers.LoadTemplateFiles("access_log.html", "web/templates/admin/logs/access_log.html")
|
||||||
|
|
||||||
_ = tmpl.ExecuteTemplate(w, "layout", context)
|
_ = tmpl.ExecuteTemplate(w, "layout", context)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func AuditLogHandler(db *sql.DB) http.HandlerFunc {
|
func AuditLogHandler(db *sql.DB) http.HandlerFunc {
|
||||||
return middleware.Auth(true)(func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
context := helpers.TemplateContext(w, r, models.TemplateData{})
|
data := models.TemplateData{}
|
||||||
|
context := templateHelpers.TemplateContext(w, r, data)
|
||||||
|
|
||||||
rows, err := db.Query(`
|
rows, err := db.Query(`
|
||||||
SELECT timestamp, user_id, action, ip, user_agent
|
SELECT timestamp, user_id, action, ip, user_agent
|
||||||
@@ -71,8 +73,7 @@ func AuditLogHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
var logs []models.AuditEntry
|
var logs []models.AuditEntry
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var entry models.AuditEntry
|
var entry models.AuditEntry
|
||||||
err := rows.Scan(&entry.Timestamp, &entry.UserID, &entry.Action, &entry.IP, &entry.UserAgent)
|
if err := rows.Scan(&entry.Timestamp, &entry.UserID, &entry.Action, &entry.IP, &entry.UserAgent); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Println("⚠️ Failed to scan row:", err)
|
log.Println("⚠️ Failed to scan row:", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -81,12 +82,10 @@ func AuditLogHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
context["AuditLogs"] = logs
|
context["AuditLogs"] = logs
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("audit.html", "templates/admin/logs/audit.html")
|
tmpl := templateHelpers.LoadTemplateFiles("audit.html", "web/templates/admin/logs/audit.html")
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
|
||||||
err = tmpl.ExecuteTemplate(w, "layout", context)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("❌ Failed to render audit page:", err)
|
log.Println("❌ Failed to render audit page:", err)
|
||||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
96
internal/handlers/admin/dashboard.go
Normal file
96
internal/handlers/admin/dashboard.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// internal/handlers/admin/dashboard.go
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
// ToDo: move SQL into storage layer
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
templateHandlers "synlotto-website/internal/handlers/template"
|
||||||
|
security "synlotto-website/internal/helpers/security"
|
||||||
|
templateHelpers "synlotto-website/internal/helpers/template"
|
||||||
|
"synlotto-website/internal/platform/bootstrap"
|
||||||
|
usersStorage "synlotto-website/internal/storage/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AdminDashboardHandler(app *bootstrap.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := security.GetCurrentUserID(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := usersStorage.GetUserByID(app.DB, userID)
|
||||||
|
if user == nil {
|
||||||
|
http.Error(w, "User not found", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
} else {
|
||||||
|
defer rows.Close()
|
||||||
|
var logs []struct {
|
||||||
|
RunAt any
|
||||||
|
TriggeredBy string
|
||||||
|
TicketsMatched int
|
||||||
|
WinnersFound int
|
||||||
|
Notes string
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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,17 +1,19 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
|
// ToDo: move SQL into storage layer
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
helpers "synlotto-website/helpers"
|
templateHelpers "synlotto-website/internal/helpers/template"
|
||||||
"synlotto-website/models"
|
"synlotto-website/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDrawHandler(db *sql.DB) http.HandlerFunc {
|
func NewDrawHandler(db *sql.DB) http.HandlerFunc {
|
||||||
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
context := helpers.TemplateContext(w, r, models.TemplateData{})
|
data := models.TemplateData{}
|
||||||
|
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||||
|
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
game := r.FormValue("game_type")
|
game := r.FormValue("game_type")
|
||||||
@@ -19,29 +21,35 @@ func NewDrawHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
machine := r.FormValue("machine")
|
machine := r.FormValue("machine")
|
||||||
ballset := r.FormValue("ball_set")
|
ballset := r.FormValue("ball_set")
|
||||||
|
|
||||||
_, err := db.Exec(`INSERT INTO results_thunderball (game_type, draw_date, machine, ball_set) VALUES (?, ?, ?, ?)`,
|
_, err := db.Exec(
|
||||||
game, date, machine, ballset)
|
`INSERT INTO results_thunderball (game_type, draw_date, machine, ball_set) VALUES (?, ?, ?, ?)`,
|
||||||
|
game, date, machine, ballset,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to add draw", http.StatusInternalServerError)
|
http.Error(w, "Failed to add draw", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("new_draw", "templates/admin/draws/new_draw.html")
|
tmpl := templateHelpers.LoadTemplateFiles("new_draw", "web/templates/admin/draws/new_draw.html")
|
||||||
|
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||||
tmpl.ExecuteTemplate(w, "layout", context)
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ModifyDrawHandler(db *sql.DB) http.HandlerFunc {
|
func ModifyDrawHandler(db *sql.DB) http.HandlerFunc {
|
||||||
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
id := r.FormValue("id")
|
id := r.FormValue("id")
|
||||||
_, err := db.Exec(`UPDATE results_thunderball SET game_type=?, draw_date=?, ball_set=?, machine=? WHERE id=?`,
|
_, err := db.Exec(
|
||||||
r.FormValue("game_type"), r.FormValue("draw_date"), r.FormValue("ball_set"), r.FormValue("machine"), id)
|
`UPDATE results_thunderball SET game_type=?, draw_date=?, ball_set=?, machine=? WHERE id=?`,
|
||||||
|
r.FormValue("game_type"),
|
||||||
|
r.FormValue("draw_date"),
|
||||||
|
r.FormValue("ball_set"),
|
||||||
|
r.FormValue("machine"),
|
||||||
|
id,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Update failed", http.StatusInternalServerError)
|
http.Error(w, "Update failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -49,32 +57,30 @@ func ModifyDrawHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// For GET: load draw by ID (pseudo-code)
|
// For GET: load draw by ID if needed and render a form/template
|
||||||
// id := r.URL.Query().Get("id")
|
}
|
||||||
// query DB, pass into context.Draw
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteDrawHandler(db *sql.DB) http.HandlerFunc {
|
func DeleteDrawHandler(db *sql.DB) http.HandlerFunc {
|
||||||
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
id := r.FormValue("id")
|
id := r.FormValue("id")
|
||||||
_, err := db.Exec(`DELETE FROM results_thunderball WHERE id = ?`, id)
|
if _, err := db.Exec(`DELETE FROM results_thunderball WHERE id = ?`, id); err != nil {
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Delete failed", http.StatusInternalServerError)
|
http.Error(w, "Delete failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListDrawsHandler(db *sql.DB) http.HandlerFunc {
|
func ListDrawsHandler(db *sql.DB) http.HandlerFunc {
|
||||||
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
context := helpers.TemplateContext(w, r, models.TemplateData{})
|
data := models.TemplateData{}
|
||||||
draws := []models.DrawSummary{}
|
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||||
|
|
||||||
|
var draws []models.DrawSummary
|
||||||
rows, err := db.Query(`
|
rows, err := db.Query(`
|
||||||
SELECT r.id, r.game_type, r.draw_date, r.ball_set, r.machine,
|
SELECT r.id, r.game_type, r.draw_date, r.ball_set, r.machine,
|
||||||
(SELECT COUNT(1) FROM prizes_thunderball p WHERE p.draw_date = r.draw_date) as prize_exists
|
(SELECT COUNT(1) FROM prizes_thunderball p WHERE p.draw_date = r.draw_date) as prize_exists
|
||||||
@@ -97,11 +103,9 @@ func ListDrawsHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
d.PrizeSet = prizeFlag > 0
|
d.PrizeSet = prizeFlag > 0
|
||||||
draws = append(draws, d)
|
draws = append(draws, d)
|
||||||
}
|
}
|
||||||
|
ctx["Draws"] = draws
|
||||||
|
|
||||||
context["Draws"] = draws
|
tmpl := templateHelpers.LoadTemplateFiles("list.html", "web/templates/admin/draws/list.html")
|
||||||
|
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||||
tmpl := helpers.LoadTemplateFiles("list.html", "templates/admin/draws/list.html")
|
}
|
||||||
|
|
||||||
tmpl.ExecuteTemplate(w, "layout", context)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
@@ -8,14 +8,17 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"synlotto-website/helpers"
|
templateHelpers "synlotto-website/internal/helpers/template"
|
||||||
"synlotto-website/models"
|
services "synlotto-website/internal/services/tickets"
|
||||||
services "synlotto-website/services/tickets"
|
|
||||||
|
"synlotto-website/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ToDo: need to fix flash messages from new gin context
|
||||||
func AdminTriggersHandler(db *sql.DB) http.HandlerFunc {
|
func AdminTriggersHandler(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
context := helpers.TemplateContext(w, r, models.TemplateData{})
|
data := models.TemplateData{}
|
||||||
|
context := templateHelpers.TemplateContext(w, r, data)
|
||||||
|
|
||||||
if flash := r.URL.Query().Get("flash"); flash != "" {
|
if flash := r.URL.Query().Get("flash"); flash != "" {
|
||||||
context["Flash"] = flash
|
context["Flash"] = flash
|
||||||
@@ -71,7 +74,7 @@ func AdminTriggersHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("triggers.html", "templates/admin/triggers.html")
|
tmpl := templateHelpers.LoadTemplateFiles("triggers.html", "web/templates/admin/triggers.html")
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(w, "layout", context)
|
err := tmpl.ExecuteTemplate(w, "layout", context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -5,21 +5,24 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"synlotto-website/helpers"
|
|
||||||
"synlotto-website/models"
|
templateHelpers "synlotto-website/internal/helpers/template"
|
||||||
|
"synlotto-website/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func AddPrizesHandler(db *sql.DB) http.HandlerFunc {
|
// ToDo: move SQL into the storage layer.
|
||||||
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
tmpl := helpers.LoadTemplateFiles("add_prizes.html", "templates/admin/draws/prizes/add_prizes.html")
|
|
||||||
|
|
||||||
tmpl.ExecuteTemplate(w, "layout", helpers.TemplateContext(w, r, models.TemplateData{}))
|
func AddPrizesHandler(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := models.TemplateData{}
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
tmpl := templateHelpers.LoadTemplateFiles("add_prizes.html", "web/templates/admin/draws/prizes/add_prizes.html")
|
||||||
|
_ = tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
drawDate := r.FormValue("draw_date")
|
drawDate := r.FormValue("draw_date")
|
||||||
values := make([]interface{}, 0)
|
values := make([]interface{}, 0, 9)
|
||||||
for i := 1; i <= 9; i++ {
|
for i := 1; i <= 9; i++ {
|
||||||
val, _ := strconv.Atoi(r.FormValue(fmt.Sprintf("prize%d_per_winner", i)))
|
val, _ := strconv.Atoi(r.FormValue(fmt.Sprintf("prize%d_per_winner", i)))
|
||||||
values = append(values, val)
|
values = append(values, val)
|
||||||
@@ -31,22 +34,21 @@ func AddPrizesHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
prize7_per_winner, prize8_per_winner, prize9_per_winner
|
prize7_per_winner, prize8_per_winner, prize9_per_winner
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
|
||||||
_, err := db.Exec(stmt, append([]interface{}{drawDate}, values...)...)
|
if _, err := db.Exec(stmt, append([]interface{}{drawDate}, values...)...); err != nil {
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Insert failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Insert failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/admin/draws", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/draws", http.StatusSeeOther)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ModifyPrizesHandler(db *sql.DB) http.HandlerFunc {
|
func ModifyPrizesHandler(db *sql.DB) http.HandlerFunc {
|
||||||
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := models.TemplateData{}
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
tmpl := helpers.LoadTemplateFiles("modify_prizes.html", "templates/admin/draws/prizes/modify_prizes.html")
|
tmpl := templateHelpers.LoadTemplateFiles("modify_prizes.html", "web/templates/admin/draws/prizes/modify_prizes.html")
|
||||||
|
_ = tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data))
|
||||||
tmpl.ExecuteTemplate(w, "layout", helpers.TemplateContext(w, r, models.TemplateData{}))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,13 +56,12 @@ func ModifyPrizesHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
for i := 1; i <= 9; i++ {
|
for i := 1; i <= 9; i++ {
|
||||||
key := fmt.Sprintf("prize%d_per_winner", i)
|
key := fmt.Sprintf("prize%d_per_winner", i)
|
||||||
val, _ := strconv.Atoi(r.FormValue(key))
|
val, _ := strconv.Atoi(r.FormValue(key))
|
||||||
_, err := db.Exec("UPDATE prizes_thunderball SET "+key+" = ? WHERE draw_date = ?", val, drawDate)
|
if _, err := db.Exec("UPDATE prizes_thunderball SET "+key+" = ? WHERE draw_date = ?", val, drawDate); err != nil {
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Update failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Update failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/admin/draws", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/draws", http.StatusSeeOther)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"synlotto-website/models"
|
"synlotto-website/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Draws []models.ThunderballResult
|
var Draws []models.ThunderballResult
|
||||||
29
internal/handlers/home.go
Normal file
29
internal/handlers/home.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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(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("layout.html", "web/templates/index.html")
|
||||||
|
|
||||||
|
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)
|
||||||
|
c.String(http.StatusInternalServerError, "Template render error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,19 +5,20 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"synlotto-website/helpers"
|
"synlotto-website/internal/helpers"
|
||||||
"synlotto-website/models"
|
templateHelpers "synlotto-website/internal/helpers/template"
|
||||||
|
"synlotto-website/internal/models"
|
||||||
|
resultsThunderballStorage "synlotto-website/internal/storage/results/thunderball"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDraw(db *sql.DB) http.HandlerFunc {
|
func NewDraw(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
log.Println("➡️ New draw form opened")
|
data := models.TemplateData{}
|
||||||
|
context := templateHelpers.TemplateContext(w, r, data)
|
||||||
context := helpers.TemplateContext(w, r, models.TemplateData{})
|
|
||||||
context["Page"] = "new_draw"
|
context["Page"] = "new_draw"
|
||||||
context["Data"] = nil
|
context["Data"] = nil
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("new_draw.html", "templates/new_draw.html") // ToDo: may need removing or moving add draw should be admin functionality and only when manually required. Potential live drawing of numbers in the future.
|
tmpl := templateHelpers.LoadTemplateFiles("new_draw.html", "web/templates/admin/draws/new_draw.html") // ToDo: may need removing or moving add draw should be admin functionality and only when manually required. Potential live drawing of numbers in the future.
|
||||||
|
|
||||||
err := tmpl.ExecuteTemplate(w, "layout", context)
|
err := tmpl.ExecuteTemplate(w, "layout", context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -27,7 +28,7 @@ func NewDraw(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Submit(w http.ResponseWriter, r *http.Request) {
|
func Submit(db *sql.DB, w http.ResponseWriter, r *http.Request) {
|
||||||
log.Println("📝 Form submission received")
|
log.Println("📝 Form submission received")
|
||||||
_ = r.ParseForm()
|
_ = r.ParseForm()
|
||||||
|
|
||||||
@@ -43,7 +44,12 @@ func Submit(w http.ResponseWriter, r *http.Request) {
|
|||||||
Thunderball: helpers.Atoi(r.FormValue("thunderball")),
|
Thunderball: helpers.Atoi(r.FormValue("thunderball")),
|
||||||
}
|
}
|
||||||
|
|
||||||
Draws = append(Draws, 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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("📅 %s | 🛠 %s | 🎱 %d | 🔢 %d,%d,%d,%d,%d | ⚡ %d\n",
|
log.Printf("📅 %s | 🛠 %s | 🎱 %d | 🔢 %d,%d,%d,%d,%d | ⚡ %d\n",
|
||||||
draw.DrawDate, draw.Machine, draw.BallSet,
|
draw.DrawDate, draw.Machine, draw.BallSet,
|
||||||
212
internal/handlers/lottery/syndicate/syndicate.go
Normal file
212
internal/handlers/lottery/syndicate/syndicate.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// internal/handlers/lottery/syndicate/syndicate.go
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
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/platform/bootstrap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateSyndicateHandler(app *bootstrap.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
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(app.SessionManager, r)
|
||||||
|
if !ok || name == "" {
|
||||||
|
templateHelpers.SetFlash(r, "Invalid data submitted")
|
||||||
|
http.Redirect(w, r, "/syndicate/create", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := syndicateStorage.CreateSyndicate(app.DB, userId, name, description); err != nil {
|
||||||
|
log.Printf("❌ CreateSyndicate failed: %v", err)
|
||||||
|
templateHelpers.SetFlash(r, "Failed to create syndicate")
|
||||||
|
} else {
|
||||||
|
templateHelpers.SetFlash(r, "Syndicate created successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
|
||||||
|
default:
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListSyndicatesHandler(app *bootstrap.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
managed := syndicateStorage.GetSyndicatesByOwner(app.DB, userID)
|
||||||
|
member := syndicateStorage.GetSyndicatesByMember(app.DB, userID)
|
||||||
|
|
||||||
|
managedMap := make(map[int]bool, len(managed))
|
||||||
|
for _, s := range managed {
|
||||||
|
managedMap[s.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var filteredJoined []models.Syndicate
|
||||||
|
for _, s := range member {
|
||||||
|
if !managedMap[s.ID] {
|
||||||
|
filteredJoined = append(filteredJoined, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||||
|
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||||
|
ctx["ManagedSyndicates"] = managed
|
||||||
|
ctx["JoinedSyndicates"] = filteredJoined
|
||||||
|
|
||||||
|
tmpl := templateHelpers.LoadTemplateFiles("syndicates.html", "web/templates/syndicate/index.html")
|
||||||
|
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ViewSyndicateHandler(app *bootstrap.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
|
||||||
|
syndicate, err := syndicateStorage.GetSyndicateByID(app.DB, syndicateID)
|
||||||
|
if err != nil || syndicate == nil {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isManager := userID == syndicate.OwnerID
|
||||||
|
isMember := syndicateStorage.IsSyndicateMember(app.DB, syndicateID, userID)
|
||||||
|
if !isManager && !isMember {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
members := syndicateStorage.GetSyndicateMembers(app.DB, syndicateID)
|
||||||
|
|
||||||
|
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", "web/templates/syndicate/view.html")
|
||||||
|
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SyndicateLogTicketHandler(app *bootstrap.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syndicateId := helpers.Atoi(r.URL.Query().Get("id"))
|
||||||
|
syndicate, err := syndicateStorage.GetSyndicateByID(app.DB, syndicateId)
|
||||||
|
if err != nil || syndicate.OwnerID != userID {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||||
|
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||||
|
ctx["Syndicate"] = syndicate
|
||||||
|
|
||||||
|
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")
|
||||||
|
drawDateStr := r.FormValue("draw_date")
|
||||||
|
method := r.FormValue("purchase_method")
|
||||||
|
|
||||||
|
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: dt,
|
||||||
|
PurchaseMethod: method,
|
||||||
|
SyndicateId: &syndicateId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
templateHelpers.SetFlash(r, "Failed to add ticket.")
|
||||||
|
} else {
|
||||||
|
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, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SyndicateTicketsHandler(app *bootstrap.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
|
||||||
|
if syndicateID == 0 {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !syndicateStorage.IsSyndicateMember(app.DB, syndicateID, userID) {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(app, w, r)
|
||||||
|
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||||
|
ctx["SyndicateID"] = syndicateID
|
||||||
|
ctx["Tickets"] = tickets
|
||||||
|
|
||||||
|
tmpl := templateHelpers.LoadTemplateFiles("syndicate-tickets.html", "web/templates/syndicate/tickets.html")
|
||||||
|
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
240
internal/handlers/lottery/syndicate/syndicate_invites.go
Normal file
240
internal/handlers/lottery/syndicate/syndicate_invites.go
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
// internal/handlers/lottery/syndicate/syndicate_invites.go
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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/platform/bootstrap"
|
||||||
|
syndicateStorage "synlotto-website/internal/storage/syndicate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
|
||||||
|
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||||
|
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||||
|
ctx["SyndicateID"] = syndicateID
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
if err := syndicateStorage.InviteToSyndicate(app.DB, userID, syndicateID, username); err != nil {
|
||||||
|
templateHelpers.SetFlash(r, "Failed to send invite: "+err.Error())
|
||||||
|
} else {
|
||||||
|
templateHelpers.SetFlash(r, "Invite sent successfully.")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther)
|
||||||
|
|
||||||
|
default:
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /syndicate/invites
|
||||||
|
func ViewInvitesHandler(app *bootstrap.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
invites := syndicateStorage.GetPendingSyndicateInvites(app.DB, userID)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := syndicateStorage.AcceptInvite(app.DB, inviteID, userID); err != nil {
|
||||||
|
templateHelpers.SetFlash(r, "Failed to accept invite")
|
||||||
|
} else {
|
||||||
|
templateHelpers.SetFlash(r, "You have joined the syndicate")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"))
|
||||||
|
_ = syndicateStorage.UpdateInviteStatus(app.DB, inviteID, "declined")
|
||||||
|
http.Redirect(w, r, "/syndicate/invites", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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 = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate + consume a token to join a syndicate.
|
||||||
|
func AcceptInviteToken(app *bootstrap.App, token string, userID int) error {
|
||||||
|
var syndicateID int
|
||||||
|
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 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := app.DB.Exec(`
|
||||||
|
UPDATE syndicate_invite_tokens
|
||||||
|
SET accepted_by_user_id = ?, accepted_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE token = ?
|
||||||
|
`, userID, token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
|
||||||
|
token, err := CreateInviteToken(app, syndicateID, userID, 48)
|
||||||
|
if err != nil {
|
||||||
|
templateHelpers.SetFlash(r, "Failed to generate invite link.")
|
||||||
|
http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "http://"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https://"
|
||||||
|
}
|
||||||
|
inviteLink := fmt.Sprintf("%s%s/syndicate/join?token=%s", scheme, r.Host, token)
|
||||||
|
|
||||||
|
templateHelpers.SetFlash(r, "Invite link created: "+inviteLink)
|
||||||
|
http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /syndicate/join?token=<token>
|
||||||
|
func JoinSyndicateWithTokenHandler(app *bootstrap.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
if token == "" {
|
||||||
|
templateHelpers.SetFlash(r, "Invalid or missing invite token.")
|
||||||
|
http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := AcceptInviteToken(app, token, userID); err != nil {
|
||||||
|
templateHelpers.SetFlash(r, "Failed to join syndicate: "+err.Error())
|
||||||
|
} else {
|
||||||
|
templateHelpers.SetFlash(r, "You have joined the syndicate!")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
|
||||||
|
if !syndicateStorage.IsSyndicateManager(app.DB, syndicateID, userID) {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens := syndicateStorage.GetInviteTokensForSyndicate(app.DB, 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", "web/templates/syndicate/invite_links.html")
|
||||||
|
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// internal/handlers/lottery/tickets/ticket_handler.go
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,18 +9,25 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"synlotto-website/helpers"
|
|
||||||
"synlotto-website/models"
|
|
||||||
draws "synlotto-website/services/draws"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/csrf"
|
templateHandlers "synlotto-website/internal/handlers/template"
|
||||||
|
securityHelpers "synlotto-website/internal/helpers/security"
|
||||||
|
templateHelpers "synlotto-website/internal/helpers/template"
|
||||||
|
draws "synlotto-website/internal/services/draws"
|
||||||
|
|
||||||
|
"synlotto-website/internal/helpers"
|
||||||
|
"synlotto-website/internal/models"
|
||||||
|
"synlotto-website/internal/platform/bootstrap"
|
||||||
|
|
||||||
|
"github.com/justinas/nosurf"
|
||||||
)
|
)
|
||||||
|
|
||||||
func AddTicket(db *sql.DB) http.HandlerFunc {
|
// AddTicket renders the add-ticket form (GET) and handles multi-line ticket submission (POST).
|
||||||
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
func AddTicket(app *bootstrap.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
rows, err := db.Query(`
|
rows, err := app.DB.Query(`
|
||||||
SELECT DISTINCT draw_date
|
SELECT DISTINCT draw_date
|
||||||
FROM results_thunderball
|
FROM results_thunderball
|
||||||
ORDER BY draw_date DESC
|
ORDER BY draw_date DESC
|
||||||
@@ -39,39 +47,45 @@ func AddTicket(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context := helpers.TemplateContext(w, r, models.TemplateData{})
|
// Use shared template data builder (expects *bootstrap.App)
|
||||||
context["csrfField"] = csrf.TemplateField(r)
|
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||||
|
context := templateHelpers.TemplateContext(w, r, data)
|
||||||
|
context["CSRFToken"] = nosurf.Token(r)
|
||||||
context["DrawDates"] = drawDates
|
context["DrawDates"] = drawDates
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("add_ticket.html", "templates/account/tickets/add_ticket.html")
|
tmpl := templateHelpers.LoadTemplateFiles("add_ticket.html", "web/templates/account/tickets/add_ticket.html")
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
|
||||||
err = tmpl.ExecuteTemplate(w, "layout", context)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("❌ Template render error:", err)
|
log.Println("❌ Template render error:", err)
|
||||||
http.Error(w, "Error rendering form", http.StatusInternalServerError)
|
http.Error(w, "Error rendering form", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := r.ParseMultipartForm(10 << 20)
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Invalid form", http.StatusBadRequest)
|
http.Error(w, "Invalid form", http.StatusBadRequest)
|
||||||
log.Println("❌ Failed to parse form:", err)
|
log.Println("❌ Failed to parse form:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, ok := helpers.GetCurrentUserID(r)
|
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
game := r.FormValue("game_type")
|
game := r.FormValue("game_type")
|
||||||
drawDate := r.FormValue("draw_date")
|
drawDateStr := r.FormValue("draw_date")
|
||||||
purchaseMethod := r.FormValue("purchase_method")
|
purchaseMethod := r.FormValue("purchase_method")
|
||||||
purchaseDate := r.FormValue("purchase_date")
|
purchaseDate := r.FormValue("purchase_date")
|
||||||
purchaseTime := r.FormValue("purchase_time")
|
purchaseTime := r.FormValue("purchase_time")
|
||||||
|
|
||||||
|
dt, err := helpers.ParseDrawDate(drawDateStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid draw date", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
drawDateDB := helpers.FormatDrawDate(dt) // "YYYY-MM-DD"
|
||||||
|
|
||||||
if purchaseTime != "" {
|
if purchaseTime != "" {
|
||||||
purchaseDate += "T" + purchaseTime
|
purchaseDate += "T" + purchaseTime
|
||||||
}
|
}
|
||||||
@@ -84,7 +98,7 @@ func AddTicket(db *sql.DB) http.HandlerFunc {
|
|||||||
out, err := os.Create(filename)
|
out, err := os.Create(filename)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
io.Copy(out, file)
|
_, _ = io.Copy(out, file)
|
||||||
imagePath = filename
|
imagePath = filename
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,7 +165,7 @@ func AddTicket(db *sql.DB) http.HandlerFunc {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := db.Exec(`
|
if _, err := app.DB.Exec(`
|
||||||
INSERT INTO my_tickets (
|
INSERT INTO my_tickets (
|
||||||
userId, game_type, draw_date,
|
userId, game_type, draw_date,
|
||||||
ball1, ball2, ball3, ball4, ball5, ball6,
|
ball1, ball2, ball3, ball4, ball5, ball6,
|
||||||
@@ -159,42 +173,48 @@ func AddTicket(db *sql.DB) http.HandlerFunc {
|
|||||||
purchase_method, purchase_date, image_path
|
purchase_method, purchase_date, image_path
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
userID, game, drawDate,
|
userID, game, drawDateDB,
|
||||||
b[0], b[1], b[2], b[3], b[4], b[5],
|
b[0], b[1], b[2], b[3], b[4], b[5],
|
||||||
bo[0], bo[1],
|
bo[0], bo[1],
|
||||||
purchaseMethod, purchaseDate, imagePath,
|
purchaseMethod, purchaseDate, imagePath,
|
||||||
)
|
); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Println("❌ Failed to insert ticket line:", err)
|
log.Println("❌ Failed to insert ticket line:", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("✅ Ticket line %d saved", i+1) // ToDo create audit
|
log.Printf("✅ Ticket line %d saved", i+1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/tickets", http.StatusSeeOther)
|
http.Redirect(w, r, "/tickets", http.StatusSeeOther)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func SubmitTicket(db *sql.DB) http.HandlerFunc {
|
// SubmitTicket handles alternate multipart ticket submission (POST-only).
|
||||||
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
func SubmitTicket(app *bootstrap.App) http.HandlerFunc {
|
||||||
err := r.ParseMultipartForm(10 << 20)
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if err != nil {
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||||
http.Error(w, "Invalid form", http.StatusBadRequest)
|
http.Error(w, "Invalid form", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, ok := helpers.GetCurrentUserID(r)
|
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
game := r.FormValue("game_type")
|
game := r.FormValue("game_type")
|
||||||
drawDate := r.FormValue("draw_date")
|
drawDateStr := r.FormValue("draw_date")
|
||||||
purchaseMethod := r.FormValue("purchase_method")
|
purchaseMethod := r.FormValue("purchase_method")
|
||||||
purchaseDate := r.FormValue("purchase_date")
|
purchaseDate := r.FormValue("purchase_date")
|
||||||
purchaseTime := r.FormValue("purchase_time")
|
purchaseTime := r.FormValue("purchase_time")
|
||||||
|
|
||||||
|
dt, err := helpers.ParseDrawDate(drawDateStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid draw date", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
drawDateDB := helpers.FormatDrawDate(dt)
|
||||||
|
|
||||||
if purchaseTime != "" {
|
if purchaseTime != "" {
|
||||||
purchaseDate += "T" + purchaseTime
|
purchaseDate += "T" + purchaseTime
|
||||||
}
|
}
|
||||||
@@ -207,13 +227,13 @@ func SubmitTicket(db *sql.DB) http.HandlerFunc {
|
|||||||
out, err := os.Create(filename)
|
out, err := os.Create(filename)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
io.Copy(out, file)
|
_, _ = io.Copy(out, file)
|
||||||
imagePath = filename
|
imagePath = filename
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ballCount := 6
|
const ballCount = 6
|
||||||
bonusCount := 2
|
const bonusCount = 2
|
||||||
|
|
||||||
balls := make([][]int, ballCount)
|
balls := make([][]int, ballCount)
|
||||||
bonuses := make([][]int, bonusCount)
|
bonuses := make([][]int, bonusCount)
|
||||||
@@ -241,7 +261,7 @@ func SubmitTicket(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := db.Exec(`
|
if _, err := app.DB.Exec(`
|
||||||
INSERT INTO my_tickets (
|
INSERT INTO my_tickets (
|
||||||
user_id, game_type, draw_date,
|
user_id, game_type, draw_date,
|
||||||
ball1, ball2, ball3, ball4, ball5, ball6,
|
ball1, ball2, ball3, ball4, ball5, ball6,
|
||||||
@@ -249,29 +269,34 @@ func SubmitTicket(db *sql.DB) http.HandlerFunc {
|
|||||||
purchase_method, purchase_date, image_path
|
purchase_method, purchase_date, image_path
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
userID, game, drawDate,
|
userID, game, drawDateDB,
|
||||||
b[0], b[1], b[2], b[3], b[4], b[5],
|
b[0], b[1], b[2], b[3], b[4], b[5],
|
||||||
bo[0], bo[1],
|
bo[0], bo[1],
|
||||||
purchaseMethod, purchaseDate, imagePath,
|
purchaseMethod, purchaseDate, imagePath,
|
||||||
)
|
); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Println("❌ Insert failed:", err)
|
log.Println("❌ Insert failed:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/tickets", http.StatusSeeOther)
|
http.Redirect(w, r, "/tickets", http.StatusSeeOther)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetMyTickets(db *sql.DB) http.HandlerFunc {
|
// GetMyTickets lists the current user's tickets.
|
||||||
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
func GetMyTickets(app *bootstrap.App) http.HandlerFunc {
|
||||||
userID, ok := helpers.GetCurrentUserID(r)
|
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)
|
||||||
|
|
||||||
|
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := db.Query(`
|
var tickets []models.Ticket
|
||||||
|
rows, err := app.DB.Query(`
|
||||||
SELECT id, game_type, draw_date,
|
SELECT id, game_type, draw_date,
|
||||||
ball1, ball2, ball3, ball4, ball5, ball6,
|
ball1, ball2, ball3, ball4, ball5, ball6,
|
||||||
bonus1, bonus2,
|
bonus1, bonus2,
|
||||||
@@ -288,10 +313,9 @@ func GetMyTickets(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var tickets []models.Ticket
|
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t models.Ticket
|
var t models.Ticket
|
||||||
|
var drawDateStr string // ← add
|
||||||
var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64
|
var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64
|
||||||
var matchedMain, matchedBonus sql.NullInt64
|
var matchedMain, matchedBonus sql.NullInt64
|
||||||
var prizeTier sql.NullString
|
var prizeTier sql.NullString
|
||||||
@@ -299,19 +323,23 @@ func GetMyTickets(db *sql.DB) http.HandlerFunc {
|
|||||||
var prizeLabel sql.NullString
|
var prizeLabel sql.NullString
|
||||||
var prizeAmount sql.NullFloat64
|
var prizeAmount sql.NullFloat64
|
||||||
|
|
||||||
err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&t.Id, &t.GameType, &t.DrawDate,
|
&t.Id, &t.GameType, &drawDateStr, // ← was &t.DrawDate
|
||||||
&b1, &b2, &b3, &b4, &b5, &b6,
|
&b1, &b2, &b3, &b4, &b5, &b6,
|
||||||
&bo1, &bo2,
|
&bo1, &bo2,
|
||||||
&t.PurchaseMethod, &t.PurchaseDate, &t.ImagePath, &t.Duplicate,
|
&t.PurchaseMethod, &t.PurchaseDate, &t.ImagePath, &t.Duplicate,
|
||||||
&matchedMain, &matchedBonus, &prizeTier, &isWinner, &prizeLabel, &prizeAmount,
|
&matchedMain, &matchedBonus, &prizeTier, &isWinner, &prizeLabel, &prizeAmount,
|
||||||
)
|
); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Println("⚠️ Failed to scan ticket row:", err)
|
log.Println("⚠️ Failed to scan ticket row:", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build primary number + bonus fields
|
// Parse into time.Time (UTC)
|
||||||
|
if dt, err := helpers.ParseDrawDate(drawDateStr); err == nil {
|
||||||
|
t.DrawDate = dt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize fields
|
||||||
t.Ball1 = int(b1.Int64)
|
t.Ball1 = int(b1.Int64)
|
||||||
t.Ball2 = int(b2.Int64)
|
t.Ball2 = int(b2.Int64)
|
||||||
t.Ball3 = int(b3.Int64)
|
t.Ball3 = int(b3.Int64)
|
||||||
@@ -339,31 +367,55 @@ func GetMyTickets(db *sql.DB) http.HandlerFunc {
|
|||||||
if prizeAmount.Valid {
|
if prizeAmount.Valid {
|
||||||
t.PrizeAmount = prizeAmount.Float64
|
t.PrizeAmount = prizeAmount.Float64
|
||||||
}
|
}
|
||||||
// Build balls slices (for template use)
|
|
||||||
|
// Derived fields for templates
|
||||||
t.Balls = helpers.BuildBallsSlice(t)
|
t.Balls = helpers.BuildBallsSlice(t)
|
||||||
t.BonusBalls = helpers.BuildBonusSlice(t)
|
t.BonusBalls = helpers.BuildBonusSlice(t)
|
||||||
|
|
||||||
// 🎯 Get the actual draw info (used to show which numbers matched)
|
// Fetch matching draw info
|
||||||
draw := draws.GetDrawResultForTicket(db, t.GameType, t.DrawDate)
|
draw := draws.GetDrawResultForTicket(app.DB, t.GameType, helpers.FormatDrawDate(t.DrawDate))
|
||||||
t.MatchedDraw = draw
|
t.MatchedDraw = draw
|
||||||
|
|
||||||
// ✅ DEBUG
|
|
||||||
log.Printf("✅ Ticket #%d", t.Id)
|
|
||||||
log.Printf("Balls: %v", t.Balls)
|
|
||||||
log.Printf("DrawResult: %+v", draw)
|
|
||||||
|
|
||||||
tickets = append(tickets, t)
|
tickets = append(tickets, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
context := helpers.TemplateContext(w, r, models.TemplateData{})
|
|
||||||
context["Tickets"] = tickets
|
context["Tickets"] = tickets
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("my_tickets.html", "templates/account/tickets/my_tickets.html")
|
tmpl := templateHelpers.LoadTemplateFiles("my_tickets.html", "web/templates/account/tickets/my_tickets.html")
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
|
||||||
err = tmpl.ExecuteTemplate(w, "layout", context)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("❌ Template error:", err)
|
log.Println("❌ Template error:", err)
|
||||||
http.Error(w, "Error rendering page", http.StatusInternalServerError)
|
http.Error(w, "Error rendering page", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToDo
|
||||||
|
// http: superfluous response.WriteHeader call (from SCS)
|
||||||
|
|
||||||
|
//This happens when headers are written twice in a request. With SCS, it sets cookies in WriteHeader. If something else already wrote the headers (or wrote them again), you see this warning.
|
||||||
|
|
||||||
|
//Common culprits & fixes:
|
||||||
|
|
||||||
|
//Use 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,7 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"synlotto-website/models"
|
"synlotto-website/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func MatchTicketToDraw(ticket models.MatchTicket, draw models.DrawResult, rules []models.PrizeRule) models.MatchResult {
|
func MatchTicketToDraw(ticket models.MatchTicket, draw models.DrawResult, rules []models.PrizeRule) models.MatchResult {
|
||||||
182
internal/handlers/messages.go
Normal file
182
internal/handlers/messages.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
templateHandlers "synlotto-website/internal/handlers/template"
|
||||||
|
securityHelpers "synlotto-website/internal/helpers/security"
|
||||||
|
templateHelpers "synlotto-website/internal/helpers/template"
|
||||||
|
messagesStorage "synlotto-website/internal/storage/messages"
|
||||||
|
|
||||||
|
"synlotto-website/internal/helpers"
|
||||||
|
"synlotto-website/internal/platform/bootstrap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Inbox: paginated list of messages
|
||||||
|
func MessagesInboxHandler(app *bootstrap.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := helpers.Atoi(r.URL.Query().Get("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
perPage := 10
|
||||||
|
|
||||||
|
totalCount := messagesStorage.GetInboxMessageCount(app.DB, userID)
|
||||||
|
totalPages := (totalCount + perPage - 1) / perPage
|
||||||
|
if totalPages == 0 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := messagesStorage.GetInboxMessages(app.DB, userID, page, perPage)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a single message (marks as read)
|
||||||
|
func ReadMessageHandler(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(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message, err := messagesStorage.GetMessageByID(app.DB, userID, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Message not found: %v", err)
|
||||||
|
message = nil
|
||||||
|
} else if message != nil && !message.IsRead {
|
||||||
|
_ = messagesStorage.MarkMessageAsRead(app.DB, id, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||||
|
ctx := templateHelpers.TemplateContext(w, r, data)
|
||||||
|
ctx["Message"] = message
|
||||||
|
|
||||||
|
tmpl := templateHelpers.LoadTemplateFiles("read-message.html", "web/templates/account/messages/read.html")
|
||||||
|
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := messagesStorage.ArchiveMessage(app.DB, userID, id); err != nil {
|
||||||
|
templateHelpers.SetFlash(r, "Failed to archive message.")
|
||||||
|
} else {
|
||||||
|
templateHelpers.SetFlash(r, "Message archived.")
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/account/messages", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List archived messages (paged)
|
||||||
|
func ArchivedMessagesHandler(app *bootstrap.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := helpers.Atoi(r.URL.Query().Get("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
perPage := 10
|
||||||
|
|
||||||
|
messages := messagesStorage.GetArchivedMessages(app.DB, userID, page, perPage)
|
||||||
|
hasMore := len(messages) == perPage
|
||||||
|
|
||||||
|
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", "web/templates/account/messages/archived.html")
|
||||||
|
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(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(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recipientID := helpers.Atoi(r.FormValue("recipient_id"))
|
||||||
|
subject := r.FormValue("subject")
|
||||||
|
body := r.FormValue("message")
|
||||||
|
|
||||||
|
if err := messagesStorage.SendMessage(app.DB, senderID, recipientID, subject, body); err != nil {
|
||||||
|
templateHelpers.SetFlash(r, "Failed to send message.")
|
||||||
|
} else {
|
||||||
|
templateHelpers.SetFlash(r, "Message sent.")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/account/messages", http.StatusSeeOther)
|
||||||
|
default:
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(app.SessionManager, r)
|
||||||
|
if !ok {
|
||||||
|
templateHelpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := messagesStorage.RestoreMessage(app.DB, userID, id); err != nil {
|
||||||
|
templateHelpers.SetFlash(r, "Failed to restore message.")
|
||||||
|
} else {
|
||||||
|
templateHelpers.SetFlash(r, "Message restored.")
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/account/messages/archive", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
internal/handlers/notifications.go
Normal file
73
internal/handlers/notifications.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
templateHandlers "synlotto-website/internal/handlers/template"
|
||||||
|
templateHelpers "synlotto-website/internal/helpers/template"
|
||||||
|
"synlotto-website/internal/platform/bootstrap"
|
||||||
|
"synlotto-website/internal/platform/sessionkeys"
|
||||||
|
notificationsStorage "synlotto-website/internal/storage/notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(app, w, r)
|
||||||
|
context := templateHelpers.TemplateContext(w, r, data)
|
||||||
|
|
||||||
|
tmpl := templateHelpers.LoadTemplateFiles("index.html", "web/templates/account/notifications/index.html")
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 || notificationID <= 0 {
|
||||||
|
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCS-native session access
|
||||||
|
userID := app.SessionManager.GetInt(r.Context(), sessionkeys.UserID)
|
||||||
|
if userID == 0 {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if err := notificationsStorage.MarkNotificationAsRead(app.DB, userID, notificationID); err != nil {
|
||||||
|
log.Printf("⚠️ Failed to mark as read: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateHandlers.BuildTemplateData(app, w, r)
|
||||||
|
context := templateHelpers.TemplateContext(w, r, data)
|
||||||
|
context["Notification"] = notification
|
||||||
|
|
||||||
|
tmpl := templateHelpers.LoadTemplateFiles("read.html", "web/templates/account/notifications/read.html")
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,15 +8,17 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"synlotto-website/helpers"
|
|
||||||
"synlotto-website/models"
|
"synlotto-website/internal/helpers"
|
||||||
|
templateHelpers "synlotto-website/internal/helpers/template"
|
||||||
|
"synlotto-website/internal/http/middleware"
|
||||||
|
"synlotto-website/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||||
limiter := helpers.GetVisitorLimiter(ip)
|
limiter := middleware.GetVisitorLimiter(ip)
|
||||||
|
|
||||||
if !limiter.Allow() {
|
if !limiter.Allow() {
|
||||||
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
||||||
return
|
return
|
||||||
@@ -42,7 +44,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
|||||||
doSearch := isValidDate(query) || isValidNumber(query)
|
doSearch := isValidDate(query) || isValidNumber(query)
|
||||||
|
|
||||||
whereClause := "WHERE 1=1"
|
whereClause := "WHERE 1=1"
|
||||||
args := []interface{}{}
|
args := []any{}
|
||||||
|
|
||||||
if doSearch {
|
if doSearch {
|
||||||
whereClause += " AND (draw_date = ? OR id = ?)"
|
whereClause += " AND (draw_date = ? OR id = ?)"
|
||||||
@@ -61,7 +63,21 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
|||||||
args = append(args, ballSetFilter)
|
args = append(args, ballSetFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
totalPages, totalResults := helpers.GetTotalPages(db, "results_thunderball", whereClause, args, pageSize)
|
// ✅ FIX: Proper GetTotalPages call with context + correct table name
|
||||||
|
totalPages, totalResults, err := templateHelpers.GetTotalPages(
|
||||||
|
r.Context(),
|
||||||
|
db,
|
||||||
|
"results_thunderball",
|
||||||
|
whereClause,
|
||||||
|
args,
|
||||||
|
pageSize,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("❌ Pagination count error:", err)
|
||||||
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if page < 1 || page > totalPages {
|
if page < 1 || page > totalPages {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -75,7 +91,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
|||||||
LIMIT ? OFFSET ?`
|
LIMIT ? OFFSET ?`
|
||||||
argsWithLimit := append(args, pageSize, offset)
|
argsWithLimit := append(args, pageSize, offset)
|
||||||
|
|
||||||
rows, err := db.Query(querySQL, argsWithLimit...)
|
rows, err := db.QueryContext(r.Context(), querySQL, argsWithLimit...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
log.Println("❌ DB error:", err)
|
log.Println("❌ DB error:", err)
|
||||||
@@ -109,7 +125,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
|||||||
noResultsMsg = "No results found for \"" + query + "\""
|
noResultsMsg = "No results found for \"" + query + "\""
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl := helpers.LoadTemplateFiles("thunderball.html", "templates/results/thunderball.html")
|
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/results/thunderball.html")
|
||||||
|
|
||||||
err = tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{
|
err = tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{
|
||||||
"Results": results,
|
"Results": results,
|
||||||
34
internal/handlers/statistics/thunderball.go
Normal file
34
internal/handlers/statistics/thunderball.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// internal/handlers/statistics/thunderball.go
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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(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(app, w, r)
|
||||||
|
context := templateHelpers.TemplateContext(w, r, data)
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
61
internal/handlers/template/templatedata.go
Normal file
61
internal/handlers/template/templatedata.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// internal/handlers/template/templatedata.go
|
||||||
|
package templateHandler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/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/platform/bootstrap"
|
||||||
|
"synlotto-website/internal/platform/sessionkeys"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildTemplateData aggregates common UI data (user, notifications, messages)
|
||||||
|
// from the current SCS session + DB.
|
||||||
|
func BuildTemplateData(app *bootstrap.App, w http.ResponseWriter, r *http.Request) models.TemplateData {
|
||||||
|
sm := app.SessionManager
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
var (
|
||||||
|
user *models.User
|
||||||
|
isAdmin bool
|
||||||
|
notificationCount int
|
||||||
|
notifications []models.Notification
|
||||||
|
messageCount int
|
||||||
|
messages []models.Message
|
||||||
|
)
|
||||||
|
|
||||||
|
// Read user_id from SCS (may be int or int64 depending on writes)
|
||||||
|
if v := sm.Get(ctx, sessionkeys.UserID); v != nil {
|
||||||
|
var uid int64
|
||||||
|
switch t := v.(type) {
|
||||||
|
case int64:
|
||||||
|
uid = t
|
||||||
|
case int:
|
||||||
|
uid = int64(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.TemplateData{
|
||||||
|
User: user,
|
||||||
|
IsAdmin: isAdmin,
|
||||||
|
NotificationCount: notificationCount,
|
||||||
|
Notifications: notifications,
|
||||||
|
MessageCount: messageCount,
|
||||||
|
Messages: messages,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ package helpers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"synlotto-website/models"
|
"synlotto-website/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func BuildBallsSlice(t models.Ticket) []int {
|
func BuildBallsSlice(t models.Ticket) []int {
|
||||||
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,4 +1,4 @@
|
|||||||
package helpers
|
package security
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package helpers
|
package security
|
||||||
|
|
||||||
import "golang.org/x/crypto/bcrypt"
|
import "golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
15
internal/helpers/security/tokens.go
Normal file
15
internal/helpers/security/tokens.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateSecureToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
14
internal/helpers/security/users.go
Normal file
14
internal/helpers/security/users.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"synlotto-website/internal/platform/sessionkeys"
|
||||||
|
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetCurrentUserID(sm *scs.SessionManager, r *http.Request) (int, bool) {
|
||||||
|
userID := sm.GetInt(r.Context(), sessionkeys.UserID)
|
||||||
|
return userID, userID != 0
|
||||||
|
}
|
||||||
7
internal/helpers/session/encoding.go
Normal file
7
internal/helpers/session/encoding.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import "encoding/base64"
|
||||||
|
|
||||||
|
func EncodeKey(b []byte) string {
|
||||||
|
return base64.StdEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
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,38 +1,53 @@
|
|||||||
package helpers
|
package templateHelper
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"synlotto-website/models"
|
"synlotto-website/internal/models"
|
||||||
|
|
||||||
"github.com/gorilla/csrf"
|
"github.com/alexedwards/scs/v2"
|
||||||
|
"github.com/justinas/nosurf"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TemplateContext(w http.ResponseWriter, r *http.Request, data models.TemplateData) map[string]interface{} {
|
// ToDo should these structs be here?
|
||||||
session, _ := GetSession(w, r)
|
type siteMeta struct {
|
||||||
|
Name string
|
||||||
|
CopyrightYearStart int
|
||||||
|
}
|
||||||
|
|
||||||
var flash string
|
var meta siteMeta
|
||||||
if f, ok := session.Values["flash"].(string); ok {
|
|
||||||
flash = f
|
|
||||||
delete(session.Values, "flash")
|
|
||||||
session.Save(r, w)
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]interface{}{
|
func InitSiteMeta(name string, yearStart, yearEnd int) {
|
||||||
"CSRFField": csrf.TemplateField(r),
|
meta = siteMeta{
|
||||||
"Flash": flash,
|
Name: name,
|
||||||
"User": data.User,
|
CopyrightYearStart: yearStart,
|
||||||
"IsAdmin": data.IsAdmin,
|
|
||||||
"NotificationCount": data.NotificationCount,
|
|
||||||
"Notifications": data.Notifications,
|
|
||||||
"MessageCount": data.MessageCount,
|
|
||||||
"Messages": data.Messages,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"CSRFToken": nosurf.Token(r),
|
||||||
|
"User": data.User,
|
||||||
|
"IsAdmin": data.IsAdmin,
|
||||||
|
"NotificationCount": data.NotificationCount,
|
||||||
|
"Notifications": data.Notifications,
|
||||||
|
"MessageCount": data.MessageCount,
|
||||||
|
"Messages": data.Messages,
|
||||||
|
"SiteName": meta.Name,
|
||||||
|
"CopyrightYearStart": meta.CopyrightYearStart,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToDo the funcs need breaking up getting large
|
||||||
func TemplateFuncs() template.FuncMap {
|
func TemplateFuncs() template.FuncMap {
|
||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
"plus1": func(i int) int { return i + 1 },
|
"plus1": func(i int) int { return i + 1 },
|
||||||
@@ -48,9 +63,8 @@ func TemplateFuncs() template.FuncMap {
|
|||||||
"min": func(a, b int) int {
|
"min": func(a, b int) int {
|
||||||
if a < b {
|
if a < b {
|
||||||
return a
|
return a
|
||||||
} else {
|
|
||||||
return b
|
|
||||||
}
|
}
|
||||||
|
return b
|
||||||
},
|
},
|
||||||
"intVal": func(p *int) int {
|
"intVal": func(p *int) int {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
@@ -58,9 +72,8 @@ func TemplateFuncs() template.FuncMap {
|
|||||||
}
|
}
|
||||||
return *p
|
return *p
|
||||||
},
|
},
|
||||||
"inSlice": InSlice,
|
"inSlice": InSlice,
|
||||||
"lower": lower,
|
"lower": lower,
|
||||||
"rangeClass": rangeClass,
|
|
||||||
"truncate": func(s string, max int) string {
|
"truncate": func(s string, max int) string {
|
||||||
if len(s) <= max {
|
if len(s) <= max {
|
||||||
return s
|
return s
|
||||||
@@ -68,24 +81,39 @@ func TemplateFuncs() template.FuncMap {
|
|||||||
return s[:max] + "..."
|
return s[:max] + "..."
|
||||||
},
|
},
|
||||||
"PageRange": PageRange,
|
"PageRange": PageRange,
|
||||||
|
"now": time.Now,
|
||||||
|
"humanTime": func(v interface{}) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case time.Time:
|
||||||
|
return t.Local().Format("02 Jan 2006 15:04")
|
||||||
|
case string:
|
||||||
|
parsed, err := time.Parse(time.RFC3339, t)
|
||||||
|
if err == nil {
|
||||||
|
return parsed.Local().Format("02 Jan 2006 15:04")
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rangeClass": rangeClass,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadTemplateFiles(name string, files ...string) *template.Template {
|
func LoadTemplateFiles(name string, files ...string) *template.Template {
|
||||||
shared := []string{
|
shared := []string{
|
||||||
"templates/layout.html",
|
"web/templates/main/layout.html",
|
||||||
"templates/topbar.html",
|
"web/templates/main/topbar.html",
|
||||||
|
"web/templates/main/footer.html",
|
||||||
}
|
}
|
||||||
all := append(shared, files...)
|
all := append(shared, files...)
|
||||||
|
|
||||||
log.Printf("📄 Loading templates: %v", all)
|
|
||||||
return template.Must(template.New(name).Funcs(TemplateFuncs()).ParseFiles(all...))
|
return template.Must(template.New(name).Funcs(TemplateFuncs()).ParseFiles(all...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetFlash(w http.ResponseWriter, r *http.Request, message string) {
|
func SetFlash(r *http.Request, message string) {
|
||||||
session, _ := GetSession(w, r)
|
if sm != nil {
|
||||||
session.Values["flash"] = message
|
sm.Put(r.Context(), "flash", message)
|
||||||
session.Save(r, w)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func InSlice(n int, list []int) bool {
|
func InSlice(n int, list []int) bool {
|
||||||
@@ -101,6 +129,14 @@ func lower(input string) string {
|
|||||||
return strings.ToLower(input)
|
return strings.ToLower(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PageRange(current, total int) []int {
|
||||||
|
var pages []int
|
||||||
|
for i := 1; i <= total; i++ {
|
||||||
|
pages = append(pages, i)
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
func rangeClass(n int) string {
|
func rangeClass(n int) string {
|
||||||
switch {
|
switch {
|
||||||
case n >= 1 && n <= 9:
|
case n >= 1 && n <= 9:
|
||||||
@@ -117,11 +153,3 @@ func rangeClass(n int) string {
|
|||||||
return "50-plus"
|
return "50-plus"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func PageRange(current, total int) []int {
|
|
||||||
var pages []int
|
|
||||||
for i := 1; i <= total; i++ {
|
|
||||||
pages = append(pages, i)
|
|
||||||
}
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
package helpers
|
package templateHelper
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"synlotto-website/models"
|
|
||||||
|
"synlotto-website/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RenderError renders an HTML error page (e.g., 404.html, 500.html).
|
||||||
|
// It uses TemplateContext which reads site meta from InitSiteMeta().
|
||||||
func RenderError(w http.ResponseWriter, r *http.Request, statusCode int) {
|
func RenderError(w http.ResponseWriter, r *http.Request, statusCode int) {
|
||||||
log.Printf("⚙️ RenderError called with status: %d", statusCode)
|
log.Printf("⚙️ RenderError called with status: %d", statusCode)
|
||||||
|
|
||||||
context := TemplateContext(w, r, models.TemplateData{})
|
ctx := TemplateContext(w, r, models.TemplateData{})
|
||||||
|
|
||||||
pagePath := fmt.Sprintf("templates/error/%d.html", statusCode)
|
pagePath := fmt.Sprintf("templates/error/%d.html", statusCode)
|
||||||
log.Printf("📄 Checking for template file: %s", pagePath)
|
log.Printf("📄 Checking for template file: %s", pagePath)
|
||||||
@@ -22,19 +25,14 @@ func RenderError(w http.ResponseWriter, r *http.Request, statusCode int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("✅ Template file found, loading...")
|
|
||||||
|
|
||||||
tmpl := LoadTemplateFiles(fmt.Sprintf("%d.html", statusCode), pagePath)
|
tmpl := LoadTemplateFiles(fmt.Sprintf("%d.html", statusCode), pagePath)
|
||||||
|
|
||||||
w.WriteHeader(statusCode)
|
w.WriteHeader(statusCode)
|
||||||
err := tmpl.ExecuteTemplate(w, "layout", context)
|
if err := tmpl.ExecuteTemplate(w, "layout", ctx); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ Failed to render error page layout: %v", err)
|
log.Printf("❌ Failed to render error page layout: %v", err)
|
||||||
http.Error(w, http.StatusText(statusCode), statusCode)
|
http.Error(w, http.StatusText(statusCode), statusCode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("✅ Successfully rendered 500 page")
|
log.Println("✅ Successfully rendered error page")
|
||||||
}
|
}
|
||||||
|
|
||||||
//ToDo Pages.go /template.go to be merged?
|
|
||||||
72
internal/helpers/template/pagination.go
Normal file
72
internal/helpers/template/pagination.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// internal/helpers/pagination/pagination.go (move out of template/*)
|
||||||
|
package templateHelper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
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, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MakePageRange(current, total int) []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()
|
||||||
|
}
|
||||||
|
}
|
||||||
120
internal/http/middleware/auth.go
Normal file
120
internal/http/middleware/auth.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sessionHelper "synlotto-website/internal/helpers/session"
|
||||||
|
"synlotto-website/internal/platform/bootstrap"
|
||||||
|
"synlotto-website/internal/platform/sessionkeys"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
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 logged in, update last activity
|
||||||
|
if sm.Exists(ctx, sessionkeys.UserID) {
|
||||||
|
sm.Put(ctx, sessionkeys.LastActivity, time.Now().UTC())
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional remember-me using selector:verifier token pair.
|
||||||
|
func RememberMiddleware(app *bootstrap.App) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
sm := app.SessionManager
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
// Already logged in? Skip.
|
||||||
|
if sm.Exists(ctx, sessionkeys.UserID) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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,8 +1,8 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
|
// ToDo: make sure im using with gin
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
// Redirects all HTTP to HTTPS (only in production)
|
|
||||||
func EnforceHTTPS(next http.Handler, enabled bool) http.Handler {
|
func EnforceHTTPS(next http.Handler, enabled bool) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if enabled && r.Header.Get("X-Forwarded-Proto") != "https" && r.TLS == nil {
|
if enabled && r.Header.Get("X-Forwarded-Proto") != "https" && r.TLS == nil {
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package helpers
|
package middleware
|
||||||
|
|
||||||
|
// ToDo: make sure im using with gin
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -17,7 +18,7 @@ func GetVisitorLimiter(ip string) *rate.Limiter {
|
|||||||
|
|
||||||
limiter, exists := visitors[ip]
|
limiter, exists := visitors[ip]
|
||||||
if !exists {
|
if !exists {
|
||||||
limiter = rate.NewLimiter(1, 5)
|
limiter = rate.NewLimiter(3, 5)
|
||||||
visitors[ip] = limiter
|
visitors[ip] = limiter
|
||||||
}
|
}
|
||||||
return limiter
|
return limiter
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
|
// ToDo: make sure im using with gin not to be confused with gins recovery but may do the same?
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"synlotto-website/helpers"
|
|
||||||
|
templateHelpers "synlotto-website/internal/helpers/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Recover(next http.Handler) http.Handler {
|
func Recover(next http.Handler) http.Handler {
|
||||||
@@ -13,8 +15,7 @@ func Recover(next http.Handler) http.Handler {
|
|||||||
if rec := recover(); rec != nil {
|
if rec := recover(); rec != nil {
|
||||||
log.Printf("🔥 Recovered from panic: %v\n%s", rec, debug.Stack())
|
log.Printf("🔥 Recovered from panic: %v\n%s", rec, debug.Stack())
|
||||||
|
|
||||||
// ✅ Call your custom template-based fallback
|
templateHelpers.RenderError(w, r, http.StatusInternalServerError)
|
||||||
helpers.RenderError(w, r, http.StatusInternalServerError)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
internal/http/routes/accountroutes.go
Normal file
94
internal/http/routes/accountroutes.go
Normal file
@@ -0,0 +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 (
|
||||||
|
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"
|
||||||
|
|
||||||
|
"synlotto-website/internal/http/middleware"
|
||||||
|
"synlotto-website/internal/platform/bootstrap"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
38
internal/http/routes/adminroutes.go
Normal file
38
internal/http/routes/adminroutes.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
admin "synlotto-website/internal/handlers/admin"
|
||||||
|
|
||||||
|
"synlotto-website/internal/http/middleware"
|
||||||
|
"synlotto-website/internal/platform/bootstrap"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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))
|
||||||
|
}
|
||||||
12
internal/http/routes/resultroutes.go
Normal file
12
internal/http/routes/resultroutes.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"synlotto-website/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SetupResultRoutes(mux *http.ServeMux, db *sql.DB) {
|
||||||
|
mux.HandleFunc("/results/thunderball", handlers.ResultsThunderball(db))
|
||||||
|
}
|
||||||
20
internal/http/routes/statisticroutes.go
Normal file
20
internal/http/routes/statisticroutes.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
stats "synlotto-website/internal/handlers/statistics"
|
||||||
|
|
||||||
|
"synlotto-website/internal/http/middleware"
|
||||||
|
"synlotto-website/internal/platform/bootstrap"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)))
|
||||||
|
}
|
||||||
33
internal/http/routes/syndicateroutes.go
Normal file
33
internal/http/routes/syndicateroutes.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
s "synlotto-website/internal/handlers/lottery/syndicate"
|
||||||
|
|
||||||
|
"synlotto-website/internal/http/middleware"
|
||||||
|
"synlotto-website/internal/platform/bootstrap"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)))
|
||||||
|
}
|
||||||
25
internal/licensecheck/checker.go
Normal file
25
internal/licensecheck/checker.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LicenseChecker struct {
|
||||||
|
LicenseAPIURL string
|
||||||
|
APIKey string
|
||||||
|
PollInterval time.Duration
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
lastGood time.Time
|
||||||
|
valid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lc *LicenseChecker) setValid(ok bool) {
|
||||||
|
lc.mu.Lock()
|
||||||
|
defer lc.mu.Unlock()
|
||||||
|
lc.valid = ok
|
||||||
|
if ok {
|
||||||
|
lc.lastGood = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
76
internal/licensecheck/validate.go
Normal file
76
internal/licensecheck/validate.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (lc *LicenseChecker) Validate() error {
|
||||||
|
url := fmt.Sprintf("%s/license/lookup?key=%s&format=json", lc.LicenseAPIURL, lc.APIKey)
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
lc.setValid(false)
|
||||||
|
|
||||||
|
return fmt.Errorf("license lookup failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
lc.setValid(false)
|
||||||
|
|
||||||
|
return fmt.Errorf("license lookup error: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
lc.setValid(false)
|
||||||
|
|
||||||
|
return fmt.Errorf("reading response failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data struct {
|
||||||
|
Revoked bool `json:"revoked"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &data); err != nil {
|
||||||
|
lc.setValid(false)
|
||||||
|
|
||||||
|
return fmt.Errorf("unmarshal error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.Revoked || time.Now().After(data.ExpiresAt) {
|
||||||
|
lc.setValid(false)
|
||||||
|
|
||||||
|
return fmt.Errorf("license expired or revoked")
|
||||||
|
}
|
||||||
|
|
||||||
|
lc.mu.Lock()
|
||||||
|
lc.valid = true
|
||||||
|
lc.lastGood = time.Now()
|
||||||
|
lc.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("✅ License validated. Expires: %s", data.ExpiresAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lc *LicenseChecker) StartBackgroundCheck() {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
time.Sleep(lc.PollInterval)
|
||||||
|
err := lc.Validate()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ License check failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lc *LicenseChecker) IsValid() bool {
|
||||||
|
lc.mu.RLock()
|
||||||
|
defer lc.mu.RUnlock()
|
||||||
|
return lc.valid
|
||||||
|
}
|
||||||
21
internal/logging/config.go
Normal file
21
internal/logging/config.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"synlotto-website/internal/platform/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LogConfig(config *config.Config) {
|
||||||
|
safeConfig := *config
|
||||||
|
|
||||||
|
cfg, err := json.MarshalIndent(safeConfig, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to log config:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("App starting with config:")
|
||||||
|
log.Println(string(cfg))
|
||||||
|
}
|
||||||
13
internal/logging/messages.go
Normal file
13
internal/logging/messages.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Info(msg string, args ...any) {
|
||||||
|
log.Printf("[INFO] "+msg, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Error(msg string, args ...any) {
|
||||||
|
log.Printf("[ERROR] "+msg, args...)
|
||||||
|
}
|
||||||
9
internal/models/machine.go
Normal file
9
internal/models/machine.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type MachineUsage struct {
|
||||||
|
Machine string
|
||||||
|
DrawsUsed int
|
||||||
|
PctOfDraws float64
|
||||||
|
FirstUsed string
|
||||||
|
LastUsed string
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
15
internal/models/prediction.go
Normal file
15
internal/models/prediction.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NextMachineBallsetPrediction struct {
|
||||||
|
NextDrawDate string
|
||||||
|
CurrentMachine string
|
||||||
|
EstimatedNextMachine string
|
||||||
|
MachineTransitionPct float64
|
||||||
|
CurrentBallset sql.NullString
|
||||||
|
EstimatedNextBallset sql.NullString
|
||||||
|
BallsetTransitionPct sql.NullFloat64
|
||||||
|
}
|
||||||
18
internal/models/statistics.go
Normal file
18
internal/models/statistics.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type TopNum struct {
|
||||||
|
Number int
|
||||||
|
Frequency int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pair struct {
|
||||||
|
A int
|
||||||
|
B int
|
||||||
|
Frequency int
|
||||||
|
}
|
||||||
|
|
||||||
|
type ZScore struct {
|
||||||
|
Ball int
|
||||||
|
Recent int
|
||||||
|
Z float64
|
||||||
|
}
|
||||||
41
internal/models/syndicate.go
Normal file
41
internal/models/syndicate.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Syndicate struct {
|
||||||
|
ID int
|
||||||
|
OwnerID int
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
CreatedBy int
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyndicateMember struct {
|
||||||
|
ID int
|
||||||
|
SyndicateID int
|
||||||
|
UserID int
|
||||||
|
Role string
|
||||||
|
JoinedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyndicateInvite struct {
|
||||||
|
ID int
|
||||||
|
SyndicateID int
|
||||||
|
InvitedUserID int
|
||||||
|
SentByUserID int
|
||||||
|
Status string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyndicateInviteToken struct {
|
||||||
|
Token string
|
||||||
|
InvitedByUserID int
|
||||||
|
AcceptedByUserID sql.NullInt64
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
AcceptedAt sql.NullTime
|
||||||
|
}
|
||||||
53
internal/models/ticket.go
Normal file
53
internal/models/ticket.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// 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 // 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 // TODO: convert to time.Time
|
||||||
|
ImagePath string
|
||||||
|
Duplicate bool // Calculated during insert
|
||||||
|
MatchedMain int
|
||||||
|
MatchedBonus int
|
||||||
|
PrizeTier string
|
||||||
|
IsWinner bool
|
||||||
|
|
||||||
|
// Non-DB display helpers populated in read model
|
||||||
|
Balls []int
|
||||||
|
BonusBalls []int
|
||||||
|
MatchedDraw DrawResult
|
||||||
|
PrizeAmount float64 `db:"prize_amount"`
|
||||||
|
PrizeLabel string `db:"prize_label"`
|
||||||
|
}
|
||||||
15
internal/models/user.go
Normal file
15
internal/models/user.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Id int64
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
PasswordHash string
|
||||||
|
IsAdmin bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
32
internal/platform/bootstrap/license.go
Normal file
32
internal/platform/bootstrap/license.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
internal "synlotto-website/internal/licensecheck"
|
||||||
|
"synlotto-website/internal/platform/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var globalChecker *internal.LicenseChecker
|
||||||
|
|
||||||
|
func InitLicenseChecker(config *config.Config) error {
|
||||||
|
checker := &internal.LicenseChecker{
|
||||||
|
LicenseAPIURL: config.License.APIURL,
|
||||||
|
APIKey: config.License.APIKey,
|
||||||
|
PollInterval: 10 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checker.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
checker.StartBackgroundCheck()
|
||||||
|
globalChecker = checker
|
||||||
|
log.Println("✅ License validation started.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLicenseChecker() *internal.LicenseChecker {
|
||||||
|
return globalChecker
|
||||||
|
}
|
||||||
206
internal/platform/bootstrap/loader.go
Normal file
206
internal/platform/bootstrap/loader.go
Normal file
@@ -0,0 +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 (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/gob"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
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 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 Load(configPath string) (*App, error) {
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user