- Update module path from github.com/imc/backend to git.workaround.org/chaas/imc/backend - Update git remote to use git.workaround.org
249 lines
12 KiB
Go
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
|
|
}
|