Compare commits

...

128 Commits

Author SHA1 Message Date
cc759ec694 Fix csrf 2025-11-02 09:50:50 +00:00
f0fc70eac6 Add in mark as reaad button to list view, use ajax to preform the action without page refresh. 2025-11-02 09:11:48 +00:00
61ad033520 Fix archiving and unarchiving functionality. 2025-11-01 22:37:47 +00:00
9dc01f925a Changes to pagination and fixing archive messages in progress 2025-10-31 22:55:04 +00:00
8529116ad2 Messages now sending/loading and populating on message dropdown 2025-10-31 12:08:38 +00:00
776ea53a66 Formatting 2025-10-31 12:00:43 +00:00
5880d1ca43 Fix reading of messages. 2025-10-31 12:00:08 +00:00
da365aa9ef Remove unused functions. 2025-10-31 11:57:39 +00:00
5177194895 Add sender 2025-10-31 09:45:20 +00:00
a7a5169c67 Fix model issues. 2025-10-30 22:19:48 +00:00
262536135d Still working through messages and notifications. 2025-10-30 17:22:52 +00:00
8650b1fd63 Continued work on messages and notifications. 2025-10-30 11:11:22 +00:00
b41e92629b Continued work around getting messages and notifications cleaned up since moving to MySQL and changing to Gin, SCS, NoSurf. 2025-10-29 15:22:05 +00:00
0b2883a494 todo comment 2025-10-29 15:21:20 +00:00
5520685504 minor update to footer. 2025-10-29 15:21:07 +00:00
e2b30c0234 minor formatting and text 2025-10-29 15:19:24 +00:00
07f7a50b77 ToDo job 2025-10-29 15:19:07 +00:00
f458250d3a correct package name 2025-10-29 11:38:05 +00:00
f2cb283158 todo for a later date 2025-10-29 11:37:50 +00:00
b9bc29d5bc fix loading of ticket add page 2025-10-29 11:37:35 +00:00
b6b5207d43 Fleshing out some routes from notifications and messages 2025-10-29 10:43:48 +00:00
34918d770f Fix for tim.Time change to tickets model includes date helper. 2025-10-29 10:00:58 +00:00
eba25a4fb5 comment model. 2025-10-29 09:47:51 +00:00
e6654fc1b4 User specific lottery ticket creation 2025-10-29 09:47:35 +00:00
ddafdd0468 current duplicate check uses IS ? which is fragile in MySQL. Using the NULL-safe equality operator <=> instead. 2025-10-29 09:29:51 +00:00
5fcb4fb016 change the field to time.Time for correctness 2025-10-29 09:29:10 +00:00
71c8d4d06c fix typo 2025-10-29 08:54:19 +00:00
244b882f11 Code documentation 2025-10-29 08:36:10 +00:00
8d2ce27a74 commented code 2025-10-28 22:46:11 +00:00
72e655674f Remove redunant ping and update comments. 2025-10-28 22:42:58 +00:00
f1e16fbc52 Logged-in users don’t see login/signup pages 2025-10-28 22:26:15 +00:00
aec8022439 Add additional columns to aufit_login for session tokens. fixed requireAuth for loading of some pages as requireauth was threating a valid session as not logged in. 2025-10-28 22:22:17 +00:00
e1fa6c502e Centralize audit SQL + writers 2025-10-28 15:26:43 +00:00
aa20652abc Moved admin only to middleware. 2025-10-28 15:24:25 +00:00
c9f3863a25 Fix: apply schema: Error 1064 (42000) 2025-10-28 14:38:50 +00:00
76cdb96966 update signup html 2025-10-28 14:37:21 +00:00
29cb50bb34 Update users table and fix potential panic as fk references users before its created. 2025-10-28 14:37:05 +00:00
ffcc340034 remove flash from layout. 2025-10-28 13:49:55 +00:00
af581a4def Fix ob: type not registered for interface: map[string]string & superfluous response.WriteHeader, as well as wired up to go to custom 500 messages. 2025-10-28 13:16:29 +00:00
e0b063fab0 Remove comments and update path. 2025-10-28 12:43:41 +00:00
4a6bfad880 readme file. 2025-10-28 12:01:52 +00:00
04c3cb3851 Current config structure 2025-10-28 11:59:04 +00:00
c911bf9151 Ignore main.exe 2025-10-28 11:57:27 +00:00
86be6479f1 Stack of changes to get gin, scs, nosurf running. 2025-10-28 11:56:42 +00:00
07117ba35e No longer required. 2025-10-24 13:19:55 +01:00
ac1f6e9399 refactor(template): delegate handler-level RenderError to helpers package
- Moved core RenderError logic to internal/helpers/template/error.go
- Added thin wrapper method in internal/handlers/template/error.go
- Simplified function signature (no config args, uses InitSiteMeta)
- Preserved architecture: handlers own HTTP layer, helpers supply logic
2025-10-24 13:15:12 +01:00
fb07c4a5eb Refactoring for Gin, NoSurf and SCS continues. 2025-10-24 13:08:53 +01:00
7276903733 refactor(config): move Config struct from business layer to platform/config
Moved the Config struct (previously in internal/models/config.go) into internal/platform/config/types.go to align with clean architecture principles.

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

No functional changes to configuration loading structure and JSON schema remain the same; only the package location and imports were updated.
2025-10-24 08:35:39 +01:00
0f60be448d Refactoring continues. 2025-10-23 21:51:15 +01:00
82f457c5a4 New packages for MySQL. 2025-10-23 19:53:06 +01:00
b098915ab9 Update import paths 2025-10-23 19:51:28 +01:00
21ebc9c34b Refactor and remove sqlite and replace with MySQL 2025-10-23 18:43:31 +01:00
d53e27eea8 Switching to MySQL 2025-10-22 22:43:35 +01:00
752db0b89d New statistics related models and handlers. 2025-10-22 20:58:25 +01:00
8d06a7a962 Remove log output and updated comment 2025-10-22 20:57:03 +01:00
7597fff8b1 routes.SetupStatisticsRoutes 2025-10-22 20:56:04 +01:00
58dd313703 Forgot to add draw IDs to tables. 2025-10-20 14:48:33 +01:00
e0a2e5430e Change greeting to include user related dropdown items. 2025-04-23 15:30:23 +01:00
7d26f6cf48 Update footer so copyright start year is displayed and evaluated. 2025-04-23 15:30:02 +01:00
0634876b75 Add in context to dashboard for user to topbar loads all items & refactor for clarity of variable extraction. 2025-04-23 15:29:34 +01:00
b5f1b17684 Fix loading of tickets missing range class. 2025-04-23 11:54:45 +01:00
f9853c921d Refactoring finalised ... I think. 2025-04-23 11:30:03 +01:00
e938828a8c fix loading ofr login form and redirects. 2025-04-23 10:06:55 +01:00
2ce810a4dd Refactoring cont. 2025-04-23 09:44:19 +01:00
5c3a847900 Massive refactor! 2025-04-22 23:26:11 +01:00
05bb05d45c Move admin.go this is a storage task, updated routes to use proection 2025-04-16 10:41:06 +01:00
7f91771166 Implement a stronger, reusable session timeout 2025-04-16 10:32:34 +01:00
f7e9fe7794 make shutting down graceful 2025-04-16 10:24:54 +01:00
2440b3a668 Rewiring CSRF protection and movign some functionality to the bootstrapping stage. 2025-04-16 09:50:58 +01:00
4bb3b58ddb Bootstrapped the creation and loading of session keys to lighten main. 2025-04-16 08:21:02 +01:00
0a21973237 rework to lighten the main, refactor wrappers. Rehandle csrf and pull config items. 2025-04-15 22:19:55 +01:00
0a5d61ea1e Re-work loading of configuration, introduce a loader for start up & and custom logging wrapper. 2025-04-15 21:10:57 +01:00
d7c15141b8 Been too long since i did anything, can't remember what the hell is in all this.... 2025-04-15 20:56:21 +01:00
152c5cb18c Add Syndicate Invite Token System (Secure Links)
- Added route + handler: GenerateInviteLinkHandler to create signed tokens
- Added handler: JoinSyndicateWithTokenHandler to join using invite token
- Integrated secure token generation via helpers.GenerateSecureToken()
- Created DB model: syndicate_invite_tokens (assumed pre-existing)
- Updated syndicate view template to allow managers to generate links
- Flash messaging for invite success/failure
- Invite links are scoped to manager role and valid for 48 hours
2025-04-04 22:33:40 +01:00
8b02a3137d Filter out syndicates where the user is both the owner and a member in syndicate veiw. 2025-04-04 11:19:45 +01:00
f6350b1d7f more link fixes. 2025-04-04 11:12:54 +01:00
292db01011 Fix issue creating syndicates, accepted should not be set when creating. 2025-04-04 11:10:15 +01:00
02300c69d8 Fix Syndicate paths. 2025-04-04 10:57:06 +01:00
22fbf59157 Forgot to remove handler. 2025-04-04 10:30:28 +01:00
ef4478e8a6 Added syndicate creation and invite logic
- Implemented `CreateSyndicate` with transaction support
- Added `InviteToSyndicate` with membership check and invite insert
- Created syndicate invite model and DB logic for accepting/declining
- Ensured consistent error handling with `fmt.Errorf`
- Cleaned up unused invite handlers/routes
2025-04-04 10:26:24 +01:00
df6608dda5 forgot to stage 2025-04-02 23:53:40 +01:00
053ccf3845 **Untested! ** Add restore functionality for archived messages
- Added `RestoreMessageHandler` and route at `/account/messages/restore`
- Updated `users_messages` table to support `archived_at` reset
- Added restore button to archived messages template
- Ensures archived messages can be moved back into inbox
2025-04-02 23:53:29 +01:00
db5352bc9c Add restore functionality for archived messages
- Added `RestoreMessageHandler` and route at `/account/messages/restore`
- Updated `users_messages` table to support `archived_at` reset
- Added restore button to archived messages template
- Ensures archived messages can be moved back into inbox
2025-04-02 22:18:02 +01:00
dd83081271 Added full message handling system with archive view, pagination, and send support
- Implemented message inbox and archived messages view
- Added pagination logic to both inbox and archive handlers
- Integrated message sending functionality with CSRF protection
- Updated schema to include `archived_at` timestamp
- Included archive button and logic with feedback flash messaging
- Fixed message dropdown routing and rendering in topbar
- Cleaned up template load paths and error handling
2025-04-02 21:29:54 +01:00
e3428911b9 Messages: Add archive (soft-delete) support + dropdown UI polish
- Implemented `/account/messages/archive` route for soft-archiving messages
- Added `is_archived` flag to `users_messages` schema and model
- Topbar dropdown now reflects accurate unread message count
- Fixed missing route registration for archive handler
- Improved message visibility checks to prevent access violations
- Placeholder for rate-limit (429) error page rendering identified
2025-04-02 17:15:57 +01:00
2fd053777d Feature: complete message inbox, view, and topbar integration
Added users_messages schema with correct field naming (senderId, recipientId)

