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:
@@ -0,0 +1,758 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/squid/MigaduAdmin/internal/auth"
|
||||
"github.com/squid/MigaduAdmin/internal/config"
|
||||
"github.com/squid/MigaduAdmin/internal/lists"
|
||||
"github.com/squid/MigaduAdmin/internal/migadu"
|
||||
"github.com/squid/MigaduAdmin/internal/store"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
auth *auth.Service
|
||||
store *store.Store
|
||||
migadu *migadu.Client
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, authService *auth.Service, st *store.Store, migaduClient *migadu.Client) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
auth: authService,
|
||||
store: st,
|
||||
migadu: migaduClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Router(static http.Handler) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
// Do not use RealIP before auth: trusted-proxy checks need the immediate peer.
|
||||
r.Use(s.auth.StripUntrustedIdentityHeaders)
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Get("/health", s.handleHealth)
|
||||
|
||||
api.Route("/auth", func(ar chi.Router) {
|
||||
ar.Get("/status", s.handleAuthStatus)
|
||||
ar.Post("/register", s.handleRegister)
|
||||
ar.Post("/login", s.handleLogin)
|
||||
ar.Post("/logout", s.handleLogout)
|
||||
ar.With(s.auth.Middleware).Get("/me", s.handleMe)
|
||||
})
|
||||
|
||||
api.Group(func(protected chi.Router) {
|
||||
protected.Use(s.auth.Middleware)
|
||||
protected.Use(s.auth.RequireAdmin)
|
||||
|
||||
protected.Get("/managed-domains", s.handleListManagedDomains)
|
||||
protected.Post("/managed-domains", s.handleAddManagedDomain)
|
||||
protected.Delete("/managed-domains/{domain}", s.handleDeleteManagedDomain)
|
||||
|
||||
protected.Get("/shared-lists", s.handleGetSharedLists)
|
||||
protected.Put("/shared-lists", s.handlePutSharedLists)
|
||||
protected.Post("/shared-lists/apply", s.handleApplySharedLists)
|
||||
protected.Post("/shared-lists/import", s.handleImportSharedLists)
|
||||
|
||||
protected.Get("/domains/{domain}", s.handleGetDomain)
|
||||
protected.Patch("/domains/{domain}", s.handleUpdateDomain)
|
||||
|
||||
protected.Get("/users", s.handleListUsers)
|
||||
protected.Post("/users", s.handleCreateUser)
|
||||
})
|
||||
})
|
||||
|
||||
if static != nil {
|
||||
r.Handle("/*", static)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
allowed, err := s.auth.RegistrationAllowed(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to check registration status")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"registration_open": allowed,
|
||||
"auth_mode": s.cfg.AuthMode,
|
||||
})
|
||||
}
|
||||
|
||||
type credentialsRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req credentialsRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
user, err := s.auth.Register(r.Context(), req.Email, req.DisplayName, req.Password)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrClosedReg):
|
||||
writeError(w, http.StatusForbidden, "registration is closed")
|
||||
case errors.Is(err, store.ErrEmailTaken):
|
||||
writeError(w, http.StatusConflict, "email already registered")
|
||||
case errors.Is(err, store.ErrInvalidInput):
|
||||
writeError(w, http.StatusBadRequest, "invalid email or role")
|
||||
default:
|
||||
if strings.Contains(err.Error(), "password") {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "registration failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.auth.CreateSession(r.Context(), w, user.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create session")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.AuthMode == config.AuthModePangolin {
|
||||
writeError(w, http.StatusBadRequest, "local login disabled; use Pangolin SSO")
|
||||
return
|
||||
}
|
||||
var req credentialsRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
user, err := s.auth.AuthenticateLocal(r.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUnauthorized) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid email or password")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "login failed")
|
||||
return
|
||||
}
|
||||
if err := s.auth.CreateSession(r.Context(), w, user.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create session")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
s.auth.ClearSession(r.Context(), w, r)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := s.store.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": users})
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role store.Role `json:"role"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req createUserRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
user, err := s.auth.CreateUserAsAdmin(r.Context(), req.Email, req.DisplayName, req.Password, req.Role)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrEmailTaken):
|
||||
writeError(w, http.StatusConflict, "email already registered")
|
||||
case errors.Is(err, store.ErrInvalidInput):
|
||||
writeError(w, http.StatusBadRequest, "invalid email or role")
|
||||
default:
|
||||
if strings.Contains(err.Error(), "password") {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create user")
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func (s *Server) handleListManagedDomains(w http.ResponseWriter, r *http.Request) {
|
||||
managed, err := s.store.ListManagedDomains(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list managed domains")
|
||||
return
|
||||
}
|
||||
|
||||
type managedDomainResponse struct {
|
||||
Name string `json:"name"`
|
||||
AddedAt string `json:"added_at"`
|
||||
AddedBy *int64 `json:"added_by,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Accessible bool `json:"accessible"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
out := make([]managedDomainResponse, 0, len(managed))
|
||||
for _, item := range managed {
|
||||
entry := managedDomainResponse{
|
||||
Name: item.Name,
|
||||
AddedAt: item.AddedAt.UTC().Format(time.RFC3339Nano),
|
||||
AddedBy: item.AddedBy,
|
||||
}
|
||||
domain, err := s.migadu.GetDomain(r.Context(), item.Name)
|
||||
if err != nil {
|
||||
entry.Accessible = false
|
||||
var apiErr *migadu.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
entry.Error = fmt.Sprintf("migadu HTTP %d", apiErr.StatusCode)
|
||||
} else {
|
||||
entry.Error = "migadu unreachable"
|
||||
}
|
||||
} else {
|
||||
entry.Accessible = true
|
||||
entry.State = domain.State
|
||||
entry.Description = domain.Description
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"domains": out})
|
||||
}
|
||||
|
||||
type addManagedDomainRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAddManagedDomain(w http.ResponseWriter, r *http.Request) {
|
||||
var req addManagedDomainRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "domain name is required")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("managed-domains: verifying access to %q via Migadu", name)
|
||||
domain, err := s.migadu.GetDomain(r.Context(), name)
|
||||
if err != nil {
|
||||
var apiErr *migadu.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
log.Printf(
|
||||
"managed-domains: access check failed for %q: HTTP %d body=%q",
|
||||
name,
|
||||
apiErr.StatusCode,
|
||||
apiErr.Body,
|
||||
)
|
||||
// Migadu commonly returns 400 on failure; also treat auth/not-found as inaccessible.
|
||||
if apiErr.StatusCode == http.StatusBadRequest ||
|
||||
apiErr.StatusCode == http.StatusNotFound ||
|
||||
apiErr.StatusCode == http.StatusForbidden ||
|
||||
apiErr.StatusCode == http.StatusUnauthorized {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{
|
||||
"error": "cannot access domain with configured API key",
|
||||
"migadu_status": apiErr.StatusCode,
|
||||
"migadu_body": apiErr.Body,
|
||||
"domain": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeMigaduError(w, err)
|
||||
return
|
||||
}
|
||||
log.Printf("managed-domains: access check failed for %q: %v", name, err)
|
||||
writeError(w, http.StatusBadGateway, "cannot verify domain with Migadu: "+err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("managed-domains: access ok for %q (state=%q)", domain.Name, domain.State)
|
||||
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
managed, err := s.store.AddManagedDomain(r.Context(), domain.Name, user.ID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrDomainExists):
|
||||
writeError(w, http.StatusConflict, "domain already managed")
|
||||
case errors.Is(err, store.ErrInvalidInput):
|
||||
writeError(w, http.StatusBadRequest, "invalid domain name")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "failed to save domain")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"name": managed.Name,
|
||||
"added_at": managed.AddedAt.UTC().Format(time.RFC3339Nano),
|
||||
"added_by": managed.AddedBy,
|
||||
"state": domain.State,
|
||||
"description": domain.Description,
|
||||
"accessible": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteManagedDomain(w http.ResponseWriter, r *http.Request) {
|
||||
domainName := chi.URLParam(r, "domain")
|
||||
if err := s.store.DeleteManagedDomain(r.Context(), domainName); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "domain not found in registry")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to remove domain")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSharedLists(w http.ResponseWriter, r *http.Request) {
|
||||
lists, err := s.store.GetSharedLists(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, lists)
|
||||
}
|
||||
|
||||
func (s *Server) handlePutSharedLists(w http.ResponseWriter, r *http.Request) {
|
||||
var req store.SharedLists
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.SenderDenylist == nil {
|
||||
req.SenderDenylist = []string{}
|
||||
}
|
||||
if req.SenderAllowlist == nil {
|
||||
req.SenderAllowlist = []string{}
|
||||
}
|
||||
if req.RecipientDenylist == nil {
|
||||
req.RecipientDenylist = []string{}
|
||||
}
|
||||
if err := s.store.PutSharedLists(r.Context(), req); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to save shared lists")
|
||||
return
|
||||
}
|
||||
lists, err := s.store.GetSharedLists(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, lists)
|
||||
}
|
||||
|
||||
type sharedListsTargetsRequest struct {
|
||||
All bool `json:"all"`
|
||||
Domains []string `json:"domains"`
|
||||
Replace bool `json:"replace"`
|
||||
}
|
||||
|
||||
type sharedListsDomainResult struct {
|
||||
Domain string `json:"domain"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Rejected []string `json:"rejected,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleApplySharedLists(w http.ResponseWriter, r *http.Request) {
|
||||
var req sharedListsTargetsRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if !req.All && len(req.Domains) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "provide all=true or a non-empty domains list")
|
||||
return
|
||||
}
|
||||
|
||||
shared, err := s.store.GetSharedLists(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
|
||||
return
|
||||
}
|
||||
|
||||
targets, err := s.resolveSharedListTargets(r, req)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "failed to") {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sharedDenylist := lists.Compact(shared.SenderDenylist)
|
||||
sharedAllowlist := lists.Compact(shared.SenderAllowlist)
|
||||
sharedRecipient := lists.Compact(shared.RecipientDenylist)
|
||||
|
||||
// Persist normalized/compacted shared lists (repairs typos, drops covered entries).
|
||||
if err := s.store.PutSharedLists(r.Context(), store.SharedLists{
|
||||
SenderDenylist: sharedDenylist,
|
||||
SenderAllowlist: sharedAllowlist,
|
||||
RecipientDenylist: sharedRecipient,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to save compacted shared lists")
|
||||
return
|
||||
}
|
||||
log.Printf(
|
||||
"shared-lists apply: replace=%v targets=%d denylist=%d allowlist=%d recipient=%d",
|
||||
req.Replace,
|
||||
len(targets),
|
||||
len(sharedDenylist),
|
||||
len(sharedAllowlist),
|
||||
len(sharedRecipient),
|
||||
)
|
||||
|
||||
results := make([]sharedListsDomainResult, 0, len(targets))
|
||||
for _, domainName := range targets {
|
||||
result := sharedListsDomainResult{Domain: domainName}
|
||||
|
||||
var denylist, allowlist, recipient []string
|
||||
if req.Replace {
|
||||
// Always use non-nil slices so JSON encodes [] not null (Migadu 400s on null).
|
||||
denylist = append([]string{}, sharedDenylist...)
|
||||
allowlist = append([]string{}, sharedAllowlist...)
|
||||
// Recipient deny is domain-scoped: only addresses for this domain.
|
||||
recipient = lists.FilterRecipientEntriesForDomain(domainName, sharedRecipient)
|
||||
} else {
|
||||
domain, err := s.migadu.GetDomain(r.Context(), domainName)
|
||||
if err != nil {
|
||||
result.Error = migaduErrorMessage(err)
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
denylist = unionStringLists([]string(domain.SenderDenylist), sharedDenylist)
|
||||
allowlist = unionStringLists([]string(domain.SenderAllowlist), sharedAllowlist)
|
||||
recipient = unionStringLists(
|
||||
[]string(domain.RecipientDenylist),
|
||||
lists.FilterRecipientEntriesForDomain(domainName, sharedRecipient),
|
||||
)
|
||||
}
|
||||
|
||||
writeResult, err := s.migadu.UpdateDomainLists(r.Context(), domainName, denylist, allowlist, recipient)
|
||||
if err != nil {
|
||||
result.Error = migaduErrorMessage(err)
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
result.OK = true
|
||||
result.Rejected = writeResult.Rejected
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
||||
}
|
||||
|
||||
func (s *Server) handleImportSharedLists(w http.ResponseWriter, r *http.Request) {
|
||||
var req sharedListsTargetsRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if !req.All && len(req.Domains) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "provide all=true or a non-empty domains list")
|
||||
return
|
||||
}
|
||||
|
||||
shared, err := s.store.GetSharedLists(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
|
||||
return
|
||||
}
|
||||
|
||||
targets, err := s.resolveSharedListTargets(r, req)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "failed to") {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
denylist := append([]string(nil), shared.SenderDenylist...)
|
||||
allowlist := append([]string(nil), shared.SenderAllowlist...)
|
||||
recipient := append([]string(nil), shared.RecipientDenylist...)
|
||||
|
||||
results := make([]sharedListsDomainResult, 0, len(targets))
|
||||
for _, domainName := range targets {
|
||||
result := sharedListsDomainResult{Domain: domainName}
|
||||
domain, err := s.migadu.GetDomain(r.Context(), domainName)
|
||||
if err != nil {
|
||||
result.Error = migaduErrorMessage(err)
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
denylist = append(denylist, []string(domain.SenderDenylist)...)
|
||||
allowlist = append(allowlist, []string(domain.SenderAllowlist)...)
|
||||
recipient = append(recipient, []string(domain.RecipientDenylist)...)
|
||||
result.OK = true
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
merged := store.SharedLists{
|
||||
SenderDenylist: lists.Compact(denylist),
|
||||
SenderAllowlist: lists.Compact(allowlist),
|
||||
RecipientDenylist: lists.Compact(recipient),
|
||||
}
|
||||
if err := s.store.PutSharedLists(r.Context(), merged); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to save shared lists")
|
||||
return
|
||||
}
|
||||
saved, err := s.store.GetSharedLists(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"lists": saved,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) resolveSharedListTargets(r *http.Request, req sharedListsTargetsRequest) ([]string, error) {
|
||||
if req.All {
|
||||
managed, err := s.store.ListManagedDomains(r.Context())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list managed domains")
|
||||
}
|
||||
names := make([]string, 0, len(managed))
|
||||
for _, item := range managed {
|
||||
names = append(names, item.Name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(req.Domains))
|
||||
names := make([]string, 0, len(req.Domains))
|
||||
for _, raw := range req.Domains {
|
||||
name := strings.ToLower(strings.TrimSpace(raw))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
ok, err := s.store.IsManagedDomain(r.Context(), name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check managed domain")
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("domain is not in the administered registry: %s", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("provide all=true or a non-empty domains list")
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func unionStringLists(existing, shared []string) []string {
|
||||
out := make([]string, 0, len(existing)+len(shared))
|
||||
out = append(out, existing...)
|
||||
out = append(out, shared...)
|
||||
return lists.Compact(out)
|
||||
}
|
||||
|
||||
func migaduErrorMessage(err error) string {
|
||||
var apiErr *migadu.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
if apiErr.Body != "" {
|
||||
return fmt.Sprintf("migadu HTTP %d: %s", apiErr.StatusCode, apiErr.Body)
|
||||
}
|
||||
return fmt.Sprintf("migadu HTTP %d", apiErr.StatusCode)
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (s *Server) requireManagedDomain(w http.ResponseWriter, r *http.Request, domainName string) bool {
|
||||
ok, err := s.store.IsManagedDomain(r.Context(), domainName)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to check managed domain")
|
||||
return false
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusForbidden, "domain is not in the administered registry")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleGetDomain(w http.ResponseWriter, r *http.Request) {
|
||||
domainName := chi.URLParam(r, "domain")
|
||||
if !s.requireManagedDomain(w, r, domainName) {
|
||||
return
|
||||
}
|
||||
domain, err := s.migadu.GetDomain(r.Context(), domainName)
|
||||
if err != nil {
|
||||
writeMigaduError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, domain)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateDomain(w http.ResponseWriter, r *http.Request) {
|
||||
domainName := chi.URLParam(r, "domain")
|
||||
if !s.requireManagedDomain(w, r, domainName) {
|
||||
return
|
||||
}
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := decodeJSON(r, &raw); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
allowed := map[string]struct{}{
|
||||
"sender_denylist": {},
|
||||
"sender_allowlist": {},
|
||||
"recipient_denylist": {},
|
||||
"spam_aggressiveness": {},
|
||||
"junk_subject_keyword_spam": {},
|
||||
"subject_rewriting_enabled": {},
|
||||
}
|
||||
for key := range raw {
|
||||
if _, ok := allowed[key]; !ok {
|
||||
writeError(w, http.StatusBadRequest, "unsupported field: "+key)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var update migadu.DomainUpdate
|
||||
if value, ok := raw["sender_denylist"]; ok {
|
||||
var list []string
|
||||
if err := json.Unmarshal(value, &list); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "sender_denylist must be a string array")
|
||||
return
|
||||
}
|
||||
list = lists.Compact(list)
|
||||
update.SenderDenylist = &list
|
||||
}
|
||||
if value, ok := raw["sender_allowlist"]; ok {
|
||||
var list []string
|
||||
if err := json.Unmarshal(value, &list); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "sender_allowlist must be a string array")
|
||||
return
|
||||
}
|
||||
list = lists.Compact(list)
|
||||
update.SenderAllowlist = &list
|
||||
}
|
||||
if value, ok := raw["recipient_denylist"]; ok {
|
||||
var list []string
|
||||
if err := json.Unmarshal(value, &list); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "recipient_denylist must be a string array")
|
||||
return
|
||||
}
|
||||
list = lists.Compact(list)
|
||||
update.RecipientDenylist = &list
|
||||
}
|
||||
if value, ok := raw["spam_aggressiveness"]; ok {
|
||||
var aggressiveness string
|
||||
if err := json.Unmarshal(value, &aggressiveness); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "spam_aggressiveness must be a string")
|
||||
return
|
||||
}
|
||||
update.SpamAggressiveness = &aggressiveness
|
||||
}
|
||||
if value, ok := raw["junk_subject_keyword_spam"]; ok {
|
||||
var flag bool
|
||||
if err := json.Unmarshal(value, &flag); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "junk_subject_keyword_spam must be a boolean")
|
||||
return
|
||||
}
|
||||
update.JunkSubjectKeywordSpam = &flag
|
||||
}
|
||||
if value, ok := raw["subject_rewriting_enabled"]; ok {
|
||||
var flag bool
|
||||
if err := json.Unmarshal(value, &flag); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "subject_rewriting_enabled must be a boolean")
|
||||
return
|
||||
}
|
||||
update.SubjectRewritingEnabled = &flag
|
||||
}
|
||||
|
||||
domain, err := s.migadu.UpdateDomain(r.Context(), domainName, update)
|
||||
if err != nil {
|
||||
writeMigaduError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, domain)
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dest any) error {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(dest)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func writeMigaduError(w http.ResponseWriter, err error) {
|
||||
var apiErr *migadu.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
status := http.StatusBadGateway
|
||||
if apiErr.StatusCode == http.StatusNotFound {
|
||||
status = http.StatusNotFound
|
||||
} else if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
|
||||
status = apiErr.StatusCode
|
||||
}
|
||||
writeJSON(w, status, map[string]any{
|
||||
"error": "migadu request failed",
|
||||
"migadu_status": apiErr.StatusCode,
|
||||
"migadu_body": apiErr.Body,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadGateway, "migadu request failed")
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/squid/MigaduAdmin/internal/config"
|
||||
"github.com/squid/MigaduAdmin/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
SessionCookieName = "migaduadmin_session"
|
||||
SessionTTL = 7 * 24 * time.Hour
|
||||
bcryptCost = 12
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userContextKey contextKey = "user"
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrClosedReg = errors.New("registration is closed")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewService(st *store.Store, cfg *config.Config) *Service {
|
||||
return &Service{store: st, config: cfg}
|
||||
}
|
||||
|
||||
func (s *Service) HashPassword(password string) (string, error) {
|
||||
if len(password) < 8 {
|
||||
return "", fmt.Errorf("password must be at least 8 characters")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func (s *Service) CheckPassword(hash, password string) bool {
|
||||
if hash == "" || password == "" {
|
||||
return false
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func (s *Service) RegistrationAllowed(ctx context.Context) (bool, error) {
|
||||
count, err := s.store.CountUsers(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count == 0 {
|
||||
return true, nil
|
||||
}
|
||||
return s.config.RegistrationOpen, nil
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, email, displayName, password string) (*store.User, error) {
|
||||
allowed, err := s.RegistrationAllowed(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !allowed {
|
||||
return nil, ErrClosedReg
|
||||
}
|
||||
|
||||
count, err := s.store.CountUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
role := store.RoleUser
|
||||
if count == 0 {
|
||||
role = store.RoleAdmin
|
||||
}
|
||||
|
||||
hash, err := s.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.CreateUser(ctx, email, displayName, hash, role)
|
||||
}
|
||||
|
||||
func (s *Service) CreateUserAsAdmin(ctx context.Context, email, displayName, password string, role store.Role) (*store.User, error) {
|
||||
hash, err := s.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == "" {
|
||||
role = store.RoleUser
|
||||
}
|
||||
return s.store.CreateUser(ctx, email, displayName, hash, role)
|
||||
}
|
||||
|
||||
func (s *Service) AuthenticateLocal(ctx context.Context, email, password string) (*store.User, error) {
|
||||
user, err := s.store.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !s.CheckPassword(user.PasswordHash, password) {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateSession(ctx context.Context, w http.ResponseWriter, userID int64) error {
|
||||
_ = s.store.DeleteExpiredSessions(ctx)
|
||||
|
||||
token, err := randomToken(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expires := time.Now().UTC().Add(SessionTTL)
|
||||
if err := s.store.CreateSession(ctx, token, userID, expires); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.config.CookieSecure,
|
||||
Expires: expires,
|
||||
MaxAge: int(SessionTTL.Seconds()),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ClearSession(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(SessionCookieName); err == nil && cookie.Value != "" {
|
||||
_ = s.store.DeleteSession(ctx, cookie.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.config.CookieSecure,
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (*store.User, error) {
|
||||
if s.config.AuthMode == config.AuthModePangolin || s.config.AuthMode == config.AuthModeBoth {
|
||||
if user, err := s.userFromPangolin(ctx, r); err == nil {
|
||||
return user, nil
|
||||
} else if !errors.Is(err, ErrUnauthorized) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if s.config.AuthMode == config.AuthModeLocal || s.config.AuthMode == config.AuthModeBoth {
|
||||
return s.userFromSession(ctx, r)
|
||||
}
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
|
||||
func (s *Service) userFromSession(ctx context.Context, r *http.Request) (*store.User, error) {
|
||||
cookie, err := r.Cookie(SessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
session, err := s.store.GetSession(ctx, cookie.Value)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().UTC().After(session.ExpiresAt) {
|
||||
_ = s.store.DeleteSession(ctx, session.ID)
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
user, err := s.store.GetUserByID(ctx, session.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Service) userFromPangolin(ctx context.Context, r *http.Request) (*store.User, error) {
|
||||
if !s.isTrustedPeer(r) {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(r.Header.Get("Remote-Email"))
|
||||
if email == "" {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
|
||||
displayName := strings.TrimSpace(r.Header.Get("Remote-Name"))
|
||||
if displayName == "" {
|
||||
displayName = strings.TrimSpace(r.Header.Get("Remote-User"))
|
||||
}
|
||||
|
||||
user, err := s.store.GetUserByEmail(ctx, email)
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
count, err := s.store.CountUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
role := store.RoleUser
|
||||
if count == 0 {
|
||||
role = store.RoleAdmin
|
||||
} else if !s.config.SSOAutoProvision {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
|
||||
return s.store.CreateUser(ctx, email, displayName, "", role)
|
||||
}
|
||||
|
||||
func (s *Service) isTrustedPeer(r *http.Request) bool {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return s.config.IsTrustedProxy(ip)
|
||||
}
|
||||
|
||||
// StripUntrustedIdentityHeaders removes Pangolin identity headers unless the peer is trusted.
|
||||
func (s *Service) StripUntrustedIdentityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.isTrustedPeer(r) {
|
||||
headers := r.Header.Clone()
|
||||
headers.Del("Remote-Email")
|
||||
headers.Del("Remote-User")
|
||||
headers.Del("Remote-Name")
|
||||
headers.Del("Remote-Role")
|
||||
r = r.Clone(r.Context())
|
||||
r.Header = headers
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.UserFromRequest(r.Context(), r)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnauthorized) {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
if user == nil || user.Role != store.RoleAdmin {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func UserFromContext(ctx context.Context) *store.User {
|
||||
user, _ := ctx.Value(userContextKey).(*store.User)
|
||||
return user
|
||||
}
|
||||
|
||||
func ConstantTimeEquals(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
func randomToken(size int) (string, error) {
|
||||
buf := make([]byte, size)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func RandomSecret(size int) (string, error) {
|
||||
buf := make([]byte, size)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type AuthMode string
|
||||
|
||||
const (
|
||||
AuthModeLocal AuthMode = "local"
|
||||
AuthModePangolin AuthMode = "pangolin"
|
||||
AuthModeBoth AuthMode = "both"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
MigaduUser string
|
||||
MigaduAPIKey string
|
||||
MigaduBaseURL string
|
||||
SessionSecret string
|
||||
DatabasePath string
|
||||
AuthMode AuthMode
|
||||
TrustedProxies []*net.IPNet
|
||||
RegistrationOpen bool
|
||||
SSOAutoProvision bool
|
||||
CookieSecure bool
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
// Prefer project .env over stale exported shell vars (common after editing the key).
|
||||
if err := godotenv.Overload(); err != nil {
|
||||
// Missing .env is fine when vars are provided by the environment.
|
||||
if !os.IsNotExist(err) {
|
||||
// godotenv returns a plain error for missing file; ignore only that case.
|
||||
if !strings.Contains(err.Error(), "no such file") {
|
||||
return nil, fmt.Errorf("load .env: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
authMode := AuthMode(strings.ToLower(strings.TrimSpace(envOr("AUTH_MODE", "both"))))
|
||||
switch authMode {
|
||||
case AuthModeLocal, AuthModePangolin, AuthModeBoth:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid AUTH_MODE %q (use local, pangolin, or both)", authMode)
|
||||
}
|
||||
|
||||
trusted, err := parseCIDRs(envOr("TRUSTED_PROXIES", "127.0.0.1/32,::1/128"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TRUSTED_PROXIES: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
ListenAddr: envOr("LISTEN_ADDR", ":8080"),
|
||||
MigaduUser: cleanEnv(os.Getenv("MIGADU_USER")),
|
||||
MigaduAPIKey: cleanEnv(os.Getenv("MIGADU_API_KEY")),
|
||||
MigaduBaseURL: strings.TrimRight(envOr("MIGADU_BASE_URL", "https://api.migadu.com/v1"), "/"),
|
||||
SessionSecret: cleanEnv(os.Getenv("SESSION_SECRET")),
|
||||
DatabasePath: envOr("DATABASE_PATH", "data/migaduadmin.db"),
|
||||
AuthMode: authMode,
|
||||
TrustedProxies: trusted,
|
||||
RegistrationOpen: envBool("REGISTRATION_OPEN", false),
|
||||
SSOAutoProvision: envBool("SSO_AUTO_PROVISION", false),
|
||||
CookieSecure: envBool("COOKIE_SECURE", false),
|
||||
}
|
||||
|
||||
if cfg.MigaduUser == "" {
|
||||
return nil, fmt.Errorf("MIGADU_USER is required")
|
||||
}
|
||||
if cfg.MigaduAPIKey == "" {
|
||||
return nil, fmt.Errorf("MIGADU_API_KEY is required")
|
||||
}
|
||||
if strings.ContainsAny(cfg.MigaduAPIKey, " \t\n\r") {
|
||||
return nil, fmt.Errorf("MIGADU_API_KEY contains whitespace; check .env quoting")
|
||||
}
|
||||
if cfg.SessionSecret == "" || len(cfg.SessionSecret) < 32 {
|
||||
return nil, fmt.Errorf("SESSION_SECRET is required and must be at least 32 characters")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// MaskedMigaduKey returns a safe fingerprint for logs.
|
||||
func (c *Config) MaskedMigaduKey() string {
|
||||
key := c.MigaduAPIKey
|
||||
if key == "" {
|
||||
return "(empty)"
|
||||
}
|
||||
if len(key) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return fmt.Sprintf("%s…%s (len=%d)", key[:4], key[len(key)-4:], len(key))
|
||||
}
|
||||
|
||||
func cleanEnv(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 2 {
|
||||
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
||||
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
||||
value = strings.TrimSpace(value[1 : len(value)-1])
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (c *Config) IsTrustedProxy(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, network := range c.TrustedProxies {
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if value := cleanEnv(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
raw := cleanEnv(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseCIDRs(raw string) ([]*net.IPNet, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
networks := make([]*net.IPNet, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(part, "/") {
|
||||
if strings.Contains(part, ":") {
|
||||
part += "/128"
|
||||
} else {
|
||||
part += "/32"
|
||||
}
|
||||
}
|
||||
_, network, err := net.ParseCIDR(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CIDR %q: %w", part, err)
|
||||
}
|
||||
networks = append(networks, network)
|
||||
}
|
||||
return networks, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsTrustedProxy(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
networks, err := parseCIDRs("10.0.0.0/8,127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.TrustedProxies = networks
|
||||
|
||||
if !cfg.IsTrustedProxy(net.ParseIP("10.1.2.3")) {
|
||||
t.Fatal("expected 10.1.2.3 trusted")
|
||||
}
|
||||
if !cfg.IsTrustedProxy(net.ParseIP("127.0.0.1")) {
|
||||
t.Fatal("expected 127.0.0.1 trusted")
|
||||
}
|
||||
if cfg.IsTrustedProxy(net.ParseIP("192.168.1.1")) {
|
||||
t.Fatal("expected 192.168.1.1 untrusted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package lists
|
||||
|
||||
import "strings"
|
||||
|
||||
// Compact trims, lowercases, normalizes, dedupes, and drops entries covered by a
|
||||
// broader wildcard in the same list (*@domain or *@*.suffix).
|
||||
func Compact(entries []string) []string {
|
||||
if len(entries) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
unique := make([]string, 0, len(entries))
|
||||
seen := make(map[string]struct{}, len(entries))
|
||||
for _, value := range entries {
|
||||
entry := normalizeEntry(value)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[entry]; ok {
|
||||
continue
|
||||
}
|
||||
seen[entry] = struct{}{}
|
||||
unique = append(unique, entry)
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(unique))
|
||||
for i, entry := range unique {
|
||||
covered := false
|
||||
for j, other := range unique {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
if Covers(other, entry) && other != entry {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !covered {
|
||||
out = append(out, entry)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeEntry lowercases and repairs common wildcard typos:
|
||||
// *domain.tld → *@domain.tld
|
||||
// *@*rumble.com → *@*.rumble.com
|
||||
func normalizeEntry(value string) string {
|
||||
entry := strings.ToLower(strings.TrimSpace(value))
|
||||
if entry == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(entry, "*") && !strings.Contains(entry, "@") && strings.Contains(entry, ".") {
|
||||
entry = "*@" + strings.TrimPrefix(entry, "*")
|
||||
}
|
||||
local, domain, ok := splitAddress(entry)
|
||||
if ok && local == "*" && strings.HasPrefix(domain, "*") && !strings.HasPrefix(domain, "*.") {
|
||||
entry = "*@*." + strings.TrimPrefix(domain, "*")
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// Covers reports whether broader covers specific.
|
||||
// Equal entries cover each other.
|
||||
// *@domain covers any local@domain for that domain.
|
||||
// *@*.suffix covers any local@host.suffix (domain glob).
|
||||
func Covers(broader, specific string) bool {
|
||||
broader = normalizeEntry(broader)
|
||||
specific = normalizeEntry(specific)
|
||||
if broader == "" || specific == "" {
|
||||
return false
|
||||
}
|
||||
if broader == specific {
|
||||
return true
|
||||
}
|
||||
|
||||
broaderLocal, broaderDomain, broaderOK := splitAddress(broader)
|
||||
_, specificDomain, specificOK := splitAddress(specific)
|
||||
if !broaderOK || !specificOK {
|
||||
return false
|
||||
}
|
||||
if broaderLocal != "*" {
|
||||
return false
|
||||
}
|
||||
return domainMatches(broaderDomain, specificDomain)
|
||||
}
|
||||
|
||||
func domainMatches(pattern, domain string) bool {
|
||||
if pattern == domain {
|
||||
return true
|
||||
}
|
||||
// *.suffix → any domain ending with .suffix (and longer than the suffix).
|
||||
if strings.HasPrefix(pattern, "*.") {
|
||||
suffix := pattern[1:] // ".shop", ".ac.in"
|
||||
return strings.HasSuffix(domain, suffix) && len(domain) > len(suffix)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitAddress(entry string) (local, domain string, ok bool) {
|
||||
at := strings.LastIndex(entry, "@")
|
||||
if at <= 0 || at == len(entry)-1 {
|
||||
return "", "", false
|
||||
}
|
||||
return entry[:at], entry[at+1:], true
|
||||
}
|
||||
|
||||
// FilterRecipientEntriesForDomain keeps recipient-deny entries that belong to
|
||||
// the given domain. Migadu rejects addresses for other domains on PATCH.
|
||||
// Bare local parts (no @) are kept and applied to every domain.
|
||||
func FilterRecipientEntriesForDomain(domainName string, entries []string) []string {
|
||||
domainName = strings.ToLower(strings.TrimSpace(domainName))
|
||||
out := make([]string, 0, len(entries))
|
||||
seen := make(map[string]struct{}, len(entries))
|
||||
for _, value := range entries {
|
||||
entry := normalizeEntry(value)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
local, entryDomain, hasAt := splitAddress(entry)
|
||||
if hasAt {
|
||||
if entryDomain != domainName {
|
||||
continue
|
||||
}
|
||||
// Prefer local@domain form; keep as normalized.
|
||||
entry = local + "@" + entryDomain
|
||||
}
|
||||
// Bare local-part applies to this domain as-is.
|
||||
if _, ok := seen[entry]; ok {
|
||||
continue
|
||||
}
|
||||
seen[entry] = struct{}{}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package lists
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompact(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
input: nil,
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "exact duplicates case insensitive",
|
||||
input: []string{"Lisa@Simpsons.com", "lisa@simpsons.com", " LISA@SIMPSONS.COM "},
|
||||
want: []string{"lisa@simpsons.com"},
|
||||
},
|
||||
{
|
||||
name: "wildcard covers specific",
|
||||
input: []string{"lisa@simpsons.com", "*@simpsons.com"},
|
||||
want: []string{"*@simpsons.com"},
|
||||
},
|
||||
{
|
||||
name: "specific dropped when wildcard present first",
|
||||
input: []string{"*@simpsons.com", "lisa@simpsons.com", "homer@simpsons.com"},
|
||||
want: []string{"*@simpsons.com"},
|
||||
},
|
||||
{
|
||||
name: "distinct locals kept",
|
||||
input: []string{"lisa@simpsons.com", "homer@simpsons.com"},
|
||||
want: []string{"lisa@simpsons.com", "homer@simpsons.com"},
|
||||
},
|
||||
{
|
||||
name: "different domains kept",
|
||||
input: []string{"*@simpsons.com", "lisa@flanders.com", "*@flanders.com"},
|
||||
want: []string{"*@simpsons.com", "*@flanders.com"},
|
||||
},
|
||||
{
|
||||
name: "bare hostname exact dedupe only",
|
||||
input: []string{"simpsons.com", "Simpsons.com", "lisa@simpsons.com"},
|
||||
want: []string{"simpsons.com", "lisa@simpsons.com"},
|
||||
},
|
||||
{
|
||||
name: "blank entries dropped",
|
||||
input: []string{"", " ", "a@b.com"},
|
||||
want: []string{"a@b.com"},
|
||||
},
|
||||
{
|
||||
name: "repair star without at",
|
||||
input: []string{"*vsa.instalily.ai", "lisa@vsa.instalily.ai"},
|
||||
want: []string{"*@vsa.instalily.ai"},
|
||||
},
|
||||
{
|
||||
name: "suffix glob covers specific domains",
|
||||
input: []string{"*@*.shop", "*@alth.shop", "spam@woowstars.shop", "*@other.com"},
|
||||
want: []string{"*@*.shop", "*@other.com"},
|
||||
},
|
||||
{
|
||||
name: "repair star-domain without dot",
|
||||
input: []string{"*@*rumble.com", "user@foo.rumble.com"},
|
||||
want: []string{"*@*.rumble.com"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := Compact(tt.input)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("Compact() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRecipientEntriesForDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := FilterRecipientEntriesForDomain("skaab.nu", []string{
|
||||
"brittis@gronberg.info",
|
||||
"brgr@tspse.net",
|
||||
"abuse@skaab.nu",
|
||||
"postmaster",
|
||||
"ABUSE@SKAAB.NU",
|
||||
})
|
||||
want := []string{"abuse@skaab.nu", "postmaster"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCovers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
broader string
|
||||
specific string
|
||||
want bool
|
||||
}{
|
||||
{"*@simpsons.com", "lisa@simpsons.com", true},
|
||||
{"*@simpsons.com", "*@simpsons.com", true},
|
||||
{"lisa@simpsons.com", "*@simpsons.com", false},
|
||||
{"lisa@simpsons.com", "homer@simpsons.com", false},
|
||||
{"*@simpsons.com", "lisa@flanders.com", false},
|
||||
{"simpsons.com", "lisa@simpsons.com", false},
|
||||
{"", "a@b.com", false},
|
||||
{"*@*.shop", "*@alth.shop", true},
|
||||
{"*@*.shop", "user@alth.shop", true},
|
||||
{"*@*.shop", "*@other.com", false},
|
||||
{"*@simpsons.com", "lisa@mail.simpsons.com", false},
|
||||
{"*vsa.instalily.ai", "x@vsa.instalily.ai", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := Covers(tt.broader, tt.specific)
|
||||
if got != tt.want {
|
||||
t.Fatalf("Covers(%q, %q) = %v, want %v", tt.broader, tt.specific, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package lists
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// EntryIssue describes a single invalid list entry.
|
||||
type EntryIssue struct {
|
||||
Entry string `json:"entry"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ValidateEntries checks each entry against Migadu-style address patterns.
|
||||
// Empty input is valid. Entries are normalized before checking.
|
||||
func ValidateEntries(entries []string) []EntryIssue {
|
||||
issues := make([]EntryIssue, 0)
|
||||
for _, value := range entries {
|
||||
raw := strings.TrimSpace(value)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
entry := normalizeEntry(raw)
|
||||
if reason := validateNormalizedEntry(entry); reason != "" {
|
||||
issues = append(issues, EntryIssue{Entry: raw, Reason: reason})
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
// ValidateEntry returns an error reason for one entry, or "" if valid.
|
||||
func ValidateEntry(value string) string {
|
||||
raw := strings.TrimSpace(value)
|
||||
if raw == "" {
|
||||
return "entry is empty"
|
||||
}
|
||||
return validateNormalizedEntry(normalizeEntry(raw))
|
||||
}
|
||||
|
||||
func validateNormalizedEntry(entry string) string {
|
||||
if entry == "" {
|
||||
return "entry is empty"
|
||||
}
|
||||
if strings.ContainsAny(entry, " \t\r\n") {
|
||||
return "entry must not contain whitespace"
|
||||
}
|
||||
if strings.Count(entry, "@") != 1 {
|
||||
return "entry must look like local@domain (e.g. user@example.com or *@example.com)"
|
||||
}
|
||||
|
||||
local, domain, ok := splitAddress(entry)
|
||||
if !ok {
|
||||
return "entry must look like local@domain"
|
||||
}
|
||||
if local == "" {
|
||||
return "local part before @ is empty"
|
||||
}
|
||||
if !validLocalPart(local) {
|
||||
return "invalid local part (use user, *@domain, or *prefix)"
|
||||
}
|
||||
if domain == "" {
|
||||
return "domain after @ is empty"
|
||||
}
|
||||
if !validDomainPart(domain) {
|
||||
return "invalid domain (use example.com or *.com / *.co.uk)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validLocalPart(local string) bool {
|
||||
if local == "*" {
|
||||
return true
|
||||
}
|
||||
// *prefix or normal local-part characters commonly used in email addresses.
|
||||
if strings.HasPrefix(local, "*") {
|
||||
rest := local[1:]
|
||||
if rest == "" {
|
||||
return true
|
||||
}
|
||||
return validLocalChars(rest)
|
||||
}
|
||||
return validLocalChars(local)
|
||||
}
|
||||
|
||||
func validLocalChars(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '.', '_', '+', '-', '%':
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validDomainPart(domain string) bool {
|
||||
if strings.HasPrefix(domain, "*.") {
|
||||
return validDNSSuffix(domain[2:])
|
||||
}
|
||||
return validDNSDomain(domain)
|
||||
}
|
||||
|
||||
// validDNSSuffix allows "shop" or "ac.in" after a *. glob prefix.
|
||||
func validDNSSuffix(suffix string) bool {
|
||||
if suffix == "" || len(suffix) > 253 {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(suffix, ".") || strings.HasSuffix(suffix, ".") {
|
||||
return false
|
||||
}
|
||||
labels := strings.Split(suffix, ".")
|
||||
if len(labels) < 1 {
|
||||
return false
|
||||
}
|
||||
for _, label := range labels {
|
||||
if !validDNSLabel(label) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
tld := labels[len(labels)-1]
|
||||
for _, r := range tld {
|
||||
if !unicode.IsLetter(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validDNSDomain(domain string) bool {
|
||||
if domain == "" || len(domain) > 253 {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
|
||||
return false
|
||||
}
|
||||
labels := strings.Split(domain, ".")
|
||||
if len(labels) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, label := range labels {
|
||||
if !validDNSLabel(label) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// TLD should be alphabetic (allows multi-char like com, uk, info).
|
||||
tld := labels[len(labels)-1]
|
||||
for _, r := range tld {
|
||||
if !unicode.IsLetter(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validDNSLabel(label string) bool {
|
||||
if label == "" || len(label) > 63 {
|
||||
return false
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for i, r := range label {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
continue
|
||||
}
|
||||
if r == '-' && i > 0 && i < len(label)-1 {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// FormatIssues returns a short multi-line summary for logs/UI.
|
||||
func FormatIssues(issues []EntryIssue) string {
|
||||
if len(issues) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(issues))
|
||||
for _, issue := range issues {
|
||||
parts = append(parts, fmt.Sprintf("%s: %s", issue.Entry, issue.Reason))
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package lists
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
entries []string
|
||||
wantN int
|
||||
}{
|
||||
{name: "empty list", entries: nil, wantN: 0},
|
||||
{name: "valid user", entries: []string{"user@example.com"}, wantN: 0},
|
||||
{name: "valid star domain", entries: []string{"*@example.com"}, wantN: 0},
|
||||
{name: "valid suffix glob", entries: []string{"*@*.shop", "*@*.ac.in"}, wantN: 0},
|
||||
{name: "valid plus addressing", entries: []string{"a.b+c@mail-host.co.uk"}, wantN: 0},
|
||||
{name: "repair then valid", entries: []string{"*vsa.instalily.ai"}, wantN: 0},
|
||||
{name: "missing at", entries: []string{"not-an-email"}, wantN: 1},
|
||||
{name: "bare domain", entries: []string{"example.com"}, wantN: 1},
|
||||
{name: "spaces", entries: []string{"user @example.com"}, wantN: 1},
|
||||
{name: "bad domain", entries: []string{"user@-example.com"}, wantN: 1},
|
||||
{name: "single label domain", entries: []string{"*@localhost"}, wantN: 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := ValidateEntries(tt.entries)
|
||||
if len(got) != tt.wantN {
|
||||
t.Fatalf("ValidateEntries() issues=%v want %d", got, tt.wantN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEntryReasons(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := ValidateEntry(""); got == "" {
|
||||
t.Fatal("expected empty reason")
|
||||
}
|
||||
if got := ValidateEntry("lisa@simpsons.com"); got != "" {
|
||||
t.Fatalf("unexpected: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
package migadu
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
username string
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL, username, apiKey string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
username: username,
|
||||
apiKey: apiKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// StringList normalizes Migadu fields that may arrive as a string, string list, or null.
|
||||
type StringList []string
|
||||
|
||||
func (s *StringList) UnmarshalJSON(data []byte) error {
|
||||
data = bytes.TrimSpace(data)
|
||||
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
|
||||
*s = []string{}
|
||||
return nil
|
||||
}
|
||||
if data[0] == '"' {
|
||||
var single string
|
||||
if err := json.Unmarshal(data, &single); err != nil {
|
||||
return err
|
||||
}
|
||||
single = strings.TrimSpace(single)
|
||||
if single == "" {
|
||||
*s = []string{}
|
||||
return nil
|
||||
}
|
||||
parts := strings.FieldsFunc(single, func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == ';'
|
||||
})
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
*s = out
|
||||
return nil
|
||||
}
|
||||
var list []string
|
||||
if err := json.Unmarshal(data, &list); err != nil {
|
||||
return err
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
*s = out
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s StringList) MarshalJSON() ([]byte, error) {
|
||||
if s == nil {
|
||||
return json.Marshal([]string{})
|
||||
}
|
||||
return json.Marshal([]string(s))
|
||||
}
|
||||
|
||||
type DomainSummary struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Description string `json:"description"`
|
||||
SpamAggressiveness any `json:"spam_aggressiveness"`
|
||||
CanSend bool `json:"can_send"`
|
||||
CanReceive bool `json:"can_receive"`
|
||||
}
|
||||
|
||||
type Domain struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Description string `json:"description"`
|
||||
Tags StringList `json:"tags"`
|
||||
ActivatedAt *string `json:"activated_at"`
|
||||
DeactivatedAt *string `json:"deactivated_at"`
|
||||
CanSend bool `json:"can_send"`
|
||||
CanReceive bool `json:"can_receive"`
|
||||
CanAccess bool `json:"can_access"`
|
||||
MxProxyEnabled bool `json:"mx_proxy_enabled"`
|
||||
SpamAggressiveness any `json:"spam_aggressiveness"`
|
||||
SubjectRewritingEnabled bool `json:"subject_rewriting_enabled"`
|
||||
JunkSubjectKeywordSpam bool `json:"junk_subject_keyword_spam"`
|
||||
SenderDenylist StringList `json:"sender_denylist"`
|
||||
SenderAllowlist StringList `json:"sender_allowlist"`
|
||||
RecipientDenylist StringList `json:"recipient_denylist"`
|
||||
CatchallDestinations StringList `json:"catchall_destinations"`
|
||||
HostedDNS bool `json:"hosted_dns"`
|
||||
}
|
||||
|
||||
type DomainUpdate struct {
|
||||
SenderDenylist *[]string `json:"-"`
|
||||
SenderAllowlist *[]string `json:"-"`
|
||||
RecipientDenylist *[]string `json:"-"`
|
||||
SpamAggressiveness *string `json:"spam_aggressiveness,omitempty"`
|
||||
JunkSubjectKeywordSpam *bool `json:"junk_subject_keyword_spam,omitempty"`
|
||||
SubjectRewritingEnabled *bool `json:"subject_rewriting_enabled,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON encodes denylist/allowlist fields as comma-separated strings.
|
||||
// Migadu's API documents these as String/List and rejects JSON arrays on PATCH.
|
||||
func (u DomainUpdate) MarshalJSON() ([]byte, error) {
|
||||
raw := map[string]any{}
|
||||
if u.SenderDenylist != nil {
|
||||
raw["sender_denylist"] = joinListField(*u.SenderDenylist)
|
||||
}
|
||||
if u.SenderAllowlist != nil {
|
||||
raw["sender_allowlist"] = joinListField(*u.SenderAllowlist)
|
||||
}
|
||||
if u.RecipientDenylist != nil {
|
||||
raw["recipient_denylist"] = joinListField(*u.RecipientDenylist)
|
||||
}
|
||||
if u.SpamAggressiveness != nil {
|
||||
raw["spam_aggressiveness"] = *u.SpamAggressiveness
|
||||
}
|
||||
if u.JunkSubjectKeywordSpam != nil {
|
||||
raw["junk_subject_keyword_spam"] = *u.JunkSubjectKeywordSpam
|
||||
}
|
||||
if u.SubjectRewritingEnabled != nil {
|
||||
raw["subject_rewriting_enabled"] = *u.SubjectRewritingEnabled
|
||||
}
|
||||
return json.Marshal(raw)
|
||||
}
|
||||
|
||||
func joinListField(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
parts = append(parts, value)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Body == "" {
|
||||
return fmt.Sprintf("migadu api error: HTTP %d", e.StatusCode)
|
||||
}
|
||||
return fmt.Sprintf("migadu api error: HTTP %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
func (c *Client) ListDomains(ctx context.Context) ([]DomainSummary, error) {
|
||||
raw, err := c.doRaw(ctx, http.MethodGet, "/domains", nil, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var wrapped struct {
|
||||
Domains []DomainSummary `json:"domains"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrapped); err == nil && wrapped.Domains != nil {
|
||||
return wrapped.Domains, nil
|
||||
}
|
||||
|
||||
var list []DomainSummary
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetDomain(ctx context.Context, name string) (*Domain, error) {
|
||||
var domain Domain
|
||||
path := "/domains/" + url.PathEscape(name)
|
||||
if err := c.do(ctx, http.MethodGet, path, nil, &domain, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &domain, nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateDomain(ctx context.Context, name string, update DomainUpdate) (*Domain, error) {
|
||||
return c.updateDomain(ctx, name, update, false)
|
||||
}
|
||||
|
||||
func (c *Client) updateDomain(ctx context.Context, name string, update DomainUpdate, quiet bool) (*Domain, error) {
|
||||
update.normalizeListPointers()
|
||||
var domain Domain
|
||||
path := "/domains/" + url.PathEscape(name)
|
||||
if err := c.do(ctx, http.MethodPatch, path, update, &domain, quiet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &domain, nil
|
||||
}
|
||||
|
||||
// ListWriteResult is the outcome of writing denylist/allowlist fields with
|
||||
// rejection bisect when Migadu returns HTTP 400.
|
||||
type ListWriteResult struct {
|
||||
SenderDenylist []string
|
||||
SenderAllowlist []string
|
||||
RecipientDenylist []string
|
||||
Rejected []string
|
||||
}
|
||||
|
||||
// UpdateDomainLists writes list fields one at a time. On HTTP 400 it bisects to
|
||||
// find entries Migadu rejects, skips them, and applies the rest.
|
||||
func (c *Client) UpdateDomainLists(ctx context.Context, name string, denylist, allowlist, recipient []string) (*ListWriteResult, error) {
|
||||
result := &ListWriteResult{}
|
||||
|
||||
acceptedDeny, rejectedDeny, err := c.writeListField(ctx, name, "sender_denylist", denylist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acceptedAllow, rejectedAllow, err := c.writeListField(ctx, name, "sender_allowlist", allowlist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acceptedRecipient, rejectedRecipient, err := c.writeListField(ctx, name, "recipient_denylist", recipient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result.SenderDenylist = acceptedDeny
|
||||
result.SenderAllowlist = acceptedAllow
|
||||
result.RecipientDenylist = acceptedRecipient
|
||||
result.Rejected = append(result.Rejected, rejectedDeny...)
|
||||
result.Rejected = append(result.Rejected, rejectedAllow...)
|
||||
result.Rejected = append(result.Rejected, rejectedRecipient...)
|
||||
if len(result.Rejected) > 0 {
|
||||
log.Printf(
|
||||
"migadu: %s list write skipped %d rejected entr(y/ies): %s",
|
||||
name,
|
||||
len(result.Rejected),
|
||||
strings.Join(result.Rejected, ", "),
|
||||
)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) writeListField(ctx context.Context, domainName, field string, entries []string) (accepted, rejected []string, err error) {
|
||||
entries = append([]string{}, entries...)
|
||||
_, err = c.updateDomain(ctx, domainName, domainUpdateForField(field, entries), false)
|
||||
if err == nil {
|
||||
return entries, nil, nil
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusBadRequest {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
rejected = c.findRejectedEntries(ctx, domainName, field, entries)
|
||||
rejectedSet := make(map[string]struct{}, len(rejected))
|
||||
for _, entry := range rejected {
|
||||
rejectedSet[entry] = struct{}{}
|
||||
}
|
||||
accepted = make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if _, bad := rejectedSet[entry]; !bad {
|
||||
accepted = append(accepted, entry)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err = c.updateDomain(ctx, domainName, domainUpdateForField(field, accepted), false); err != nil {
|
||||
return nil, rejected, err
|
||||
}
|
||||
return accepted, rejected, nil
|
||||
}
|
||||
|
||||
func domainUpdateForField(field string, entries []string) DomainUpdate {
|
||||
list := append([]string{}, entries...)
|
||||
switch field {
|
||||
case "sender_denylist":
|
||||
return DomainUpdate{SenderDenylist: &list}
|
||||
case "sender_allowlist":
|
||||
return DomainUpdate{SenderAllowlist: &list}
|
||||
case "recipient_denylist":
|
||||
return DomainUpdate{RecipientDenylist: &list}
|
||||
default:
|
||||
return DomainUpdate{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) findRejectedEntries(ctx context.Context, domainName, field string, entries []string) []string {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := c.updateDomain(ctx, domainName, domainUpdateForField(field, entries), true)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusBadRequest {
|
||||
return append([]string{}, entries...)
|
||||
}
|
||||
if len(entries) == 1 {
|
||||
log.Printf("migadu: %s rejected %s entry %q", domainName, field, entries[0])
|
||||
return []string{entries[0]}
|
||||
}
|
||||
mid := len(entries) / 2
|
||||
left := c.findRejectedEntries(ctx, domainName, field, entries[:mid])
|
||||
right := c.findRejectedEntries(ctx, domainName, field, entries[mid:])
|
||||
return append(left, right...)
|
||||
}
|
||||
|
||||
// normalizeListPointers ensures list fields are non-nil when present so they
|
||||
// encode as empty strings rather than null.
|
||||
func (u *DomainUpdate) normalizeListPointers() {
|
||||
u.SenderDenylist = nonNilStringListPtr(u.SenderDenylist)
|
||||
u.SenderAllowlist = nonNilStringListPtr(u.SenderAllowlist)
|
||||
u.RecipientDenylist = nonNilStringListPtr(u.RecipientDenylist)
|
||||
}
|
||||
|
||||
func nonNilStringListPtr(list *[]string) *[]string {
|
||||
if list == nil {
|
||||
return nil
|
||||
}
|
||||
if *list == nil {
|
||||
empty := []string{}
|
||||
return &empty
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, body any, out any, quiet bool) error {
|
||||
raw, err := c.doRaw(ctx, method, path, body, quiet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out == nil || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) doRaw(ctx context.Context, method, path string, body any, quiet bool) ([]byte, error) {
|
||||
var reader io.Reader
|
||||
var encoded []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
encoded, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode request: %w", err)
|
||||
}
|
||||
reader = bytes.NewReader(encoded)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("migadu: %s %s failed for user=%q: %v", method, path, c.username, err)
|
||||
return nil, fmt.Errorf("migadu request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
responseBody := strings.TrimSpace(string(raw))
|
||||
if !quiet {
|
||||
log.Printf(
|
||||
"migadu: %s %s → HTTP %d user=%q api_key=%s request_bytes=%d request=%s body=%q",
|
||||
method,
|
||||
c.baseURL+path,
|
||||
resp.StatusCode,
|
||||
c.username,
|
||||
maskSecret(c.apiKey),
|
||||
len(encoded),
|
||||
stringOrEmpty(encoded),
|
||||
truncate(responseBody, 500),
|
||||
)
|
||||
}
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Body: responseBody}
|
||||
}
|
||||
if !quiet {
|
||||
log.Printf("migadu: %s %s → HTTP %d ok", method, path, resp.StatusCode)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func stringOrEmpty(value []byte) string {
|
||||
if len(value) == 0 {
|
||||
return "(none)"
|
||||
}
|
||||
return string(value)
|
||||
}
|
||||
|
||||
func maskSecret(secret string) string {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if secret == "" {
|
||||
return "(empty)"
|
||||
}
|
||||
if len(secret) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return secret[:4] + "…" + secret[len(secret)-4:] + fmt.Sprintf(" (len=%d)", len(secret))
|
||||
}
|
||||
|
||||
func truncate(value string, max int) string {
|
||||
if len(value) <= max {
|
||||
return value
|
||||
}
|
||||
return value[:max] + "…"
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package migadu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStringListUnmarshal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want []string
|
||||
}{
|
||||
{name: "null", raw: `null`, want: []string{}},
|
||||
{name: "empty string", raw: `""`, want: []string{}},
|
||||
{name: "csv string", raw: `"a@x.com, b@y.com"`, want: []string{"a@x.com", "b@y.com"}},
|
||||
{name: "array", raw: `["a@x.com","b@y.com"]`, want: []string{"a@x.com", "b@y.com"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var got StringList
|
||||
if err := json.Unmarshal([]byte(tc.raw), &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len=%d want %d (%v)", len(got), len(tc.want), got)
|
||||
}
|
||||
for i := range tc.want {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("got %v want %v", got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainUpdateNilListMarshalsAsEmptyArray(t *testing.T) {
|
||||
var nilList []string
|
||||
update := DomainUpdate{
|
||||
SenderDenylist: &nilList,
|
||||
SenderAllowlist: &[]string{"a@b.com", "c@d.com"},
|
||||
RecipientDenylist: &[]string{},
|
||||
}
|
||||
update.normalizeListPointers()
|
||||
|
||||
raw, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var decoded map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if string(decoded["sender_denylist"]) != `""` {
|
||||
t.Fatalf("sender_denylist=%s want empty string", decoded["sender_denylist"])
|
||||
}
|
||||
if string(decoded["sender_allowlist"]) != `"a@b.com,c@d.com"` {
|
||||
t.Fatalf("sender_allowlist=%s", decoded["sender_allowlist"])
|
||||
}
|
||||
if string(decoded["recipient_denylist"]) != `""` {
|
||||
t.Fatalf("recipient_denylist=%s want empty string", decoded["recipient_denylist"])
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user