From 12cddbd89682aae8b974cca83447965b53dcb5ca Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 22 May 2022 08:34:09 +0300 Subject: [PATCH] feat(demo): disable features on demo env [EE-1874] (#6040) --- api/cli/cli.go | 1 + api/cmd/portainer/main.go | 11 ++ api/demo/demo.go | 118 ++++++++++++++++++ api/demo/init.go | 79 ++++++++++++ api/http/errors/errors.go | 2 + api/http/handler/backup/backup_test.go | 5 +- api/http/handler/backup/handler.go | 23 +++- api/http/handler/backup/restore_test.go | 5 +- .../endpoint_create_global_key_test.go | 1 + api/http/handler/endpoints/endpoint_delete.go | 5 + .../handler/endpoints/endpoint_list_test.go | 2 +- api/http/handler/endpoints/handler.go | 5 +- api/http/handler/settings/handler.go | 7 +- api/http/handler/settings/settings_update.go | 5 + api/http/handler/status/handler.go | 13 +- api/http/handler/status/status_inspect.go | 14 ++- api/http/handler/users/handler.go | 5 +- .../users/user_create_access_token_test.go | 2 +- api/http/handler/users/user_delete_test.go | 2 +- .../users/user_get_access_tokens_test.go | 2 +- .../users/user_remove_access_token_test.go | 2 +- api/http/handler/users/user_update.go | 4 + .../handler/users/user_update_password.go | 4 + api/http/handler/users/user_update_test.go | 2 +- api/http/middlewares/demo.go | 23 ++++ api/http/middlewares/demo_test.go | 41 ++++++ api/http/server.go | 20 ++- api/portainer.go | 1 + .../demo-feature-indicator.controller.js | 16 +++ .../demo-feature-indicator.html | 10 ++ .../demo-feature-indicator/index.js | 12 ++ .../theme/theme-settings.controller.js | 9 +- app/portainer/models/status.js | 1 + app/portainer/rest/backup.js | 18 ++- app/portainer/services/authentication.js | 3 +- app/portainer/services/stateManager.js | 1 + app/portainer/views/account/account.html | 11 +- .../views/account/accountController.js | 13 +- app/portainer/views/settings/settings.html | 34 +++-- .../views/settings/settingsController.js | 16 +++ 40 files changed, 492 insertions(+), 56 deletions(-) create mode 100644 api/demo/demo.go create mode 100644 api/demo/init.go create mode 100644 api/http/middlewares/demo.go create mode 100644 api/http/middlewares/demo_test.go create mode 100644 app/portainer/components/demo-feature-indicator/demo-feature-indicator.controller.js create mode 100644 app/portainer/components/demo-feature-indicator/demo-feature-indicator.html create mode 100644 app/portainer/components/demo-feature-indicator/index.js diff --git a/api/cli/cli.go b/api/cli/cli.go index 8a2ee9c19..8e92e1849 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -35,6 +35,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { TunnelPort: kingpin.Flag("tunnel-port", "Port to serve the tunnel server").Default(defaultTunnelServerPort).String(), Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(), Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(), + DemoEnvironment: kingpin.Flag("demo", "Demo environment").Bool(), EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(), FeatureFlags: BoolPairs(kingpin.Flag("feat", "List of feature flags").Hidden()), EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(), diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 0797110b4..68806b9aa 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -23,6 +23,7 @@ import ( "github.com/portainer/portainer/api/database/boltdb" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/exec" "github.com/portainer/portainer/api/filesystem" @@ -572,6 +573,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { openAMTService := openamt.NewService() cryptoService := initCryptoService() + digitalSignatureService := initDigitalSignatureService() sslService, err := initSSLService(*flags.AddrHTTPS, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger) @@ -634,6 +636,14 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { applicationStatus := initStatus(instanceID) + demoService := demo.NewService() + if *flags.DemoEnvironment { + err := demoService.Init(dataStore, cryptoService) + if err != nil { + log.Fatalf("failed initializing demo environment: %v", err) + } + } + err = initEndpoint(flags, dataStore, snapshotService) if err != nil { logrus.Fatalf("Failed initializing environment: %v", err) @@ -722,6 +732,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { ShutdownCtx: shutdownCtx, ShutdownTrigger: shutdownTrigger, StackDeployer: stackDeployer, + DemoService: demoService, } } diff --git a/api/demo/demo.go b/api/demo/demo.go new file mode 100644 index 000000000..330807a64 --- /dev/null +++ b/api/demo/demo.go @@ -0,0 +1,118 @@ +package demo + +import ( + "log" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type EnvironmentDetails struct { + Enabled bool `json:"enabled"` + Users []portainer.UserID `json:"users"` + Environments []portainer.EndpointID `json:"environments"` +} + +type Service struct { + details EnvironmentDetails +} + +func NewService() *Service { + return &Service{} +} + +func (service *Service) Details() EnvironmentDetails { + return service.details +} + +func (service *Service) Init(store dataservices.DataStore, cryptoService portainer.CryptoService) error { + log.Print("[INFO] [main] Starting demo environment") + + isClean, err := isCleanStore(store) + if err != nil { + return errors.WithMessage(err, "failed checking if store is clean") + } + + if !isClean { + return errors.New(" Demo environment can only be initialized on a clean database") + } + + id, err := initDemoUser(store, cryptoService) + if err != nil { + return errors.WithMessage(err, "failed creating demo user") + } + + endpointIds, err := initDemoEndpoints(store) + if err != nil { + return errors.WithMessage(err, "failed creating demo endpoint") + } + + err = initDemoSettings(store) + if err != nil { + return errors.WithMessage(err, "failed updating demo settings") + } + + service.details = EnvironmentDetails{ + Enabled: true, + Users: []portainer.UserID{id}, + // endpoints 2,3 are created after deployment of portainer + Environments: endpointIds, + } + + return nil +} + +func isCleanStore(store dataservices.DataStore) (bool, error) { + endpoints, err := store.Endpoint().Endpoints() + if err != nil { + return false, err + } + + if len(endpoints) > 0 { + return false, nil + } + + users, err := store.User().Users() + if err != nil { + return false, err + } + + if len(users) > 0 { + return false, nil + } + + return true, nil +} + +func (service *Service) IsDemo() bool { + return service.details.Enabled +} + +func (service *Service) IsDemoEnvironment(environmentID portainer.EndpointID) bool { + if !service.IsDemo() { + return false + } + + for _, demoEndpointID := range service.details.Environments { + if environmentID == demoEndpointID { + return true + } + } + + return false +} + +func (service *Service) IsDemoUser(userID portainer.UserID) bool { + if !service.IsDemo() { + return false + } + + for _, demoUserID := range service.details.Users { + if userID == demoUserID { + return true + } + } + + return false +} diff --git a/api/demo/init.go b/api/demo/init.go new file mode 100644 index 000000000..a27b6baa7 --- /dev/null +++ b/api/demo/init.go @@ -0,0 +1,79 @@ +package demo + +import ( + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +func initDemoUser( + store dataservices.DataStore, + cryptoService portainer.CryptoService, +) (portainer.UserID, error) { + + password, err := cryptoService.Hash("tryportainer") + if err != nil { + return 0, errors.WithMessage(err, "failed creating password hash") + } + + admin := &portainer.User{ + Username: "admin", + Password: password, + Role: portainer.AdministratorRole, + } + + err = store.User().Create(admin) + return admin.ID, errors.WithMessage(err, "failed creating user") +} + +func initDemoEndpoints(store dataservices.DataStore) ([]portainer.EndpointID, error) { + localEndpointId, err := initDemoLocalEndpoint(store) + if err != nil { + return nil, err + } + + // second and third endpoints are going to be created with docker-compose as a part of the demo environment set up. + // ref: https://github.com/portainer/portainer-demo/blob/master/docker-compose.yml + return []portainer.EndpointID{localEndpointId, localEndpointId + 1, localEndpointId + 2}, nil +} + +func initDemoLocalEndpoint(store dataservices.DataStore) (portainer.EndpointID, error) { + id := portainer.EndpointID(store.Endpoint().GetNextIdentifier()) + localEndpoint := &portainer.Endpoint{ + ID: id, + Name: "local", + URL: "unix:///var/run/docker.sock", + PublicURL: "demo.portainer.io", + Type: portainer.DockerEnvironment, + GroupID: portainer.EndpointGroupID(1), + TLSConfig: portainer.TLSConfiguration{ + TLS: false, + }, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + TagIDs: []portainer.TagID{}, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.DockerSnapshot{}, + Kubernetes: portainer.KubernetesDefault(), + } + + err := store.Endpoint().Create(localEndpoint) + return id, errors.WithMessage(err, "failed creating local endpoint") +} + +func initDemoSettings( + store dataservices.DataStore, +) error { + settings, err := store.Settings().Settings() + if err != nil { + return errors.WithMessage(err, "failed fetching settings") + } + + settings.EnableTelemetry = false + settings.LogoURL = "" + + err = store.Settings().UpdateSettings(settings) + return errors.WithMessage(err, "failed updating settings") +} diff --git a/api/http/errors/errors.go b/api/http/errors/errors.go index df896fde0..4b0b8dc68 100644 --- a/api/http/errors/errors.go +++ b/api/http/errors/errors.go @@ -9,4 +9,6 @@ var ( ErrUnauthorized = errors.New("Unauthorized") // ErrResourceAccessDenied Access denied to resource error ErrResourceAccessDenied = errors.New("Access denied to resource") + // ErrNotAvailableInDemo feature is not allowed in demo + ErrNotAvailableInDemo = errors.New("This feature is not available in the demo version of Portainer") ) diff --git a/api/http/handler/backup/backup_test.go b/api/http/handler/backup/backup_test.go index 40c7a01bc..6ca23afd4 100644 --- a/api/http/handler/backup/backup_test.go +++ b/api/http/handler/backup/backup_test.go @@ -18,6 +18,7 @@ import ( "github.com/docker/docker/pkg/ioutils" "github.com/portainer/portainer/api/adminmonitor" "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/http/offlinegate" i "github.com/portainer/portainer/api/internal/testhelpers" "github.com/stretchr/testify/assert" @@ -49,7 +50,7 @@ func Test_backupHandlerWithoutPassword_shouldCreateATarballArchive(t *testing.T) gate := offlinegate.NewOfflineGate() adminMonitor := adminmonitor.New(time.Hour, nil, context.Background()) - handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor).backup(w, r) + handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor, &demo.Service{}).backup(w, r) assert.Nil(t, handlerErr, "Handler should not fail") response := w.Result() @@ -86,7 +87,7 @@ func Test_backupHandlerWithPassword_shouldCreateEncryptedATarballArchive(t *test gate := offlinegate.NewOfflineGate() adminMonitor := adminmonitor.New(time.Hour, nil, nil) - handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor).backup(w, r) + handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor, &demo.Service{}).backup(w, r) assert.Nil(t, handlerErr, "Handler should not fail") response := w.Result() diff --git a/api/http/handler/backup/handler.go b/api/http/handler/backup/handler.go index a0aed8ead..500a3c374 100644 --- a/api/http/handler/backup/handler.go +++ b/api/http/handler/backup/handler.go @@ -9,6 +9,8 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/adminmonitor" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/demo" + "github.com/portainer/portainer/api/http/middlewares" "github.com/portainer/portainer/api/http/offlinegate" "github.com/portainer/portainer/api/http/security" ) @@ -25,7 +27,17 @@ type Handler struct { } // NewHandler creates an new instance of backup handler -func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore, gate *offlinegate.OfflineGate, filestorePath string, shutdownTrigger context.CancelFunc, adminMonitor *adminmonitor.Monitor) *Handler { +func NewHandler( + bouncer *security.RequestBouncer, + dataStore dataservices.DataStore, + gate *offlinegate.OfflineGate, + filestorePath string, + shutdownTrigger context.CancelFunc, + adminMonitor *adminmonitor.Monitor, + demoService *demo.Service, + +) *Handler { + h := &Handler{ Router: mux.NewRouter(), bouncer: bouncer, @@ -36,8 +48,11 @@ func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataSto adminMonitor: adminMonitor, } - h.Handle("/backup", bouncer.RestrictedAccess(adminAccess(httperror.LoggerHandler(h.backup)))).Methods(http.MethodPost) - h.Handle("/restore", bouncer.PublicAccess(httperror.LoggerHandler(h.restore))).Methods(http.MethodPost) + demoRestrictedRouter := h.NewRoute().Subrouter() + demoRestrictedRouter.Use(middlewares.RestrictDemoEnv(demoService.IsDemo)) + + demoRestrictedRouter.Handle("/backup", bouncer.RestrictedAccess(adminAccess(httperror.LoggerHandler(h.backup)))).Methods(http.MethodPost) + demoRestrictedRouter.Handle("/restore", bouncer.PublicAccess(httperror.LoggerHandler(h.restore))).Methods(http.MethodPost) return h } @@ -50,7 +65,7 @@ func adminAccess(next http.Handler) http.Handler { } if !securityContext.IsAdmin { - httperror.WriteError(w, http.StatusUnauthorized, "User is not authorized to perfom the action", nil) + httperror.WriteError(w, http.StatusUnauthorized, "User is not authorized to perform the action", nil) } next.ServeHTTP(w, r) diff --git a/api/http/handler/backup/restore_test.go b/api/http/handler/backup/restore_test.go index bf9617248..5c21a67d7 100644 --- a/api/http/handler/backup/restore_test.go +++ b/api/http/handler/backup/restore_test.go @@ -14,6 +14,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/adminmonitor" + "github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/http/offlinegate" i "github.com/portainer/portainer/api/internal/testhelpers" "github.com/stretchr/testify/assert" @@ -51,7 +52,7 @@ func Test_restoreArchive_usingCombinationOfPasswords(t *testing.T) { datastore := i.NewDatastore(i.WithUsers([]portainer.User{}), i.WithEdgeJobs([]portainer.EdgeJob{})) adminMonitor := adminmonitor.New(time.Hour, datastore, context.Background()) - h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor) + h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor, &demo.Service{}) //backup archive := backup(t, h, test.backupPassword) @@ -74,7 +75,7 @@ func Test_restoreArchive_shouldFailIfSystemWasAlreadyInitialized(t *testing.T) { datastore := i.NewDatastore(i.WithUsers([]portainer.User{admin}), i.WithEdgeJobs([]portainer.EdgeJob{})) adminMonitor := adminmonitor.New(time.Hour, datastore, context.Background()) - h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor) + h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor, &demo.Service{}) //backup archive := backup(t, h, "password") diff --git a/api/http/handler/endpoints/endpoint_create_global_key_test.go b/api/http/handler/endpoints/endpoint_create_global_key_test.go index 0be6bf2ff..aa37c3d75 100644 --- a/api/http/handler/endpoints/endpoint_create_global_key_test.go +++ b/api/http/handler/endpoints/endpoint_create_global_key_test.go @@ -12,6 +12,7 @@ import ( func TestEmptyGlobalKey(t *testing.T) { handler := NewHandler( helper.NewTestRequestBouncer(), + nil, ) req, err := http.NewRequest(http.MethodPost, "https://portainer.io:9443/endpoints/global-key", nil) diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index da87d9d3c..065df12ec 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -8,6 +8,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" ) // @id EndpointDelete @@ -29,6 +30,10 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err} } + if handler.demoService.IsDemoEnvironment(portainer.EndpointID(endpointID)) { + return &httperror.HandlerError{http.StatusForbidden, httperrors.ErrNotAvailableInDemo.Error(), httperrors.ErrNotAvailableInDemo} + } + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if handler.DataStore.IsErrObjectNotFound(err) { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database", err} diff --git a/api/http/handler/endpoints/endpoint_list_test.go b/api/http/handler/endpoints/endpoint_list_test.go index 269897687..41d8fc9ed 100644 --- a/api/http/handler/endpoints/endpoint_list_test.go +++ b/api/http/handler/endpoints/endpoint_list_test.go @@ -52,7 +52,7 @@ func Test_endpointList(t *testing.T) { is.NoError(err, "error creating a user") bouncer := helper.NewTestRequestBouncer() - h := NewHandler(bouncer) + h := NewHandler(bouncer, nil) h.DataStore = store h.ComposeStackManager = testhelpers.NewComposeStackManager() diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index e4e075e07..764236a61 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -4,6 +4,7 @@ import ( httperror "github.com/portainer/libhttp/error" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/kubernetes/cli" @@ -35,6 +36,7 @@ type requestBouncer interface { type Handler struct { *mux.Router requestBouncer requestBouncer + demoService *demo.Service DataStore dataservices.DataStore FileService portainer.FileService ProxyManager *proxy.Manager @@ -48,10 +50,11 @@ type Handler struct { } // NewHandler creates a handler to manage environment(endpoint) operations. -func NewHandler(bouncer requestBouncer) *Handler { +func NewHandler(bouncer requestBouncer, demoService *demo.Service) *Handler { h := &Handler{ Router: mux.NewRouter(), requestBouncer: bouncer, + demoService: demoService, } h.Handle("/endpoints", diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index 9992c510f..36067327b 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -7,6 +7,7 @@ import ( httperror "github.com/portainer/libhttp/error" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/http/security" ) @@ -24,12 +25,14 @@ type Handler struct { JWTService dataservices.JWTService LDAPService portainer.LDAPService SnapshotService portainer.SnapshotService + demoService *demo.Service } // NewHandler creates a handler to manage settings operations. -func NewHandler(bouncer *security.RequestBouncer) *Handler { +func NewHandler(bouncer *security.RequestBouncer, demoService *demo.Service) *Handler { h := &Handler{ - Router: mux.NewRouter(), + Router: mux.NewRouter(), + demoService: demoService, } h.Handle("/settings", bouncer.AdminAccess(httperror.LoggerHandler(h.settingsInspect))).Methods(http.MethodGet) diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index f00a14df0..06a880f5d 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -113,6 +113,11 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err} } + if handler.demoService.IsDemo() { + payload.EnableTelemetry = nil + payload.LogoURL = nil + } + if payload.AuthenticationMethod != nil { settings.AuthenticationMethod = portainer.AuthenticationMethod(*payload.AuthenticationMethod) } diff --git a/api/http/handler/status/handler.go b/api/http/handler/status/handler.go index af11d25a3..19c64b4bb 100644 --- a/api/http/handler/status/handler.go +++ b/api/http/handler/status/handler.go @@ -5,21 +5,24 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/http/security" ) // Handler is the HTTP handler used to handle status operations. type Handler struct { *mux.Router - Status *portainer.Status + Status *portainer.Status + demoService *demo.Service } // NewHandler creates a handler to manage status operations. -func NewHandler(bouncer *security.RequestBouncer, status *portainer.Status) *Handler { +func NewHandler(bouncer *security.RequestBouncer, status *portainer.Status, demoService *demo.Service) *Handler { h := &Handler{ - Router: mux.NewRouter(), - Status: status, + Router: mux.NewRouter(), + Status: status, + demoService: demoService, } h.Handle("/status", bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspect))).Methods(http.MethodGet) diff --git a/api/http/handler/status/status_inspect.go b/api/http/handler/status/status_inspect.go index d7b399ae5..bda7df64a 100644 --- a/api/http/handler/status/status_inspect.go +++ b/api/http/handler/status/status_inspect.go @@ -5,16 +5,26 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/demo" ) +type status struct { + *portainer.Status + DemoEnvironment demo.EnvironmentDetails +} + // @id StatusInspect // @summary Check Portainer status // @description Retrieve Portainer status // @description **Access policy**: public // @tags status // @produce json -// @success 200 {object} portainer.Status "Success" +// @success 200 {object} status "Success" // @router /status [get] func (handler *Handler) statusInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - return response.JSON(w, handler.Status) + return response.JSON(w, &status{ + Status: handler.Status, + DemoEnvironment: handler.demoService.Details(), + }) } diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go index 89023fbe8..d02330c28 100644 --- a/api/http/handler/users/handler.go +++ b/api/http/handler/users/handler.go @@ -8,6 +8,7 @@ import ( "github.com/portainer/portainer/api/apikey" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/http/security" "net/http" @@ -32,16 +33,18 @@ type Handler struct { *mux.Router bouncer *security.RequestBouncer apiKeyService apikey.APIKeyService + demoService *demo.Service DataStore dataservices.DataStore CryptoService portainer.CryptoService } // NewHandler creates a handler to manage user operations. -func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, apiKeyService apikey.APIKeyService) *Handler { +func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, apiKeyService apikey.APIKeyService, demoService *demo.Service) *Handler { h := &Handler{ Router: mux.NewRouter(), bouncer: bouncer, apiKeyService: apiKeyService, + demoService: demoService, } h.Handle("/users", bouncer.AdminAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost) diff --git a/api/http/handler/users/user_create_access_token_test.go b/api/http/handler/users/user_create_access_token_test.go index 283ab2bdd..cf0688ae0 100644 --- a/api/http/handler/users/user_create_access_token_test.go +++ b/api/http/handler/users/user_create_access_token_test.go @@ -40,7 +40,7 @@ func Test_userCreateAccessToken(t *testing.T) { requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService) rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) - h := NewHandler(requestBouncer, rateLimiter, apiKeyService) + h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil) h.DataStore = store // generate standard and admin user tokens diff --git a/api/http/handler/users/user_delete_test.go b/api/http/handler/users/user_delete_test.go index 6ed52a28d..dbfac4d68 100644 --- a/api/http/handler/users/user_delete_test.go +++ b/api/http/handler/users/user_delete_test.go @@ -32,7 +32,7 @@ func Test_deleteUserRemovesAccessTokens(t *testing.T) { requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService) rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) - h := NewHandler(requestBouncer, rateLimiter, apiKeyService) + h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil) h.DataStore = store t.Run("standard user deletion removes all associated access tokens", func(t *testing.T) { diff --git a/api/http/handler/users/user_get_access_tokens_test.go b/api/http/handler/users/user_get_access_tokens_test.go index 5a258eab0..9a3e24e55 100644 --- a/api/http/handler/users/user_get_access_tokens_test.go +++ b/api/http/handler/users/user_get_access_tokens_test.go @@ -39,7 +39,7 @@ func Test_userGetAccessTokens(t *testing.T) { requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService) rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) - h := NewHandler(requestBouncer, rateLimiter, apiKeyService) + h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil) h.DataStore = store // generate standard and admin user tokens diff --git a/api/http/handler/users/user_remove_access_token_test.go b/api/http/handler/users/user_remove_access_token_test.go index 2b3edbdad..8a6ce0f83 100644 --- a/api/http/handler/users/user_remove_access_token_test.go +++ b/api/http/handler/users/user_remove_access_token_test.go @@ -37,7 +37,7 @@ func Test_userRemoveAccessToken(t *testing.T) { requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService) rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) - h := NewHandler(requestBouncer, rateLimiter, apiKeyService) + h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil) h.DataStore = store // generate standard and admin user tokens diff --git a/api/http/handler/users/user_update.go b/api/http/handler/users/user_update.go index cec78759c..064aa1c68 100644 --- a/api/http/handler/users/user_update.go +++ b/api/http/handler/users/user_update.go @@ -57,6 +57,10 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} } + if handler.demoService.IsDemoUser(portainer.UserID(userID)) { + return &httperror.HandlerError{http.StatusForbidden, httperrors.ErrNotAvailableInDemo.Error(), httperrors.ErrNotAvailableInDemo} + } + tokenData, err := security.RetrieveTokenData(r) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} diff --git a/api/http/handler/users/user_update_password.go b/api/http/handler/users/user_update_password.go index e569d0ab5..9db56a3cf 100644 --- a/api/http/handler/users/user_update_password.go +++ b/api/http/handler/users/user_update_password.go @@ -55,6 +55,10 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} } + if handler.demoService.IsDemoUser(portainer.UserID(userID)) { + return &httperror.HandlerError{http.StatusForbidden, httperrors.ErrNotAvailableInDemo.Error(), httperrors.ErrNotAvailableInDemo} + } + tokenData, err := security.RetrieveTokenData(r) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} diff --git a/api/http/handler/users/user_update_test.go b/api/http/handler/users/user_update_test.go index dd6f31596..ab0c596ff 100644 --- a/api/http/handler/users/user_update_test.go +++ b/api/http/handler/users/user_update_test.go @@ -32,7 +32,7 @@ func Test_updateUserRemovesAccessTokens(t *testing.T) { requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService) rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) - h := NewHandler(requestBouncer, rateLimiter, apiKeyService) + h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil) h.DataStore = store t.Run("standard user deletion removes all associated access tokens", func(t *testing.T) { diff --git a/api/http/middlewares/demo.go b/api/http/middlewares/demo.go new file mode 100644 index 000000000..ea8b1aadb --- /dev/null +++ b/api/http/middlewares/demo.go @@ -0,0 +1,23 @@ +package middlewares + +import ( + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer/api/http/errors" +) + +// restrict functionality on demo environments +func RestrictDemoEnv(isDemo func() bool) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isDemo() { + next.ServeHTTP(w, r) + return + } + + httperror.WriteError(w, http.StatusBadRequest, errors.ErrNotAvailableInDemo.Error(), errors.ErrNotAvailableInDemo) + }) + } +} diff --git a/api/http/middlewares/demo_test.go b/api/http/middlewares/demo_test.go new file mode 100644 index 000000000..385d36456 --- /dev/null +++ b/api/http/middlewares/demo_test.go @@ -0,0 +1,41 @@ +package middlewares + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_demoEnvironment_shouldFail(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)) + w := httptest.NewRecorder() + + h := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {}) + + RestrictDemoEnv(func() bool { return true }).Middleware(h).ServeHTTP(w, r) + + response := w.Result() + defer response.Body.Close() + + assert.Equal(t, http.StatusBadRequest, response.StatusCode) + + body, _ := io.ReadAll(response.Body) + assert.Contains(t, string(body), "This feature is not available in the demo version of Portainer") +} + +func Test_notDemoEnvironment_shouldSucceed(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)) + w := httptest.NewRecorder() + + h := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {}) + + RestrictDemoEnv(func() bool { return false }).Middleware(h).ServeHTTP(w, r) + + response := w.Result() + assert.Equal(t, http.StatusOK, response.StatusCode) + +} diff --git a/api/http/server.go b/api/http/server.go index 3cabe0b25..f8d6608b8 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -15,6 +15,7 @@ import ( "github.com/portainer/portainer/api/apikey" "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/http/handler" "github.com/portainer/portainer/api/http/handler/auth" @@ -98,6 +99,7 @@ type Server struct { ShutdownCtx context.Context ShutdownTrigger context.CancelFunc StackDeployer stackdeployer.StackDeployer + DemoService *demo.Service } // Start starts the HTTP server @@ -121,7 +123,15 @@ func (server *Server) Start() error { adminMonitor := adminmonitor.New(5*time.Minute, server.DataStore, server.ShutdownCtx) adminMonitor.Start() - var backupHandler = backup.NewHandler(requestBouncer, server.DataStore, offlineGate, server.FileService.GetDatastorePath(), server.ShutdownTrigger, adminMonitor) + var backupHandler = backup.NewHandler( + requestBouncer, + server.DataStore, + offlineGate, + server.FileService.GetDatastorePath(), + server.ShutdownTrigger, + adminMonitor, + server.DemoService, + ) var roleHandler = roles.NewHandler(requestBouncer) roleHandler.DataStore = server.DataStore @@ -147,7 +157,7 @@ func (server *Server) Start() error { var edgeTemplatesHandler = edgetemplates.NewHandler(requestBouncer) edgeTemplatesHandler.DataStore = server.DataStore - var endpointHandler = endpoints.NewHandler(requestBouncer) + var endpointHandler = endpoints.NewHandler(requestBouncer, server.DemoService) endpointHandler.DataStore = server.DataStore endpointHandler.FileService = server.FileService endpointHandler.ProxyManager = server.ProxyManager @@ -194,7 +204,7 @@ func (server *Server) Start() error { var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer) resourceControlHandler.DataStore = server.DataStore - var settingsHandler = settings.NewHandler(requestBouncer) + var settingsHandler = settings.NewHandler(requestBouncer, server.DemoService) settingsHandler.DataStore = server.DataStore settingsHandler.FileService = server.FileService settingsHandler.JWTService = server.JWTService @@ -234,7 +244,7 @@ func (server *Server) Start() error { var teamMembershipHandler = teammemberships.NewHandler(requestBouncer) teamMembershipHandler.DataStore = server.DataStore - var statusHandler = status.NewHandler(requestBouncer, server.Status) + var statusHandler = status.NewHandler(requestBouncer, server.Status, server.DemoService) var templatesHandler = templates.NewHandler(requestBouncer) templatesHandler.DataStore = server.DataStore @@ -244,7 +254,7 @@ func (server *Server) Start() error { var uploadHandler = upload.NewHandler(requestBouncer) uploadHandler.FileService = server.FileService - var userHandler = users.NewHandler(requestBouncer, rateLimiter, server.APIKeyService) + var userHandler = users.NewHandler(requestBouncer, rateLimiter, server.APIKeyService, server.DemoService) userHandler.DataStore = server.DataStore userHandler.CryptoService = server.CryptoService diff --git a/api/portainer.go b/api/portainer.go index 8b22cd708..0b081e3ce 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -102,6 +102,7 @@ type ( Assets *string Data *string FeatureFlags *[]Pair + DemoEnvironment *bool EnableEdgeComputeFeatures *bool EndpointURL *string Labels *[]Pair diff --git a/app/portainer/components/demo-feature-indicator/demo-feature-indicator.controller.js b/app/portainer/components/demo-feature-indicator/demo-feature-indicator.controller.js new file mode 100644 index 000000000..51c0a89ef --- /dev/null +++ b/app/portainer/components/demo-feature-indicator/demo-feature-indicator.controller.js @@ -0,0 +1,16 @@ +class DemoFeatureIndicatorController { + /* @ngInject */ + constructor(StateManager) { + Object.assign(this, { StateManager }); + + this.isDemo = false; + } + + $onInit() { + const state = this.StateManager.getState(); + + this.isDemo = state.application.demoEnvironment.enabled; + } +} + +export default DemoFeatureIndicatorController; diff --git a/app/portainer/components/demo-feature-indicator/demo-feature-indicator.html b/app/portainer/components/demo-feature-indicator/demo-feature-indicator.html new file mode 100644 index 000000000..9a72040c3 --- /dev/null +++ b/app/portainer/components/demo-feature-indicator/demo-feature-indicator.html @@ -0,0 +1,10 @@ +
+
+ + + + {{ $ctrl.content }} + + +
+
diff --git a/app/portainer/components/demo-feature-indicator/index.js b/app/portainer/components/demo-feature-indicator/index.js new file mode 100644 index 000000000..e81a981ef --- /dev/null +++ b/app/portainer/components/demo-feature-indicator/index.js @@ -0,0 +1,12 @@ +import angular from 'angular'; +import controller from './demo-feature-indicator.controller.js'; + +export const demoFeatureIndicator = { + templateUrl: './demo-feature-indicator.html', + controller, + bindings: { + content: '<', + }, +}; + +angular.module('portainer.app').component('demoFeatureIndicator', demoFeatureIndicator); diff --git a/app/portainer/components/theme/theme-settings.controller.js b/app/portainer/components/theme/theme-settings.controller.js index 9b96a2cd0..d50af50a0 100644 --- a/app/portainer/components/theme/theme-settings.controller.js +++ b/app/portainer/components/theme/theme-settings.controller.js @@ -20,8 +20,12 @@ export default class ThemeSettingsController { } else { this.ThemeManager.setTheme(theme); } + this.state.userTheme = theme; - await this.UserService.updateUserTheme(this.state.userId, this.state.userTheme); + if (!this.state.isDemo) { + await this.UserService.updateUserTheme(this.state.userId, this.state.userTheme); + } + this.Notifications.success('Success', 'User theme successfully updated'); } catch (err) { this.Notifications.error('Failure', err, 'Unable to update user theme'); @@ -30,10 +34,13 @@ export default class ThemeSettingsController { $onInit() { return this.$async(async () => { + const state = this.StateManager.getState(); + this.state = { userId: null, userTheme: '', defaultTheme: 'auto', + isDemo: state.application.demoEnvironment.enabled, }; this.state.availableThemes = [ diff --git a/app/portainer/models/status.js b/app/portainer/models/status.js index 70460922b..a38c6db9c 100644 --- a/app/portainer/models/status.js +++ b/app/portainer/models/status.js @@ -4,6 +4,7 @@ export function StatusViewModel(data) { this.Version = data.Version; this.Edition = data.Edition; this.InstanceID = data.InstanceID; + this.DemoEnvironment = data.DemoEnvironment; } export function StatusVersionViewModel(data) { diff --git a/app/portainer/rest/backup.js b/app/portainer/rest/backup.js index 65e7fa6f1..6cc853945 100644 --- a/app/portainer/rest/backup.js +++ b/app/portainer/rest/backup.js @@ -9,12 +9,20 @@ angular.module('portainer.app').factory('Backup', [ { download: { method: 'POST', - responseType: 'blob', + responseType: 'arraybuffer', ignoreLoadingBar: true, - transformResponse: (data, headersGetter) => ({ - file: data, - name: headersGetter('Content-Disposition').replace('attachment; filename=', ''), - }), + transformResponse: (data, headersGetter, status) => { + if (status !== 200) { + const decoder = new TextDecoder('utf-8'); + const str = decoder.decode(data); + return JSON.parse(str); + } + + return { + file: data, + name: headersGetter('Content-Disposition').replace('attachment; filename=', ''), + }; + }, }, getS3Settings: { method: 'GET', params: { subResource: 's3', action: 'settings' } }, saveS3Settings: { method: 'POST', params: { subResource: 's3', action: 'settings' } }, diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index b95122b5c..bf777be45 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -101,7 +101,8 @@ angular.module('portainer.app').factory('Authentication', [ async function setUserTheme() { const data = await UserService.user(user.ID); - // Initialize user theme base on Usertheme from database + + // Initialize user theme base on UserTheme from database const userTheme = data.UserTheme; if (userTheme === 'auto' || !userTheme) { ThemeManager.autoTheme(); diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index de8ac780f..f7258f646 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -87,6 +87,7 @@ function StateManagerFactory( state.application.version = status.Version; state.application.edition = status.Edition; state.application.instanceId = status.InstanceID; + state.application.demoEnvironment = status.DemoEnvironment; state.application.enableTelemetry = settings.EnableTelemetry; state.application.logo = settings.LogoURL; diff --git a/app/portainer/views/account/account.html b/app/portainer/views/account/account.html index 500ed2107..333cae78a 100644 --- a/app/portainer/views/account/account.html +++ b/app/portainer/views/account/account.html @@ -3,6 +3,8 @@ User settings + +
@@ -56,15 +58,16 @@ - + Update password + + You cannot change your password when using LDAP authentication. - + You cannot change your password when using OAuth authentication. diff --git a/app/portainer/views/account/accountController.js b/app/portainer/views/account/accountController.js index c00ec8637..2f75777f2 100644 --- a/app/portainer/views/account/accountController.js +++ b/app/portainer/views/account/accountController.js @@ -97,12 +97,19 @@ angular.module('portainer.app').controller('AccountController', [ }; async function initView() { - $scope.userID = Authentication.getUserDetails().ID; - $scope.forceChangePassword = Authentication.getUserDetails().forceChangePassword; + const state = StateManager.getState(); + const userDetails = Authentication.getUserDetails(); + $scope.userID = userDetails.ID; + $scope.forceChangePassword = userDetails.forceChangePassword; + + if (state.application.demoEnvironment.enabled) { + $scope.isDemoUser = state.application.demoEnvironment.users.includes($scope.userID); + } const data = await UserService.user($scope.userID); - $scope.formValues.userTheme = data.Usertheme; + $scope.formValues.userTheme = data.UserTheme; + SettingsService.publicSettings() .then(function success(data) { $scope.AuthenticationMethod = data.AuthenticationMethod; diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index dcd91810a..e3cef017b 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -20,13 +20,19 @@
- - + +
+
+ You cannot use this feature in the demo version of Portainer.
+
You can specify the URL to your logo here. For an optimal display, logo dimensions should be 155px by 55px. @@ -39,21 +45,25 @@
- +
- - + +
+
+ You cannot use this feature in the demo version of Portainer.
You can find more information about this in our privacy policy.
-
App Templates
diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js index b5597a6b5..4206642f1 100644 --- a/app/portainer/views/settings/settingsController.js +++ b/app/portainer/views/settings/settingsController.js @@ -20,6 +20,7 @@ angular.module('portainer.app').controller('SettingsController', [ ]; $scope.state = { + isDemo: false, actionInProgress: false, availableKubeconfigExpiryOptions: [ { @@ -60,6 +61,18 @@ angular.module('portainer.app').controller('SettingsController', [ backupFormType: $scope.BACKUP_FORM_TYPES.FILE, }; + $scope.onToggleEnableTelemetry = function onToggleEnableTelemetry(checked) { + $scope.$evalAsync(() => { + $scope.formValues.enableTelemetry = checked; + }); + }; + + $scope.onToggleCustomLogo = function onToggleCustomLogo(checked) { + $scope.$evalAsync(() => { + $scope.formValues.customLogo = checked; + }); + }; + $scope.onToggleAutoBackups = function onToggleAutoBackups(checked) { $scope.$evalAsync(() => { $scope.formValues.scheduleAutomaticBackups = checked; @@ -142,6 +155,9 @@ angular.module('portainer.app').controller('SettingsController', [ } function initView() { + const state = StateManager.getState(); + $scope.state.isDemo = state.application.demoEnvironment.enabled; + SettingsService.settings() .then(function success(data) { var settings = data;