diff --git a/go.mod b/go.mod index a92fd5bf..fc108bc9 100644 --- a/go.mod +++ b/go.mod @@ -73,13 +73,13 @@ require ( golang.org/x/sys v0.22.0 golang.org/x/term v0.22.0 golang.org/x/time v0.5.0 - google.golang.org/api v0.188.0 + google.golang.org/api v0.189.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) require ( cloud.google.com/go v0.115.0 // indirect - cloud.google.com/go/auth v0.7.1 // indirect + cloud.google.com/go/auth v0.7.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect cloud.google.com/go/compute/metadata v0.5.0 // indirect cloud.google.com/go/iam v1.1.11 // indirect @@ -116,9 +116,9 @@ require ( github.com/goccy/go-json v0.10.3 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/s2a-go v0.1.7 // indirect + github.com/google/s2a-go v0.1.8 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect - github.com/googleapis/gax-go/v2 v2.12.5 // indirect + github.com/googleapis/gax-go/v2 v2.13.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -173,9 +173,9 @@ require ( golang.org/x/text v0.16.0 // indirect golang.org/x/tools v0.23.0 // indirect golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect - google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d // indirect + google.golang.org/genproto v0.0.0-20240723171418-e6d459c13d2a // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240723171418-e6d459c13d2a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240723171418-e6d459c13d2a // indirect google.golang.org/grpc v1.65.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 872f2a72..c590bae6 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,18 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14= cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU= -cloud.google.com/go/auth v0.7.1 h1:Iv1bbpzJ2OIg16m94XI9/tlzZZl3cdeR3nGVGj78N7s= -cloud.google.com/go/auth v0.7.1/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs= +cloud.google.com/go/auth v0.7.2 h1:uiha352VrCDMXg+yoBtaD0tUF4Kv9vrtrWPYXwutnDE= +cloud.google.com/go/auth v0.7.2/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs= cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI= cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I= cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= cloud.google.com/go/iam v1.1.11 h1:0mQ8UKSfdHLut6pH9FM3bI55KWR46ketn0PuXleDyxw= cloud.google.com/go/iam v1.1.11/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ= -cloud.google.com/go/kms v1.18.2 h1:EGgD0B9k9tOOkbPhYW1PHo2W0teamAUYMOUIcDRMfPk= -cloud.google.com/go/kms v1.18.2/go.mod h1:YFz1LYrnGsXARuRePL729oINmN5J/5e7nYijgvfiIeY= -cloud.google.com/go/longrunning v0.5.9 h1:haH9pAuXdPAMqHvzX0zlWQigXT7B0+CL4/2nXXdBo5k= -cloud.google.com/go/longrunning v0.5.9/go.mod h1:HD+0l9/OOW0za6UWdKJtXoFAX/BGg/3Wj8p10NeWF7c= +cloud.google.com/go/kms v1.18.3 h1:8+Z2S4bQDSCdghB5ZA5dVDDJTLmnkRlowtFiXqMFd74= +cloud.google.com/go/kms v1.18.3/go.mod h1:y/Lcf6fyhbdn7MrG1VaDqXxM8rhOBc5rWcWAhcvZjQU= +cloud.google.com/go/longrunning v0.5.10 h1:eB/BniENNRKhjz/xgiillrdcH3G74TGSl3BXinGlI7E= +cloud.google.com/go/longrunning v0.5.10/go.mod h1:tljz5guTr5oc/qhlUjBlk7UAIFMOGuPNxkNDZXlLics= cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs= cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= @@ -195,8 +195,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= -github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= -github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -207,8 +207,8 @@ github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= -github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA= -github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= +github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s= +github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -514,19 +514,19 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk= golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -google.golang.org/api v0.188.0 h1:51y8fJ/b1AaaBRJr4yWm96fPcuxSo0JcegXE3DaHQHw= -google.golang.org/api v0.188.0/go.mod h1:VR0d+2SIiWOYG3r/jdm7adPW9hI2aRv9ETOSCQ9Beag= +google.golang.org/api v0.189.0 h1:equMo30LypAkdkLMBqfeIqtyAnlyig1JSZArl4XPwdI= +google.golang.org/api v0.189.0/go.mod h1:FLWGJKb0hb+pU2j+rJqwbnsF+ym+fQs73rbJ+KAUgy8= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d h1:/hmn0Ku5kWij/kjGsrcJeC1T/MrJi2iNWwgAqrihFwc= -google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY= -google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY= -google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d h1:JU0iKnSg02Gmb5ZdV8nYsKEKsP6o/FGVWTrw4i1DA9A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/genproto v0.0.0-20240723171418-e6d459c13d2a h1:hPbLwHFm59QoSKUT0uGaL19YN4U9W5lY4+iNXlUBNj0= +google.golang.org/genproto v0.0.0-20240723171418-e6d459c13d2a/go.mod h1:+7gIV7FP6jBo5hiY2lsWA//NkNORQVj0J1Isc/4HzR4= +google.golang.org/genproto/googleapis/api v0.0.0-20240723171418-e6d459c13d2a h1:YIa/rzVqMEokBkPtydCkx1VLmv3An1Uw7w1P1m6EhOY= +google.golang.org/genproto/googleapis/api v0.0.0-20240723171418-e6d459c13d2a/go.mod h1:AHT0dDg3SoMOgZGnZk29b5xTbPHMoEC8qthmBLJCpys= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240723171418-e6d459c13d2a h1:hqK4+jJZXCU4pW7jsAdGOVFIfLHQeV7LaizZKnZ84HI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240723171418-e6d459c13d2a/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= diff --git a/internal/common/common.go b/internal/common/common.go index a8bff346..a53d707e 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -163,8 +163,14 @@ var ( rateLimiters map[string][]*rateLimiter isShuttingDown atomic.Bool ftpLoginCommands = []string{"PASS", "USER"} + fnUpdateBranding func(*dataprovider.BrandingConfigs) ) +// SetUpdateBrandingFn sets the function to call to update branding configs. +func SetUpdateBrandingFn(fn func(*dataprovider.BrandingConfigs)) { + fnUpdateBranding = fn +} + // Initialize sets the common configuration func Initialize(c Configuration, isShared int) error { isShuttingDown.Store(false) @@ -403,6 +409,23 @@ func AddDefenderEvent(ip, protocol string, event HostEvent) bool { return Config.defender.AddEvent(ip, protocol, event) } +func reloadProviderConfigs() { + configs, err := dataprovider.GetConfigs() + if err != nil { + logger.Error(logSender, "", "unable to load config from provider: %v", err) + return + } + configs.SetNilsToEmpty() + if fnUpdateBranding != nil { + fnUpdateBranding(configs.Branding) + } + if err := configs.SMTP.TryDecrypt(); err != nil { + logger.Error(logSender, "", "unable to decrypt smtp config: %v", err) + return + } + smtp.Activate(configs.SMTP) +} + func startPeriodicChecks(duration time.Duration, isShared int) { startEventScheduler() spec := fmt.Sprintf("@every %s", duration) @@ -411,7 +434,7 @@ func startPeriodicChecks(duration time.Duration, isShared int) { logger.Info(logSender, "", "scheduled overquota transfers check, schedule %q", spec) if isShared == 1 { logger.Info(logSender, "", "add reload configs task") - _, err := eventScheduler.AddFunc("@every 10m", smtp.ReloadProviderConf) + _, err := eventScheduler.AddFunc("@every 10m", reloadProviderConfigs) util.PanicOnError(err) } if Config.IdleTimeout > 0 { diff --git a/internal/dataprovider/configs.go b/internal/dataprovider/configs.go index a68abf72..cb3f1099 100644 --- a/internal/dataprovider/configs.go +++ b/internal/dataprovider/configs.go @@ -15,8 +15,11 @@ package dataprovider import ( + "bytes" "encoding/json" "fmt" + "image/png" + "net/url" "golang.org/x/crypto/ssh" @@ -305,6 +308,27 @@ func (c *SMTPConfigs) TryDecrypt() error { return nil } +func (c *SMTPConfigs) prepareForRendering() { + if c.Password != nil { + c.Password.Hide() + if c.Password.IsEmpty() { + c.Password = nil + } + } + if c.OAuth2.ClientSecret != nil { + c.OAuth2.ClientSecret.Hide() + if c.OAuth2.ClientSecret.IsEmpty() { + c.OAuth2.ClientSecret = nil + } + } + if c.OAuth2.RefreshToken != nil { + c.OAuth2.RefreshToken.Hide() + if c.OAuth2.RefreshToken.IsEmpty() { + c.OAuth2.RefreshToken = nil + } + } +} + func (c *SMTPConfigs) getACopy() *SMTPConfigs { var password *kms.Secret if c.Password != nil { @@ -387,13 +411,137 @@ func (c *ACMEConfigs) getACopy() *ACMEConfigs { } } +// BrandingConfig defines the branding configuration +type BrandingConfig struct { + Name string `json:"name"` + ShortName string `json:"short_name"` + Logo []byte `json:"logo"` + Favicon []byte `json:"favicon"` + DisclaimerName string `json:"disclaimer_name"` + DisclaimerURL string `json:"disclaimer_url"` +} + +func (c *BrandingConfig) isEmpty() bool { + if c.Name != "" { + return false + } + if c.ShortName != "" { + return false + } + if len(c.Logo) > 0 { + return false + } + if len(c.Favicon) > 0 { + return false + } + if c.DisclaimerName != "" && c.DisclaimerURL != "" { + return false + } + return true +} + +func (*BrandingConfig) validatePNG(b []byte, maxWidth, maxHeight int) error { + if len(b) == 0 { + return nil + } + // DecodeConfig is more efficient, but I'm not sure if this would lead to + // accepting invalid images in some edge cases and performance does not + // matter here. + img, err := png.Decode(bytes.NewBuffer(b)) + if err != nil { + return util.NewI18nError( + util.NewValidationError("invalid PNG image"), + util.I18nErrorInvalidPNG, + ) + } + bounds := img.Bounds() + if bounds.Dx() > maxWidth || bounds.Dy() > maxHeight { + return util.NewI18nError( + util.NewValidationError("invalid PNG image size"), + util.I18nErrorInvalidPNGSize, + ) + } + return nil +} + +func (c *BrandingConfig) validateDisclaimerURL() error { + if c.DisclaimerURL == "" { + return nil + } + u, err := url.Parse(c.DisclaimerURL) + if err != nil { + return util.NewI18nError( + util.NewValidationError("invalid disclaimer URL"), + util.I18nErrorInvalidDisclaimerURL, + ) + } + if u.Scheme != "http" && u.Scheme != "https" { + return util.NewI18nError( + util.NewValidationError("invalid disclaimer URL scheme"), + util.I18nErrorInvalidDisclaimerURL, + ) + } + return nil +} + +func (c *BrandingConfig) validate() error { + if err := c.validateDisclaimerURL(); err != nil { + return err + } + if err := c.validatePNG(c.Logo, 512, 512); err != nil { + return err + } + return c.validatePNG(c.Favicon, 256, 256) +} + +func (c *BrandingConfig) getACopy() BrandingConfig { + logo := make([]byte, len(c.Logo)) + copy(logo, c.Logo) + favicon := make([]byte, len(c.Favicon)) + copy(favicon, c.Favicon) + + return BrandingConfig{ + Name: c.Name, + ShortName: c.ShortName, + Logo: logo, + Favicon: favicon, + DisclaimerName: c.DisclaimerName, + DisclaimerURL: c.DisclaimerURL, + } +} + +// BrandingConfigs defines the branding configuration for WebAdmin and WebClient UI +type BrandingConfigs struct { + WebAdmin BrandingConfig + WebClient BrandingConfig +} + +func (c *BrandingConfigs) isEmpty() bool { + return c.WebAdmin.isEmpty() && c.WebClient.isEmpty() +} + +func (c *BrandingConfigs) validate() error { + if err := c.WebAdmin.validate(); err != nil { + return err + } + return c.WebClient.validate() +} + +func (c *BrandingConfigs) getACopy() *BrandingConfigs { + return &BrandingConfigs{ + WebAdmin: c.WebAdmin.getACopy(), + WebClient: c.WebClient.getACopy(), + } +} + // Configs allows to set configuration keys disabled by default without // modifying the config file or setting env vars type Configs struct { - SFTPD *SFTPDConfigs `json:"sftpd,omitempty"` - SMTP *SMTPConfigs `json:"smtp,omitempty"` - ACME *ACMEConfigs `json:"acme,omitempty"` - UpdatedAt int64 `json:"updated_at,omitempty"` + SFTPD *SFTPDConfigs `json:"sftpd,omitempty"` + SMTP *SMTPConfigs `json:"smtp,omitempty"` + ACME *ACMEConfigs `json:"acme,omitempty"` + Branding *BrandingConfigs `json:"branding,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` } func (c *Configs) validate() error { @@ -412,6 +560,11 @@ func (c *Configs) validate() error { return err } } + if c.Branding != nil { + if err := c.Branding.validate(); err != nil { + return err + } + } return nil } @@ -428,25 +581,11 @@ func (c *Configs) PrepareForRendering() { if c.ACME != nil && c.ACME.isEmpty() { c.ACME = nil } + if c.Branding != nil && c.Branding.isEmpty() { + c.Branding = nil + } if c.SMTP != nil { - if c.SMTP.Password != nil { - c.SMTP.Password.Hide() - if c.SMTP.Password.IsEmpty() { - c.SMTP.Password = nil - } - } - if c.SMTP.OAuth2.ClientSecret != nil { - c.SMTP.OAuth2.ClientSecret.Hide() - if c.SMTP.OAuth2.ClientSecret.IsEmpty() { - c.SMTP.OAuth2.ClientSecret = nil - } - } - if c.SMTP.OAuth2.RefreshToken != nil { - c.SMTP.OAuth2.RefreshToken.Hide() - if c.SMTP.OAuth2.RefreshToken.IsEmpty() { - c.SMTP.OAuth2.RefreshToken = nil - } - } + c.SMTP.prepareForRendering() } } @@ -470,6 +609,9 @@ func (c *Configs) SetNilsToEmpty() { if c.ACME == nil { c.ACME = &ACMEConfigs{} } + if c.Branding == nil { + c.Branding = &BrandingConfigs{} + } } // RenderAsJSON implements the renderer interface used within plugins @@ -498,6 +640,9 @@ func (c *Configs) getACopy() Configs { if c.ACME != nil { result.ACME = c.ACME.getACopy() } + if c.Branding != nil { + result.Branding = c.Branding.getACopy() + } result.UpdatedAt = c.UpdatedAt return result } diff --git a/internal/httpd/api_utils.go b/internal/httpd/api_utils.go index e25409e8..f11094e1 100644 --- a/internal/httpd/api_utils.go +++ b/internal/httpd/api_utils.go @@ -363,6 +363,16 @@ func streamJSONArray(w http.ResponseWriter, chunkSize int, dataGetter func(limit streamData(w, []byte("]")) } +func renderPNGImage(w http.ResponseWriter, r *http.Request, b []byte) { + if len(b) == 0 { + ctx := context.WithValue(r.Context(), render.StatusCtxKey, http.StatusNotFound) + render.PlainText(w, r.WithContext(ctx), http.StatusText(http.StatusNotFound)) + return + } + w.Header().Set("Content-Type", "image/png") + streamData(w, b) +} + func getCompressedFileName(username string, files []string) string { if len(files) == 1 { name := path.Base(files[0]) diff --git a/internal/httpd/httpd.go b/internal/httpd/httpd.go index 4e41d8cc..cefabb3b 100644 --- a/internal/httpd/httpd.go +++ b/internal/httpd/httpd.go @@ -28,6 +28,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "time" "github.com/go-chi/chi/v5" @@ -285,12 +286,88 @@ var ( installationCodeHint string fnInstallationCodeResolver FnInstallationCodeResolver configurationDir string + dbBrandingConfig brandingCache ) func init() { updateWebAdminURLs("") updateWebClientURLs("") acme.SetReloadHTTPDCertsFn(ReloadCertificateMgr) + common.SetUpdateBrandingFn(dbBrandingConfig.Set) +} + +type brandingCache struct { + mu sync.RWMutex + configs *dataprovider.BrandingConfigs +} + +func (b *brandingCache) Set(configs *dataprovider.BrandingConfigs) { + b.mu.Lock() + defer b.mu.Unlock() + + b.configs = configs +} + +func (b *brandingCache) getWebAdminLogo() []byte { + b.mu.RLock() + defer b.mu.RUnlock() + + return b.configs.WebAdmin.Logo +} + +func (b *brandingCache) getWebAdminFavicon() []byte { + b.mu.RLock() + defer b.mu.RUnlock() + + return b.configs.WebAdmin.Favicon +} + +func (b *brandingCache) getWebClientLogo() []byte { + b.mu.RLock() + defer b.mu.RUnlock() + + return b.configs.WebClient.Logo +} + +func (b *brandingCache) getWebClientFavicon() []byte { + b.mu.RLock() + defer b.mu.RUnlock() + + return b.configs.WebClient.Favicon +} + +func (b *brandingCache) mergeBrandingConfig(branding UIBranding, isWebClient bool) UIBranding { + b.mu.RLock() + defer b.mu.RUnlock() + + var urlPrefix string + var cfg dataprovider.BrandingConfig + if isWebClient { + cfg = b.configs.WebClient + urlPrefix = "webclient" + } else { + cfg = b.configs.WebAdmin + urlPrefix = "webadmin" + } + if cfg.Name != "" { + branding.Name = cfg.Name + } + if cfg.ShortName != "" { + branding.ShortName = cfg.ShortName + } + if cfg.DisclaimerName != "" { + branding.DisclaimerName = cfg.DisclaimerName + } + if cfg.DisclaimerURL != "" { + branding.DisclaimerPath = cfg.DisclaimerURL + } + if len(cfg.Logo) > 0 { + branding.LogoPath = path.Join("/", "branding", urlPrefix, "logo.png") + } + if len(cfg.Favicon) > 0 { + branding.FaviconPath = path.Join("/", "branding", urlPrefix, "favicon.png") + } + return branding } // FnInstallationCodeResolver defines a method to get the installation code. @@ -406,19 +483,23 @@ type UIBranding struct { // the default CSS files DefaultCSS []string `json:"default_css" mapstructure:"default_css"` // Additional CSS file paths, relative to "static_files_path", to include - ExtraCSS []string `json:"extra_css" mapstructure:"extra_css"` + ExtraCSS []string `json:"extra_css" mapstructure:"extra_css"` + DefaultLogoPath string `json:"-" mapstructure:"-"` + DefaultFaviconPath string `json:"-" mapstructure:"-"` } func (b *UIBranding) check() { + b.DefaultLogoPath = "/img/logo.png" + b.DefaultFaviconPath = "/favicon.png" if b.LogoPath != "" { b.LogoPath = util.CleanPath(b.LogoPath) } else { - b.LogoPath = "/img/logo.png" + b.LogoPath = b.DefaultLogoPath } if b.FaviconPath != "" { b.FaviconPath = util.CleanPath(b.FaviconPath) } else { - b.FaviconPath = "/favicon.png" + b.FaviconPath = b.DefaultFaviconPath } if b.DisclaimerPath != "" { if !strings.HasPrefix(b.DisclaimerPath, "https://") && !strings.HasPrefix(b.DisclaimerPath, "http://") { @@ -548,6 +629,14 @@ func (b *Binding) checkBranding() { } } +func (b *Binding) webAdminBranding() UIBranding { + return dbBrandingConfig.mergeBrandingConfig(b.Branding.WebAdmin, false) +} + +func (b *Binding) webClientBranding() UIBranding { + return dbBrandingConfig.mergeBrandingConfig(b.Branding.WebClient, true) +} + func (b *Binding) parseAllowedProxy() error { if filepath.IsAbs(b.Address) && len(b.ProxyAllowed) > 0 { // unix domain socket @@ -879,6 +968,7 @@ func (c *Conf) loadFromProvider() error { return fmt.Errorf("unable to load config from provider: %w", err) } configs.SetNilsToEmpty() + dbBrandingConfig.Set(configs.Branding) if configs.ACME.Domain == "" || !configs.ACME.HasProtocol(common.ProtocolHTTP) { return nil } diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index a3a72d80..13d70e42 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -20,6 +20,9 @@ import ( "encoding/json" "errors" "fmt" + "image" + "image/color" + "image/png" "io" "io/fs" "math" @@ -13350,10 +13353,12 @@ func TestWebConfigsMock(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) form := make(url.Values) - req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode()))) + b, contentType, err := getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) assert.NoError(t, err) setJWTCookieForReq(req, webToken) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", contentType) rr = executeRequest(req) checkResponseCode(t, http.StatusForbidden, rr) // parse form error @@ -13371,10 +13376,12 @@ func TestWebConfigsMock(t *testing.T) { form.Add("sftp_host_key_algos", ssh.InsecureCertAlgoDSAv01) form.Set("sftp_pub_key_algos", ssh.InsecureKeyAlgoDSA) form.Set("form_action", "sftp_submit") - req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode()))) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) assert.NoError(t, err) setJWTCookieForReq(req, webToken) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", contentType) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), util.I18nError500Message) // invalid algo @@ -13383,10 +13390,12 @@ func TestWebConfigsMock(t *testing.T) { form.Set("sftp_pub_key_algos", ssh.InsecureKeyAlgoDSA) form.Set("sftp_kex_algos", "diffie-hellman-group18-sha512") form.Add("sftp_kex_algos", ssh.KeyExchangeDH16SHA512) - req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode()))) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) assert.NoError(t, err) setJWTCookieForReq(req, webToken) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", contentType) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), util.I18nConfigsOK) @@ -13401,10 +13410,12 @@ func TestWebConfigsMock(t *testing.T) { assert.Contains(t, configs.SFTPD.KexAlgorithms, ssh.KeyExchangeDH16SHA512) // invalid form action form.Set("form_action", "") - req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode()))) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) assert.NoError(t, err) setJWTCookieForReq(req, webToken) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", contentType) rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) assert.Contains(t, rr.Body.String(), util.I18nError400Message) @@ -13416,10 +13427,12 @@ func TestWebConfigsMock(t *testing.T) { form.Set("smtp_password", defaultPassword) form.Set("smtp_domain", "localdomain") form.Set("smtp_auth", "100") - req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode()))) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) assert.NoError(t, err) setJWTCookieForReq(req, webToken) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", contentType) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), util.I18nError500Message) // invalid smtp_auth @@ -13430,10 +13443,12 @@ func TestWebConfigsMock(t *testing.T) { form.Set("smtp_debug", "checked") form.Set("smtp_oauth2_provider", "1") form.Set("smtp_oauth2_client_id", "123") - req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode()))) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) assert.NoError(t, err) setJWTCookieForReq(req, webToken) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", contentType) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), util.I18nConfigsOK) @@ -13460,10 +13475,12 @@ func TestWebConfigsMock(t *testing.T) { form.Set("smtp_password", redactedSecret) form.Set("smtp_auth", "") configs.SMTP.AuthType = 0 // empty will be converted to 0 - req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode()))) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) assert.NoError(t, err) setJWTCookieForReq(req, webToken) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", contentType) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), util.I18nConfigsOK) @@ -13479,10 +13496,12 @@ func TestWebConfigsMock(t *testing.T) { updatedConfigs.SMTP.Password = kms.NewSecret(sdkkms.SecretStatusSecretBox, encryptedPayload, secretKey, "") err = dataprovider.UpdateConfigs(&updatedConfigs, "", "", "") assert.NoError(t, err) - req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode()))) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) assert.NoError(t, err) setJWTCookieForReq(req, webToken) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", contentType) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), util.I18nConfigsOK) @@ -13492,19 +13511,23 @@ func TestWebConfigsMock(t *testing.T) { form.Add("acme_protocols", "2") form.Add("acme_protocols", "3") form.Set("acme_domain", "example.com") + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) // no email set, validation will fail - req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode()))) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) assert.NoError(t, err) setJWTCookieForReq(req, webToken) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", contentType) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidEmail) form.Set("acme_domain", "") - req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode()))) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) assert.NoError(t, err) setJWTCookieForReq(req, webToken) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", contentType) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), util.I18nConfigsOK) @@ -13535,10 +13558,12 @@ func TestWebConfigsMock(t *testing.T) { form.Add("acme_protocols", "1000") form.Set("acme_domain", domain) form.Set("acme_email", "email@example.com") - req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode()))) + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) assert.NoError(t, err) setJWTCookieForReq(req, webToken) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", contentType) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), util.I18nConfigsOK) @@ -13562,6 +13587,201 @@ func TestWebConfigsMock(t *testing.T) { assert.NoError(t, err) } +func TestBrandingConfigMock(t *testing.T) { + err := dataprovider.UpdateConfigs(nil, "", "", "") + assert.NoError(t, err) + + webClientLogoPath := "/static/branding/webclient/logo.png" + webClientFaviconPath := "/static/branding/webclient/favicon.png" + webAdminLogoPath := "/static/branding/webadmin/logo.png" + webAdminFaviconPath := "/static/branding/webadmin/favicon.png" + // no custom log or favicon was set + for _, p := range []string{webClientLogoPath, webClientFaviconPath, webAdminLogoPath, webAdminFaviconPath} { + req, err := http.NewRequest(http.MethodGet, p, nil) + assert.NoError(t, err) + rr := executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + } + + webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + csrfToken, err := getCSRFTokenFromInternalPageMock(webConfigsPath, webToken) + assert.NoError(t, err) + form := make(url.Values) + form.Set(csrfFormToken, csrfToken) + form.Set("form_action", "branding_submit") + form.Set("branding_webadmin_name", "Custom WebAdmin") + form.Set("branding_webadmin_short_name", "WebAdmin") + form.Set("branding_webadmin_disclaimer_name", "Admin disclaimer") + form.Set("branding_webadmin_disclaimer_url", "invalid, not a URL") + form.Set("branding_webclient_name", "Custom WebClient") + form.Set("branding_webclient_short_name", "WebClient") + form.Set("branding_webclient_disclaimer_name", "Client disclaimer") + form.Set("branding_webclient_disclaimer_url", "https://example.com") + b, contentType, err := getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, webConfigsPath, &b) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + req.Header.Set("Content-Type", contentType) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidDisclaimerURL) + + form.Set("branding_webadmin_disclaimer_url", "https://example.net") + tmpFile := filepath.Join(os.TempDir(), util.GenerateUniqueID()+".png") + err = createTestPNG(tmpFile, 512, 512, color.RGBA{100, 200, 200, 0xff}) + assert.NoError(t, err) + + b, contentType, err = getMultipartFormData(form, "branding_webadmin_logo", tmpFile) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), util.I18nConfigsOK) + // check + configs, err := dataprovider.GetConfigs() + assert.NoError(t, err) + assert.Equal(t, "Custom WebAdmin", configs.Branding.WebAdmin.Name) + assert.Equal(t, "WebAdmin", configs.Branding.WebAdmin.ShortName) + assert.Equal(t, "Admin disclaimer", configs.Branding.WebAdmin.DisclaimerName) + assert.Equal(t, "https://example.net", configs.Branding.WebAdmin.DisclaimerURL) + assert.Equal(t, "Custom WebClient", configs.Branding.WebClient.Name) + assert.Equal(t, "WebClient", configs.Branding.WebClient.ShortName) + assert.Equal(t, "Client disclaimer", configs.Branding.WebClient.DisclaimerName) + assert.Equal(t, "https://example.com", configs.Branding.WebClient.DisclaimerURL) + assert.Greater(t, len(configs.Branding.WebAdmin.Logo), 0) + assert.Len(t, configs.Branding.WebAdmin.Favicon, 0) + assert.Len(t, configs.Branding.WebClient.Logo, 0) + assert.Len(t, configs.Branding.WebClient.Favicon, 0) + + err = createTestPNG(tmpFile, 256, 256, color.RGBA{120, 220, 220, 0xff}) + assert.NoError(t, err) + form.Set("branding_webadmin_logo_remove", "0") // 0 preserves WebAdmin logo + b, contentType, err = getMultipartFormData(form, "branding_webadmin_favicon", tmpFile) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), util.I18nConfigsOK) + configs, err = dataprovider.GetConfigs() + assert.NoError(t, err) + assert.Equal(t, "Custom WebAdmin", configs.Branding.WebAdmin.Name) + assert.Equal(t, "WebAdmin", configs.Branding.WebAdmin.ShortName) + assert.Equal(t, "Admin disclaimer", configs.Branding.WebAdmin.DisclaimerName) + assert.Equal(t, "https://example.net", configs.Branding.WebAdmin.DisclaimerURL) + assert.Equal(t, "Custom WebClient", configs.Branding.WebClient.Name) + assert.Equal(t, "WebClient", configs.Branding.WebClient.ShortName) + assert.Equal(t, "Client disclaimer", configs.Branding.WebClient.DisclaimerName) + assert.Equal(t, "https://example.com", configs.Branding.WebClient.DisclaimerURL) + assert.Greater(t, len(configs.Branding.WebAdmin.Logo), 0) + assert.Greater(t, len(configs.Branding.WebAdmin.Favicon), 0) + assert.Len(t, configs.Branding.WebClient.Logo, 0) + assert.Len(t, configs.Branding.WebClient.Favicon, 0) + + err = createTestPNG(tmpFile, 256, 256, color.RGBA{80, 90, 110, 0xff}) + assert.NoError(t, err) + b, contentType, err = getMultipartFormData(form, "branding_webclient_logo", tmpFile) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), util.I18nConfigsOK) + configs, err = dataprovider.GetConfigs() + assert.NoError(t, err) + assert.Greater(t, len(configs.Branding.WebClient.Logo), 0) + + err = createTestPNG(tmpFile, 256, 256, color.RGBA{120, 50, 120, 0xff}) + assert.NoError(t, err) + b, contentType, err = getMultipartFormData(form, "branding_webclient_favicon", tmpFile) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), util.I18nConfigsOK) + configs, err = dataprovider.GetConfigs() + assert.NoError(t, err) + assert.Greater(t, len(configs.Branding.WebClient.Favicon), 0) + + for _, p := range []string{webClientLogoPath, webClientFaviconPath, webAdminLogoPath, webAdminFaviconPath} { + req, err := http.NewRequest(http.MethodGet, p, nil) + assert.NoError(t, err) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + } + // remove images + form.Set("branding_webadmin_logo_remove", "1") + form.Set("branding_webclient_logo_remove", "1") + form.Set("branding_webadmin_favicon_remove", "1") + form.Set("branding_webclient_favicon_remove", "1") + b, contentType, err = getMultipartFormData(form, "", "") + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), util.I18nConfigsOK) + configs, err = dataprovider.GetConfigs() + assert.NoError(t, err) + assert.Len(t, configs.Branding.WebAdmin.Logo, 0) + assert.Len(t, configs.Branding.WebAdmin.Favicon, 0) + assert.Len(t, configs.Branding.WebClient.Logo, 0) + assert.Len(t, configs.Branding.WebClient.Favicon, 0) + for _, p := range []string{webClientLogoPath, webClientFaviconPath, webAdminLogoPath, webAdminFaviconPath} { + req, err := http.NewRequest(http.MethodGet, p, nil) + assert.NoError(t, err) + rr := executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + } + form.Del("branding_webadmin_logo_remove") + form.Del("branding_webclient_logo_remove") + form.Del("branding_webadmin_favicon_remove") + form.Del("branding_webclient_favicon_remove") + // image too large + err = createTestPNG(tmpFile, 768, 512, color.RGBA{120, 50, 120, 0xff}) + assert.NoError(t, err) + b, contentType, err = getMultipartFormData(form, "branding_webclient_logo", tmpFile) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidPNGSize) + // not a png image + err = createTestFile(tmpFile, 128) + assert.NoError(t, err) + b, contentType, err = getMultipartFormData(form, "branding_webclient_logo", tmpFile) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidPNG) + + err = os.Remove(tmpFile) + assert.NoError(t, err) + err = dataprovider.UpdateConfigs(nil, "", "", "") + assert.NoError(t, err) +} + func TestSFTPLoopError(t *testing.T) { user1 := getTestUser() user2 := getTestUser() @@ -26798,6 +27018,23 @@ func isDbDefenderSupported() bool { } } +func createTestPNG(name string, width, height int, imgColor color.Color) error { + upLeft := image.Point{0, 0} + lowRight := image.Point{width, height} + img := image.NewRGBA(image.Rectangle{upLeft, lowRight}) + for x := 0; x < width; x++ { + for y := 0; y < height; y++ { + img.Set(x, y, imgColor) + } + } + f, err := os.Create(name) + if err != nil { + return err + } + defer f.Close() + return png.Encode(f, img) +} + func BenchmarkSecretDecryption(b *testing.B) { s := kms.NewPlainSecret("test data") s.SetAdditionalData("username") diff --git a/internal/httpd/internal_test.go b/internal/httpd/internal_test.go index 2e580869..aee400f1 100644 --- a/internal/httpd/internal_test.go +++ b/internal/httpd/internal_test.go @@ -412,6 +412,16 @@ func TestGCSWebInvalidFormFile(t *testing.T) { assert.EqualError(t, err, http.ErrNotMultipart.Error()) } +func TestBrandingInvalidFormFile(t *testing.T) { + form := make(url.Values) + req, _ := http.NewRequest(http.MethodPost, webConfigsPath, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + err := req.ParseForm() + assert.NoError(t, err) + _, err = getBrandingConfigFromPostFields(req, &dataprovider.BrandingConfigs{}) + assert.EqualError(t, err, http.ErrNotMultipart.Error()) +} + func TestVerifyCSRFToken(t *testing.T) { server := httpdServer{} server.initializeRouter() diff --git a/internal/httpd/server.go b/internal/httpd/server.go index c2d90017..a7405dcc 100644 --- a/internal/httpd/server.go +++ b/internal/httpd/server.go @@ -24,6 +24,7 @@ import ( "net" "net/http" "net/url" + "path" "path/filepath" "strings" "time" @@ -172,7 +173,7 @@ func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, r *http.Reque CurrentURL: webClientLoginPath, Error: err, CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, xid.New().String(), webBaseClientPath), - Branding: s.binding.Branding.WebClient, + Branding: s.binding.webClientBranding(), FormDisabled: s.binding.isWebClientLoginFormDisabled(), CheckRedirect: true, } @@ -181,7 +182,7 @@ func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, r *http.Reque } if s.binding.showAdminLoginURL() { data.AltLoginURL = webAdminLoginPath - data.AltLoginName = s.binding.Branding.WebAdmin.ShortName + data.AltLoginName = s.binding.webAdminBranding().ShortName } if smtp.IsEnabled() && !data.FormDisabled { data.ForgotPwdURL = webClientForgotPwdPath @@ -590,13 +591,13 @@ func (s *httpdServer) renderAdminLoginPage(w http.ResponseWriter, r *http.Reques CurrentURL: webAdminLoginPath, Error: err, CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, xid.New().String(), webBaseAdminPath), - Branding: s.binding.Branding.WebAdmin, + Branding: s.binding.webAdminBranding(), FormDisabled: s.binding.isWebAdminLoginFormDisabled(), CheckRedirect: false, } if s.binding.showClientLoginURL() { data.AltLoginURL = webClientLoginPath - data.AltLoginName = s.binding.Branding.WebClient.ShortName + data.AltLoginName = s.binding.webClientBranding().ShortName } if smtp.IsEnabled() && !data.FormDisabled { data.ForgotPwdURL = webAdminForgotPwdPath @@ -1520,6 +1521,16 @@ func (s *httpdServer) setupWebClientRoutes() { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) http.Redirect(w, r, webClientLoginPath, http.StatusFound) }) + s.router.Get(path.Join(webStaticFilesPath, "branding/webclient/logo.png"), + func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + renderPNGImage(w, r, dbBrandingConfig.getWebClientLogo()) + }) + s.router.Get(path.Join(webStaticFilesPath, "branding/webclient/favicon.png"), + func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + renderPNGImage(w, r, dbBrandingConfig.getWebClientFavicon()) + }) s.router.Get(webClientLoginPath, s.handleClientWebLogin) if s.binding.OIDC.isEnabled() && !s.binding.isWebClientOIDCLoginDisabled() { s.router.Get(webClientOIDCLoginPath, s.handleWebClientOIDCLogin) @@ -1643,6 +1654,16 @@ func (s *httpdServer) setupWebAdminRoutes() { r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize) s.redirectToWebPath(w, r, webAdminLoginPath) }) + s.router.Get(path.Join(webStaticFilesPath, "branding/webadmin/logo.png"), + func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + renderPNGImage(w, r, dbBrandingConfig.getWebAdminLogo()) + }) + s.router.Get(path.Join(webStaticFilesPath, "branding/webadmin/favicon.png"), + func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + renderPNGImage(w, r, dbBrandingConfig.getWebAdminFavicon()) + }) s.router.Get(webAdminLoginPath, s.handleWebAdminLogin) if s.binding.OIDC.hasRoles() && !s.binding.isWebAdminOIDCLoginDisabled() { s.router.Get(webAdminOIDCLoginPath, s.handleWebAdminOIDCLogin) diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index 404b1c5f..4d573ecd 100644 --- a/internal/httpd/webadmin.go +++ b/internal/httpd/webadmin.go @@ -329,6 +329,7 @@ type configsPage struct { RedactedSecret string OAuth2TokenURL string OAuth2RedirectURL string + WebClientBranding UIBranding Error *util.I18nError } @@ -662,7 +663,7 @@ func (s *httpdServer) getBasePageData(title, currentURL string, w http.ResponseW HasSearcher: plugin.Handler.HasSearcher(), HasExternalLogin: isLoggedInWithOIDC(r), CSRFToken: csrfToken, - Branding: s.binding.Branding.WebAdmin, + Branding: s.binding.webAdminBranding(), } } @@ -720,7 +721,7 @@ func (s *httpdServer) renderForgotPwdPage(w http.ResponseWriter, r *http.Request CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, xid.New().String(), webBaseAdminPath), LoginURL: webAdminLoginPath, Title: util.I18nForgotPwdTitle, - Branding: s.binding.Branding.WebAdmin, + Branding: s.binding.webAdminBranding(), } renderAdminTemplate(w, templateForgotPassword, data) } @@ -733,7 +734,7 @@ func (s *httpdServer) renderResetPwdPage(w http.ResponseWriter, r *http.Request, CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, "", webBaseAdminPath), LoginURL: webAdminLoginPath, Title: util.I18nResetPwdTitle, - Branding: s.binding.Branding.WebAdmin, + Branding: s.binding.webAdminBranding(), } renderAdminTemplate(w, templateResetPassword, data) } @@ -746,7 +747,7 @@ func (s *httpdServer) renderTwoFactorPage(w http.ResponseWriter, r *http.Request Error: err, CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, "", webBaseAdminPath), RecoveryURL: webAdminTwoFactorRecoveryPath, - Branding: s.binding.Branding.WebAdmin, + Branding: s.binding.webAdminBranding(), } renderAdminTemplate(w, templateTwoFactor, data) } @@ -758,7 +759,7 @@ func (s *httpdServer) renderTwoFactorRecoveryPage(w http.ResponseWriter, r *http CurrentURL: webAdminTwoFactorRecoveryPath, Error: err, CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, "", webBaseAdminPath), - Branding: s.binding.Branding.WebAdmin, + Branding: s.binding.webAdminBranding(), } renderAdminTemplate(w, templateTwoFactorRecovery, data) } @@ -838,6 +839,7 @@ func (s *httpdServer) renderConfigsPage(w http.ResponseWriter, r *http.Request, RedactedSecret: redactedSecret, OAuth2TokenURL: webOAuth2TokenPath, OAuth2RedirectURL: webOAuth2RedirectPath, + WebClientBranding: s.binding.webClientBranding(), Error: getI18nError(err), } @@ -855,7 +857,7 @@ func (s *httpdServer) renderAdminSetupPage(w http.ResponseWriter, r *http.Reques InstallationCodeHint: installationCodeHint, HideSupportLink: hideSupportLink, Error: err, - Branding: s.binding.Branding.WebAdmin, + Branding: s.binding.webAdminBranding(), } renderAdminTemplate(w, templateSetup, data) @@ -1582,7 +1584,7 @@ func getGCSConfig(r *http.Request) (vfs.GCSFsConfig, error) { config.AutomaticCredentials = 0 } credentials, _, err := r.FormFile("gcs_credential_file") - if err == http.ErrMissingFile { + if errors.Is(err, http.ErrMissingFile) { return config, nil } if err != nil { @@ -2760,6 +2762,71 @@ func getSMTPConfigsFromPostFields(r *http.Request) *dataprovider.SMTPConfigs { } } +func getImageInputBytes(r *http.Request, fieldName, removeFieldName string, defaultVal []byte) ([]byte, error) { + var result []byte + remove := r.Form.Get(removeFieldName) + if remove == "" || remove == "0" { + result = defaultVal + } + f, _, err := r.FormFile(fieldName) + if err != nil { + if errors.Is(err, http.ErrMissingFile) { + return result, nil + } + return nil, err + } + defer f.Close() + + return io.ReadAll(f) +} + +func getBrandingConfigFromPostFields(r *http.Request, config *dataprovider.BrandingConfigs) ( + *dataprovider.BrandingConfigs, error, +) { + if config == nil { + config = &dataprovider.BrandingConfigs{} + } + adminLogo, err := getImageInputBytes(r, "branding_webadmin_logo", "branding_webadmin_logo_remove", config.WebAdmin.Logo) + if err != nil { + return nil, util.NewI18nError(err, util.I18nErrorInvalidForm) + } + adminFavicon, err := getImageInputBytes(r, "branding_webadmin_favicon", "branding_webadmin_favicon_remove", + config.WebAdmin.Favicon) + if err != nil { + return nil, util.NewI18nError(err, util.I18nErrorInvalidForm) + } + clientLogo, err := getImageInputBytes(r, "branding_webclient_logo", "branding_webclient_logo_remove", + config.WebClient.Logo) + if err != nil { + return nil, util.NewI18nError(err, util.I18nErrorInvalidForm) + } + clientFavicon, err := getImageInputBytes(r, "branding_webclient_favicon", "branding_webclient_favicon_remove", + config.WebClient.Favicon) + if err != nil { + return nil, util.NewI18nError(err, util.I18nErrorInvalidForm) + } + + branding := &dataprovider.BrandingConfigs{ + WebAdmin: dataprovider.BrandingConfig{ + Name: strings.TrimSpace(r.Form.Get("branding_webadmin_name")), + ShortName: strings.TrimSpace(r.Form.Get("branding_webadmin_short_name")), + Logo: adminLogo, + Favicon: adminFavicon, + DisclaimerName: strings.TrimSpace(r.Form.Get("branding_webadmin_disclaimer_name")), + DisclaimerURL: strings.TrimSpace(r.Form.Get("branding_webadmin_disclaimer_url")), + }, + WebClient: dataprovider.BrandingConfig{ + Name: strings.TrimSpace(r.Form.Get("branding_webclient_name")), + ShortName: strings.TrimSpace(r.Form.Get("branding_webclient_short_name")), + Logo: clientLogo, + Favicon: clientFavicon, + DisclaimerName: strings.TrimSpace(r.Form.Get("branding_webclient_disclaimer_name")), + DisclaimerURL: strings.TrimSpace(r.Form.Get("branding_webclient_disclaimer_url")), + }, + } + return branding, nil +} + func (s *httpdServer) handleWebAdminForgotPwd(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) if !smtp.IsEnabled() { @@ -4207,11 +4274,13 @@ func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Reques s.renderInternalServerErrorPage(w, r, err) return } - err = r.ParseForm() + err = r.ParseMultipartForm(maxRequestSize) if err != nil { s.renderBadRequestPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm)) return } + defer r.MultipartForm.RemoveAll() //nolint:errcheck + ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil { s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF)) @@ -4237,6 +4306,15 @@ func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Reques smtpConfigs := getSMTPConfigsFromPostFields(r) updateSMTPSecrets(smtpConfigs, configs.SMTP) configs.SMTP = smtpConfigs + case "branding_submit": + configSection = 4 + brandingConfigs, err := getBrandingConfigFromPostFields(r, configs.Branding) + if err != nil { + logger.Info(logSender, "", "unable to get branding config: %v", err) + s.renderConfigsPage(w, r, configs, err, configSection) + return + } + configs.Branding = brandingConfigs default: s.renderBadRequestPage(w, r, errors.New("unsupported form action")) return @@ -4247,15 +4325,22 @@ func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Reques s.renderConfigsPage(w, r, configs, err, configSection) return } - if configSection == 3 { + postConfigsUpdate(configSection, configs) + s.renderMessagePage(w, r, util.I18nConfigsTitle, http.StatusOK, nil, util.I18nConfigsOK) +} + +func postConfigsUpdate(section int, configs dataprovider.Configs) { + switch section { + case 3: err := configs.SMTP.TryDecrypt() if err == nil { smtp.Activate(configs.SMTP) } else { logger.Error(logSender, "", "unable to decrypt SMTP configuration, cannot activate configuration: %v", err) } + case 4: + dbBrandingConfig.Set(configs.Branding) } - s.renderMessagePage(w, r, util.I18nConfigsTitle, http.StatusOK, nil, util.I18nConfigsOK) } func (s *httpdServer) handleOAuth2TokenRedirect(w http.ResponseWriter, r *http.Request) { diff --git a/internal/httpd/webclient.go b/internal/httpd/webclient.go index 3d082eef..c62831c9 100644 --- a/internal/httpd/webclient.go +++ b/internal/httpd/webclient.go @@ -546,7 +546,7 @@ func (s *httpdServer) getBaseClientPageData(title, currentURL string, w http.Res CSRFToken: csrfToken, LoggedUser: getUserFromToken(r), IsLoggedToShare: false, - Branding: s.binding.Branding.WebClient, + Branding: s.binding.webClientBranding(), } if !strings.HasPrefix(r.RequestURI, webClientPubSharesPath) { data.LoginURL = webClientLoginPath @@ -562,7 +562,7 @@ func (s *httpdServer) renderClientForgotPwdPage(w http.ResponseWriter, r *http.R CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, xid.New().String(), webBaseClientPath), LoginURL: webClientLoginPath, Title: util.I18nForgotPwdTitle, - Branding: s.binding.Branding.WebClient, + Branding: s.binding.webClientBranding(), } renderClientTemplate(w, templateForgotPassword, data) } @@ -575,7 +575,7 @@ func (s *httpdServer) renderClientResetPwdPage(w http.ResponseWriter, r *http.Re CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, "", webBaseClientPath), LoginURL: webClientLoginPath, Title: util.I18nResetPwdTitle, - Branding: s.binding.Branding.WebClient, + Branding: s.binding.webClientBranding(), } renderClientTemplate(w, templateResetPassword, data) } @@ -587,7 +587,7 @@ func (s *httpdServer) renderShareLoginPage(w http.ResponseWriter, r *http.Reques CurrentURL: r.RequestURI, Error: err, CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, xid.New().String(), webBaseClientPath), - Branding: s.binding.Branding.WebClient, + Branding: s.binding.webClientBranding(), } renderClientTemplate(w, templateShareLogin, data) } @@ -637,7 +637,7 @@ func (s *httpdServer) renderClientTwoFactorPage(w http.ResponseWriter, r *http.R Error: err, CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, "", webBaseClientPath), RecoveryURL: webClientTwoFactorRecoveryPath, - Branding: s.binding.Branding.WebClient, + Branding: s.binding.webClientBranding(), } if next := r.URL.Query().Get("next"); strings.HasPrefix(next, webClientFilesPath) { data.CurrentURL += "?next=" + url.QueryEscape(next) @@ -652,7 +652,7 @@ func (s *httpdServer) renderClientTwoFactorRecoveryPage(w http.ResponseWriter, r CurrentURL: webClientTwoFactorRecoveryPath, Error: err, CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, "", webBaseClientPath), - Branding: s.binding.Branding.WebClient, + Branding: s.binding.webClientBranding(), } renderClientTemplate(w, templateTwoFactorRecovery, data) } @@ -1102,7 +1102,7 @@ func (s *httpdServer) handleShareViewPDF(w http.ResponseWriter, r *http.Request) Title: path.Base(name), URL: fmt.Sprintf("%s?path=%s&_=%d", path.Join(webClientPubSharesPath, share.ShareID, "getpdf"), url.QueryEscape(name), time.Now().UTC().Unix()), - Branding: s.binding.Branding.WebClient, + Branding: s.binding.webClientBranding(), } renderClientTemplate(w, templateClientViewPDF, data) } @@ -1778,7 +1778,7 @@ func (s *httpdServer) handleClientViewPDF(w http.ResponseWriter, r *http.Request commonBasePage: getCommonBasePage(r), Title: path.Base(name), URL: fmt.Sprintf("%s?path=%s&_=%d", webClientGetPDFPath, url.QueryEscape(name), time.Now().UTC().Unix()), - Branding: s.binding.Branding.WebClient, + Branding: s.binding.webClientBranding(), } renderClientTemplate(w, templateClientViewPDF, data) } diff --git a/internal/smtp/smtp.go b/internal/smtp/smtp.go index 7202e4b8..56de459e 100644 --- a/internal/smtp/smtp.go +++ b/internal/smtp/smtp.go @@ -415,12 +415,6 @@ func SendEmail(to, bcc []string, subject, body string, contentType EmailContentT return config.sendEmail(to, bcc, subject, body, contentType, attachments...) } -// ReloadProviderConf reloads the configuration from the provider -// and apply it if different from the active one -func ReloadProviderConf() { - loadConfigFromProvider() //nolint:errcheck -} - func loadConfigFromProvider() error { configs, err := dataprovider.GetConfigs() if err != nil { diff --git a/internal/util/i18n.go b/internal/util/i18n.go index 56db5b60..a3cb927e 100644 --- a/internal/util/i18n.go +++ b/internal/util/i18n.go @@ -303,6 +303,9 @@ const ( I18nErrorEvSyncUnsupportedFs = "rules.sync_unsupported_fs_event" I18nErrorRuleFailureActionsOnly = "rules.only_failure_actions" I18nErrorRuleSyncActionRequired = "rules.sync_action_required" + I18nErrorInvalidPNG = "branding.invalid_png" + I18nErrorInvalidPNGSize = "branding.invalid_png_size" + I18nErrorInvalidDisclaimerURL = "branding.invalid_disclaimer_url" ) // NewI18nError returns a I18nError wrappring the provided error diff --git a/static/locales/en/translation.json b/static/locales/en/translation.json index 25254bc8..c9534be0 100644 --- a/static/locales/en/translation.json +++ b/static/locales/en/translation.json @@ -883,6 +883,19 @@ "help": "From this section you can enable algorithms disabled by default. You don't need to set values already defined using env vars or config file. A service restart is required to apply changes", "host_key_algos": "Host Key Algorithms" }, + "branding": { + "title": "Branding", + "help": "From this section you can customize SFTPGo to fit your brand and add a disclaimer to the login pages", + "short_name": "Short name", + "logo": "Logo", + "logo_help": "PNG image, max accepted size 512x512, default logo size is 256x256", + "favicon": "Favicon", + "disclaimer_name": "Disclaimer title", + "disclaimer_url": "Disclaimer URL", + "invalid_png": "Invalid PNG image", + "invalid_png_size": "Invalid PNG image size", + "invalid_disclaimer_url": "The disclaimer URL must be an http or https link" + }, "events": { "search": "Search logs", "fs_events": "Filesystem events", diff --git a/static/locales/it/translation.json b/static/locales/it/translation.json index de6bd488..5db589e7 100644 --- a/static/locales/it/translation.json +++ b/static/locales/it/translation.json @@ -883,6 +883,19 @@ "help": "Da questa sezione è possibile abilitare gli algoritmi disabilitati di default. Non è necessario impostare valori già definiti utilizzando env vars o il file di configurazione. Per applicare le modifiche è necessario il riavvio del servizio", "host_key_algos": "Algoritmi per chiavi host" }, + "branding": { + "title": "Branding", + "help": "Da questa sezione puoi personalizzare SFTPGo per adattarlo al tuo marchio e aggiungere un disclaimer alle pagine di accesso", + "short_name": "Nome breve", + "logo": "Logo", + "logo_help": "Immagine PNG, dimensione massima accettata 512x512, la dimensione predefinita del logo è 256x256", + "favicon": "Favicon", + "disclaimer_name": "Titolo Disclaimer", + "disclaimer_url": "URL Disclaimer", + "invalid_png": "Immagine PNG non valida", + "invalid_png_size": "Dimensione immagine PNG non valida", + "invalid_disclaimer_url": "L'URL del Disclaimer deve essere un link http o https" + }, "events": { "search": "Cerca eventi", "fs_events": "Eventi filesystem", diff --git a/templates/webadmin/configs.html b/templates/webadmin/configs.html index 89698374..3e57c41d 100644 --- a/templates/webadmin/configs.html +++ b/templates/webadmin/configs.html @@ -15,6 +15,36 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). --> {{template "base" .}} +{{- define "extra_css"}} + +{{- end}} + {{- define "page_body"}}
@@ -352,6 +382,221 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
+
+

+ +

+
+
+ {{- template "infomsg" "branding.help"}} +
+
+
+

WebAdmin

+
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+
+ + + + + + + + + + +
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+

WebClient

+
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+
+ + + + + + + + + + +
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ + + +
+ +
+
+
+
+ @@ -591,6 +836,12 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). submitButton.setAttribute('data-kt-indicator', 'on'); submitButton.disabled = true; }); + + $('#configs_branding_form').submit(function (event) { + let submitButton = document.querySelector('#branding_form_submit'); + submitButton.setAttribute('data-kt-indicator', 'on'); + submitButton.disabled = true; + }); }); {{- end}} \ No newline at end of file