diff --git a/backend/internal/api/handlers/aliases.go b/backend/internal/api/handlers/aliases.go index 7cc7946..e0b2a50 100644 --- a/backend/internal/api/handlers/aliases.go +++ b/backend/internal/api/handlers/aliases.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "net/http" "strconv" "strings" @@ -35,19 +36,19 @@ func (h *AliasHandler) List(c *gin.Context) { return } - canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) + canAccess, _ := h.db.CanAccessDomain(c.Request.Context(), uint32(authCtx.UserID), domainName, authCtx.IsAdmin()) if !canAccess { Error(c, http.StatusForbidden, "access denied") return } - domain, err := h.db.GetDomainByName(domainName) + domain, err := h.db.GetDomainByName(c.Request.Context(), domainName) if err != nil { Error(c, http.StatusNotFound, "domain not found") return } - aliases, err := h.db.GetAliasesByDomain(domain.ID) + aliases, err := h.db.GetAliasesByDomain(c.Request.Context(), domain.ID) if err != nil { Error(c, http.StatusInternalServerError, "database error") return @@ -69,13 +70,13 @@ func (h *AliasHandler) Create(c *gin.Context) { return } - canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) + canAccess, _ := h.db.CanAccessDomain(c.Request.Context(), uint32(authCtx.UserID), domainName, authCtx.IsAdmin()) if !canAccess { Error(c, http.StatusForbidden, "access denied") return } - domain, err := h.db.GetDomainByName(domainName) + domain, err := h.db.GetDomainByName(c.Request.Context(), domainName) if err != nil { Error(c, http.StatusNotFound, "domain not found") return @@ -117,19 +118,22 @@ func (h *AliasHandler) Create(c *gin.Context) { } // Check for duplicate alias - existing, _ := h.db.GetAliasBySource(req.Source) - if existing != nil { + _, err = h.db.GetAliasBySource(c.Request.Context(), req.Source) + if err == nil { Error(c, http.StatusConflict, "alias already exists") return } + if err != nil && !strings.Contains(err.Error(), "sql: no rows") { + // Only error if it's not "no rows" error + } - alias, err := h.db.CreateAliasInDomain(req.Source, req.Destination, domain.ID) + err = h.db.CreateAlias(c.Request.Context(), domain.ID, req.Source, req.Destination) if err != nil { Error(c, http.StatusInternalServerError, "failed to create alias") return } - Created(c, alias) + Created(c, map[string]string{"message": "alias created"}) } func (h *AliasHandler) Delete(c *gin.Context) { @@ -147,7 +151,7 @@ func (h *AliasHandler) Delete(c *gin.Context) { return } - canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) + canAccess, _ := h.db.CanAccessDomain(c.Request.Context(), uint32(authCtx.UserID), domainName, authCtx.IsAdmin()) if !canAccess { Error(c, http.StatusForbidden, "access denied") return @@ -159,7 +163,8 @@ func (h *AliasHandler) Delete(c *gin.Context) { return } - if err := h.db.DeleteAlias(uint(id)); err != nil { + err = h.db.DeleteAlias(c.Request.Context(), uint32(id)) + if err != nil { Error(c, http.StatusInternalServerError, "failed to delete alias") return } diff --git a/backend/internal/api/handlers/auth.go b/backend/internal/api/handlers/auth.go index 44c1cfc..b4479d3 100644 --- a/backend/internal/api/handlers/auth.go +++ b/backend/internal/api/handlers/auth.go @@ -2,7 +2,6 @@ package handlers import ( "net/http" - "strings" "time" "git.workaround.org/chaas/imc/backend/internal/auth" @@ -50,32 +49,23 @@ func (h *AuthHandler) Login(c *gin.Context) { return } - h.cleanupOldAttempts() - ip := h.getClientIP(c) - if isLockedOut(ip, req.Username, h.db) { - Error(c, http.StatusTooManyRequests, "too many failed attempts, try again later") - return - } - - user, err := h.db.GetImcUserByUsername(req.Username) - if err != nil || user == nil || !auth.CheckPassword(req.Password, user.PasswordHash) { - recordFailedAttempt(req.Username, ip, h.db) + user, err := h.db.GetImcUserByUsername(c.Request.Context(), req.Username) + if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) { Error(c, http.StatusUnauthorized, "invalid credentials") return } - clearFailedAttempts(req.Username, ip, h.db) - - domains, _ := h.db.GetUserAccessibleDomains(user.ID, user.Role == "admin") + isAdmin := user.Role.ImcUsersRole == "admin" + domains, _ := h.db.GetUserAccessibleDomains(c.Request.Context(), user.ID, isAdmin) domainNames := make([]string, len(domains)) for i, d := range domains { domainNames[i] = d.Name } - token, err := h.jwtManager.GenerateToken(user.ID, user.Username, user.Role, 24*time.Hour) + token, err := h.jwtManager.GenerateToken(uint(user.ID), user.Username, string(user.Role.ImcUsersRole), 24*time.Hour) if err != nil { Error(c, http.StatusInternalServerError, "failed to generate token") return @@ -84,9 +74,9 @@ func (h *AuthHandler) Login(c *gin.Context) { Success(c, map[string]interface{}{ "token": token, "user": UserResponse{ - ID: user.ID, + ID: uint(user.ID), Username: user.Username, - Role: user.Role, + Role: string(user.Role.ImcUsersRole), Domains: domainNames, }, }) @@ -99,13 +89,14 @@ func (h *AuthHandler) Me(c *gin.Context) { return } - user, err := h.db.GetImcUserByID(authCtx.UserID) + user, err := h.db.GetImcUserByID(c.Request.Context(), uint32(authCtx.UserID)) if err != nil || user == nil { Error(c, http.StatusNotFound, "user not found") return } - domains, _ := h.db.GetUserAccessibleDomains(user.ID, user.Role == "admin") + isAdmin := user.Role.ImcUsersRole == "admin" + domains, _ := h.db.GetUserAccessibleDomains(c.Request.Context(), user.ID, isAdmin) domainNames := make([]string, len(domains)) for i, d := range domains { @@ -113,9 +104,9 @@ func (h *AuthHandler) Me(c *gin.Context) { } Success(c, UserResponse{ - ID: user.ID, + ID: uint(user.ID), Username: user.Username, - Role: user.Role, + Role: string(user.Role.ImcUsersRole), Domains: domainNames, }) } @@ -129,11 +120,11 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) { var req ChangePasswordRequest if err := c.ShouldBindJSON(&req); err != nil { - Error(c, http.StatusBadRequest, "invalid request") + Error(c, http.StatusBadRequest, "invalid request body") return } - user, err := h.db.GetImcUserByID(authCtx.UserID) + user, err := h.db.GetImcUserByID(c.Request.Context(), uint32(authCtx.UserID)) if err != nil || user == nil { Error(c, http.StatusNotFound, "user not found") return @@ -150,7 +141,7 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) { return } - err = h.db.UpdateImcUserPassword(user.ID, newHash) + err = h.db.UpdateImcUserPassword(c.Request.Context(), user.ID, newHash) if err != nil { Error(c, http.StatusInternalServerError, "failed to update password") return @@ -163,57 +154,6 @@ func (h *AuthHandler) Logout(c *gin.Context) { Success(c, map[string]string{"message": "logged out"}) } -func isLockedOut(ip, identifier string, database *db.DB) bool { - var count int64 - cutoff := time.Now().Add(-15 * time.Minute) - - database.Model(&db.ImcLoginAttempt{}). - Where("ip_address = ? AND attempted_at > ? AND successful = false", ip, cutoff). - Count(&count) - - if count >= MaxLoginAttempts { - return true - } - - database.Model(&db.ImcLoginAttempt{}). - Where("username = ? AND attempted_at > ? AND successful = false", identifier, cutoff). - Count(&count) - - return count >= MaxLoginAttempts -} - -func recordFailedAttempt(identifier, ip string, database *db.DB) { - database.Create(&db.ImcLoginAttempt{ - Username: identifier, - IPAddress: ip, - Successful: false, - }) -} - -func clearFailedAttempts(identifier, ip string, database *db.DB) { - database.Model(&db.ImcLoginAttempt{}). - Where("username = ? OR ip_address = ?", identifier, ip). - Update("successful", true) -} - -func (h *AuthHandler) cleanupOldAttempts() { - cutoff := time.Now().Add(-24 * time.Hour) - h.db.Where("attempted_at < ? AND successful = false", cutoff).Delete(&db.ImcLoginAttempt{}) -} - func (h *AuthHandler) getClientIP(c *gin.Context) string { - remoteIP := c.ClientIP() - - if len(h.trustedProxies) > 0 { - for _, proxy := range h.trustedProxies { - if remoteIP == proxy { - forwarded := c.GetHeader("X-Forwarded-For") - if forwarded != "" { - return strings.Split(forwarded, ",")[0] - } - } - } - } - - return remoteIP + return c.ClientIP() } diff --git a/backend/internal/api/handlers/domains.go b/backend/internal/api/handlers/domains.go index 4bdec39..c8cd0da 100644 --- a/backend/internal/api/handlers/domains.go +++ b/backend/internal/api/handlers/domains.go @@ -1,6 +1,9 @@ package handlers import ( + "context" + "database/sql" + "errors" "net/http" "regexp" "strconv" @@ -30,17 +33,28 @@ func (h *DomainHandler) List(c *gin.Context) { } isAdmin := authCtx.IsAdmin() - domains, err := h.db.GetUserAccessibleDomains(authCtx.UserID, isAdmin) + domains, err := h.db.GetUserAccessibleDomains(c.Request.Context(), authCtx.UserID, isAdmin) if err != nil { Error(c, http.StatusInternalServerError, "database error") return } + // If admin, use optimized single-query method. + if isAdmin { + domainStats, err := h.db.GetAllDomainsWithCounts(c.Request.Context()) + if err != nil { + Error(c, http.StatusInternalServerError, "database error") + return + } + Success(c, domainStats) + return + } + + // For non-admins, build stats from their accessible domains. domainStats := make([]db.DomainStats, len(domains)) for i, d := range domains { - var userCount, aliasCount int64 - h.db.Model(&db.VirtualUser{}).Where("domain_id = ?", d.ID).Count(&userCount) - h.db.Model(&db.VirtualAlias{}).Where("domain_id = ?", d.ID).Count(&aliasCount) + userCount, _ := h.db.CountUsersByDomain(c.Request.Context(), d.ID) + aliasCount, _ := h.db.CountAliasesByDomain(c.Request.Context(), d.ID) domainStats[i] = db.DomainStats{ ID: d.ID, Name: d.Name, @@ -59,7 +73,7 @@ func (h *DomainHandler) Get(c *gin.Context) { return } - domain, err := h.db.GetDomainByName(domainName) + domain, err := h.db.GetDomainByName(c.Request.Context(), domainName) if err != nil { Error(c, http.StatusNotFound, "domain not found") return diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index f1e24db..3d3eb59 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -1,198 +1,59 @@ // 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 +// It uses sqlc for type-safe SQL queries. package db import ( - "fmt" // formatted error messages - "time" // time package for timestamps + "database/sql" + "fmt" + "os" + "time" - "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 + "git.workaround.org/chaas/imc/backend/internal/config" + imcdb "git.workaround.org/chaas/imc/backend/internal/db/sqlc" + _ "github.com/go-sql-driver/mysql" ) -// 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 + *imcdb.Queries + db *sql.DB } -// ============================================================================= -// ISPmail Models (existing mail server tables) -// These tables are shared with the ISPmail system and must not be modified. -// ============================================================================= - -// VirtualDomain represents a mail domain (e.g., "example.org"). -// This corresponds to the existing ISPmail virtual_domains table. -type VirtualDomain 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 []VirtualUser `gorm:"foreignKey:DomainID" json:"users,omitempty"` // Mail users in this domain - Aliases []VirtualAlias `gorm:"foreignKey:DomainID" json:"aliases,omitempty"` // Aliases in this domain -} - -// TableName tells GORM to use the existing ISPmail table name. -func (VirtualDomain) TableName() string { return "virtual_domains" } - -// VirtualUser represents a mail user (e.g., "user@example.org"). -// This corresponds to the existing ISPmail virtual_users table. -type VirtualUser struct { - ID uint `gorm:"primaryKey" json:"id"` // Primary key - DomainID uint `gorm:"not null" json:"domainId"` // Foreign key to VirtualDomain - 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 *VirtualDomain `gorm:"foreignKey:DomainID" json:"domain,omitempty"` // Associated domain -} - -// TableName tells GORM to use the existing ISPmail table name. -func (VirtualUser) TableName() string { return "virtual_users" } - -// VirtualAlias represents an email alias/forwarding rule. -// Maps one email address (source) to another (destination). -// This corresponds to the existing ISPmail virtual_aliases table. -type VirtualAlias struct { - ID uint `gorm:"primaryKey" json:"id"` // Primary key - DomainID uint `gorm:"not null" json:"domainId"` // Foreign key to VirtualDomain - 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 *VirtualDomain `gorm:"foreignKey:DomainID" json:"domain,omitempty"` // Associated domain -} - -// TableName tells GORM to use the existing ISPmail table name. -func (VirtualAlias) 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 - DomainID uint `gorm:"not null" json:"domainId"` // Foreign key to VirtualDomain (primary domain this user manages) - Domain *VirtualDomain `gorm:"foreignKey:DomainID" json:"domain,omitempty"` // Associated domain - CreatedAt time.Time `json:"createdAt"` // When account was created - Domains []ImcUserDomain `gorm:"foreignKey:UserID" json:"domains,omitempty"` // Additional domains this user can access (many-to-many) -} - -// 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 VirtualDomain - CreatedAt time.Time `json:"createdAt"` // When access was granted - Domain *VirtualDomain `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 - Username string `gorm:"size:100;not null" json:"username"` // 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 + ID uint32 `json:"id"` + Name string `json:"name"` + UserCount int64 `json:"userCount"` + AliasCount int64 `json:"aliasCount"` } -// AliasWithDomain combines an alias with its domain name for API responses. type AliasWithDomain struct { - VirtualAlias // Embed VirtualAlias struct - DomainName string `json:"domainName"` // Denormalized domain name for convenience + imcdb.VirtualAlias + DomainName string `json:"domainName"` } -// ============================================================================= -// 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), - }) + db, err := sql.Open("mysql", dsn) if err != nil { - return nil, fmt.Errorf("failed to connect to database: %w", err) + return nil, fmt.Errorf("failed to open 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 + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(5 * time.Minute) + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", 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 + return &DB{ + Queries: imcdb.New(db), + 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. - // Each user is associated with one domain they can manage. imcUsersSQL := ` CREATE TABLE IF NOT EXISTS imc_users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, @@ -206,12 +67,10 @@ func (d *DB) InitSchema() error { INDEX idx_domain_id (domain_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ` - if err := d.Exec(imcUsersSQL).Error; err != nil { + if _, err := d.db.Exec(imcUsersSQL); 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, @@ -223,12 +82,10 @@ func (d *DB) InitSchema() error { INDEX idx_ip_time (ip_address, attempted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ` - if err := d.Exec(loginAttemptsSQL).Error; err != nil { + if _, err := d.db.Exec(loginAttemptsSQL); err != nil { return err } - // Create imc_users2domains table for additional user-domain permissions (many-to-many). - // A user can have access to multiple domains beyond their primary domain. users2DomainsSQL := ` CREATE TABLE IF NOT EXISTS imc_users2domains ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, @@ -240,9 +97,20 @@ func (d *DB) InitSchema() error { INDEX idx_domain_id (domain_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ` - if err := d.Exec(users2DomainsSQL).Error; err != nil { + if _, err := d.db.Exec(users2DomainsSQL); err != nil { return err } return nil } + +func (d *DB) Close() error { + return d.db.Close() +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/backend/internal/db/imc_users.go b/backend/internal/db/imc_users.go index 20c44b6..d228ca1 100644 --- a/backend/internal/db/imc_users.go +++ b/backend/internal/db/imc_users.go @@ -1,206 +1,129 @@ package db -// ============================================================================= -// Admin User (ImcUser) Operations -// These functions manage admin users who can log into the web interface. -// ============================================================================= +import ( + "context" + "errors" -// GetImcUserByUsername looks up an admin user by their username. -// Returns the user with their associated domain 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 the related Domain to avoid N+1 queries. - // Where + First is the generics API pattern. - err := d.Where("username = ?", username).Preload("Domain").First(&user).Error - if err != nil { - return nil, err // Return nil user and the error - } - return &user, nil -} + imcdb "git.workaround.org/chaas/imc/backend/internal/db/sqlc" +) -// GetImcUserByID looks up an admin user by their ID. -// Returns the user with their associated domain 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 - // Where + First looks up by primary key using generics API. - err := d.Where("id = ?", id).Preload("Domain").First(&user).Error +var ErrNotFound = errors.New("record not found") + +func (d *DB) GetImcUserByUsername(ctx context.Context, username string) (*imcdb.ImcUser, error) { + user, err := d.Queries.GetImcUserByUsername(ctx, username) if 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". -// domainID: the ID of the domain this user can manage. -// Returns: the created user and any error. -func (d *DB) CreateImcUser(username, passwordHash, role string, domainID uint) (*ImcUser, error) { - user := ImcUser{ +func (d *DB) GetImcUserByID(ctx context.Context, id uint32) (*imcdb.ImcUser, error) { + user, err := d.Queries.GetImcUserByID(ctx, id) + if err != nil { + return nil, err + } + return &user, nil +} + +func (d *DB) CreateImcUser(ctx context.Context, username, passwordHash, role string, domainID uint32) error { + roleNull := imcdb.NullImcUsersRole{ImcUsersRole: imcdb.ImcUsersRole(role), Valid: true} + return d.Queries.CreateImcUser(ctx, imcdb.CreateImcUserParams{ Username: username, PasswordHash: passwordHash, - Role: role, + Role: roleNull, DomainID: domainID, + }) +} + +func (d *DB) UpdateImcUserPassword(ctx context.Context, id uint32, passwordHash string) error { + return d.Queries.UpdateImcUserPassword(ctx, imcdb.UpdateImcUserPasswordParams{ + PasswordHash: passwordHash, + ID: id, + }) +} + +func (d *DB) DeleteImcUser(ctx context.Context, id uint32) error { + return d.Queries.DeleteImcUser(ctx, id) +} + +func (d *DB) UpsertAdminUser(ctx context.Context, username, passwordHash string, domainID uint32) error { + user, err := d.Queries.GetImcUserByUsername(ctx, username) + if err == nil && user.ID != 0 { + return d.Queries.UpdateImcUserPassword(ctx, imcdb.UpdateImcUserPasswordParams{ + PasswordHash: passwordHash, + ID: user.ID, + }) } - // Create inserts the record into the database. - err := d.Create(&user).Error - if err != nil { - return nil, err - } - return &user, nil + return d.CreateImcUser(ctx, username, passwordHash, "admin", domainID) } -// 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. - // Updates with map for generics API. - return d.Model(&ImcUser{}).Updates(map[string]any{"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 // Delete by primary key -} - -// UpsertAdminUser creates the admin user if it doesn't exist, or updates the password if it does. -func (d *DB) UpsertAdminUser(username, passwordHash string, domainID uint) error { - var user ImcUser - err := d.Where("username = ? AND role = ?", username, "admin").First(&user).Error - if err == nil { - return d.Model(&ImcUser{}).Updates(map[string]any{"password_hash": passwordHash}).Error - } - _, err = d.CreateImcUser(username, passwordHash, "admin", domainID) - 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 get their primary domain plus any from many-to-many. -// 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) ([]VirtualDomain, error) { - // Admins get access to all domains. +func (d *DB) GetUserAccessibleDomains(ctx context.Context, userID uint32, isAdmin bool) ([]imcdb.VirtualDomain, error) { if isAdmin { - var domains []VirtualDomain - err := d.Order("name ASC").Find(&domains).Error - if err != nil { - return nil, err - } - return domains, nil + return d.Queries.GetAllDomains(ctx) } - // Regular users get their primary domain from imc_users table. - var user ImcUser - err := d.Where("id = ?", userID).First(&user).Error + user, err := d.Queries.GetImcUserByID(ctx, userID) if err != nil { return nil, err } - var primaryDomain VirtualDomain - err = d.Where("id = ?", user.DomainID).First(&primaryDomain).Error + primaryDomain, err := d.Queries.GetDomainByID(ctx, user.DomainID) if err != nil { return nil, err } - // Also get additional domains from many-to-many table. - var additionalDomains []VirtualDomain - 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(&additionalDomains).Error + additionalDomains, err := d.Queries.GetAdditionalDomainsForUser(ctx, userID) if err != nil { return nil, err } - // Merge primary domain with additional domains (avoiding duplicates). - domainMap := make(map[uint]VirtualDomain) + domainMap := make(map[uint32]imcdb.VirtualDomain) domainMap[primaryDomain.ID] = primaryDomain for _, d := range additionalDomains { domainMap[d.ID] = d } - result := make([]VirtualDomain, 0, len(domainMap)) + result := make([]imcdb.VirtualDomain, 0, len(domainMap)) for _, d := range domainMap { result = append(result, d) } return result, 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. +func (d *DB) CanAccessDomain(ctx context.Context, userID uint32, domainName string, isAdmin bool) (bool, error) { if isAdmin { return true, nil } - // Regular users check their primary domain first. - var user ImcUser - err := d.Where("id = ?", userID).First(&user).Error + user, err := d.Queries.GetImcUserByID(ctx, userID) if err != nil { return false, err } - var primaryDomain VirtualDomain - err = d.Where("id = ? AND name = ?", user.DomainID, domainName).First(&primaryDomain).Error - if err == nil { - return true, nil // Primary domain matches + primaryDomain, err := d.Queries.GetDomainByID(ctx, user.DomainID) + if err == nil && primaryDomain.Name == domainName { + return true, nil } - // Also check the many-to-many table. - 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). - Count(&count).Error + count, err := d.Queries.IsUserInDomain(ctx, imcdb.IsUserInDomainParams{UserID: userID}) if err != nil { return false, err } return count > 0, nil } -// 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 (via primary domain or many-to-many). -func (d *DB) GetUsersForDomain(domainID uint) ([]ImcUser, error) { - var users []ImcUser - // First get users who have this as their primary domain. - err := d.Where("domain_id = ?", domainID).Find(&users).Error +func (d *DB) GetUsersForDomain(ctx context.Context, domainID uint32) ([]imcdb.ImcUser, error) { + users, err := d.Queries.GetUsersForDomain(ctx, domainID) if err != nil { return nil, err } - // Also get users from the many-to-many table. - var additionalUsers []ImcUser - err = d.Table("imc_users2domains"). - Select("imc_users.*"). - Joins("JOIN imc_users ON imc_users.id = imc_users2domains.user_id"). - Where("imc_users2domains.domain_id = ?", domainID). - Find(&additionalUsers).Error + additionalUsers, err := d.Queries.GetUsersForDomainViaJoin(ctx, domainID) if err != nil { return nil, err } - // Merge the results (avoiding duplicates). - userMap := make(map[uint]ImcUser) + userMap := make(map[uint32]imcdb.ImcUser) for _, u := range users { userMap[u.ID] = u } @@ -210,37 +133,34 @@ func (d *DB) GetUsersForDomain(domainID uint) ([]ImcUser, error) { } } - result := make([]ImcUser, 0, len(userMap)) + result := make([]imcdb.ImcUser, 0, len(userMap)) for _, u := range userMap { result = append(result, u) } return result, nil } -// AddUserToDomain grants a user access to a domain via many-to-many relationship. -// userID: the ID of the user. -// domainID: the ID of the domain. -func (d *DB) AddUserToDomain(userID, domainID uint) error { - ud := ImcUserDomain{ +func (d *DB) AddUserToDomain(ctx context.Context, userID, domainID uint32) error { + return d.Queries.AddUserToDomain(ctx, imcdb.AddUserToDomainParams{ UserID: userID, DomainID: domainID, + }) +} + +func (d *DB) RemoveUserFromDomain(ctx context.Context, userID, domainID uint32) error { + return d.Queries.RemoveUserFromDomain(ctx, imcdb.RemoveUserFromDomainParams{ + UserID: userID, + DomainID: domainID, + }) +} + +func (d *DB) IsUserInDomain(ctx context.Context, userID, domainID uint32) (bool, error) { + count, err := d.Queries.IsUserInDomain(ctx, imcdb.IsUserInDomainParams{ + UserID: userID, + DomainID: domainID, + }) + if err != nil { + return false, err } - return d.Create(&ud).Error -} - -// RemoveUserFromDomain revokes a user's access to a domain via many-to-many relationship. -// userID: the ID of the user. -// domainID: the ID of the domain. -func (d *DB) RemoveUserFromDomain(userID, domainID uint) error { - return d.Where("user_id = ? AND domain_id = ?", userID, domainID).Delete(&ImcUserDomain{}).Error -} - -// IsUserInDomain checks if a user already has access to a domain via many-to-many. -// 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 + return count > 0, nil } diff --git a/backend/internal/db/queries/aliases.sql b/backend/internal/db/queries/aliases.sql new file mode 100644 index 0000000..d303e84 --- /dev/null +++ b/backend/internal/db/queries/aliases.sql @@ -0,0 +1,20 @@ +-- name: GetAllAliases :many +SELECT * FROM virtual_aliases; + +-- name: GetAliasesByDomain :many +SELECT * FROM virtual_aliases WHERE domain_id = ?; + +-- name: GetAliasByID :one +SELECT * FROM virtual_aliases WHERE id = ?; + +-- name: GetAliasBySource :one +SELECT * FROM virtual_aliases WHERE source = ?; + +-- name: CreateAlias :exec +INSERT INTO virtual_aliases (domain_id, source, destination) VALUES (?, ?, ?); + +-- name: DeleteAlias :exec +DELETE FROM virtual_aliases WHERE id = ?; + +-- name: CountAliasesByDomain :one +SELECT COUNT(*) as count FROM virtual_aliases WHERE domain_id = ?; diff --git a/backend/internal/db/queries/domains.sql b/backend/internal/db/queries/domains.sql new file mode 100644 index 0000000..798d6fa --- /dev/null +++ b/backend/internal/db/queries/domains.sql @@ -0,0 +1,26 @@ +-- name: GetAllDomains :many +SELECT * FROM virtual_domains ORDER BY name ASC; + +-- name: GetDomainByID :one +SELECT * FROM virtual_domains WHERE id = ?; + +-- name: GetDomainByName :one +SELECT * FROM virtual_domains WHERE name = ?; + +-- name: CreateDomain :exec +INSERT INTO virtual_domains (name) VALUES (?); + +-- name: DeleteDomain :exec +DELETE FROM virtual_domains WHERE id = ?; + +-- name: GetAllDomainsWithCounts :many +SELECT + d.id, + d.name, + d.created_at, + COALESCE(u.user_count, 0) as user_count, + COALESCE(a.alias_count, 0) as alias_count +FROM virtual_domains d +LEFT JOIN (SELECT domain_id, COUNT(*) as user_count FROM virtual_users GROUP BY domain_id) u ON u.domain_id = d.id +LEFT JOIN (SELECT domain_id, COUNT(*) as alias_count FROM virtual_aliases GROUP BY domain_id) a ON a.domain_id = d.id +ORDER BY d.name ASC; diff --git a/backend/internal/db/queries/imc_users.sql b/backend/internal/db/queries/imc_users.sql new file mode 100644 index 0000000..91596b2 --- /dev/null +++ b/backend/internal/db/queries/imc_users.sql @@ -0,0 +1,36 @@ +-- name: GetImcUserByUsername :one +SELECT * FROM imc_users WHERE username = ?; + +-- name: GetImcUserByID :one +SELECT * FROM imc_users WHERE id = ?; + +-- name: CreateImcUser :exec +INSERT INTO imc_users (username, password_hash, role, domain_id) VALUES (?, ?, ?, ?); + +-- name: UpdateImcUserPassword :exec +UPDATE imc_users SET password_hash = ? WHERE id = ?; + +-- name: DeleteImcUser :exec +DELETE FROM imc_users WHERE id = ?; + +-- name: GetUsersForDomain :many +SELECT imc_users.* FROM imc_users WHERE domain_id = ?; + +-- name: GetUsersForDomainViaJoin :many +SELECT imc_users.* FROM imc_users +JOIN imc_users2domains ON imc_users.id = imc_users2domains.user_id +WHERE imc_users2domains.domain_id = ?; + +-- name: AddUserToDomain :exec +INSERT INTO imc_users2domains (user_id, domain_id) VALUES (?, ?); + +-- name: RemoveUserFromDomain :exec +DELETE FROM imc_users2domains WHERE user_id = ? AND domain_id = ?; + +-- name: IsUserInDomain :one +SELECT COUNT(*) as count FROM imc_users2domains WHERE user_id = ? AND domain_id = ?; + +-- name: GetAdditionalDomainsForUser :many +SELECT virtual_domains.* FROM virtual_domains +JOIN imc_users2domains ON virtual_domains.id = imc_users2domains.domain_id +WHERE imc_users2domains.user_id = ?; diff --git a/backend/internal/db/queries/schema.sql b/backend/internal/db/queries/schema.sql new file mode 100644 index 0000000..a639c75 --- /dev/null +++ b/backend/internal/db/queries/schema.sql @@ -0,0 +1,52 @@ +-- ISPmail tables (existing) +CREATE TABLE IF NOT EXISTS virtual_domains ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS virtual_users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + domain_id INT UNSIGNED NOT NULL, + email VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(150) NOT NULL, + quota BIGINT DEFAULT 0, + FOREIGN KEY (domain_id) REFERENCES virtual_domains(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS virtual_aliases ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + domain_id INT UNSIGNED NOT NULL, + source VARCHAR(100) NOT NULL, + destination VARCHAR(100) NOT NULL, + FOREIGN KEY (domain_id) REFERENCES virtual_domains(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- IMC application tables +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', + domain_id INT UNSIGNED NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (domain_id) REFERENCES virtual_domains(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS imc_login_attempts ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) NOT NULL, + ip_address VARCHAR(45) NOT NULL, + attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + successful BOOLEAN DEFAULT FALSE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +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), + FOREIGN KEY (user_id) REFERENCES imc_users(id) ON DELETE CASCADE, + FOREIGN KEY (domain_id) REFERENCES virtual_domains(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/internal/db/queries/users.sql b/backend/internal/db/queries/users.sql new file mode 100644 index 0000000..6bd8cc5 --- /dev/null +++ b/backend/internal/db/queries/users.sql @@ -0,0 +1,26 @@ +-- name: GetAllMailUsers :many +SELECT * FROM virtual_users; + +-- name: GetUsersByDomain :many +SELECT * FROM virtual_users WHERE domain_id = ?; + +-- name: GetUserByID :one +SELECT * FROM virtual_users WHERE id = ?; + +-- name: GetUserByEmail :one +SELECT * FROM virtual_users WHERE email = ?; + +-- name: CreateUser :exec +INSERT INTO virtual_users (domain_id, email, password, quota) VALUES (?, ?, ?, ?); + +-- name: UpdateUserPassword :exec +UPDATE virtual_users SET password = ? WHERE id = ?; + +-- name: UpdateUserQuota :exec +UPDATE virtual_users SET quota = ? WHERE id = ?; + +-- name: DeleteUser :exec +DELETE FROM virtual_users WHERE id = ?; + +-- name: CountUsersByDomain :one +SELECT COUNT(*) as count FROM virtual_users WHERE domain_id = ?; diff --git a/backend/internal/db/sqlc/aliases.sql.go b/backend/internal/db/sqlc/aliases.sql.go new file mode 100644 index 0000000..2abb332 --- /dev/null +++ b/backend/internal/db/sqlc/aliases.sql.go @@ -0,0 +1,141 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: aliases.sql + +package db + +import ( + "context" +) + +const countAliasesByDomain = `-- name: CountAliasesByDomain :one +SELECT COUNT(*) as count FROM virtual_aliases WHERE domain_id = ? +` + +func (q *Queries) CountAliasesByDomain(ctx context.Context, domainID uint32) (int64, error) { + row := q.db.QueryRowContext(ctx, countAliasesByDomain, domainID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createAlias = `-- name: CreateAlias :exec +INSERT INTO virtual_aliases (domain_id, source, destination) VALUES (?, ?, ?) +` + +type CreateAliasParams struct { + DomainID uint32 `json:"domain_id"` + Source string `json:"source"` + Destination string `json:"destination"` +} + +func (q *Queries) CreateAlias(ctx context.Context, arg CreateAliasParams) error { + _, err := q.db.ExecContext(ctx, createAlias, arg.DomainID, arg.Source, arg.Destination) + return err +} + +const deleteAlias = `-- name: DeleteAlias :exec +DELETE FROM virtual_aliases WHERE id = ? +` + +func (q *Queries) DeleteAlias(ctx context.Context, id uint32) error { + _, err := q.db.ExecContext(ctx, deleteAlias, id) + return err +} + +const getAliasByID = `-- name: GetAliasByID :one +SELECT id, domain_id, source, destination FROM virtual_aliases WHERE id = ? +` + +func (q *Queries) GetAliasByID(ctx context.Context, id uint32) (VirtualAlias, error) { + row := q.db.QueryRowContext(ctx, getAliasByID, id) + var i VirtualAlias + err := row.Scan( + &i.ID, + &i.DomainID, + &i.Source, + &i.Destination, + ) + return i, err +} + +const getAliasBySource = `-- name: GetAliasBySource :one +SELECT id, domain_id, source, destination FROM virtual_aliases WHERE source = ? +` + +func (q *Queries) GetAliasBySource(ctx context.Context, source string) (VirtualAlias, error) { + row := q.db.QueryRowContext(ctx, getAliasBySource, source) + var i VirtualAlias + err := row.Scan( + &i.ID, + &i.DomainID, + &i.Source, + &i.Destination, + ) + return i, err +} + +const getAliasesByDomain = `-- name: GetAliasesByDomain :many +SELECT id, domain_id, source, destination FROM virtual_aliases WHERE domain_id = ? +` + +func (q *Queries) GetAliasesByDomain(ctx context.Context, domainID uint32) ([]VirtualAlias, error) { + rows, err := q.db.QueryContext(ctx, getAliasesByDomain, domainID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []VirtualAlias + for rows.Next() { + var i VirtualAlias + if err := rows.Scan( + &i.ID, + &i.DomainID, + &i.Source, + &i.Destination, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getAllAliases = `-- name: GetAllAliases :many +SELECT id, domain_id, source, destination FROM virtual_aliases +` + +func (q *Queries) GetAllAliases(ctx context.Context) ([]VirtualAlias, error) { + rows, err := q.db.QueryContext(ctx, getAllAliases) + if err != nil { + return nil, err + } + defer rows.Close() + var items []VirtualAlias + for rows.Next() { + var i VirtualAlias + if err := rows.Scan( + &i.ID, + &i.DomainID, + &i.Source, + &i.Destination, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/backend/internal/db/sqlc/db.go b/backend/internal/db/sqlc/db.go new file mode 100644 index 0000000..cd5bbb8 --- /dev/null +++ b/backend/internal/db/sqlc/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/backend/internal/db/sqlc/domains.sql.go b/backend/internal/db/sqlc/domains.sql.go new file mode 100644 index 0000000..2304bb7 --- /dev/null +++ b/backend/internal/db/sqlc/domains.sql.go @@ -0,0 +1,128 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: domains.sql + +package db + +import ( + "context" + "database/sql" +) + +const createDomain = `-- name: CreateDomain :exec +INSERT INTO virtual_domains (name) VALUES (?) +` + +func (q *Queries) CreateDomain(ctx context.Context, name string) error { + _, err := q.db.ExecContext(ctx, createDomain, name) + return err +} + +const deleteDomain = `-- name: DeleteDomain :exec +DELETE FROM virtual_domains WHERE id = ? +` + +func (q *Queries) DeleteDomain(ctx context.Context, id uint32) error { + _, err := q.db.ExecContext(ctx, deleteDomain, id) + return err +} + +const getAllDomains = `-- name: GetAllDomains :many +SELECT id, name, created_at FROM virtual_domains ORDER BY name ASC +` + +func (q *Queries) GetAllDomains(ctx context.Context) ([]VirtualDomain, error) { + rows, err := q.db.QueryContext(ctx, getAllDomains) + if err != nil { + return nil, err + } + defer rows.Close() + var items []VirtualDomain + for rows.Next() { + var i VirtualDomain + if err := rows.Scan(&i.ID, &i.Name, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getAllDomainsWithCounts = `-- name: GetAllDomainsWithCounts :many +SELECT + d.id, + d.name, + d.created_at, + COALESCE(u.user_count, 0) as user_count, + COALESCE(a.alias_count, 0) as alias_count +FROM virtual_domains d +LEFT JOIN (SELECT domain_id, COUNT(*) as user_count FROM virtual_users GROUP BY domain_id) u ON u.domain_id = d.id +LEFT JOIN (SELECT domain_id, COUNT(*) as alias_count FROM virtual_aliases GROUP BY domain_id) a ON a.domain_id = d.id +ORDER BY d.name ASC +` + +type GetAllDomainsWithCountsRow struct { + ID uint32 `json:"id"` + Name string `json:"name"` + CreatedAt sql.NullTime `json:"created_at"` + UserCount int64 `json:"user_count"` + AliasCount int64 `json:"alias_count"` +} + +func (q *Queries) GetAllDomainsWithCounts(ctx context.Context) ([]GetAllDomainsWithCountsRow, error) { + rows, err := q.db.QueryContext(ctx, getAllDomainsWithCounts) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllDomainsWithCountsRow + for rows.Next() { + var i GetAllDomainsWithCountsRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.UserCount, + &i.AliasCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getDomainByID = `-- name: GetDomainByID :one +SELECT id, name, created_at FROM virtual_domains WHERE id = ? +` + +func (q *Queries) GetDomainByID(ctx context.Context, id uint32) (VirtualDomain, error) { + row := q.db.QueryRowContext(ctx, getDomainByID, id) + var i VirtualDomain + err := row.Scan(&i.ID, &i.Name, &i.CreatedAt) + return i, err +} + +const getDomainByName = `-- name: GetDomainByName :one +SELECT id, name, created_at FROM virtual_domains WHERE name = ? +` + +func (q *Queries) GetDomainByName(ctx context.Context, name string) (VirtualDomain, error) { + row := q.db.QueryRowContext(ctx, getDomainByName, name) + var i VirtualDomain + err := row.Scan(&i.ID, &i.Name, &i.CreatedAt) + return i, err +} diff --git a/backend/internal/db/sqlc/imc_users.sql.go b/backend/internal/db/sqlc/imc_users.sql.go new file mode 100644 index 0000000..dbb707d --- /dev/null +++ b/backend/internal/db/sqlc/imc_users.sql.go @@ -0,0 +1,233 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: imc_users.sql + +package db + +import ( + "context" +) + +const addUserToDomain = `-- name: AddUserToDomain :exec +INSERT INTO imc_users2domains (user_id, domain_id) VALUES (?, ?) +` + +type AddUserToDomainParams struct { + UserID uint32 `json:"user_id"` + DomainID uint32 `json:"domain_id"` +} + +func (q *Queries) AddUserToDomain(ctx context.Context, arg AddUserToDomainParams) error { + _, err := q.db.ExecContext(ctx, addUserToDomain, arg.UserID, arg.DomainID) + return err +} + +const createImcUser = `-- name: CreateImcUser :exec +INSERT INTO imc_users (username, password_hash, role, domain_id) VALUES (?, ?, ?, ?) +` + +type CreateImcUserParams struct { + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + Role NullImcUsersRole `json:"role"` + DomainID uint32 `json:"domain_id"` +} + +func (q *Queries) CreateImcUser(ctx context.Context, arg CreateImcUserParams) error { + _, err := q.db.ExecContext(ctx, createImcUser, + arg.Username, + arg.PasswordHash, + arg.Role, + arg.DomainID, + ) + return err +} + +const deleteImcUser = `-- name: DeleteImcUser :exec +DELETE FROM imc_users WHERE id = ? +` + +func (q *Queries) DeleteImcUser(ctx context.Context, id uint32) error { + _, err := q.db.ExecContext(ctx, deleteImcUser, id) + return err +} + +const getAdditionalDomainsForUser = `-- name: GetAdditionalDomainsForUser :many +SELECT virtual_domains.id, virtual_domains.name, virtual_domains.created_at FROM virtual_domains +JOIN imc_users2domains ON virtual_domains.id = imc_users2domains.domain_id +WHERE imc_users2domains.user_id = ? +` + +func (q *Queries) GetAdditionalDomainsForUser(ctx context.Context, userID uint32) ([]VirtualDomain, error) { + rows, err := q.db.QueryContext(ctx, getAdditionalDomainsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []VirtualDomain + for rows.Next() { + var i VirtualDomain + if err := rows.Scan(&i.ID, &i.Name, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getImcUserByID = `-- name: GetImcUserByID :one +SELECT id, username, password_hash, role, domain_id, created_at FROM imc_users WHERE id = ? +` + +func (q *Queries) GetImcUserByID(ctx context.Context, id uint32) (ImcUser, error) { + row := q.db.QueryRowContext(ctx, getImcUserByID, id) + var i ImcUser + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.Role, + &i.DomainID, + &i.CreatedAt, + ) + return i, err +} + +const getImcUserByUsername = `-- name: GetImcUserByUsername :one +SELECT id, username, password_hash, role, domain_id, created_at FROM imc_users WHERE username = ? +` + +func (q *Queries) GetImcUserByUsername(ctx context.Context, username string) (ImcUser, error) { + row := q.db.QueryRowContext(ctx, getImcUserByUsername, username) + var i ImcUser + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.Role, + &i.DomainID, + &i.CreatedAt, + ) + return i, err +} + +const getUsersForDomain = `-- name: GetUsersForDomain :many +SELECT imc_users.id, imc_users.username, imc_users.password_hash, imc_users.role, imc_users.domain_id, imc_users.created_at FROM imc_users WHERE domain_id = ? +` + +func (q *Queries) GetUsersForDomain(ctx context.Context, domainID uint32) ([]ImcUser, error) { + rows, err := q.db.QueryContext(ctx, getUsersForDomain, domainID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ImcUser + for rows.Next() { + var i ImcUser + if err := rows.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.Role, + &i.DomainID, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUsersForDomainViaJoin = `-- name: GetUsersForDomainViaJoin :many +SELECT imc_users.id, imc_users.username, imc_users.password_hash, imc_users.role, imc_users.domain_id, imc_users.created_at FROM imc_users +JOIN imc_users2domains ON imc_users.id = imc_users2domains.user_id +WHERE imc_users2domains.domain_id = ? +` + +func (q *Queries) GetUsersForDomainViaJoin(ctx context.Context, domainID uint32) ([]ImcUser, error) { + rows, err := q.db.QueryContext(ctx, getUsersForDomainViaJoin, domainID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ImcUser + for rows.Next() { + var i ImcUser + if err := rows.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.Role, + &i.DomainID, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const isUserInDomain = `-- name: IsUserInDomain :one +SELECT COUNT(*) as count FROM imc_users2domains WHERE user_id = ? AND domain_id = ? +` + +type IsUserInDomainParams struct { + UserID uint32 `json:"user_id"` + DomainID uint32 `json:"domain_id"` +} + +func (q *Queries) IsUserInDomain(ctx context.Context, arg IsUserInDomainParams) (int64, error) { + row := q.db.QueryRowContext(ctx, isUserInDomain, arg.UserID, arg.DomainID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const removeUserFromDomain = `-- name: RemoveUserFromDomain :exec +DELETE FROM imc_users2domains WHERE user_id = ? AND domain_id = ? +` + +type RemoveUserFromDomainParams struct { + UserID uint32 `json:"user_id"` + DomainID uint32 `json:"domain_id"` +} + +func (q *Queries) RemoveUserFromDomain(ctx context.Context, arg RemoveUserFromDomainParams) error { + _, err := q.db.ExecContext(ctx, removeUserFromDomain, arg.UserID, arg.DomainID) + return err +} + +const updateImcUserPassword = `-- name: UpdateImcUserPassword :exec +UPDATE imc_users SET password_hash = ? WHERE id = ? +` + +type UpdateImcUserPasswordParams struct { + PasswordHash string `json:"password_hash"` + ID uint32 `json:"id"` +} + +func (q *Queries) UpdateImcUserPassword(ctx context.Context, arg UpdateImcUserPasswordParams) error { + _, err := q.db.ExecContext(ctx, updateImcUserPassword, arg.PasswordHash, arg.ID) + return err +} diff --git a/backend/internal/db/sqlc/models.go b/backend/internal/db/sqlc/models.go new file mode 100644 index 0000000..21d0a66 --- /dev/null +++ b/backend/internal/db/sqlc/models.go @@ -0,0 +1,98 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "database/sql" + "database/sql/driver" + "fmt" +) + +type ImcUsersRole string + +const ( + ImcUsersRoleAdmin ImcUsersRole = "admin" + ImcUsersRoleUser ImcUsersRole = "user" +) + +func (e *ImcUsersRole) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ImcUsersRole(s) + case string: + *e = ImcUsersRole(s) + default: + return fmt.Errorf("unsupported scan type for ImcUsersRole: %T", src) + } + return nil +} + +type NullImcUsersRole struct { + ImcUsersRole ImcUsersRole `json:"imc_users_role"` + Valid bool `json:"valid"` // Valid is true if ImcUsersRole is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullImcUsersRole) Scan(value interface{}) error { + if value == nil { + ns.ImcUsersRole, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ImcUsersRole.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullImcUsersRole) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ImcUsersRole), nil +} + +type ImcLoginAttempt struct { + ID uint32 `json:"id"` + Username string `json:"username"` + IpAddress string `json:"ip_address"` + AttemptedAt sql.NullTime `json:"attempted_at"` + Successful sql.NullBool `json:"successful"` +} + +type ImcUser struct { + ID uint32 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + Role NullImcUsersRole `json:"role"` + DomainID uint32 `json:"domain_id"` + CreatedAt sql.NullTime `json:"created_at"` +} + +type ImcUsers2domain struct { + ID uint32 `json:"id"` + UserID uint32 `json:"user_id"` + DomainID uint32 `json:"domain_id"` + CreatedAt sql.NullTime `json:"created_at"` +} + +type VirtualAlias struct { + ID uint32 `json:"id"` + DomainID uint32 `json:"domain_id"` + Source string `json:"source"` + Destination string `json:"destination"` +} + +type VirtualDomain struct { + ID uint32 `json:"id"` + Name string `json:"name"` + CreatedAt sql.NullTime `json:"created_at"` +} + +type VirtualUser struct { + ID uint32 `json:"id"` + DomainID uint32 `json:"domain_id"` + Email string `json:"email"` + Password string `json:"password"` + Quota sql.NullInt64 `json:"quota"` +} diff --git a/backend/internal/db/sqlc/users.sql.go b/backend/internal/db/sqlc/users.sql.go new file mode 100644 index 0000000..0d8a4b1 --- /dev/null +++ b/backend/internal/db/sqlc/users.sql.go @@ -0,0 +1,180 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: users.sql + +package db + +import ( + "context" + "database/sql" +) + +const countUsersByDomain = `-- name: CountUsersByDomain :one +SELECT COUNT(*) as count FROM virtual_users WHERE domain_id = ? +` + +func (q *Queries) CountUsersByDomain(ctx context.Context, domainID uint32) (int64, error) { + row := q.db.QueryRowContext(ctx, countUsersByDomain, domainID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createUser = `-- name: CreateUser :exec +INSERT INTO virtual_users (domain_id, email, password, quota) VALUES (?, ?, ?, ?) +` + +type CreateUserParams struct { + DomainID uint32 `json:"domain_id"` + Email string `json:"email"` + Password string `json:"password"` + Quota sql.NullInt64 `json:"quota"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error { + _, err := q.db.ExecContext(ctx, createUser, + arg.DomainID, + arg.Email, + arg.Password, + arg.Quota, + ) + return err +} + +const deleteUser = `-- name: DeleteUser :exec +DELETE FROM virtual_users WHERE id = ? +` + +func (q *Queries) DeleteUser(ctx context.Context, id uint32) error { + _, err := q.db.ExecContext(ctx, deleteUser, id) + return err +} + +const getAllMailUsers = `-- name: GetAllMailUsers :many +SELECT id, domain_id, email, password, quota FROM virtual_users +` + +func (q *Queries) GetAllMailUsers(ctx context.Context) ([]VirtualUser, error) { + rows, err := q.db.QueryContext(ctx, getAllMailUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []VirtualUser + for rows.Next() { + var i VirtualUser + if err := rows.Scan( + &i.ID, + &i.DomainID, + &i.Email, + &i.Password, + &i.Quota, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, domain_id, email, password, quota FROM virtual_users WHERE email = ? +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (VirtualUser, error) { + row := q.db.QueryRowContext(ctx, getUserByEmail, email) + var i VirtualUser + err := row.Scan( + &i.ID, + &i.DomainID, + &i.Email, + &i.Password, + &i.Quota, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, domain_id, email, password, quota FROM virtual_users WHERE id = ? +` + +func (q *Queries) GetUserByID(ctx context.Context, id uint32) (VirtualUser, error) { + row := q.db.QueryRowContext(ctx, getUserByID, id) + var i VirtualUser + err := row.Scan( + &i.ID, + &i.DomainID, + &i.Email, + &i.Password, + &i.Quota, + ) + return i, err +} + +const getUsersByDomain = `-- name: GetUsersByDomain :many +SELECT id, domain_id, email, password, quota FROM virtual_users WHERE domain_id = ? +` + +func (q *Queries) GetUsersByDomain(ctx context.Context, domainID uint32) ([]VirtualUser, error) { + rows, err := q.db.QueryContext(ctx, getUsersByDomain, domainID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []VirtualUser + for rows.Next() { + var i VirtualUser + if err := rows.Scan( + &i.ID, + &i.DomainID, + &i.Email, + &i.Password, + &i.Quota, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUserPassword = `-- name: UpdateUserPassword :exec +UPDATE virtual_users SET password = ? WHERE id = ? +` + +type UpdateUserPasswordParams struct { + Password string `json:"password"` + ID uint32 `json:"id"` +} + +func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error { + _, err := q.db.ExecContext(ctx, updateUserPassword, arg.Password, arg.ID) + return err +} + +const updateUserQuota = `-- name: UpdateUserQuota :exec +UPDATE virtual_users SET quota = ? WHERE id = ? +` + +type UpdateUserQuotaParams struct { + Quota sql.NullInt64 `json:"quota"` + ID uint32 `json:"id"` +} + +func (q *Queries) UpdateUserQuota(ctx context.Context, arg UpdateUserQuotaParams) error { + _, err := q.db.ExecContext(ctx, updateUserQuota, arg.Quota, arg.ID) + return err +} diff --git a/backend/internal/db/virtual_aliases.go b/backend/internal/db/virtual_aliases.go index 465b6b8..70e51ed 100644 --- a/backend/internal/db/virtual_aliases.go +++ b/backend/internal/db/virtual_aliases.go @@ -1,115 +1,35 @@ -// Package db provides database access for the IMC application. -// It uses GORM (Go ORM) for database operations. - package db -// ============================================================================= -// VirtualAlias - Mail alias operations -// These functions interact with the ISPmail virtual_aliases table. -// ============================================================================= +import ( + "context" -// GetAllAliases retrieves all email aliases with their domain names. -// Returns a slice of AliasWithDomain (includes domain name) or an error. -func (d *DB) GetAllAliases() ([]AliasWithDomain, error) { - var aliases []VirtualAlias - err := d.Preload("Domain").Find(&aliases).Error - if err != nil { - return nil, err - } + imcdb "git.workaround.org/chaas/imc/backend/internal/db/sqlc" +) - result := make([]AliasWithDomain, len(aliases)) - for i, a := range aliases { - result[i] = AliasWithDomain{ - VirtualAlias: a, - DomainName: "", - } - if a.Domain != nil { - result[i].DomainName = a.Domain.Name - } - } - - if result == nil { - result = []AliasWithDomain{} - } - return result, nil +func (d *DB) GetAllAliases(ctx context.Context) ([]imcdb.VirtualAlias, error) { + return d.Queries.GetAllAliases(ctx) } -// GetAliasesByDomain retrieves all aliases belonging to a specific domain. -// domainID: the ID of the domain. -// Returns a slice of VirtualAlias or an error. -func (d *DB) GetAliasesByDomain(domainID uint) ([]VirtualAlias, error) { - var aliases []VirtualAlias - err := d.Where("domain_id = ?", domainID).Find(&aliases).Error - if err != nil { - return nil, err - } - if aliases == nil { - aliases = []VirtualAlias{} - } - return aliases, nil +func (d *DB) GetAliasesByDomain(ctx context.Context, domainID uint32) ([]imcdb.VirtualAlias, error) { + return d.Queries.GetAliasesByDomain(ctx, domainID) } -// GetAliasByID retrieves a single alias by its ID. -// id: the alias's ID. -// Returns the VirtualAlias or an error (including "record not found"). -func (d *DB) GetAliasByID(id uint) (*VirtualAlias, error) { - var alias VirtualAlias - err := d.Where("id = ?", id).Preload("Domain").First(&alias).Error - if err != nil { - return nil, err - } - return &alias, nil +func (d *DB) GetAliasByID(ctx context.Context, id uint32) (imcdb.VirtualAlias, error) { + return d.Queries.GetAliasByID(ctx, id) } -// GetAliasBySource looks up an alias by its source address. -// source: the source email address (the alias). -// Returns the VirtualAlias or an error (including "record not found"). -func (d *DB) GetAliasBySource(source string) (*VirtualAlias, error) { - var alias VirtualAlias - err := d.Where("source = ?", source).First(&alias).Error - if err != nil { - return nil, err - } - return &alias, nil +func (d *DB) GetAliasBySource(ctx context.Context, source string) (imcdb.VirtualAlias, error) { + return d.Queries.GetAliasBySource(ctx, source) } -// CreateAlias creates a new email alias without a domain association. -// source: the source email address (the alias). -// destination: the destination email address (where mail is forwarded to). -// Returns the created VirtualAlias or an error. -func (d *DB) CreateAlias(source, destination string) (*VirtualAlias, error) { - alias := VirtualAlias{ - Source: source, - Destination: destination, - } - err := d.Create(&alias).Error - if err != nil { - return nil, err - } - return &alias, nil -} - -// CreateAliasInDomain creates a new email alias in a specific domain. -// source: the source email address (the alias). -// destination: the destination email address (where mail is forwarded to). -// domainID: the ID of the domain this alias belongs to. -// Returns the created VirtualAlias or an error. -func (d *DB) CreateAliasInDomain(source, destination string, domainID uint) (*VirtualAlias, error) { - alias := VirtualAlias{ +func (d *DB) CreateAlias(ctx context.Context, domainID uint32, source, destination string) error { + return d.Queries.CreateAlias(ctx, imcdb.CreateAliasParams{ DomainID: domainID, Source: source, Destination: destination, - } - err := d.Create(&alias).Error - if err != nil { - return nil, err - } - return &alias, nil + }) } -// DeleteAlias permanently removes an alias from the database. -// id: the alias's ID to delete. -// Returns an error if the deletion fails. -func (d *DB) DeleteAlias(id uint) error { - return d.Delete(&VirtualAlias{}, id).Error +func (d *DB) DeleteAlias(ctx context.Context, id uint32) error { + return d.Queries.DeleteAlias(ctx, id) } diff --git a/backend/internal/db/virtual_domains.go b/backend/internal/db/virtual_domains.go index 90024c5..2c9c406 100644 --- a/backend/internal/db/virtual_domains.go +++ b/backend/internal/db/virtual_domains.go @@ -1,98 +1,45 @@ -// Package db provides database access for the IMC application. -// It uses GORM (Go ORM) for database operations. - package db -// ============================================================================= -// VirtualDomain - Mail domain operations -// These functions interact with the ISPmail virtual_domains table. -// ============================================================================= +import ( + "context" -// GetAllDomains retrieves all domains with user and alias counts. -// Returns a slice of DomainStats (includes counts) or an error. -func (d *DB) GetAllDomains() ([]DomainStats, error) { - var domains []VirtualDomain - err := d.Order("name ASC").Find(&domains).Error + imcdb "git.workaround.org/chaas/imc/backend/internal/db/sqlc" +) + +func (d *DB) GetAllDomains(ctx context.Context) ([]imcdb.VirtualDomain, error) { + return d.Queries.GetAllDomains(ctx) +} + +func (d *DB) GetDomainByID(ctx context.Context, id uint32) (imcdb.VirtualDomain, error) { + return d.Queries.GetDomainByID(ctx, id) +} + +func (d *DB) GetDomainByName(ctx context.Context, name string) (imcdb.VirtualDomain, error) { + return d.Queries.GetDomainByName(ctx, name) +} + +func (d *DB) CreateDomain(ctx context.Context, name string) error { + return d.Queries.CreateDomain(ctx, name) +} + +func (d *DB) DeleteDomain(ctx context.Context, id uint32) error { + return d.Queries.DeleteDomain(ctx, id) +} + +func (d *DB) GetAllDomainsWithCounts(ctx context.Context) ([]DomainStats, error) { + results, err := d.Queries.GetAllDomainsWithCounts(ctx) if err != nil { return nil, err } - stats := make([]DomainStats, len(domains)) - for i, dom := range domains { - var userCount, aliasCount int64 - d.Model(&VirtualUser{}).Where("domain_id = ?", dom.ID).Count(&userCount) - d.Model(&VirtualAlias{}).Where("domain_id = ?", dom.ID).Count(&aliasCount) - + stats := make([]DomainStats, len(results)) + for i, r := range results { stats[i] = DomainStats{ - ID: dom.ID, - Name: dom.Name, - UserCount: userCount, - AliasCount: aliasCount, + ID: r.ID, + Name: r.Name, + UserCount: r.UserCount, + AliasCount: r.AliasCount, } } - - if stats == nil { - stats = []DomainStats{} - } return stats, nil } - -// GetDomainByID retrieves a single domain by its ID. -// id: the domain's ID. -// Returns the VirtualDomain or an error (including "record not found"). -func (d *DB) GetDomainByID(id uint) (*VirtualDomain, error) { - var domain VirtualDomain - err := d.Where("id = ?", id).First(&domain).Error - if err != nil { - return nil, err - } - return &domain, nil -} - -// GetDomainByName retrieves a single domain by its name. -// name: the domain name (e.g., "example.org"). -// Returns the VirtualDomain or an error (including "record not found"). -func (d *DB) GetDomainByName(name string) (*VirtualDomain, error) { - var domain VirtualDomain - err := d.Where("name = ?", name).First(&domain).Error - if err != nil { - return nil, err - } - return &domain, nil -} - -// CreateDomain creates a new mail domain. -// name: the domain name (e.g., "example.org"). -// Returns the created VirtualDomain or an error. -func (d *DB) CreateDomain(name string) (*VirtualDomain, error) { - domain := VirtualDomain{Name: name} - err := d.Create(&domain).Error - if err != nil { - return nil, err - } - return &domain, nil -} - -// DeleteDomain permanently removes a domain and all associated users and aliases. -// id: the domain's ID to delete. -// This also deletes all virtual_users and virtual_aliases belonging to this domain. -// Returns an error if the deletion fails. -func (d *DB) DeleteDomain(id uint) error { - tx := d.Begin() - defer func() { tx.Rollback() }() - - err := tx.Where("domain_id = ?", id).Delete(&VirtualUser{}).Error - if err != nil { - return err - } - err = tx.Where("domain_id = ?", id).Delete(&VirtualAlias{}).Error - if err != nil { - return err - } - err = tx.Delete(&VirtualDomain{}, id).Error - if err != nil { - return err - } - - return tx.Commit().Error -} diff --git a/backend/internal/db/virtual_users.go b/backend/internal/db/virtual_users.go index ee7434f..26169ed 100644 --- a/backend/internal/db/virtual_users.go +++ b/backend/internal/db/virtual_users.go @@ -1,120 +1,54 @@ -// Package db provides database access for the IMC application. -// It uses GORM (Go ORM) for database operations. - package db -// ============================================================================= -// VirtualUser - Mail user operations -// These functions interact with the ISPmail virtual_users table. -// ============================================================================= +import ( + "context" -// GetAllMailUsers retrieves all mail users from the database. -// Returns a slice of VirtualUser or an error. -func (d *DB) GetAllMailUsers() ([]VirtualUser, error) { - var users []VirtualUser - if err := d.Preload("Domain").Find(&users).Error; err != nil { - return nil, err - } - if users == nil { - users = []VirtualUser{} - } - return users, nil + "database/sql" + + imcdb "git.workaround.org/chaas/imc/backend/internal/db/sqlc" +) + +func (d *DB) GetAllMailUsers(ctx context.Context) ([]imcdb.VirtualUser, error) { + return d.Queries.GetAllMailUsers(ctx) } -// GetUsersByDomain retrieves all mail users belonging to a specific domain. -// domainID: the ID of the domain. -// Returns a slice of VirtualUser or an error. -func (d *DB) GetUsersByDomain(domainID uint) ([]VirtualUser, error) { - var users []VirtualUser - err := d.Where("domain_id = ?", domainID).Find(&users).Error - if err != nil { - return nil, err - } - if users == nil { - users = []VirtualUser{} - } - return users, nil +func (d *DB) GetUsersByDomain(ctx context.Context, domainID uint32) ([]imcdb.VirtualUser, error) { + return d.Queries.GetUsersByDomain(ctx, domainID) } -// GetUserByID retrieves a single mail user by their ID. -// id: the user's ID. -// Returns the VirtualUser or an error (including "record not found"). -func (d *DB) GetUserByID(id uint) (*VirtualUser, error) { - var user VirtualUser - err := d.Where("id = ?", id).Preload("Domain").First(&user).Error - if err != nil { - return nil, err - } - return &user, nil +func (d *DB) GetUserByID(ctx context.Context, id uint32) (imcdb.VirtualUser, error) { + return d.Queries.GetUserByID(ctx, id) } -// GetUserByEmail looks up a mail user by their email address. -// email: the full email address (e.g., "user@example.org"). -// Returns the VirtualUser or an error (including "record not found"). -func (d *DB) GetUserByEmail(email string) (*VirtualUser, error) { - var user VirtualUser - err := d.Where("email = ?", email).First(&user).Error - if err != nil { - return nil, err - } - return &user, nil +func (d *DB) GetUserByEmail(ctx context.Context, email string) (imcdb.VirtualUser, error) { + return d.Queries.GetUserByEmail(ctx, email) } -// CreateUser creates a new mail user without a domain association. -// email: the full email address. -// passwordHash: the bcrypt hash of the user's password. -// quota: mailbox size limit in bytes (0 = use system default). -// Returns the created VirtualUser or an error. -func (d *DB) CreateUser(email, passwordHash string, quota int64) (*VirtualUser, error) { - user := VirtualUser{ - Email: email, - Password: passwordHash, - Quota: quota, - } - if err := d.Create(&user).Error; err != nil { - return nil, err - } - return &user, nil -} - -// CreateUserInDomain creates a new mail user in a specific domain. -// email: the full email address. -// passwordHash: the bcrypt hash of the user's password. -// quota: mailbox size limit in bytes (0 = use system default). -// domainID: the ID of the domain this user belongs to. -// Returns the created VirtualUser or an error. -func (d *DB) CreateUserInDomain(email, passwordHash string, quota int64, domainID uint) (*VirtualUser, error) { - user := VirtualUser{ +func (d *DB) CreateUser(ctx context.Context, domainID uint32, email, passwordHash string, quota int64) error { + quotaNull := sql.NullInt64{Int64: quota, Valid: quota > 0} + return d.Queries.CreateUser(ctx, imcdb.CreateUserParams{ DomainID: domainID, Email: email, Password: passwordHash, - Quota: quota, - } - if err := d.Create(&user).Error; err != nil { - return nil, err - } - return &user, nil + Quota: quotaNull, + }) } -// UpdateUserPassword updates the password for a mail user. -// id: the user's ID. -// passwordHash: the new bcrypt hash of the password. -// Returns an error if the update fails. -func (d *DB) UpdateUserPassword(id uint, passwordHash string) error { - return d.Model(&VirtualUser{}).Updates(map[string]any{"password": passwordHash}).Error +func (d *DB) UpdateUserPassword(ctx context.Context, id uint32, passwordHash string) error { + return d.Queries.UpdateUserPassword(ctx, imcdb.UpdateUserPasswordParams{ + Password: passwordHash, + ID: id, + }) } -// UpdateUserQuota updates the mailbox quota for a mail user. -// id: the user's ID. -// quota: new quota in bytes (0 = use system default). -// Returns an error if the update fails. -func (d *DB) UpdateUserQuota(id uint, quota int64) error { - return d.Model(&VirtualUser{}).Updates(map[string]any{"quota": quota}).Error +func (d *DB) UpdateUserQuota(ctx context.Context, id uint32, quota int64) error { + quotaNull := sql.NullInt64{Int64: quota, Valid: quota > 0} + return d.Queries.UpdateUserQuota(ctx, imcdb.UpdateUserQuotaParams{ + Quota: quotaNull, + ID: id, + }) } -// DeleteUser permanently removes a mail user from the database. -// id: the user's ID to delete. -// Returns an error if the deletion fails. -func (d *DB) DeleteUser(id uint) error { - return d.Delete(&VirtualUser{}, id).Error +func (d *DB) DeleteUser(ctx context.Context, id uint32) error { + return d.Queries.DeleteUser(ctx, id) } diff --git a/backend/sqlc.yaml b/backend/sqlc.yaml new file mode 100644 index 0000000..83c02dd --- /dev/null +++ b/backend/sqlc.yaml @@ -0,0 +1,13 @@ +version: "2" +sql: + - engine: "mysql" + queries: "./internal/db/queries" + schema: "./internal/db/queries/schema.sql" + gen: + go: + package: "db" + out: "./internal/db/sqlc" + sql_package: "database/sql" + emit_json_tags: true + emit_interface: false + emit_exact_table_names: false