ultisuite-backend/internal/provision/handler.go
R3D347HR4Y 951c88b1ca
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(migration): graph childFolders, parent FK, B2B hardening
- Graph mail: discover nested childFolders, merge new folders into
  cached graphFolderQueue without breaking in-progress cursors
- Add mail_folders.parent_id (migration 000050) and wire hierarchy on import
- Shared drives: skip discovery on delta ticks, guard merge by project
- Provision: remove platform-domain email rewrite on claim
- Integration tests for nested folders, parent_id, delta childFolders mocks
2026-06-13 13:16:36 +02:00

151 lines
4.6 KiB
Go

package provision
import (
"errors"
"log/slog"
"net/http"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/mail/hosted"
"github.com/ultisuite/ulti-backend/internal/migration"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
"github.com/ultisuite/ulti-backend/internal/users"
)
type Handler struct {
secret string
platformDomain string
hosted *hosted.Service
nc *nextcloud.Client
db *pgxpool.Pool
logger *slog.Logger
}
func NewHandler(secret, platformDomain string, hostedSvc *hosted.Service, nc *nextcloud.Client, db *pgxpool.Pool) *Handler {
return &Handler{
secret: strings.TrimSpace(secret),
platformDomain: strings.ToLower(strings.TrimSpace(platformDomain)),
hosted: hostedSvc,
nc: nc,
db: db,
logger: slog.Default().With("component", "provision"),
}
}
type provisionUserRequest struct {
Email string `json:"email"`
Username string `json:"username"`
Name string `json:"name"`
Password string `json:"password"`
ExternalID string `json:"external_id"`
}
func (h *Handler) ProvisionUser(w http.ResponseWriter, r *http.Request) {
if h.secret == "" {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "provision_disabled", "provisioning webhook not configured", nil)
return
}
if !authorizeProvision(r, h.secret) {
apiresponse.WriteError(w, r, http.StatusUnauthorized, "unauthorized", "invalid provision secret", nil)
return
}
req, err := decodeProvisionBody(r)
if err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, "invalid_json", "invalid request body", nil)
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if email == "" {
apiresponse.WriteError(w, r, http.StatusBadRequest, "validation_error", "email required", nil)
return
}
if h.platformDomain != "" && !strings.Contains(email, "@") {
email = email + "@" + h.platformDomain
}
ctx := r.Context()
externalID := strings.TrimSpace(req.ExternalID)
userID, err := users.ResolveProvisionUser(ctx, h.db, externalID, email, req.Name)
if err != nil {
h.logger.Error("resolve user", "error", err, "email", email)
apiresponse.WriteError(w, r, http.StatusInternalServerError, "internal_error", "failed to provision user", nil)
return
}
skipMailbox, err := migration.HasPendingMigrationInvite(ctx, h.db, email)
if err != nil {
h.logger.Error("check migration invite", "error", err, "email", email)
apiresponse.WriteError(w, r, http.StatusInternalServerError, "internal_error", "failed to check migration invite", nil)
return
}
var result hosted.ProvisionMailboxResult
if !skipMailbox {
result, err = h.hosted.EnsureMailboxProvisioned(ctx, hosted.ProvisionMailboxInput{
UserID: userID,
Email: email,
DisplayName: req.Name,
Password: req.Password,
})
if err != nil {
if errors.Is(err, hosted.ErrAddressTaken) {
apiresponse.WriteError(w, r, http.StatusConflict, "address_taken", err.Error(), nil)
return
}
h.logger.Error("provision mailbox", "error", err, "email", email)
apiresponse.WriteError(w, r, http.StatusConflict, "provision_failed", err.Error(), nil)
return
}
}
_ = migration.LinkHostedMailboxByEmail(ctx, h.db, userID, email)
if h.nc != nil && userID != "" && externalID != "" {
if _, err := h.nc.EnsurePrincipal(ctx, email, externalID, req.Name); err != nil {
h.logger.Warn("nextcloud provision", "error", err)
}
}
resp := map[string]any{
"user_id": userID,
"email": email,
}
if !skipMailbox {
resp["mailbox_id"] = result.Mailbox.ID
resp["mail_account_id"] = result.MailAccountID
}
if skipMailbox {
resp["mailbox_deferred"] = true
}
apiresponse.WriteJSON(w, http.StatusOK, resp)
}
// CheckAddress validates local part availability (Authentik expression policy or public API).
func (h *Handler) CheckAddress(w http.ResponseWriter, r *http.Request) {
if h.hosted == nil {
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"available": true})
return
}
local := strings.TrimSpace(r.URL.Query().Get("local"))
domain := strings.TrimSpace(r.URL.Query().Get("domain"))
if domain == "" {
domain = h.platformDomain
}
if local == "" || domain == "" {
apiresponse.WriteError(w, r, http.StatusBadRequest, "validation_error", "local and domain required", nil)
return
}
available, err := h.hosted.IsAddressAvailable(r.Context(), domain, local)
if err != nil {
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"available": false, "reason": err.Error()})
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"available": available})
}