Implemented message count and recent message fetch via storage.GetMessageCount and GetRecentMessages

Fixed field mismatches in SQL queries (recipientId vs recipient_id)

Displayed unread message badge in topbar with truncation for body preview

Linked messages in dropdown to full view (/account/messages/read?id=...)

Added fallback handling for unauthorized/invalid message access

Cleaned up BuildTemplateData to support full message context

Ensured CSRF/session/user context remains intact throughout
2025-04-02 13:41:51 +01:00
b630296b8c Add message system (inbox, read view, dropdown) and truncate helper
Implemented message retrieval and read logic in storage layer

Added handlers for inbox and individual message view

Integrated messages into topbar dropdown with unread badge

Added truncate helper to template functions

Created new templates: messages/index.html and messages/read.html

Fixed missing template function error in topbar rendering
2025-04-02 11:56:11 +01:00
ab1d9abc72 Refactor: Recover middleware now uses RenderError + add full notifications view
- Replaced http.Error with helpers.RenderError in Recover middleware
- Custom 500.html now rendered with layout and topbar on panic
- RenderError gracefully checks template existence and falls back to plain response
- Added /account/notifications full view page (index)
- Linked "Back to notifications" from notification read view
- Fixed typo in template path for notifications/index.html
- Improved layout consistency across error and account pages
2025-04-02 09:54:20 +01:00
2498b33a9c Refactor: Centralize template loading and improve error handling
- Introduced helpers.LoadTemplateFiles() for consistent layout + topbar rendering
- Replaced repeated template.ParseFiles() calls across handlers
- Created generic RenderError(w, r, statusCode) helper
- Replaced old Render403 with flexible RenderError
- Updated AdminOnly middleware to render 403 errors with context
- Added 500.html template for graceful panic fallback
- Prepared structure for future error codes (404, 429, etc.)
2025-04-02 09:12:13 +01:00
f5653f737d Delete synlotto.db 2025-04-01 22:22:46 +00:00
e5bf12ad77 Feature: Full notification read view with conditional mark-as-read logic
- Added dedicated route and view for reading individual notifications (/account/notifications/read)
- Ensured notification is only marked as read if it hasn't already been
- Updated Notification model to use Subject and Body fields
- Fixed field references in templates (Title → Subject, Message → Body)
- Updated topbar dropdown to use correct field names and display logic
- Gracefully handle "notification not found" cases in template output
- Ensured consistent template parsing with layout and topbar inclusion
- Improved error logging for better diagnosis
2025-04-01 23:08:58 +01:00
06e647d00f Feature: Add account-protected route to mark notifications as read
- Created /account/notifications/read endpoint secured by session middleware
- Ensured users can only mark their own notifications as read
- Updated dropdown links to point to /account/notifications/read?id={id}
- Improved notification security by matching user_id in DB update
- Added redirect flow to full notifications page after marking read
- Logged DB errors to assist debugging
2025-04-01 22:12:41 +01:00
1e372da57d adjust count mechanism. 2025-04-01 21:39:57 +01:00
d5b953475c schema update, no sender name required. 2025-04-01 21:38:08 +01:00
5ea780fcab Cleanup: Finalize template context integration and remove legacy code
- Replaced legacy TemplateContext calls with structured TemplateData usage
- Removed unused variables and redundant storage calls in notifications handler
- Ensured consistent use of BuildTemplateData across user-facing handlers
- Resolved all compile-time errors from refactor
- Ready for runtime testing and further layout integration
2025-04-01 21:20:05 +01:00
03b1e095ce Refactor: Centralize template context using unified TemplateData struct
- Introduced models.TemplateData for shared user/context state
- Moved context construction logic into handlers/template_context.go
- Simplified helpers.TemplateContext to accept structured data
- Restored and organized template helper functions
- Updated affected handlers (main.go, draw_handler.go, notifications.go)
- Improved scalability and separation of concerns in template rendering
2025-04-01 21:08:00 +01:00
6dbac8ab14 Already exists in helpers. 2025-04-01 16:02:59 +01:00
b79621cf9f Should not be in template.go should be template functions. 2025-04-01 16:01:33 +01:00
e68559ad3d thing this are broken but checking in so i have a state to revert too. 2025-04-01 15:52:31 +01:00
8469f02eb8 add db to ignore. 2025-04-01 15:50:39 +01:00
0e20cc023c more changes but unsure of state had to trash last set fof changes and try repair. 2025-04-01 15:50:15 +01:00
5aaddf16f1 More layout and customisations. 2025-04-01 10:19:56 +01:00
aaf90b55da Lots of UI and admin changes, need to clean up the three audit log tables and a few other niggles. 2025-04-01 00:05:48 +01:00
7eefb9ced0 Additional security and hardening. 2025-03-31 15:14:16 +01:00
c3a7480c65 expand on admin functionality, hardening still needs to be implemented. 2025-03-31 10:52:12 +01:00
b466c351da Fixzed display of prize tiers on tickets 2025-03-30 23:51:27 +01:00
f8dab97a54 Working through issue where prizes are incorrect and need updating. 2025-03-29 15:42:14 +00:00
c0143df8c0 not required already in layout 2025-03-28 22:55:38 +00:00
322b4877ed New admin triggers for db maintenance, updating display of prize results and logic fix 2025-03-28 22:52:54 +00:00
593dbb598e update table for prizes. 2025-03-28 15:27:10 +00:00
94a4274732 removed commented prize 2025-03-28 15:21:42 +00:00
0d33d65844 finalise ball colourings and display 2025-03-28 15:20:56 +00:00
75d8d1700e updated so balls are coloured to their respective game. need to thing about set for life. 2025-03-28 14:52:50 +00:00
cabc283673 madness continues 2025-03-28 13:04:53 +00:00
23e0208317 Lots of changes around viewing tickets from css perspective logic changes nwe handlers and service triggers... just lots of stuff... 2025-03-28 10:05:54 +00:00
e13b375af7 Lots of changes, still more to do 2025-03-27 15:03:43 +00:00
27b862b84b structs for matching results to tickets. 2025-03-27 10:45:29 +00:00
204cfb1539 create lotto results table. 2025-03-27 10:44:09 +00:00
f3949717b8 Can't remember why i added this lol 2025-03-27 10:43:45 +00:00
f7b54db7c1 Get ticket functionality. 2025-03-26 15:33:40 +00:00
66abdbdd4d Add tickets to db. 2025-03-26 13:26:43 +00:00
f001cfe35e update ticket handler 2025-03-26 10:09:31 +00:00
9df4b173fb remove println 2025-03-26 09:43:01 +00:00
c736b95c50 remove println 2025-03-26 09:42:44 +00:00
06a7296285 update my tickets table creation 2025-03-26 09:42:02 +00:00
8dc4f61089 new helper for adding tickets 2025-03-26 09:41:39 +00:00
eae4561e1f new ticket functions 2025-03-26 09:41:22 +00:00
41ce9a3dd0 Move around endpoints. 2025-03-26 09:41:01 +00:00
188 changed files with 9829 additions and 1011 deletions

2
.gitignore vendored
View File

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

223
README.md Normal file
View File

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

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

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

49
go.mod
View File

@@ -3,23 +3,44 @@ module synlotto-website
go 1.24.1
require (
github.com/gorilla/csrf v1.7.2
github.com/gorilla/sessions v1.4.0
golang.org/x/crypto v0.36.0
modernc.org/sqlite v1.36.1
github.com/alexedwards/scs/v2 v2.9.0
github.com/gin-gonic/gin v1.11.0
github.com/go-sql-driver/mysql v1.9.3
github.com/justinas/nosurf v1.2.0
golang.org/x/crypto v0.40.0
golang.org/x/time v0.11.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
golang.org/x/sys v0.31.0 // indirect
modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)

147
go.sum
View File

