diff --git a/.env.example b/.env.example index 9500851c..f031cda5 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,9 @@ ALPHONE_TEST_DATABASE_MAX_AGE=1h # ALPHONE_ADDR=localhost:8080 # ALPHONE_WEB_DIR= +# The address people reach AlphOne at, which plugins and invitation links use, required with a mail relay. +# ALPHONE_PUBLIC_URL= + # Comma-separated CIDR ranges of reverse proxies allowed for login rate limiting. # ALPHONE_TRUSTED_PROXIES=10.0.0.0/8 @@ -94,8 +97,6 @@ ALPHONE_TEST_DATABASE_MAX_AGE=1h # ALPHONE_SMTP_FROM= # Transport security to the relay: mandatory, opportunistic or none. # ALPHONE_SMTP_TLS=mandatory -# The address email links lead back to, required with a mailer. -# ALPHONE_PUBLIC_URL= # Directory whose files override the built-in mail templates by name. # ALPHONE_MAIL_TEMPLATE_DIR= diff --git a/cmd/alphone/compose.go b/cmd/alphone/compose.go index 8ae51e1b..17b8a85a 100644 --- a/cmd/alphone/compose.go +++ b/cmd/alphone/compose.go @@ -79,7 +79,7 @@ func compose(ctx context.Context, cfg composeConfig, plugins func(sdk.Deps) ([]s resolver := contact.NewResolver(built.contacts, contact.WithEvents(built.events)) built.registered, built.failed = plugins(sdk.Deps{ DatabaseURL: cfg.databaseURL, - PublicURL: cfg.mail.publicURL, + PublicURL: cfg.publicURL, MachineGrace: cfg.machineGrace, TenantsHeld: cfg.tenants.held, TenantsRefresh: cfg.tenants.refresh, @@ -99,7 +99,7 @@ func compose(ctx context.Context, cfg composeConfig, plugins func(sdk.Deps) ([]s if err := declareEvents(built.registered); err != nil { return built, fmt.Errorf("declare plugin events: %w", err) } - sender, mailer, err := buildMail(cfg.mail, cfg.logger) + sender, mailer, err := buildMail(cfg.mail, cfg.publicURL, cfg.logger) if err != nil { return built, err } diff --git a/cmd/alphone/mail.go b/cmd/alphone/mail.go index 024375e6..91b61665 100644 --- a/cmd/alphone/mail.go +++ b/cmd/alphone/mail.go @@ -16,9 +16,9 @@ import ( "github.com/gopherium/alphone/sdk" ) -// buildMail returns the relay sender and the mailer the account flows deliver through. -func buildMail(settings mailSettings, logger *slog.Logger) (mailkit.Sender, graphres.Mailer, error) { - sender, err := buildMailSender(settings, logger) +// buildMail returns the relay sender greeting as the host of publicURL and the mailer the account flows use. +func buildMail(settings mailSettings, publicURL string, logger *slog.Logger) (mailkit.Sender, graphres.Mailer, error) { + sender, err := buildMailSender(settings, publicURL, logger) if err != nil { return nil, nil, err } @@ -29,8 +29,8 @@ func buildMail(settings mailSettings, logger *slog.Logger) (mailkit.Sender, grap return sender, mailer, nil } -// buildMailSender returns the relay sender, nil when no relay is named. -func buildMailSender(settings mailSettings, logger *slog.Logger) (mailkit.Sender, error) { +// buildMailSender returns the relay sender greeting as the host of publicURL, nil when no relay is named. +func buildMailSender(settings mailSettings, publicURL string, logger *slog.Logger) (mailkit.Sender, error) { if settings.host == "" { logger.Info("no mail relay configured, invitations answer their activation link") return nil, nil @@ -42,7 +42,7 @@ func buildMailSender(settings mailSettings, logger *slog.Logger) (mailkit.Sender Password: settings.password, From: settings.from, TLS: smtp.TLS(settings.tls), - HELO: heloFor(settings.publicURL), + HELO: heloFor(publicURL), }) if err != nil { return nil, fmt.Errorf("build mail sender: %w", err) diff --git a/cmd/alphone/mailbuild_test.go b/cmd/alphone/mailbuild_test.go index 897e9808..d7008bc8 100644 --- a/cmd/alphone/mailbuild_test.go +++ b/cmd/alphone/mailbuild_test.go @@ -50,7 +50,7 @@ func TestTheHostRefusesAMailSenderItCannotBuild(t *testing.T) { t.Run(testName, func(t *testing.T) { t.Parallel() - held, err := buildMailSender(refusedSettings, discardLogger()) + held, err := buildMailSender(refusedSettings, "", discardLogger()) if err == nil { t.Fatal("buildMailSender() error = nil, want the relay settings refused") @@ -69,12 +69,11 @@ func TestBuildingMailStopsAtARefusedSender(t *testing.T) { t.Parallel() sender, mailer, err := buildMail(mailSettings{ - host: "mail.example.com", - port: 2525, - from: "not an address", - tls: "none", - publicURL: "https://crm.example.com", - }, discardLogger()) + host: "mail.example.com", + port: 2525, + from: "not an address", + tls: "none", + }, "https://crm.example.com", discardLogger()) if err == nil { t.Fatal("buildMail() error = nil, want the refused sender surfaced") @@ -97,9 +96,8 @@ func TestBuildingMailStopsAtARefusedMailer(t *testing.T) { port: 2525, from: "crm@example.com", tls: "none", - publicURL: "https://crm.example.com", templateDir: absentTemplateDir, - }, discardLogger()) + }, "https://crm.example.com", discardLogger()) if err == nil { t.Fatal("buildMail() error = nil, want the unusable template directory refused") diff --git a/cmd/alphone/mailconfig_test.go b/cmd/alphone/mailconfig_test.go index 055139c3..67c68416 100644 --- a/cmd/alphone/mailconfig_test.go +++ b/cmd/alphone/mailconfig_test.go @@ -3,6 +3,7 @@ package main import ( + "fmt" "strings" "testing" "time" @@ -51,12 +52,14 @@ func TestTheMailSettingsAreReadFromTheEnvironment(t *testing.T) { password: "correct horse battery", from: "crm@example.com", tls: "none", - publicURL: "https://crm.example.com", templateDir: "/srv/mail", } if held.mail != want { t.Errorf("mail = %+v, want %+v", held.mail, want) } + if held.publicURL != "https://crm.example.com" { + t.Errorf("publicURL = %q, want the configured address", held.publicURL) + } } func TestTheMailDefaultsApply(t *testing.T) { @@ -153,6 +156,7 @@ func TestAMailerNamesItsRequiredCompanions(t *testing.T) { "the sender address": {unset: "ALPHONE_SMTP_FROM"}, "the public url": {unset: "ALPHONE_PUBLIC_URL"}, } + want := func(unset string) string { return unset + " is required when ALPHONE_SMTP_HOST is set" } for testName, tt := range tests { t.Run(testName, func(t *testing.T) { t.Parallel() @@ -170,8 +174,8 @@ func TestAMailerNamesItsRequiredCompanions(t *testing.T) { if err == nil { t.Fatalf("loadRunConfig() without %s error = nil, want it required", tt.unset) } - if !strings.Contains(err.Error(), tt.unset) { - t.Errorf("error %q does not name %s", err, tt.unset) + if err.Error() != want(tt.unset) { + t.Errorf("error %q, want %q", err, want(tt.unset)) } }) } @@ -205,6 +209,42 @@ func TestMailSettingsWithoutAHostAreRefused(t *testing.T) { } } +func TestAMailSettingIsNamedBeforeThePublicURL(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + vars map[string]string + want string + }{ + "a relay on an unreadable port": { + vars: map[string]string{ + "ALPHONE_SMTP_HOST": "mail.example.com", + "ALPHONE_SMTP_PORT": "abc", + "ALPHONE_SMTP_FROM": "crm@example.com", + }, + want: `ALPHONE_SMTP_PORT: must be a whole number, got "abc"`, + }, + "a stray port without a relay": { + vars: map[string]string{"ALPHONE_SMTP_PORT": "2525"}, + want: "ALPHONE_SMTP_PORT is set but ALPHONE_SMTP_HOST is not", + }, + } + for testName, tt := range tests { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + tt.vars["ALPHONE_DATABASE_URL"] = "postgres://localhost/x" + tt.vars["ALPHONE_PUBLIC_URL"] = refusedPublicURL + + _, err := loadRunConfig(testkit.Getenv(tt.vars)) + + if fmt.Sprint(err) != tt.want { + t.Errorf("loadRunConfig() error = %v, want %q", err, tt.want) + } + }) + } +} + func TestTheMailPortRefusesAnUnreadableValue(t *testing.T) { t.Parallel() @@ -321,8 +361,8 @@ func TestThePublicURLDropsItsTrailingSlash(t *testing.T) { if err != nil { t.Fatalf("loadRunConfig() error = %v, want nil", err) } - if held.mail.publicURL != "https://crm.example.com" { - t.Errorf("mail.publicURL = %q, want the slash dropped", held.mail.publicURL) + if held.publicURL != "https://crm.example.com" { + t.Errorf("publicURL = %q, want the slash dropped", held.publicURL) } } diff --git a/cmd/alphone/mailwiring_seam_test.go b/cmd/alphone/mailwiring_seam_test.go index a26698f2..92445351 100644 --- a/cmd/alphone/mailwiring_seam_test.go +++ b/cmd/alphone/mailwiring_seam_test.go @@ -111,7 +111,7 @@ func TestTheMailSenderBridgeSurfacesAFailure(t *testing.T) { func TestTheHostBuildsNoMailSenderWithoutARelay(t *testing.T) { t.Parallel() - held, err := buildMailSender(mailSettings{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + held, err := buildMailSender(mailSettings{}, "", slog.New(slog.NewTextHandler(io.Discard, nil))) if err != nil { t.Fatalf("buildMailSender() error = %v, want nil", err) diff --git a/cmd/alphone/mailwiring_test.go b/cmd/alphone/mailwiring_test.go index 802a6bf7..d7667382 100644 --- a/cmd/alphone/mailwiring_test.go +++ b/cmd/alphone/mailwiring_test.go @@ -132,21 +132,17 @@ func tokenFromMail(t *testing.T, relay *smtpmock.Server, base string) string { return strings.TrimSpace(token) } -func TestRunAnswersTheActivationLinkWithoutARelay(t *testing.T) { - t.Parallel() - - if testing.Short() { - t.Skip("skipping database test in short mode") - } - addr := testkit.FreeAddr(t) +// invitedWithoutARelay serves run over env and a fresh database with no relay, answering the invite the admin sends. +func invitedWithoutARelay(t *testing.T, env map[string]string) string { + t.Helper() databaseURL := testDatabaseURL(t) + addr := testkit.FreeAddr(t) + env["ALPHONE_DATABASE_URL"] = databaseURL + env["ALPHONE_ADDR"] = addr ctx, cancel := context.WithCancel(t.Context()) runErr := make(chan error, 1) go func() { - runErr <- run(ctx, testkit.Getenv(map[string]string{ - "ALPHONE_DATABASE_URL": databaseURL, - "ALPHONE_ADDR": addr, - }), io.Discard, registerPlugins) + runErr <- run(ctx, testkit.Getenv(env), io.Discard, registerPlugins) }() t.Cleanup(func() { stopRun(t, cancel, runErr) }) @@ -155,15 +151,40 @@ func TestRunAnswersTheActivationLinkWithoutARelay(t *testing.T) { seedAdmin(t, databaseURL) session := loginSession(t, baseURL) - invited := postGraphAuthed(t, ctx, session, baseURL, + return postGraphAuthed(t, ctx, session, baseURL, `{"query":"mutation { invite(email: \"grace@example.com\", name: \"Grace Hopper\")`+ ` { delivered activationLink } }"}`) +} - if !strings.Contains(invited, `"delivered":false`) { - t.Fatalf("invite = %q, want it undelivered without a relay", invited) +func TestRunAnswersTheActivationLinkWithoutARelay(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + env map[string]string + link string + }{ + "no public address": { + env: map[string]string{}, + link: `"activationLink":"/activate?token=`, + }, + "a public address": { + env: map[string]string{"ALPHONE_PUBLIC_URL": "https://crm.example.com"}, + link: `"activationLink":"https://crm.example.com/activate?token=`, + }, } - if !strings.Contains(invited, `"activationLink":"/activate?token=`) { - t.Fatalf("invite = %q, want the link answered for hand delivery", invited) + for testName, tc := range tests { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + invited := invitedWithoutARelay(t, tc.env) + + if !strings.Contains(invited, `"delivered":false`) { + t.Fatalf("invite = %q, want it undelivered without a relay", invited) + } + if !strings.Contains(invited, tc.link) { + t.Fatalf("invite = %q, want the link handed over as %s", invited, tc.link) + } + }) } } diff --git a/cmd/alphone/publicurl_test.go b/cmd/alphone/publicurl_test.go index 4159272a..cb221140 100644 --- a/cmd/alphone/publicurl_test.go +++ b/cmd/alphone/publicurl_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/gopherium/framework/gonsole" "github.com/gopherium/framework/gonsole/testkit" "github.com/gopherium/alphone/sdk" @@ -15,6 +16,12 @@ import ( var errDepsRecorded = errors.New("the dependencies were recorded") +// refusedPublicURL is a public address on a scheme AlphOne refuses. +const refusedPublicURL = "ftp://crm.example.com" + +// refusedPublicURLError is the error a public address on that scheme meets. +const refusedPublicURLError = `ALPHONE_PUBLIC_URL: must be an http or https address, got "ftp://crm.example.com"` + // recordedPublicURL boots run with env, answering the public URL the host handed the plugins. func recordedPublicURL(t *testing.T, env map[string]string) string { t.Helper() @@ -28,7 +35,7 @@ func recordedPublicURL(t *testing.T, env map[string]string) string { err := run(t.Context(), testkit.Getenv(env), io.Discard, recording) if !errors.Is(err, errDepsRecorded) { - t.Fatalf("run() error = %v, want the recording plugin's own refusal", err) + t.Fatalf("run() error = %v, want the recording plugin's own error", err) } return handed } @@ -49,12 +56,61 @@ func TestRunHandsThePublicURLToPlugins(t *testing.T) { } } -func TestRunHandsAnEmptyPublicURLWithoutARelay(t *testing.T) { +func TestRunHandsThePublicURLToPluginsWithoutARelay(t *testing.T) { + t.Parallel() + + handed := recordedPublicURL(t, map[string]string{"ALPHONE_PUBLIC_URL": "https://crm.example.com"}) + + if handed != "https://crm.example.com" { + t.Errorf("plugins received public URL %q, want the configured address without a relay", handed) + } +} + +func TestRunHandsAnEmptyPublicURLWhenNoneIsSet(t *testing.T) { t.Parallel() handed := recordedPublicURL(t, map[string]string{}) - if strings.TrimSpace(handed) != "" { - t.Errorf("plugins received public URL %q, want none without a relay", handed) + if handed != "" { + t.Errorf("plugins received public URL %q, want none when no address is set", handed) + } +} + +func TestRunRefusesAMalformedPublicURLWithoutARelay(t *testing.T) { + t.Parallel() + + err := run(t.Context(), testkit.Getenv(map[string]string{ + "ALPHONE_DATABASE_URL": unreachableDatabaseURL, + "ALPHONE_PUBLIC_URL": refusedPublicURL, + }), io.Discard, registeringNothing) + + if err == nil || err.Error() != refusedPublicURLError { + t.Errorf("run() error = %v, want %q", err, refusedPublicURLError) + } +} + +func TestTheCommandsRefuseAMalformedPublicURLWithoutARelay(t *testing.T) { + t.Parallel() + + lines := map[string][]string{ + "check": {"check"}, + "account:grant-role": {"account:grant-role", "-role", "admin", "-yes", "-as", "admin@example.com"}, + "account:role": {"account:role", "maria.perez@example.com", "admin", "-yes", "-as", "admin@example.com"}, + "account:disable": {"account:disable", "maria.perez@example.com", "-yes", "-as", "admin@example.com"}, + "account:enable": {"account:enable", "maria.perez@example.com", "-yes", "-as", "admin@example.com"}, + } + for name, args := range lines { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testkit.Run(t, bareProgram(map[string]string{ + "ALPHONE_DATABASE_URL": unreachableDatabaseURL, + "ALPHONE_PUBLIC_URL": refusedPublicURL, + }), "", args...) + + if got.Code != gonsole.ExitFailed || !strings.Contains(got.Stderr, refusedPublicURLError) { + t.Errorf("%s = %d with stderr %q, want 1 and %q", name, got.Code, got.Stderr, refusedPublicURLError) + } + }) } } diff --git a/cmd/alphone/run.go b/cmd/alphone/run.go index 9f58b4b2..8511a295 100644 --- a/cmd/alphone/run.go +++ b/cmd/alphone/run.go @@ -119,7 +119,7 @@ func graphResolver(settings runConfig, built composed, auth *authkit.Handlers, l Onboarding: postgres.NewOnboarding(built.pool, invites), Accounts: built.users, Mailer: built.mailer, - PublicURL: settings.mail.publicURL, + PublicURL: settings.publicURL, Settings: postgres.NewUserSettingStore(built.pool), LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{}), TokenLimiter: ratelimit.NewLimiter(ratelimit.Config{}), @@ -300,6 +300,7 @@ type composeSettings struct { machineGrace time.Duration tenants tenantSettings contactMatchMax int + publicURL string mail mailSettings webhookHosts webhook.AllowList paging graphres.Paging @@ -360,7 +361,7 @@ type resetSettings struct { cooldown time.Duration } -// mailSettings names the relay, sender identity and link base mail rides on. +// mailSettings names the relay and sender identity mail rides on. type mailSettings struct { host string port int @@ -368,7 +369,6 @@ type mailSettings struct { password string from string tls string - publicURL string templateDir string } @@ -406,7 +406,7 @@ const defaultResetLinks = 3 // defaultResetCooldown spaces the reset mail one address may receive. const defaultResetCooldown = time.Minute -// parsePublicURL reads the address email links lead back to, without its trailing slash. +// parsePublicURL reads the address people reach the application at, without its trailing slash. func parsePublicURL(value string) (string, error) { held, err := url.Parse(value) if err != nil { @@ -447,13 +447,6 @@ func loadMailSettings(env gonsole.Env) (mailSettings, error) { if from == "" { return mailSettings{}, fmt.Errorf("%s is required when %s is set", env.Key("SMTP_FROM"), env.Key("SMTP_HOST")) } - publicURL, err := gonsole.Parse(env, "PUBLIC_URL", "", parsePublicURL) - if err != nil { - return mailSettings{}, err - } - if publicURL == "" { - return mailSettings{}, fmt.Errorf("%s is required when %s is set", env.Key("PUBLIC_URL"), env.Key("SMTP_HOST")) - } return mailSettings{ host: host, port: port, @@ -461,11 +454,22 @@ func loadMailSettings(env gonsole.Env) (mailSettings, error) { password: env.Value("SMTP_PASSWORD"), from: from, tls: tlsPolicy, - publicURL: publicURL, templateDir: env.Value("MAIL_TEMPLATE_DIR"), }, nil } +// loadPublicURL reads the address people reach the application at, which a mail relay requires. +func loadPublicURL(env gonsole.Env, mail mailSettings) (string, error) { + publicURL, err := gonsole.Parse(env, "PUBLIC_URL", "", parsePublicURL) + if err != nil { + return "", err + } + if mail.host != "" && publicURL == "" { + return "", fmt.Errorf("%s is required when %s is set", env.Key("PUBLIC_URL"), env.Key("SMTP_HOST")) + } + return publicURL, nil +} + // loadComposeSettings reads the settings compose hands the plugins and the stores it builds. func loadComposeSettings(env gonsole.Env) (composeSettings, error) { machineGrace, err := env.Duration("TENANT_MACHINE_GRACE", tenant.DefaultMachineGrace, gonsole.AllowZero()) @@ -484,6 +488,10 @@ func loadComposeSettings(env gonsole.Env) (composeSettings, error) { if err != nil { return composeSettings{}, err } + publicURL, err := loadPublicURL(env, mail) + if err != nil { + return composeSettings{}, err + } webhookHosts, err := gonsole.Parse(env, "WEBHOOK_ALLOWED_HOSTS", webhook.AllowList{}, webhook.ParseAllowList) if err != nil { return composeSettings{}, err @@ -494,7 +502,7 @@ func loadComposeSettings(env gonsole.Env) (composeSettings, error) { } return composeSettings{ machineGrace: machineGrace, tenants: tenants, contactMatchMax: contactMatchMax, - mail: mail, webhookHosts: webhookHosts, paging: paging, + publicURL: publicURL, mail: mail, webhookHosts: webhookHosts, paging: paging, }, nil } diff --git a/docs/src/content/docs/self-hosting/configuration.md b/docs/src/content/docs/self-hosting/configuration.md index 76d47099..ba3c7162 100644 --- a/docs/src/content/docs/self-hosting/configuration.md +++ b/docs/src/content/docs/self-hosting/configuration.md @@ -20,11 +20,12 @@ When a variable holds a value AlphOne refuses: - A refused value under [Mail](#mail), [Workspaces](#workspaces), [Contacts](#contacts), [Webhooks](#webhooks), [Fields plugin](#fields-plugin) or [WhatsApp plugin](#whatsapp-plugin), - in `ALPHONE_GRAPH_PAGE_SIZE` or `ALPHONE_GRAPH_PAGE_CAP`, - or in `ALPHONE_SHUTDOWN_STOP_GRACE`, also stops the account commands - that take `-as`, before they change anything. It stops `migrate`, - `seed -yes` and `account:create-admin` too, but only once they have - applied the account, record and core migrations. + in `ALPHONE_PUBLIC_URL`, `ALPHONE_GRAPH_PAGE_SIZE` or + `ALPHONE_GRAPH_PAGE_CAP`, or in `ALPHONE_SHUTDOWN_STOP_GRACE`, also + stops the account commands that take `-as`, before they change + anything. It stops `migrate`, `seed -yes` and `account:create-admin` + too, but only once they have applied the account, record and core + migrations. [Commands](/self-hosting/commands/) describes every command. @@ -43,6 +44,7 @@ seconds and gives up after five minutes. | `ALPHONE_DATABASE_URL` | yes | none | PostgreSQL connection string, e.g. `postgres://user:pass@host:5432/alphone?sslmode=disable`. Write a reserved sign in the user name or password percent encoded, `@` as `%40`, `/` as `%2F` and `%` as `%25`. The password ends at its first `@`, so a bare `@` in it stops `serve` at start. | | `ALPHONE_ADDR` | no | `localhost:8080` | Listen address. The container image sets `0.0.0.0:8080`. | | `ALPHONE_WEB_DIR` | no | unset | Directory holding the built frontend, served for all non-API paths. The container image sets `/web`. Unset, only the API is served, which suits development behind Vite. | +| `ALPHONE_PUBLIC_URL` | no | unset | The address people reach AlphOne at, such as `https://crm.example.com`. It must be an `http` or `https` address of a site root, with no path, query or fragment. Plugins get it with or without a mail relay, for example to send people back to AlphOne from another site. An invitation link that AlphOne shows for you to pass on starts with it too. Unset, that link holds only its path, such as `/activate?token=...`. A mail relay requires it, because the link in every mail leads back to it. A value AlphOne refuses stops `serve` and the other commands listed at the top of this page. | | `ALPHONE_TRUSTED_PROXIES` | no | unset | Comma-separated CIDR ranges allowed to set `X-Forwarded-For`, e.g. `172.18.0.0/16`. Only addresses in these ranges are trusted when the login rate limiter resolves the client IP. A peer outside them is limited by its own address, whatever header it sends. A range written in IPv4-mapped form, such as `::ffff:172.18.0.0/112`, counts as the IPv4 range it covers, and one shorter than `/96` stops `serve`. Unset, the direct peer address is used. **Set this whenever AlphOne runs behind a reverse proxy**, or all visitors share one rate-limit bucket. Each entry must be CIDR notation. A bare IP is refused. | | `ALPHONE_DEV_GRAPHIQL` | no | unset | Any non-empty value serves the interactive GraphiQL page on `GET /api/graphql`. Development only. | @@ -55,8 +57,9 @@ reset request sends nothing. `ALPHONE_SMTP_PORT`, `ALPHONE_SMTP_USERNAME`, `ALPHONE_SMTP_PASSWORD`, `ALPHONE_SMTP_FROM` and `ALPHONE_SMTP_TLS` are refused without a host, with an error such as `ALPHONE_SMTP_PORT is set but ALPHONE_SMTP_HOST is not`. -`ALPHONE_PUBLIC_URL` and `ALPHONE_MAIL_TEMPLATE_DIR` are read only with -a host. +`ALPHONE_MAIL_TEMPLATE_DIR` is read only with a host. A relay also needs +[`ALPHONE_PUBLIC_URL`](#core), the address the link in every mail leads +back to. | Variable | Purpose | | --- | --- | @@ -66,7 +69,6 @@ a host. | `ALPHONE_SMTP_PASSWORD` | The password for that user name. | | `ALPHONE_SMTP_FROM` | The address every mail is sent from, such as `crm@example.com`. Required with a host. | | `ALPHONE_SMTP_TLS` | How the connection to the relay is secured with STARTTLS. `mandatory`, the default, refuses a relay that does not offer it. `opportunistic` uses it when the relay offers it, and `none` never does. A user name and password need `mandatory`. | -| `ALPHONE_PUBLIC_URL` | The address people reach AlphOne at, such as `https://crm.example.com`. The link in every mail leads back to it. Required with a host. It must be an `http` or `https` address of a site root, with no path, query or fragment. | | `ALPHONE_MAIL_TEMPLATE_DIR` | A directory of your own mail templates. A file named `invite.tmpl` or `reset.tmpl` there replaces the built-in template of that name. Unset, the built-in ones are used. AlphOne refuses a directory that does not exist. | ## Invitations and password resets diff --git a/docs/src/content/docs/self-hosting/updates-and-backups.md b/docs/src/content/docs/self-hosting/updates-and-backups.md index b251e0f8..03ab6453 100644 --- a/docs/src/content/docs/self-hosting/updates-and-backups.md +++ b/docs/src/content/docs/self-hosting/updates-and-backups.md @@ -106,6 +106,15 @@ server, because a watcher on `:latest` applies it on its own. - Only accounts that may manage users open Users and New user. A member who opens them lands on the home screen and finds their API tokens in the menu where Users was. +- `ALPHONE_PUBLIC_URL` is now read without a mail relay too. On an + install that sends no mail, a value AlphOne refuses now stops `serve`, + the account commands that take `-as`, `migrate`, `seed -yes` and + `account:create-admin`, as the top of + [Configuration](/self-hosting/configuration/) describes, and + `alphone check` names it. When it is set, the activation link AlphOne + shows for you to pass on starts with it, so make sure it still names + the address people reach AlphOne at. See + [Core](/self-hosting/configuration/#core). - What's Up Docker before 9.1.0 rebuilds the container from the old container's settings. Those name no command, because older images had none, so the new version only lists the commands, exits 0 and diff --git a/internal/graphres/graphres.go b/internal/graphres/graphres.go index 82f513f2..815ce41f 100644 --- a/internal/graphres/graphres.go +++ b/internal/graphres/graphres.go @@ -233,7 +233,7 @@ type Resolver struct { Onboarding Onboarder // Mailer delivers the account mail. Nil runs without a mailer. Mailer Mailer - // PublicURL is the address email links lead back to, empty without a mailer. + // PublicURL is the address email and invitation links lead back to, empty when unset. PublicURL string // TokenLimiter counts token operations per client IP. TokenLimiter AttemptLimiter diff --git a/sdk/sdk.go b/sdk/sdk.go index dbb16a57..f8472826 100644 --- a/sdk/sdk.go +++ b/sdk/sdk.go @@ -50,7 +50,7 @@ type PublicPathProvider = pluginkit.PublicPathProvider // which the host calls once per installed plugin. type Deps struct { DatabaseURL string - // PublicURL is the address people reach the application at, empty without a mail relay. + // PublicURL is the address people reach the application at, empty when unset. PublicURL string // MachineGrace is how long a deactivated tenant keeps recording what a channel delivers. MachineGrace time.Duration diff --git a/test/features/features/operator-commands.feature b/test/features/features/operator-commands.feature index a5679b59..e4127823 100644 --- a/test/features/features/operator-commands.feature +++ b/test/features/features/operator-commands.feature @@ -148,6 +148,22 @@ Feature: Operators run AlphOne from one command line Then the command exits with code 1 And the answer names the setting "ALPHONE_INVITE_TTL" + Scenario: The check command names a malformed public address on an install without a mail relay + Given the setting "ALPHONE_PUBLIC_URL" holds "ftp://crm.example.com" + When the operator runs "check" + Then the command exits with code 1 + And the answer names the setting "ALPHONE_PUBLIC_URL" + + Scenario: An account change is refused over a malformed public address on an install without a mail relay + Given the administrator "admin@example.com" + And the member "maria.perez@example.com" + And the setting "ALPHONE_PUBLIC_URL" holds "ftp://crm.example.com" + When the operator gives "maria.perez@example.com" the role "admin" acting as "admin@example.com" + Then the command exits with code 1 + And the error says "ALPHONE_PUBLIC_URL: must be an http or https address" + And the account "maria.perez@example.com" still holds the role "member" + And no account change is on record + Scenario: A token belongs to its owner's workspace Given the member "maria.perez@example.com" And the workspace "Acme" exists