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")
|
||||
}
|
||||
Reference in New Issue
Block a user