@@ -1,59 +1,98 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/justinas/nosurf v1.2.0 h1:yMs1bSRrNiwXk4AS6n8vL2Ssgpb9CB25T/4xrixaK0s=
github.com/justinas/nosurf v1.2.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.36.1 h1:bDa8BJUH4lg6EGkLbahKe/8QqoF8p9gArSc6fTqYhyQ=
modernc.org/sqlite v1.36.1/go.mod h1:7MPwH7Z6bREicF9ZVUR78P1IKuxfZ8mRIDHD0iD+8TU=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,130 +0,0 @@
package handlers
import (
"html/template"
"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 := template.Must(template.ParseFiles(
"templates/layout.html",
"templates/account/login.html",
))
context := helpers.TemplateContext(w, r)
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 := template.Must(template.ParseFiles(
"templates/layout.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)
}

View File

@@ -1,109 +0,0 @@
package handlers
import (
"database/sql"
"html/template"
"log"
"net/http"
"sort"
"synlotto-website/helpers"
"synlotto-website/models"
"github.com/gorilla/csrf"
)
func Home(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Println("✅ Home hit")
rows, err := db.Query(`
SELECT id, draw_date, machine, ballset, ball1, ball2, ball3, ball4, ball5, thunderball
FROM results_thunderball
ORDER BY id DESC
`)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
log.Println("❌ DB error:", err)
return
}
var results []models.ThunderballResult
for rows.Next() {
var res models.ThunderballResult
err := rows.Scan(
&res.Id, &res.DrawDate, &res.Machine, &res.BallSet,
&res.Ball1, &res.Ball2, &res.Ball3, &res.Ball4, &res.Ball5, &res.Thunderball,
)
if err != nil {
log.Println("❌ Row scan error:", err)
continue
}
res.SortedBalls = []int{
res.Ball1, res.Ball2, res.Ball3, res.Ball4, res.Ball5,
}
sort.Ints(res.SortedBalls)
results = append(results, res)
}
tmpl := template.Must(template.ParseFiles(
"templates/layout.html",
"templates/index.html",
))
err = tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{
"Data": results,
})
if err != nil {
log.Println("❌ Template error:", err)
http.Error(w, "Error rendering homepage", http.StatusInternalServerError)
return
}
}
}
func NewDraw(w http.ResponseWriter, r *http.Request) {
log.Println("➡️ New draw form opened")
tmpl := template.Must(template.ParseFiles(
"templates/layout.html",
"templates/new_draw.html",
))
err := tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{
"csrfField": csrf.TemplateField(r),
"Page": "new_draw",
"Data": nil,
})
if err != nil {
log.Println("❌ Template error:", err)
http.Error(w, "Error rendering form", http.StatusInternalServerError)
}
}
func Submit(w http.ResponseWriter, r *http.Request) {
log.Println("📝 Form submission received")
r.ParseForm()
draw := models.ThunderballResult{
DrawDate: r.FormValue("date"),
Machine: r.FormValue("machine"),
BallSet: helpers.Atoi(r.FormValue("ballSet")),
Ball1: helpers.Atoi(r.FormValue("ball1")),
Ball2: helpers.Atoi(r.FormValue("ball2")),
Ball3: helpers.Atoi(r.FormValue("ball3")),
Ball4: helpers.Atoi(r.FormValue("ball4")),
Ball5: helpers.Atoi(r.FormValue("ball5")),
Thunderball: helpers.Atoi(r.FormValue("thunderball")),
}
Draws = append(Draws, draw)
log.Printf("📅 %s | 🛠 %s | 🎱 %d | 🔢 %d,%d,%d,%d,%d | ⚡ %d\n",
draw.DrawDate, draw.Machine, draw.BallSet,
draw.Ball1, draw.Ball2, draw.Ball3, draw.Ball4, draw.Ball5, draw.Thunderball)
http.Redirect(w, r, "/", http.StatusSeeOther)
}

View File

@@ -1,109 +0,0 @@
package handlers
import (
"database/sql"
"html/template"
"log"
"net/http"
"synlotto-website/helpers"
"synlotto-website/models"
"synlotto-website/storage"
"github.com/gorilla/csrf"
)
func NewTicket(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Println("➡️ New ticket form opened")
tmpl := template.Must(template.ParseFiles(
"templates/layout.html",
"templates/new_ticket.html",
))
err := tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{
"csrfField": csrf.TemplateField(r),
"Page": "new_ticket",
"Data": nil,
})
if err != nil {
log.Println("❌ Template error:", err)
http.Error(w, "Error rendering form", http.StatusInternalServerError)
}
}
}
func SubmitTicket(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, ok := helpers.GetCurrentUserID(r); !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ticket := models.MyTicket{
GameType: r.FormValue("game_type"),
DrawDate: r.FormValue("draw_date"),
Ball1: helpers.Atoi(r.FormValue("ball1")),
Ball2: helpers.Atoi(r.FormValue("ball2")),
Ball3: helpers.Atoi(r.FormValue("ball3")),
Ball4: helpers.Atoi(r.FormValue("ball4")),
Ball5: helpers.Atoi(r.FormValue("ball5")),
Bonus1: helpers.Nullable(helpers.Atoi(r.FormValue("bonus1"))),
Bonus2: helpers.Nullable(helpers.Atoi(r.FormValue("bonus2"))),
}
if err := storage.InsertTicket(db, ticket); err != nil {
log.Println("❌ Failed to insert ticket:", err)
http.Error(w, "Error storing ticket", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
func ListTickets(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Println("📋 Tickets page hit")
tmpl := template.Must(template.ParseFiles(
"templates/layout.html",
"templates/tickets.html",
))
rows, err := db.Query(`
SELECT id, game_type, draw_date, ball1, ball2, ball3, ball4, ball5, bonus1, bonus2, duplicate
FROM my_tickets
ORDER BY draw_date DESC
`)
if err != nil {
log.Println("❌ Failed to query tickets:", err)
http.Error(w, "Could not load tickets", http.StatusInternalServerError)
return
}
defer rows.Close()
var tickets []models.MyTicket
for rows.Next() {
var t models.MyTicket
err := rows.Scan(
&t.Id, &t.GameType, &t.DrawDate,
&t.Ball1, &t.Ball2, &t.Ball3, &t.Ball4, &t.Ball5,
&t.Bonus1, &t.Bonus2, &t.Duplicate,
)
if err != nil {
log.Println("❌ Row scan error:", err)
continue
}
tickets = append(tickets, t)
}
err = tmpl.ExecuteTemplate(w, "layout", map[string]any{
"Page": "tickets",
"Data": tickets,
})
if err != nil {
log.Println("❌ Template rendering error:", err)
http.Error(w, "Could not render page", http.StatusInternalServerError)
}
}
}

View File

@@ -1,18 +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
}

View File

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

View File

@@ -1,57 +0,0 @@
package helpers
import (
"html/template"
"log"
"net/http"
"synlotto-website/models"
)
func TemplateFuncs() template.FuncMap {
return template.FuncMap{
"plus1": func(i int) int { return i + 1 },
"minus1": func(i int) int {
if i > 1 {
return i - 1
}
return 0
},
"mul": func(a, b int) int { return a * b },
"add": func(a, b int) int { return a + b },
"min": func(a, b int) int {
if a < b {
return a
}
return b
},
}
}
func TemplateContext(w http.ResponseWriter, r *http.Request) map[string]interface{} {
session, _ := GetSession(w, r)
var flash string
if f, ok := session.Values["flash"].(string); ok {
flash = f
delete(session.Values, "flash")
session.Save(r, w)
}
var currentUser *models.User
switch v := session.Values["user_id"].(type) {
case int:
currentUser = models.GetUserByID(v)
case int64:
currentUser = models.GetUserByID(int(v))
default:
currentUser = nil
}
log.Printf("🧪 TemplateContext user: %#v", currentUser)
return map[string]interface{}{
"Flash": flash,
"User": currentUser,
}
}

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
package accountHandler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
securityHelpers "synlotto-website/internal/helpers/security"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/logging"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/bootstrap"
"synlotto-website/internal/platform/sessionkeys"
auditlogStorage "synlotto-website/internal/storage/auditlog"
usersStorage "synlotto-website/internal/storage/users"
)
func LoginGet(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{})
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
ctx["Flash"] = f
}
ctx["CSRFToken"] = nosurf.Token(c.Request)
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/login.html")
c.Status(http.StatusOK)
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
logging.Info("❌ Template render error: %v", err)
c.String(http.StatusInternalServerError, "Error rendering login page")
}
}
func LoginPost(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
db := app.DB
r := c.Request
w := c.Writer
username := r.FormValue("username")
password := r.FormValue("password")
logging.Info("🔐 Login attempt - Username: %s", username)
user := usersStorage.GetUserByUsername(db, username)
if user == nil {
logging.Info("❌ User not found: %s", username)
auditlogStorage.LogLoginAttempt(db, r.RemoteAddr, r.UserAgent(), username, false)
sm.Put(r.Context(), "flash", "Invalid username or password.")
c.Redirect(http.StatusSeeOther, "/account/login")
return
}
if !securityHelpers.CheckPasswordHash(user.PasswordHash, password) {
logging.Info("❌ Password mismatch for user: %s", username)
auditlogStorage.LogLoginAttempt(db, r.RemoteAddr, r.UserAgent(), username, false)
sm.Put(r.Context(), "flash", "Invalid username or password.")
c.Redirect(http.StatusSeeOther, "/account/login")
return
}
logging.Info("✅ Login successful for user: %s", username)
auditlogStorage.LogLoginAttempt(db, r.RemoteAddr, r.UserAgent(), username, true)
_ = sm.Destroy(r.Context())
_ = sm.RenewToken(r.Context())
sm.Put(r.Context(), "user_id", user.Id)
sm.Put(r.Context(), sessionkeys.Username, user.Username)
sm.Put(r.Context(), sessionkeys.IsAdmin, user.IsAdmin)
sm.Put(r.Context(), "last_activity", time.Now().UTC())
sm.Put(r.Context(), "flash", "Welcome back, "+user.Username+"!")
http.Redirect(w, r, "/", http.StatusSeeOther)
}

View File

