imc-vibe/backend/internal/db/db.go
Christoph Haas 270cee44ff Change Go module path to git.workaround.org
- Update module path from github.com/imc/backend to git.workaround.org/chaas/imc/backend
- Update git remote to use git.workaround.org
2026-03-24 23:50:34 +01:00

249 lines
12 KiB
Go

// Package db provides database access for the IMC 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" // formatted error messages
"time" // time package for timestamps
"gorm.io/driver/mysql" // GORM MySQL driver
"gorm.io/gorm" // GORM ORM library
"gorm.io/gorm/logger" // GORM logger configuration
"git.workaround.org/chaas/imc/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 // 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"` // 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"` // 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
}
// 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"` // 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 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"` // 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"` // 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"` // 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" }
// =============================================================================
// 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"` // 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 // 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 // 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),
})
if err != nil {
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'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,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role ENUM('admin','user') DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_username (username),
INDEX idx_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`
if err := d.Exec(imcUsersSQL).Error; err != nil {
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,
email VARCHAR(100) NOT NULL,
ip_address VARCHAR(45) NOT NULL,
attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
successful BOOLEAN DEFAULT FALSE,
INDEX idx_email_time (email, attempted_at),
INDEX idx_ip_time (ip_address, attempted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`
if err := d.Exec(loginAttemptsSQL).Error; err != nil {
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,
user_id INT UNSIGNED NOT NULL,
domain_id INT UNSIGNED NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_user_domain (user_id, domain_id),
INDEX idx_user_id (user_id),
INDEX idx_domain_id (domain_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`
if err := d.Exec(users2DomainsSQL).Error; err != nil {
return err
}
return nil
}