Add comprehensive human-readable comments to all Go files

- Comments explain what each function does and why
- Fixed unused strconv import in queue.go
- Better documentation for SMTP email service
- Explained journalctl log parsing in logs.go
- Clarified queue operations in queue.go
This commit is contained in:
Christoph Haas 2026-03-22 22:43:41 +01:00
parent 3e3620a24f
commit 68285d861a
9 changed files with 728 additions and 203 deletions

View file

@ -1,115 +1,174 @@
// Package db provides database access for the imc-vibe application.
// It uses GORM (Go ORM) for database operations.
//
// The database contains two sets of tables:
// 1. ISPmail tables (virtual_domains, virtual_users, virtual_aliases) - existing mail data
// 2. imc tables (imc_users, imc_users2domains, etc.) - application-specific data
package db
import (
"fmt"
"time"
"fmt" // formatted error messages
"time" // time package for timestamps
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/driver/mysql" // GORM MySQL driver
"gorm.io/gorm" // GORM ORM library
"gorm.io/gorm/logger" // GORM logger configuration
"github.com/imc-vibe/backend/internal/config"
"github.com/imc-vibe/backend/internal/config" // configuration
)
// DB wraps GORM's database connection with helper methods.
// This is the main database access point for the application.
type DB struct {
*gorm.DB
*gorm.DB // Embeds GORM's DB type, giving us all GORM methods
}
// =============================================================================
// ISPmail Models (existing mail server tables)
// These tables are shared with the ISPmail system and must not be modified.
// =============================================================================
// Domain represents a mail domain (e.g., "example.org").
// This corresponds to the existing ISPmail virtual_domains table.
type Domain struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex;size:50;not null" json:"name"`
CreatedAt time.Time `json:"createdAt"`
Users []User `gorm:"foreignKey:DomainID" json:"users,omitempty"`
Aliases []Alias `gorm:"foreignKey:DomainID" json:"aliases,omitempty"`
ID uint `gorm:"primaryKey" json:"id"` // Primary key, auto-increment
Name string `gorm:"uniqueIndex;size:50;not null" json:"name"` // Domain name, must be unique
CreatedAt time.Time `json:"createdAt"` // When the domain was added
Users []User `gorm:"foreignKey:DomainID" json:"users,omitempty"` // Mail users in this domain
Aliases []Alias `gorm:"foreignKey:DomainID" json:"aliases,omitempty"` // Aliases in this domain
}
// TableName tells GORM to use the existing ISPmail table name.
func (Domain) TableName() string { return "virtual_domains" }
// User represents a mail user (e.g., "user@example.org").
// This corresponds to the existing ISPmail virtual_users table.
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
DomainID uint `gorm:"not null" json:"domainId"`
Email string `gorm:"uniqueIndex;size:100;not null" json:"email"`
Password string `gorm:"size:150;not null" json:"-"`
Quota int64 `gorm:"default:0" json:"quota"`
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
ID uint `gorm:"primaryKey" json:"id"` // Primary key
DomainID uint `gorm:"not null" json:"domainId"` // Foreign key to Domain
Email string `gorm:"uniqueIndex;size:100;not null" json:"email"` // Full email address, must be unique
Password string `gorm:"size:150;not null" json:"-"` // Mailbox password (bcrypt hash, - means exclude from JSON)
Quota int64 `gorm:"default:0" json:"quota"` // Mailbox size limit in bytes (0 = default)
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"` // Associated domain
CreatedAt time.Time `json:"createdAt,omitempty"` // When the user was created
}
// TableName tells GORM to use the existing ISPmail table name.
func (User) TableName() string { return "virtual_users" }
// Alias represents an email alias/forwarding rule.
// Maps one email address (source) to another (destination).
// This corresponds to the existing ISPmail virtual_aliases table.
type Alias struct {
ID uint `gorm:"primaryKey" json:"id"`
DomainID uint `gorm:"not null" json:"domainId"`
Source string `gorm:"size:100;not null" json:"source"`
Destination string `gorm:"size:100;not null" json:"destination"`
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"`
ID uint `gorm:"primaryKey" json:"id"` // Primary key
DomainID uint `gorm:"not null" json:"domainId"` // Foreign key to Domain
Source string `gorm:"size:100;not null" json:"source"` // Original email address (alias)
Destination string `gorm:"size:100;not null" json:"destination"` // Forward-to email address
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"` // Associated domain
}
// TableName tells GORM to use the existing ISPmail table name.
func (Alias) TableName() string { return "virtual_aliases" }
// =============================================================================
// imc-vibe Application Models (tables created by this app)
// =============================================================================
// ImcUser represents an admin user who can log into this application.
// This is separate from mail users - it's for the web admin interface.
type ImcUser struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;size:100;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
Role string `gorm:"type:enum('admin','user');default:'user'" json:"role"`
CreatedAt time.Time `json:"createdAt"`
Domains []ImcUserDomain `gorm:"foreignKey:UserID" json:"domains,omitempty"`
ID uint `gorm:"primaryKey" json:"id"` // Primary key
Username string `gorm:"uniqueIndex;size:100;not null" json:"username"` // Login username
PasswordHash string `gorm:"size:255;not null" json:"-"` // Bcrypt hash of password (- means exclude from JSON)
Role string `gorm:"type:enum('admin','user');default:'user'" json:"role"` // Role: admin or regular user
CreatedAt time.Time `json:"createdAt"` // When account was created
Domains []ImcUserDomain `gorm:"foreignKey:UserID" json:"domains,omitempty"` // Domains this user can access
}
// TableName specifies the database table name for ImcUser.
func (ImcUser) TableName() string { return "imc_users" }
// ImcUserDomain represents the many-to-many relationship between admin users and domains.
// An admin user can have access to multiple domains, and a domain can be accessed by multiple users.
type ImcUserDomain struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null" json:"userId"`
DomainID uint `gorm:"not null" json:"domainId"`
CreatedAt time.Time `json:"createdAt"`
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"`
ID uint `gorm:"primaryKey" json:"id"` // Primary key
UserID uint `gorm:"not null" json:"userId"` // Foreign key to ImcUser
DomainID uint `gorm:"not null" json:"domainId"` // Foreign key to Domain
CreatedAt time.Time `json:"createdAt"` // When access was granted
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"` // Associated domain (for preloading)
}
// TableName specifies the database table name for ImcUserDomain.
func (ImcUserDomain) TableName() string { return "imc_users2domains" }
// ImcLoginAttempt records login attempts for brute-force protection.
// Used to track failed login attempts and implement rate limiting.
type ImcLoginAttempt struct {
ID uint `gorm:"primaryKey" json:"id"`
Email string `gorm:"size:100;not null" json:"email"`
IPAddress string `gorm:"size:45;not null" json:"ipAddress"`
AttemptedAt time.Time `json:"attemptedAt"`
Successful bool `gorm:"default:false" json:"successful"`
ID uint `gorm:"primaryKey" json:"id"` // Primary key
Email string `gorm:"size:100;not null" json:"email"` // Email/username that was used
IPAddress string `gorm:"size:45;not null" json:"ipAddress"` // IP address of the requester (IPv6 compatible)
AttemptedAt time.Time `json:"attemptedAt"` // When the attempt occurred
Successful bool `gorm:"default:false" json:"successful"` // Whether the login succeeded
}
// TableName specifies the database table name for ImcLoginAttempt.
func (ImcLoginAttempt) TableName() string { return "imc_login_attempts" }
// PasswordResetToken stores one-time tokens for password reset functionality.
type PasswordResetToken struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null" json:"userId"`
Token string `gorm:"size:64;not null;uniqueIndex" json:"token"`
ExpiresAt time.Time `gorm:"not null" json:"expiresAt"`
Used bool `gorm:"default:false" json:"used"`
CreatedAt time.Time `json:"createdAt"`
ID uint `gorm:"primaryKey" json:"id"` // Primary key
UserID uint `gorm:"not null" json:"userId"` // Foreign key to ImcUser
Token string `gorm:"size:64;not null;uniqueIndex" json:"token"` // The reset token (unique index for fast lookup)
ExpiresAt time.Time `gorm:"not null" json:"expiresAt"` // When this token expires
Used bool `gorm:"default:false" json:"used"` // Whether this token has been used
CreatedAt time.Time `json:"createdAt"` // When the token was created
}
// TableName specifies the database table name for PasswordResetToken.
func (PasswordResetToken) TableName() string { return "imc_password_reset_tokens" }
// =============================================================================
// Helper/View Models (not stored in database)
// =============================================================================
// DomainStats holds statistics about a domain (user count, alias count).
// Used for displaying dashboard information.
type DomainStats struct {
ID uint `json:"id"`
Name string `json:"name"`
UserCount int64 `json:"userCount"`
AliasCount int64 `json:"aliasCount"`
ID uint `json:"id"` // Domain ID
Name string `json:"name"` // Domain name
UserCount int64 `json:"userCount"` // Number of mail users
AliasCount int64 `json:"aliasCount"` // Number of aliases
}
// AliasWithDomain combines an alias with its domain name for API responses.
type AliasWithDomain struct {
Alias
DomainName string `json:"domainName"`
Alias // Embed Alias struct
DomainName string `json:"domainName"` // Denormalized domain name for convenience
}
// UserWithDomain combines a user with their domain name for API responses.
type UserWithDomain struct {
User
DomainName string `json:"domainName"`
User // Embed User struct
DomainName string `json:"domainName"` // Denormalized domain name for convenience
}
// =============================================================================
// Database Connection
// =============================================================================
// Connect establishes a connection to the MySQL/MariaDB database.
// cfg: configuration containing database credentials and connection settings.
// Returns: a DB wrapper around the GORM connection, or an error if connection fails.
func Connect(cfg *config.Config) (*DB, error) {
// Build the Data Source Name (DSN) string.
// Format: username:password@protocol(address:port)/dbname?options
// parseTime=true: automatically convert time.Time to/from SQL DATETIME
// charset=utf8mb4: use full UTF-8 encoding (supports emojis, etc.)
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4",
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName)
// Open a connection to the database using GORM.
// We configure GORM to be silent (no auto-logging) to reduce noise.
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
@ -117,19 +176,40 @@ func Connect(cfg *config.Config) (*DB, error) {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
// Get the underlying sql.DB object from GORM.
// GORM wraps the standard database/sql package.
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
// Configure connection pool settings for performance.
// These settings help balance between connection reuse and resource usage.
// SetMaxOpenConns: maximum number of open connections to the database.
// Too few = slow requests, too many = database overload.
// 25 is a reasonable default for most applications.
sqlDB.SetMaxOpenConns(25)
// SetMaxIdleConns: maximum number of idle connections in the pool.
// Idle connections are kept open even when not in use.
// This avoids the overhead of opening new connections.
sqlDB.SetMaxIdleConns(5)
// SetConnMaxLifetime: maximum time a connection can be reused.
// Connections older than this are closed and replaced.
// This helps avoid issues with stale connections.
sqlDB.SetConnMaxLifetime(5 * time.Minute)
return &DB{db}, nil
}
// InitSchema creates imc-vibe's database tables if they don't exist.
// This only creates the imc_* tables, not the ISPmail virtual_* tables.
// Called during application startup.
func (d *DB) InitSchema() error {
// Create imc_users table for admin accounts.
// This table stores admin users who can log into the web interface.
imcUsersSQL := `
CREATE TABLE IF NOT EXISTS imc_users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
@ -145,6 +225,8 @@ func (d *DB) InitSchema() error {
return err
}
// Create imc_login_attempts table for tracking login attempts.
// Used for brute-force protection (rate limiting).
loginAttemptsSQL := `
CREATE TABLE IF NOT EXISTS imc_login_attempts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
@ -160,6 +242,8 @@ func (d *DB) InitSchema() error {
return err
}
// Create imc_users2domains table for user-domain permissions.
// This implements many-to-many: a user can access multiple domains.
users2DomainsSQL := `
CREATE TABLE IF NOT EXISTS imc_users2domains (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
@ -175,6 +259,8 @@ func (d *DB) InitSchema() error {
return err
}
// Create imc_password_reset_tokens table for password reset functionality.
// Stores temporary tokens for resetting forgotten passwords.
resetTokensSQL := `
CREATE TABLE IF NOT EXISTS imc_password_reset_tokens (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

View file

@ -2,53 +2,101 @@ package db
import "time"
// =============================================================================
// Admin User (ImcUser) Operations
// These functions manage admin users who can log into the web interface.
// =============================================================================
// GetImcUserByUsername looks up an admin user by their username.
// Returns the user with their associated domain permissions preloaded.
// username: the username to search for.
// Returns: the user and any error.
func (d *DB) GetImcUserByUsername(username string) (*ImcUser, error) {
var user ImcUser
// Preload loads related data (Domains) to avoid N+1 queries.
// Where creates a SQL WHERE clause, ? is a placeholder for the username.
if err := d.Preload("Domains.Domain").Where("username = ?", username).First(&user).Error; err != nil {
return nil, err
return nil, err // Return nil user and the error
}
return &user, nil
}
// GetImcUserByID looks up an admin user by their ID.
// Returns the user with their associated domain permissions preloaded.
// id: the user's primary key ID.
// Returns: the user and any error.
func (d *DB) GetImcUserByID(id uint) (*ImcUser, error) {
var user ImcUser
// First(&user, id) looks up by primary key.
if err := d.Preload("Domains.Domain").First(&user, id).Error; err != nil {
return nil, err
}
return &user, nil
}
// CreateImcUser creates a new admin user account.
// username: the login username (must be unique).
// passwordHash: the bcrypt hash of the password (NOT the plain password).
// role: "admin" or "user".
// Returns: the created user and any error.
func (d *DB) CreateImcUser(username, passwordHash, role string) (*ImcUser, error) {
user := ImcUser{
Username: username,
PasswordHash: passwordHash,
Role: role,
}
// Create inserts the record into the database.
if err := d.Create(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// UpdateImcUserPassword updates the password hash for a user.
// id: the user's ID.
// passwordHash: the new bcrypt hash of the password.
func (d *DB) UpdateImcUserPassword(id uint, passwordHash string) error {
// Model specifies which table/struct to update.
// Where filters which rows to update.
// Update only changes the specified fields.
return d.Model(&ImcUser{}).Where("id = ?", id).Update("password_hash", passwordHash).Error
}
// DeleteImcUser removes an admin user from the database.
// id: the user's ID to delete.
func (d *DB) DeleteImcUser(id uint) error {
return d.Delete(&ImcUser{}, id).Error
return d.Delete(&ImcUser{}, id).Error // Delete by primary key
}
// EnsureAdminUser creates the admin user if it doesn't exist, or updates the password if it does.
// This is used during --reset-admin-password and startup.
// username: always "admin" in our case.
// passwordHash: the bcrypt hash of the password.
func (d *DB) EnsureAdminUser(username, passwordHash string) error {
var user ImcUser
// Try to find an existing admin user with this username.
err := d.Where("username = ? AND role = ?", username, "admin").First(&user).Error
if err == nil {
// User exists - update their password.
return d.Model(&ImcUser{}).Where("id = ?", user.ID).Update("password_hash", passwordHash).Error
}
// User doesn't exist - create them.
_, err = d.CreateImcUser(username, passwordHash, "admin")
return err
}
// =============================================================================
// Domain Access Control
// These functions manage which domains users can access.
// =============================================================================
// GetUserAccessibleDomains returns all domains a user can access.
// Admin users can access all domains; regular users only their assigned domains.
// userID: the ID of the user.
// isAdmin: whether the user has admin privileges.
// Returns: a list of accessible domains.
func (d *DB) GetUserAccessibleDomains(userID uint, isAdmin bool) ([]Domain, error) {
// Admins get access to all domains.
if isAdmin {
var domains []Domain
if err := d.Find(&domains).Error; err != nil {
@ -57,38 +105,51 @@ func (d *DB) GetUserAccessibleDomains(userID uint, isAdmin bool) ([]Domain, erro
return domains, nil
}
// Regular users query the imc_users2domains table.
// We use raw SQL with Joins because GORM's association methods
// don't handle our cross-database queries well.
var domains []Domain
err := d.Table("imc_users2domains").
Select("virtual_domains.*").
Joins("JOIN virtual_domains ON virtual_domains.id = imc_users2domains.domain_id").
Where("imc_users2domains.user_id = ?", userID).
Find(&domains).Error
err := d.Table("imc_users2domains"). // Query from this table
Select("virtual_domains.*"). // Select all columns from domains
Joins("JOIN virtual_domains ON virtual_domains.id = imc_users2domains.domain_id"). // Join to get domain details
Where("imc_users2domains.user_id = ?", userID). // Only this user's domains
Find(&domains).Error
if err != nil {
return nil, err
}
if domains == nil {
domains = []Domain{}
domains = []Domain{} // Return empty slice, not nil
}
return domains, nil
}
// CanAccessDomain checks if a user can access a specific domain.
// userID: the ID of the user.
// domainName: the name of the domain (e.g., "example.org").
// isAdmin: whether the user has admin privileges.
// Returns: true if the user can access the domain, false otherwise.
func (d *DB) CanAccessDomain(userID uint, domainName string, isAdmin bool) (bool, error) {
// Admins can access all domains.
if isAdmin {
return true, nil
}
// Count matching records - if count > 0, access is granted.
var count int64
err := d.Table("imc_users2domains").
Select("COUNT(*)").
Joins("JOIN virtual_domains ON virtual_domains.id = imc_users2domains.domain_id").
Where("imc_users2domains.user_id = ? AND virtual_domains.name = ?", userID, domainName).
Select("COUNT(*)"). // Count matching rows
Joins("JOIN virtual_domains ON virtual_domains.id = imc_users2domains.domain_id"). // Join to domain table
Where("imc_users2domains.user_id = ? AND virtual_domains.name = ?", userID, domainName). // Match user and domain name
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
return count > 0, nil // True if at least one match found
}
// AddUserToDomain grants a user access to a domain.
// userID: the ID of the user.
// domainID: the ID of the domain.
func (d *DB) AddUserToDomain(userID, domainID uint) error {
ud := ImcUserDomain{
UserID: userID,
@ -97,15 +158,22 @@ func (d *DB) AddUserToDomain(userID, domainID uint) error {
return d.Create(&ud).Error
}
// RemoveUserFromDomain revokes a user's access to a domain.
// userID: the ID of the user.
// domainID: the ID of the domain.
func (d *DB) RemoveUserFromDomain(userID, domainID uint) error {
// Delete records matching both user_id and domain_id.
return d.Where("user_id = ? AND domain_id = ?", userID, domainID).Delete(&ImcUserDomain{}).Error
}
// GetUsersForDomain returns all admin users who can access a specific domain.
// domainID: the ID of the domain.
// Returns: a list of users who can access this domain.
func (d *DB) GetUsersForDomain(domainID uint) ([]ImcUser, error) {
var users []ImcUser
err := d.Table("imc_users2domains").
Select("imc_users.*").
Joins("JOIN imc_users ON imc_users.id = imc_users2domains.user_id").
Select("imc_users.*"). // Select all columns from imc_users
Joins("JOIN imc_users ON imc_users.id = imc_users2domains.user_id"). // Join to users table
Where("imc_users2domains.domain_id = ?", domainID).
Find(&users).Error
if err != nil {
@ -117,12 +185,25 @@ func (d *DB) GetUsersForDomain(domainID uint) ([]ImcUser, error) {
return users, nil
}
// IsUserInDomain checks if a user already has access to a domain.
// userID: the ID of the user.
// domainID: the ID of the domain.
// Returns: true if the user already has access, false otherwise.
func (d *DB) IsUserInDomain(userID, domainID uint) (bool, error) {
var count int64
err := d.Model(&ImcUserDomain{}).Where("user_id = ? AND domain_id = ?", userID, domainID).Count(&count).Error
return count > 0, err
}
// =============================================================================
// Password Reset Tokens
// These functions manage one-time tokens for password reset.
// =============================================================================
// CreatePasswordResetToken creates a new password reset token.
// userID: the ID of the user requesting password reset.
// token: the random token string.
// expiresAt: when this token becomes invalid.
func (d *DB) CreatePasswordResetToken(userID uint, token string, expiresAt time.Time) error {
resetToken := PasswordResetToken{
UserID: userID,
@ -132,8 +213,12 @@ func (d *DB) CreatePasswordResetToken(userID uint, token string, expiresAt time.
return d.Create(&resetToken).Error
}
// GetValidPasswordResetToken looks up a valid (unused, not expired) reset token.
// token: the token string to look up.
// Returns: the token if valid, or an error if not found/expired/used.
func (d *DB) GetValidPasswordResetToken(token string) (*PasswordResetToken, error) {
var resetToken PasswordResetToken
// Check token exists, hasn't been used, and hasn't expired.
err := d.Where("token = ? AND used = false AND expires_at > ?", token, time.Now()).First(&resetToken).Error
if err != nil {
return nil, err
@ -141,6 +226,8 @@ func (d *DB) GetValidPasswordResetToken(token string) (*PasswordResetToken, erro
return &resetToken, nil
}
// MarkResetTokenUsed marks a token as used (prevents reuse).
// token: the token string to mark as used.
func (d *DB) MarkResetTokenUsed(token string) error {
return d.Model(&PasswordResetToken{}).Where("token = ?", token).Update("used", true).Error
}