diff --git a/cmd/admin-panel/main.go b/cmd/admin-panel/main.go index 5c2572b..64b651a 100644 --- a/cmd/admin-panel/main.go +++ b/cmd/admin-panel/main.go @@ -11,6 +11,10 @@ import ( "github.com/gin-gonic/gin" ) +// main is the program entry point for the admin panel. It loads configuration, +// initializes middleware and the database (closed on exit), configures a Gin +// router with HTML templates and static assets, registers public and +// authenticated routes, and starts the HTTP server on the configured port. func main() { cfg := config.Load() middleware.Init() diff --git a/internal/config/config.go b/internal/config/config.go index a7795b7..dea85f8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,9 @@ type Config struct { var Current *Config +// Load loads configuration from environment variables or a .env file and initializes the package-level Current configuration. +// It constructs a Config with sensible defaults for server, PostgreSQL, SMTP, admin credentials, secret key, and base URL. +// If SENDER_EMAIL is not set, it falls back to SMTP_USER. The created Config is assigned to Current and returned. func Load() *Config { if err := godotenv.Load(); err != nil { log.Println("No .env file found, using environment variables") @@ -59,6 +62,8 @@ func Load() *Config { return cfg } +// getEnv returns the value of the environment variable named by key, or defaultValue if that variable is not set or is empty. +// If the environment variable exists but is the empty string, defaultValue is returned. func getEnv(key, defaultValue string) string { value := os.Getenv(key) if value == "" { @@ -67,6 +72,9 @@ func getEnv(key, defaultValue string) string { return value } +// getEnvInt retrieves the environment variable named by key and returns its integer value or defaultValue. +// If the variable is not set, it returns defaultValue. If the variable is set but cannot be parsed as an integer, +// it logs the parse error and returns defaultValue. func getEnvInt(key string, defaultValue int) int { value := os.Getenv(key) if value == "" { diff --git a/internal/database/database.go b/internal/database/database.go index 1c6e253..6742644 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -19,6 +19,13 @@ type Admin struct { Password string } +// Init initializes the package database connection using values from cfg, sets connection pool limits, +// creates required tables, and ensures a default admin user exists. +// It assigns the opened *sql.DB to the package-level db and will terminate the program if establishing +// or verifying the connection fails. +// +// cfg provides PostgreSQL connection parameters and the default admin credentials used to create the +// default admin user when missing. func Init(cfg *config.Config) { password := url.QueryEscape(cfg.PGPassword) @@ -47,12 +54,17 @@ func Init(cfg *config.Config) { createDefaultAdmin(cfg) } +// Close closes the package-level database connection if it has been initialized. +// It is safe to call multiple times; if no connection exists, the call is a no-op. func Close() { if db != nil { db.Close() } } +// createTables creates the required database tables if they do not already exist. +// It ensures the subscribers, admin_users, and newsletters tables are present; errors +// encountered while creating individual tables are logged but do not abort the process. func createTables() { queries := []string{ `CREATE TABLE IF NOT EXISTS subscribers ( @@ -81,6 +93,8 @@ func createTables() { log.Println("Database tables ready.") } +// GetAllEmails retrieves all subscriber email addresses from the database. +// It returns a slice of email strings and any error encountered while querying or scanning rows. func GetAllEmails() ([]string, error) { rows, err := db.Query("SELECT email FROM subscribers") if err != nil { @@ -101,6 +115,8 @@ func GetAllEmails() ([]string, error) { return emails, rows.Err() } +// GetAdmin retrieves the admin user with the given username. +// It returns a pointer to the Admin when a matching row exists. If no admin is found, it returns an error "admin not found"; other database errors are returned unchanged. func GetAdmin(username string) (*Admin, error) { var admin Admin err := db.QueryRow( @@ -118,6 +134,10 @@ func GetAdmin(username string) (*Admin, error) { return &admin, nil } +// createDefaultAdmin ensures a default admin user exists by inserting cfg.AdminUsername +// with a bcrypt-hashed cfg.AdminPassword into the admin_users table; the insert is +// idempotent (no-op if the username already exists). If password hashing fails the +// function terminates the process; insertion errors are logged. func createDefaultAdmin(cfg *config.Config) { hashedPassword, err := hashPassword(cfg.AdminPassword) if err != nil { @@ -136,6 +156,8 @@ func createDefaultAdmin(cfg *config.Config) { } } +// LogNewsletter inserts a newsletter record with the provided subject and body into the newsletters table. +// It returns any error encountered while inserting the record. func LogNewsletter(subject, body string) error { _, err := db.Exec( "INSERT INTO newsletters (subject, body) VALUES ($1, $2)", @@ -144,6 +166,8 @@ func LogNewsletter(subject, body string) error { return err } +// hashPassword generates a bcrypt hash for the given plaintext password. +// It uses bcrypt.DefaultCost and returns the hashed password as a string and any error encountered. func hashPassword(password string) (string, error) { hash, err := bcrypt.GenerateFromPassword( []byte(password), @@ -152,6 +176,8 @@ func hashPassword(password string) (string, error) { return string(hash), err } +// VerifyPassword reports whether the provided plaintext password matches the given bcrypt hash. +// It returns true if the password matches, false otherwise. func VerifyPassword(hash, password string) bool { return bcrypt.CompareHashAndPassword( []byte(hash), diff --git a/internal/email/email.go b/internal/email/email.go index 0045e0b..9d9abd6 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -11,6 +11,13 @@ import ( "github.com/wneessen/go-mail" ) +// SendUpdate sends a newsletter with the given subject and body to all subscriber emails stored in the database. +// It returns a human-readable status message and, when subscriber retrieval fails, the underlying error. +// - If retrieving subscribers fails: returns "Failed to retrieve subscribers" and the error. +// - If no subscribers are found: returns "No subscribers found." and nil. +// - If sending to a specific subscriber fails: returns "Failed to send to " and nil. +// - On success: returns "Email has been sent to all subscribers." and nil. +// Note: logging the newsletter entry in the database is attempted after sending and any logging failure is non-fatal. func SendUpdate(subject, body string) (string, error) { subscribers, err := database.GetAllEmails() if err != nil { @@ -34,6 +41,8 @@ func SendUpdate(subject, body string) (string, error) { return "Email has been sent to all subscribers.", nil } +// send constructs and sends an HTML newsletter update to the specified recipient using the current SMTP configuration. +// It embeds an unsubscribe link for the recipient and returns true if the message was sent successfully, false if client creation, message setup, or sending fails. func send(subject, body, recipient string) bool { cfg := config.Current diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index d8e0ded..d4a302b 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -9,10 +9,13 @@ import ( "github.com/gin-gonic/gin" ) +// LoginGet renders the login page using the "login.html" template with HTTP 200 status. func LoginGet(c *gin.Context) { c.HTML(http.StatusOK, "login.html", gin.H{}) } +// LoginPost handles POST /login form submissions, authenticates the user, creates a session, and redirects to "/" on success. +// On invalid credentials it renders the login page with HTTP 401 and an error message; if session retrieval or saving fails it aborts with HTTP 500. func LoginPost(c *gin.Context) { username := c.PostForm("username") password := c.PostForm("password") @@ -39,6 +42,8 @@ func LoginPost(c *gin.Context) { c.Redirect(http.StatusFound, "/") } +// Logout invalidates the current user session if one exists and redirects the client to the login page. +// If the session cannot be retrieved, the handler still redirects to "/login". func Logout(c *gin.Context) { session, err := middleware.GetStore().Get(c.Request, "session") if err == nil { diff --git a/internal/handlers/newsletter.go b/internal/handlers/newsletter.go index c4b02dd..afb407d 100644 --- a/internal/handlers/newsletter.go +++ b/internal/handlers/newsletter.go @@ -8,10 +8,15 @@ import ( "github.com/gin-gonic/gin" ) +// SendUpdateGet renders the update form page using the "send_update.html" template and responds with HTTP 200 OK. func SendUpdateGet(c *gin.Context) { c.HTML(http.StatusOK, "send_update.html", gin.H{}) } +// SendUpdatePost handles POST requests to submit a newsletter update. +// It reads "subject" and "body" from the form, calls email.SendUpdate(subject, body), +// and renders the "send_update.html" template with gin.H{"error": message} when sending fails +// or gin.H{"success": message} when sending succeeds, returning HTTP 200 in both cases. func SendUpdatePost(c *gin.Context) { subject := c.PostForm("subject") body := c.PostForm("body") diff --git a/internal/handlers/subscribers.go b/internal/handlers/subscribers.go index 076b432..9ae5d2d 100644 --- a/internal/handlers/subscribers.go +++ b/internal/handlers/subscribers.go @@ -8,6 +8,9 @@ import ( "github.com/gin-gonic/gin" ) +// IndexGet handles requests for the admin index page by retrieving all subscriber emails +// and rendering the "admin_index.html" template with those emails. +// If retrieving emails fails, it aborts the request with HTTP 500 and the error. func IndexGet(c *gin.Context) { emails, err := database.GetAllEmails() if err != nil { diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 5ee4132..afcf803 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -11,6 +11,9 @@ import ( var store *sessions.CookieStore +// Init initializes the package-level cookie store used for session management. +// It panics if config.Current.SecretKey is empty. +// The created store is configured with Path "/", MaxAge one week, HttpOnly true, Secure false, and SameSite 0. func Init() { if config.Current.SecretKey == "" { panic("SECRET_KEY not set") @@ -25,10 +28,16 @@ func Init() { } } +// GetStore returns the package-level Gorilla cookie store used for session management. +// It may be nil if Init has not been called. func GetStore() *sessions.CookieStore { return store } +// Auth enforces session-based authentication for Gin handlers. +// If the request has no session named "session" or the session lacks a "username" value, +// the middleware redirects to "/login" (HTTP 302) and aborts further handling. +// Otherwise the middleware calls the next handler in the chain. func Auth() gin.HandlerFunc { return func(c *gin.Context) { session, err := store.Get(c.Request, "session")