@@ -0,0 +1,19 @@
package accountHandler
import (
"net/http"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
)
func Logout(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
_ = sm.Destroy(c.Request.Context())
_ = sm.RenewToken(c.Request.Context())
sm.Put(c.Request.Context(), "flash", "You've been logged out.")
c.Redirect(http.StatusSeeOther, "/account/login")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
// internal/handlers/account/notifications/read.go
package accountNotificationHandler
import (
"net/http"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/logging"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
// ToDo: functional also in messages needs to come out
func parseIDParam(c *gin.Context, name string) (int64, error) {
// typical atoi wrapper
// (implement: strconv.ParseInt(c.Param(name), 10, 64))
return atoi64(c.Param(name))
}
func (h *AccountNotificationHandlers) ReadGet(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
id, err := parseIDParam(c, "id")
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
n, err := h.Svc.GetByID(userID, id)
if err != nil || n == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{})
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
ctx["Flash"] = f
}
ctx["CSRFToken"] = nosurf.Token(c.Request)
ctx["Title"] = n.Title // or Subject/Heading depending on your struct
ctx["Notification"] = n
tmpl := templateHelpers.LoadTemplateFiles(
"layout.html",
"web/templates/account/notifications/read.html",
)
c.Status(http.StatusOK)
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
logging.Info("❌ Template render error: %v", err)
c.String(http.StatusInternalServerError, "Error rendering notification")
}
}

View File

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

View File

@@ -0,0 +1,157 @@
package accountHandler
import (
"database/sql"
"net/http"
"strings"
httphelpers "synlotto-website/internal/helpers/http"
securityHelpers "synlotto-website/internal/helpers/security"
templateHelpers "synlotto-website/internal/helpers/template"
auditlogStorage "synlotto-website/internal/storage/auditlog"
usersStorage "synlotto-website/internal/storage/users"
"synlotto-website/internal/logging"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
type registerForm struct {
Username string
Email string
Password string
PasswordConfirm string
AcceptTerms bool
}
func SignupGet(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{})
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
ctx["Flash"] = f
}
ctx["CSRFToken"] = nosurf.Token(c.Request)
if v := sm.Pop(c.Request.Context(), "register.form"); v != nil {
if fm, ok := v.(map[string]string); ok {
ctx["Form"] = fm
}
}
if v := sm.Pop(c.Request.Context(), "register.errors"); v != nil {
if errs, ok := v.(map[string]string); ok {
ctx["Errors"] = errs
}
}
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/signup.html")
c.Status(http.StatusOK)
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
logging.Info("❌ Template render error (register): %v", err)
c.String(http.StatusInternalServerError, "Error rendering register page")
}
}
func SignupPost(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
db := app.DB
r := c.Request
form := registerForm{
Username: strings.TrimSpace(r.FormValue("username")),
Email: strings.TrimSpace(r.FormValue("email")),
Password: r.FormValue("password"),
PasswordConfirm: r.FormValue("password_confirm"),
AcceptTerms: r.FormValue("accept_terms") == "on",
}
errMap := validateRegisterForm(db, form)
if len(errMap) > 0 {
formMap := map[string]string{
"username": form.Username,
"email": form.Email,
"accept_terms": func() string {
if form.AcceptTerms {
return "on"
}
return ""
}(),
}
sm.Put(r.Context(), "register.form", formMap)
sm.Put(r.Context(), "register.errors", errMap)
sm.Put(r.Context(), "flash", "Please fix the highlighted errors.")
c.Redirect(http.StatusSeeOther, "/account/signup")
c.Abort()
return
}
hash, err := securityHelpers.HashPassword(form.Password)
if err != nil {
logging.Info("❌ Hash error: %v", err)
sm.Put(r.Context(), "flash", "Something went wrong. Please try again.")
c.Redirect(http.StatusSeeOther, "/account/signup")
c.Abort()
return
}
id, err := usersStorage.CreateUser(db, form.Username, form.Email, hash)
if err != nil {
logging.Info("❌ CreateUser error: %v", err)
sm.Put(r.Context(), "flash", "That username or email is already taken.")
c.Redirect(http.StatusSeeOther, "/account/signup")
c.Abort()
return
}
auditlogStorage.LogSignup(
db,
id,
form.Username,
form.Email,
httphelpers.ClientIP(r),
r.UserAgent(),
)
sm.Put(r.Context(), "flash", "Account created. You can log in now.")
c.Redirect(http.StatusSeeOther, "/account/login")
c.Abort()
}
func validateRegisterForm(db *sql.DB, f registerForm) map[string]string {
errs := make(map[string]string)
if f.Username == "" || len(f.Username) < 3 {
errs["username"] = "Username must be at least 3 characters."
} else if usersStorage.UsernameExists(db, f.Username) {
errs["username"] = "Username is already in use."
}
if f.Email == "" || !looksLikeEmail(f.Email) {
errs["email"] = "Please enter a valid email."
} else if usersStorage.EmailExists(db, f.Email) {
errs["email"] = "Email is already registered."
}
if len(f.Password) < 8 {
errs["password"] = "Password must be at least 8 characters."
}
if f.Password != f.PasswordConfirm {
errs["password_confirm"] = "Passwords do not match."
}
if !f.AcceptTerms {
errs["accept_terms"] = "You must accept the terms."
}
return errs
}
func looksLikeEmail(s string) bool {
return strings.Count(s, "@") == 1 && strings.Contains(s, ".")
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,91 @@
package handlers
import (
"database/sql"
"log"
"net/http"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/models"
)
type AdminLogEntry struct {
AccessedAt string
UserID int
Path string
IP string
UserAgent string
}
func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data)
rows, err := db.Query(`
SELECT accessed_at, user_id, path, ip, user_agent
FROM admin_access_log
ORDER BY accessed_at DESC
LIMIT 100
`)
if err != nil {
log.Println("⚠️ Failed to load admin access logs:", err)
http.Error(w, "Error loading logs", http.StatusInternalServerError)
return
}
defer rows.Close()
var logs []AdminLogEntry // ToDo: move to models ?
for rows.Next() {
var entry AdminLogEntry
if err := rows.Scan(&entry.AccessedAt, &entry.UserID, &entry.Path, &entry.IP, &entry.UserAgent); err != nil {
log.Println("⚠️ Scan failed:", err)
continue
}
logs = append(logs, entry)
}
context["AuditLogs"] = logs
tmpl := templateHelpers.LoadTemplateFiles("access_log.html", "web/templates/admin/logs/access_log.html")
_ = tmpl.ExecuteTemplate(w, "layout", context)
}
}
func AuditLogHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data)
rows, err := db.Query(`
SELECT timestamp, user_id, action, ip, user_agent
FROM audit_log
ORDER BY timestamp DESC
LIMIT 100
`)
if err != nil {
log.Println("❌ Failed to load audit log:", err)
http.Error(w, "Could not load audit log", http.StatusInternalServerError)
return
}
defer rows.Close()
var logs []models.AuditEntry
for rows.Next() {
var entry models.AuditEntry
if err := rows.Scan(&entry.Timestamp, &entry.UserID, &entry.Action, &entry.IP, &entry.UserAgent); err != nil {
log.Println("⚠️ Failed to scan row:", err)
continue
}
logs = append(logs, entry)
}
context["AuditLogs"] = logs
tmpl := templateHelpers.LoadTemplateFiles("audit.html", "web/templates/admin/logs/audit.html")
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
log.Println("❌ Failed to render audit page:", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
}

View File

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

View File

@@ -0,0 +1,111 @@
package handlers
// ToDo: move SQL into storage layer
import (
"database/sql"
"log"
"net/http"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/models"
)
func NewDrawHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
ctx := templateHelpers.TemplateContext(w, r, data)
if r.Method == http.MethodPost {
game := r.FormValue("game_type")
date := r.FormValue("draw_date")
machine := r.FormValue("machine")
ballset := r.FormValue("ball_set")
_, err := db.Exec(
`INSERT INTO results_thunderball (game_type, draw_date, machine, ball_set) VALUES (?, ?, ?, ?)`,
game, date, machine, ballset,
)
if err != nil {
http.Error(w, "Failed to add draw", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
return
}
tmpl := templateHelpers.LoadTemplateFiles("new_draw", "web/templates/admin/draws/new_draw.html")
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
}
}
func ModifyDrawHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
id := r.FormValue("id")
_, err := db.Exec(
`UPDATE results_thunderball SET game_type=?, draw_date=?, ball_set=?, machine=? WHERE id=?`,
r.FormValue("game_type"),
r.FormValue("draw_date"),
r.FormValue("ball_set"),
r.FormValue("machine"),
id,
)
if err != nil {
http.Error(w, "Update failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
return
}
// For GET: load draw by ID if needed and render a form/template
}
}
func DeleteDrawHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
id := r.FormValue("id")
if _, err := db.Exec(`DELETE FROM results_thunderball WHERE id = ?`, id); err != nil {
http.Error(w, "Delete failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
return
}
}
}
func ListDrawsHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
ctx := templateHelpers.TemplateContext(w, r, data)
var draws []models.DrawSummary
rows, err := db.Query(`
SELECT r.id, r.game_type, r.draw_date, r.ball_set, r.machine,
(SELECT COUNT(1) FROM prizes_thunderball p WHERE p.draw_date = r.draw_date) as prize_exists
FROM results_thunderball r
ORDER BY r.draw_date DESC
`)
if err != nil {
http.Error(w, "Failed to query draws", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var d models.DrawSummary
var prizeFlag int
if err := rows.Scan(&d.Id, &d.GameType, &d.DrawDate, &d.BallSet, &d.Machine, &prizeFlag); err != nil {
log.Println("⚠️ Draw scan failed:", err)
continue
}
d.PrizeSet = prizeFlag > 0
draws = append(draws, d)
}
ctx["Draws"] = draws
tmpl := templateHelpers.LoadTemplateFiles("list.html", "web/templates/admin/draws/list.html")
_ = tmpl.ExecuteTemplate(w, "layout", ctx)
}
}

View File

