allow to customize name and log from the WebUI

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2024-07-23 20:52:36 +02:00
parent b2926377b7
commit b5c821795a
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
16 changed files with 996 additions and 101 deletions

14
go.mod
View file

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

36
go.sum
View file

@ -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=

View file

@ -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 {

View file

@ -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
}

View file

@ -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])

View file

@ -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
}

View file

@ -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")

View file

@ -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()

View file

@ -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)

View file

@ -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) {

View file

@ -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)
}

View file

@ -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 {

View file

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

View file

@ -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",

View file

@ -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",

View file

@ -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}}