diff --git a/Procfile b/Procfile index c2da4852fa..050b57698b 100644 --- a/Procfile +++ b/Procfile @@ -1,7 +1,7 @@ build: while true; do make -qs bin/goalert || make bin/goalert || (echo '\033[0;31mBuild Failure'; sleep 3); sleep 0.1; done @watch-file=./bin/goalert -goalert: ./bin/goalert -l=localhost:3030 --ui-dir=web/src/build --db-url=postgres://goalert@localhost --listen-sysapi=localhost:1234 --listen-prometheus=localhost:2112 +goalert: ./bin/goalert -l=localhost:3030 --ui-dir=web/src/build --db-url=postgres://goalert@localhost --listen-sysapi=localhost:1234 --listen-prometheus=localhost:2112 --public-url=http://localhost:3030$HTTP_PREFIX smtp: go run github.com/mailhog/MailHog -ui-bind-addr=localhost:8025 -api-bind-addr=localhost:8025 -smtp-bind-addr=localhost:1025 | grep -v KEEPALIVE prom: bin/tools/prometheus --log.level=warn --config.file=devtools/prometheus/prometheus.yml --storage.tsdb.path=bin/prom-data/ --web.listen-address=localhost:9090 diff --git a/Procfile.cypress b/Procfile.cypress index 951e6f1e19..c23ca6321e 100644 --- a/Procfile.cypress +++ b/Procfile.cypress @@ -1,7 +1,7 @@ build: while true; do make -qs bin/goalert || make bin/goalert || (echo '\033[0;31mBuild Failure'; sleep 3); sleep 0.1; done @watch-file=./bin/goalert -goalert: go run ./devtools/waitfor postgres://postgres@localhost:5433 && go run ./devtools/procwrap -test=localhost:3042 bin/goalert -l=localhost:3042 --ui-dir=web/src/build --db-url=postgres://postgres@localhost:5433 --slack-base-url=http://localhost:3040/slack --log-errors-only +goalert: go run ./devtools/waitfor postgres://postgres@localhost:5433 && go run ./devtools/procwrap -test=localhost:3042 bin/goalert -l=localhost:3042 --ui-dir=web/src/build --db-url=postgres://postgres@localhost:5433 --slack-base-url=http://localhost:3040/slack --log-errors-only --public-url=http://localhost:3040$HTTP_PREFIX @watch-file=./web/src/esbuild.config.js ui: yarn workspace goalert-web run esbuild --watch @@ -14,6 +14,6 @@ slack: go run ./devtools/mockslack/cmd/mockslack -client-id=000000000000.0000000 proxy: go run ./devtools/simpleproxy -addr=localhost:3040 /slack/=http://localhost:3046 http://localhost:3042 @oneshot -cypress: go run ./devtools/waitfor http://localhost:3042 && CYPRESS_DB_URL=postgres://postgres@localhost:5433 yarn workspace goalert-web --cwd=bin/build/integration cypress open --config baseUrl=http://localhost:3040$GOALERT_HTTP_PREFIX +cypress: go run ./devtools/waitfor http://localhost:3042 && CYPRESS_DB_URL=postgres://postgres@localhost:5433 yarn workspace goalert-web --cwd=bin/build/integration cypress open --config baseUrl=http://localhost:3040$HTTP_PREFIX db: $CONTAINER_TOOL rm -f smoketest-postgres || true; $CONTAINER_TOOL run -it --rm --name smoketest-postgres -p5433:5432 -e=POSTGRES_HOST_AUTH_METHOD=trust postgres:13-alpine diff --git a/Procfile.cypress.ci b/Procfile.cypress.ci index 047642beab..02a4513991 100644 --- a/Procfile.cypress.ci +++ b/Procfile.cypress.ci @@ -1,7 +1,7 @@ @oneshot -cypress: go run ./devtools/waitfor http://localhost:3042 && CYPRESS_DB_URL=$DB_URL yarn workspace goalert-web --cwd=bin/build/integration cypress $CY_ACTION --config baseUrl=http://localhost:3040$GOALERT_HTTP_PREFIX +cypress: go run ./devtools/waitfor http://localhost:3042 && CYPRESS_DB_URL=$DB_URL yarn workspace goalert-web --cwd=bin/build/integration cypress $CY_ACTION --config baseUrl=http://localhost:3040$HTTP_PREFIX -goalert: go run ./devtools/waitfor $DB_URL && go run ./devtools/procwrap -test=localhost:3042 bin/goalert -l=localhost:3042 --db-url=$DB_URL --slack-base-url=http://localhost:3040/slack --log-errors-only +goalert: go run ./devtools/waitfor $DB_URL && go run ./devtools/procwrap -test=localhost:3042 bin/goalert -l=localhost:3042 --db-url=$DB_URL --slack-base-url=http://localhost:3040/slack --log-errors-only --public-url=http://localhost:3040$HTTP_PREFIX slack: go run ./devtools/mockslack/cmd/mockslack -client-id=000000000000.000000000000 -client-secret=00000000000000000000000000000000 -access-token=xoxp-000000000000-000000000000-000000000000-00000000000000000000000000000000 -prefix=/slack -single-user=bob -addr=localhost:3046 diff --git a/Procfile.cypress.prod b/Procfile.cypress.prod index fe37301444..c47941573f 100644 --- a/Procfile.cypress.prod +++ b/Procfile.cypress.prod @@ -1,14 +1,14 @@ build: while true; do make -qs bin/goalert BUNDLE=1 >/dev/null || make bin/goalert BUNDLE=1 || (echo '\033[0;31mBuild Failure'; sleep 3); sleep 0.1; done @watch-file=./bin/goalert -goalert: go run ./devtools/waitfor postgres://postgres@localhost:5433 && go run ./devtools/procwrap -test=localhost:3042 bin/goalert -l=localhost:3042 --db-url=postgres://postgres@localhost:5433 --slack-base-url=http://localhost:3040/slack --log-errors-only +goalert: go run ./devtools/waitfor postgres://postgres@localhost:5433 && go run ./devtools/procwrap -test=localhost:3042 bin/goalert -l=localhost:3042 --db-url=postgres://postgres@localhost:5433 --slack-base-url=http://localhost:3040/slack --log-errors-only --public-url=http://localhost:3040$HTTP_PREFIX slack: go run ./devtools/mockslack/cmd/mockslack -client-id=000000000000.000000000000 -client-secret=00000000000000000000000000000000 -access-token=xoxp-000000000000-000000000000-000000000000-00000000000000000000000000000000 -prefix=/slack -single-user=bob -addr=localhost:3046 proxy: go run ./devtools/simpleproxy -addr=localhost:3040 /slack/=http://localhost:3046 http://localhost:3042 @oneshot -cypress: go run ./devtools/waitfor http://localhost:3042 && CYPRESS_DB_URL=postgres://postgres@localhost:5433 yarn workspace goalert-web --cwd=bin/build/integration cypress $CY_ACTION --config baseUrl=http://localhost:3040$GOALERT_HTTP_PREFIX +cypress: go run ./devtools/waitfor http://localhost:3042 && CYPRESS_DB_URL=postgres://postgres@localhost:5433 yarn workspace goalert-web --cwd=bin/build/integration cypress $CY_ACTION --config baseUrl=http://localhost:3040$HTTP_PREFIX db: $CONTAINER_TOOL rm -f smoketest-postgres || true; $CONTAINER_TOOL run -it --rm --name smoketest-postgres -p5433:5432 -e=POSTGRES_HOST_AUTH_METHOD=trust postgres:13-alpine diff --git a/Procfile.prod b/Procfile.prod index 931b747458..5ca04e8542 100644 --- a/Procfile.prod +++ b/Procfile.prod @@ -1,7 +1,7 @@ build: while true; do make -qs bin/goalert BUNDLE=1 || make bin/goalert BUNDLE=1 || (echo '\033[0;31mBuild Failure'; sleep 3); sleep 0.1; done @watch-file=./bin/goalert -goalert: ./bin/goalert -l=localhost:3030 --db-url=postgres://goalert@localhost --listen-sysapi=localhost:1234 --listen-prometheus=localhost:2112 +goalert: ./bin/goalert -l=localhost:3030 --db-url=postgres://goalert@localhost --listen-sysapi=localhost:1234 --listen-prometheus=localhost:2112 --public-url=http://localhost:3030$HTTP_PREFIX smtp: go run github.com/mailhog/MailHog -ui-bind-addr=localhost:8025 -api-bind-addr=localhost:8025 -smtp-bind-addr=localhost:1025 | grep -v KEEPALIVE prom: bin/tools/prometheus --log.level=warn --config.file=devtools/prometheus/prometheus.yml --storage.tsdb.path=bin/prom-data/ --web.listen-address=localhost:9090 diff --git a/app/cmd.go b/app/cmd.go index a78e4b0809..fcf6d51cbc 100644 --- a/app/cmd.go +++ b/app/cmd.go @@ -263,7 +263,7 @@ Migration: %s (#%d) ctx := cmd.Context() - store, err := config.NewStore(ctx, conn, cf.EncryptionKeys, "") + store, err := config.NewStore(ctx, conn, cf.EncryptionKeys, "", "") if err != nil { return fmt.Errorf("read config: %w", err) } @@ -623,6 +623,8 @@ func getConfig(ctx context.Context) (Config, error) { DBMaxOpen: viper.GetInt("db-max-open"), DBMaxIdle: viper.GetInt("db-max-idle"), + PublicURL: viper.GetString("public-url"), + MaxReqBodyBytes: viper.GetInt64("max-request-body-bytes"), MaxReqHeaderBytes: viper.GetInt("max-request-header-bytes"), @@ -657,6 +659,17 @@ func getConfig(ctx context.Context) (Config, error) { UIDir: viper.GetString("ui-dir"), } + if cfg.PublicURL != "" { + u, err := url.Parse(cfg.PublicURL) + if err != nil { + return cfg, errors.Wrap(err, "parse public url") + } + if cfg.HTTPPrefix != "" { + return cfg, errors.New("public-url and http-prefix cannot be used together") + } + cfg.HTTPPrefix = u.Path + } + if cfg.DBURL == "" { return cfg, ErrDBRequired } @@ -685,6 +698,8 @@ func init() { RootCmd.Flags().String("sysapi-key-file", "", "(Experimental) Specifies a path to a PEM-encoded private key file use when connecting to plugin services.") RootCmd.Flags().String("sysapi-ca-file", "", "(Experimental) Specifies a path to a PEM-encoded certificate(s) to authorize connections from plugin services.") + RootCmd.Flags().String("public-url", "", "Externally routable URL to the application. Used for validating callback requests, links, auth, and prefix calculation.") + RootCmd.PersistentFlags().StringP("listen-prometheus", "p", "", "Bind address for Prometheus metrics.") RootCmd.Flags().String("tls-cert-file", "", "Specifies a path to a PEM-encoded certificate. Has no effect if --listen-tls is unset.") @@ -693,6 +708,7 @@ func init() { RootCmd.Flags().String("tls-key-data", "", "Specifies a PEM-encoded private key. Has no effect if --listen-tls is unset.") RootCmd.Flags().String("http-prefix", def.HTTPPrefix, "Specify the HTTP prefix of the application.") + RootCmd.Flags().MarkDeprecated("http-prefix", "use --public-url instead") RootCmd.Flags().Bool("api-only", def.APIOnly, "Starts in API-only mode (schedules & notifications will not be processed). Useful in clusters.") diff --git a/app/config.go b/app/config.go index 2be896156a..480f8e9b8e 100644 --- a/app/config.go +++ b/app/config.go @@ -19,6 +19,8 @@ type Config struct { APIOnly bool LogEngine bool + PublicURL string + TLSListenAddr string TLSConfig *tls.Config diff --git a/app/getsetconfig.go b/app/getsetconfig.go index 33b0b38639..d012aba3a8 100644 --- a/app/getsetconfig.go +++ b/app/getsetconfig.go @@ -41,7 +41,7 @@ func getSetConfig(ctx context.Context, setCfg bool, data []byte) error { } defer tx.Rollback() - s, err := config.NewStore(ctx, db, c.EncryptionKeys, "") + s, err := config.NewStore(ctx, db, c.EncryptionKeys, "", "") if err != nil { return errors.Wrap(err, "init config store") } diff --git a/app/initstores.go b/app/initstores.go index 380945f4b6..86bf76d16b 100644 --- a/app/initstores.go +++ b/app/initstores.go @@ -45,7 +45,7 @@ func (app *App) initStores(ctx context.Context) error { fallback.Scheme = "http" fallback.Host = app.l.Addr().String() fallback.Path = app.cfg.HTTPPrefix - app.ConfigStore, err = config.NewStore(ctx, app.db, app.cfg.EncryptionKeys, fallback.String()) + app.ConfigStore, err = config.NewStore(ctx, app.db, app.cfg.EncryptionKeys, app.cfg.PublicURL, fallback.String()) } if err != nil { return errors.Wrap(err, "init config store") diff --git a/auth/cookies.go b/auth/cookies.go index 9a875b286e..f778facd99 100644 --- a/auth/cookies.go +++ b/auth/cookies.go @@ -2,7 +2,11 @@ package auth import ( "net/http" + "net/url" + "strings" "time" + + "github.com/target/goalert/config" ) // SetCookie will set a cookie value for all API prefixes, respecting the current config parameters. @@ -12,18 +16,25 @@ func SetCookie(w http.ResponseWriter, req *http.Request, name, value string) { // SetCookieAge behaves like SetCookie but also sets the MaxAge. func SetCookieAge(w http.ResponseWriter, req *http.Request, name, value string, age time.Duration) { + cfg := config.FromContext(req.Context()) + u, err := url.Parse(cfg.PublicURL()) + if err != nil { + panic(err) + } + + cookiePath := "/" + secure := req.URL.Scheme == "https" + if cfg.ShouldUsePublicURL() { + cookiePath = strings.TrimSuffix(u.Path, "/") + "/" + secure = u.Scheme == "https" + } + http.SetCookie(w, &http.Cookie{ HttpOnly: true, - Secure: req.URL.Scheme == "https", + Secure: secure, Name: name, - // Until we can finish removing /v1 from all UI calls - // we need cookies available on both /api and /v1. - // - // Unfortunately we can't just set both paths without breaking integration tests... - // We'll keep this as `/` until Cypress fixes it's cookie handling, or we - // finish removing the `/v1` UI code. Whichever is sooner. - Path: "/", + Path: cookiePath, Value: value, MaxAge: int(age.Seconds()), }) diff --git a/auth/handler.go b/auth/handler.go index e514b2258d..7624129d52 100644 --- a/auth/handler.go +++ b/auth/handler.go @@ -208,16 +208,38 @@ func (h *Handler) FindAllUserSessions(ctx context.Context, userID string) ([]Use return sessions, nil } -// ServeLogout will clear the current session cookie and end the session (if any). +// ServeLogout will clear the current session cookie and end the session(s) (if any). func (h *Handler) ServeLogout(w http.ResponseWriter, req *http.Request) { - h.setSessionCookie(w, req, "") + ClearCookie(w, req, CookieName) + var sessionIDs []string + for _, c := range req.Cookies() { + switch c.Name { + case CookieName, v1CookieName: + default: + // only interested in cookies with one of the names above + continue + } + + tok, _, _ := authtoken.Parse(c.Value, nil) + if tok == nil { + continue + } + sessionIDs = append(sessionIDs, tok.ID.String()) + } ctx := req.Context() src := permission.Source(ctx) if src != nil && src.Type == permission.SourceTypeAuthProvider { - _, err := h.endSession.ExecContext(log.FromContext(ctx).BackgroundContext(), sqlutil.UUIDArray([]string{src.ID})) - if err != nil { - log.Log(ctx, errors.Wrap(err, "end session")) - } + sessionIDs = append(sessionIDs, src.ID) + } + + if len(sessionIDs) == 0 { + // no session to end + return + } + + _, err := h.endSession.ExecContext(log.FromContext(ctx).BackgroundContext(), sqlutil.UUIDArray(sessionIDs)) + if err != nil { + log.Log(ctx, errors.Wrap(err, "end session(s)")) } } @@ -268,14 +290,27 @@ func (h *Handler) IdentityProviderHandler(id string) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { ctx := req.Context() + cfg := config.FromContext(ctx) var refU *url.URL if req.Method == "POST" { - var ok bool - refU, ok = h.refererURL(w, req) - if !ok { - errutil.HTTPError(ctx, w, validation.NewFieldError("referer", "failed to resolve referer")) - return + if cfg.ShouldUsePublicURL() { + refU, _ = url.Parse(req.Header.Get("referer")) + if refU == nil || !cfg.ValidReferer("", req.Header.Get("referer")) { + // redirect with err + q := make(url.Values) + q.Set("login_error", "invalid referer") + http.Redirect(w, req, cfg.CallbackURL("", q), http.StatusTemporaryRedirect) + return + } + } else { + // fallback to old method + var ok bool + refU, ok = h.refererURL(w, req) + if !ok { + errutil.HTTPError(ctx, w, validation.NewFieldError("referer", "failed to resolve referer")) + return + } } } else { c, err := req.Cookie("login_redir") @@ -354,9 +389,14 @@ func (h *Handler) handleProvider(id string, p IdentityProvider, refU *url.URL, w route.RelativePath = "/" } - u := *req.URL - u.RawQuery = "" // strip query params - route.CurrentURL = u.String() + cfg := config.FromContext(ctx) + if cfg.ShouldUsePublicURL() { + route.CurrentURL = cfg.CallbackURL(req.URL.Path) + } else { + u := *req.URL + u.RawQuery = "" // strip query params + route.CurrentURL = u.String() + } sub, err := p.ExtractIdentity(&route, w, req) var r Redirector @@ -493,12 +533,7 @@ func (h *Handler) CreateSession(ctx context.Context, userAgent, userID string) ( } func (h *Handler) setSessionCookie(w http.ResponseWriter, req *http.Request, val string) { - ClearCookie(w, req, "login_redir") - if val == "" { - ClearCookie(w, req, CookieName) - } else { - SetCookieAge(w, req, CookieName, val, 30*24*time.Hour) - } + SetCookieAge(w, req, CookieName, val, 30*24*time.Hour) } func (h *Handler) authWithToken(w http.ResponseWriter, req *http.Request, next http.Handler) bool { @@ -549,6 +584,47 @@ func (h *Handler) authWithToken(w http.ResponseWriter, req *http.Request, next h return true } +func (h *Handler) tryAuthUser(ctx context.Context, w http.ResponseWriter, req *http.Request, tokenStr string, isCookie bool) (context.Context, error) { + tok, isOld, err := authtoken.Parse(tokenStr, func(t authtoken.Type, p, sig []byte) (bool, bool) { + // only session tokens are supported for cookies + return h.cfg.SessionKeyring.Verify(p, sig) + }) + if err != nil { + return nil, err + } + + var userID uuid.UUID + var userRole permission.Role + err = h.fetchSession.QueryRowContext(ctx, tok.ID.String()).Scan(&userID, &userRole) + if err != nil { + return nil, err + } + + if isCookie && isOld { + // send new signature back if it was signed with an old key + newSignedToken, err := tok.Encode(h.cfg.SessionKeyring.Sign) + if err != nil { + log.Log(ctx, errors.Wrap(err, "failed to sign/issue new session token")) + } else { + h.setSessionCookie(w, req, newSignedToken) + _, err = h.updateUA.ExecContext(ctx, tok.ID.String(), req.UserAgent()) + if err != nil { + log.Log(ctx, errors.Wrap(err, "update user agent (session key refresh)")) + } + } + } + + return permission.UserSourceContext( + ctx, + userID.String(), + userRole, + &permission.SourceInfo{ + Type: permission.SourceTypeAuthProvider, + ID: tok.ID.String(), + }, + ), nil +} + // WrapHandler will wrap an existing http.Handler so the Context of the request // includes authentication information (if the request is authorized). // @@ -567,80 +643,36 @@ func (h *Handler) WrapHandler(wrapped http.Handler) http.Handler { } // User session flow - ctx := req.Context() + tokStr := GetToken(req) - var fromCookie bool - if tokStr == "" { - c, err := req.Cookie(CookieName) - if err == nil { - fromCookie = true - tokStr = c.Value - } - } - if tokStr == "" { - c, err := req.Cookie(v1CookieName) - if err == nil { - fromCookie = true - tokStr = c.Value + // explicit token always takes precedence + if tokStr != "" { + ctx, err := h.tryAuthUser(req.Context(), w, req, tokStr, false) + if err != nil { + wrapped.ServeHTTP(w, req) + return } - } - if tokStr == "" { - // no cookie value - wrapped.ServeHTTP(w, req) + wrapped.ServeHTTP(w, req.WithContext(ctx)) return } - tok, isOld, err := authtoken.Parse(tokStr, func(t authtoken.Type, p, sig []byte) (bool, bool) { - // only session tokens are supported for cookies - return h.cfg.SessionKeyring.Verify(p, sig) - }) - if err != nil { - if fromCookie { - h.setSessionCookie(w, req, "") + + for _, c := range req.Cookies() { + switch c.Name { + case CookieName, v1CookieName: + default: + // only interested in cookies with one of the names above + continue } - wrapped.ServeHTTP(w, req) - return - } - if fromCookie && isOld { - // send new signature back if it was signed with an old key - newSignedToken, err := tok.Encode(h.cfg.SessionKeyring.Sign) + ctx, err := h.tryAuthUser(req.Context(), w, req, c.Value, true) if err != nil { - log.Log(ctx, errors.Wrap(err, "failed to sign/issue new session token")) - } else { - h.setSessionCookie(w, req, newSignedToken) - _, err = h.updateUA.ExecContext(ctx, tok.ID.String(), req.UserAgent()) - if err != nil { - log.Log(ctx, errors.Wrap(err, "update user agent (session key refresh)")) - } + continue } - } - var userID string - var userRole permission.Role - err = h.fetchSession.QueryRowContext(ctx, tok.ID.String()).Scan(&userID, &userRole) - if errors.Is(err, sql.ErrNoRows) { - if fromCookie { - h.setSessionCookie(w, req, "") - } - wrapped.ServeHTTP(w, req) + wrapped.ServeHTTP(w, req.WithContext(ctx)) return } - if err != nil { - errutil.HTTPError(ctx, w, err) - return - } - - ctx = permission.UserSourceContext( - ctx, - userID, - userRole, - &permission.SourceInfo{ - Type: permission.SourceTypeAuthProvider, - ID: tok.ID.String(), - }, - ) - req = req.WithContext(ctx) wrapped.ServeHTTP(w, req) }) diff --git a/auth/routeinfo.go b/auth/routeinfo.go index 568ed9b667..c0710b708d 100644 --- a/auth/routeinfo.go +++ b/auth/routeinfo.go @@ -6,7 +6,7 @@ type RouteInfo struct { // identity provider. RelativePath string - // CurrentURL is calculated using the AuthRefererURLs and + // CurrentURL is calculated using the --public-url or AuthRefererURLs and // the current auth attempt's referer. It does not include // query parameters of the current request. CurrentURL string diff --git a/config/config.go b/config/config.go index 5d999c1371..877b470f00 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "net/http" "net/url" "strings" @@ -17,11 +18,12 @@ const SchemaVersion = 1 type Config struct { data []byte fallbackURL string + explicitURL string General struct { ApplicationName string `public:"true" info:"The name used in messaging and page titles. Defaults to \"GoAlert\"."` - PublicURL string `public:"true" info:"Publicly routable URL for UI links and API calls."` - GoogleAnalyticsID string `public:"true" info:"No longer used."` + PublicURL string `public:"true" info:"Publicly routable URL for UI links and API calls." deprecated:"Use --public-url flag instead, which takes precedence."` + GoogleAnalyticsID string `public:"true" info:"No longer used." deprecated:"No longer used."` NotificationDisclaimer string `public:"true" info:"Disclaimer text for receiving pre-recorded notifications (appears on profile page)."` DisableMessageBundles bool `public:"true" info:"Disable bundling status updates and alert notifications."` ShortURL string `public:"true" info:"If set, messages will contain a shorter URL using this as a prefix (e.g. http://example.com). It should point to GoAlert and can be the same as the PublicURL."` @@ -37,7 +39,7 @@ type Config struct { } Auth struct { - RefererURLs []string `info:"Allowed referer URLs for auth and redirects."` + RefererURLs []string `info:"Allowed referer URLs for auth and redirects." deprecated:"Use --public-url flag instead, which takes precedence."` DisableBasic bool `public:"true" info:"Disallow username/password login."` } @@ -152,6 +154,31 @@ func (cfg Config) TwilioSMSFromNumber(carrier string) string { return cfg.Twilio.FromNumber } +// RequestURL returns the full URL for the given request based on the current public url. +func RequestURL(req *http.Request) string { + cfg := FromContext(req.Context()) + if !cfg.ShouldUsePublicURL() { + // fallback to old method + u, err := url.ParseRequestURI(req.RequestURI) + if err != nil { + panic(errors.Wrap(err, "parse RequestURI")) + } + u.Host = req.Host + u.Scheme = req.URL.Scheme + return u.String() + } + + base, err := url.Parse(cfg.PublicURL()) + if err != nil { + panic(errors.Wrap(err, "parse PublicURL")) + } + + base.Path = strings.TrimSuffix(base.Path, "/") + req.URL.Path + base.RawQuery = req.URL.RawQuery + + return base.String() +} + func (cfg Config) rawCallbackURL(path string, mergeParams ...url.Values) *url.URL { base, err := url.Parse(cfg.PublicURL()) if err != nil { @@ -280,8 +307,18 @@ func (cfg Config) ValidWebhookURL(testURL string) bool { return false } +// ShouldUsePublicURL returns true if redirects, validation, etc.. should use the +// configured PublicURL instead of host/referer. +func (cfg Config) ShouldUsePublicURL() bool { return cfg.explicitURL != "" } + // ValidReferer returns true if the URL is an allowed referer source. func (cfg Config) ValidReferer(reqURL, ref string) bool { + // --public-url flag takes precedence + if cfg.explicitURL != "" { + valid, _ := MatchURL(cfg.explicitURL, ref) + return valid + } + pubURL := cfg.PublicURL() if pubURL != "" && strings.HasPrefix(ref, pubURL) { return true @@ -325,11 +362,14 @@ func (cfg Config) ApplicationName() string { // PublicURL will return the General.PublicURL or a fallback address (i.e. the app listening port). func (cfg Config) PublicURL() string { - if cfg.General.PublicURL == "" { - return strings.TrimSuffix(cfg.fallbackURL, "/") + switch { + case cfg.explicitURL != "": + return strings.TrimSuffix(cfg.explicitURL, "/") + case cfg.General.PublicURL != "": + return strings.TrimSuffix(cfg.General.PublicURL, "/") } - return strings.TrimSuffix(cfg.General.PublicURL, "/") + return strings.TrimSuffix(cfg.fallbackURL, "/") } func validateEnable(prefix string, isEnabled bool, vals ...string) error { diff --git a/config/store.go b/config/store.go index f384a02125..b106004bd6 100644 --- a/config/store.go +++ b/config/store.go @@ -28,6 +28,7 @@ type Store struct { rawCfg Config cfgVers int fallbackURL string + explicitURL string mx sync.RWMutex db *sql.DB keys keyring.Keys @@ -40,12 +41,13 @@ type Store struct { // NewStore will create a new Store with the given parameters. It will automatically detect // new configuration changes. -func NewStore(ctx context.Context, db *sql.DB, keys keyring.Keys, fallbackURL string) (*Store, error) { +func NewStore(ctx context.Context, db *sql.DB, keys keyring.Keys, explicitURL, fallbackURL string) (*Store, error) { p := util.Prepare{Ctx: ctx, DB: db} s := &Store{ db: db, fallbackURL: fallbackURL, + explicitURL: explicitURL, latestConfig: p.P(`select id, data, schema from config where schema <= $1 order by id desc limit 1`), setConfig: p.P(`insert into config (id, schema, data) values (DEFAULT, $1, $2) returning (id)`), lock: p.P(`lock config in exclusive mode`), @@ -125,6 +127,7 @@ func (s *Store) Reload(ctx context.Context) error { } rawCfg := *cfg rawCfg.fallbackURL = s.fallbackURL + rawCfg.explicitURL = s.explicitURL err = cfg.Validate() if err != nil { diff --git a/devtools/configparams/run.go b/devtools/configparams/run.go index 1f5ba66bd2..14d72b11e1 100644 --- a/devtools/configparams/run.go +++ b/devtools/configparams/run.go @@ -48,7 +48,7 @@ func MapConfigHints(cfg config.Hints) []ConfigHint { func MapConfigValues(cfg config.Config) []ConfigValue { return []ConfigValue{ {{- range .ConfigFields }} - {ID: {{quote .ID}}, Type: {{.Type}}, Description: {{quote .Desc}}, Value: {{.Value}}{{if .Password}}, Password: true{{end}}}, + {ID: {{quote .ID}}, Type: {{.Type}}, Description: {{quote .Desc}}, Value: {{.Value}}{{if .Password}}, Password: true{{end}}{{if .Dep}}, Deprecated: {{quote .Dep}}{{end}}}, {{- end}} } } @@ -58,7 +58,7 @@ func MapPublicConfigValues(cfg config.Config) []ConfigValue { return []ConfigValue{ {{- range .ConfigFields }} {{- if .Public}} - {ID: {{quote .ID}}, Type: {{.Type}}, Description: {{quote .Desc}}, Value: {{.Value}}{{if .Password}}, Password: true{{end}}}, + {ID: {{quote .ID}}, Type: {{.Type}}, Description: {{quote .Desc}}, Value: {{.Value}}{{if .Password}}, Password: true{{end}}{{if .Dep}}, Deprecated: {{quote .Dep}}{{end}}}, {{- end}} {{- end}} } @@ -129,8 +129,8 @@ func ApplyConfigValues(cfg config.Config, vals []ConfigValueInput) (config.Confi `)) type field struct { - ID, Type, Desc, Value string - Public, Password bool + ID, Type, Desc, Value, Dep string + Public, Password bool } func main() { @@ -155,8 +155,8 @@ package graphql2`) ConfigFields []field HintFields []field } - input.ConfigFields = printType("", reflect.TypeOf(config.Config{}), "", false, false) - input.HintFields = printType("", reflect.TypeOf(config.Hints{}), "", false, false) + input.ConfigFields = printType("", reflect.TypeOf(config.Config{}), "", "", false, false) + input.HintFields = printType("", reflect.TypeOf(config.Hints{}), "", "", false, false) err := tmpl.Execute(w, input) if err != nil { @@ -169,9 +169,10 @@ func printField(prefix string, f reflect.StructField) []field { if f.Type.Kind() == reflect.Slice && f.Type.Elem().Kind() == reflect.Struct { fPrefix = prefix + f.Name + "[]." } - return printType(fPrefix, f.Type, f.Tag.Get("info"), f.Tag.Get("public") == "true", f.Tag.Get("password") == "true") + return printType(fPrefix, f.Type, f.Tag.Get("info"), f.Tag.Get("deprecated"), f.Tag.Get("public") == "true", f.Tag.Get("password") == "true") } -func printType(prefix string, v reflect.Type, details string, public, pass bool) []field { + +func printType(prefix string, v reflect.Type, details, dep string, public, pass bool) []field { var f []field key := strings.TrimSuffix(prefix, ".") @@ -207,6 +208,6 @@ func printType(prefix string, v reflect.Type, details string, public, pass bool) panic(fmt.Sprintf("not implemented for type %T", v.Kind())) } - f = append(f, field{ID: key, Type: typ, Desc: details, Value: value, Public: public, Password: pass}) + f = append(f, field{ID: key, Type: typ, Desc: details, Dep: dep, Value: value, Public: public, Password: pass}) return f } diff --git a/devtools/sendit/README.md b/devtools/sendit/README.md index d9977dba11..47a5c551fd 100644 --- a/devtools/sendit/README.md +++ b/devtools/sendit/README.md @@ -16,7 +16,7 @@ Usage: `sendit -token / ` Example: `sendit -token xxxxxx https://example.com/foobar http://localhost:3030` -Make sure GoAlert is started with the appropriate prefix. For the above example: `make start GOALERT_HTTP_PREFIX=/foobar` +Make sure GoAlert is started with the appropriate prefix. For the above example: `make start HTTP_PREFIX=/foobar` If you are testing Twilio functionality, you will also need to set your `General.PublicURL` config to the source URL (example above: `https://example.com/foobar`). Don't forget to update configuration within Twilio, GitHub, etc... to match. diff --git a/docs/getting-started.md b/docs/getting-started.md index a4d6494750..e9a9c2872d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -10,12 +10,7 @@ The only hard requirement for GoAlert is a running Postgres instance/database. ### Running Behind a Proxy -When running GoAlert behind a reverse proxy: - -- Specify the `--http-prefix` flag or `GOALERT_HTTP_PREFIX` env var for any instances behind the proxy with a path prefix _without_ the trailing slash -- Ensure the proxy passes the complete path, including prefix, if applicable -- Ensure the proxy passes the original host header (used for validating Twilio requests) -- Ensure the `General.PublicPath` contains the prefix in the URL, if applicable +When running GoAlert behind a reverse proxy, make sure the `--public-url` includes the prefix path, if applicable. Ensure the proxy does _not_ trim the prefix before passing the request to GoAlert; it will be handled internally. ## Database @@ -42,13 +37,13 @@ More information on Postgres connection strings can be found [here](https://www. Binary: ```bash -goalert --db-url postgres://goalert@localhost/goalert --data-encryption-key super-awesome-secret-key +goalert --db-url postgres://goalert@localhost/goalert --data-encryption-key super-awesome-secret-key --public-url https://goalert.example.com ``` Container: ```bash -podman run -p 8081:8081 -e GOALERT_DB_URL=postgres://goalert@localhost/goalert -e GOALERT_DATA_ENCRYPTION_KEY=super-awesome-secret-key goalert/goalert +podman run -p 8081:8081 -e GOALERT_DB_URL=postgres://goalert@localhost/goalert -e GOALERT_DATA_ENCRYPTION_KEY=super-awesome-secret-key -e GOALERT_PUBLIC_URL=https://goalert.example.com goalert/goalert ``` You should see migrations applied followed by a `Listening.` message and an engine cycle start and end. @@ -123,8 +118,8 @@ Using following as examples for required fields: | Field | Example Value | | -------------------------- | ---------------------------------------------------------------- | | Application name | `GoAlert` | -| Homepage URL | `` | -| Authorization callback URL | `/api/v2/identity/providers/github/callback` | +| Homepage URL | `` | +| Authorization callback URL | `/api/v2/identity/providers/github/callback` | Document **Client ID** and **Client Secret** after creation and input into appropriate fields in GoAlert's Admin page. @@ -143,9 +138,9 @@ When creating the **user consent screen**, use the following as examples for req | Field | Example Value | | ------------------------------- | ---------------------- | | Application name | `GoAlert` | -| Authorized domains | `` | -| Application Homepage link | `` | -| Application Privacy Policy link | `` | +| Authorized domains | `` | +| Application Homepage link | `` | +| Application Privacy Policy link | `` | When creating the **OAuth client ID**, use the following as examples for required fields: @@ -153,8 +148,8 @@ When creating the **OAuth client ID**, use the following as examples for require | ----------------------------- | -------------------------------------------------------------- | | Application type | `Web application` | | Name | `GoAlert` | -| Authorized JavaScript origins | `` | -| Authorized redirect URIs | `/api/v2/identity/providers/oidc/callback` | +| Authorized JavaScript origins | `` | +| Authorized redirect URIs | `/api/v2/identity/providers/oidc/callback` | Document **Client ID** and **Client Secret** after creation and input into appropriate fields in GoAlert's Admin page under the **OIDC** section. @@ -177,7 +172,7 @@ To configure Mailgun to forward to GoAlert: 1. Set **Expression Type** to `Match Recipient` 1. Set **Recipient** to `.*@` 1. Check **Forward** -1. In the forward box, enter `/api/v2/mailgun/incoming` +1. In the forward box, enter `/api/v2/mailgun/incoming` 1. Click **Create Route** ### Slack @@ -229,7 +224,7 @@ In order for incoming SMS messages to be processed, the message callback URL mus From Twilio Dashboard, navigate to **Phone Numbers** and click on your trial phone number. -- Under **Messaging** section, update the webhook URL for _A MESSAGE COMES IN_ to `/api/v2/twilio/message` +- Under **Messaging** section, update the webhook URL for _A MESSAGE COMES IN_ to `/api/v2/twilio/message` Twilio trial account limitations (if you decide to upgrade your Twilio account these go away): diff --git a/graphql2/generated.go b/graphql2/generated.go index b9ada61ead..e5fd476398 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -161,6 +161,7 @@ type ComplexityRoot struct { } ConfigValue struct { + Deprecated func(childComplexity int) int Description func(childComplexity int) int ID func(childComplexity int) int Password func(childComplexity int) int @@ -1053,6 +1054,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.ConfigHint.Value(childComplexity), true + case "ConfigValue.deprecated": + if e.complexity.ConfigValue.Deprecated == nil { + break + } + + return e.complexity.ConfigValue.Deprecated(childComplexity), true + case "ConfigValue.description": if e.complexity.ConfigValue.Description == nil { break @@ -6729,6 +6737,50 @@ func (ec *executionContext) fieldContext_ConfigValue_password(ctx context.Contex return fc, nil } +func (ec *executionContext) _ConfigValue_deprecated(ctx context.Context, field graphql.CollectedField, obj *ConfigValue) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_ConfigValue_deprecated(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Deprecated, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_ConfigValue_deprecated(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ConfigValue", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _DebugCarrierInfo_name(ctx context.Context, field graphql.CollectedField, obj *twilio.CarrierInfo) (ret graphql.Marshaler) { fc, err := ec.fieldContext_DebugCarrierInfo_name(ctx, field) if err != nil { @@ -14461,6 +14513,8 @@ func (ec *executionContext) fieldContext_Query_config(ctx context.Context, field return ec.fieldContext_ConfigValue_type(ctx, field) case "password": return ec.fieldContext_ConfigValue_password(ctx, field) + case "deprecated": + return ec.fieldContext_ConfigValue_deprecated(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type ConfigValue", field.Name) }, @@ -26419,6 +26473,13 @@ func (ec *executionContext) _ConfigValue(ctx context.Context, sel ast.SelectionS out.Values[i] = ec._ConfigValue_password(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "deprecated": + + out.Values[i] = ec._ConfigValue_deprecated(ctx, field, obj) + if out.Values[i] == graphql.Null { invalids++ } diff --git a/graphql2/mapconfig.go b/graphql2/mapconfig.go index 9f0deb0694..b22b18b021 100644 --- a/graphql2/mapconfig.go +++ b/graphql2/mapconfig.go @@ -26,8 +26,8 @@ func MapConfigHints(cfg config.Hints) []ConfigHint { func MapConfigValues(cfg config.Config) []ConfigValue { return []ConfigValue{ {ID: "General.ApplicationName", Type: ConfigTypeString, Description: "The name used in messaging and page titles. Defaults to \"GoAlert\".", Value: cfg.General.ApplicationName}, - {ID: "General.PublicURL", Type: ConfigTypeString, Description: "Publicly routable URL for UI links and API calls.", Value: cfg.General.PublicURL}, - {ID: "General.GoogleAnalyticsID", Type: ConfigTypeString, Description: "No longer used.", Value: cfg.General.GoogleAnalyticsID}, + {ID: "General.PublicURL", Type: ConfigTypeString, Description: "Publicly routable URL for UI links and API calls.", Value: cfg.General.PublicURL, Deprecated: "Use --public-url flag instead, which takes precedence."}, + {ID: "General.GoogleAnalyticsID", Type: ConfigTypeString, Description: "No longer used.", Value: cfg.General.GoogleAnalyticsID, Deprecated: "No longer used."}, {ID: "General.NotificationDisclaimer", Type: ConfigTypeString, Description: "Disclaimer text for receiving pre-recorded notifications (appears on profile page).", Value: cfg.General.NotificationDisclaimer}, {ID: "General.DisableMessageBundles", Type: ConfigTypeBoolean, Description: "Disable bundling status updates and alert notifications.", Value: fmt.Sprintf("%t", cfg.General.DisableMessageBundles)}, {ID: "General.ShortURL", Type: ConfigTypeString, Description: "If set, messages will contain a shorter URL using this as a prefix (e.g. http://example.com). It should point to GoAlert and can be the same as the PublicURL.", Value: cfg.General.ShortURL}, @@ -37,7 +37,7 @@ func MapConfigValues(cfg config.Config) []ConfigValue { {ID: "Maintenance.AlertCleanupDays", Type: ConfigTypeInteger, Description: "Closed alerts will be deleted after this many days (0 means disable cleanup).", Value: fmt.Sprintf("%d", cfg.Maintenance.AlertCleanupDays)}, {ID: "Maintenance.APIKeyExpireDays", Type: ConfigTypeInteger, Description: "Unused calendar API keys will be disabled after this many days (0 means disable cleanup).", Value: fmt.Sprintf("%d", cfg.Maintenance.APIKeyExpireDays)}, {ID: "Maintenance.ScheduleCleanupDays", Type: ConfigTypeInteger, Description: "Schedule on-call history will be deleted after this many days (0 means disable cleanup).", Value: fmt.Sprintf("%d", cfg.Maintenance.ScheduleCleanupDays)}, - {ID: "Auth.RefererURLs", Type: ConfigTypeStringList, Description: "Allowed referer URLs for auth and redirects.", Value: strings.Join(cfg.Auth.RefererURLs, "\n")}, + {ID: "Auth.RefererURLs", Type: ConfigTypeStringList, Description: "Allowed referer URLs for auth and redirects.", Value: strings.Join(cfg.Auth.RefererURLs, "\n"), Deprecated: "Use --public-url flag instead, which takes precedence."}, {ID: "Auth.DisableBasic", Type: ConfigTypeBoolean, Description: "Disallow username/password login.", Value: fmt.Sprintf("%t", cfg.Auth.DisableBasic)}, {ID: "GitHub.Enable", Type: ConfigTypeBoolean, Description: "Enable GitHub authentication.", Value: fmt.Sprintf("%t", cfg.GitHub.Enable)}, {ID: "GitHub.NewUsers", Type: ConfigTypeBoolean, Description: "Allow new user creation via GitHub authentication.", Value: fmt.Sprintf("%t", cfg.GitHub.NewUsers)}, @@ -91,8 +91,8 @@ func MapConfigValues(cfg config.Config) []ConfigValue { func MapPublicConfigValues(cfg config.Config) []ConfigValue { return []ConfigValue{ {ID: "General.ApplicationName", Type: ConfigTypeString, Description: "The name used in messaging and page titles. Defaults to \"GoAlert\".", Value: cfg.General.ApplicationName}, - {ID: "General.PublicURL", Type: ConfigTypeString, Description: "Publicly routable URL for UI links and API calls.", Value: cfg.General.PublicURL}, - {ID: "General.GoogleAnalyticsID", Type: ConfigTypeString, Description: "No longer used.", Value: cfg.General.GoogleAnalyticsID}, + {ID: "General.PublicURL", Type: ConfigTypeString, Description: "Publicly routable URL for UI links and API calls.", Value: cfg.General.PublicURL, Deprecated: "Use --public-url flag instead, which takes precedence."}, + {ID: "General.GoogleAnalyticsID", Type: ConfigTypeString, Description: "No longer used.", Value: cfg.General.GoogleAnalyticsID, Deprecated: "No longer used."}, {ID: "General.NotificationDisclaimer", Type: ConfigTypeString, Description: "Disclaimer text for receiving pre-recorded notifications (appears on profile page).", Value: cfg.General.NotificationDisclaimer}, {ID: "General.DisableMessageBundles", Type: ConfigTypeBoolean, Description: "Disable bundling status updates and alert notifications.", Value: fmt.Sprintf("%t", cfg.General.DisableMessageBundles)}, {ID: "General.ShortURL", Type: ConfigTypeString, Description: "If set, messages will contain a shorter URL using this as a prefix (e.g. http://example.com). It should point to GoAlert and can be the same as the PublicURL.", Value: cfg.General.ShortURL}, diff --git a/graphql2/models_gen.go b/graphql2/models_gen.go index e8cc9c7f1b..77da0b2772 100644 --- a/graphql2/models_gen.go +++ b/graphql2/models_gen.go @@ -100,6 +100,7 @@ type ConfigValue struct { Value string `json:"value"` Type ConfigType `json:"type"` Password bool `json:"password"` + Deprecated string `json:"deprecated"` } type ConfigValueInput struct { diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql index 4c9d6c32e7..43bfdd5ef2 100644 --- a/graphql2/schema.graphql +++ b/graphql2/schema.graphql @@ -179,6 +179,7 @@ type ConfigValue { value: String! type: ConfigType! password: Boolean! + deprecated: String! } type ConfigHint { id: String! diff --git a/notification/twilio/validation.go b/notification/twilio/validation.go index edc4d8ed85..854c8fa545 100644 --- a/notification/twilio/validation.go +++ b/notification/twilio/validation.go @@ -3,7 +3,6 @@ package twilio import ( "crypto/hmac" "net/http" - "net/url" "regexp" "github.com/target/goalert/config" @@ -24,14 +23,7 @@ func validateRequest(req *http.Request) error { return errors.New("missing X-Twilio-Signature") } - u, err := url.ParseRequestURI(req.RequestURI) - if err != nil { - return err - } - u.Host = req.Host - u.Scheme = req.URL.Scheme - - calcSig := Signature(cfg.Twilio.AuthToken, u.String(), req.PostForm) + calcSig := Signature(cfg.Twilio.AuthToken, config.RequestURL(req), req.PostForm) if !hmac.Equal([]byte(sig), calcSig) { return errors.New("invalid X-Twilio-Signature") } @@ -55,8 +47,10 @@ func WrapValidation(h http.Handler, c Config) http.Handler { }) } -var numRx = regexp.MustCompile(`^\+\d{1,15}$`) -var sidRx = regexp.MustCompile(`^(CA|SM)[\da-f]{32}$`) +var ( + numRx = regexp.MustCompile(`^\+\d{1,15}$`) + sidRx = regexp.MustCompile(`^(CA|SM)[\da-f]{32}$`) +) func validPhone(n string) string { if !numRx.MatchString(n) { @@ -65,6 +59,7 @@ func validPhone(n string) string { return n } + func validSID(n string) string { if len(n) != 34 { return "" diff --git a/web/live.js b/web/live.js index 7839fd22ab..454922b48f 100644 --- a/web/live.js +++ b/web/live.js @@ -186,7 +186,7 @@ THE SOFTWARE // act upon a changed url of certain content type refreshResource: function (url, type) { - switch (type.toLowerCase()) { + switch ((type || '').toLowerCase()) { // css files can be reloaded dynamically by replacing the link element case 'text/css': var link = currentLinkElements[url], diff --git a/web/src/app/admin/AdminConfig.tsx b/web/src/app/admin/AdminConfig.tsx index 00fb3edaa0..c10eb5024b 100644 --- a/web/src/app/admin/AdminConfig.tsx +++ b/web/src/app/admin/AdminConfig.tsx @@ -34,6 +34,7 @@ const query = gql` password type value + deprecated } configHints { id @@ -230,6 +231,7 @@ export default function AdminConfig(): JSX.Element { password: f.password, type: f.type, value: f.value, + deprecated: f.deprecated, }))} /> diff --git a/web/src/app/admin/AdminSection.tsx b/web/src/app/admin/AdminSection.tsx index 9e299c617f..7fde0a19b6 100644 --- a/web/src/app/admin/AdminSection.tsx +++ b/web/src/app/admin/AdminSection.tsx @@ -14,6 +14,7 @@ import { BoolInput, } from './AdminFieldComponents' import { ConfigValue } from '../../schema' +import { Alert } from '@mui/material' const components = { string: StringInput, @@ -86,8 +87,23 @@ export default function AdminSection(props: AdminSectionProps): JSX.Element { > + + Deprecated: {f.deprecated} + + {f.description} + + ) : ( + f.description + ) + } />
@@ -35,6 +35,7 @@ const WrapLink = forwardRef(function WrapLink( const AppLink: ForwardRefRenderFunction = function AppLink(props, ref): JSX.Element { let { to, newTab, ...other } = props + const [location] = useLocation() if (newTab) { other.target = '_blank' @@ -45,7 +46,7 @@ const AppLink: ForwardRefRenderFunction = // handle relative URLs if (!external && !to.startsWith('/')) { - to = joinURL(window.location.pathname, to) + to = joinURL(location, to) } return ( diff --git a/web/src/cypress/integration/admin.ts b/web/src/cypress/integration/admin.ts index 3e420b1704..a88f13bdfe 100644 --- a/web/src/cypress/integration/admin.ts +++ b/web/src/cypress/integration/admin.ts @@ -1,7 +1,7 @@ import { Chance } from 'chance' import { DateTime } from 'luxon' import { DebugMessage } from '../../schema' -import { testScreen, Config } from '../support' +import { testScreen, Config, pathPrefix } from '../support' const c = new Chance() function testAdmin(): void { @@ -269,10 +269,15 @@ function testAdmin(): void { it('should verify user link from a logs details', () => { cy.get('[data-cy="outgoing-message-list"]').children('div').eq(0).click() + cy.get('[data-cy="debug-message-details"') .find('a') .contains(debugMessage?.userName ?? '') - .should('have.attr', 'href', '/users/' + debugMessage.userID) + .should( + 'have.attr', + 'href', + pathPrefix() + '/users/' + debugMessage.userID, + ) .should('have.attr', 'target', '_blank') .should('have.attr', 'rel', 'noopener noreferrer') }) @@ -282,7 +287,11 @@ function testAdmin(): void { cy.get('[data-cy="debug-message-details"') .find('a') .contains(debugMessage?.serviceName ?? '') - .should('have.attr', 'href', '/services/' + debugMessage.serviceID) + .should( + 'have.attr', + 'href', + pathPrefix() + '/services/' + debugMessage.serviceID, + ) .should('have.attr', 'target', '_blank') .should('have.attr', 'rel', 'noopener noreferrer') }) diff --git a/web/src/cypress/integration/alerts.ts b/web/src/cypress/integration/alerts.ts index 5c012c1d74..7da6e2afac 100644 --- a/web/src/cypress/integration/alerts.ts +++ b/web/src/cypress/integration/alerts.ts @@ -1,6 +1,6 @@ import { Chance } from 'chance' -import { testScreen } from '../support' +import { pathPrefix, testScreen } from '../support' const c = new Chance() function testAlerts(screen: ScreenFormat): void { @@ -175,13 +175,19 @@ function testAlerts(screen: ScreenFormat): void { cy.get('button[aria-label=Acknowledge]').should('not.exist') cy.get( - `[href="/services/${alert1.serviceID}/alerts/${alert1.id}"]`, + `[href="${pathPrefix()}/services/${alert1.serviceID}/alerts/${ + alert1.id + }"]`, ).should('not.contain', 'UNACKNOWLEDGED') cy.get( - `[href="/services/${alert2.serviceID}/alerts/${alert2.id}"]`, + `[href="${pathPrefix()}/services/${alert2.serviceID}/alerts/${ + alert2.id + }"]`, ).should('contain', 'UNACKNOWLEDGED') cy.get( - `[href="/services/${alert3.serviceID}/alerts/${alert3.id}"]`, + `[href="${pathPrefix()}/services/${alert3.serviceID}/alerts/${ + alert3.id + }"]`, ).should('contain', 'UNACKNOWLEDGED') cy.reload() @@ -192,17 +198,27 @@ function testAlerts(screen: ScreenFormat): void { cy.get('button[aria-label=Acknowledge]').click() cy.get('[role="alert"]').should('contain', '2 of 3 alerts updated') cy.get( - `[href="/services/${alert1.serviceID}/alerts/${alert1.id}"]`, + `[href="${pathPrefix()}/services/${alert1.serviceID}/alerts/${ + alert1.id + }"]`, ).should('not.contain', 'UNACKNOWLEDGED') cy.get( - `[href="/services/${alert2.serviceID}/alerts/${alert2.id}"]`, + `[href="${pathPrefix()}/services/${alert2.serviceID}/alerts/${ + alert2.id + }"]`, ).should('not.contain', 'UNACKNOWLEDGED') cy.get( - `[href="/services/${alert3.serviceID}/alerts/${alert3.id}"]`, + `[href="${pathPrefix()}/services/${alert3.serviceID}/alerts/${ + alert3.id + }"]`, ).should('not.contain', 'UNACKNOWLEDGED') }) it('should not acknowledge acknowledged alerts', () => { + const prefix = new URL(Cypress.config().baseUrl || '').pathname.replace( + /\/$/, + '', + ) // ack first two cy.get(`span[data-cy=item-${alert1.id}] input`).check() cy.get(`span[data-cy=item-${alert2.id}] input`).check() @@ -212,13 +228,13 @@ function testAlerts(screen: ScreenFormat): void { // ack // unack cy.get( - `[href="/services/${alert1.serviceID}/alerts/${alert1.id}"]`, + `[href="${prefix}/services/${alert1.serviceID}/alerts/${alert1.id}"]`, ).should('not.contain', 'UNACKNOWLEDGED') cy.get( - `[href="/services/${alert2.serviceID}/alerts/${alert2.id}"]`, + `[href="${prefix}/services/${alert2.serviceID}/alerts/${alert2.id}"]`, ).should('not.contain', 'UNACKNOWLEDGED') cy.get( - `[href="/services/${alert3.serviceID}/alerts/${alert3.id}"]`, + `[href="${prefix}/services/${alert3.serviceID}/alerts/${alert3.id}"]`, ).should('contain', 'UNACKNOWLEDGED') // ack first two again (noop) diff --git a/web/src/cypress/integration/services.ts b/web/src/cypress/integration/services.ts index 6ba16220bf..420d395fbf 100644 --- a/web/src/cypress/integration/services.ts +++ b/web/src/cypress/integration/services.ts @@ -1,13 +1,8 @@ import { Chance } from 'chance' import { DateTime } from 'luxon' -import { testScreen } from '../support' +import { pathPrefix, testScreen } from '../support' const c = new Chance() -function basePrefix(): string { - const u = new URL(Cypress.config('baseUrl') as string) - return u.pathname.replace(/\/$/, '') -} - function testServices(screen: ScreenFormat): void { beforeEach(() => { window.localStorage.setItem('show_services_new_feature_popup', 'false') @@ -243,7 +238,7 @@ function testServices(screen: ScreenFormat): void { .should( 'have.attr', 'href', - basePrefix() + `/escalation-policies/${svc.ep.id}`, + pathPrefix() + `/escalation-policies/${svc.ep.id}`, ) }) @@ -276,7 +271,7 @@ function testServices(screen: ScreenFormat): void { .should( 'have.attr', 'href', - basePrefix() + `/escalation-policies/${ep.id}`, + pathPrefix() + `/escalation-policies/${ep.id}`, ) }) }) diff --git a/web/src/cypress/support/util.ts b/web/src/cypress/support/util.ts index a6d9140440..8eeaff7800 100644 --- a/web/src/cypress/support/util.ts +++ b/web/src/cypress/support/util.ts @@ -32,6 +32,13 @@ values ('${profileAdmin.id}', '${profileAdmin.username}', '${profileAdmin.passwordHash}'); ` +// pathPrefix will return the path prefix for the current environment +// +// Trailing `/` is removed, so it is safe to use `pathPrefix + '/foo'`. +export function pathPrefix(): string { + return new URL(Cypress.config().baseUrl || '').pathname.replace(/\/$/, '') +} + // randInterval creates a random interval in the future. export function randInterval(): Interval { const now = DateTime.utc() diff --git a/web/src/schema.d.ts b/web/src/schema.d.ts index b9258ec4cf..ce6ed69b77 100644 --- a/web/src/schema.d.ts +++ b/web/src/schema.d.ts @@ -103,6 +103,7 @@ export interface ConfigValue { value: string type: ConfigType password: boolean + deprecated: string } export interface ConfigHint {