@@ -0,0 +1,131 @@
package handlers
import (
"database/sql"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
templateHelpers "synlotto-website/internal/helpers/template"
services "synlotto-website/internal/services/tickets"
"synlotto-website/internal/models"
)
// ToDo: need to fix flash messages from new gin context
func AdminTriggersHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data)
if flash := r.URL.Query().Get("flash"); flash != "" {
context["Flash"] = flash
}
if r.Method == http.MethodPost {
action := r.FormValue("action")
flashMsg := ""
switch action {
case "match":
stats, err := services.RunTicketMatching(db, "manual")
if err != nil {
http.Error(w, "Matching failed: "+err.Error(), http.StatusInternalServerError)
return
}
flashMsg = fmt.Sprintf("✅ Matched %d tickets, %d winners.", stats.TicketsMatched, stats.WinnersFound)
case "prizes":
err := services.UpdateMissingPrizes(db)
if err != nil {
http.Error(w, "Prize update failed: "+err.Error(), http.StatusInternalServerError)
return
}
flashMsg = "✅ Missing prizes updated."
case "refresh_prizes":
err := services.RefreshTicketPrizes(db)
if err != nil {
http.Error(w, "Refresh failed: "+err.Error(), http.StatusInternalServerError)
return
}
flashMsg = "✅ Ticket prizes refreshed."
case "run_all":
stats, err := services.RunTicketMatching(db, "manual")
if err != nil {
http.Error(w, "Matching failed: "+err.Error(), http.StatusInternalServerError)
return
}
err = services.UpdateMissingPrizes(db)
if err != nil {
http.Error(w, "Prize update failed: "+err.Error(), http.StatusInternalServerError)
return
}
flashMsg = fmt.Sprintf("✅ Matched %d tickets, %d winners. Prizes updated.", stats.TicketsMatched, stats.WinnersFound)
default:
flashMsg = "⚠️ Unknown action."
}
http.Redirect(w, r, "/admin/triggers?flash="+url.QueryEscape(flashMsg), http.StatusSeeOther)
return
}
tmpl := templateHelpers.LoadTemplateFiles("triggers.html", "web/templates/admin/triggers.html")
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Println("Template error:", err)
http.Error(w, "Failed to load page", http.StatusInternalServerError)
}
}
}
func MatchTicketsHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
stats, err := services.RunTicketMatching(db, "manual")
if err != nil {
http.Error(w, "Matching failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/triggers?flash=Matched "+
strconv.Itoa(stats.TicketsMatched)+" tickets, "+
strconv.Itoa(stats.WinnersFound)+" winners.", http.StatusSeeOther)
}
}
func UpdateMissingPrizesHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := services.UpdateMissingPrizes(db)
if err != nil {
http.Error(w, "Prize update failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/triggers?flash=Updated missing prize data.", http.StatusSeeOther)
}
}
func RunAllHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
stats, err := services.RunTicketMatching(db, "manual")
if err != nil {
http.Error(w, "Matching failed: "+err.Error(), http.StatusInternalServerError)
return
}
err = services.UpdateMissingPrizes(db)
if err != nil {
http.Error(w, "Prize update failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/triggers?flash=Matched "+
strconv.Itoa(stats.TicketsMatched)+" tickets, "+
strconv.Itoa(stats.WinnersFound)+" winners. Prizes updated.", http.StatusSeeOther)
}
}

View File

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

View File

@@ -1,8 +1,8 @@
package handlers
import (
"synlotto-website/models"
"synlotto-website/internal/models"
)
var Draws []models.ThunderballResult
var MyTickets []models.MyTicket
var MyTickets []models.Ticket

29
internal/handlers/home.go Normal file
View 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
}
}
}

View File

@@ -0,0 +1,59 @@
package handlers
import (
"database/sql"
"log"
"net/http"
"synlotto-website/internal/helpers"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/models"
resultsThunderballStorage "synlotto-website/internal/storage/results/thunderball"
)
func NewDraw(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data)
context["Page"] = "new_draw"
context["Data"] = nil
tmpl := templateHelpers.LoadTemplateFiles("new_draw.html", "web/templates/admin/draws/new_draw.html") // ToDo: may need removing or moving add draw should be admin functionality and only when manually required. Potential live drawing of numbers in the future.
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Println("❌ Template error:", err)
http.Error(w, "Error rendering form", http.StatusInternalServerError)
}
}
}
func Submit(db *sql.DB, w http.ResponseWriter, r *http.Request) {
log.Println("📝 Form submission received")
_ = r.ParseForm()
draw := models.ThunderballResult{
DrawDate: r.FormValue("date"),
Machine: r.FormValue("machine"),
BallSet: helpers.Atoi(r.FormValue("ballSet")),
Ball1: helpers.Atoi(r.FormValue("ball1")),
Ball2: helpers.Atoi(r.FormValue("ball2")),
Ball3: helpers.Atoi(r.FormValue("ball3")),
Ball4: helpers.Atoi(r.FormValue("ball4")),
Ball5: helpers.Atoi(r.FormValue("ball5")),
Thunderball: helpers.Atoi(r.FormValue("thunderball")),
}
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",
draw.DrawDate, draw.Machine, draw.BallSet,
draw.Ball1, draw.Ball2, draw.Ball3, draw.Ball4, draw.Ball5, draw.Thunderball)
http.Redirect(w, r, "/", http.StatusSeeOther)
}

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

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

View File

