From c7e306841a933b022aed30713f4432b361f0e4c0 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 1 Jun 2017 10:14:55 +0200 Subject: [PATCH] feat(settings): add settings management (#906) --- api/bolt/datastore.go | 8 + api/bolt/internal/internal.go | 10 ++ api/bolt/settings_service.go | 61 +++++++ api/cli/cli.go | 22 ++- api/cli/defaults.go | 1 - api/cli/defaults_windows.go | 1 - api/cmd/portainer/main.go | 48 +++++- api/errors.go | 5 + api/http/handler/handler.go | 3 + api/http/handler/settings.go | 64 +++++-- api/http/handler/status.go | 38 +++++ api/http/handler/templates.go | 19 ++- api/http/proxy/containers.go | 28 ++- api/http/proxy/factory.go | 3 + api/http/proxy/filter.go | 21 +++ api/http/proxy/manager.go | 3 +- api/http/proxy/service.go | 14 +- api/http/proxy/transport.go | 96 ++++++++--- api/http/proxy/utils.go | 15 ++ api/http/proxy/volumes.go | 14 +- api/http/server.go | 14 +- api/portainer.go | 34 +++- app/app.js | 25 ++- app/components/auth/authController.js | 10 +- .../containerConsoleController.js | 4 +- .../containers/containersController.js | 25 ++- .../createContainerController.js | 79 ++++----- .../dashboard/dashboardController.js | 18 +- app/components/images/imagesController.js | 4 +- app/components/network/networkController.js | 34 ++-- app/components/networks/networksController.js | 10 +- app/components/settings/settings.html | 159 ++++++++++++++---- app/components/settings/settingsController.js | 105 +++++++++--- app/components/sidebar/sidebar.html | 7 +- app/components/sidebar/sidebarController.js | 12 +- .../templates/templatesController.js | 50 +++--- app/components/userSettings/userSettings.html | 68 ++++++++ .../userSettings/userSettingsController.js | 29 ++++ app/directives/header-content.js | 2 +- app/helpers/containerHelper.js | 15 -- app/models/api/settings.js | 6 + app/models/api/status.js | 6 + app/rest/{ => api}/auth.js | 0 app/rest/{ => api}/endpoint.js | 0 app/rest/{ => api}/resourceControl.js | 0 app/rest/api/settings.js | 8 + app/rest/api/status.js | 7 + app/rest/{ => api}/team.js | 0 app/rest/{ => api}/teamMembership.js | 0 app/rest/{ => api}/template.js | 0 app/rest/{ => api}/user.js | 0 app/rest/config.js | 4 - app/rest/containerCommit.js | 10 -- app/rest/{ => docker}/container.js | 4 +- app/rest/docker/containerCommit.js | 10 ++ app/rest/{ => docker}/containerLogs.js | 4 +- app/rest/{ => docker}/containerTop.js | 4 +- app/rest/{ => docker}/event.js | 4 +- app/rest/{ => docker}/exec.js | 4 +- app/rest/{ => docker}/image.js | 4 +- app/rest/docker/info.js | 7 + app/rest/{ => docker}/network.js | 4 +- app/rest/{ => docker}/node.js | 4 +- app/rest/{ => docker}/response/handlers.js | 0 app/rest/{ => docker}/secret.js | 4 +- app/rest/{ => docker}/service.js | 4 +- app/rest/docker/swarm.js | 10 ++ app/rest/{ => docker}/task.js | 4 +- app/rest/docker/version.js | 7 + app/rest/{ => docker}/volume.js | 4 +- app/rest/info.js | 7 - app/rest/swarm.js | 10 -- app/rest/version.js | 7 - app/services/{ => api}/endpointService.js | 0 .../{ => api}/resourceControlService.js | 0 app/services/api/settingsService.js | 26 +++ app/services/api/statusService.js | 22 +++ .../{ => api}/teamMembershipService.js | 0 app/services/{ => api}/teamService.js | 0 app/services/{ => api}/userService.js | 0 app/services/{ => docker}/containerService.js | 9 +- app/services/{ => docker}/imageService.js | 0 app/services/{ => docker}/infoService.js | 0 app/services/{ => docker}/networkService.js | 0 app/services/{ => docker}/nodeService.js | 0 app/services/{ => docker}/secretService.js | 0 app/services/{ => docker}/serviceService.js | 0 app/services/{ => docker}/taskService.js | 0 app/services/{ => docker}/volumeService.js | 0 app/services/lineChart.js | 2 +- app/services/pagination.js | 4 +- app/services/settings.js | 17 -- app/services/stateManager.js | 138 +++++++++------ 93 files changed, 1086 insertions(+), 457 deletions(-) create mode 100644 api/bolt/settings_service.go create mode 100644 api/http/handler/status.go create mode 100644 app/components/userSettings/userSettings.html create mode 100644 app/components/userSettings/userSettingsController.js create mode 100644 app/models/api/settings.js create mode 100644 app/models/api/status.js rename app/rest/{ => api}/auth.js (100%) rename app/rest/{ => api}/endpoint.js (100%) rename app/rest/{ => api}/resourceControl.js (100%) create mode 100644 app/rest/api/settings.js create mode 100644 app/rest/api/status.js rename app/rest/{ => api}/team.js (100%) rename app/rest/{ => api}/teamMembership.js (100%) rename app/rest/{ => api}/template.js (100%) rename app/rest/{ => api}/user.js (100%) delete mode 100644 app/rest/config.js delete mode 100644 app/rest/containerCommit.js rename app/rest/{ => docker}/container.js (85%) create mode 100644 app/rest/docker/containerCommit.js rename app/rest/{ => docker}/containerLogs.js (65%) rename app/rest/{ => docker}/containerTop.js (53%) rename app/rest/{ => docker}/event.js (56%) rename app/rest/{ => docker}/exec.js (55%) rename app/rest/{ => docker}/image.js (83%) create mode 100644 app/rest/docker/info.js rename app/rest/{ => docker}/network.js (68%) rename app/rest/{ => docker}/node.js (62%) rename app/rest/{ => docker}/response/handlers.js (100%) rename app/rest/{ => docker}/secret.js (59%) rename app/rest/{ => docker}/service.js (65%) create mode 100644 app/rest/docker/swarm.js rename app/rest/{ => docker}/task.js (53%) create mode 100644 app/rest/docker/version.js rename app/rest/{ => docker}/volume.js (64%) delete mode 100644 app/rest/info.js delete mode 100644 app/rest/swarm.js delete mode 100644 app/rest/version.js rename app/services/{ => api}/endpointService.js (100%) rename app/services/{ => api}/resourceControlService.js (100%) create mode 100644 app/services/api/settingsService.js create mode 100644 app/services/api/statusService.js rename app/services/{ => api}/teamMembershipService.js (100%) rename app/services/{ => api}/teamService.js (100%) rename app/services/{ => api}/userService.js (100%) rename app/services/{ => docker}/containerService.js (86%) rename app/services/{ => docker}/imageService.js (100%) rename app/services/{ => docker}/infoService.js (100%) rename app/services/{ => docker}/networkService.js (100%) rename app/services/{ => docker}/nodeService.js (100%) rename app/services/{ => docker}/secretService.js (100%) rename app/services/{ => docker}/serviceService.js (100%) rename app/services/{ => docker}/taskService.js (100%) rename app/services/{ => docker}/volumeService.js (100%) delete mode 100644 app/services/settings.js diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 96b179b46..3c56ede26 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -22,6 +22,7 @@ type Store struct { EndpointService *EndpointService ResourceControlService *ResourceControlService VersionService *VersionService + SettingsService *SettingsService db *bolt.DB checkForDataMigration bool @@ -35,6 +36,7 @@ const ( teamMembershipBucketName = "team_membership" endpointBucketName = "endpoints" resourceControlBucketName = "resource_control" + settingsBucketName = "settings" ) // NewStore initializes a new Store and the associated services @@ -47,6 +49,7 @@ func NewStore(storePath string) (*Store, error) { EndpointService: &EndpointService{}, ResourceControlService: &ResourceControlService{}, VersionService: &VersionService{}, + SettingsService: &SettingsService{}, } store.UserService.store = store store.TeamService.store = store @@ -54,6 +57,7 @@ func NewStore(storePath string) (*Store, error) { store.EndpointService.store = store store.ResourceControlService.store = store store.VersionService.store = store + store.SettingsService.store = store _, err := os.Stat(storePath + "/" + databaseFileName) if err != nil && os.IsNotExist(err) { @@ -100,6 +104,10 @@ func (store *Store) Open() error { if err != nil { return err } + _, err = tx.CreateBucketIfNotExists([]byte(settingsBucketName)) + if err != nil { + return err + } return nil }) } diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go index e02c26ba4..f55f72118 100644 --- a/api/bolt/internal/internal.go +++ b/api/bolt/internal/internal.go @@ -57,6 +57,16 @@ func UnmarshalResourceControl(data []byte, rc *portainer.ResourceControl) error return json.Unmarshal(data, rc) } +// MarshalSettings encodes a settings object to binary format. +func MarshalSettings(settings *portainer.Settings) ([]byte, error) { + return json.Marshal(settings) +} + +// UnmarshalSettings decodes a settings object from a binary data. +func UnmarshalSettings(data []byte, settings *portainer.Settings) error { + return json.Unmarshal(data, settings) +} + // Itob returns an 8-byte big endian representation of v. // This function is typically used for encoding integer IDs to byte slices // so that they can be used as BoltDB keys. diff --git a/api/bolt/settings_service.go b/api/bolt/settings_service.go new file mode 100644 index 000000000..9ea7cc2ab --- /dev/null +++ b/api/bolt/settings_service.go @@ -0,0 +1,61 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// SettingsService represents a service to manage application settings. +type SettingsService struct { + store *Store +} + +const ( + dbSettingsKey = "SETTINGS" +) + +// Settings retrieve the settings object. +func (service *SettingsService) Settings() (*portainer.Settings, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(settingsBucketName)) + value := bucket.Get([]byte(dbSettingsKey)) + if value == nil { + return portainer.ErrSettingsNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var settings portainer.Settings + err = internal.UnmarshalSettings(data, &settings) + if err != nil { + return nil, err + } + return &settings, nil +} + +// StoreSettings persists a Settings object. +func (service *SettingsService) StoreSettings(settings *portainer.Settings) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(settingsBucketName)) + + data, err := internal.MarshalSettings(settings) + if err != nil { + return err + } + + err = bucket.Put([]byte(dbSettingsKey), data) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/cli/cli.go b/api/cli/cli.go index 68a017b1d..80308d41a 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -1,6 +1,7 @@ package cli import ( + "log" "time" "github.com/portainer/portainer" @@ -29,14 +30,11 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { flags := &portainer.CLIFlags{ Endpoint: kingpin.Flag("host", "Dockerd endpoint").Short('H').String(), - Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(), - Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')), ExternalEndpoints: kingpin.Flag("external-endpoints", "Path to a file defining available endpoints").String(), SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(), Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').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(), - Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Default(defaultTemplatesURL).Short('t').String(), NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(), NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAuth).Bool(), TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(), @@ -47,6 +45,10 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(), SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(), AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(), + // Deprecated flags + Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')), + Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(), + Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Short('t').String(), } kingpin.Parse() @@ -79,6 +81,8 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error { return errNoAuthExcludeAdminPassword } + displayDeprecationWarnings(*flags.Templates, *flags.Logo, *flags.Labels) + return nil } @@ -122,3 +126,15 @@ func validateSyncInterval(syncInterval string) error { } return nil } + +func displayDeprecationWarnings(templates, logo string, labels []portainer.Pair) { + if templates != "" { + log.Println("Warning: the --templates / -t flag is deprecated and will be removed in future versions.") + } + if logo != "" { + log.Println("Warning: the --logo flag is deprecated and will be removed in future versions.") + } + if labels != nil { + log.Println("Warning: the --hide-label / -l flag is deprecated and will be removed in future versions.") + } +} diff --git a/api/cli/defaults.go b/api/cli/defaults.go index 5de413911..80ba58c51 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -6,7 +6,6 @@ const ( defaultBindAddress = ":9000" defaultDataDirectory = "/data" defaultAssetsDirectory = "." - defaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" defaultNoAuth = "false" defaultNoAnalytics = "false" defaultTLSVerify = "false" diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index da5f0ce45..5e80489e4 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -4,7 +4,6 @@ const ( defaultBindAddress = ":9000" defaultDataDirectory = "C:\\data" defaultAssetsDirectory = "." - defaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" defaultNoAuth = "false" defaultNoAnalytics = "false" defaultTLSVerify = "false" diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 68c6183fb..9876de6aa 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -82,16 +82,43 @@ func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpo return authorizeEndpointMgmt } -func initSettings(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portainer.Settings { - return &portainer.Settings{ - HiddenLabels: *flags.Labels, - Logo: *flags.Logo, +func initStatus(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portainer.Status { + return &portainer.Status{ Analytics: !*flags.NoAnalytics, Authentication: !*flags.NoAuth, EndpointManagement: authorizeEndpointMgmt, + Version: portainer.APIVersion, } } +func initSettings(settingsService portainer.SettingsService, flags *portainer.CLIFlags) error { + _, err := settingsService.Settings() + if err == portainer.ErrSettingsNotFound { + settings := &portainer.Settings{ + LogoURL: *flags.Logo, + DisplayExternalContributors: true, + } + + if *flags.Templates != "" { + settings.TemplatesURL = *flags.Templates + } else { + settings.TemplatesURL = portainer.DefaultTemplatesURL + } + + if *flags.Labels != nil { + settings.BlackListedLabels = *flags.Labels + } else { + settings.BlackListedLabels = make([]portainer.Pair, 0) + } + + return settingsService.StoreSettings(settings) + } else if err != nil { + return err + } + + return nil +} + func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService) *portainer.Endpoint { endpoints, err := endpointService.Endpoints() if err != nil { @@ -114,7 +141,12 @@ func main() { authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval) - settings := initSettings(authorizeEndpointMgmt, flags) + err := initSettings(store.SettingsService, flags) + if err != nil { + log.Fatal(err) + } + + applicationStatus := initStatus(authorizeEndpointMgmt, flags) if *flags.Endpoint != "" { var endpoints []portainer.Endpoint @@ -156,10 +188,9 @@ func main() { } var server portainer.Server = &http.Server{ + Status: applicationStatus, BindAddress: *flags.Addr, AssetsPath: *flags.Assets, - Settings: settings, - TemplatesURL: *flags.Templates, AuthDisabled: *flags.NoAuth, EndpointManagement: authorizeEndpointMgmt, UserService: store.UserService, @@ -167,6 +198,7 @@ func main() { TeamMembershipService: store.TeamMembershipService, EndpointService: store.EndpointService, ResourceControlService: store.ResourceControlService, + SettingsService: store.SettingsService, CryptoService: cryptoService, JWTService: jwtService, FileService: fileService, @@ -176,7 +208,7 @@ func main() { } log.Printf("Starting Portainer on %s", *flags.Addr) - err := server.Start() + err = server.Start() if err != nil { log.Fatal(err) } diff --git a/api/errors.go b/api/errors.go index e22dbc01d..0be2338d8 100644 --- a/api/errors.go +++ b/api/errors.go @@ -47,6 +47,11 @@ const ( ErrDBVersionNotFound = Error("DB version not found") ) +// Settings errors. +const ( + ErrSettingsNotFound = Error("Settings not found") +) + // Crypto errors. const ( ErrCryptoHashFailure = Error("Unable to hash data") diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 89a963c33..0692dc6de 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -18,6 +18,7 @@ type Handler struct { TeamMembershipHandler *TeamMembershipHandler EndpointHandler *EndpointHandler ResourceHandler *ResourceHandler + StatusHandler *StatusHandler SettingsHandler *SettingsHandler TemplatesHandler *TemplatesHandler DockerHandler *DockerHandler @@ -53,6 +54,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.ResourceHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/settings") { http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/status") { + http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/templates") { http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/upload") { diff --git a/api/http/handler/settings.go b/api/http/handler/settings.go index cfae1dc30..26e1cfe92 100644 --- a/api/http/handler/settings.go +++ b/api/http/handler/settings.go @@ -1,6 +1,9 @@ package handler import ( + "encoding/json" + + "github.com/asaskevich/govalidator" "github.com/portainer/portainer" httperror "github.com/portainer/portainer/http/error" "github.com/portainer/portainer/http/security" @@ -12,32 +15,69 @@ import ( "github.com/gorilla/mux" ) -// SettingsHandler represents an HTTP API handler for managing settings. +// SettingsHandler represents an HTTP API handler for managing Settings. type SettingsHandler struct { *mux.Router - Logger *log.Logger - settings *portainer.Settings + Logger *log.Logger + SettingsService portainer.SettingsService } -// NewSettingsHandler returns a new instance of SettingsHandler. -func NewSettingsHandler(bouncer *security.RequestBouncer, settings *portainer.Settings) *SettingsHandler { +// NewSettingsHandler returns a new instance of OldSettingsHandler. +func NewSettingsHandler(bouncer *security.RequestBouncer) *SettingsHandler { h := &SettingsHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - settings: settings, + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), } h.Handle("/settings", - bouncer.PublicAccess(http.HandlerFunc(h.handleGetSettings))) + bouncer.PublicAccess(http.HandlerFunc(h.handleGetSettings))).Methods(http.MethodGet) + h.Handle("/settings", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettings))).Methods(http.MethodPut) return h } // handleGetSettings handles GET requests on /settings func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodGet}) + settings, err := handler.SettingsService.Settings() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - encodeJSON(w, handler.settings, handler.Logger) + encodeJSON(w, settings, handler.Logger) + return +} + +// handlePutSettings handles PUT requests on /settings +func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http.Request) { + var req putSettingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + settings := &portainer.Settings{ + TemplatesURL: req.TemplatesURL, + LogoURL: req.LogoURL, + BlackListedLabels: req.BlackListedLabels, + DisplayExternalContributors: req.DisplayExternalContributors, + } + + err = handler.SettingsService.StoreSettings(settings) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + } +} + +type putSettingsRequest struct { + TemplatesURL string `valid:"required"` + LogoURL string `valid:""` + BlackListedLabels []portainer.Pair `valid:""` + DisplayExternalContributors bool `valid:""` } diff --git a/api/http/handler/status.go b/api/http/handler/status.go new file mode 100644 index 000000000..6bae3c8a7 --- /dev/null +++ b/api/http/handler/status.go @@ -0,0 +1,38 @@ +package handler + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/security" + + "log" + "net/http" + "os" + + "github.com/gorilla/mux" +) + +// StatusHandler represents an HTTP API handler for managing Status. +type StatusHandler struct { + *mux.Router + Logger *log.Logger + Status *portainer.Status +} + +// NewStatusHandler returns a new instance of StatusHandler. +func NewStatusHandler(bouncer *security.RequestBouncer, status *portainer.Status) *StatusHandler { + h := &StatusHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + Status: status, + } + h.Handle("/status", + bouncer.PublicAccess(http.HandlerFunc(h.handleGetStatus))).Methods(http.MethodGet) + + return h +} + +// handleGetStatus handles GET requests on /status +func (handler *StatusHandler) handleGetStatus(w http.ResponseWriter, r *http.Request) { + encodeJSON(w, handler.Status, handler.Logger) + return +} diff --git a/api/http/handler/templates.go b/api/http/handler/templates.go index 9383e407e..516fc892e 100644 --- a/api/http/handler/templates.go +++ b/api/http/handler/templates.go @@ -7,6 +7,7 @@ import ( "os" "github.com/gorilla/mux" + "github.com/portainer/portainer" httperror "github.com/portainer/portainer/http/error" "github.com/portainer/portainer/http/security" ) @@ -14,8 +15,8 @@ import ( // TemplatesHandler represents an HTTP API handler for managing templates. type TemplatesHandler struct { *mux.Router - Logger *log.Logger - containerTemplatesURL string + Logger *log.Logger + SettingsService portainer.SettingsService } const ( @@ -23,11 +24,10 @@ const ( ) // NewTemplatesHandler returns a new instance of TemplatesHandler. -func NewTemplatesHandler(bouncer *security.RequestBouncer, containerTemplatesURL string) *TemplatesHandler { +func NewTemplatesHandler(bouncer *security.RequestBouncer) *TemplatesHandler { h := &TemplatesHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - containerTemplatesURL: containerTemplatesURL, + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), } h.Handle("/templates", bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates))) @@ -49,7 +49,12 @@ func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *ht var templatesURL string if key == "containers" { - templatesURL = handler.containerTemplatesURL + settings, err := handler.SettingsService.Settings() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + templatesURL = settings.TemplatesURL } else if key == "linuxserver.io" { templatesURL = containerTemplatesURLLinuxServerIo } else { diff --git a/api/http/proxy/containers.go b/api/http/proxy/containers.go index 909a7dc0d..964dab1f6 100644 --- a/api/http/proxy/containers.go +++ b/api/http/proxy/containers.go @@ -15,7 +15,7 @@ const ( // containerListOperation extracts the response as a JSON object, loop through the containers array // decorate and/or filter the containers based on resource controls before rewriting the response -func containerListOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { +func containerListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { var err error // ContainerList response is a JSON array // https://docs.docker.com/engine/api/v1.28/#operation/ContainerList @@ -24,22 +24,30 @@ func containerListOperation(request *http.Request, response *http.Response, oper return err } - if operationContext.isAdmin { - responseArray, err = decorateContainerList(responseArray, operationContext.resourceControls) + if executor.operationContext.isAdmin { + responseArray, err = decorateContainerList(responseArray, executor.operationContext.resourceControls) } else { - responseArray, err = filterContainerList(responseArray, operationContext.resourceControls, operationContext.userID, operationContext.userTeamIDs) + responseArray, err = filterContainerList(responseArray, executor.operationContext.resourceControls, + executor.operationContext.userID, executor.operationContext.userTeamIDs) } if err != nil { return err } + if executor.labelBlackList != nil { + responseArray, err = filterContainersWithBlackListedLabels(responseArray, executor.labelBlackList) + if err != nil { + return err + } + } + return rewriteResponse(response, responseArray, http.StatusOK) } // containerInspectOperation extracts the response as a JSON object, verify that the user // has access to the container based on resource control (check are done based on the containerID and optional Swarm service ID) // and either rewrite an access denied response or a decorated container. -func containerInspectOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { +func containerInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { // ContainerInspect response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect responseObject, err := getResponseAsJSONOBject(response) @@ -52,9 +60,10 @@ func containerInspectOperation(request *http.Request, response *http.Response, o } containerID := responseObject[containerIdentifier].(string) - resourceControl := getResourceControlByResourceID(containerID, operationContext.resourceControls) + resourceControl := getResourceControlByResourceID(containerID, executor.operationContext.resourceControls) if resourceControl != nil { - if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) { + if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, + executor.operationContext.userTeamIDs, resourceControl) { responseObject = decorateObject(responseObject, resourceControl) } else { return rewriteAccessDeniedResponse(response) @@ -64,9 +73,10 @@ func containerInspectOperation(request *http.Request, response *http.Response, o containerLabels := extractContainerLabelsFromContainerInspectObject(responseObject) if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil { serviceID := containerLabels[containerLabelForServiceIdentifier].(string) - resourceControl := getResourceControlByResourceID(serviceID, operationContext.resourceControls) + resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls) if resourceControl != nil { - if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) { + if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, + executor.operationContext.userTeamIDs, resourceControl) { responseObject = decorateObject(responseObject, resourceControl) } else { return rewriteAccessDeniedResponse(response) diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index 96d2239d2..3e0d71445 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -13,6 +13,7 @@ import ( type proxyFactory struct { ResourceControlService portainer.ResourceControlService TeamMembershipService portainer.TeamMembershipService + SettingsService portainer.SettingsService } func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { @@ -37,6 +38,7 @@ func (factory *proxyFactory) newSocketProxy(path string) http.Handler { transport := &proxyTransport{ ResourceControlService: factory.ResourceControlService, TeamMembershipService: factory.TeamMembershipService, + SettingsService: factory.SettingsService, dockerTransport: newSocketTransport(path), } proxy.Transport = transport @@ -48,6 +50,7 @@ func (factory *proxyFactory) createReverseProxy(u *url.URL) *httputil.ReversePro transport := &proxyTransport{ ResourceControlService: factory.ResourceControlService, TeamMembershipService: factory.TeamMembershipService, + SettingsService: factory.SettingsService, dockerTransport: newHTTPTransport(), } proxy.Transport = transport diff --git a/api/http/proxy/filter.go b/api/http/proxy/filter.go index 02de684b3..0e66ab4fd 100644 --- a/api/http/proxy/filter.go +++ b/api/http/proxy/filter.go @@ -65,6 +65,27 @@ func filterContainerList(containerData []interface{}, resourceControls []portain return filteredContainerData, nil } +// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains +// any labels in the labels black list. +func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) { + filteredContainerData := make([]interface{}, 0) + + for _, container := range containerData { + containerObject := container.(map[string]interface{}) + + containerLabels := extractContainerLabelsFromContainerListObject(containerObject) + if containerLabels != nil { + if !containerHasBlackListedLabel(containerLabels, labelBlackList) { + filteredContainerData = append(filteredContainerData, containerObject) + } + } else { + filteredContainerData = append(filteredContainerData, containerObject) + } + } + + return filteredContainerData, nil +} + // filterServiceList loops through all services, filters services without any resource control (public resources) or with // any resource control giving access to the user (these services will be decorated). // Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index b90596e10..8710c7a44 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -15,12 +15,13 @@ type Manager struct { } // NewManager initializes a new proxy Service -func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService) *Manager { +func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService, settingsService portainer.SettingsService) *Manager { return &Manager{ proxies: cmap.New(), proxyFactory: &proxyFactory{ ResourceControlService: resourceControlService, TeamMembershipService: teamMembershipService, + SettingsService: settingsService, }, } } diff --git a/api/http/proxy/service.go b/api/http/proxy/service.go index fcf604a84..317da9ebc 100644 --- a/api/http/proxy/service.go +++ b/api/http/proxy/service.go @@ -14,7 +14,7 @@ const ( // serviceListOperation extracts the response as a JSON array, loop through the service array // decorate and/or filter the services based on resource controls before rewriting the response -func serviceListOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { +func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { var err error // ServiceList response is a JSON array // https://docs.docker.com/engine/api/v1.28/#operation/ServiceList @@ -23,10 +23,10 @@ func serviceListOperation(request *http.Request, response *http.Response, operat return err } - if operationContext.isAdmin { - responseArray, err = decorateServiceList(responseArray, operationContext.resourceControls) + if executor.operationContext.isAdmin { + responseArray, err = decorateServiceList(responseArray, executor.operationContext.resourceControls) } else { - responseArray, err = filterServiceList(responseArray, operationContext.resourceControls, operationContext.userID, operationContext.userTeamIDs) + responseArray, err = filterServiceList(responseArray, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs) } if err != nil { return err @@ -38,7 +38,7 @@ func serviceListOperation(request *http.Request, response *http.Response, operat // serviceInspectOperation extracts the response as a JSON object, verify that the user // has access to the service based on resource control and either rewrite an access denied response // or a decorated service. -func serviceInspectOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { +func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { // ServiceInspect response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect responseObject, err := getResponseAsJSONOBject(response) @@ -51,9 +51,9 @@ func serviceInspectOperation(request *http.Request, response *http.Response, ope } serviceID := responseObject[serviceIdentifier].(string) - resourceControl := getResourceControlByResourceID(serviceID, operationContext.resourceControls) + resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls) if resourceControl != nil { - if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) { + if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) { responseObject = decorateObject(responseObject, resourceControl) } else { return rewriteAccessDeniedResponse(response) diff --git a/api/http/proxy/transport.go b/api/http/proxy/transport.go index ca18ebcb5..29c1d6c72 100644 --- a/api/http/proxy/transport.go +++ b/api/http/proxy/transport.go @@ -15,6 +15,7 @@ type ( dockerTransport *http.Transport ResourceControlService portainer.ResourceControlService TeamMembershipService portainer.TeamMembershipService + SettingsService portainer.SettingsService } restrictedOperationContext struct { isAdmin bool @@ -22,7 +23,11 @@ type ( userTeamIDs []portainer.TeamID resourceControls []portainer.ResourceControl } - restrictedOperationRequest func(*http.Request, *http.Response, *restrictedOperationContext) error + operationExecutor struct { + operationContext *restrictedOperationContext + labelBlackList []portainer.Pair + } + restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error ) func newSocketTransport(socketPath string) *http.Transport { @@ -60,7 +65,6 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon } func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Response, error) { - // return p.executeDockerRequest(request) switch requestPath := request.URL.Path; requestPath { case "/containers/create": return p.executeDockerRequest(request) @@ -69,7 +73,7 @@ func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Res return p.administratorOperation(request) case "/containers/json": - return p.rewriteOperation(request, containerListOperation) + return p.rewriteOperationWithLabelFiltering(request, containerListOperation) default: // This section assumes /containers/** @@ -96,9 +100,6 @@ func (p *proxyTransport) proxyServiceRequest(request *http.Request) (*http.Respo case "/services/create": return p.executeDockerRequest(request) - case "/volumes/prune": - return p.administratorOperation(request) - case "/services": return p.rewriteOperation(request, serviceListOperation) @@ -177,9 +178,69 @@ func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID s return p.executeDockerRequest(request) } +// rewriteOperation will create a new operation context with data that will be used +// to decorate the original request's response as well as retrieve all the black listed labels +// to filter the resources. +func (p *proxyTransport) rewriteOperationWithLabelFiltering(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) { + operationContext, err := p.createOperationContext(request) + if err != nil { + return nil, err + } + + settings, err := p.SettingsService.Settings() + if err != nil { + return nil, err + } + + executor := &operationExecutor{ + operationContext: operationContext, + labelBlackList: settings.BlackListedLabels, + } + + return p.executeRequestAndRewriteResponse(request, operation, executor) +} + // rewriteOperation will create a new operation context with data that will be used // to decorate the original request's response. func (p *proxyTransport) rewriteOperation(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) { + operationContext, err := p.createOperationContext(request) + if err != nil { + return nil, err + } + + executor := &operationExecutor{ + operationContext: operationContext, + } + + return p.executeRequestAndRewriteResponse(request, operation, executor) +} + +func (p *proxyTransport) executeRequestAndRewriteResponse(request *http.Request, operation restrictedOperationRequest, executor *operationExecutor) (*http.Response, error) { + response, err := p.executeDockerRequest(request) + if err != nil { + return response, err + } + + err = operation(request, response, executor) + return response, err +} + +// administratorOperation ensures that the user has administrator privileges +// before executing the original request. +func (p *proxyTransport) administratorOperation(request *http.Request) (*http.Response, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + if tokenData.Role != portainer.AdministratorRole { + return writeAccessDeniedResponse() + } + + return p.executeDockerRequest(request) +} + +func (p *proxyTransport) createOperationContext(request *http.Request) (*restrictedOperationContext, error) { var err error tokenData, err := security.RetrieveTokenData(request) if err != nil { @@ -212,26 +273,5 @@ func (p *proxyTransport) rewriteOperation(request *http.Request, operation restr operationContext.userTeamIDs = userTeamIDs } - response, err := p.executeDockerRequest(request) - if err != nil { - return response, err - } - - err = operation(request, response, operationContext) - return response, err -} - -// administratorOperation ensures that the user has administrator privileges -// before executing the original request. -func (p *proxyTransport) administratorOperation(request *http.Request) (*http.Response, error) { - tokenData, err := security.RetrieveTokenData(request) - if err != nil { - return nil, err - } - - if tokenData.Role != portainer.AdministratorRole { - return writeAccessDeniedResponse() - } - - return p.executeDockerRequest(request) + return operationContext, nil } diff --git a/api/http/proxy/utils.go b/api/http/proxy/utils.go index 36afce97d..7f85a624a 100644 --- a/api/http/proxy/utils.go +++ b/api/http/proxy/utils.go @@ -15,3 +15,18 @@ func getResourceControlByResourceID(resourceID string, resourceControls []portai } return nil } + +func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool { + for key, value := range containerLabels { + labelName := key + labelValue := value.(string) + + for _, blackListedLabel := range labelBlackList { + if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue { + return true + } + } + } + + return false +} diff --git a/api/http/proxy/volumes.go b/api/http/proxy/volumes.go index c39805de8..ce451c0e2 100644 --- a/api/http/proxy/volumes.go +++ b/api/http/proxy/volumes.go @@ -14,7 +14,7 @@ const ( // volumeListOperation extracts the response as a JSON object, loop through the volume array // decorate and/or filter the volumes based on resource controls before rewriting the response -func volumeListOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { +func volumeListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { var err error // VolumeList response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/VolumeList @@ -28,10 +28,10 @@ func volumeListOperation(request *http.Request, response *http.Response, operati if responseObject["Volumes"] != nil { volumeData := responseObject["Volumes"].([]interface{}) - if operationContext.isAdmin { - volumeData, err = decorateVolumeList(volumeData, operationContext.resourceControls) + if executor.operationContext.isAdmin { + volumeData, err = decorateVolumeList(volumeData, executor.operationContext.resourceControls) } else { - volumeData, err = filterVolumeList(volumeData, operationContext.resourceControls, operationContext.userID, operationContext.userTeamIDs) + volumeData, err = filterVolumeList(volumeData, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs) } if err != nil { return err @@ -47,7 +47,7 @@ func volumeListOperation(request *http.Request, response *http.Response, operati // volumeInspectOperation extracts the response as a JSON object, verify that the user // has access to the volume based on resource control and either rewrite an access denied response // or a decorated volume. -func volumeInspectOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { +func volumeInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { // VolumeInspect response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect responseObject, err := getResponseAsJSONOBject(response) @@ -60,9 +60,9 @@ func volumeInspectOperation(request *http.Request, response *http.Response, oper } volumeID := responseObject[volumeIdentifier].(string) - resourceControl := getResourceControlByResourceID(volumeID, operationContext.resourceControls) + resourceControl := getResourceControlByResourceID(volumeID, executor.operationContext.resourceControls) if resourceControl != nil { - if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) { + if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) { responseObject = decorateObject(responseObject, resourceControl) } else { return rewriteAccessDeniedResponse(response) diff --git a/api/http/server.go b/api/http/server.go index 11d126d33..c183e81a5 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -15,16 +15,16 @@ type Server struct { AssetsPath string AuthDisabled bool EndpointManagement bool + Status *portainer.Status UserService portainer.UserService TeamService portainer.TeamService TeamMembershipService portainer.TeamMembershipService EndpointService portainer.EndpointService ResourceControlService portainer.ResourceControlService + SettingsService portainer.SettingsService CryptoService portainer.CryptoService JWTService portainer.JWTService FileService portainer.FileService - Settings *portainer.Settings - TemplatesURL string Handler *handler.Handler SSL bool SSLCert string @@ -34,7 +34,7 @@ type Server struct { // Start starts the HTTP server func (server *Server) Start() error { requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled) - proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService) + proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService) var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled) authHandler.UserService = server.UserService @@ -51,8 +51,11 @@ func (server *Server) Start() error { teamHandler.TeamMembershipService = server.TeamMembershipService var teamMembershipHandler = handler.NewTeamMembershipHandler(requestBouncer) teamMembershipHandler.TeamMembershipService = server.TeamMembershipService - var settingsHandler = handler.NewSettingsHandler(requestBouncer, server.Settings) - var templatesHandler = handler.NewTemplatesHandler(requestBouncer, server.TemplatesURL) + var statusHandler = handler.NewStatusHandler(requestBouncer, server.Status) + var settingsHandler = handler.NewSettingsHandler(requestBouncer) + settingsHandler.SettingsService = server.SettingsService + var templatesHandler = handler.NewTemplatesHandler(requestBouncer) + templatesHandler.SettingsService = server.SettingsService var dockerHandler = handler.NewDockerHandler(requestBouncer) dockerHandler.EndpointService = server.EndpointService dockerHandler.TeamMembershipService = server.TeamMembershipService @@ -77,6 +80,7 @@ func (server *Server) Start() error { EndpointHandler: endpointHandler, ResourceHandler: resourceHandler, SettingsHandler: settingsHandler, + StatusHandler: statusHandler, TemplatesHandler: templatesHandler, DockerHandler: dockerHandler, WebSocketHandler: websocketHandler, diff --git a/api/portainer.go b/api/portainer.go index c0315ecaf..7051588dd 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -17,9 +17,6 @@ type ( ExternalEndpoints *string SyncInterval *string Endpoint *string - Labels *[]Pair - Logo *string - Templates *string NoAuth *bool NoAnalytics *bool TLSVerify *bool @@ -30,15 +27,26 @@ type ( SSLCert *string SSLKey *string AdminPassword *string + // Deprecated fields + Logo *string + Templates *string + Labels *[]Pair } - // Settings represents Portainer settings. + // Status represents the application status. + Status struct { + Authentication bool `json:"Authentication"` + EndpointManagement bool `json:"EndpointManagement"` + Analytics bool `json:"Analytics"` + Version string `json:"Version"` + } + + // Settings represents the application settings. Settings struct { - HiddenLabels []Pair `json:"hiddenLabels"` - Logo string `json:"logo"` - Authentication bool `json:"authentication"` - Analytics bool `json:"analytics"` - EndpointManagement bool `json:"endpointManagement"` + TemplatesURL string `json:"TemplatesURL"` + LogoURL string `json:"LogoURL"` + BlackListedLabels []Pair `json:"BlackListedLabels"` + DisplayExternalContributors bool `json:"DisplayExternalContributors"` } // User represents a user account. @@ -209,6 +217,12 @@ type ( Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error } + // SettingsService represents a service for managing application settings. + SettingsService interface { + Settings() (*Settings, error) + StoreSettings(settings *Settings) error + } + // VersionService represents a service for managing version data. VersionService interface { DBVersion() (int, error) @@ -255,6 +269,8 @@ const ( APIVersion = "1.13.1" // DBVersion is the version number of the Portainer database. DBVersion = 2 + // DefaultTemplatesURL represents the default URL for the templates definitions. + DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" ) const ( diff --git a/app/app.js b/app/app.js index 513743c75..ddb5bd587 100644 --- a/app/app.js +++ b/app/app.js @@ -57,6 +57,7 @@ angular.module('portainer', [ 'templates', 'user', 'users', + 'userSettings', 'volume', 'volumes']) .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', '$uibTooltipProvider', '$compileProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider, $uibTooltipProvider, $compileProvider) { @@ -594,6 +595,19 @@ angular.module('portainer', [ } } }) + .state('userSettings', { + url: '/userSettings/', + views: { + 'content@': { + templateUrl: 'app/components/userSettings/userSettings.html', + controller: 'UserSettingsController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('teams', { url: '/teams/', views: { @@ -662,9 +676,11 @@ angular.module('portainer', [ }]) // This is your docker url that the api will use to make requests // You need to set this to the api endpoint without the port i.e. http://192.168.1.9 - .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243 + // .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243 .constant('DOCKER_ENDPOINT', 'api/docker') - .constant('CONFIG_ENDPOINT', 'api/settings') + .constant('CONFIG_ENDPOINT', 'api/old_settings') + .constant('SETTINGS_ENDPOINT', 'api/settings') + .constant('STATUS_ENDPOINT', 'api/status') .constant('AUTH_ENDPOINT', 'api/auth') .constant('USERS_ENDPOINT', 'api/users') .constant('TEAMS_ENDPOINT', 'api/teams') @@ -672,5 +688,6 @@ angular.module('portainer', [ .constant('RESOURCE_CONTROL_ENDPOINT', 'api/resource_controls') .constant('ENDPOINTS_ENDPOINT', 'api/endpoints') .constant('TEMPLATES_ENDPOINT', 'api/templates') - .constant('PAGINATION_MAX_ITEMS', 10) - .constant('UI_VERSION', 'v1.13.1'); + .constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json') + .constant('PAGINATION_MAX_ITEMS', 10); + // .constant('UI_VERSION', 'v1.13.1'); diff --git a/app/components/auth/authController.js b/app/components/auth/authController.js index d6edeff95..14ef479d7 100644 --- a/app/components/auth/authController.js +++ b/app/components/auth/authController.js @@ -1,6 +1,6 @@ angular.module('auth', []) -.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Config', 'Authentication', 'Users', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', -function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Authentication, Users, EndpointService, StateManager, EndpointProvider, Notifications) { +.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Authentication', 'Users', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', +function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Authentication, Users, EndpointService, StateManager, EndpointProvider, Notifications) { $scope.authData = { username: 'admin', @@ -13,6 +13,8 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au error: false }; + $scope.logo = StateManager.getState().application.logo; + if (!$scope.applicationState.application.authentication) { EndpointService.endpoints() .then(function success(data) { @@ -59,10 +61,6 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au $state.go('dashboard'); } - Config.$promise.then(function (c) { - $scope.logo = c.logo; - }); - $scope.createAdminUser = function() { var password = $sanitize($scope.initPasswordData.password); Users.initAdminUser({password: password}, function (d) { diff --git a/app/components/containerConsole/containerConsoleController.js b/app/components/containerConsole/containerConsoleController.js index d00881c6f..b9da6c20f 100644 --- a/app/components/containerConsole/containerConsoleController.js +++ b/app/components/containerConsole/containerConsoleController.js @@ -1,6 +1,6 @@ angular.module('containerConsole', []) -.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Settings', 'Container', 'Image', 'Exec', '$timeout', 'EndpointProvider', 'Notifications', -function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, EndpointProvider, Notifications) { +.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Container', 'Image', 'Exec', '$timeout', 'EndpointProvider', 'Notifications', +function ($scope, $stateParams, Container, Image, Exec, $timeout, EndpointProvider, Notifications) { $scope.state = {}; $scope.state.loaded = false; $scope.state.connected = false; diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index 01c50acb9..b3cf31d85 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -1,9 +1,9 @@ angular.module('containers', []) - .controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerService', 'ContainerHelper', 'Info', 'Settings', 'Notifications', 'Config', 'Pagination', 'EntityListService', 'ModalService', 'ResourceControlService', 'EndpointProvider', - function ($q, $scope, $filter, Container, ContainerService, ContainerHelper, Info, Settings, Notifications, Config, Pagination, EntityListService, ModalService, ResourceControlService, EndpointProvider) { + .controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerService', 'ContainerHelper', 'Info', 'Notifications', 'Pagination', 'EntityListService', 'ModalService', 'ResourceControlService', 'EndpointProvider', + function ($q, $scope, $filter, Container, ContainerService, ContainerHelper, Info, Notifications, Pagination, EntityListService, ModalService, ResourceControlService, EndpointProvider) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('containers'); - $scope.state.displayAll = Settings.displayAll; + $scope.state.displayAll = true; $scope.state.displayIP = false; $scope.sortType = 'State'; $scope.sortReverse = false; @@ -25,9 +25,6 @@ angular.module('containers', []) $scope.state.selectedItemCount = 0; Container.query(data, function (d) { var containers = d; - if ($scope.containersToHideLabels) { - containers = ContainerHelper.hideContainers(d, $scope.containersToHideLabels); - } $scope.containers = containers.map(function (container) { var model = new ContainerViewModel(container); model.Status = $filter('containerstatus')(model.Status); @@ -59,7 +56,7 @@ angular.module('containers', []) counter = counter - 1; if (counter === 0) { $('#loadContainersSpinner').hide(); - update({all: Settings.displayAll ? 1 : 0}); + update({all: $scope.state.displayAll ? 1 : 0}); } }; angular.forEach(items, function (c) { @@ -134,8 +131,7 @@ angular.module('containers', []) }; $scope.toggleGetAll = function () { - Settings.displayAll = $scope.state.displayAll; - update({all: Settings.displayAll ? 1 : 0}); + update({all: $scope.state.displayAll ? 1 : 0}); }; $scope.startAction = function () { @@ -206,15 +202,16 @@ angular.module('containers', []) return swarm_hosts; } - Config.$promise.then(function (c) { - $scope.containersToHideLabels = c.hiddenLabels; + function initView(){ if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM') { Info.get({}, function (d) { $scope.swarm_hosts = retrieveSwarmHostsInfo(d); - update({all: Settings.displayAll ? 1 : 0}); + update({all: $scope.state.displayAll ? 1 : 0}); }); } else { - update({all: Settings.displayAll ? 1 : 0}); + update({all: $scope.state.displayAll ? 1 : 0}); } - }); + } + + initView(); }]); diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index 3f8bfe73a..f5375bf1e 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -1,8 +1,8 @@ // @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. // See app/components/templates/templatesController.js as a reference. angular.module('createContainer', []) -.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'ControllerDataPipeline', 'FormValidator', -function ($q, $scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, ControllerDataPipeline, FormValidator) { +.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'ControllerDataPipeline', 'FormValidator', +function ($q, $scope, $state, $stateParams, $filter, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, ControllerDataPipeline, FormValidator) { $scope.formValues = { alwaysPull: true, @@ -233,47 +233,41 @@ function ($q, $scope, $state, $stateParams, $filter, Config, Info, Container, Co } function initView() { - Config.$promise.then(function (c) { - var containersToHideLabels = c.hiddenLabels; - - Volume.query({}, function (d) { - $scope.availableVolumes = d.Volumes; - }, function (e) { - Notifications.error('Failure', e, 'Unable to retrieve volumes'); - }); - - Network.query({}, function (d) { - var networks = d; - if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { - networks = d.filter(function (network) { - if (network.Scope === 'global') { - return network; - } - }); - $scope.globalNetworkCount = networks.length; - networks.push({Name: 'bridge'}); - networks.push({Name: 'host'}); - networks.push({Name: 'none'}); - } - networks.push({Name: 'container'}); - $scope.availableNetworks = networks; - if (!_.find(networks, {'Name': 'bridge'})) { - $scope.config.HostConfig.NetworkMode = 'nat'; - } - }, function (e) { - Notifications.error('Failure', e, 'Unable to retrieve networks'); - }); - - Container.query({}, function (d) { - var containers = d; - if (containersToHideLabels) { - containers = ContainerHelper.hideContainers(d, containersToHideLabels); - } - $scope.runningContainers = containers; - }, function(e) { - Notifications.error('Failure', e, 'Unable to retrieve running containers'); - }); + Volume.query({}, function (d) { + $scope.availableVolumes = d.Volumes; + }, function (e) { + Notifications.error('Failure', e, 'Unable to retrieve volumes'); }); + + Network.query({}, function (d) { + var networks = d; + if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { + networks = d.filter(function (network) { + if (network.Scope === 'global') { + return network; + } + }); + $scope.globalNetworkCount = networks.length; + networks.push({Name: 'bridge'}); + networks.push({Name: 'host'}); + networks.push({Name: 'none'}); + } + networks.push({Name: 'container'}); + $scope.availableNetworks = networks; + if (!_.find(networks, {'Name': 'bridge'})) { + $scope.config.HostConfig.NetworkMode = 'nat'; + } + }, function (e) { + Notifications.error('Failure', e, 'Unable to retrieve networks'); + }); + + Container.query({}, function (d) { + var containers = d; + $scope.runningContainers = containers; + }, function(e) { + Notifications.error('Failure', e, 'Unable to retrieve running containers'); + }); + } function validateForm(accessControlData, isAdmin) { @@ -327,5 +321,4 @@ function ($q, $scope, $state, $stateParams, $filter, Config, Info, Container, Co } initView(); - }]); diff --git a/app/components/dashboard/dashboardController.js b/app/components/dashboard/dashboardController.js index 89f38ce00..9b01ceb56 100644 --- a/app/components/dashboard/dashboardController.js +++ b/app/components/dashboard/dashboardController.js @@ -1,6 +1,6 @@ angular.module('dashboard', []) -.controller('DashboardController', ['$scope', '$q', 'Config', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'Info', 'Notifications', -function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume, Info, Notifications) { +.controller('DashboardController', ['$scope', '$q', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'Info', 'Notifications', +function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, Info, Notifications) { $scope.containerData = { total: 0 @@ -15,14 +15,10 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume total: 0 }; - function prepareContainerData(d, containersToHideLabels) { + function prepareContainerData(d) { var running = 0; var stopped = 0; - var containers = d; - if (containersToHideLabels) { - containers = ContainerHelper.hideContainers(d, containersToHideLabels); - } for (var i = 0; i < containers.length; i++) { var item = containers[i]; @@ -65,7 +61,7 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume $scope.infoData = info; } - function fetchDashboardData(containersToHideLabels) { + function initView() { $('#loadingViewSpinner').show(); $q.all([ Container.query({all: 1}).$promise, @@ -74,7 +70,7 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume Network.query({}).$promise, Info.get({}).$promise ]).then(function (d) { - prepareContainerData(d[0], containersToHideLabels); + prepareContainerData(d[0]); prepareImageData(d[1]); prepareVolumeData(d[2]); prepareNetworkData(d[3]); @@ -86,7 +82,5 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume }); } - Config.$promise.then(function (c) { - fetchDashboardData(c.hiddenLabels); - }); + initView(); }]); diff --git a/app/components/images/imagesController.js b/app/components/images/imagesController.js index 70918b82d..f5ecbc618 100644 --- a/app/components/images/imagesController.js +++ b/app/components/images/imagesController.js @@ -1,6 +1,6 @@ angular.module('images', []) -.controller('ImagesController', ['$scope', '$state', 'Config', 'ImageService', 'Notifications', 'Pagination', 'ModalService', -function ($scope, $state, Config, ImageService, Notifications, Pagination, ModalService) { +.controller('ImagesController', ['$scope', '$state', 'ImageService', 'Notifications', 'Pagination', 'ModalService', +function ($scope, $state, ImageService, Notifications, Pagination, ModalService) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('images'); $scope.sortType = 'RepoTags'; diff --git a/app/components/network/networkController.js b/app/components/network/networkController.js index f6b44df84..291b6ca9e 100644 --- a/app/components/network/networkController.js +++ b/app/components/network/networkController.js @@ -1,6 +1,6 @@ angular.module('network', []) -.controller('NetworkController', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Network', 'Container', 'ContainerHelper', 'Notifications', -function ($scope, $state, $stateParams, $filter, Config, Network, Container, ContainerHelper, Notifications) { +.controller('NetworkController', ['$scope', '$state', '$stateParams', '$filter', 'Network', 'Container', 'ContainerHelper', 'Notifications', +function ($scope, $state, $stateParams, $filter, Network, Container, ContainerHelper, Notifications) { $scope.removeNetwork = function removeNetwork(networkId) { $('#loadingViewSpinner').show(); @@ -36,21 +36,7 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con }); }; - function getNetwork() { - $('#loadingViewSpinner').show(); - Network.get({id: $stateParams.id}, function success(data) { - $scope.network = data; - getContainersInNetwork(data); - }, function error(err) { - $('#loadingViewSpinner').hide(); - Notifications.error('Failure', err, 'Unable to retrieve network info'); - }); - } - function filterContainersInNetwork(network, containers) { - if ($scope.containersToHideLabels) { - containers = ContainerHelper.hideContainers(containers, $scope.containersToHideLabels); - } var containersInNetwork = []; containers.forEach(function(container) { var containerInNetwork = network.Containers[container.Id]; @@ -93,8 +79,16 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con } } - Config.$promise.then(function (c) { - $scope.containersToHideLabels = c.hiddenLabels; - getNetwork(); - }); + function initView() { + $('#loadingViewSpinner').show(); + Network.get({id: $stateParams.id}, function success(data) { + $scope.network = data; + getContainersInNetwork(data); + }, function error(err) { + $('#loadingViewSpinner').hide(); + Notifications.error('Failure', err, 'Unable to retrieve network info'); + }); + } + + initView(); }]); diff --git a/app/components/networks/networksController.js b/app/components/networks/networksController.js index 2e8e6e9ed..8468a1283 100644 --- a/app/components/networks/networksController.js +++ b/app/components/networks/networksController.js @@ -1,6 +1,6 @@ angular.module('networks', []) -.controller('NetworksController', ['$scope', '$state', 'Network', 'Config', 'Notifications', 'Pagination', -function ($scope, $state, Network, Config, Notifications, Pagination) { +.controller('NetworksController', ['$scope', '$state', 'Network', 'Notifications', 'Pagination', +function ($scope, $state, Network, Notifications, Pagination) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('networks'); $scope.state.selectedItemCount = 0; @@ -97,7 +97,7 @@ function ($scope, $state, Network, Config, Notifications, Pagination) { }); }; - function fetchNetworks() { + function initView() { $('#loadNetworksSpinner').show(); Network.query({}, function (d) { $scope.networks = d; @@ -109,7 +109,5 @@ function ($scope, $state, Network, Config, Notifications, Pagination) { }); } - Config.$promise.then(function (c) { - fetchNetworks(); - }); + initView(); }]); diff --git a/app/components/settings/settings.html b/app/components/settings/settings.html index f0b235b46..d3e9dcede 100644 --- a/app/components/settings/settings.html +++ b/app/components/settings/settings.html @@ -1,66 +1,153 @@ + Settings
-
+
- + -
- + + +
+ Logo +
- -
-
- - +
+ + +
+
+
+
+ + You can specify the URL to your logo here. For an optimal display, logo dimensions should be 155px by 55px. + +
+
+ +
+
- -
+ + +
+ App Templates +
+
- - Current password is not valid + +
- -
- -
-
- - +
+
+ + You can specify the URL to your own template definitions file here. See Portainer documentation for more details. + +
+
+ +
+
-
- - Your new password must be at least 8 characters long + +
- -
- -
-
- - - -
-
-
- + +
- + + +
+ + + + +
+
+ +
+
+ + + +
+
+ + You can hide containers with specific labels from Portainer UI. You need to specify the label name and value. + +
+
+ +
+ +
+ +
+ +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
NameValue
{{ label.name }}{{ label.value }}
No filtered containers labels.
Loading...
+
+
+
diff --git a/app/components/settings/settingsController.js b/app/components/settings/settingsController.js index 602a5612f..f74f7def7 100644 --- a/app/components/settings/settingsController.js +++ b/app/components/settings/settingsController.js @@ -1,29 +1,94 @@ angular.module('settings', []) -.controller('SettingsController', ['$scope', '$state', '$sanitize', 'Authentication', 'UserService', 'Notifications', -function ($scope, $state, $sanitize, Authentication, UserService, Notifications) { +.controller('SettingsController', ['$scope', '$state', 'Notifications', 'SettingsService', 'StateManager', 'DEFAULT_TEMPLATES_URL', +function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_TEMPLATES_URL) { + $scope.formValues = { - currentPassword: '', - newPassword: '', - confirmPassword: '' + customLogo: false, + customTemplates: false, + externalContributions: false, + labelName: '', + labelValue: '' }; - $scope.updatePassword = function() { - $scope.invalidPassword = false; - var userID = Authentication.getUserDetails().ID; - var currentPassword = $sanitize($scope.formValues.currentPassword); - var newPassword = $sanitize($scope.formValues.newPassword); + $scope.removeFilteredContainerLabel = function(index) { + var settings = $scope.settings; + settings.BlackListedLabels.splice(index, 1); - UserService.updateUserPassword(userID, currentPassword, newPassword) - .then(function success() { - Notifications.success('Success', 'Password successfully updated'); - $state.reload(); + updateSettings(settings, false); + }; + + $scope.addFilteredContainerLabel = function() { + var settings = $scope.settings; + var label = { + name: $scope.formValues.labelName, + value: $scope.formValues.labelValue + }; + settings.BlackListedLabels.push(label); + + updateSettings(settings, true); + }; + + $scope.saveApplicationSettings = function() { + var settings = $scope.settings; + + if (!$scope.formValues.customLogo) { + settings.LogoURL = ''; + } + + if (!$scope.formValues.customTemplates) { + settings.TemplatesURL = DEFAULT_TEMPLATES_URL; + } + settings.DisplayExternalContributors = !$scope.formValues.externalContributions; + + updateSettings(settings, false); + }; + + function resetFormValues() { + $scope.formValues.labelName = ''; + $scope.formValues.labelValue = ''; + } + + function updateSettings(settings, resetForm) { + $('#loadingViewSpinner').show(); + + SettingsService.update(settings) + .then(function success(data) { + Notifications.success('Settings updated'); + StateManager.updateLogo(settings.LogoURL); + StateManager.updateExternalContributions(settings.DisplayExternalContributors); + if (resetForm) { + resetFormValues(); + } }) .catch(function error(err) { - if (err.invalidPassword) { - $scope.invalidPassword = true; - } else { - Notifications.error('Failure', err, err.msg); - } + Notifications.error('Failure', err, 'Unable to update settings'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); }); - }; + } + + function initView() { + $('#loadingViewSpinner').show(); + SettingsService.settings() + .then(function success(data) { + var settings = data; + $scope.settings = settings; + if (settings.LogoURL !== '') { + $scope.formValues.customLogo = true; + } + if (settings.TemplatesURL !== DEFAULT_TEMPLATES_URL) { + $scope.formValues.customTemplates = true; + } + $scope.formValues.externalContributions = !settings.DisplayExternalContributors; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve application settings'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); }]); diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html index fdad3cc53..cda6504a9 100644 --- a/app/components/sidebar/sidebar.html +++ b/app/components/sidebar/sidebar.html @@ -21,7 +21,7 @@ @@ -52,7 +52,7 @@ - +