374 lines
11 KiB
Go
374 lines
11 KiB
Go
// Package db — admin-specific queries: queue, ip_bans, security_events,
|
|
// and update/delete operations for users and domains.
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// ---- Types ----
|
|
|
|
// QueueEntry is a row from the delivery queue.
|
|
type QueueEntry struct {
|
|
ID int64
|
|
DomainID sql.NullInt64
|
|
FromAddr string
|
|
ToAddr string
|
|
MessageID string
|
|
Status string // pending | failed | sent
|
|
Attempts int
|
|
LastAttempt sql.NullTime
|
|
NextAttempt time.Time
|
|
ErrorLog string
|
|
CreatedAt time.Time
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// IPBan is a row from ip_bans.
|
|
type IPBan struct {
|
|
ID int64
|
|
IP string
|
|
Reason string
|
|
BannedAt time.Time
|
|
ExpiresAt sql.NullTime
|
|
ReleasedBy string
|
|
}
|
|
|
|
// SecurityEvent is a row from security_events.
|
|
type SecurityEvent struct {
|
|
ID int64
|
|
Type string
|
|
IP string
|
|
UserID sql.NullInt64
|
|
Detail string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// AdminStats aggregates summary counts for the dashboard.
|
|
type AdminStats struct {
|
|
TotalDomains int
|
|
TotalUsers int
|
|
TotalMessages int
|
|
QueuePending int
|
|
QueueFailed int
|
|
ActiveBans int
|
|
RecentEvents int // last 24h
|
|
}
|
|
|
|
// ---- Queue ----
|
|
|
|
// ListQueueEntries returns all non-sent queue entries ordered by created_at desc.
|
|
func (d *DB) ListQueueEntries(ctx context.Context) ([]*QueueEntry, error) {
|
|
rows, err := d.db.QueryContext(ctx, `
|
|
SELECT id, domain_id, from_addr, to_addr, message_id, status,
|
|
attempts, last_attempt, next_attempt, error_log, created_at, expires_at
|
|
FROM queue
|
|
WHERE status != 'sent'
|
|
ORDER BY created_at DESC
|
|
LIMIT 500`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []*QueueEntry
|
|
for rows.Next() {
|
|
q := &QueueEntry{}
|
|
err := rows.Scan(
|
|
&q.ID, &q.DomainID, &q.FromAddr, &q.ToAddr, &q.MessageID, &q.Status,
|
|
&q.Attempts, &q.LastAttempt, &q.NextAttempt, &q.ErrorLog, &q.CreatedAt, &q.ExpiresAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, q)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RetryQueueEntry resets a queue entry to pending with immediate next_attempt.
|
|
func (d *DB) RetryQueueEntry(ctx context.Context, id int64) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE queue SET status='pending', next_attempt=? WHERE id=?",
|
|
time.Now().UTC(), id)
|
|
return err
|
|
}
|
|
|
|
// DeleteQueueEntry removes a queue entry permanently.
|
|
func (d *DB) DeleteQueueEntry(ctx context.Context, id int64) error {
|
|
_, err := d.db.ExecContext(ctx, "DELETE FROM queue WHERE id=?", id)
|
|
return err
|
|
}
|
|
|
|
// ---- IP Bans ----
|
|
|
|
// ListIPBans returns all IP bans, active first.
|
|
func (d *DB) ListIPBans(ctx context.Context) ([]*IPBan, error) {
|
|
rows, err := d.db.QueryContext(ctx, `
|
|
SELECT id, ip, reason, banned_at, expires_at, released_by
|
|
FROM ip_bans
|
|
ORDER BY banned_at DESC
|
|
LIMIT 500`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []*IPBan
|
|
for rows.Next() {
|
|
b := &IPBan{}
|
|
err := rows.Scan(&b.ID, &b.IP, &b.Reason, &b.BannedAt, &b.ExpiresAt, &b.ReleasedBy)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, b)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// AddIPBan inserts a manual IP ban. hours=0 means permanent.
|
|
func (d *DB) AddIPBan(ctx context.Context, ip, reason string, hours int) error {
|
|
var expiresAt *time.Time
|
|
if hours > 0 {
|
|
t := time.Now().UTC().Add(time.Duration(hours) * time.Hour)
|
|
expiresAt = &t
|
|
}
|
|
_, err := d.db.ExecContext(ctx,
|
|
"INSERT OR REPLACE INTO ip_bans (ip, reason, banned_at, expires_at) VALUES (?, ?, ?, ?)",
|
|
ip, reason, time.Now().UTC(), expiresAt)
|
|
return err
|
|
}
|
|
|
|
// RemoveIPBan deletes a ban by IP address.
|
|
func (d *DB) RemoveIPBan(ctx context.Context, ip string) error {
|
|
_, err := d.db.ExecContext(ctx, "DELETE FROM ip_bans WHERE ip=?", ip)
|
|
return err
|
|
}
|
|
|
|
// ---- Security Events ----
|
|
|
|
// ListSecurityEvents returns recent security events.
|
|
func (d *DB) ListSecurityEvents(ctx context.Context, limit int) ([]*SecurityEvent, error) {
|
|
if limit <= 0 || limit > 1000 {
|
|
limit = 200
|
|
}
|
|
rows, err := d.db.QueryContext(ctx, `
|
|
SELECT id, type, ip, user_id, detail, created_at
|
|
FROM security_events
|
|
ORDER BY created_at DESC
|
|
LIMIT ?`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []*SecurityEvent
|
|
for rows.Next() {
|
|
ev := &SecurityEvent{}
|
|
err := rows.Scan(&ev.ID, &ev.Type, &ev.IP, &ev.UserID, &ev.Detail, &ev.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, ev)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// LogSecurityEvent inserts a security event record.
|
|
func (d *DB) LogSecurityEvent(ctx context.Context, eventType, ip string, userID *int64, detail string) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"INSERT INTO security_events (type, ip, user_id, detail, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
eventType, ip, userID, detail, time.Now().UTC())
|
|
return err
|
|
}
|
|
|
|
// ---- User admin operations ----
|
|
|
|
// SetUserEnabled enables or disables a user account.
|
|
func (d *DB) SetUserEnabled(ctx context.Context, userID int64, enabled bool) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE users SET enabled=? WHERE id=?", enabled, userID)
|
|
return err
|
|
}
|
|
|
|
// SetUserAdmin sets admin / domain_admin flags.
|
|
func (d *DB) SetUserAdmin(ctx context.Context, userID int64, admin, domainAdmin bool) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE users SET admin=?, domain_admin=? WHERE id=?", admin, domainAdmin, userID)
|
|
return err
|
|
}
|
|
|
|
// SetUserQuota updates quota_bytes.
|
|
func (d *DB) SetUserQuota(ctx context.Context, userID int64, quotaBytes int64) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE users SET quota_bytes=? WHERE id=?", quotaBytes, userID)
|
|
return err
|
|
}
|
|
|
|
// SetUserPassword replaces the bcrypt hash for a user.
|
|
func (d *DB) SetUserPassword(ctx context.Context, userID int64, hash string) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE users SET password_hash=? WHERE id=?", hash, userID)
|
|
return err
|
|
}
|
|
|
|
// SetUserDisplayName updates display_name.
|
|
func (d *DB) SetUserDisplayName(ctx context.Context, userID int64, name string) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE users SET display_name=? WHERE id=?", name, userID)
|
|
return err
|
|
}
|
|
|
|
// DeleteUser permanently deletes a user and all associated data (cascade).
|
|
func (d *DB) DeleteUser(ctx context.Context, userID int64) error {
|
|
_, err := d.db.ExecContext(ctx, "DELETE FROM users WHERE id=?", userID)
|
|
return err
|
|
}
|
|
|
|
// ListAllUsers returns users across all domains, joined with domain name.
|
|
func (d *DB) ListAllUsers(ctx context.Context) ([]*UserWithDomain, error) {
|
|
rows, err := d.db.QueryContext(ctx, `
|
|
SELECT u.id, u.domain_id, u.username, u.email, u.display_name,
|
|
u.quota_bytes, u.used_bytes, u.enabled, u.admin, u.domain_admin,
|
|
u.mfa_enabled, u.created_at, u.last_login,
|
|
d.name AS domain_name
|
|
FROM users u
|
|
LEFT JOIN domains d ON d.id = u.domain_id
|
|
ORDER BY d.name, u.email`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []*UserWithDomain
|
|
for rows.Next() {
|
|
u := &UserWithDomain{}
|
|
var lastLogin sql.NullTime
|
|
err := rows.Scan(
|
|
&u.ID, &u.DomainID, &u.Username, &u.Email, &u.DisplayName,
|
|
&u.QuotaBytes, &u.UsedBytes, &u.Enabled, &u.Admin, &u.DomainAdmin,
|
|
&u.MFAEnabled, &u.CreatedAt, &lastLogin,
|
|
&u.DomainName,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if lastLogin.Valid {
|
|
u.LastLogin = lastLogin.Time
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// UserWithDomain is a user row augmented with the domain name.
|
|
type UserWithDomain struct {
|
|
ID int64
|
|
DomainID int64
|
|
Username string
|
|
Email string
|
|
DisplayName string
|
|
DomainName string
|
|
QuotaBytes int64
|
|
UsedBytes int64
|
|
Enabled bool
|
|
Admin bool
|
|
DomainAdmin bool
|
|
MFAEnabled bool
|
|
CreatedAt time.Time
|
|
LastLogin time.Time
|
|
}
|
|
|
|
// ---- MFA operations ----
|
|
|
|
// SetMFASecret stores the encrypted TOTP secret. Passing nil clears the secret.
|
|
func (d *DB) SetMFASecret(ctx context.Context, userID int64, encSecret []byte) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE users SET mfa_secret_enc=? WHERE id=?", encSecret, userID)
|
|
return err
|
|
}
|
|
|
|
// SetMFAEnabled enables or disables TOTP for a user.
|
|
func (d *DB) SetMFAEnabled(ctx context.Context, userID int64, enabled bool) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE users SET mfa_enabled=? WHERE id=?", enabled, userID)
|
|
return err
|
|
}
|
|
|
|
// SetRecoveryCodes stores the encrypted recovery codes JSON.
|
|
func (d *DB) SetRecoveryCodes(ctx context.Context, userID int64, encCodes []byte) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE users SET recovery_codes_enc=? WHERE id=?", encCodes, userID)
|
|
return err
|
|
}
|
|
|
|
// ClearMFA disables MFA and removes the secret + recovery codes atomically.
|
|
func (d *DB) ClearMFA(ctx context.Context, userID int64) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE users SET mfa_enabled=0, mfa_secret_enc=NULL, recovery_codes_enc=NULL WHERE id=?",
|
|
userID)
|
|
return err
|
|
}
|
|
|
|
// ---- Domain admin operations ----
|
|
|
|
// SetDomainEnabled enables or disables a domain.
|
|
func (d *DB) SetDomainEnabled(ctx context.Context, domainID int64, enabled bool) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE domains SET enabled=? WHERE id=?", enabled, domainID)
|
|
return err
|
|
}
|
|
|
|
// SetDomainLimits updates max_users and max_quota_bytes.
|
|
func (d *DB) SetDomainLimits(ctx context.Context, domainID int64, maxUsers int, maxQuota int64) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE domains SET max_users=?, max_quota_bytes=? WHERE id=?",
|
|
maxUsers, maxQuota, domainID)
|
|
return err
|
|
}
|
|
|
|
// SetDomainDNS stores SPF and DMARC policy strings (informational, for display).
|
|
func (d *DB) SetDomainDNS(ctx context.Context, domainID int64, spf, dmarc string) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE domains SET spf_policy=?, dmarc_policy=? WHERE id=?", spf, dmarc, domainID)
|
|
return err
|
|
}
|
|
|
|
// DeleteDomain permanently removes a domain (cascade deletes users + their data).
|
|
func (d *DB) DeleteDomain(ctx context.Context, domainID int64) error {
|
|
_, err := d.db.ExecContext(ctx, "DELETE FROM domains WHERE id=?", domainID)
|
|
return err
|
|
}
|
|
|
|
// ---- Stats ----
|
|
|
|
// GetAdminStats returns aggregate counts for the admin dashboard.
|
|
func (d *DB) GetAdminStats(ctx context.Context) (*AdminStats, error) {
|
|
s := &AdminStats{}
|
|
|
|
queries := []struct {
|
|
dest *int
|
|
sql string
|
|
args []any
|
|
}{
|
|
{&s.TotalDomains, "SELECT COUNT(*) FROM domains WHERE enabled=1", nil},
|
|
{&s.TotalUsers, "SELECT COUNT(*) FROM users WHERE enabled=1", nil},
|
|
{&s.TotalMessages, "SELECT COUNT(*) FROM messages WHERE deleted_at IS NULL", nil},
|
|
{&s.QueuePending, "SELECT COUNT(*) FROM queue WHERE status='pending'", nil},
|
|
{&s.QueueFailed, "SELECT COUNT(*) FROM queue WHERE status='failed'", nil},
|
|
{&s.ActiveBans, "SELECT COUNT(*) FROM ip_bans WHERE (expires_at IS NULL OR expires_at > ?)", []any{time.Now().UTC()}},
|
|
{&s.RecentEvents, "SELECT COUNT(*) FROM security_events WHERE created_at > ?", []any{time.Now().UTC().Add(-24 * time.Hour)}},
|
|
}
|
|
|
|
for _, q := range queries {
|
|
err := d.db.QueryRowContext(ctx, q.sql, q.args...).Scan(q.dest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("admin stats: %w", err)
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|