@@ -0,0 +1,421 @@
// internal/handlers/lottery/tickets/ticket_handler.go
package handlers
import (
"database/sql"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
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"
)
// AddTicket renders the add-ticket form (GET) and handles multi-line ticket submission (POST).
func AddTicket(app *bootstrap.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
rows, err := app.DB.Query(`
SELECT DISTINCT draw_date
FROM results_thunderball
ORDER BY draw_date DESC
`)
if err != nil {
log.Println("❌ Failed to load draw dates:", err)
http.Error(w, "Unable to load draw dates", http.StatusInternalServerError)
return
}
defer rows.Close()
var drawDates []string
for rows.Next() {
var date string
if err := rows.Scan(&date); err == nil {
drawDates = append(drawDates, date)
}
}
// Use shared template data builder (expects *bootstrap.App)
data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data)
context["CSRFToken"] = nosurf.Token(r)
context["DrawDates"] = drawDates
tmpl := templateHelpers.LoadTemplateFiles("add_ticket.html", "web/templates/account/tickets/add_ticket.html")
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
log.Println("❌ Template render error:", err)
http.Error(w, "Error rendering form", http.StatusInternalServerError)
}
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "Invalid form", http.StatusBadRequest)
log.Println("❌ Failed to parse form:", err)
return
}
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok {
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
game := r.FormValue("game_type")
drawDateStr := r.FormValue("draw_date")
purchaseMethod := r.FormValue("purchase_method")
purchaseDate := r.FormValue("purchase_date")
purchaseTime := r.FormValue("purchase_time")
dt, err := helpers.ParseDrawDate(drawDateStr)
if err != nil {
http.Error(w, "Invalid draw date", http.StatusBadRequest)
return
}
drawDateDB := helpers.FormatDrawDate(dt) // "YYYY-MM-DD"
if purchaseTime != "" {
purchaseDate += "T" + purchaseTime
}
imagePath := ""
file, handler, err := r.FormFile("ticket_image")
if err == nil && handler != nil {
defer file.Close()
filename := fmt.Sprintf("uploads/ticket_%d_%s", time.Now().UnixNano(), handler.Filename)
out, err := os.Create(filename)
if err == nil {
defer out.Close()
_, _ = io.Copy(out, file)
imagePath = filename
}
}
var ballCount, bonusCount int
switch game {
case "Thunderball":
ballCount, bonusCount = 5, 1
case "Lotto":
ballCount, bonusCount = 6, 0
case "EuroMillions":
ballCount, bonusCount = 5, 2
case "SetForLife":
ballCount, bonusCount = 5, 1
default:
http.Error(w, "Unsupported game type", http.StatusBadRequest)
return
}
balls := make([][]int, ballCount)
bonuses := make([][]int, bonusCount)
for i := 1; i <= ballCount; i++ {
field := fmt.Sprintf("ball%d[]", i)
balls[i-1] = helpers.ParseIntSlice(r.Form[field])
log.Printf("🔢 %s: %v", field, balls[i-1])
}
for i := 1; i <= bonusCount; i++ {
field := fmt.Sprintf("bonus%d[]", i)
bonuses[i-1] = helpers.ParseIntSlice(r.Form[field])
log.Printf("🎯 %s: %v", field, bonuses[i-1])
}
lineCount := 0
if len(balls) > 0 {
lineCount = len(balls[0])
}
log.Println("🧾 Total lines to insert:", lineCount)
for i := 0; i < lineCount; i++ {
b := make([]int, 6)
bo := make([]int, 2)
valid := true
for j := 0; j < ballCount; j++ {
if j < len(balls) && i < len(balls[j]) {
b[j] = balls[j][i]
if b[j] == 0 {
valid = false
}
}
}
for j := 0; j < bonusCount; j++ {
if j < len(bonuses) && i < len(bonuses[j]) {
bo[j] = bonuses[j][i]
if bo[j] == 0 {
valid = false
}
}
}
if !valid {
log.Printf("⚠️ Skipping invalid line %d (incomplete values)", i+1)
continue
}
if _, err := app.DB.Exec(`
INSERT INTO my_tickets (
userId, game_type, draw_date,
ball1, ball2, ball3, ball4, ball5, ball6,
bonus1, bonus2,
purchase_method, purchase_date, image_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
userID, game, drawDateDB,
b[0], b[1], b[2], b[3], b[4], b[5],
bo[0], bo[1],
purchaseMethod, purchaseDate, imagePath,
); err != nil {
log.Println("❌ Failed to insert ticket line:", err)
} else {
log.Printf("✅ Ticket line %d saved", i+1)
}
}
http.Redirect(w, r, "/tickets", http.StatusSeeOther)
}
}
// SubmitTicket handles alternate multipart ticket submission (POST-only).
func SubmitTicket(app *bootstrap.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "Invalid form", http.StatusBadRequest)
return
}
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok {
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
game := r.FormValue("game_type")
drawDateStr := r.FormValue("draw_date")
purchaseMethod := r.FormValue("purchase_method")
purchaseDate := r.FormValue("purchase_date")
purchaseTime := r.FormValue("purchase_time")
dt, err := helpers.ParseDrawDate(drawDateStr)
if err != nil {
http.Error(w, "Invalid draw date", http.StatusBadRequest)
return
}
drawDateDB := helpers.FormatDrawDate(dt)
if purchaseTime != "" {
purchaseDate += "T" + purchaseTime
}
imagePath := ""
file, handler, err := r.FormFile("ticket_image")
if err == nil && handler != nil {
defer file.Close()
filename := fmt.Sprintf("uploads/ticket_%d_%s", time.Now().UnixNano(), handler.Filename)
out, err := os.Create(filename)
if err == nil {
defer out.Close()
_, _ = io.Copy(out, file)
imagePath = filename
}
}
const ballCount = 6
const bonusCount = 2
balls := make([][]int, ballCount)
bonuses := make([][]int, bonusCount)
for i := 1; i <= ballCount; i++ {
balls[i-1] = helpers.ParseIntSlice(r.Form["ball"+strconv.Itoa(i)])
}
for i := 1; i <= bonusCount; i++ {
bonuses[i-1] = helpers.ParseIntSlice(r.Form["bonus"+strconv.Itoa(i)])
}
lineCount := len(balls[0])
for i := 0; i < lineCount; i++ {
var b [6]int
var bo [2]int
for j := 0; j < ballCount; j++ {
if j < len(balls) && i < len(balls[j]) {
b[j] = balls[j][i]
}
}
for j := 0; j < bonusCount; j++ {
if j < len(bonuses) && i < len(bonuses[j]) {
bo[j] = bonuses[j][i]
}
}
if _, err := app.DB.Exec(`
INSERT INTO my_tickets (
user_id, game_type, draw_date,
ball1, ball2, ball3, ball4, ball5, ball6,
bonus1, bonus2,
purchase_method, purchase_date, image_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
userID, game, drawDateDB,
b[0], b[1], b[2], b[3], b[4], b[5],
bo[0], bo[1],
purchaseMethod, purchaseDate, imagePath,
); err != nil {
log.Println("❌ Insert failed:", err)
}
}
http.Redirect(w, r, "/tickets", http.StatusSeeOther)
}
}
// GetMyTickets lists the current user's tickets.
func GetMyTickets(app *bootstrap.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Use shared template data builder (ensures user/flash/notifications present)
data := templateHandlers.BuildTemplateData(app, w, r)
context := templateHelpers.TemplateContext(w, r, data)
userID, ok := securityHelpers.GetCurrentUserID(app.SessionManager, r)
if !ok {
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
var tickets []models.Ticket
rows, err := app.DB.Query(`
SELECT id, game_type, draw_date,
ball1, ball2, ball3, ball4, ball5, ball6,
bonus1, bonus2,
purchase_method, purchase_date, image_path, duplicate,
matched_main, matched_bonus, prize_tier, is_winner, prize_label, prize_amount
FROM my_tickets
WHERE userid = ?
ORDER BY draw_date DESC, created_at DESC
`, userID)
if err != nil {
log.Println("❌ Query failed:", err)
http.Error(w, "Could not load tickets", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var t models.Ticket
var drawDateStr string // ← add
var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64
var matchedMain, matchedBonus sql.NullInt64
var prizeTier sql.NullString
var isWinner sql.NullBool
var prizeLabel sql.NullString
var prizeAmount sql.NullFloat64
if err := rows.Scan(
&t.Id, &t.GameType, &drawDateStr, // ← was &t.DrawDate
&b1, &b2, &b3, &b4, &b5, &b6,
&bo1, &bo2,
&t.PurchaseMethod, &t.PurchaseDate, &t.ImagePath, &t.Duplicate,
&matchedMain, &matchedBonus, &prizeTier, &isWinner, &prizeLabel, &prizeAmount,
); err != nil {
log.Println("⚠️ Failed to scan ticket row:", err)
continue
}
// Parse into time.Time (UTC)
if dt, err := helpers.ParseDrawDate(drawDateStr); err == nil {
t.DrawDate = dt
}
// Normalize fields
t.Ball1 = int(b1.Int64)
t.Ball2 = int(b2.Int64)
t.Ball3 = int(b3.Int64)
t.Ball4 = int(b4.Int64)
t.Ball5 = int(b5.Int64)
t.Ball6 = int(b6.Int64)
t.Bonus1 = helpers.IntPtrIfValid(bo1)
t.Bonus2 = helpers.IntPtrIfValid(bo2)
if matchedMain.Valid {
t.MatchedMain = int(matchedMain.Int64)
}
if matchedBonus.Valid {
t.MatchedBonus = int(matchedBonus.Int64)
}
if prizeTier.Valid {
t.PrizeTier = prizeTier.String
}
if isWinner.Valid {
t.IsWinner = isWinner.Bool
}
if prizeLabel.Valid {
t.PrizeLabel = prizeLabel.String
}
if prizeAmount.Valid {
t.PrizeAmount = prizeAmount.Float64
}
// Derived fields for templates
t.Balls = helpers.BuildBallsSlice(t)
t.BonusBalls = helpers.BuildBonusSlice(t)
// Fetch matching draw info
draw := draws.GetDrawResultForTicket(app.DB, t.GameType, helpers.FormatDrawDate(t.DrawDate))
t.MatchedDraw = draw
tickets = append(tickets, t)
}
context["Tickets"] = tickets
tmpl := templateHelpers.LoadTemplateFiles("my_tickets.html", "web/templates/account/tickets/my_tickets.html")
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
log.Println("❌ Template error:", err)
http.Error(w, "Error rendering page", http.StatusInternalServerError)
}
}
}
// ToDo
// http: superfluous response.WriteHeader call (from SCS)
//This happens when headers are written twice in a request. With SCS, it sets cookies in WriteHeader. If something else already wrote the headers (or wrote them again), you see this warning.
//Common culprits & fixes:
//Use Gins redirect instead of the stdlib one:
// Replace:
//http.Redirect(w, r, "/account/login", http.StatusSeeOther)
// With:
//c.Redirect(http.StatusSeeOther, "/account/login")
//c.Abort() // stop further handlers writing
//Do this everywhere you redirect (signup, login, logout).
//Dont call two status-writes. For template GETs, this is fine:
//c.Status(http.StatusOK)
//_ = tmpl.ExecuteTemplate(c.Writer, "layout", ctx) // writes body once
//Just make sure you never write another header after that.
//Keep your wrapping order as you have it (its correct):
//Gin → SCS.LoadAndSave → NoSurf → http.Server
//If you still get the warning after switching to c.Redirect + c.Abort(), tell me which handler its coming from and Ill point to the exact double-write.

View File

@@ -0,0 +1,44 @@
package handlers
import (
"synlotto-website/internal/models"
)
func MatchTicketToDraw(ticket models.MatchTicket, draw models.DrawResult, rules []models.PrizeRule) models.MatchResult {
mainMatches := countMatches(ticket.Balls, draw.Balls)
bonusMatches := countMatches(ticket.BonusBalls, draw.BonusBalls)
prizeTier := getPrizeTier(ticket.GameType, mainMatches, bonusMatches, rules)
isWinner := prizeTier != ""
return models.MatchResult{
MatchedDrawID: draw.DrawID,
MatchedMain: mainMatches,
MatchedBonus: bonusMatches,
PrizeTier: prizeTier,
IsWinner: isWinner,
}
}
func countMatches(a, b []int) int {
m := make(map[int]bool)
for _, n := range b {
m[n] = true
}
match := 0
for _, n := range a {
if m[n] {
match++
}
}
return match
}
func getPrizeTier(game string, main, bonus int, rules []models.PrizeRule) string {
for _, rule := range rules {
if rule.Game == game && rule.MainMatches == main && rule.BonusMatches == bonus {
return rule.Tier
}
}
return ""
}

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

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

View File

