188 lines
6.0 KiB
Go
188 lines
6.0 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
|
)
|
|
|
|
// GetUserByEmail returns the user with the given email (case-insensitive), or nil.
|
|
func (d *DB) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
|
|
row := d.db.QueryRowContext(ctx, `
|
|
SELECT id, domain_id, username, email, password_hash, display_name,
|
|
quota_bytes, used_bytes, enabled, admin, domain_admin, is_relay,
|
|
mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login
|
|
FROM users WHERE lower(email)=lower(?)`, email)
|
|
return scanUser(row)
|
|
}
|
|
|
|
// GetUserByID returns the user with the given ID, or nil.
|
|
func (d *DB) GetUserByID(ctx context.Context, id int64) (*models.User, error) {
|
|
row := d.db.QueryRowContext(ctx, `
|
|
SELECT id, domain_id, username, email, password_hash, display_name,
|
|
quota_bytes, used_bytes, enabled, admin, domain_admin, is_relay,
|
|
mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login
|
|
FROM users WHERE id=?`, id)
|
|
return scanUser(row)
|
|
}
|
|
|
|
// UserExistsByEmail returns true if any user (enabled or not) has this email or alias.
|
|
func (d *DB) UserExistsByEmail(ctx context.Context, email string) (bool, error) {
|
|
var count int
|
|
err := d.db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM (
|
|
SELECT 1 FROM users WHERE lower(email)=lower(?) AND enabled=1
|
|
UNION ALL
|
|
SELECT 1 FROM user_aliases WHERE lower(alias_email)=lower(?)
|
|
)`, email, email).Scan(&count)
|
|
return count > 0, err
|
|
}
|
|
|
|
// ResolveEmail returns the canonical user for an email or alias, or nil.
|
|
func (d *DB) ResolveEmail(ctx context.Context, email string) (*models.User, error) {
|
|
// Direct match first.
|
|
u, err := d.GetUserByEmail(ctx, email)
|
|
if err != nil || u != nil {
|
|
return u, err
|
|
}
|
|
|
|
// Alias match.
|
|
var userID int64
|
|
err = d.db.QueryRowContext(ctx,
|
|
"SELECT user_id FROM user_aliases WHERE lower(alias_email)=lower(?)", email).
|
|
Scan(&userID)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return d.GetUserByID(ctx, userID)
|
|
}
|
|
|
|
// HasAdminUser returns true if at least one enabled admin user exists.
|
|
func (d *DB) HasAdminUser(ctx context.Context) (bool, error) {
|
|
var n int
|
|
err := d.db.QueryRowContext(ctx,
|
|
"SELECT COUNT(*) FROM users WHERE admin=1 AND enabled=1").Scan(&n)
|
|
return n > 0, err
|
|
}
|
|
|
|
// CreateUser inserts a new user. Returns the new ID.
|
|
func (d *DB) CreateUser(ctx context.Context, domainID int64, username, email, passwordHash, displayName string, quotaBytes int64, domainAdmin bool) (int64, error) {
|
|
res, err := d.db.ExecContext(ctx, `
|
|
INSERT INTO users
|
|
(domain_id, username, email, password_hash, display_name, quota_bytes,
|
|
enabled, admin, domain_admin, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, 1, 0, ?, ?)`,
|
|
domainID, username, email, passwordHash, displayName, quotaBytes, domainAdmin,
|
|
time.Now().UTC())
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create user: %w", err)
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
// CreateAdminUser inserts the first superadmin (admin=1). Used only during initial setup.
|
|
func (d *DB) CreateAdminUser(ctx context.Context, domainID int64, email, passwordHash string) (int64, error) {
|
|
username := email
|
|
res, err := d.db.ExecContext(ctx, `
|
|
INSERT INTO users
|
|
(domain_id, username, email, password_hash, display_name, quota_bytes,
|
|
enabled, admin, domain_admin, created_at)
|
|
VALUES (?, ?, ?, ?, ?, 0, 1, 1, 1, ?)`,
|
|
domainID, username, email, passwordHash, email, time.Now().UTC())
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create admin user: %w", err)
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
// UpdateUsedBytes sets the cached used_bytes for a user (approximate, updated on store).
|
|
func (d *DB) UpdateUsedBytes(ctx context.Context, userID int64, delta int64) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE users SET used_bytes = MAX(0, used_bytes + ?) WHERE id=?",
|
|
delta, userID)
|
|
return err
|
|
}
|
|
|
|
// UpdateLastLogin sets last_login to now.
|
|
func (d *DB) UpdateLastLogin(ctx context.Context, userID int64) {
|
|
d.db.ExecContext(ctx, //nolint:errcheck — best-effort
|
|
"UPDATE users SET last_login=? WHERE id=?", time.Now().UTC(), userID)
|
|
}
|
|
|
|
// ListUsers returns all users for a domain.
|
|
func (d *DB) ListUsers(ctx context.Context, domainID int64) ([]*models.User, error) {
|
|
rows, err := d.db.QueryContext(ctx, `
|
|
SELECT id, domain_id, username, email, password_hash, display_name,
|
|
quota_bytes, used_bytes, enabled, admin, domain_admin, is_relay,
|
|
mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login
|
|
FROM users WHERE domain_id=? ORDER BY email`, domainID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var users []*models.User
|
|
for rows.Next() {
|
|
var u models.User
|
|
var mfaEnc, rcEnc []byte
|
|
var lastLogin sql.NullTime
|
|
err := rows.Scan(
|
|
&u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash,
|
|
&u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled,
|
|
&u.Admin, &u.DomainAdmin, &u.IsRelay,
|
|
&mfaEnc, &u.MFAEnabled, &rcEnc,
|
|
&u.CreatedAt, &lastLogin,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u.MFASecretEnc = mfaEnc
|
|
u.RecoveryCodesEnc = rcEnc
|
|
if lastLogin.Valid {
|
|
u.LastLogin = lastLogin.Time
|
|
}
|
|
users = append(users, &u)
|
|
}
|
|
return users, rows.Err()
|
|
}
|
|
|
|
// SetUserIsRelay sets the is_relay flag for a user.
|
|
func (d *DB) SetUserIsRelay(ctx context.Context, userID int64, isRelay bool) error {
|
|
_, err := d.db.ExecContext(ctx, "UPDATE users SET is_relay=? WHERE id=?", isRelay, userID)
|
|
return err
|
|
}
|
|
|
|
// ---- private ----
|
|
|
|
func scanUser(row *sql.Row) (*models.User, error) {
|
|
var u models.User
|
|
var mfaEnc, rcEnc []byte
|
|
var lastLogin sql.NullTime
|
|
|
|
err := row.Scan(
|
|
&u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash,
|
|
&u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled,
|
|
&u.Admin, &u.DomainAdmin, &u.IsRelay,
|
|
&mfaEnc, &u.MFAEnabled, &rcEnc,
|
|
&u.CreatedAt, &lastLogin,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan user: %w", err)
|
|
}
|
|
u.MFASecretEnc = mfaEnc
|
|
u.RecoveryCodesEnc = rcEnc
|
|
if lastLogin.Valid {
|
|
u.LastLogin = lastLogin.Time
|
|
}
|
|
return &u, nil
|
|
}
|