Add Migadu domain admin with shared lists, compaction, and safer apply.

Build the Go API and React UI for managing domain denylist/allowlist/recipient
deny and spam settings, plus shared lists that import/apply across domains with
wildcard compaction, entry verification, and Migadu-friendly list encoding
(including per-domain recipient filtering and rejection bisect on apply).
This commit is contained in:
2026-07-26 21:50:26 +02:00
parent 930f1f7b69
commit a79cb61104
55 changed files with 7600 additions and 1 deletions
+469
View File
@@ -0,0 +1,469 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/squid/MigaduAdmin/internal/lists"
_ "modernc.org/sqlite"
)
var (
ErrNotFound = errors.New("not found")
ErrEmailTaken = errors.New("email already registered")
ErrInvalidInput = errors.New("invalid input")
ErrDomainExists = errors.New("domain already managed")
)
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role Role `json:"role"`
PasswordHash string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
type Session struct {
ID string
UserID int64
ExpiresAt time.Time
CreatedAt time.Time
}
type ManagedDomain struct {
Name string `json:"name"`
AddedAt time.Time `json:"added_at"`
AddedBy *int64 `json:"added_by,omitempty"`
}
// SharedLists holds denylist/allowlist entries applied across managed domains.
type SharedLists struct {
SenderDenylist []string `json:"sender_denylist"`
SenderAllowlist []string `json:"sender_allowlist"`
RecipientDenylist []string `json:"recipient_denylist"`
}
type Store struct {
db *sql.DB
}
func Open(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create database directory: %w", err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
store := &Store{db: db}
if err := store.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return store, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
const schema = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL,
password_hash TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS managed_domains (
name TEXT PRIMARY KEY COLLATE NOCASE,
added_at TEXT NOT NULL,
added_by INTEGER REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS shared_lists (
id INTEGER PRIMARY KEY CHECK (id = 1),
sender_denylist TEXT NOT NULL DEFAULT '[]',
sender_allowlist TEXT NOT NULL DEFAULT '[]',
recipient_denylist TEXT NOT NULL DEFAULT '[]'
);
INSERT OR IGNORE INTO shared_lists (id, sender_denylist, sender_allowlist, recipient_denylist)
VALUES (1, '[]', '[]', '[]');
`
_, err := s.db.Exec(schema)
return err
}
func (s *Store) CountUsers(ctx context.Context) (int, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count)
return count, err
}
func (s *Store) CreateUser(ctx context.Context, email, displayName, passwordHash string, role Role) (*User, error) {
email = normalizeEmail(email)
displayName = strings.TrimSpace(displayName)
if email == "" {
return nil, ErrInvalidInput
}
if displayName == "" {
displayName = email
}
if role != RoleAdmin && role != RoleUser {
return nil, ErrInvalidInput
}
now := time.Now().UTC()
result, err := s.db.ExecContext(ctx, `
INSERT INTO users (email, display_name, role, password_hash, created_at)
VALUES (?, ?, ?, ?, ?)`,
email, displayName, string(role), passwordHash, now.Format(time.RFC3339Nano),
)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return nil, ErrEmailTaken
}
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return &User{
ID: id,
Email: email,
DisplayName: displayName,
Role: role,
PasswordHash: passwordHash,
CreatedAt: now,
}, nil
}
func (s *Store) GetUserByID(ctx context.Context, id int64) (*User, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, email, display_name, role, password_hash, created_at
FROM users WHERE id = ?`, id)
return scanUser(row)
}
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*User, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, email, display_name, role, password_hash, created_at
FROM users WHERE email = ? COLLATE NOCASE`, normalizeEmail(email))
return scanUser(row)
}
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, email, display_name, role, password_hash, created_at
FROM users ORDER BY id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]User, 0)
for rows.Next() {
user, err := scanUser(rows)
if err != nil {
return nil, err
}
users = append(users, *user)
}
return users, rows.Err()
}
func (s *Store) CreateSession(ctx context.Context, id string, userID int64, expiresAt time.Time) error {
now := time.Now().UTC()
_, err := s.db.ExecContext(ctx, `
INSERT INTO sessions (id, user_id, expires_at, created_at)
VALUES (?, ?, ?, ?)`,
id, userID, expiresAt.UTC().Format(time.RFC3339Nano), now.Format(time.RFC3339Nano),
)
return err
}
func (s *Store) GetSession(ctx context.Context, id string) (*Session, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?`, id)
var session Session
var expiresAt, createdAt string
if err := row.Scan(&session.ID, &session.UserID, &expiresAt, &createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
var err error
session.ExpiresAt, err = time.Parse(time.RFC3339Nano, expiresAt)
if err != nil {
return nil, err
}
session.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt)
if err != nil {
return nil, err
}
return &session, nil
}
func (s *Store) DeleteSession(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, id)
return err
}
func (s *Store) DeleteExpiredSessions(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `
DELETE FROM sessions WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339Nano))
return err
}
func normalizeDomainName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func (s *Store) AddManagedDomain(ctx context.Context, name string, addedBy int64) (*ManagedDomain, error) {
name = normalizeDomainName(name)
if name == "" || !strings.Contains(name, ".") {
return nil, ErrInvalidInput
}
now := time.Now().UTC()
_, err := s.db.ExecContext(ctx, `
INSERT INTO managed_domains (name, added_at, added_by)
VALUES (?, ?, ?)`, name, now.Format(time.RFC3339Nano), addedBy)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return nil, ErrDomainExists
}
return nil, err
}
return &ManagedDomain{
Name: name,
AddedAt: now,
AddedBy: &addedBy,
}, nil
}
func (s *Store) ListManagedDomains(ctx context.Context) ([]ManagedDomain, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT name, added_at, added_by FROM managed_domains ORDER BY name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
domains := make([]ManagedDomain, 0)
for rows.Next() {
var domain ManagedDomain
var addedAt string
var addedBy sql.NullInt64
if err := rows.Scan(&domain.Name, &addedAt, &addedBy); err != nil {
return nil, err
}
parsed, err := time.Parse(time.RFC3339Nano, addedAt)
if err != nil {
return nil, err
}
domain.AddedAt = parsed
if addedBy.Valid {
value := addedBy.Int64
domain.AddedBy = &value
}
domains = append(domains, domain)
}
return domains, rows.Err()
}
func (s *Store) GetManagedDomain(ctx context.Context, name string) (*ManagedDomain, error) {
row := s.db.QueryRowContext(ctx, `
SELECT name, added_at, added_by FROM managed_domains WHERE name = ? COLLATE NOCASE`,
normalizeDomainName(name))
var domain ManagedDomain
var addedAt string
var addedBy sql.NullInt64
if err := row.Scan(&domain.Name, &addedAt, &addedBy); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
parsed, err := time.Parse(time.RFC3339Nano, addedAt)
if err != nil {
return nil, err
}
domain.AddedAt = parsed
if addedBy.Valid {
value := addedBy.Int64
domain.AddedBy = &value
}
return &domain, nil
}
func (s *Store) IsManagedDomain(ctx context.Context, name string) (bool, error) {
_, err := s.GetManagedDomain(ctx, name)
if err == nil {
return true, nil
}
if errors.Is(err, ErrNotFound) {
return false, nil
}
return false, err
}
func (s *Store) DeleteManagedDomain(ctx context.Context, name string) error {
result, err := s.db.ExecContext(ctx, `
DELETE FROM managed_domains WHERE name = ? COLLATE NOCASE`, normalizeDomainName(name))
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) GetSharedLists(ctx context.Context) (*SharedLists, error) {
row := s.db.QueryRowContext(ctx, `
SELECT sender_denylist, sender_allowlist, recipient_denylist
FROM shared_lists WHERE id = 1`)
var denylistJSON, allowlistJSON, recipientJSON string
if err := row.Scan(&denylistJSON, &allowlistJSON, &recipientJSON); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return &SharedLists{
SenderDenylist: []string{},
SenderAllowlist: []string{},
RecipientDenylist: []string{},
}, nil
}
return nil, err
}
lists := &SharedLists{}
if err := unmarshalStringList(denylistJSON, &lists.SenderDenylist); err != nil {
return nil, err
}
if err := unmarshalStringList(allowlistJSON, &lists.SenderAllowlist); err != nil {
return nil, err
}
if err := unmarshalStringList(recipientJSON, &lists.RecipientDenylist); err != nil {
return nil, err
}
return lists, nil
}
func (s *Store) PutSharedLists(ctx context.Context, lists SharedLists) error {
denylistJSON, err := marshalStringList(lists.SenderDenylist)
if err != nil {
return err
}
allowlistJSON, err := marshalStringList(lists.SenderAllowlist)
if err != nil {
return err
}
recipientJSON, err := marshalStringList(lists.RecipientDenylist)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO shared_lists (id, sender_denylist, sender_allowlist, recipient_denylist)
VALUES (1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
sender_denylist = excluded.sender_denylist,
sender_allowlist = excluded.sender_allowlist,
recipient_denylist = excluded.recipient_denylist`,
denylistJSON, allowlistJSON, recipientJSON,
)
return err
}
func marshalStringList(values []string) (string, error) {
normalized := normalizeStringList(values)
data, err := json.Marshal(normalized)
if err != nil {
return "", err
}
return string(data), nil
}
func unmarshalStringList(raw string, dest *[]string) error {
if strings.TrimSpace(raw) == "" {
*dest = []string{}
return nil
}
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return err
}
*dest = normalizeStringList(values)
return nil
}
func normalizeStringList(values []string) []string {
return lists.Compact(values)
}
type scannable interface {
Scan(dest ...any) error
}
func scanUser(row scannable) (*User, error) {
var user User
var role string
var createdAt string
if err := row.Scan(&user.ID, &user.Email, &user.DisplayName, &role, &user.PasswordHash, &createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
user.Role = Role(role)
parsed, err := time.Parse(time.RFC3339Nano, createdAt)
if err != nil {
return nil, err
}
user.CreatedAt = parsed
return &user, nil
}
func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}