diff --git a/admin/cmd/main.go b/admin/cmd/main.go index 693382f..bada1f4 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -30,6 +30,7 @@ func main() { } licensing.SetSigningKey(cfg.SigningKey) + api.SetAppLoginURL(cfg.AppLoginURL) mail.Init(mail.Config{ Host: cfg.SMTPHost, Port: cfg.SMTPPort, From: cfg.SMTPFrom, diff --git a/admin/internal/api/customer.go b/admin/internal/api/customer.go index a7f16f5..5d71a11 100644 --- a/admin/internal/api/customer.go +++ b/admin/internal/api/customer.go @@ -3,16 +3,23 @@ package api import ( "errors" "fmt" + "log" "net/http" + "net/url" + "strings" + "time" "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/admin/internal/audit" "github.com/mrhid6/vantage/admin/internal/auth" + "github.com/mrhid6/vantage/admin/internal/cloudprov" "github.com/mrhid6/vantage/admin/internal/db" "github.com/mrhid6/vantage/admin/internal/inject" "github.com/mrhid6/vantage/admin/internal/licensing" "github.com/mrhid6/vantage/admin/internal/mail" "github.com/mrhid6/vantage/admin/internal/models" "github.com/mrhid6/vantage/shared/license" + "github.com/mrhid6/vantage/shared/provision" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -187,6 +194,135 @@ func listSubscriptions(c *gin.Context) { c.JSON(http.StatusOK, subs) } +// createInstance provisions a Free cloud instance for the calling account. +// +// The ordering matters and each step unwinds the previous one. Licence issuance +// and email are deliberately NOT allowed to fail the request: the instance +// exists and the customer can sign in, they see the licence banner, and staff +// can issue by hand. Rolling back an instance the customer can already see would +// be worse than shipping it unlicensed. +func createInstance(c *gin.Context) { + var body struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + name := strings.TrimSpace(body.Name) + ctx := c.Request.Context() + s := auth.Current(c) + + // Pre-check the Free rule so we never create an instance we then cannot + // licence. licensing.Issue enforces it too; this is the friendly refusal. + n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{ + "account_id": s.AccountID, + "tier": license.TierFree, + "status": bson.M{"$ne": models.StatusCancelled}, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not check your account"}) + return + } + if n > 0 { + c.JSON(http.StatusConflict, gin.H{ + "error": "this account already has a Free instance"}) + return + } + + var cu models.CustomerUser + if err := db.Admin("customer_users").FindOne(ctx, + bson.M{"user_id": s.UserID}).Decode(&cu); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"}) + return + } + + inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID) + if err != nil { + if errors.Is(err, provision.ErrEmailTaken) { + // users.email is unique per instance, so this means the address + // already owns a user in an instance we are not creating — a legacy + // cloud tenant. Staff have to attach that one by hand. + c.JSON(http.StatusConflict, gin.H{ + "error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"}) + return + } + if errors.Is(err, provision.ErrNameRejected) { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"}) + return + } + + rec := models.Instance{ + InstanceID: inst.InstanceID, + AccountID: s.AccountID, + Name: inst.Name, + Slug: inst.Slug, + Deployment: license.DeploymentCloud, + Status: models.StatusActive, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil { + // Unwind in reverse: the owner first, because RollbackInstance refuses + // an instance that still has users. + if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil { + _ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid) + } + if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil { + log.Printf("createInstance: rollback of %s failed: %v", inst.InstanceID, e) + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"}) + return + } + + audit.Write(ctx, models.AuditEntry{ + Actor: s.Email, Action: "instance.created", AccountID: s.AccountID, + Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()}) + + // Past this point nothing fails the request. + lic, err := licensing.Issue(ctx, licensing.IssueInput{ + InstanceID: inst.InstanceID, + Tier: license.TierFree, + Term: "monthly", + Reason: models.ReasonNew, + IssuedBy: "self-serve", + }) + if err != nil { + log.Printf("ISSUE FAILED for new instance %s: %v", inst.InstanceID, err) + c.JSON(http.StatusCreated, rec) + return + } + inject.Deliver(ctx, lic) + + if mail.Enabled() { + if err := mail.SendInstanceReady(s.Email, inst.Name, + loginURLFor(inst.Slug), lic.ExpiresAt); err != nil { + log.Printf("createInstance: instance-ready email to %s: %v", s.Email, err) + } + } + + rec.Tier = lic.Tier + rec.CurrentLicense = lic.LicenseID + c.JSON(http.StatusCreated, rec) +} + +// loginURLFor fills the {slug} template in APP_LOGIN_URL. An empty template +// yields an empty string, and the email simply omits the link. +func loginURLFor(slug string) string { + if appLoginURL == "" { + return "" + } + return strings.ReplaceAll(appLoginURL, "{slug}", url.PathEscape(slug)) +} + +// appLoginURL is set once at boot from config. +var appLoginURL string + +// SetAppLoginURL is called from main. +func SetAppLoginURL(v string) { appLoginURL = v } + // deliver sends a freshly issued licence where it needs to go. Cloud instances // are injected; self-hosted customers are emailed and can download. // diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index e8ad0b3..dfe1f49 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -41,6 +41,7 @@ func Routes(cfg config.Config) http.Handler { cust.Use(auth.RequireCustomer()) { cust.GET("/account", getAccount) + cust.POST("/instances", createInstance) cust.POST("/instances/link", linkInstance) cust.POST("/instances/:id/relink", relinkInstance) cust.GET("/instances/:id/license", getInstanceLicense) diff --git a/admin/internal/mail/mail.go b/admin/internal/mail/mail.go index 2e69207..14da8e3 100644 --- a/admin/internal/mail/mail.go +++ b/admin/internal/mail/mail.go @@ -5,6 +5,7 @@ import ( "fmt" "net/smtp" "strings" + "time" ) type Config struct { @@ -53,3 +54,22 @@ func SendLicense(to, instanceName, blob string) error { "Paste it into Settings → Licence on your Vantage install:\n\n%s\n", instanceName, blob)) } + +// SendInstanceReady tells a customer their cloud instance exists, where it is, +// and when its licence runs out. +// +// The expiry is stated here rather than only in a later reminder: a Free licence +// that quietly expires in a month is a surprise, and the first email is the one +// people keep. +func SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error { + body := fmt.Sprintf("%s is ready.\n\n", instanceName) + if loginURL != "" { + body += "Sign in here:\n\n" + loginURL + "\n\n" + } + body += fmt.Sprintf( + "Your Free licence runs until %s. We will email you before then so you can renew it in one click.\n\n"+ + "Sign in with the same email address and password you use for your Vantage account. "+ + "Changing one does not change the other.\n", + expires.Format("2 January 2006")) + return send(to, instanceName+" is ready", body) +} diff --git a/admin/internal/models/models.go b/admin/internal/models/models.go index 47a45c8..e13eb02 100644 --- a/admin/internal/models/models.go +++ b/admin/internal/models/models.go @@ -18,6 +18,11 @@ const ( StatusActive = "active" StatusLapsed = "lapsed" StatusCancelled = "cancelled" + + // StatusDeleted marks an instance the control plane has reaped. The row is + // kept because the licence history references it and support questions + // outlive the instance. + StatusDeleted = "deleted" ) // Account statuses. @@ -75,7 +80,12 @@ type Instance struct { CurrentLicense string `bson:"current_license,omitempty" json:"current_license,omitempty"` RelinkCount int `bson:"relink_count" json:"relink_count"` InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` + // NoticesSent holds the lifecycle notice keys already emailed for the + // CURRENT term ("expiring", "expired", "delete_7", "delete_1"). Renewal + // clears it, so the next term starts the sequence again. It is what stops a + // restart re-sending a notice. + NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` } // License is append-only. A renewal writes a new row and sets SupersededBy on