@@ -2,22 +2,23 @@ package handlers
import (
"database/sql"
"html/template"
"log"
"net"
"net/http"
"regexp"
"sort"
"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 {
return func(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
limiter := helpers.GetVisitorLimiter(ip)
limiter := middleware.GetVisitorLimiter(ip)
if !limiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
@@ -43,7 +44,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
doSearch := isValidDate(query) || isValidNumber(query)
whereClause := "WHERE 1=1"
args := []interface{}{}
args := []any{}
if doSearch {
whereClause += " AND (draw_date = ? OR id = ?)"
@@ -62,7 +63,21 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
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 {
http.NotFound(w, r)
return
@@ -76,7 +91,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
LIMIT ? OFFSET ?`
argsWithLimit := append(args, pageSize, offset)
rows, err := db.Query(querySQL, argsWithLimit...)
rows, err := db.QueryContext(r.Context(), querySQL, argsWithLimit...)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
log.Println("❌ DB error:", err)
@@ -110,10 +125,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
noResultsMsg = "No results found for \"" + query + "\""
}
tmpl := template.Must(template.New("").Funcs(helpers.TemplateFuncs()).ParseFiles(
"templates/layout.html",
"templates/results/thunderball.html",
))
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/results/thunderball.html")
err = tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{
"Results": results,

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

View File

@@ -0,0 +1,56 @@
// internal/handlers/template/error.go
package templateHandler
// ToDo not nessisarily an issue with this file but ✅ internal/handlers/template/
//→ For anything that handles HTTP rendering (RenderError, RenderPage)
//✅ internal/helpers/template/
//→ For anything that helps render (TemplateContext, pagination, funcs)
// there for bear usages between helpers and handlers
//In clean Go architecture (especially following “Package by responsibility”):
//Type Responsibility Should access
//Helpers / Utilities Pure, stateless logic — e.g. template functions, math, formatters. Shared logic, no config, no HTTP handlers.
//Handlers Own an HTTP concern — e.g. routes, rendering responses, returning templates or JSON. Injected dependencies (cfg, db, etc.). Should use helpers, not vice versa.
// ToDo: duplicated work of internal/http/error/errors.go?
import (
"fmt"
"net/http"
"os"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/models"
"github.com/alexedwards/scs/v2"
"github.com/gin-gonic/gin"
)
func RenderError(c *gin.Context, sessions *scs.SessionManager, status int) {
// Base context
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{})
// Flash
if f := sessions.PopString(c.Request.Context(), "flash"); f != "" {
ctx["Flash"] = f
}
// Correct template paths
pagePath := fmt.Sprintf("web/templates/error/%d.html", status)
if _, err := os.Stat(pagePath); err != nil {
c.String(status, http.StatusText(status))
return
}
tmpl := templateHelpers.LoadTemplateFiles(
"web/templates/layout.html",
pagePath,
)
c.Status(status)
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
c.String(status, http.StatusText(status))
return
}
}

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
package helpers
import (
"database/sql"
"synlotto-website/internal/models"
)
func BuildBallsSlice(t models.Ticket) []int {
balls := []int{t.Ball1, t.Ball2, t.Ball3, t.Ball4, t.Ball5}
if t.GameType == "Lotto" && t.Ball6 > 0 {
balls = append(balls, t.Ball6)
}
return balls
}
func BuildBonusSlice(t models.Ticket) []int {
var bonuses []int
if t.Bonus1 != nil {
bonuses = append(bonuses, *t.Bonus1)
}
if t.Bonus2 != nil {
bonuses = append(bonuses, *t.Bonus2)
}
return bonuses
}
// BuildBallsFromNulls builds main balls from sql.NullInt64 values
func BuildBallsFromNulls(vals ...sql.NullInt64) []int {
var result []int
for _, v := range vals {
if v.Valid {
result = append(result, int(v.Int64))
}
}
return result
}
// BuildBonusFromNulls builds bonus balls from two sql.NullInt64 values
func BuildBonusFromNulls(b1, b2 sql.NullInt64) []int {
var result []int
if b1.Valid {
result = append(result, int(b1.Int64))
}
if b2.Valid {
result = append(result, int(b2.Int64))
}
return result
}

View File

@@ -0,0 +1,68 @@
package databaseHelpers
import (
"bufio"
"database/sql"
"strings"
)
// ExecScript executes a multi-statement SQL script.
// It only requires that statements end with ';' and ignores '--' comments.
// (Good for simple DDL/DML. If you add routines/triggers, upgrade later.)
func ExecScript(tx *sql.Tx, script string) error {
sc := bufio.NewScanner(strings.NewReader(script))
sc.Split(splitStatements)
for sc.Scan() {
stmt := strings.TrimSpace(sc.Text())
if stmt == "" {
continue
}
if _, err := tx.Exec(stmt); err != nil {
return err
}
}
return sc.Err()
}
// splitStatements separates statements at ';'
// and strips whitespace and '--' comments.
func splitStatements(data []byte, atEOF bool) (advance int, token []byte, err error) {
// skip whitespace and comments
start := 0
for {
// whitespace
for start < len(data) {
switch data[start] {
case ' ', '\t', '\n', '\r':
start++
continue
}
break
}
// '-- comment'
if start+1 < len(data) && data[start] == '-' && data[start+1] == '-' {
i := start + 2
for i < len(data) && data[i] != '\n' {
i++
}
if i >= len(data) {
return len(data), nil, nil
}
start = i + 1
continue
}
break
}
// detect semicolon termination
for i := start; i < len(data); i++ {
if data[i] == ';' {
return i + 1, data[start:i], nil
}
}
if atEOF && start < len(data) {
return len(data), data[start:], nil
}
return 0, nil, nil
}

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

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

View File

@@ -0,0 +1,19 @@
package httpHelpers
import (
"net"
"net/http"
"strings"
)
func ClientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}

View File

@@ -0,0 +1,13 @@
package helpers
import (
"database/sql"
)
func IntPtrIfValid(val sql.NullInt64) *int {
if val.Valid {
n := int(val.Int64)
return &n
}
return nil
}

16
internal/helpers/match.go Normal file
View File

@@ -0,0 +1,16 @@
package helpers
func CountMatches(a, b []int) int {
m := make(map[int]bool)
for _, n := range b {
m[n] = true
}
match := 0
for _, n := range a {
if m[n] {
match++
}
}
return match
}

14
internal/helpers/parse.go Normal file
View File

@@ -0,0 +1,14 @@
package helpers
import "strconv"
func ParseIntSlice(input []string) []int {
var out []int
for _, s := range input {
n, err := strconv.Atoi(s)
if err == nil {
out = append(out, n)
}
}
return out
}

View File

@@ -0,0 +1,16 @@
package security
import (
"database/sql"
"log"
)
func IsAdmin(db *sql.DB, userID int) bool {
var isAdmin bool
err := db.QueryRow(`SELECT is_admin FROM users WHERE id = ?`, userID).Scan(&isAdmin)
if err != nil {
log.Printf("⚠️ Failed to check is_admin for user %d: %v", userID, err)
return false
}
return isAdmin
}

View File

@@ -1,4 +1,4 @@
package helpers
package security
import "golang.org/x/crypto/bcrypt"

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

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

View File

@@ -0,0 +1,7 @@
package helpers
import "encoding/base64"
func EncodeKey(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}

View File

@@ -0,0 +1,69 @@
package helpers
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"time"
)
func randomBase64(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func HashVerifier(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// StoreToken inserts a new token row
func StoreToken(db *sql.DB, userID int64, selector, verifierHash string, expiresAt time.Time) error {
_, err := db.Exec(`
INSERT INTO remember_tokens (user_id, selector, verifier_hash, issued_at, expires_at)
VALUES ($1,$2,$3,NOW(),$4)`, userID, selector, verifierHash, expiresAt)
return err
}
// FindToken fetches selector+hash
func FindToken(db *sql.DB, selector string) (userID int64, verifierHash string, expiresAt time.Time, revokedAt *time.Time, err error) {
err = db.QueryRow(`SELECT user_id, verifier_hash, expires_at, revoked_at FROM remember_tokens WHERE selector=$1`, selector).
Scan(&userID, &verifierHash, &expiresAt, &revokedAt)
return
}
// RevokeToken marks token as revoked
func RevokeToken(db *sql.DB, selector string) error {
_, err := db.Exec(`UPDATE remember_tokens SET revoked_at=NOW() WHERE selector=$1`, selector)
return err
}
// GenerateAndStore creates a new remember-me token, stores it server-side,
// and returns the cookie-safe plaintext value to set on the client
func GenerateAndStore(db *sql.DB, userID int64, duration time.Duration) (string, time.Time, error) {
selector, err := randomBase64(16)
if err != nil {
return "", time.Time{}, err
}
verifier, err := randomBase64(32)
if err != nil {
return "", time.Time{}, err
}
hash := HashVerifier(verifier)
expires := time.Now().Add(duration)
if err := StoreToken(db, userID, selector, hash, expires); err != nil {
return "", time.Time{}, err
}
// The client cookie value contains selector + verifier
cookieVal := selector + ":" + verifier
return cookieVal, expires, nil
}

View File

@@ -0,0 +1,155 @@
package templateHelper
import (
"html/template"
"net/http"
"strings"
"time"
"synlotto-website/internal/models"
"github.com/alexedwards/scs/v2"
"github.com/justinas/nosurf"
)
// ToDo should these structs be here?
type siteMeta struct {
Name string
CopyrightYearStart int
}
var meta siteMeta
func InitSiteMeta(name string, yearStart, yearEnd int) {
meta = siteMeta{
Name: name,
CopyrightYearStart: yearStart,
}
}
var sm *scs.SessionManager
func InitSessionManager(manager *scs.SessionManager) {
sm = manager
}
func TemplateContext(w http.ResponseWriter, r *http.Request, data models.TemplateData) map[string]interface{} {
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 {
return template.FuncMap{
"plus1": func(i int) int { return i + 1 },
"minus1": func(i int) int {
if i > 1 {
return i - 1
}
return 0
},
"mul": func(a, b int) int { return a * b },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"min": func(a, b int) int {
if a < b {
return a
}
return b
},
"intVal": func(p *int) int {
if p == nil {
return 0
}
return *p
},
"inSlice": InSlice,
"lower": lower,
"truncate": func(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
},
"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 {
shared := []string{
"web/templates/main/layout.html",
"web/templates/main/topbar.html",
"web/templates/main/footer.html",
}
all := append(shared, files...)
return template.Must(template.New(name).Funcs(TemplateFuncs()).ParseFiles(all...))
}
func SetFlash(r *http.Request, message string) {
if sm != nil {
sm.Put(r.Context(), "flash", message)
}
}
func InSlice(n int, list []int) bool {
for _, v := range list {
if v == n {
return true
}
}
return false
}
func lower(input string) string {
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 {
switch {
case n >= 1 && n <= 9:
return "01-09"
case n >= 10 && n <= 19:
return "10-19"
case n >= 20 && n <= 29:
return "20-29"
case n >= 30 && n <= 39:
return "30-39"
case n >= 40 && n <= 49:
return "40-49"
default:
return "50-plus"
}
}

View File

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

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

View File

@@ -0,0 +1,89 @@
package errors
import (
"fmt"
"net/http"
"os"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/sessionkeys"
"github.com/alexedwards/scs/v2"
"github.com/gin-gonic/gin"
)
// RenderStatus renders web/templates/error/<status>.html inside layout.html,
// using ONLY session data (no DB) so 404/500 pages don't crash and still
// look "logged in" when a session exists.
func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) {
r := c.Request
uid := int64(0)
if v := sessions.Get(r.Context(), sessionkeys.UserID); v != nil {
switch t := v.(type) {
case int64:
uid = t
case int:
uid = int64(t)
}
}
// --- build minimal template data from session
var data models.TemplateData
if uid > 0 {
uname := ""
if v := sessions.Get(r.Context(), sessionkeys.Username); v != nil {
if s, ok := v.(string); ok {
uname = s
}
}
isAdmin := false
if v := sessions.Get(r.Context(), sessionkeys.IsAdmin); v != nil {
if b, ok := v.(bool); ok {
isAdmin = b
}
}
data.User = &models.User{
Id: uid,
Username: uname,
IsAdmin: isAdmin,
}
data.IsAdmin = isAdmin
}
ctxMap := templateHelpers.TemplateContext(c.Writer, r, data)
if f := sessions.PopString(r.Context(), sessionkeys.Flash); f != "" {
ctxMap["Flash"] = f
}
pagePath := fmt.Sprintf("web/templates/error/%d.html", status)
if _, err := os.Stat(pagePath); err != nil {
c.String(status, http.StatusText(status))
return
}
tmpl := templateHelpers.LoadTemplateFiles(
"web/templates/layout.html",
pagePath,
)
c.Status(status)
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctxMap); err != nil {
c.String(status, http.StatusText(status))
}
}
// Adapters so bootstrap can wire these without lambdas everywhere.
func NoRoute(sessions *scs.SessionManager) gin.HandlerFunc {
return func(c *gin.Context) { RenderStatus(c, sessions, http.StatusNotFound) }
}
func NoMethod(sessions *scs.SessionManager) gin.HandlerFunc {
return func(c *gin.Context) { RenderStatus(c, sessions, http.StatusMethodNotAllowed) }
}
func Recovery(sessions *scs.SessionManager) gin.RecoveryFunc {
return func(c *gin.Context, rec interface{}) {
RenderStatus(c, sessions, http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,52 @@
package middleware
import (
"net/http"
"time"
httphelpers "synlotto-website/internal/helpers/http"
securityHelpers "synlotto-website/internal/helpers/security"
auditlogStorage "synlotto-website/internal/storage/auditlog"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
)
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
ctx := c.Request.Context()
v := sm.Get(ctx, "user_id")
var uid int64
switch t := v.(type) {
case int64:
uid = t
case int:
uid = int64(t)
default:
c.Redirect(http.StatusSeeOther, "/account/login")
c.Abort()
return
}
if !securityHelpers.IsAdmin(app.DB, int(uid)) {
c.String(http.StatusForbidden, "Forbidden")
c.Abort()
return
}
auditlogStorage.LogAdminAccess(
app.DB,
uid,
c.Request.URL.Path,
httphelpers.ClientIP(c.Request),
c.Request.UserAgent(),
time.Now().UTC(),
)
c.Next()
}
}

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
package middleware
// ToDo: make sure im using with gin
import "net/http"
func EnforceHTTPS(next http.Handler, enabled bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if enabled && r.Header.Get("X-Forwarded-Proto") != "https" && r.TLS == nil {
http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusMovedPermanently)
return
}
next.ServeHTTP(w, r)
})
}
func SecureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' https://cdn.jsdelivr.net; script-src 'self' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
next.ServeHTTP(w, r)
})
}

View File

@@ -1,5 +1,6 @@
package helpers
package middleware
// ToDo: make sure im using with gin
import (
"net"
"net/http"
@@ -17,7 +18,7 @@ func GetVisitorLimiter(ip string) *rate.Limiter {
limiter, exists := visitors[ip]
if !exists {
limiter = rate.NewLimiter(1, 5)
limiter = rate.NewLimiter(3, 5)
visitors[ip] = limiter
}
return limiter

View File

@@ -0,0 +1,23 @@
package middleware
// ToDo: make sure im using with gin not to be confused with gins recovery but may do the same?
import (
"log"
"net/http"
"runtime/debug"
templateHelpers "synlotto-website/internal/helpers/template"
)
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("🔥 Recovered from panic: %v\n%s", rec, debug.Stack())
templateHelpers.RenderError(w, r, http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,66 @@
package middleware
import (
"strings"
"time"
"github.com/gin-gonic/gin"
sessionHelper "synlotto-website/internal/helpers/session"
"synlotto-website/internal/platform/bootstrap"
"synlotto-website/internal/platform/sessionkeys"
)
// Remember checks if a remember-me cookie exists and restores the session if valid.
func Remember(app *bootstrap.App) gin.HandlerFunc {
return func(c *gin.Context) {
sm := app.SessionManager
ctx := c.Request.Context()
// Already logged in? skip.
if sm.Exists(ctx, sessionkeys.UserID) {
c.Next()
return
}
// Look for remember-me cookie
cookie, err := c.Request.Cookie(app.Config.Session.RememberCookieName)
if err != nil {
c.Next()
return
}
parts := strings.SplitN(cookie.Value, ":", 2)
if len(parts) != 2 {
c.Next()
return
}
selector, verifier := parts[0], parts[1]
if selector == "" || verifier == "" {
c.Next()
return
}
userID, hash, expiresAt, revokedAt, err := sessionHelper.FindToken(app.DB, selector)
if err != nil || revokedAt != nil || time.Now().After(expiresAt) {
c.Next()
return
}
// Constant-time compare via hashing the verifier
if sessionHelper.HashVerifier(verifier) != hash {
_ = sessionHelper.RevokeToken(app.DB, selector) // tampered → revoke
c.Next()
return
}
// ✅ Valid token → create a new session for the user
_ = sm.RenewToken(ctx)
sm.Put(ctx, sessionkeys.UserID, userID)
sm.Put(ctx, sessionkeys.LastActivity, time.Now().UTC())
// (Optional TODO): rotate token and set a fresh cookie.
c.Next()
}
}

View File

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

View File

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

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

View File

@@ -0,0 +1,10 @@
package routes
import (
"synlotto-website/internal/handlers"
"synlotto-website/internal/platform/bootstrap"
)
func RegisterHomeRoutes(app *bootstrap.App) {
app.Router.GET("/", handlers.Home(app))
}

View File

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

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

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

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

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

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

View 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...)
}

14
internal/models/audit.go Normal file
View File

@@ -0,0 +1,14 @@
package models
import "time"
type AuditEntry struct {
ID int
UserID int
Username string
Action string
Path string
IP string
UserAgent string
Timestamp time.Time
}

View File

@@ -1,5 +1,14 @@
package models
type DrawSummary struct {
Id int
GameType string
DrawDate string
BallSet string
Machine string
PrizeSet bool
}
type ThunderballResult struct {
Id int
DrawDate string

View File

@@ -0,0 +1,9 @@
package models
type MachineUsage struct {
Machine string
DrawsUsed int
PctOfDraws float64
FirstUsed string
LastUsed string
}

49
internal/models/match.go Normal file
View File

@@ -0,0 +1,49 @@
package models
type MatchTicket struct {
ID int
GameType string
DrawDate string
Balls []int
BonusBalls []int
}
type DrawResult struct {
DrawID int
GameType string
DrawDate string
Balls []int
BonusBalls []int
}
type MatchResult struct {
MatchedMain int
MatchedBonus int
PrizeTier string
IsWinner bool
MatchedDrawID int
PrizeAmount float64
PrizeLabel string
}
type PrizeRule struct {
Game string
MainMatches int
BonusMatches int
Tier string
}
type MatchRunStats struct {
TicketsMatched int
WinnersFound int
}
type MatchLog struct {
ID int
TriggeredBy string
RunAt string
TicketsMatched int
WinnersFound int
Notes string
}

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,10 @@
package models
type TemplateData struct {
User *User
IsAdmin bool
NotificationCount int
Notifications []Notification
MessageCount int
Messages []Message
}

53
internal/models/ticket.go Normal file
View 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
View 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
}

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

View 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)
// - Theres a second DB ping: openMySQL() pings, and Load() pings again.
// This is harmless but redundant; consider deleting one for clarity.
// - gin.Recovery() is commented out; we use CustomRecovery instead (intentional).
// - Consider moving ReadHeaderTimeout to config to match the rest of server tuning.
//
// Change log
// [2025-10-28] Document EnsureInitialSchema bootstrap, explicit gob registrations,
// clarified middleware/handler order, and server timeout behavior.
// [2025-10-24] Migrated to SCS-first wrapping and explicit App wiring.
package bootstrap
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
}

View File

@@ -0,0 +1,54 @@
// Package config
// Path: /internal/platform/config
// File: config.go
//
// Purpose
// Provide a safe one-time initialization and global access point for
// the application's Config object, once it has been constructed during
// bootstrap.
//
// This allows other packages to retrieve configuration without needing
// dependency injection at every call site, while still preventing
// accidental mutation after init.
//
// Responsibilities (as implemented here)
// 1) Store a single *Config instance for the lifetime of the process.
// 2) Ensure Init() can only succeed once via sync.Once.
// 3) Expose Get() as a global accessor.
//
// Design notes
// - Config is written once at startup via Init() inside bootstrap.
// - Calls to Init() after the first are ignored silently.
// - Get() may return nil if called before Init() — caller must ensure
// bootstrap has completed.
//
// TODOs (from current architectural direction)
// - Evaluate replacing global access with explicit dependency injection
// in future modules for stronger compile-time guarantees.
// - Consider panicking or logging if Get() is called before Init().
// - Move non-static configuration into runtime struct(s) owned by App.
// - Ensure immutability: avoid mutating Config fields after Init().
//
// Change log
// [2025-10-28] Documentation aligned with real runtime responsibilities.
package config
import (
"sync"
)
var (
appConfig *Config
once sync.Once
)
func Init(config *Config) {
once.Do(func() {
appConfig = config
})
}
func Get() *Config {
return appConfig
}

View File

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

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