forked from Nixius/authelia
Path-based routing, tiered pricing, customer details, Stripe MCP
- Path-based routing: bc.a250.ca/login, /dashboard, /activate, etc. - Tiered pricing: first 10 free (3mo), next 40 $20/yr->$100/mo, 51+ $200/mo - Success page: instructs to check inbox for password + 2FA setup - Messaging: LANDING_TAGLINE, LANDING_FEATURES env vars - Domain validation: required, must resolve via DNS - Customer details: phone required, sanitized email/phone, stored in LLDAP - Stripe MCP: .cursor/mcp.json, stripe-setup.sh for products/prices - Invoice webhook: migrate $20/yr subs to $100/mo on renewal Made-with: Cursor
This commit is contained in:
parent
7e40fea6f3
commit
4ac4de9df2
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"stripe": {
|
||||
"command": "sh",
|
||||
"args": [
|
||||
"-c",
|
||||
"set -a && [ -f .env ] && . ./.env; set +a; exec npx -y @stripe/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -58,8 +58,8 @@ session:
|
|||
remember_me: 1M
|
||||
cookies:
|
||||
- domain: {{ env "TRAEFIK_DOMAIN" }}
|
||||
authelia_url: 'https://login.{{ env "TRAEFIK_DOMAIN" }}'
|
||||
default_redirection_url: 'https://app.{{ env "TRAEFIK_DOMAIN" }}/dashboard'
|
||||
authelia_url: 'https://{{ env "TRAEFIK_DOMAIN" }}/login'
|
||||
default_redirection_url: 'https://{{ env "TRAEFIK_DOMAIN" }}/dashboard'
|
||||
name: 'authelia_session'
|
||||
same_site: 'lax'
|
||||
inactivity: '5m'
|
||||
|
|
|
|||
|
|
@ -1,15 +1,27 @@
|
|||
package config
|
||||
|
||||
import "os"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
AppURL string
|
||||
AutheliaURL string
|
||||
AutheliaInternalURL string
|
||||
StripeSecretKey string
|
||||
StripeSecretKey string
|
||||
StripeWebhookSecret string
|
||||
StripePriceID string
|
||||
StripePriceID string // Fallback when tier prices not set
|
||||
StripePaymentLink string // Optional: legacy Payment Link for $0
|
||||
StripePriceIDFree string // $0/3mo, auto-cancel (first 10)
|
||||
StripePriceIDYear string // $20/year (customers 11–50)
|
||||
StripePriceIDMonth100 string // $100/month (after year for 11–50)
|
||||
StripePriceIDMonth200 string // $200/month (customers 51+)
|
||||
FreeTierLimit int // First N get free tier (default 10)
|
||||
YearTierLimit int // Up to this count get year tier (default 50)
|
||||
MaxSignups int // Cap on new signups (0 = no limit)
|
||||
LDAPUrl string
|
||||
LDAPAdminDN string
|
||||
LDAPAdminPassword string
|
||||
|
|
@ -18,31 +30,45 @@ type Config struct {
|
|||
DockerHost string
|
||||
TraefikDomain string
|
||||
TraefikNetwork string
|
||||
TemplatePath string
|
||||
CustomerDomain string
|
||||
ArchivePath string
|
||||
TemplatePath string
|
||||
CustomerDomain string
|
||||
ArchivePath string
|
||||
LandingTagline string // Main tagline under logo
|
||||
LandingFeatures []string // Bullet points on subscribe card (comma-separated in env)
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Port: envOrDefault("PORT", "8080"),
|
||||
AppURL: envOrDefault("APP_URL", "http://app.bc.a250.ca"),
|
||||
AutheliaURL: envOrDefault("AUTHELIA_URL", "http://login.bc.a250.ca"),
|
||||
AutheliaInternalURL: envOrDefault("AUTHELIA_INTERNAL_URL", "http://authelia_dev_main:9091"),
|
||||
StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""),
|
||||
StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""),
|
||||
StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""),
|
||||
LDAPUrl: envOrDefault("LLDAP_URL", "ldap://lldap_lldap:3890"),
|
||||
LDAPAdminDN: envOrDefault("LLDAP_ADMIN_DN", "uid=admin,ou=people,dc=a250,dc=ca"),
|
||||
LDAPAdminPassword: envOrDefault("LLDAP_ADMIN_PASSWORD", ""),
|
||||
LDAPBaseDN: envOrDefault("LLDAP_BASE_DN", "dc=a250,dc=ca"),
|
||||
LLDAPHttpURL: envOrDefault("LLDAP_HTTP_URL", "http://lldap:17170"),
|
||||
DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"),
|
||||
TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"),
|
||||
TraefikNetwork: envOrDefault("TRAEFIK_NETWORK", "authelia_dev"),
|
||||
AppURL: envOrDefault("APP_URL", "https://bc.a250.ca"),
|
||||
AutheliaURL: envOrDefault("AUTHELIA_URL", "https://bc.a250.ca/login"),
|
||||
AutheliaInternalURL: envOrDefault("AUTHELIA_INTERNAL_URL", "http://authelia_dev_main:9091/login"),
|
||||
StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""),
|
||||
StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""),
|
||||
StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""),
|
||||
StripePaymentLink: envOrDefault("STRIPE_PAYMENT_LINK", ""),
|
||||
StripePriceIDFree: envOrDefault("STRIPE_PRICE_ID_FREE", ""),
|
||||
StripePriceIDYear: envOrDefault("STRIPE_PRICE_ID_YEAR", ""),
|
||||
StripePriceIDMonth100: envOrDefault("STRIPE_PRICE_ID_MONTH_100", ""),
|
||||
StripePriceIDMonth200: envOrDefault("STRIPE_PRICE_ID_MONTH_200", ""),
|
||||
FreeTierLimit: envIntOrDefault("FREE_TIER_LIMIT", 10),
|
||||
YearTierLimit: envIntOrDefault("YEAR_TIER_LIMIT", 50),
|
||||
MaxSignups: envIntOrDefault("MAX_SIGNUPS", 0),
|
||||
LDAPUrl: envOrDefault("LLDAP_URL", "ldap://lldap_lldap:3890"),
|
||||
LDAPAdminDN: envOrDefault("LLDAP_ADMIN_DN", "uid=admin,ou=people,dc=a250,dc=ca"),
|
||||
LDAPAdminPassword: envOrDefault("LLDAP_ADMIN_PASSWORD", ""),
|
||||
LDAPBaseDN: envOrDefault("LLDAP_BASE_DN", "dc=a250,dc=ca"),
|
||||
LLDAPHttpURL: envOrDefault("LLDAP_HTTP_URL", "http://lldap:17170"),
|
||||
DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"),
|
||||
TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"),
|
||||
TraefikNetwork: envOrDefault("TRAEFIK_NETWORK", "authelia_dev"),
|
||||
TemplatePath: envOrDefault("TEMPLATE_PATH", "/app/templates"),
|
||||
CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "app.a250.ca"),
|
||||
CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "bc.a250.ca"),
|
||||
ArchivePath: envOrDefault("ARCHIVE_PATH", "/archives"),
|
||||
LandingTagline: envOrDefault("LANDING_TAGLINE",
|
||||
"Your own workspace, ready in minutes."),
|
||||
LandingFeatures: envListOrDefault("LANDING_FEATURES",
|
||||
[]string{"Dedicated environment", "Secure single sign-on", "Automatic provisioning", "Manage subscription anytime"}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,3 +79,29 @@ func envOrDefault(key, fallback string) string {
|
|||
return fallback
|
||||
}
|
||||
|
||||
func envIntOrDefault(key string, fallback int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envListOrDefault(key string, fallback []string) []string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
parts := strings.Split(v, "|")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if s := strings.TrimSpace(p); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ func TestEnvOrDefault(t *testing.T) {
|
|||
func TestLoadDefaults(t *testing.T) {
|
||||
// Clear env vars that Load uses
|
||||
envKeys := []string{
|
||||
"PORT", "APP_URL", "AUTHELIA_URL", "STRIPE_SECRET_KEY",
|
||||
"STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "LLDAP_URL",
|
||||
"PORT", "APP_URL", "AUTHELIA_URL", "STRIPE_SECRET_KEY",
|
||||
"STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "STRIPE_PAYMENT_LINK", "MAX_SIGNUPS", "LLDAP_URL",
|
||||
"LLDAP_ADMIN_DN", "LLDAP_ADMIN_PASSWORD", "LLDAP_BASE_DN",
|
||||
"DOCKER_HOST", "TRAEFIK_DOMAIN", "TRAEFIK_NETWORK", "TEMPLATE_PATH", "CUSTOMER_DOMAIN",
|
||||
}
|
||||
|
|
@ -48,8 +48,8 @@ func TestLoadDefaults(t *testing.T) {
|
|||
want string
|
||||
}{
|
||||
{"Port", cfg.Port, "8080"},
|
||||
{"AppURL", cfg.AppURL, "http://app.bc.a250.ca"},
|
||||
{"AutheliaURL", cfg.AutheliaURL, "http://login.bc.a250.ca"},
|
||||
{"AppURL", cfg.AppURL, "https://bc.a250.ca"},
|
||||
{"AutheliaURL", cfg.AutheliaURL, "https://bc.a250.ca/login"},
|
||||
{"LDAPUrl", cfg.LDAPUrl, "ldap://lldap_lldap:3890"},
|
||||
{"LDAPAdminDN", cfg.LDAPAdminDN, "uid=admin,ou=people,dc=a250,dc=ca"},
|
||||
{"LDAPBaseDN", cfg.LDAPBaseDN, "dc=a250,dc=ca"},
|
||||
|
|
@ -57,13 +57,22 @@ func TestLoadDefaults(t *testing.T) {
|
|||
{ "TraefikDomain", cfg.TraefikDomain, "bc.a250.ca"},
|
||||
{"TraefikNetwork", cfg.TraefikNetwork, "authelia_dev"},
|
||||
{"TemplatePath", cfg.TemplatePath, "/app/templates"},
|
||||
{"CustomerDomain", cfg.CustomerDomain, "app.a250.ca"},
|
||||
{"CustomerDomain", cfg.CustomerDomain, "bc.a250.ca"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("Load().%s = %q, want %q", tt.field, tt.got, tt.want)
|
||||
}
|
||||
}
|
||||
if cfg.MaxSignups != 0 {
|
||||
t.Errorf("Load().MaxSignups = %d, want 0", cfg.MaxSignups)
|
||||
}
|
||||
if cfg.FreeTierLimit != 10 {
|
||||
t.Errorf("Load().FreeTierLimit = %d, want 10", cfg.FreeTierLimit)
|
||||
}
|
||||
if cfg.YearTierLimit != 50 {
|
||||
t.Errorf("Load().YearTierLimit = %d, want 50", cfg.YearTierLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnv(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||
stackDeployed := false
|
||||
stackRunning := false
|
||||
var subStatus *ssstripe.SubscriptionStatus
|
||||
paidNotActivated := false
|
||||
|
||||
if remoteUser != "" {
|
||||
cid, _ := a.ldap.GetStripeCustomerID(remoteUser)
|
||||
if cid != "" && !isSubscribed {
|
||||
paidNotActivated = true
|
||||
}
|
||||
}
|
||||
|
||||
if isSubscribed && remoteUser != "" {
|
||||
cid, err := a.ldap.GetStripeCustomerID(remoteUser)
|
||||
|
|
@ -53,20 +61,27 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
customerDomain := ""
|
||||
if remoteUser != "" {
|
||||
customerDomain, _ = a.ldap.GetCustomerDomain(remoteUser)
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"AppURL": a.cfg.AppURL,
|
||||
"AutheliaURL": a.cfg.AutheliaURL,
|
||||
"User": remoteUser,
|
||||
"Email": remoteEmail,
|
||||
"Groups": remoteGroups,
|
||||
"Domain": a.cfg.TraefikDomain,
|
||||
"IsSubscribed": isSubscribed,
|
||||
"AppURL": a.cfg.AppURL,
|
||||
"AutheliaURL": a.cfg.AutheliaURL,
|
||||
"User": remoteUser,
|
||||
"Email": remoteEmail,
|
||||
"Groups": remoteGroups,
|
||||
"Domain": a.cfg.TraefikDomain,
|
||||
"IsSubscribed": isSubscribed,
|
||||
"PaidNotActivated": paidNotActivated,
|
||||
"CustomerID": customerID,
|
||||
"SubStatus": subStatus,
|
||||
"StackDeployed": stackDeployed,
|
||||
"StackRunning": stackRunning,
|
||||
"Commit": version.Commit,
|
||||
"BuildTime": version.BuildTime,
|
||||
"StackDeployed": stackDeployed,
|
||||
"StackRunning": stackRunning,
|
||||
"CustomerDomain": customerDomain,
|
||||
"Commit": version.Commit,
|
||||
"BuildTime": version.BuildTime,
|
||||
}
|
||||
|
||||
if err := a.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil {
|
||||
|
|
|
|||
|
|
@ -56,13 +56,14 @@ func TestSanitizeUsername(t *testing.T) {
|
|||
email string
|
||||
want string
|
||||
}{
|
||||
{"user@example.com", "user"},
|
||||
{"User.Name@domain.com", "user-name"},
|
||||
{"user_name@domain.com", "user_name"},
|
||||
{"user123@domain.com", "user123"},
|
||||
{"UPPER@domain.com", "upper"},
|
||||
{"a.b.c@x.com", "a-b-c"},
|
||||
{"spécial@x.com", "sp-cial"},
|
||||
{"user@example.com", "user-example"},
|
||||
{"User.Name@domain.com", "user-name-domain"},
|
||||
{"user_name@domain.com", "user_name-domain"},
|
||||
{"user123@domain.com", "user123-domain"},
|
||||
{"UPPER@domain.com", "upper-domain"},
|
||||
{"a.b.c@x.com", "a-b-c-x"},
|
||||
{"spécial@x.com", "sp-cial-x"},
|
||||
{"alice@nixc.us", "alice-nixc"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := sanitizeUsername(tt.email); got != tt.want {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.nixc.us/a250/ss-atlas/internal/pricing"
|
||||
"git.nixc.us/a250/ss-atlas/internal/stripe"
|
||||
"git.nixc.us/a250/ss-atlas/internal/validation"
|
||||
"git.nixc.us/a250/ss-atlas/internal/version"
|
||||
)
|
||||
|
||||
|
|
@ -26,10 +31,22 @@ func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
count, _ := a.ldap.CountCustomers()
|
||||
soldOut := a.cfg.MaxSignups > 0 && count >= a.cfg.MaxSignups
|
||||
tier := pricing.ForCustomer(count, a.cfg.FreeTierLimit, a.cfg.YearTierLimit)
|
||||
|
||||
useForm := a.cfg.StripePriceID != "" || a.cfg.StripePriceIDFree != "" ||
|
||||
a.cfg.StripePriceIDYear != "" || a.cfg.StripePriceIDMonth200 != ""
|
||||
data := map[string]any{
|
||||
"AppURL": a.cfg.AppURL,
|
||||
"Commit": version.Commit,
|
||||
"BuildTime": version.BuildTime,
|
||||
"AppURL": a.cfg.AppURL,
|
||||
"Commit": version.Commit,
|
||||
"BuildTime": version.BuildTime,
|
||||
"StripePaymentLink": a.cfg.StripePaymentLink,
|
||||
"SoldOut": soldOut,
|
||||
"PricingTier": int(tier),
|
||||
"UseCheckoutForm": useForm,
|
||||
"Tagline": a.cfg.LandingTagline,
|
||||
"Features": a.cfg.LandingFeatures,
|
||||
}
|
||||
if err := a.tmpl.ExecuteTemplate(w, "landing.html", data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
|
|
@ -38,14 +55,43 @@ func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (a *App) handleCreateCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
email := r.FormValue("email")
|
||||
if a.cfg.MaxSignups > 0 {
|
||||
count, err := a.ldap.CountCustomers()
|
||||
if err == nil && count >= a.cfg.MaxSignups {
|
||||
http.Error(w, "signup limit reached, try again later", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rawEmail := r.FormValue("email")
|
||||
email := validation.SanitizeEmail(rawEmail)
|
||||
if email == "" {
|
||||
http.Error(w, "email required", http.StatusBadRequest)
|
||||
http.Error(w, "valid email required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
domain := strings.TrimSpace(r.FormValue("domain"))
|
||||
if domain == "" {
|
||||
http.Error(w, "domain required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !validation.DomainResolves(domain, 5*time.Second) {
|
||||
http.Error(w, "domain does not resolve; please enter a valid domain", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rawPhone := r.FormValue("phone")
|
||||
phone := validation.SanitizePhone(rawPhone)
|
||||
if phone == "" {
|
||||
http.Error(w, "valid phone number required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := a.stripe.CreateCheckoutSession(email)
|
||||
count, _ := a.ldap.CountCustomers()
|
||||
sess, err := a.stripe.CreateCheckoutSession(email, domain, phone, count)
|
||||
if err != nil {
|
||||
if errors.Is(err, stripe.ErrNoPriceForTier) {
|
||||
http.Error(w, "pricing not configured for current tier", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
log.Printf("stripe checkout error: %v", err)
|
||||
http.Error(w, "failed to create checkout", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -61,6 +107,14 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if a.cfg.MaxSignups > 0 {
|
||||
count, err := a.ldap.CountCustomers()
|
||||
if err == nil && count >= a.cfg.MaxSignups {
|
||||
http.Error(w, "signup limit reached, contact support", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sess, err := a.stripe.GetCheckoutSession(sessionID)
|
||||
if err != nil {
|
||||
log.Printf("stripe get session error: %v", err)
|
||||
|
|
@ -76,32 +130,32 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
|
|||
email := sess.CustomerDetails.Email
|
||||
customerID := sess.Customer.ID
|
||||
username := sanitizeUsername(email)
|
||||
phone := ""
|
||||
if sess.Metadata != nil {
|
||||
phone = sess.Metadata["customer_phone"]
|
||||
}
|
||||
|
||||
result, err := a.ldap.ProvisionUser(username, email, customerID)
|
||||
result, err := a.ldap.ProvisionUser(username, email, customerID, phone)
|
||||
if err != nil {
|
||||
log.Printf("ldap provision failed for %s: %v", email, err)
|
||||
http.Error(w, "account creation failed, contact support", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if sess.Metadata != nil && sess.Metadata["customer_domain"] != "" {
|
||||
if err := a.ldap.SetCustomerDomain(result.Username, sess.Metadata["customer_domain"]); err != nil {
|
||||
log.Printf("ldap set customer domain failed for %s: %v", result.Username, err)
|
||||
}
|
||||
}
|
||||
|
||||
inGroup, _ := a.ldap.IsInGroup(result.Username, "customers")
|
||||
|
||||
if result.IsNew || !inGroup {
|
||||
// New or lapsed customer: send password setup email, show onboarding.
|
||||
// Group membership and stack deploy happen on /activate after they log in.
|
||||
// New or lapsed: send password email, show success page.
|
||||
if err := a.triggerPasswordReset(result.Username); err != nil {
|
||||
log.Printf("authelia reset trigger failed for %s: %v", username, err)
|
||||
}
|
||||
data := map[string]any{
|
||||
"Username": result.Username,
|
||||
"IsNew": result.IsNew,
|
||||
"Email": email,
|
||||
"LoginURL": a.cfg.AutheliaURL,
|
||||
"ActivateURL": a.cfg.AppURL + "/activate",
|
||||
"DashboardURL": a.cfg.AppURL + "/dashboard",
|
||||
"InstanceURL": "https://" + result.Username + "." + a.cfg.CustomerDomain,
|
||||
}
|
||||
if err := a.tmpl.ExecuteTemplate(w, "welcome.html", data); err != nil {
|
||||
if err := a.tmpl.ExecuteTemplate(w, "success.html", map[string]any{"AppURL": a.cfg.AppURL}); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
|
|
@ -150,8 +204,13 @@ func (a *App) handleResubscribe(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
sess, err := a.stripe.CreateCheckoutForCustomer(customerID)
|
||||
count, _ := a.ldap.CountCustomers()
|
||||
sess, err := a.stripe.CreateCheckoutForCustomer(customerID, count)
|
||||
if err != nil {
|
||||
if errors.Is(err, stripe.ErrNoPriceForTier) {
|
||||
http.Error(w, "pricing not configured for current tier", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
log.Printf("stripe resubscribe error: %v", err)
|
||||
http.Error(w, "failed to create checkout session", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ func (a *App) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||
a.onSubscriptionDeleted(event)
|
||||
case "customer.subscription.updated":
|
||||
a.onSubscriptionUpdated(event)
|
||||
case "invoice.paid":
|
||||
a.onInvoicePaid(event)
|
||||
default:
|
||||
log.Printf("unhandled webhook event: %s", event.Type)
|
||||
}
|
||||
|
|
@ -42,8 +44,7 @@ func (a *App) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Reconciliation backstop: ensures LLDAP user + Stripe ID are set.
|
||||
// Does NOT send password reset — that's the success page's responsibility
|
||||
// so it can reliably show the welcome/onboarding page.
|
||||
// Does NOT send password reset — that's the success page's responsibility.
|
||||
func (a *App) onCheckoutCompleted(event stripego.Event) {
|
||||
var sess stripego.CheckoutSession
|
||||
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
|
||||
|
|
@ -54,12 +55,48 @@ func (a *App) onCheckoutCompleted(event stripego.Event) {
|
|||
email := sess.CustomerDetails.Email
|
||||
customerID := sess.Customer.ID
|
||||
username := sanitizeUsername(email)
|
||||
phone := ""
|
||||
if sess.Metadata != nil {
|
||||
phone = sess.Metadata["customer_phone"]
|
||||
}
|
||||
|
||||
log.Printf("webhook: checkout completed email=%s customer=%s", email, customerID)
|
||||
|
||||
if err := a.ldap.EnsureUser(username, email, customerID); err != nil {
|
||||
if a.cfg.MaxSignups > 0 {
|
||||
count, err := a.ldap.CountCustomers()
|
||||
if err == nil && count >= a.cfg.MaxSignups {
|
||||
log.Printf("webhook: signup limit reached (%d), skipping provision for %s", a.cfg.MaxSignups, email)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.ldap.EnsureUser(username, email, customerID, phone); err != nil {
|
||||
log.Printf("webhook: ldap ensure user failed: %v", err)
|
||||
}
|
||||
if sess.Metadata != nil {
|
||||
if d := sess.Metadata["customer_domain"]; d != "" {
|
||||
if err := a.ldap.SetCustomerDomain(username, d); err != nil {
|
||||
log.Printf("webhook: ldap set customer domain failed for %s: %v", username, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subID := ""
|
||||
if sess.Subscription != nil && sess.Subscription.ID != "" {
|
||||
subID = sess.Subscription.ID
|
||||
} else {
|
||||
var raw struct {
|
||||
Subscription interface{} `json:"subscription"`
|
||||
}
|
||||
if json.Unmarshal(event.Data.Raw, &raw) == nil {
|
||||
if s, ok := raw.Subscription.(string); ok {
|
||||
subID = s
|
||||
}
|
||||
}
|
||||
}
|
||||
if subID != "" {
|
||||
a.maybeScheduleFreeTierCancel(subID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) onSubscriptionDeleted(event stripego.Event) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
stripego "github.com/stripe/stripe-go/v84"
|
||||
"github.com/stripe/stripe-go/v84/subscription"
|
||||
)
|
||||
|
||||
const freeTierMonths = 3
|
||||
|
||||
// maybeScheduleFreeTierCancel fetches the subscription and, if it uses the free-tier
|
||||
// price, sets cancel_at to 3 months from now so it auto-cancels at end of term.
|
||||
func (a *App) maybeScheduleFreeTierCancel(subID string) {
|
||||
sub, err := a.stripe.GetSubscription(subID)
|
||||
if err != nil {
|
||||
log.Printf("webhook: could not fetch subscription %s: %v", subID, err)
|
||||
return
|
||||
}
|
||||
if sub.Items == nil || len(sub.Items.Data) == 0 {
|
||||
return
|
||||
}
|
||||
priceID := sub.Items.Data[0].Price.ID
|
||||
if !a.stripe.IsFreeTierPrice(priceID) {
|
||||
return
|
||||
}
|
||||
cancelAt := time.Now().AddDate(0, freeTierMonths, 0).Unix()
|
||||
if err := a.stripe.ScheduleSubscriptionCancelAt(subID, cancelAt); err != nil {
|
||||
log.Printf("webhook: failed to schedule free-tier cancel for sub %s: %v", subID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("webhook: scheduled free-tier sub %s to cancel at %d (3 months)", subID, cancelAt)
|
||||
}
|
||||
|
||||
type invoicePaidPayload struct {
|
||||
BillingReason stripego.InvoiceBillingReason `json:"billing_reason"`
|
||||
Subscription interface{} `json:"subscription"`
|
||||
Lines struct {
|
||||
Data []struct {
|
||||
Price struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"price"`
|
||||
} `json:"data"`
|
||||
} `json:"lines"`
|
||||
}
|
||||
|
||||
func (a *App) onInvoicePaid(event stripego.Event) {
|
||||
var raw invoicePaidPayload
|
||||
if err := json.Unmarshal(event.Data.Raw, &raw); err != nil {
|
||||
log.Printf("invoice unmarshal error: %v", err)
|
||||
return
|
||||
}
|
||||
if raw.BillingReason != stripego.InvoiceBillingReasonSubscriptionCycle {
|
||||
return
|
||||
}
|
||||
subID := ""
|
||||
switch v := raw.Subscription.(type) {
|
||||
case string:
|
||||
subID = v
|
||||
case map[string]interface{}:
|
||||
if id, ok := v["id"].(string); ok {
|
||||
subID = id
|
||||
}
|
||||
}
|
||||
if subID == "" || len(raw.Lines.Data) == 0 {
|
||||
return
|
||||
}
|
||||
priceID := raw.Lines.Data[0].Price.ID
|
||||
if priceID == "" || !a.stripe.IsYearTierPrice(priceID) {
|
||||
return
|
||||
}
|
||||
price100 := a.cfg.StripePriceIDMonth100
|
||||
if price100 == "" {
|
||||
log.Printf("webhook: STRIPE_PRICE_ID_MONTH_100 not set, cannot migrate sub %s", subID)
|
||||
return
|
||||
}
|
||||
sub, err := a.stripe.GetSubscription(subID)
|
||||
if err != nil || sub.Items == nil || len(sub.Items.Data) == 0 {
|
||||
log.Printf("webhook: could not get subscription %s for migration: %v", subID, err)
|
||||
return
|
||||
}
|
||||
subItemID := sub.Items.Data[0].ID
|
||||
params := &stripego.SubscriptionParams{
|
||||
Items: []*stripego.SubscriptionItemsParams{
|
||||
{ID: stripego.String(subItemID), Price: stripego.String(price100)},
|
||||
},
|
||||
ProrationBehavior: stripego.String("none"),
|
||||
}
|
||||
if _, err := subscription.Update(subID, params); err != nil {
|
||||
log.Printf("webhook: failed to migrate sub %s to $100/mo: %v", subID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("webhook: migrated sub %s from $20/year to $100/month", subID)
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ func (c *Client) connect() (*goldap.Conn, error) {
|
|||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*ProvisionResult, error) {
|
||||
func (c *Client) ProvisionUser(username, email, stripeCustomerID, phone string) (*ProvisionResult, error) {
|
||||
conn, err := c.connect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -55,6 +55,9 @@ func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*Provi
|
|||
}
|
||||
if exists {
|
||||
log.Printf("ldap user %s already exists", username)
|
||||
if phone != "" {
|
||||
_ = c.SetCustomerPhone(username, phone)
|
||||
}
|
||||
return &ProvisionResult{Username: username, IsNew: false}, nil
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +70,9 @@ func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*Provi
|
|||
addReq.Attribute("sn", []string{username})
|
||||
addReq.Attribute("uid", []string{username})
|
||||
addReq.Attribute("mail", []string{email})
|
||||
if phone != "" {
|
||||
addReq.Attribute("telephoneNumber", []string{phone})
|
||||
}
|
||||
|
||||
if err := conn.Add(addReq); err != nil {
|
||||
return nil, fmt.Errorf("ldap add user %s: %w", username, err)
|
||||
|
|
@ -82,13 +88,18 @@ func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*Provi
|
|||
log.Printf("warning: failed to set stripe customer id for %s: %v", username, err)
|
||||
}
|
||||
}
|
||||
if phone != "" {
|
||||
if err := c.SetCustomerPhone(username, phone); err != nil {
|
||||
log.Printf("warning: failed to set customer phone for %s: %v", username, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("created ldap user %s (%s)", username, email)
|
||||
return &ProvisionResult{Username: username, Password: password, IsNew: true}, nil
|
||||
}
|
||||
|
||||
func (c *Client) EnsureUser(username, email, stripeCustomerID string) error {
|
||||
_, err := c.ProvisionUser(username, email, stripeCustomerID)
|
||||
func (c *Client) EnsureUser(username, email, stripeCustomerID, phone string) error {
|
||||
_, err := c.ProvisionUser(username, email, stripeCustomerID, phone)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -159,6 +170,59 @@ func (c *Client) SetStripeCustomerID(username, customerID string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (c *Client) SetCustomerPhone(username, phone string) error {
|
||||
if phone == "" {
|
||||
return nil
|
||||
}
|
||||
query := `mutation($userId: String!, $attrs: [AttributeValueInput!]!) {
|
||||
updateUser(user: { id: $userId, insertAttributes: $attrs }) { ok }
|
||||
}`
|
||||
attrs := []map[string]any{
|
||||
{"name": "customer-phone", "value": []string{phone}},
|
||||
}
|
||||
_, err := c.gql.exec(query, map[string]any{"userId": username, "attrs": attrs})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) SetCustomerDomain(username, domain string) error {
|
||||
if domain == "" {
|
||||
return nil
|
||||
}
|
||||
query := `mutation($userId: String!, $attrs: [AttributeValueInput!]!) {
|
||||
updateUser(user: { id: $userId, insertAttributes: $attrs }) { ok }
|
||||
}`
|
||||
attrs := []map[string]any{
|
||||
{"name": "customer-domain", "value": []string{domain}},
|
||||
}
|
||||
_, err := c.gql.exec(query, map[string]any{"userId": username, "attrs": attrs})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) GetCustomerDomain(username string) (string, error) {
|
||||
query := `query($userId: String!) { user(userId: $userId) { attributes { name value } } }`
|
||||
data, err := c.gql.exec(query, map[string]any{"userId": username})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var result struct {
|
||||
User struct {
|
||||
Attributes []struct {
|
||||
Name string `json:"name"`
|
||||
Value []string `json:"value"`
|
||||
} `json:"attributes"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, attr := range result.User.Attributes {
|
||||
if attr.Name == "customer-domain" && len(attr.Value) > 0 {
|
||||
return attr.Value[0], nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (c *Client) GetStripeCustomerID(username string) (string, error) {
|
||||
query := `query($userId: String!) { user(userId: $userId) { attributes { name value } } }`
|
||||
data, err := c.gql.exec(query, map[string]any{"userId": username})
|
||||
|
|
@ -216,6 +280,39 @@ func (c *Client) FindUserByStripeID(stripeCustomerID string) (string, error) {
|
|||
return "", fmt.Errorf("no user found with stripe customer %s", stripeCustomerID)
|
||||
}
|
||||
|
||||
// CountCustomers returns the number of users who have completed checkout (have stripe-customer-id).
|
||||
// Used to enforce signup limits.
|
||||
func (c *Client) CountCustomers() (int, error) {
|
||||
query := `query { users(filters: {}) { id attributes { name value } } }`
|
||||
data, err := c.gql.exec(query, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Users []struct {
|
||||
Attributes []struct {
|
||||
Name string `json:"name"`
|
||||
Value []string `json:"value"`
|
||||
} `json:"attributes"`
|
||||
} `json:"users"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n := 0
|
||||
for _, u := range result.Users {
|
||||
for _, attr := range u.Attributes {
|
||||
if attr.Name == "stripe-customer-id" && len(attr.Value) > 0 && attr.Value[0] != "" {
|
||||
n++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (c *Client) getGroupID(groupName string) (int, error) {
|
||||
query := `query { groups { id displayName } }`
|
||||
data, err := c.gql.exec(query, nil)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
package pricing
|
||||
|
||||
// Tier represents a pricing tier for new signups.
|
||||
type Tier int
|
||||
|
||||
const (
|
||||
TierFree Tier = iota // First 10: $0 for 3 months, auto-cancel
|
||||
TierYear // Next 40: $20/year, then $100/month after
|
||||
TierPremium // After 50: $200/month
|
||||
)
|
||||
|
||||
// ForCustomer returns the tier for a new signup given current customer count.
|
||||
// Count is the number of customers who have completed checkout (0-indexed slot).
|
||||
func ForCustomer(count int, freeLimit, yearLimit int) Tier {
|
||||
if count < freeLimit {
|
||||
return TierFree
|
||||
}
|
||||
if count < yearLimit {
|
||||
return TierYear
|
||||
}
|
||||
return TierPremium
|
||||
}
|
||||
|
|
@ -1,16 +1,21 @@
|
|||
package stripe
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.nixc.us/a250/ss-atlas/internal/config"
|
||||
"git.nixc.us/a250/ss-atlas/internal/pricing"
|
||||
stripego "github.com/stripe/stripe-go/v84"
|
||||
portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
|
||||
checkoutsession "github.com/stripe/stripe-go/v84/checkout/session"
|
||||
"github.com/stripe/stripe-go/v84/subscription"
|
||||
)
|
||||
|
||||
var ErrNoPriceForTier = errors.New("no Stripe price configured for this tier")
|
||||
|
||||
type SubscriptionStatus struct {
|
||||
Label string // "Active", "Cancels soon", etc.
|
||||
Badge string // "badge-active", "badge-inactive", etc.
|
||||
|
|
@ -26,33 +31,63 @@ func New(cfg *config.Config) *Client {
|
|||
return &Client{cfg: cfg}
|
||||
}
|
||||
|
||||
func (c *Client) CreateCheckoutSession(email string) (*stripego.CheckoutSession, error) {
|
||||
func (c *Client) priceForTier(t pricing.Tier) string {
|
||||
switch t {
|
||||
case pricing.TierFree:
|
||||
if c.cfg.StripePriceIDFree != "" {
|
||||
return c.cfg.StripePriceIDFree
|
||||
}
|
||||
case pricing.TierYear:
|
||||
if c.cfg.StripePriceIDYear != "" {
|
||||
return c.cfg.StripePriceIDYear
|
||||
}
|
||||
case pricing.TierPremium:
|
||||
if c.cfg.StripePriceIDMonth200 != "" {
|
||||
return c.cfg.StripePriceIDMonth200
|
||||
}
|
||||
}
|
||||
return c.cfg.StripePriceID
|
||||
}
|
||||
|
||||
func (c *Client) CreateCheckoutSession(email, customerDomain, customerPhone string, customerCount int) (*stripego.CheckoutSession, error) {
|
||||
t := pricing.ForCustomer(customerCount, c.cfg.FreeTierLimit, c.cfg.YearTierLimit)
|
||||
priceID := c.priceForTier(t)
|
||||
if priceID == "" {
|
||||
return nil, ErrNoPriceForTier
|
||||
}
|
||||
|
||||
params := &stripego.CheckoutSessionParams{
|
||||
Mode: stripego.String(string(stripego.CheckoutSessionModeSubscription)),
|
||||
LineItems: []*stripego.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripego.String(c.cfg.StripePriceID),
|
||||
Quantity: stripego.Int64(1),
|
||||
},
|
||||
{Price: stripego.String(priceID), Quantity: stripego.Int64(1)},
|
||||
},
|
||||
CustomerEmail: stripego.String(email),
|
||||
SuccessURL: stripego.String(c.cfg.AppURL + "/success?session_id={CHECKOUT_SESSION_ID}"),
|
||||
CancelURL: stripego.String(c.cfg.AppURL + "/"),
|
||||
}
|
||||
if customerDomain != "" {
|
||||
params.AddMetadata("customer_domain", customerDomain)
|
||||
}
|
||||
if customerPhone != "" {
|
||||
params.AddMetadata("customer_phone", customerPhone)
|
||||
}
|
||||
params.AddMetadata("pricing_tier", strconv.Itoa(int(t)))
|
||||
return checkoutsession.New(params)
|
||||
}
|
||||
|
||||
// CreateCheckoutForCustomer creates a new subscription checkout for an existing
|
||||
// Stripe customer (e.g. resubscribe after expiry). The new sub is linked to the
|
||||
// same customer record so payment methods and history are preserved.
|
||||
func (c *Client) CreateCheckoutForCustomer(customerID string) (*stripego.CheckoutSession, error) {
|
||||
// Stripe customer (e.g. resubscribe after expiry). Uses current tier by customer count.
|
||||
func (c *Client) CreateCheckoutForCustomer(customerID string, customerCount int) (*stripego.CheckoutSession, error) {
|
||||
t := pricing.ForCustomer(customerCount, c.cfg.FreeTierLimit, c.cfg.YearTierLimit)
|
||||
priceID := c.priceForTier(t)
|
||||
if priceID == "" {
|
||||
return nil, ErrNoPriceForTier
|
||||
}
|
||||
|
||||
params := &stripego.CheckoutSessionParams{
|
||||
Mode: stripego.String(string(stripego.CheckoutSessionModeSubscription)),
|
||||
LineItems: []*stripego.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripego.String(c.cfg.StripePriceID),
|
||||
Quantity: stripego.Int64(1),
|
||||
},
|
||||
{Price: stripego.String(priceID), Quantity: stripego.Int64(1)},
|
||||
},
|
||||
Customer: stripego.String(customerID),
|
||||
SuccessURL: stripego.String(c.cfg.AppURL + "/success?session_id={CHECKOUT_SESSION_ID}"),
|
||||
|
|
@ -80,6 +115,23 @@ func (c *Client) GetSubscription(subID string) (*stripego.Subscription, error) {
|
|||
return subscription.Get(subID, nil)
|
||||
}
|
||||
|
||||
// ScheduleSubscriptionCancelAt sets the subscription to cancel at the given unix timestamp.
|
||||
// Used for free-tier subs that should auto-cancel after 3 months.
|
||||
func (c *Client) ScheduleSubscriptionCancelAt(subID string, cancelAt int64) error {
|
||||
_, err := subscription.Update(subID, &stripego.SubscriptionParams{
|
||||
CancelAt: stripego.Int64(cancelAt),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) IsFreeTierPrice(priceID string) bool {
|
||||
return c.cfg.StripePriceIDFree != "" && priceID == c.cfg.StripePriceIDFree
|
||||
}
|
||||
|
||||
func (c *Client) IsYearTierPrice(priceID string) bool {
|
||||
return c.cfg.StripePriceIDYear != "" && priceID == c.cfg.StripePriceIDYear
|
||||
}
|
||||
|
||||
func (c *Client) GetCustomerSubscriptionStatus(customerID string) *SubscriptionStatus {
|
||||
if customerID == "" {
|
||||
return &SubscriptionStatus{Label: "Active", Badge: "badge-active"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
package validation
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
// SanitizeEmail trims, lowercases, and validates email format. Returns empty if invalid.
|
||||
func SanitizeEmail(input string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(input))
|
||||
if s == "" || !emailRe.MatchString(s) || len(s) > 254 {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SanitizePhone normalizes to E.164-like format: + followed by 10-15 digits.
|
||||
// Accepts (555) 123-4567, +1 555 123 4567, etc.
|
||||
func SanitizePhone(input string) string {
|
||||
var digits strings.Builder
|
||||
hasPlus := false
|
||||
for _, r := range input {
|
||||
if r == '+' {
|
||||
hasPlus = true
|
||||
} else if r >= '0' && r <= '9' {
|
||||
digits.WriteRune(r)
|
||||
}
|
||||
}
|
||||
d := digits.String()
|
||||
if len(d) < 10 || len(d) > 15 {
|
||||
return ""
|
||||
}
|
||||
if hasPlus {
|
||||
return "+" + d
|
||||
}
|
||||
return "+" + d
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DomainResolves checks that the given domain or URL has a DNS record (A or AAAA).
|
||||
// Input can be "git.example.com" or "https://git.example.com".
|
||||
func DomainResolves(input string, timeout time.Duration) bool {
|
||||
host := extractHost(input)
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
var r net.Resolver
|
||||
addrs, err := r.LookupHost(ctx, host)
|
||||
return err == nil && len(addrs) > 0
|
||||
}
|
||||
|
||||
func extractHost(input string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(input))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(s, "//") {
|
||||
s = "//" + s
|
||||
}
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" || host == "." {
|
||||
return ""
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
|
@ -181,9 +181,15 @@
|
|||
<span class="badge badge-inactive">Not deployed</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .CustomerDomain}}
|
||||
<div class="status-row">
|
||||
<span class="status-label">Domain to manage</span>
|
||||
<span class="status-value">{{.CustomerDomain}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .StackRunning}}
|
||||
<p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your dedicated environment is accessible at:</p>
|
||||
<a class="stack-link" href="https://{{.User}}.{{.Domain}}">{{.User}}.{{.Domain}}</a>
|
||||
<a class="stack-link" href="https://{{.Domain}}/i/{{.User}}">{{.Domain}}/i/{{.User}}</a>
|
||||
{{else if and .StackDeployed (not .StackRunning)}}
|
||||
<p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your stack is stopped. Start it to access your environment.</p>
|
||||
{{end}}
|
||||
|
|
@ -249,6 +255,14 @@
|
|||
<a href="{{.AutheliaURL}}/settings/security" class="btn btn-outline btn-sm">Change Password</a>
|
||||
</div>
|
||||
</div>
|
||||
{{else if .PaidNotActivated}}
|
||||
<div class="card">
|
||||
<div class="empty-state">
|
||||
<h2>Activate Your Stack</h2>
|
||||
<p>You've completed checkout. Click below to provision your dedicated environment.</p>
|
||||
<a href="{{.AppURL}}/activate" class="btn">Activate Now</a>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card">
|
||||
<div class="empty-state">
|
||||
|
|
@ -258,7 +272,7 @@
|
|||
<a href="/" class="btn">Subscribe Now</a>
|
||||
{{else}}
|
||||
<h2>Sign In Required</h2>
|
||||
<p>Sign in to access your dashboard.</p>
|
||||
<p>Check your email for the password setup link. Once you've set your password and signed in, you can activate your stack here.</p>
|
||||
<a href="{{.AutheliaURL}}" class="btn">Sign In</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
.features li { padding: 0.4rem 0; color: var(--muted); font-size: 0.95rem; }
|
||||
.features li::before { content: "\2713"; color: var(--accent); font-weight: 700; margin-right: 0.75rem; }
|
||||
form { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
input[type="email"] {
|
||||
input[type="email"], input[type="text"] {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
|
|
@ -62,8 +62,8 @@
|
|||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input[type="email"]:focus { border-color: var(--accent); }
|
||||
input[type="email"]::placeholder { color: var(--muted); }
|
||||
input[type="email"]:focus, input[type="text"]:focus { border-color: var(--accent); }
|
||||
input[type="email"]::placeholder, input[type="text"]::placeholder { color: var(--muted); }
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
|
|
@ -76,6 +76,19 @@
|
|||
transition: background 0.2s;
|
||||
}
|
||||
button:hover { background: var(--accent-hover); }
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
|
||||
.footer a { color: var(--accent); text-decoration: none; }
|
||||
.version-badge {
|
||||
|
|
@ -95,20 +108,42 @@
|
|||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">a250.ca</div>
|
||||
<p class="tagline">Your own managed infrastructure stack, provisioned instantly when you subscribe.</p>
|
||||
<p class="tagline">{{.Tagline}}</p>
|
||||
<div class="card">
|
||||
<h2>Monthly Plan</h2>
|
||||
<div class="price">$20.00 <span>/ month</span></div>
|
||||
{{if .SoldOut}}
|
||||
<h2>Signups Full</h2>
|
||||
<p style="color: var(--muted); margin-bottom: 1rem;">We've reached our limit for new signups. Check back later.</p>
|
||||
{{else if and .StripePaymentLink (eq .PricingTier 0) (not .UseCheckoutForm)}}
|
||||
<h2>Free Plan</h2>
|
||||
<div class="price">$0 <span>/ one-time</span></div>
|
||||
<ul class="features">
|
||||
<li>Dedicated Docker stack</li>
|
||||
<li>Secure single sign-on</li>
|
||||
<li>Automatic provisioning</li>
|
||||
<li>Manage subscription anytime</li>
|
||||
{{range .Features}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
<a href="{{.StripePaymentLink}}" class="btn-primary">Sign Up Free</a>
|
||||
{{else if .UseCheckoutForm}}
|
||||
{{if eq .PricingTier 0}}
|
||||
<h2>Launch Offer</h2>
|
||||
<div class="price">$0 <span>/ 3 months, then ends</span></div>
|
||||
{{else if eq .PricingTier 1}}
|
||||
<h2>Founder Plan</h2>
|
||||
<div class="price">$20 <span>/ year, then $100/month</span></div>
|
||||
{{else}}
|
||||
<h2>Pro Plan</h2>
|
||||
<div class="price">$200 <span>/ month</span></div>
|
||||
{{end}}
|
||||
<ul class="features">
|
||||
{{range .Features}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
<form method="POST" action="/subscribe">
|
||||
<input type="email" name="email" placeholder="you@example.com" required>
|
||||
<input type="tel" name="phone" placeholder="+1 555 123 4567" autocomplete="tel" required>
|
||||
<input type="text" name="domain" placeholder="Domain you want to manage (e.g. git.mycompany.com)" autocomplete="off" required>
|
||||
<button type="submit">Subscribe Now</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<h2>Subscribe</h2>
|
||||
<p style="color: var(--muted); margin-bottom: 1rem;">Pricing is being configured. Check back soon.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="footer">
|
||||
Already subscribed? <a href="/dashboard">Go to Dashboard</a>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>a250.ca - Welcome</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--border: #2a2d3a;
|
||||
--text: #e4e4e7;
|
||||
--muted: #a1a1aa;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.container { max-width: 480px; width: 100%; }
|
||||
.logo { font-size: 2.5rem; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 0.5rem; }
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
}
|
||||
.card h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; }
|
||||
.card p { color: var(--muted); line-height: 1.6; margin-bottom: 1rem; }
|
||||
.card ul { color: var(--muted); line-height: 1.7; margin: 1rem 0; padding-left: 1.25rem; }
|
||||
.footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
|
||||
.footer a { color: var(--accent); text-decoration: none; }
|
||||
</style>
|
||||
{{template "analytics"}}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">a250.ca</div>
|
||||
<div class="card">
|
||||
<h2>Check your inbox</h2>
|
||||
<p>We've sent a password set email to your address. Use the link in that email to create your password and sign in.</p>
|
||||
<p><strong>You'll be required to:</strong></p>
|
||||
<ul>
|
||||
<li>Set a password</li>
|
||||
<li>Enable two-factor authentication or a passkey</li>
|
||||
</ul>
|
||||
<p>Once you've signed in, you can activate your workspace from the dashboard.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a href="{{.AppURL}}/dashboard">Go to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>a250.ca - Welcome</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--border: #2a2d3a;
|
||||
--text: #e4e4e7;
|
||||
--muted: #a1a1aa;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--green: #22c55e;
|
||||
--yellow: #eab308;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.container { max-width: 560px; width: 100%; }
|
||||
.logo { font-size: 2rem; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 0.5rem; }
|
||||
.subtitle { color: var(--green); font-size: 1.1rem; font-weight: 600; margin-bottom: 2rem; }
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.card h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; }
|
||||
.cred-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cred-row:last-child { border-bottom: none; }
|
||||
.cred-label { color: var(--muted); font-size: 0.9rem; }
|
||||
.cred-value {
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
background: var(--bg);
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
user-select: all;
|
||||
}
|
||||
.warning {
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
border: 1px solid rgba(234, 179, 8, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--yellow);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.warning strong { color: var(--yellow); }
|
||||
.steps { list-style: none; counter-reset: step; }
|
||||
.steps li {
|
||||
counter-increment: step;
|
||||
padding: 0.6rem 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.steps li::before {
|
||||
content: counter(step);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn:hover { background: var(--accent-hover); }
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-outline:hover { border-color: var(--accent); color: var(--accent); background: transparent; }
|
||||
.actions { margin-top: 1.5rem; display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.returning { text-align: center; padding: 2rem; color: var(--muted); }
|
||||
.returning p { margin-bottom: 1rem; }
|
||||
.download-gate {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
border: 1px solid rgba(99, 102, 241, 0.35);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
}
|
||||
.download-gate strong { color: var(--accent-hover); }
|
||||
.btn-download {
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
.btn-download:hover { background: #16a34a; }
|
||||
.btn-download.downloaded {
|
||||
background: #166534;
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.nav-actions { margin-top: 1rem; display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.nav-actions a.locked {
|
||||
pointer-events: none;
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
{{template "analytics"}}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">a250.ca</div>
|
||||
{{if .IsNew}}
|
||||
<div class="subtitle">Payment successful — your account is ready!</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Check your email</h2>
|
||||
<p style="color:var(--muted);font-size:0.95rem;line-height:1.6;margin-bottom:1rem;">
|
||||
We've sent a password setup link to <strong style="color:var(--text)">{{.Email}}</strong>.<br>
|
||||
Click the link in that email to set your password and log in.
|
||||
</p>
|
||||
<div class="cred-row">
|
||||
<span class="cred-label">Username</span>
|
||||
<span class="cred-value">{{.Username}}</span>
|
||||
</div>
|
||||
<div class="cred-row">
|
||||
<span class="cred-label">Your Instance</span>
|
||||
<span class="cred-value">{{.InstanceURL}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Next Steps</h2>
|
||||
<ol class="steps">
|
||||
<li>Open the email sent to <strong>{{.Email}}</strong> and click the link</li>
|
||||
<li>Set your password</li>
|
||||
<li>Return here and click <strong>Activate Stack</strong> to go live</li>
|
||||
<li>Your instance will be at <strong>{{.InstanceURL}}</strong></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn" id="reset-btn-new"
|
||||
onclick="sendReset(this,'{{.Username}}')">Resend Set Password Email</button>
|
||||
<a href="{{.ActivateURL}}" class="btn btn-outline">Activate Stack</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="subtitle">Welcome back!</div>
|
||||
|
||||
<div class="warning">
|
||||
<strong>Credentials not shown.</strong> Your account <strong>{{.Username}}</strong> already exists — we cannot display your original password again. Use the link below if you need to reset it.
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Account Details</h2>
|
||||
<div class="cred-row">
|
||||
<span class="cred-label">Username</span>
|
||||
<span class="cred-value">{{.Username}}</span>
|
||||
</div>
|
||||
<div class="cred-row">
|
||||
<span class="cred-label">Email</span>
|
||||
<span class="cred-value">{{.Email}}</span>
|
||||
</div>
|
||||
<div class="cred-row">
|
||||
<span class="cred-label">Your Instance</span>
|
||||
<span class="cred-value">{{.InstanceURL}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn" id="reset-btn-returning"
|
||||
onclick="sendReset(this,'{{.Username}}')">Resend Set Password Email</button>
|
||||
<a href="{{.LoginURL}}" class="btn btn-outline">Sign In</a>
|
||||
<a href="{{.DashboardURL}}" class="btn btn-outline">Dashboard</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<script>
|
||||
function sendReset(btn, username) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Sending…';
|
||||
fetch('/resend-reset', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'username=' + encodeURIComponent(username)
|
||||
}).then(function(r) {
|
||||
if (r.ok) {
|
||||
btn.textContent = 'Email sent — check your inbox';
|
||||
btn.style.background = 'var(--green)';
|
||||
} else {
|
||||
btn.textContent = 'Failed — try again';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}).catch(function() {
|
||||
btn.textContent = 'Failed — try again';
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
# It defines what product/service they receive when they subscribe.
|
||||
#
|
||||
# PRODUCT: Gitea — a self-hosted Git service, backed by PostgreSQL.
|
||||
# Each customer gets their own isolated instance at their subdomain.
|
||||
# Each customer gets their own isolated instance at a sub-path.
|
||||
#
|
||||
# Structure:
|
||||
# web — the application, exposed via Traefik behind Authelia auth
|
||||
|
|
@ -16,11 +16,11 @@
|
|||
#
|
||||
# Template variables (injected at deploy time by swarm/client.go):
|
||||
# {{.ID}} - customer's username (unique resource naming)
|
||||
# {{.Subdomain}} - customer's subdomain (same as ID by default)
|
||||
# {{.Subdomain}} - customer's username (used in path: /i/{subdomain})
|
||||
# {{.Domain}} - base domain (e.g. bc.a250.ca)
|
||||
# {{.TraefikNetwork}} - Traefik overlay network name
|
||||
#
|
||||
# Each customer gets their stack at: https://{{.Subdomain}}.{{.Domain}}
|
||||
# Each customer gets their stack at: https://{{.Domain}}/i/{{.Subdomain}}
|
||||
# Access is restricted to the owning user via Authelia forward-auth.
|
||||
# =============================================================================
|
||||
services:
|
||||
|
|
@ -32,8 +32,8 @@ services:
|
|||
GITEA__database__NAME: gitea
|
||||
GITEA__database__USER: gitea
|
||||
GITEA__database__PASSWD: gitea
|
||||
GITEA__server__DOMAIN: "{{.Subdomain}}.{{.Domain}}"
|
||||
GITEA__server__ROOT_URL: "https://{{.Subdomain}}.{{.Domain}}"
|
||||
GITEA__server__DOMAIN: "{{.Domain}}"
|
||||
GITEA__server__ROOT_URL: "https://{{.Domain}}/i/{{.Subdomain}}/"
|
||||
GITEA__server__HTTP_PORT: "3000"
|
||||
GITEA__security__INSTALL_LOCK: "true"
|
||||
volumes:
|
||||
|
|
@ -47,10 +47,11 @@ services:
|
|||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.docker.network: "atlas_{{.TraefikNetwork}}"
|
||||
traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Subdomain}}.{{.Domain}}`)"
|
||||
traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Domain}}`) && PathPrefix(`/i/{{.Subdomain}}`)"
|
||||
traefik.http.routers.customer-{{.ID}}-web.entrypoints: "websecure"
|
||||
traefik.http.routers.customer-{{.ID}}-web.tls: "true"
|
||||
traefik.http.routers.customer-{{.ID}}-web.middlewares: "authelia-auth@swarm"
|
||||
traefik.http.routers.customer-{{.ID}}-web.middlewares: "strip-customer-{{.ID}}@swarm,authelia-auth@swarm"
|
||||
traefik.http.middlewares.strip-customer-{{.ID}}.stripprefix.prefixes: "/i/{{.Subdomain}}"
|
||||
traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "3000"
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
# Stripe Payment Link for $0 Signups
|
||||
|
||||
For a free trial or limited beta, use a Stripe Payment Link instead of a checkout session.
|
||||
|
||||
## Create the Payment Link
|
||||
|
||||
1. **Stripe Dashboard** → Products → Add product
|
||||
- Name: e.g. "Atlas Instance (Free)"
|
||||
- Price: $0 one-time (or $0/month subscription)
|
||||
- Save
|
||||
|
||||
2. **Payment Links** → Create payment link
|
||||
- Select the $0 product/price
|
||||
- After payment: "Customer goes to a page on your website"
|
||||
- URL: `https://bc.a250.ca/success?session_id={CHECKOUT_SESSION_ID}`
|
||||
- (Use your actual `APP_URL` + `/success?session_id={CHECKOUT_SESSION_ID}`)
|
||||
- Create link
|
||||
|
||||
3. **Configure ss-atlas** via environment:
|
||||
```
|
||||
STRIPE_PAYMENT_LINK=https://buy.stripe.com/your_link_id
|
||||
MAX_SIGNUPS=10
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- Landing page shows "Sign Up Free" button → Payment Link
|
||||
- Limit enforced at: landing, checkout form, success page, webhook
|
||||
- Set `MAX_SIGNUPS=0` to disable limit
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
# Tiered Pricing Setup
|
||||
|
||||
Tiered pricing assigns different Stripe prices based on customer count.
|
||||
|
||||
## Stripe MCP (Cursor)
|
||||
|
||||
Stripe MCP is configured in `.cursor/mcp.json`. It loads `STRIPE_SECRET_KEY` from `.env` so you can use Stripe tools in Cursor chat (create customers, prices, search docs, etc.).
|
||||
|
||||
**Enable MCP:** [Stripe Dashboard → Settings → MCP](https://dashboard.stripe.com/settings/mcp) — enable for test and/or live mode.
|
||||
|
||||
Restart Cursor after adding/updating `.env` so the MCP picks up keys.
|
||||
|
||||
## Automated Setup Script
|
||||
|
||||
Create products and prices from your `.env` keys:
|
||||
|
||||
```bash
|
||||
./scripts/stripe-setup.sh # uses .env, prints IDs
|
||||
./scripts/stripe-setup.sh --apply # creates prices and updates .env
|
||||
./scripts/stripe-setup.sh .env.prod --apply # prod: use prod keys, update .env.prod
|
||||
```
|
||||
|
||||
Then redeploy: `export $(grep -v '^#' .env | xargs) && docker stack deploy -c stack.yml atlas`
|
||||
|
||||
- **First 10** (FREE_TIER_LIMIT): $0 for 3 months, auto-cancels at end
|
||||
- **Next 40** (11–50, YEAR_TIER_LIMIT): $20/year, then $100/month after first year
|
||||
- **51+**: $200/month
|
||||
|
||||
## Stripe Prices to Create
|
||||
|
||||
In **Stripe Dashboard** → Products → create one product with four prices:
|
||||
|
||||
1. **$0/month** – recurring monthly, amount 0
|
||||
- Used for first 10 signups; subscription is scheduled to cancel in 3 months
|
||||
2. **$20/year** – recurring yearly, amount 2000
|
||||
3. **$100/month** – recurring monthly, amount 10000
|
||||
4. **$200/month** – recurring monthly, amount 20000
|
||||
|
||||
Copy each price ID (starts with `price_`).
|
||||
|
||||
## Messaging (env vars)
|
||||
|
||||
Customize the subscribe page copy without rebuilding:
|
||||
|
||||
```bash
|
||||
LANDING_TAGLINE="Your own workspace, ready in minutes."
|
||||
LANDING_FEATURES="Dedicated environment|Secure login|Set up automatically|Cancel anytime"
|
||||
```
|
||||
|
||||
Features use `|` as separator.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
STRIPE_PRICE_ID_FREE=price_xxx # $0/3mo
|
||||
STRIPE_PRICE_ID_YEAR=price_xxx # $20/year
|
||||
STRIPE_PRICE_ID_MONTH_100=price_xxx # $100/month
|
||||
STRIPE_PRICE_ID_MONTH_200=price_xxx # $200/month
|
||||
FREE_TIER_LIMIT=10
|
||||
YEAR_TIER_LIMIT=50
|
||||
MAX_SIGNUPS=0
|
||||
```
|
||||
|
||||
`STRIPE_PRICE_ID` remains as a fallback if tier-specific prices are not set.
|
||||
|
||||
## Webhook Events
|
||||
|
||||
Enable these in **Stripe Dashboard** → Developers → Webhooks:
|
||||
|
||||
- `checkout.session.completed` – provisions user, schedules free-tier cancel
|
||||
- `customer.subscription.deleted` – deprovisions stack
|
||||
- `customer.subscription.updated` – status changes
|
||||
- `invoice.paid` – migrates $20/year subscriptions to $100/month on renewal
|
||||
|
||||
## Migration Flow
|
||||
|
||||
When a customer with a $20/year subscription has their first renewal (after 1 year), the `invoice.paid` webhook switches the subscription to $100/month for the next billing cycle.
|
||||
|
|
@ -35,4 +35,4 @@ docker service update --force --image atlas-ss-atlas:latest atlas_ss-atlas
|
|||
docker service update --force --image git.nixc.us/a250/authelia:dev-authelia atlas_authelia
|
||||
|
||||
echo ""
|
||||
echo "=== Ready. Visit https://app.bc.a250.ca ==="
|
||||
echo "=== Ready. Visit https://bc.a250.ca ==="
|
||||
|
|
|
|||
|
|
@ -1,22 +1,15 @@
|
|||
#!/bin/sh
|
||||
# Local DNS setup so *.app.a250.ca and *.bc.a250.ca resolve to 127.0.0.1
|
||||
# For full wildcard support use dnsmasq. For quick test, use /etc/hosts (limited).
|
||||
# Local DNS setup so bc.a250.ca resolves to 127.0.0.1
|
||||
#
|
||||
# With path-based routing, only a single domain is needed.
|
||||
# No wildcard DNS or per-customer entries required.
|
||||
set -e
|
||||
|
||||
echo "Option 1: dnsmasq (recommended - true wildcard)"
|
||||
echo " Add to /etc/dnsmasq.conf or /usr/local/etc/dnsmasq.conf:"
|
||||
echo " address=/.app.a250.ca/127.0.0.1"
|
||||
echo " address=/.bc.a250.ca/127.0.0.1"
|
||||
echo " Then: brew services restart dnsmasq"
|
||||
echo " And point your Mac to use 127.0.0.1 for DNS, or add to /etc/resolvers/app.a250.ca"
|
||||
echo "Add this line to /etc/hosts:"
|
||||
echo ""
|
||||
echo "Option 2: /etc/hosts (manual per-subdomain)"
|
||||
echo " Add lines for each host you need:"
|
||||
echo " 127.0.0.1 app.bc.a250.ca login.bc.a250.ca lldap.bc.a250.ca traefik.bc.a250.ca colin-nixc.bc.a250.ca"
|
||||
echo " 127.0.0.1 testuser.app.a250.ca"
|
||||
echo " /etc/hosts does NOT support wildcards."
|
||||
echo " 127.0.0.1 bc.a250.ca"
|
||||
echo ""
|
||||
echo "Option 3: resolvers file for *.app.a250.ca only"
|
||||
echo " mkdir -p /etc/resolver"
|
||||
echo " echo 'nameserver 127.0.0.1' | sudo tee /etc/resolver/app.a250.ca"
|
||||
echo " (requires dnsmasq listening on 127.0.0.1 with address=/.app.a250.ca/127.0.0.1)"
|
||||
echo "That's it — all services are path-based under bc.a250.ca:"
|
||||
echo " https://bc.a250.ca/ — landing / dashboard"
|
||||
echo " https://bc.a250.ca/login/ — authentication"
|
||||
echo " https://bc.a250.ca/i/{user}/ — customer instances"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
#!/usr/bin/env bash
|
||||
# Create Atlas tiered pricing products and prices in Stripe.
|
||||
# Usage: ./scripts/stripe-setup.sh [path/to/.env]
|
||||
# Use sandbox .env for test mode, prod .env for live. Outputs env vars to add to .env.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
APPLY=false
|
||||
ENV_FILE="$REPO_ROOT/.env"
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--apply" ]; then APPLY=true
|
||||
elif [ -f "$arg" ]; then ENV_FILE="$arg"
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [ -z "${STRIPE_SECRET_KEY:-}" ]; then
|
||||
echo "ERROR: STRIPE_SECRET_KEY not set. Usage: $0 [path/to/.env]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect mode from key
|
||||
if [[ "$STRIPE_SECRET_KEY" == sk_live_* ]]; then
|
||||
MODE="live"
|
||||
echo ">>> Stripe LIVE mode" >&2
|
||||
else
|
||||
MODE="test"
|
||||
echo ">>> Stripe TEST mode" >&2
|
||||
fi
|
||||
|
||||
BASE_URL="https://api.stripe.com/v1"
|
||||
|
||||
# Create product
|
||||
echo "Creating product Atlas..." >&2
|
||||
PRODUCT=$(curl -s -X POST -u "${STRIPE_SECRET_KEY}:" "$BASE_URL/products" \
|
||||
-d "name=Atlas" \
|
||||
-d "description=Managed workspace and infrastructure")
|
||||
PRODUCT_ID=$(echo "$PRODUCT" | jq -r '.id // empty')
|
||||
if [ -z "$PRODUCT_ID" ]; then
|
||||
echo "Failed to create product. Response: $PRODUCT" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Product: $PRODUCT_ID" >&2
|
||||
|
||||
# Create prices (recurring)
|
||||
create_price() {
|
||||
local amount="$1"
|
||||
local interval="$2"
|
||||
local nickname="$3"
|
||||
local out
|
||||
out=$(curl -s -X POST -u "${STRIPE_SECRET_KEY}:" "$BASE_URL/prices" \
|
||||
-d "product=$PRODUCT_ID" \
|
||||
-d "currency=usd" \
|
||||
-d "unit_amount=$amount" \
|
||||
-d "recurring[interval]=$interval" \
|
||||
-d "nickname=$nickname")
|
||||
echo "$out" | jq -r '.id // empty'
|
||||
}
|
||||
|
||||
echo "Creating prices..." >&2
|
||||
PRICE_FREE=$(create_price 0 month "Atlas Free 3mo")
|
||||
PRICE_YEAR=$(create_price 2000 year "Atlas Founder Year")
|
||||
PRICE_100=$(create_price 10000 month "Atlas 100/mo")
|
||||
PRICE_200=$(create_price 20000 month "Atlas 200/mo")
|
||||
|
||||
if [ -z "$PRICE_FREE" ] || [ -z "$PRICE_YEAR" ] || [ -z "$PRICE_100" ] || [ -z "$PRICE_200" ]; then
|
||||
echo "Failed to create one or more prices" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "" >&2
|
||||
echo "=== Add these to your .env ($MODE mode) ===" >&2
|
||||
echo "" >&2
|
||||
cat << EOF
|
||||
# Stripe tiered pricing ($MODE)
|
||||
STRIPE_PRICE_ID_FREE=$PRICE_FREE
|
||||
STRIPE_PRICE_ID_YEAR=$PRICE_YEAR
|
||||
STRIPE_PRICE_ID_MONTH_100=$PRICE_100
|
||||
STRIPE_PRICE_ID_MONTH_200=$PRICE_200
|
||||
FREE_TIER_LIMIT=10
|
||||
YEAR_TIER_LIMIT=50
|
||||
EOF
|
||||
|
||||
# If --apply passed, merge into .env
|
||||
if [ "$APPLY" = "true" ]; then
|
||||
ENV_TARGET="$ENV_FILE"
|
||||
[ -f "$ENV_TARGET" ] || { echo "No $ENV_TARGET to update" >&2; exit 0; }
|
||||
update_var() { local k="$1" v="$2"
|
||||
if grep -q "^${k}=" "$ENV_TARGET" 2>/dev/null; then
|
||||
sed -i.bak "s|^${k}=.*|${k}=${v}|" "$ENV_TARGET"
|
||||
else
|
||||
echo "${k}=${v}" >> "$ENV_TARGET"
|
||||
fi
|
||||
}
|
||||
update_var "STRIPE_PRICE_ID_FREE" "$PRICE_FREE"
|
||||
update_var "STRIPE_PRICE_ID_YEAR" "$PRICE_YEAR"
|
||||
update_var "STRIPE_PRICE_ID_MONTH_100" "$PRICE_100"
|
||||
update_var "STRIPE_PRICE_ID_MONTH_200" "$PRICE_200"
|
||||
rm -f "${ENV_TARGET}.bak"
|
||||
echo "" >&2
|
||||
echo "Updated $ENV_TARGET. Redeploy: export \$(grep -v '^#' .env | xargs) && docker stack deploy -c stack.yml atlas" >&2
|
||||
fi
|
||||
46
stack.yml
46
stack.yml
|
|
@ -46,7 +46,9 @@ services:
|
|||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.lldap.rule=Host(`lldap.bc.a250.ca`)"
|
||||
- "traefik.http.routers.lldap.rule=Host(`bc.a250.ca`) && PathPrefix(`/admin/lldap`)"
|
||||
- "traefik.http.routers.lldap.middlewares=strip-lldap@swarm"
|
||||
- "traefik.http.middlewares.strip-lldap.stripprefix.prefixes=/admin/lldap"
|
||||
- "traefik.http.routers.lldap.entrypoints=websecure"
|
||||
- "traefik.http.routers.lldap.tls=true"
|
||||
- "traefik.http.services.lldap.loadbalancer.server.port=17170"
|
||||
|
|
@ -77,9 +79,10 @@ services:
|
|||
echo "$${CLIENT_SECRET_HEADADMIN}" > /run/secrets/CLIENT_SECRET_HEADADMIN
|
||||
echo "$${CLIENT_SECRET_PORTAINER}" > /run/secrets/CLIENT_SECRET_PORTAINER
|
||||
echo "$${CLIENT_SECRET_GITEA}" > /run/secrets/CLIENT_SECRET_GITEA
|
||||
{ echo 'access_control:'; echo ' default_policy: deny'; echo ' rules:'; echo ' - domain: login.bc.a250.ca'; echo ' policy: bypass'; echo ' - domain: app.bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/$$'"; echo " - '^/subscribe$$'"; echo " - '^/success(\\?.*)?$$'"; echo " - '^/webhook/stripe$$'"; echo " - '^/resend-reset$$'"; echo " - '^/health$$'"; echo " - '^/version$$'"; echo ' - domain: app.bc.a250.ca'; echo ' policy: one_factor'; echo ' resources:'; echo " - '^/dashboard$$'"; echo " - '^/activate$$'"; echo " - '^/portal$$'"; echo " - '^/resubscribe$$'"; echo " - '^/stack-manage$$'"; echo ' - domain:'; echo ' - lldap.bc.a250.ca'; echo ' - whoami.bc.a250.ca'; echo ' policy: bypass'; echo ' - domain: "{user}.bc.a250.ca"'; echo ' policy: two_factor'; echo ' - domain: "*.bc.a250.ca"'; echo ' policy: deny'; } > /config/configuration.acl.yml
|
||||
{ echo 'access_control:'; echo ' default_policy: deny'; echo ' rules:'; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/login(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/$$'"; echo " - '^/subscribe/?$$'"; echo " - '^/success(/|\\?.*)?$$'"; echo " - '^/webhook/stripe/?$$'"; echo " - '^/resend-reset/?$$'"; echo " - '^/health/?$$'"; echo " - '^/version/?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: one_factor'; echo ' resources:'; echo " - '^/activate/?$$'"; echo " - '^/dashboard/?$$'"; echo ' - domain: bc.a250.ca'; echo ' subject:'; echo ' - group:customers'; echo ' policy: two_factor'; echo ' resources:'; echo " - '^/portal/?$$'"; echo " - '^/resubscribe/?$$'"; echo " - '^/stack-manage/?$$'"; echo " - '^/i/[a-z0-9-]+(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/admin/lldap(/.*)?$$'"; echo " - '^/whoami(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: deny'; } > /config/configuration.acl.yml
|
||||
exec authelia --config=/config/configuration.server.yml --config=/config/configuration.ldap.yml --config=/config/configuration.acl.yml --config=/config/configuration.notifier.yml --config=/config/configuration.identity.providers.yml --config=/config/configuration.oidc.clients.yml
|
||||
environment:
|
||||
AUTHELIA_SERVER_ADDRESS: tcp://0.0.0.0:9091/login
|
||||
X_AUTHELIA_EMAIL: authelia@a250.ca
|
||||
X_AUTHELIA_SITE_NAME: a250.ca
|
||||
X_AUTHELIA_CONFIG_FILTERS: template
|
||||
|
|
@ -132,15 +135,15 @@ services:
|
|||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.authelia.rule=Host(`login.bc.a250.ca`)"
|
||||
- "traefik.http.routers.authelia.rule=Host(`bc.a250.ca`) && PathPrefix(`/login`)"
|
||||
- "traefik.http.routers.authelia.entrypoints=websecure"
|
||||
- "traefik.http.routers.authelia.tls=true"
|
||||
- "traefik.http.services.authelia.loadbalancer.server.port=9091"
|
||||
- "traefik.http.middlewares.authelia-auth.forwardauth.address=http://authelia:9091/api/verify?rd=https://login.bc.a250.ca/"
|
||||
- "traefik.http.middlewares.authelia-auth.forwardauth.address=http://authelia:9091/login/api/authz/forward-auth?rd=https://bc.a250.ca/login/"
|
||||
- "traefik.http.middlewares.authelia-auth.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.authelia-auth.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "/usr/bin/wget --spider --quiet http://localhost:9091/api/health || exit 1" ]
|
||||
test: [ "CMD-SHELL", "/usr/bin/wget --spider --quiet http://localhost:9091/login/api/health || exit 1" ]
|
||||
start_period: 15s
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
|
|
@ -170,10 +173,12 @@ services:
|
|||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.bc.a250.ca`)"
|
||||
- "traefik.http.routers.traefik-dashboard.rule=Host(`bc.a250.ca`) && PathPrefix(`/admin/traefik`)"
|
||||
- "traefik.http.routers.traefik-dashboard.entrypoints=websecure"
|
||||
- "traefik.http.routers.traefik-dashboard.tls=true"
|
||||
- "traefik.http.routers.traefik-dashboard.service=traefik-api"
|
||||
- "traefik.http.routers.traefik-dashboard.middlewares=strip-traefik@swarm"
|
||||
- "traefik.http.middlewares.strip-traefik.stripprefix.prefixes=/admin/traefik"
|
||||
- "traefik.http.services.traefik-api.loadbalancer.server.port=8080"
|
||||
|
||||
ss-atlas:
|
||||
|
|
@ -181,21 +186,31 @@ services:
|
|||
environment:
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-sk_test_placeholder}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-whsec_placeholder}
|
||||
- STRIPE_PRICE_ID=${STRIPE_PRICE_ID:-price_placeholder}
|
||||
- STRIPE_PRICE_ID=${STRIPE_PRICE_ID:-}
|
||||
- STRIPE_PRICE_ID_FREE=${STRIPE_PRICE_ID_FREE:-}
|
||||
- STRIPE_PRICE_ID_YEAR=${STRIPE_PRICE_ID_YEAR:-}
|
||||
- STRIPE_PRICE_ID_MONTH_100=${STRIPE_PRICE_ID_MONTH_100:-}
|
||||
- STRIPE_PRICE_ID_MONTH_200=${STRIPE_PRICE_ID_MONTH_200:-}
|
||||
- STRIPE_PAYMENT_LINK=${STRIPE_PAYMENT_LINK:-}
|
||||
- FREE_TIER_LIMIT=${FREE_TIER_LIMIT:-10}
|
||||
- YEAR_TIER_LIMIT=${YEAR_TIER_LIMIT:-50}
|
||||
- MAX_SIGNUPS=${MAX_SIGNUPS:-0}
|
||||
- LLDAP_URL=ldap://lldap:3890
|
||||
- LLDAP_ADMIN_DN=uid=admin,ou=people,dc=a250,dc=ca
|
||||
- LLDAP_ADMIN_PASSWORD=/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=
|
||||
- LLDAP_BASE_DN=dc=a250,dc=ca
|
||||
- LLDAP_HTTP_URL=http://lldap:17170
|
||||
- DOCKER_HOST=unix:///var/run/docker.sock
|
||||
- APP_URL=https://app.bc.a250.ca
|
||||
- AUTHELIA_URL=https://login.bc.a250.ca
|
||||
- AUTHELIA_INTERNAL_URL=http://authelia:9091
|
||||
- APP_URL=https://bc.a250.ca
|
||||
- AUTHELIA_URL=https://bc.a250.ca/login
|
||||
- AUTHELIA_INTERNAL_URL=http://authelia:9091/login
|
||||
- TRAEFIK_DOMAIN=bc.a250.ca
|
||||
- TRAEFIK_NETWORK=authelia_dev
|
||||
- CUSTOMER_DOMAIN=app.a250.ca
|
||||
- CUSTOMER_DOMAIN=bc.a250.ca
|
||||
- TEMPLATE_PATH=/app/templates
|
||||
- ARCHIVE_PATH=/archives
|
||||
- LANDING_TAGLINE=${LANDING_TAGLINE:-Your own workspace, ready in minutes.}
|
||||
- LANDING_FEATURES=${LANDING_FEATURES:-Dedicated environment|Secure single sign-on|Automatic provisioning|Manage subscription anytime}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- atlas_archives:/archives
|
||||
|
|
@ -204,9 +219,10 @@ services:
|
|||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.ss-atlas.rule=Host(`app.bc.a250.ca`)"
|
||||
- "traefik.http.routers.ss-atlas.rule=Host(`bc.a250.ca`)"
|
||||
- "traefik.http.routers.ss-atlas.entrypoints=websecure"
|
||||
- "traefik.http.routers.ss-atlas.tls=true"
|
||||
- "traefik.http.routers.ss-atlas.priority=1"
|
||||
- "traefik.http.routers.ss-atlas.middlewares=authelia-auth@swarm"
|
||||
- "traefik.http.services.ss-atlas.loadbalancer.server.port=8080"
|
||||
|
||||
|
|
@ -217,10 +233,12 @@ services:
|
|||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.whoami.rule=Host(`whoami.bc.a250.ca`)"
|
||||
- "traefik.http.routers.whoami.rule=Host(`bc.a250.ca`) && PathPrefix(`/whoami`)"
|
||||
- "traefik.http.routers.whoami.entrypoints=websecure"
|
||||
- "traefik.http.routers.whoami.tls=true"
|
||||
- "traefik.http.routers.whoami.middlewares=authelia-auth@swarm"
|
||||
- "traefik.http.routers.whoami.middlewares=strip-whoami@swarm,authelia-auth@swarm"
|
||||
- "traefik.http.middlewares.strip-whoami.stripprefix.prefixes=/whoami"
|
||||
- "traefik.http.services.whoami.loadbalancer.server.port=80"
|
||||
|
||||
networks:
|
||||
authelia_dev:
|
||||
|
|
|
|||
Loading…
Reference in New Issue