ultisuite-backend/internal/api/migration/handlers.go
R3D347HR4Y 7143a36c19
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(mail): integrate Stalwart hosted mail and migration features
- Added configuration options for Stalwart hosted mail in .env.example.
- Updated Docker Compose to include Stalwart service with health checks.
- Introduced new API endpoints for managing mail domains and migration projects.
- Enhanced Authentik blueprints for user enrollment and post-migration security.
- Updated OAuth handling for Google and Microsoft migration processes.
- Improved error handling and response structures in the mail API.
- Added integration tests for email claiming and migration workflows.
2026-06-13 12:47:08 +02:00

258 lines
8.1 KiB
Go

package migrationapi
import (
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
migr "github.com/ultisuite/ulti-backend/internal/migration"
)
const maxMigrationRequestBody = 1 << 20
type Handler struct {
svc *migr.Service
oauth *migr.OAuthService
appURL string
logger *slog.Logger
}
func NewHandler(svc *migr.Service, oauth *migr.OAuthService, appURL string) *Handler {
return &Handler{
svc: svc,
oauth: oauth,
appURL: strings.TrimRight(strings.TrimSpace(appURL), "/"),
logger: slog.Default().With("component", "migration-api"),
}
}
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Post("/claim", h.ClaimInvite)
r.Get("/status", h.GetStatus)
r.Get("/oauth/providers", h.ListOAuthProviders)
r.Post("/oauth/start", h.StartOAuth)
return r
}
func (h *Handler) OAuthCallback(w http.ResponseWriter, r *http.Request) {
if handled, err := h.handleMicrosoftAdminConsentCallback(w, r); handled {
if err != nil {
h.logger.Error("microsoft admin consent", "error", err)
}
return
}
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
if state == "" || code == "" {
http.Redirect(w, r, h.appURL+"/onboard/migration?oauth=error", http.StatusFound)
return
}
pending, token, scopes, err := h.oauth.Exchange(r.Context(), state, code)
if err != nil {
h.logger.Error("oauth exchange", "error", err)
http.Redirect(w, r, h.appURL+"/onboard/migration?oauth=error", http.StatusFound)
return
}
if err := h.svc.StoreMigrationToken(r.Context(), pending.UserID, pending.ProjectID, pending.Provider, token, scopes); err != nil {
h.logger.Error("store migration token", "error", err)
http.Redirect(w, r, h.appURL+"/onboard/migration?oauth=error", http.StatusFound)
return
}
redirect := h.appURL + "/onboard/migration?oauth=success"
if pending.InviteToken != "" {
redirect = h.appURL + "/onboard/migration?oauth=success&token=" + pending.InviteToken
}
http.Redirect(w, r, redirect, http.StatusFound)
}
func (h *Handler) handleMicrosoftAdminConsentCallback(w http.ResponseWriter, r *http.Request) (bool, error) {
q := r.URL.Query()
projectID := migr.ParseAdminConsentProjectID(q.Get("state"))
granted := strings.EqualFold(q.Get("admin_consent"), "True")
oauthErr := strings.TrimSpace(q.Get("error"))
if !granted && (projectID == "" || oauthErr == "") {
return false, nil
}
if h.oauth == nil {
return true, migr.ErrProviderDisabled
}
record := migr.MicrosoftAdminConsentRecord{
TenantID: q.Get("tenant"),
ClientID: h.oauth.MicrosoftClientID(),
ProjectID: projectID,
Granted: granted,
ErrorCode: oauthErr,
ErrorDescription: q.Get("error_description"),
}
if err := h.svc.RecordMicrosoftAdminConsent(r.Context(), record); err != nil {
redirect := h.appURL + "/admin/settings/mail-domains?microsoft_admin_consent=error"
http.Redirect(w, r, redirect, http.StatusFound)
return true, err
}
redirect := h.appURL + "/admin/settings/mail-domains?microsoft_admin_consent=success"
if !granted {
redirect = h.appURL + "/admin/settings/mail-domains?microsoft_admin_consent=error"
}
if tenant := strings.TrimSpace(record.TenantID); tenant != "" {
redirect += "&tenant=" + url.QueryEscape(tenant)
}
if projectID != "" {
redirect += "&project_id=" + url.QueryEscape(projectID)
}
http.Redirect(w, r, redirect, http.StatusFound)
return true, nil
}
func (h *Handler) GetInvite(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(r.URL.Query().Get("token"))
if token == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "token", Message: "token required",
}))
return
}
inv, proj, err := h.svc.GetInviteByToken(r.Context(), token)
if err != nil {
apiresponse.WriteError(w, r, http.StatusNotFound, "invite_not_found", err.Error(), nil)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
"invite": inv,
"project": proj,
"onboarding": h.svc.BuildInviteOnboardingHints(proj, inv),
})
}
type claimRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
func (h *Handler) ClaimInvite(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if claims == nil {
apiresponse.WriteError(w, r, http.StatusUnauthorized, "unauthorized", "authentication required", nil)
return
}
var req claimRequest
if err := apivalidate.DecodeJSON(w, r, maxMigrationRequestBody, &req); err != nil {
return
}
userID, err := h.svc.LookupUserID(r.Context(), claims.Sub)
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
status, err := h.svc.ClaimInvite(r.Context(), req.Token, userID, migr.ClaimIdentityFromAuth(claims), claims.Name, req.Password)
if err != nil {
code := http.StatusBadRequest
errCode := "claim_failed"
switch {
case err == migr.ErrInviteNotFound:
code = http.StatusNotFound
errCode = "invite_not_found"
case err == migr.ErrInviteClaimed:
errCode = "invite_already_claimed"
case err == migr.ErrEmailMismatch:
errCode = "email_mismatch"
case err == migr.ErrMigrationDomainNotActive:
errCode = "migration_domain_not_active"
case err == migr.ErrMigrationDomainMismatch:
errCode = "migration_domain_mismatch"
}
apiresponse.WriteError(w, r, code, errCode, err.Error(), nil)
return
}
apiresponse.WriteJSON(w, http.StatusOK, status)
}
func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if claims == nil {
apiresponse.WriteError(w, r, http.StatusUnauthorized, "unauthorized", "authentication required", nil)
return
}
userID, err := h.svc.LookupUserID(r.Context(), claims.Sub)
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
status, err := h.svc.GetActiveUserStatus(r.Context(), userID)
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, status)
}
func (h *Handler) ListOAuthProviders(w http.ResponseWriter, r *http.Request) {
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"providers": h.oauth.EnabledProviders()})
}
type startOAuthRequest struct {
Provider string `json:"provider"`
InviteToken string `json:"invite_token"`
ProjectID string `json:"project_id"`
}
func (h *Handler) StartOAuth(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if claims == nil {
apiresponse.WriteError(w, r, http.StatusUnauthorized, "unauthorized", "authentication required", nil)
return
}
var req startOAuthRequest
if err := apivalidate.DecodeJSON(w, r, maxMigrationRequestBody, &req); err != nil {
return
}
userID, err := h.svc.LookupUserID(r.Context(), claims.Sub)
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
projectID := req.ProjectID
if projectID == "" && req.InviteToken != "" {
inv, proj, err := h.svc.GetInviteByToken(r.Context(), req.InviteToken)
if err != nil {
apiresponse.WriteError(w, r, http.StatusNotFound, "invite_not_found", err.Error(), nil)
return
}
if inv.UserID != "" && inv.UserID != userID {
apiresponse.WriteError(w, r, http.StatusForbidden, "forbidden", "invite belongs to another user", nil)
return
}
projectID = proj.ID
}
if projectID == "" {
st, err := h.svc.GetActiveUserStatus(r.Context(), userID)
if err != nil || st.Project.ID == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "project_id", Message: "project_id required",
}))
return
}
projectID = st.Project.ID
}
provider := migr.Provider(strings.ToLower(strings.TrimSpace(req.Provider)))
if provider == "" {
provider = migr.ProviderGoogle
}
authURL, _, err := h.oauth.Start(r.Context(), migr.PendingOAuth{
UserID: userID,
ProjectID: projectID,
InviteToken: req.InviteToken,
}, provider)
if err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, "oauth_start_failed", err.Error(), nil)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"auth_url": authURL})
}