소스 검색

allow to customize name and log from the WebUI

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 1 년 전
부모
커밋
b5c821795a

+ 7 - 7
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

+ 18 - 18
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=

+ 24 - 1
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 {

+ 167 - 22
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
 }

+ 10 - 0
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])

+ 93 - 3
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
 	}

+ 259 - 22
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")

+ 10 - 0
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()

+ 25 - 4
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)

+ 95 - 10
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) {

+ 8 - 8
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)
 }

+ 0 - 6
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 {

+ 3 - 0
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

+ 13 - 0
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",

+ 13 - 0
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",

+ 251 - 0
templates/webadmin/configs.html

@@ -15,6 +15,36 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
 -->
 {{template "base" .}}
 
+{{- define "extra_css"}}
+<style {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
+    .image-input-placeholder {
+        background-image: url('{{.StaticURL}}{{.Branding.DefaultLogoPath}}');
+    }
+
+    /*{{- if ne .Branding.DefaultLogoPath .Branding.LogoPath}}*/
+    .image-input-webadmin-current {
+        background-image: url('{{.StaticURL}}{{.Branding.LogoPath}}');
+    }
+    /*{{- end}}*/
+    /*{{- if ne .Branding.DefaultFaviconPath .Branding.FaviconPath}}*/
+    .image-input-webadmin-fav-current {
+        background-image: url('{{.StaticURL}}{{.Branding.FaviconPath}}');
+    }
+    /*{{- end}}*/
+
+    /*{{- if ne .Branding.DefaultLogoPath .WebClientBranding.LogoPath}}*/
+    .image-input-webclient-current {
+        background-image: url('{{.StaticURL}}{{.WebClientBranding.LogoPath}}');
+    }
+    /*{{- end}}*/
+    /*{{- if ne .Branding.DefaultFaviconPath .WebClientBranding.FaviconPath}}*/
+    .image-input-webclient-fav-current {
+        background-image: url('{{.StaticURL}}{{.WebClientBranding.FaviconPath}}');
+    }
+    /*{{- end}}*/
+</style>
+{{- end}}
+
 {{- define "page_body"}}
 <div class="card shadow-sm">
     <div class="card-header bg-light">
@@ -352,6 +382,221 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
                 </div>
             </div>
 
+            <div class="accordion-item">
+                <h2 class="accordion-header" id="accordion_header_branding">
+                    <button class="accordion-button section-title-inner text-primary collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#accordion_branding_body" aria-expanded="{{if eq .ConfigSection 4}}true{{else}}false{{end}}" aria-controls="accordion_branding_body">
+                        <span data-i18n="branding.title">Branding</span>
+                    </button>
+                </h2>
+                <div id="accordion_branding_body" class="accordion-collapse collapse {{if eq .ConfigSection 4}}show{{end}}" aria-labelledby="accordion_header_branding" data-bs-parent="#accordion_configs">
+                    <div class="accordion-body">
+                        {{- template "infomsg" "branding.help"}}
+                        <form id="configs_branding_form" enctype="multipart/form-data" action="{{.CurrentURL}}" method="POST" autocomplete="off">
+                            <div class="card">
+                                <div class="card-header bg-light">
+                                    <h3 class="card-title section-title-inner">WebAdmin</h3>
+                                </div>
+                                <div class="card-body">
+                                    <div class="form-group row">
+                                        <label for="idBrandingWebAdminName" data-i18n="general.name" class="col-md-3 col-form-label">Name</label>
+                                        <div class="col-md-9">
+                                            <input id="idBrandingWebAdminName" type="text" placeholder="SFTPGo WebAdmin" name="branding_webadmin_name" value="{{.Configs.Branding.WebAdmin.Name}}" maxlength="255"
+                                                class="form-control" />
+                                        </div>
+                                    </div>
+
+                                    <div class="form-group row mt-10">
+                                        <label for="idBrandingWebAdminShortName" data-i18n="branding.short_name" class="col-md-3 col-form-label">Short Name</label>
+                                        <div class="col-md-9">
+                                            <input id="idBrandingWebAdminShortName" type="text" placeholder="WebAdmin" name="branding_webadmin_short_name" value="{{.Configs.Branding.WebAdmin.ShortName}}" maxlength="255"
+                                                class="form-control" />
+                                        </div>
+                                    </div>
+
+                                    <div class="form-group row mt-10">
+                                        <label for="idBrandingWebAdminLogo" data-i18n="branding.logo" class="col-md-3 col-form-label">Logo</label>
+                                        <div class="col-md-9">
+                                            <div class="image-input image-input-outline {{if eq .Branding.DefaultLogoPath .Branding.LogoPath}}image-input-empty{{end}} image-input-placeholder" data-kt-image-input="true">
+                                                <div class="image-input-wrapper w-125px h-125px image-input-webadmin-current"></div>
+
+                                                <label class="btn btn-icon btn-circle btn-color-muted btn-active-color-primary w-25px h-25px bg-body shadow"
+                                                    data-kt-image-input-action="change">
+                                                    <i class="ki-duotone ki-pencil fs-6"><span class="path1"></span><span class="path2"></span></i>
+                                                    <input type="file" id="idBrandingWebAdminLogo" name="branding_webadmin_logo" accept=".png" aria-describedby="idBrandingWebAdminLogoHelp"/>
+                                                    <input type="hidden"name="branding_webadmin_logo_remove" />
+                                                </label>
+
+                                                <span class="btn btn-icon btn-circle btn-color-muted btn-active-color-primary w-25px h-25px bg-body shadow"
+                                                    data-kt-image-input-action="cancel">
+                                                    <i class="ki-outline ki-cross fs-3"></i>
+                                                </span>
+
+                                                <span class="btn btn-icon btn-circle btn-color-muted btn-active-color-primary w-25px h-25px bg-body shadow"
+                                                    data-kt-image-input-action="remove">
+                                                    <i class="ki-outline ki-cross fs-3"></i>
+                                                </span>
+                                            </div>
+                                            <div id="idBrandingWebAdminLogoHelp" class="form-text mt-3" data-i18n="branding.logo_help"></div>
+                                        </div>
+                                    </div>
+
+                                    <div class="form-group row mt-10">
+                                        <label for="idBrandingWebAdminFavicon" data-i18n="branding.favicon" class="col-md-3 col-form-label">Favicon</label>
+                                        <div class="col-md-9">
+                                            <div class="image-input image-input-outline {{if eq .Branding.DefaultFaviconPath .Branding.FaviconPath}}image-input-empty{{end}} image-input-placeholder" data-kt-image-input="true">
+                                                <div class="image-input-wrapper w-50px h-50px image-input-webadmin-fav-current"></div>
+
+                                                <label class="btn btn-icon btn-circle btn-color-muted btn-active-color-primary w-25px h-25px bg-body shadow"
+                                                    data-kt-image-input-action="change">
+                                                    <i class="ki-duotone ki-pencil fs-6"><span class="path1"></span><span class="path2"></span></i>
+                                                    <input type="file" id="idBrandingWebAdminFavicon" name="branding_webadmin_favicon" accept=".png" />
+                                                    <input type="hidden"name="branding_webadmin_favicon_remove" />
+                                                </label>
+
+                                                <span class="btn btn-icon btn-circle btn-color-muted btn-active-color-primary w-25px h-25px bg-body shadow"
+                                                    data-kt-image-input-action="cancel">
+                                                    <i class="ki-outline ki-cross fs-3"></i>
+                                                </span>
+
+                                                <span class="btn btn-icon btn-circle btn-color-muted btn-active-color-primary w-25px h-25px bg-body shadow"
+                                                    data-kt-image-input-action="remove">
+                                                    <i class="ki-outline ki-cross fs-3"></i>
+                                                </span>
+                                            </div>
+                                        </div>
+                                    </div>
+
+                                    <div class="form-group row mt-10">
+                                        <label for="idBrandingWebAdminDisclaimerName" data-i18n="branding.disclaimer_name" class="col-md-3 col-form-label">Disclaimer Name</label>
+                                        <div class="col-md-9">
+                                            <input id="idBrandingWebAdminDisclaimerName" type="text" placeholder="" name="branding_webadmin_disclaimer_name" value="{{.Configs.Branding.WebAdmin.DisclaimerName}}" maxlength="255"
+                                                class="form-control" />
+                                        </div>
+                                    </div>
+
+                                    <div class="form-group row mt-10">
+                                        <label for="idBrandingWebAdminDisclaimerURL" data-i18n="branding.disclaimer_url" class="col-md-3 col-form-label">Disclaimer URL</label>
+                                        <div class="col-md-9">
+                                            <input id="idBrandingWebAdminDisclaimerURL" type="text" placeholder="" name="branding_webadmin_disclaimer_url" value="{{.Configs.Branding.WebAdmin.DisclaimerURL}}" maxlength="1024"
+                                                class="form-control" />
+                                        </div>
+                                    </div>
+
+                                </div>
+                            </div>
+
+                            <div class="card mt-10">
+                                <div class="card-header bg-light">
+                                    <h3 class="card-title section-title-inner">WebClient</h3>
+                                </div>
+                                <div class="card-body">
+                                    <div class="form-group row">
+                                        <label for="idBrandingWebClientName" data-i18n="general.name" class="col-md-3 col-form-label">Name</label>
+                                        <div class="col-md-9">
+                                            <input id="idBrandingWebClientName" type="text" placeholder="SFTPGo WebClient" name="branding_webclient_name" value="{{.Configs.Branding.WebClient.Name}}" maxlength="255"
+                                                class="form-control" />
+                                        </div>
+                                    </div>
+
+                                    <div class="form-group row mt-10">
+                                        <label for="idBrandingWebClientShortName" data-i18n="branding.short_name" class="col-md-3 col-form-label">Short Name</label>
+                                        <div class="col-md-9">
+                                            <input id="idBrandingWebClientShortName" type="text" placeholder="WebClient" name="branding_webclient_short_name" value="{{.Configs.Branding.WebClient.ShortName}}" maxlength="255"
+                                                class="form-control" />
+                                        </div>
+                                    </div>
+
+                                    <div class="form-group row mt-10">
+                                        <label for="idBrandingWebClientLogo" data-i18n="branding.logo" class="col-md-3 col-form-label">Logo</label>
+                                        <div class="col-md-9">
+                                            <div class="image-input image-input-outline {{if eq .Branding.DefaultLogoPath .WebClientBranding.LogoPath}}image-input-empty{{end}} image-input-placeholder" data-kt-image-input="true">
+                                                <div class="image-input-wrapper w-125px h-125px image-input-webclient-current"></div>
+
+                                                <label class="btn btn-icon btn-circle btn-color-muted btn-active-color-primary w-25px h-25px bg-body shadow"
+                                                    data-kt-image-input-action="change">
+                                                    <i class="ki-duotone ki-pencil fs-6"><span class="path1"></span><span class="path2"></span></i>
+                                                    <input type="file" id="idBrandingWebClientLogo" name="branding_webclient_logo" accept=".png" aria-describedby="idBrandingWebClientLogoHelp"/>
+                                                    <input type="hidden"name="branding_webclient_logo_remove" />
+                                                </label>
+
+                                                <span class="btn btn-icon btn-circle btn-color-muted btn-active-color-primary w-25px h-25px bg-body shadow"
+                                                    data-kt-image-input-action="cancel">
+                                                    <i class="ki-outline ki-cross fs-3"></i>
+                                                </span>
+
+                                                <span class="btn btn-icon btn-circle btn-color-muted btn-active-color-primary w-25px h-25px bg-body shadow"
+                                                    data-kt-image-input-action="remove">
+                                                    <i class="ki-outline ki-cross fs-3"></i>
+                                                </span>
+                                            </div>
+                                            <div id="idBrandingWebClientLogoHelp" class="form-text mt-3" data-i18n="branding.logo_help"></div>
+                                        </div>
+                                    </div>
+
+                                    <div class="form-group row mt-10">
+                                        <label for="idBrandingWebClientFavicon" data-i18n="branding.favicon" class="col-md-3 col-form-label">Favicon</label>
+                                        <div class="col-md-9">
+                                            <div class="image-input image-input-outline {{if eq .Branding.DefaultFaviconPath .WebClientBranding.FaviconPath}}image-input-empty{{end}} image-input-placeholder" data-kt-image-input="true">
+                                                <div class="image-input-wrapper w-50px h-50px image-input-webclient-fav-current"></div>
+
+                                                <label class="btn btn-icon btn-circle btn-color-muted btn-active-color-primary w-25px h-25px bg-body shadow"
+                                                    data-kt-image-input-action="change">
+                                                    <i class="ki-duotone ki-pencil fs-6"><span class="path1"></span><span class="path2"></span></i>
+                                                    <input type="file" id="idBrandingWebClientFavicon" name="branding_webclient_favicon" accept=".png" />
+                                                    <input type="hidden"name="branding_webclient_favicon_remove" />
+                                                </label>
+
+                                                <span class="btn btn-icon btn-circle btn-color-muted btn-active-color-primary w-25px h-25px bg-body shadow"
+                                                    data-kt-image-input-action="cancel">
+                                                    <i class="ki-outline ki-cross fs-3"></i>
+                                                </span>
+
+                                                <span class="btn btn-icon btn-circle btn-color-muted btn-active-color-primary w-25px h-25px bg-body shadow"
+                                                    data-kt-image-input-action="remove">
+                                                    <i class="ki-outline ki-cross fs-3"></i>
+                                                </span>
+                                            </div>
+                                        </div>
+                                    </div>
+
+                                    <div class="form-group row mt-10">
+                                        <label for="idBrandingWebClientDisclaimerName" data-i18n="branding.disclaimer_name" class="col-md-3 col-form-label">Disclaimer Name</label>
+                                        <div class="col-md-9">
+                                            <input id="idBrandingWebClientDisclaimerName" type="text" placeholder="" name="branding_webclient_disclaimer_name" value="{{.Configs.Branding.WebClient.DisclaimerName}}" maxlength="255"
+                                                class="form-control" />
+                                        </div>
+                                    </div>
+
+                                    <div class="form-group row mt-10">
+                                        <label for="idBrandingWebClientDisclaimerURL" data-i18n="branding.disclaimer_url" class="col-md-3 col-form-label">Disclaimer URL</label>
+                                        <div class="col-md-9">
+                                            <input id="idBrandingWebClientDisclaimerURL" type="text" placeholder="" name="branding_webclient_disclaimer_url" value="{{.Configs.Branding.WebClient.DisclaimerURL}}" maxlength="1024"
+                                                class="form-control" />
+                                        </div>
+                                    </div>
+
+                                </div>
+                            </div>
+
+                            <div class="d-flex justify-content-end mt-12">
+                                <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+                                <input type="hidden" name="form_action" value="branding_submit">
+                                <button type="submit" id="branding_form_submit" class="btn btn-primary px-10">
+                                    <span data-i18n="general.submit" class="indicator-label">
+                                        Submit
+                                    </span>
+                                    <span data-i18n="general.wait" class="indicator-progress">
+                                        Please wait...
+                                        <span class="spinner-border spinner-border-sm align-middle ms-2"></span>
+                                    </span>
+                                </button>
+                            </div>
+
+                        </form>
+                    </div>
+                </div>
+            </div>
+
         </div>
     </div>
 </div>
@@ -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;
+        });
     });
 </script>
 {{- end}}