Allow custom URL prefix for all corteza endpoints

Add new env. variables and options
 - HTTP_BASE_URL to control URL prefix, defaults to /
 - HTTP_SSL_TERMINATED to explicitly set if Corteza is running behind HTTPS
Refresh and document webapp/Makefile with more dev tasks
Fix all absolute URLs in applications, logos, icons
Improve logic behind integrated webapp serving, inject/replace <base href> tag according to URL prefix
Prevent mounting api & webapps to the same base
This commit is contained in:
Denis Arh
2021-05-09 16:47:19 +02:00
parent 9e4edd555e
commit 1d59a3acf2
19 changed files with 487 additions and 225 deletions
+1 -1
View File
@@ -143,7 +143,7 @@ provision:
$(MAKE) --directory=provision clean all
webapp:
$(MAKE) --directory=webapp install
@ $(MAKE) --directory=webapp
#######################################################################################################################
+1 -1
View File
@@ -22,7 +22,7 @@ type (
}
authServicer interface {
MountHttpRoutes(chi.Router)
MountHttpRoutes(string, chi.Router)
UpdateSettings(*settings.Settings)
Watch(ctx context.Context)
}
+4
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"fmt"
authHandlers "github.com/cortezaproject/corteza-server/auth/handlers"
"strings"
authService "github.com/cortezaproject/corteza-server/auth"
@@ -87,6 +88,9 @@ func (app *CortezaApp) Setup() (err error) {
// that might occur inside auth, mail setup...
defer sentry.Recover()
// set base path for links&routes in auth server
authHandlers.BasePath = app.Opt.HTTPServer.BaseUrl
auth.SetupDefault(app.Opt.Auth.Secret, app.Opt.Auth.Expiry)
mail.SetupDialer(
+51 -35
View File
@@ -9,12 +9,14 @@ import (
"github.com/cortezaproject/corteza-server/pkg/actionlog"
"github.com/cortezaproject/corteza-server/pkg/api/server"
"github.com/cortezaproject/corteza-server/pkg/logger"
"github.com/cortezaproject/corteza-server/pkg/options"
"github.com/cortezaproject/corteza-server/pkg/webapp"
systemRest "github.com/cortezaproject/corteza-server/system/rest"
"github.com/cortezaproject/corteza-server/system/scim"
"github.com/go-chi/chi"
"go.uber.org/zap"
"net/http"
"path"
"regexp"
"strings"
"sync"
@@ -50,15 +52,48 @@ func (app *CortezaApp) Serve(ctx context.Context) (err error) {
func (app *CortezaApp) mountHttpRoutes(r chi.Router) {
var (
apiBaseUrl = strings.Trim(app.Opt.HTTPServer.ApiBaseUrl, "/")
webappBaseUrl = strings.Trim(app.Opt.HTTPServer.WebappBaseUrl, "/")
ho = app.Opt.HTTPServer
)
app.AuthService.MountHttpRoutes(r)
func() {
if ho.ApiEnabled && ho.ApiBaseUrl == ho.WebappBaseUrl {
app.Log.
WithOptions(zap.AddStacktrace(zap.PanicLevel)).
Warn("client web applications and api can not use the same base URL: " + ho.WebappBaseUrl)
ho.WebappEnabled = false
}
if app.Opt.HTTPServer.ApiEnabled {
if !ho.WebappEnabled {
app.Log.Info("client web applications disabled")
return
}
r.Route("/"+ho.WebappBaseUrl, webapp.MakeWebappServer(app.Log, ho, app.Opt.Auth))
app.Log.Info(
"client web applications enabled",
zap.String("baseUrl", options.CleanBase(ho.BaseUrl, ho.WebappBaseUrl)),
zap.String("baseDir", ho.WebappBaseDir),
zap.Strings("apps", strings.Split(ho.WebappList, ",")),
)
}()
// Auth server
app.AuthService.MountHttpRoutes(ho.BaseUrl, r)
func() {
if !ho.ApiEnabled {
app.Log.Info("JSON REST API disabled")
}
r.Route(ho.ApiBaseUrl, func(r chi.Router) {
var fullpathAPI = options.CleanBase(ho.BaseUrl, ho.ApiBaseUrl)
app.Log.Info(
"JSON REST API enabled",
zap.String("baseUrl", fullpathAPI),
)
r.Route("/"+apiBaseUrl, func(r chi.Router) {
r.Route("/system", systemRest.MountRoutes)
r.Route("/automation", automationRest.MountRoutes)
r.Route("/compose", composeRest.MountRoutes)
@@ -67,22 +102,16 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) {
r.Route("/federation", federationRest.MountRoutes)
}
r.Handle("/docs", http.RedirectHandler("/"+apiBaseUrl+"/docs/", http.StatusPermanentRedirect))
r.Handle("/docs*", http.StripPrefix("/"+apiBaseUrl+"/docs", http.FileServer(docs.GetFS())))
var fullpathDocs = options.CleanBase(ho.BaseUrl, ho.ApiBaseUrl, "docs")
app.Log.Info(
"API docs enabled",
zap.String("baseUrl", fullpathDocs),
)
r.Handle("/docs", http.RedirectHandler(fullpathDocs+"/", http.StatusPermanentRedirect))
r.Handle("/docs*", http.StripPrefix(fullpathDocs, http.FileServer(docs.GetFS())))
})
app.Log.Info(
"JSON REST API enabled",
zap.String("baseUrl", app.Opt.HTTPServer.ApiBaseUrl),
)
app.Log.Info(
"API docs enabled",
zap.String("baseUrl", app.Opt.HTTPServer.ApiBaseUrl+"/docs"),
)
} else {
app.Log.Info("JSON REST API disabled")
}
}()
func() {
if !app.Opt.SCIM.Enabled {
@@ -96,7 +125,7 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) {
}
var (
baseUrl = "/" + strings.Trim(app.Opt.SCIM.BaseURL, "/")
baseUrl = app.Opt.SCIM.BaseURL
extIdValidation *regexp.Regexp
err error
)
@@ -112,7 +141,7 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) {
app.Log.Debug(
"SCIM enabled",
zap.String("baseUrl", baseUrl),
zap.String("baseUrl", path.Join(app.Opt.HTTPServer.BaseUrl, baseUrl)),
logger.Mask("secret", app.Opt.SCIM.Secret),
)
@@ -127,17 +156,4 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) {
})
})
}()
if app.Opt.HTTPServer.WebappEnabled {
r.Route("/"+webappBaseUrl, webapp.MakeWebappServer(app.Opt.HTTPServer, app.Opt.Auth, app.Opt.Federation))
app.Log.Info(
"client web applications enabled",
zap.String("baseUrl", app.Opt.HTTPServer.WebappBaseUrl),
zap.String("baseDir", app.Opt.HTTPServer.WebappBaseDir),
zap.Strings("apps", strings.Split(app.Opt.HTTPServer.WebappList, ",")),
)
} else {
app.Log.Info("client web applications disabled")
}
}
+10 -3
View File
@@ -218,6 +218,12 @@ func New(ctx context.Context, log *zap.Logger, s store.Storer, opt options.AuthO
external.Init(log, sesManager.Store())
svc.log.Info(
"auth server ready",
zap.String("AUTH_BASE_URL", svc.opt.BaseURL),
zap.String("AUTH_EXTERNAL_REDIRECT_URL", svc.opt.ExternalRedirectURL),
)
return
}
@@ -300,7 +306,8 @@ func (svc service) gcOAuth2Tokens(ctx context.Context) {
}
}
func (svc service) MountHttpRoutes(r chi.Router) {
func (svc service) MountHttpRoutes(basePath string, r chi.Router) {
basePath = strings.TrimRight(basePath, "/")
svc.handlers.MountHttpRoutes(r)
const uriRoot = "/auth/assets/public"
@@ -314,13 +321,13 @@ func (svc service) MountHttpRoutes(r chi.Router) {
zap.String("AUTH_ASSETS_PATH", svc.opt.AssetsPath),
)
} else {
r.Handle(uriRoot+"/*", http.StripPrefix(uriRoot, http.FileServer(http.Dir(root))))
r.Handle(uriRoot+"/*", http.StripPrefix(basePath+uriRoot, http.FileServer(http.Dir(root))))
return
}
}
// fallback to embedded assets
r.Handle(uriRoot+"/*", http.StripPrefix("/auth/", http.FileServer(http.FS(PublicAssets))))
r.Handle(uriRoot+"/*", http.StripPrefix(basePath+"/auth/", http.FileServer(http.FS(PublicAssets))))
}
// checks if directory exists & is readable
+40 -24
View File
@@ -1,5 +1,7 @@
package handlers
import "strings"
type (
Links struct {
Profile,
@@ -36,35 +38,49 @@ type (
}
)
var BasePath string = "/"
func GetLinks() Links {
var b = BasePath + "/"
return Links{
Profile: "/auth",
Signup: "/auth/signup",
ConfirmEmail: "/auth/confirm-email",
PendingEmailConfirmation: "/auth/pending-email-confirmation",
Login: "/auth/login",
Security: "/auth/security",
ChangePassword: "/auth/change-password",
RequestPasswordReset: "/auth/request-password-reset",
PasswordResetRequested: "/auth/password-reset-requested",
ResetPassword: "/auth/reset-password",
Sessions: "/auth/sessions",
AuthorizedClients: "/auth/authorized-clients",
Logout: "/auth/logout",
Profile: b + "auth",
Signup: b + "auth/signup",
ConfirmEmail: b + "auth/confirm-email",
PendingEmailConfirmation: b + "auth/pending-email-confirmation",
Login: b + "auth/login",
Security: b + "auth/security",
ChangePassword: b + "auth/change-password",
RequestPasswordReset: b + "auth/request-password-reset",
PasswordResetRequested: b + "auth/password-reset-requested",
ResetPassword: b + "auth/reset-password",
Sessions: b + "auth/sessions",
AuthorizedClients: b + "auth/authorized-clients",
Logout: b + "auth/logout",
OAuth2Authorize: "/auth/oauth2/authorize",
OAuth2AuthorizeClient: "/auth/oauth2/authorize-client",
OAuth2Token: "/auth/oauth2/token",
OAuth2Info: "/auth/oauth2/info",
OAuth2DefaultClient: "/auth/oauth2/default-client",
OAuth2Authorize: b + "auth/oauth2/authorize",
OAuth2AuthorizeClient: b + "auth/oauth2/authorize-client",
OAuth2Token: b + "auth/oauth2/token",
OAuth2Info: b + "auth/oauth2/info",
OAuth2DefaultClient: b + "auth/oauth2/default-client",
Mfa: "/auth/mfa",
MfaTotpNewSecret: "/auth/mfa/totp/setup",
MfaTotpQRImage: "/auth/mfa/totp/qr.png",
MfaTotpDisable: "/auth/mfa/totp/disable",
Mfa: b + "auth/mfa",
MfaTotpNewSecret: b + "auth/mfa/totp/setup",
MfaTotpQRImage: b + "auth/mfa/totp/qr.png",
MfaTotpDisable: b + "auth/mfa/totp/disable",
External: "/auth/external",
External: b + "auth/external",
Assets: "/auth/assets/public",
Assets: b + "auth/assets/public",
}
}
// trim base path
func tbp(s string) string {
s = strings.TrimPrefix(s, BasePath)
if !strings.HasPrefix(s, "/") {
s = "/" + s
}
return s
}
+35 -35
View File
@@ -44,57 +44,57 @@ func (h *AuthHandlers) MountHttpRoutes(r chi.Router) {
csrf.CookieName(h.Opt.CsrfCookieName),
))
r.Get(l.Profile, h.handle(authOnly(h.profileForm)))
r.Post(l.Profile, h.handle(authOnly(h.profileProc)))
r.Get(tbp(l.Profile), h.handle(authOnly(h.profileForm)))
r.Post(tbp(l.Profile), h.handle(authOnly(h.profileProc)))
r.HandleFunc(l.Logout, h.handle(h.logoutProc))
r.HandleFunc(tbp(l.Logout), h.handle(h.logoutProc))
r.Get(l.Sessions, h.handle(authOnly(h.sessionsView)))
r.Post(l.Sessions, h.handle(authOnly(h.sessionsProc)))
r.Get(tbp(l.Sessions), h.handle(authOnly(h.sessionsView)))
r.Post(tbp(l.Sessions), h.handle(authOnly(h.sessionsProc)))
r.Get(l.AuthorizedClients, h.handle(authOnly(h.clientsView)))
r.Post(l.AuthorizedClients, h.handle(authOnly(h.clientsProc)))
r.Get(tbp(l.AuthorizedClients), h.handle(authOnly(h.clientsView)))
r.Post(tbp(l.AuthorizedClients), h.handle(authOnly(h.clientsProc)))
r.Get(l.Signup, h.handle(h.onlyIfSignupEnabled(anonyOnly(h.signupForm))))
r.Post(l.Signup, h.handle(h.onlyIfSignupEnabled(anonyOnly(h.signupProc))))
r.Get(l.PendingEmailConfirmation, h.handle(h.pendingEmailConfirmation))
r.Get(l.ConfirmEmail, h.handle(h.confirmEmail))
r.Get(tbp(l.Signup), h.handle(h.onlyIfSignupEnabled(anonyOnly(h.signupForm))))
r.Post(tbp(l.Signup), h.handle(h.onlyIfSignupEnabled(anonyOnly(h.signupProc))))
r.Get(tbp(l.PendingEmailConfirmation), h.handle(h.pendingEmailConfirmation))
r.Get(tbp(l.ConfirmEmail), h.handle(h.confirmEmail))
r.Get(l.Login, h.handle(anonyOnly(h.loginForm)))
r.Post(l.Login, h.handle(h.onlyIfLocalEnabled(anonyOnly(h.loginProc))))
r.Get(tbp(l.Login), h.handle(anonyOnly(h.loginForm)))
r.Post(tbp(l.Login), h.handle(h.onlyIfLocalEnabled(anonyOnly(h.loginProc))))
r.Get(l.Mfa, h.handle(h.mfaForm))
r.Post(l.Mfa, h.handle(h.mfaProc))
r.Get(tbp(l.Mfa), h.handle(h.mfaForm))
r.Post(tbp(l.Mfa), h.handle(h.mfaProc))
r.Get(l.RequestPasswordReset, h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.requestPasswordResetForm))))
r.Post(l.RequestPasswordReset, h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.requestPasswordResetProc))))
r.Get(l.PasswordResetRequested, h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.passwordResetRequested))))
r.Get(l.ResetPassword, h.handle(h.onlyIfPasswordResetEnabled(h.resetPasswordForm)))
r.Post(l.ResetPassword, h.handle(h.onlyIfPasswordResetEnabled(authOnly(h.resetPasswordProc))))
r.Get(tbp(l.RequestPasswordReset), h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.requestPasswordResetForm))))
r.Post(tbp(l.RequestPasswordReset), h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.requestPasswordResetProc))))
r.Get(tbp(l.PasswordResetRequested), h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.passwordResetRequested))))
r.Get(tbp(l.ResetPassword), h.handle(h.onlyIfPasswordResetEnabled(h.resetPasswordForm)))
r.Post(tbp(l.ResetPassword), h.handle(h.onlyIfPasswordResetEnabled(authOnly(h.resetPasswordProc))))
r.Get(l.Security, h.handle(authOnly(h.securityForm)))
r.Post(l.Security, h.handle(authOnly(h.securityProc)))
r.Get(l.ChangePassword, h.handle(h.onlyIfLocalEnabled(authOnly(h.changePasswordForm))))
r.Post(l.ChangePassword, h.handle(h.onlyIfLocalEnabled(authOnly(h.changePasswordProc))))
r.Get(tbp(l.Security), h.handle(authOnly(h.securityForm)))
r.Post(tbp(l.Security), h.handle(authOnly(h.securityProc)))
r.Get(tbp(l.ChangePassword), h.handle(h.onlyIfLocalEnabled(authOnly(h.changePasswordForm))))
r.Post(tbp(l.ChangePassword), h.handle(h.onlyIfLocalEnabled(authOnly(h.changePasswordProc))))
r.Get(l.MfaTotpNewSecret, h.handle(partAuthOnly(h.mfaTotpConfigForm)))
r.Post(l.MfaTotpNewSecret, h.handle(partAuthOnly(h.mfaTotpConfigProc)))
r.Get(l.MfaTotpQRImage, h.handle(partAuthOnly(h.mfaTotpConfigQR)))
r.Get(l.MfaTotpDisable, h.handle(authOnly(h.mfaTotpDisableForm)))
r.Post(l.MfaTotpDisable, h.handle(authOnly(h.mfaTotpDisableProc)))
r.Get(tbp(l.MfaTotpNewSecret), h.handle(partAuthOnly(h.mfaTotpConfigForm)))
r.Post(tbp(l.MfaTotpNewSecret), h.handle(partAuthOnly(h.mfaTotpConfigProc)))
r.Get(tbp(l.MfaTotpQRImage), h.handle(partAuthOnly(h.mfaTotpConfigQR)))
r.Get(tbp(l.MfaTotpDisable), h.handle(authOnly(h.mfaTotpDisableForm)))
r.Post(tbp(l.MfaTotpDisable), h.handle(authOnly(h.mfaTotpDisableProc)))
})
r.Group(func(r chi.Router) {
// OAuth2 routes
r.HandleFunc(l.OAuth2Authorize, h.handle(h.oauth2Authorize))
r.Get(l.OAuth2AuthorizeClient, h.handle(authOnly(h.oauth2AuthorizeClient)))
r.Post(l.OAuth2AuthorizeClient, h.handle(authOnly(h.oauth2AuthorizeClientProc)))
r.Get(l.OAuth2DefaultClient, h.handle(h.oauth2authorizeDefaultClient))
r.Post(l.OAuth2DefaultClient, h.handle(h.oauth2authorizeDefaultClientProc))
r.HandleFunc(tbp(l.OAuth2Authorize), h.handle(h.oauth2Authorize))
r.Get(tbp(l.OAuth2AuthorizeClient), h.handle(authOnly(h.oauth2AuthorizeClient)))
r.Post(tbp(l.OAuth2AuthorizeClient), h.handle(authOnly(h.oauth2AuthorizeClientProc)))
r.Get(tbp(l.OAuth2DefaultClient), h.handle(h.oauth2authorizeDefaultClient))
r.Post(tbp(l.OAuth2DefaultClient), h.handle(h.oauth2authorizeDefaultClientProc))
})
r.Route(l.External+"/{provider}", func(r chi.Router) {
r.Route(tbp(l.External)+"/{provider}", func(r chi.Router) {
// External provider
r.Get("/", h.externalInit)
r.Get("/callback", h.externalCallback)
+3 -3
View File
@@ -1,10 +1,10 @@
package server
import (
"github.com/99designs/basicauth-go"
"net/http"
"github.com/766b/chi-prometheus"
"github.com/99designs/basicauth-go"
"github.com/go-chi/chi"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
@@ -15,10 +15,10 @@ func metricsMiddleware(name string) func(http.Handler) http.Handler {
}
func metricsMount(r chi.Router, username, password string) {
r.Group(func(r chi.Router) {
r.Route("/metrics", func(r chi.Router) {
r.Use(basicauth.New("Metrics", map[string][]string{
username: {password},
}))
r.Handle("/metrics", promhttp.Handler())
r.Handle("/", promhttp.Handler())
})
}
+74 -40
View File
@@ -12,6 +12,8 @@ import (
"go.uber.org/zap"
"net"
"net/http"
"path"
"strings"
)
type (
@@ -42,6 +44,8 @@ func (s *server) MountRoutes(mm ...func(chi.Router)) {
func (s server) Serve(ctx context.Context) {
s.log.Info(
"starting HTTP server",
zap.String("path-prefix", s.httpOpt.BaseUrl),
zap.String("address", s.httpOpt.Addr),
)
@@ -53,14 +57,23 @@ func (s server) Serve(ctx context.Context) {
router := chi.NewRouter()
// Base middleware, CORS, RealIP, RequestID, context-logger
router.Use(BaseMiddleware(s.environmentOpt.IsProduction(), s.log)...)
router.Route("/"+strings.TrimPrefix(s.httpOpt.BaseUrl, "/"), func(r chi.Router) {
// Reports error to Sentry if enabled
if s.httpOpt.EnablePanicReporting {
r.Use(sentryMiddleware())
}
router.Group(func(r chi.Router) {
s.bindMiscRoutes(r)
})
if s.httpOpt.EnableMetrics {
// Metrics tracking middleware
r.Use(metricsMiddleware(s.httpOpt.MetricsServiceLabel))
}
// Handle panic (sets 500 server error headers)
r.Use(handlePanic)
// Base middleware, CORS, RealIP, RequestID, context-logger
r.Use(BaseMiddleware(s.environmentOpt.IsProduction(), s.log)...)
router.Group(func(r chi.Router) {
// Logging request if enabled
if s.httpOpt.LogRequest {
r.Use(LogRequest)
@@ -79,8 +92,26 @@ func (s server) Serve(ctx context.Context) {
for _, mountRoutes := range s.endpoints {
mountRoutes(r)
}
if s.httpOpt.EnableMetrics {
metricsMount(r, s.httpOpt.MetricsUsername, s.httpOpt.MetricsPassword)
}
})
if s.httpOpt.BaseUrl != "" {
router.Handle("/", http.RedirectHandler(s.httpOpt.BaseUrl, http.StatusTemporaryRedirect))
}
s.bindMiscRoutes(router)
if s.httpOpt.EnableDebugRoute {
router.Get("/__routes", debugRoutes(router))
router.Get(s.httpOpt.BaseUrl+"/__routes", debugRoutes(router))
}
go func() {
srv := http.Server{
Handler: router,
@@ -102,48 +133,51 @@ func (s server) Serve(ctx context.Context) {
s.log.Info("HTTP server stopped", zap.Error(err))
}
func (s server) bindMiscRoutes(router chi.Router) {
if s.httpOpt.EnableMetrics {
metricsMount(router, s.httpOpt.MetricsUsername, s.httpOpt.MetricsPassword)
}
// Metrics tracking middleware
if s.httpOpt.EnableMetrics {
router.Use(metricsMiddleware(s.httpOpt.MetricsServiceLabel))
}
// Handle panic (sets 500 server error headers)
router.Use(handlePanic)
// Reports error to Sentry if enabled
if s.httpOpt.EnablePanicReporting {
router.Use(sentryMiddleware())
}
func (s server) bindMiscRoutes(r chi.Router) {
if s.httpOpt.EnableDebugRoute {
s.log.Debug("profiler: /__profiler")
router.Mount("/__profiler", middleware.Profiler())
s.log.Debug("profiler enabled: /__profiler")
r.Mount("/__profiler", middleware.Profiler())
s.log.Debug("list of routes: /__routes")
router.Get("/__routes", debugRoutes(router))
s.log.Debug("eventbus handlers debug enabled: /__eventbus")
r.Get("/__eventbus", debugEventbus())
s.log.Debug("eventbus handlers: /__eventbus")
router.Get("/__eventbus", debugEventbus())
s.log.Debug("corredor service: /__corredor")
router.Get("/__corredor", debugCorredor())
s.log.Debug("corredor service debug enabled: /__corredor")
r.Get("/__corredor", debugCorredor())
}
if s.httpOpt.EnableVersionRoute {
router.Get("/version", func(w http.ResponseWriter, r *http.Request) {
api.Send(w, r, struct {
BuildTime string `json:"buildTime"`
Version string `json:"version"`
}{version.BuildTime, version.Version})
})
var (
dPath = "/version"
sPath = path.Join(s.httpOpt.BaseUrl, dPath)
v = func(w http.ResponseWriter, r *http.Request) {
api.Send(w, r, struct {
BuildTime string `json:"buildTime"`
Version string `json:"version"`
}{version.BuildTime, version.Version})
}
)
r.Get(dPath, v)
if dPath != sPath {
r.Get(sPath, v)
}
}
if s.httpOpt.EnableHealthcheckRoute {
router.Get("/healthcheck", healthcheck.HttpHandler())
// default & sub path for healthcheck endpoint
var (
dPath = "/healthcheck"
sPath = path.Join(s.httpOpt.BaseUrl, dPath)
log = s.log.With(zap.String("url", dPath))
)
r.Get(dPath, healthcheck.HttpHandler())
if dPath != sPath {
r.Get(sPath, healthcheck.HttpHandler())
log = log.With(zap.String("url", sPath))
}
log.Info("healthcheck endpoint enabled")
}
}
+5
View File
@@ -26,12 +26,14 @@ type (
MetricsUsername string `env:"HTTP_METRICS_USERNAME"`
MetricsPassword string `env:"HTTP_METRICS_PASSWORD"`
EnablePanicReporting bool `env:"HTTP_REPORT_PANIC"`
BaseUrl string `env:"HTTP_BASE_URL"`
ApiEnabled bool `env:"HTTP_API_ENABLED"`
ApiBaseUrl string `env:"HTTP_API_BASE_URL"`
WebappEnabled bool `env:"HTTP_WEBAPP_ENABLED"`
WebappBaseUrl string `env:"HTTP_WEBAPP_BASE_URL"`
WebappBaseDir string `env:"HTTP_WEBAPP_BASE_DIR"`
WebappList string `env:"HTTP_WEBAPP_LIST"`
SslTerminated bool `env:"HTTP_SSL_TERMINATED"`
}
)
@@ -50,11 +52,14 @@ func HTTPServer() (o *HTTPServerOpt) {
MetricsUsername: "metrics",
MetricsPassword: string(rand.Bytes(5)),
EnablePanicReporting: true,
BaseUrl: "/",
ApiEnabled: true,
ApiBaseUrl: "/",
WebappEnabled: false,
WebappBaseUrl: "/",
WebappBaseDir: "webapp/public",
WebappList: "admin,compose,workflow",
SslTerminated: isSecure(),
}
fill(o)
+5 -2
View File
@@ -1,12 +1,15 @@
package options
func (o *HTTPServerOpt) Defaults() {
o.BaseUrl = CleanBase(o.BaseUrl)
o.ApiBaseUrl = CleanBase(o.ApiBaseUrl)
o.WebappBaseUrl = CleanBase(o.WebappBaseUrl)
if o.WebappEnabled && o.ApiEnabled && o.ApiBaseUrl == "" {
if o.WebappEnabled && o.ApiEnabled && (o.ApiBaseUrl == "/" || o.ApiBaseUrl == "") {
// api base URL is still on root (empty string)
// but webapps are enabled (that means, server also serves static files from WebappBaseDir)
//
// Let's be nice and move API to /api
o.ApiBaseUrl = "/api"
o.ApiBaseUrl = CleanBase("api")
}
}
+22 -1
View File
@@ -27,7 +27,6 @@ props:
env: HTTP_ERROR_TRACING
default: false
- name: enableHealthcheckRoute
type: bool
env: HTTP_ENABLE_HEALTHCHECK_ROUTE
@@ -72,6 +71,12 @@ props:
default: true
description: Report HTTP panic to Sentry.
- name: baseUrl
env: HTTP_BASE_URL
default: "/"
description: |-
Base URL (prefix) for all routes (<baseUrl>/auth, <baseUrl>/api, ...)
- name: apiEnabled
type: bool
env: HTTP_API_ENABLED
@@ -79,6 +84,11 @@ props:
- name: apiBaseUrl
env: HTTP_API_BASE_URL
default: "/"
description: |-
When webapps are enabled (HTTP_WEBAPP_ENABLED) this is moved to '/api' if not explicitly set otherwise.
API base URL is prefixed with baseUrl
- name: webappEnabled
type: bool
@@ -88,6 +98,8 @@ props:
- name: webappBaseUrl
env: HTTP_WEBAPP_BASE_URL
default: "/"
description: |-
Webapp base URL is prefixed with baseUrl
- name: webappBaseDir
env: HTTP_WEBAPP_BASE_DIR
@@ -96,3 +108,12 @@ props:
- name: webappList
env: HTTP_WEBAPP_LIST
default: "admin,compose,workflow"
- name: sslTerminated
env: HTTP_SSL_TERMINATED
type: bool
default: isSecure()
description: |-
Is SSL termination enabled in ingres, proxy or load balancer that is in front of Corteza?
By default, Corteza checks for presence of LETSENCRYPT_HOST environmental variable.
This DOES NOT enable SSL termination in Cortreza!
+4 -5
View File
@@ -9,7 +9,6 @@ package options
// pkg/options/auth.yaml
import (
"strings"
"time"
)
@@ -44,13 +43,13 @@ func Auth() (o *AuthOpt) {
o = &AuthOpt{
Secret: getSecretFromEnv("jwt secret"),
Expiry: time.Hour * 24 * 30,
ExternalRedirectURL: guessBaseURL() + "/auth/external/{provider}/callback",
ExternalRedirectURL: fullURL() + "/auth/external/{provider}/callback",
ExternalCookieSecret: getSecretFromEnv("external cookie secret"),
BaseURL: guessBaseURL() + "/auth",
BaseURL: fullURL() + "/auth",
SessionCookieName: "session",
SessionCookiePath: "/auth",
SessionCookiePath: pathPrefix() + "/auth",
SessionCookieDomain: guessHostname(),
SessionCookieSecure: strings.HasPrefix(guessBaseURL(), "https://"),
SessionCookieSecure: isSecure(),
SessionLifetime: 24 * time.Hour,
SessionPermLifetime: 360 * 24 * time.Hour,
GarbageCollectorInterval: 15 * time.Minute,
+5 -5
View File
@@ -1,6 +1,5 @@
imports:
- time
- strings
docs:
title: Authentication
@@ -50,7 +49,8 @@ props:
- name: baseURL
default: guessBaseURL() + "/auth"
description: |-
Frontend base URL. Must be an absolute URL
Frontend base URL. Must be an absolute URL, with the domain.
This is used for some redirects and links in auth emails.
- name: sessionCookieName
default: "session"
@@ -58,7 +58,7 @@ props:
Session cookie name
- name: sessionCookiePath
default: "/auth"
default: pathPrefix() + "/auth"
description: |-
Session cookie path
@@ -69,9 +69,9 @@ props:
- name: sessionCookieSecure
type: bool
default: strings.HasPrefix(guessBaseURL(), "https://")
default: isSecure()
description: |-
Defaults to true when HTTPS is used.
Defaults to true when HTTPS is used. Corteza will try to guess the this setting by
- name: sessionLifetime
type: time.Duration
+39 -6
View File
@@ -2,6 +2,7 @@ package options
import (
"os"
"path"
"reflect"
"strings"
"time"
@@ -80,17 +81,49 @@ func guessHostname() string {
return "local.cortezaproject.org"
}
func guessBaseURL() string {
// returns path prefix
func pathPrefix() string {
return CleanBase(EnvString("HTTP_BASE_URL", ""))
}
// will return base URL with domain and prefix path
func fullURL() string {
var (
host = guessHostname()
_, isSecure = os.LookupEnv("LETSENCRYPT_HOST")
full string
host = guessHostname()
)
if strings.Contains(host, "local.") || strings.Contains(host, "localhost") || !isSecure {
return "http://" + host
if strings.Contains(host, "local.") || strings.Contains(host, "localhost") || !isSecure() {
full = "http://" + host
} else {
return "https://" + host
full = "https://" + host
}
if pp := pathPrefix(); pp != "" {
return full + pp
}
return full
}
// Checks value of HTTP_SSL_TERMINATED and fallbacks to checking existence of LETSENCRYPT_HOST
// to determinate we are behind a SSL termination infrastructure
func isSecure() bool {
var _, is = os.LookupEnv("LETSENCRYPT_HOST")
// We're accessing to HTTP_SSL_TERMINATED directly here and not via HttpServerOpt.SslTerminated
// because we need to get to this value early and directly
return EnvBool("HTTP_SSL_TERMINATED", is)
}
// Path joins all parts with / and prefixes result (if not empty) with /
func CleanBase(pp ...string) string {
if p := strings.Trim(path.Join(pp...), "/"); p != "" {
// prefix base with slash
return "/" + p
}
return ""
}
func EnvString(key string, def string) string {
+95 -31
View File
@@ -1,60 +1,95 @@
package webapp
import (
"bytes"
"fmt"
"github.com/cortezaproject/corteza-server/pkg/logger"
"github.com/cortezaproject/corteza-server/pkg/options"
"github.com/go-chi/chi"
"go.uber.org/zap"
"io"
"net/http"
"os"
"path"
"strings"
"time"
)
func MakeWebappServer(httpSrvOpt options.HTTPServerOpt, authOpt options.AuthOpt, fed options.FederationOpt) func(r chi.Router) {
// Serves static files directly from FS
return func(r chi.Router) {
fileserver := http.FileServer(http.Dir(httpSrvOpt.WebappBaseDir))
var (
htmlIndex struct {
body []byte
mod time.Time
}
)
for _, app := range strings.Split(httpSrvOpt.WebappList, ",") {
basedir := path.Join(httpSrvOpt.WebappBaseUrl, app)
serveConfig(r, basedir, httpSrvOpt.ApiBaseUrl, authOpt.BaseURL, fed.Enabled)
r.Get(basedir+"*", serveIndex(httpSrvOpt.WebappBaseDir, basedir, fileserver))
func MakeWebappServer(log *zap.Logger, httpSrvOpt options.HTTPServerOpt, authOpt options.AuthOpt) func(r chi.Router) {
var (
apiBaseUrl = options.CleanBase(httpSrvOpt.BaseUrl, httpSrvOpt.ApiBaseUrl)
apps = strings.Split(httpSrvOpt.WebappList, ",")
appIndexHTMLs = make(map[string][]byte)
webBaseUrl string
err error
)
// Preload index files for all apps
for _, app := range append(apps, "") {
pathPrefix := path.Join(httpSrvOpt.BaseUrl, httpSrvOpt.WebappBaseUrl, app)
if !strings.HasSuffix(pathPrefix, "/") {
pathPrefix += "/"
}
serveConfig(r, httpSrvOpt.WebappBaseUrl, httpSrvOpt.ApiBaseUrl, authOpt.BaseURL, fed.Enabled)
r.Get(httpSrvOpt.WebappBaseUrl+"*", serveIndex(httpSrvOpt.WebappBaseDir, httpSrvOpt.WebappBaseUrl, fileserver))
appIndexHTMLs[app], err = modifyIndexHTML(path.Join(httpSrvOpt.WebappBaseDir, app), pathPrefix)
if err != nil {
log.Error("could not preload application index HTML", zap.Error(err))
}
}
// Serves static files directly from FS
return func(r chi.Router) {
fileserver := http.StripPrefix(
path.Join(httpSrvOpt.BaseUrl, httpSrvOpt.WebappBaseUrl),
http.FileServer(http.Dir(httpSrvOpt.WebappBaseDir)),
)
for _, app := range apps {
webBaseUrl = "/" + path.Join(httpSrvOpt.WebappBaseUrl, app)
serveConfig(r, webBaseUrl, apiBaseUrl, authOpt.BaseURL, httpSrvOpt.BaseUrl)
r.Get(webBaseUrl+"*", serveIndex(httpSrvOpt, appIndexHTMLs[app], fileserver))
}
webBaseUrl = "/" + path.Join(httpSrvOpt.WebappBaseUrl)
serveConfig(r, webBaseUrl, apiBaseUrl, authOpt.BaseURL, httpSrvOpt.BaseUrl)
r.Get(webBaseUrl+"*", serveIndex(httpSrvOpt, appIndexHTMLs[""], fileserver))
}
}
// Serves index.html in case the requested file isn't found (or some other os.Stat error)
func serveIndex(assetPath string, indexPath string, serve http.Handler) http.HandlerFunc {
func serveIndex(opt options.HTTPServerOpt, indexHTML []byte, serve http.Handler) http.HandlerFunc {
//indexPage := path.Join(opt.WebappBaseDir, indexPath, "index.html")
return func(w http.ResponseWriter, r *http.Request) {
indexPage := path.Join(assetPath, indexPath, "index.html")
requestedPage := path.Join(assetPath, r.URL.Path)
_, err := os.Stat(requestedPage)
if err == nil {
if strings.HasSuffix(r.URL.String(), "/") {
// If request ends with a slash we want to prevent
// serving of directory index (list of fileS)
http.ServeFile(w, r, indexPage)
return
}
requestedFile := path.Join(opt.WebappBaseDir, strings.TrimPrefix(r.URL.Path, opt.BaseUrl))
f, err := os.Stat(requestedFile)
// When file does not exist or is a directory, serve app's index
if os.IsNotExist(err) || f.IsDir() || strings.HasSuffix(r.URL.String(), "/") {
// Make sure index is not cached
// In the big scheme of things, this couple of kilobytes does not make any difference
// and it's really important that users get fresh index files on each full refresh
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(indexHTML)
return
} else if err == nil {
// Serve the file requested
serve.ServeHTTP(w, r)
return
}
if os.IsNotExist(err) {
// Forcefully serve index page on whatever
http.ServeFile(w, r, indexPage)
return
}
logger.Default().WithOptions(zap.AddStacktrace(zap.PanicLevel)).Error(
"failed to serve static file",
zap.Error(err),
@@ -65,10 +100,39 @@ func serveIndex(assetPath string, indexPath string, serve http.Handler) http.Han
}
}
func serveConfig(r chi.Router, appUrl, apiBaseUrl, authBaseUrl string, fedEnabled bool) {
r.Get(strings.TrimRight(appUrl, "/")+"/config.js", func(w http.ResponseWriter, r *http.Request) {
func serveConfig(r chi.Router, appUrl, apiBaseUrl, authBaseUrl, webappBaseUrl string) {
r.Get(strings.TrimSuffix(appUrl, "/")+"/config.js", func(w http.ResponseWriter, r *http.Request) {
const line = "window.%s = '%s';\n"
_, _ = fmt.Fprintf(w, line, "CortezaAPI", apiBaseUrl)
_, _ = fmt.Fprintf(w, line, "CortezaAuth", authBaseUrl)
_, _ = fmt.Fprintf(w, line, "CortezaWebapp", webappBaseUrl)
})
}
// Reads and modifies index HTML for the webapp
//
// It replaces <base> tag with the exact location of the web app
func modifyIndexHTML(dir, baseHref string) (buf []byte, err error) {
var (
warning = []byte("\n\n<!--\n\nError!\n\nFailed could not locate or modify <base> tag, your webapp might misbehave\n\n-->\n")
placeholder = []byte("<base href=/ >")
replacement = []byte(fmt.Sprintf(`<base href="%s" />`, baseHref))
fh *os.File
)
fh, err = os.Open(path.Join(dir, "index.html"))
if err != nil {
return
}
buf, err = io.ReadAll(fh)
if err != nil {
return
}
if bytes.Contains(buf, placeholder) {
return bytes.Replace(buf, placeholder, replacement, 1), nil
} else {
return append(buf, warning...), nil
}
}
@@ -4,60 +4,60 @@ applications:
enabled: true
unify:
listed: true
icon: /applications/low-code-platform.png
logo: /applications/low-code-platform.png
url: /compose
icon: applications/low-code-platform.png
logo: applications/low-code-platform.png
url: compose/
- name: CRM Suite
(envoy): { skipIf: "!missing" }
enabled: true
unify:
listed: true
icon: /applications/low-code-crm-app.png
logo: /applications/low-code-crm-app.png
url: /compose/ns/crm/pages
icon: applications/low-code-crm-app.png
logo: applications/low-code-crm-app.png
url: compose/ns/crm/pages
- name: Service Solution
(envoy): { skipIf: "!missing" }
enabled: true
unify:
listed: true
icon: /applications/low-code-service-solution-app.png
logo: /applications/low-code-service-solution-app.png
url: /compose/ns/service-solution/pages
icon: applications/low-code-service-solution-app.png
logo: applications/low-code-service-solution-app.png
url: compose/ns/service-solution/pages
- name: Jitsi Bridge
(envoy): { skipIf: "!missing" }
enabled: true
unify:
listed: true
icon: /applications/video-conference.png
logo: /applications/video-conference.png
url: /bridge/jitsi/
icon: applications/video-conference.png
logo: applications/video-conference.png
url: bridge/jitsi/
- name: Google Maps
(envoy): { skipIf: "!missing" }
enabled: true
unify:
listed: true
icon: /applications/google-maps.png
logo: /applications/google-maps.png
url: /bridge/google-maps/
icon: applications/google-maps.png
logo: applications/google-maps.png
url: bridge/google-maps/
- name: Admin Area
(envoy): { skipIf: "!missing" }
enabled: true
unify:
listed: true
icon: /applications/admin-area.png
logo: /applications/admin-area.png
url: /admin
icon: applications/admin-area.png
logo: applications/admin-area.png
url: admin/
- name: Workflows
(envoy): { skipIf: "!missing" }
enabled: true
unify:
listed: true
icon: /applications/workflows.png
logo: /applications/workflows.png
url: /workflow
icon: applications/workflows.png
logo: applications/workflows.png
url: workflow/
+32 -10
View File
@@ -1,3 +1,5 @@
.PHONY: all install-fresh install-packages build clean cleaner
WGET ?= wget
WGET_FLAGS ?= -q
TAR ?= tar
@@ -7,21 +9,41 @@ APPS ?= admin compose workflow
ALL_APPS ?= $(APPS) one
VERSION ?= $(shell git describe --tags --abbrev=0)
PACKAGES = $(addprefix corteza-webapp-,$(addsuffix -$(VERSION).tar.gz,$(ALL_APPS)))
SOURCE_LOC ?= "../../webapp-"
SOURCES = $(addprefix $(SOURCE_LOC),$(ALL_APPS))
all: install
all: install-packages
install: $(PACKAGES)
rm -rf $(addprefix public/,$(APPS))
mkdir -p $(addprefix public/,$(APPS))
$(TAR) $(TAR_FLAGS) -f corteza-webapp-one-$(VERSION).tar.gz -C public
$(TAR) $(TAR_FLAGS) -f corteza-webapp-admin-$(VERSION).tar.gz -C public/admin
$(TAR) $(TAR_FLAGS) -f corteza-webapp-compose-$(VERSION).tar.gz -C public/compose
$(TAR) $(TAR_FLAGS) -f corteza-webapp-workflow-$(VERSION).tar.gz -C public/workflow
install-packages: $(PACKAGES)
$(info installing packages to public/)
@ rm -rf $(addprefix public/,$(APPS))
@ mkdir -p $(addprefix public/,$(APPS))
@ $(TAR) $(TAR_FLAGS) -f corteza-webapp-one-$(VERSION).tar.gz -C public
@ $(TAR) $(TAR_FLAGS) -f corteza-webapp-admin-$(VERSION).tar.gz -C public/admin
@ $(TAR) $(TAR_FLAGS) -f corteza-webapp-compose-$(VERSION).tar.gz -C public/compose
@ $(TAR) $(TAR_FLAGS) -f corteza-webapp-workflow-$(VERSION).tar.gz -C public/workflow
download: $(PACKAGES)
$(PACKAGES):
$(WGET) $(WGET_FLAGS) $(RELEASE_PAGE)/$(@)
$(info downloading $(RELEASE_PAGE)/$(@))
@ $(WGET) $(WGET_FLAGS) $(RELEASE_PAGE)/$(@)
install-fresh: build
@ rm -rf public
@ cp -r $(SOURCE_LOC)one/dist public/
@ cp -r $(SOURCE_LOC)admin/dist public/admin
@ cp -r $(SOURCE_LOC)compose/dist public/compose
@ cp -r $(SOURCE_LOC)workflow/dist public/workflow
build: $(SOURCES)
$(SOURCES):
$(info building $(@))
@ cd $(@) && yarn build
clean:
rm -f $(PACKAGES)
@ rm -f $(PACKAGES)
cleaner:
@ rm -rf public corteza-webapp-*.tar.gz
+40 -2
View File
@@ -1,14 +1,52 @@
= Web applications
Rationale behind `/webapp/...` tools is to allow backend developers to quicky test server with frontend applications.
Rationale behind `/webapp/...` tools is to allow backend developers to quickly test server with frontend applications.
Tools (see Makefile) download and install unstable (can be changed with `VERSION` var).
.Downloads and installs unstable packages under webapp/public
.Downloads and installs packages under webapp/public using current version
[source,shell]
----
make
----
.Downloads and installs packages under webapp/public using specific version
[source,shell]
----
make VERSION=2021.3.3
----
. All available make targets
[cols="2m,5a"]
|===
|target|
| all
| default target alias for install-packages
| install-package
| installs all packages
| download
| downloads all packages
| install-fresh
| builds and installs all packages from source
| install-fresh
| builds and installs all web applications from source
| build
| builds all web applications from source
| clean
| removes packages of the current versions
| cleaner
| removes all
|===
To enable serving of webapps from the server, make sure `HTTP_WEBAPP_ENABLED` is set to `true`.
This will prefix all API endpoints with `/api/`.