From e44ff487e5951f9ed378eac8f257f91a570ff336 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Fri, 23 Jan 2026 19:22:05 +0100 Subject: [PATCH] httpd: add base URL configuration Allow overriding the browser URL when generating share links. Fixes #1858 Signed-off-by: Nicola Murino --- internal/config/config.go | 7 ++++ internal/config/config_test.go | 5 +++ internal/httpd/auth_utils.go | 2 +- internal/httpd/httpd.go | 39 +++++++++++++++-- internal/httpd/httpd_test.go | 6 +++ internal/httpd/internal_test.go | 74 +++++++++++++++++++++++++++++++++ internal/httpd/webclient.go | 2 + sftpgo.json | 1 + templates/webclient/shares.html | 14 +++++-- 9 files changed, 142 insertions(+), 8 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 23419d88..58863c6d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -120,6 +120,7 @@ var ( ClientIPHeaderDepth: 0, HideLoginURL: 0, RenderOpenAPI: true, + BaseURL: "", Languages: []string{"en"}, OIDC: httpd.OIDC{ ClientID: "", @@ -1886,6 +1887,12 @@ func getHTTPDBindingFromEnv(idx int) { //nolint:gocyclo isSet = true } + baseURL, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%d__BASE_URL", idx)) + if ok { + binding.BaseURL = baseURL + isSet = true + } + languages, ok := lookupStringListFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%d__LANGUAGES", idx)) if ok { binding.Languages = languages diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5840ca9b..668d6769 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1200,6 +1200,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLED_LOGIN_METHODS", "3") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__DISABLED_LOGIN_METHODS", "12") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__RENDER_OPENAPI", "0") + os.Setenv("SFTPGO_HTTPD__BINDINGS__2__BASE_URL", "https://example.com") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__LANGUAGES", "en,es") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_HTTPS", "1 ") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__MIN_TLS_VERSION", "13") @@ -1273,6 +1274,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLED_LOGIN_METHODS") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__DISABLED_LOGIN_METHODS") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__RENDER_OPENAPI") + os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__BASE_URL") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__LANGUAGES") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_AUTH_TYPE") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__TLS_CIPHER_SUITES") @@ -1339,6 +1341,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { require.Equal(t, 0, bindings[0].EnabledLoginMethods) require.Equal(t, 0, bindings[0].DisabledLoginMethods) require.True(t, bindings[0].RenderOpenAPI) + require.Empty(t, bindings[0].BaseURL) require.Len(t, bindings[0].Languages, 1) assert.Contains(t, bindings[0].Languages, "en") require.Len(t, bindings[0].TLSCipherSuites, 1) @@ -1362,6 +1365,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { require.Equal(t, 0, bindings[1].EnabledLoginMethods) require.Equal(t, 0, bindings[1].DisabledLoginMethods) require.True(t, bindings[1].RenderOpenAPI) + require.Empty(t, bindings[1].BaseURL) require.Len(t, bindings[1].Languages, 1) assert.Contains(t, bindings[1].Languages, "en") require.Nil(t, bindings[1].TLSCipherSuites) @@ -1385,6 +1389,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { require.Equal(t, 3, bindings[2].EnabledLoginMethods) require.Equal(t, 12, bindings[2].DisabledLoginMethods) require.False(t, bindings[2].RenderOpenAPI) + require.Equal(t, "https://example.com", bindings[2].BaseURL) require.Len(t, bindings[2].Languages, 2) assert.Contains(t, bindings[2].Languages, "en") assert.Contains(t, bindings[2].Languages, "es") diff --git a/internal/httpd/auth_utils.go b/internal/httpd/auth_utils.go index 5dc3020a..ecd9ce86 100644 --- a/internal/httpd/auth_utils.go +++ b/internal/httpd/auth_utils.go @@ -179,7 +179,7 @@ func isTLS(r *http.Request) bool { return true } if proto, ok := r.Context().Value(forwardedProtoKey).(string); ok { - return proto == "https" + return proto == "https" //nolint:goconst } return false } diff --git a/internal/httpd/httpd.go b/internal/httpd/httpd.go index c96e791e..13e39206 100644 --- a/internal/httpd/httpd.go +++ b/internal/httpd/httpd.go @@ -23,6 +23,7 @@ import ( "fmt" "net" "net/http" + "net/url" "os" "path" "path/filepath" @@ -627,6 +628,10 @@ type Binding struct { HideLoginURL int `json:"hide_login_url" mapstructure:"hide_login_url"` // Enable the built-in OpenAPI renderer RenderOpenAPI bool `json:"render_openapi" mapstructure:"render_openapi"` + // BaseURL defines the external base URL for generating public links + // (currently share access link), bypassing the default browser-based + // detection. + BaseURL string `json:"base_url" mapstructure:"base_url"` // Languages defines the list of enabled translations for the WebAdmin and WebClient UI. Languages []string `json:"languages" mapstructure:"languages"` // Defining an OIDC configuration the web admin and web client UI will use OpenID to authenticate users. @@ -667,6 +672,24 @@ func (b *Binding) languages() []string { return b.Languages } +func (b *Binding) validateBaseURL() error { + if b.BaseURL == "" { + return nil + } + u, err := url.ParseRequestURI(b.BaseURL) + if err != nil { + return err + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("invalid base URL schema %s", b.BaseURL) + } + if u.Host == "" { + return fmt.Errorf("invalid base URL host %s", b.BaseURL) + } + b.BaseURL = strings.TrimRight(u.String(), "/") + return nil +} + func (b *Binding) parseAllowedProxy() error { if filepath.IsAbs(b.Address) && len(b.ProxyAllowed) > 0 { // unix domain socket @@ -700,6 +723,18 @@ func (b *Binding) IsValid() bool { return false } +func (b *Binding) check() error { + if err := b.parseAllowedProxy(); err != nil { + return err + } + if err := b.validateBaseURL(); err != nil { + return err + } + b.checkBranding() + b.Security.updateProxyHeaders() + return nil +} + func (b *Binding) isWebAdminOIDCLoginDisabled() bool { if b.EnableWebAdmin { return b.DisabledLoginMethods&1 != 0 @@ -1135,11 +1170,9 @@ func (c *Conf) Initialize(configDir string, isShared int) error { if !binding.IsValid() { continue } - if err := binding.parseAllowedProxy(); err != nil { + if err := binding.check(); err != nil { return err } - binding.checkBranding() - binding.Security.updateProxyHeaders() go func(b Binding) { if err := b.OIDC.initialize(); err != nil { diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index 08c12d54..be4f8d88 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -580,6 +580,12 @@ func TestInitialization(t *testing.T) { assert.Contains(t, err.Error(), "oidc") } httpdConf.Bindings[0].OIDC = httpd.OIDC{} + httpdConf.Bindings[0].BaseURL = "ftp://127.0.0.1" + err = httpdConf.Initialize(configDir, isShared) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "URL schema") + } + httpdConf.Bindings[0].BaseURL = "" httpdConf.Bindings[0].EnableWebClient = true httpdConf.Bindings[0].EnableWebAdmin = true httpdConf.Bindings[0].EnableRESTAPI = true diff --git a/internal/httpd/internal_test.go b/internal/httpd/internal_test.go index 4a17fa5e..987f0263 100644 --- a/internal/httpd/internal_test.go +++ b/internal/httpd/internal_test.go @@ -4247,6 +4247,80 @@ func TestConvertEnabledLoginMethods(t *testing.T) { assert.Equal(t, 0, b.DisabledLoginMethods) } +func TestValidateBaseURL(t *testing.T) { + tests := []struct { + name string + inputURL string + expectedURL string + expectErr bool + }{ + { + name: "Valid HTTPS URL", + inputURL: "https://sftp.example.com", + expectedURL: "https://sftp.example.com", + expectErr: false, + }, + { + name: "Remove trailing slash", + inputURL: "https://sftp.example.com/", + expectedURL: "https://sftp.example.com", + expectErr: false, + }, + { + name: "Remove multiple trailing slashes", + inputURL: "http://192.168.1.100:8080///", + expectedURL: "http://192.168.1.100:8080", + expectErr: false, + }, + { + name: "Empty BaseURL (optional case)", + inputURL: "", + expectedURL: "", + expectErr: false, + }, + { + name: "Unsupported scheme (FTP)", + inputURL: "ftp://files.example.com", + expectErr: true, + }, + { + name: "Malformed URL string", + inputURL: "not-a-url", + expectErr: true, + }, + { + name: "Missing Host", + inputURL: "https://", + expectErr: true, + }, + { + name: "Preserve path without trailing slash", + inputURL: "https://example.com/sftp/", + expectedURL: "https://example.com/sftp", + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &Binding{ + BaseURL: tt.inputURL, + } + + err := b.validateBaseURL() + + if (err != nil) != tt.expectErr { + t.Errorf("validateBaseURL() error = %v, expectErr %v", err, tt.expectErr) + return + } + + if !tt.expectErr && b.BaseURL != tt.expectedURL { + t.Errorf("validateBaseURL() got = %v, want %v", b.BaseURL, tt.expectedURL) + } + }) + } +} + func getCSRFTokenFromBody(body io.Reader) (string, error) { doc, err := html.Parse(body) if err != nil { diff --git a/internal/httpd/webclient.go b/internal/httpd/webclient.go index fa44e0e5..71aba2ce 100644 --- a/internal/httpd/webclient.go +++ b/internal/httpd/webclient.go @@ -212,6 +212,7 @@ type clientMFAPage struct { type clientSharesPage struct { baseClientPage BasePublicSharesURL string + BaseURL string } type clientSharePage struct { @@ -1609,6 +1610,7 @@ func (s *httpdServer) handleClientGetShares(w http.ResponseWriter, r *http.Reque data := clientSharesPage{ baseClientPage: s.getBaseClientPageData(util.I18nSharesTitle, webClientSharesPath, w, r), BasePublicSharesURL: webClientPubSharesPath, + BaseURL: s.binding.BaseURL, } renderClientTemplate(w, templateClientShares, data) } diff --git a/sftpgo.json b/sftpgo.json index e128c22b..3b3cf1ba 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -283,6 +283,7 @@ "client_ip_header_depth": 0, "hide_login_url": 0, "render_openapi": true, + "base_url": "", "languages": [ "en" ], diff --git a/templates/webclient/shares.html b/templates/webclient/shares.html index 71f36564..2329dc78 100644 --- a/templates/webclient/shares.html +++ b/templates/webclient/shares.html @@ -229,6 +229,12 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). $('#writeShare').hide(); $('#readShare').hide(); } else { + let baseURL; + //{{- if .BaseURL}} + baseURL = '{{.BaseURL}}'; + //{{- else}} + baseURL = getCurrentURI(); + //{{- end}} let shareURL = '{{.BasePublicSharesURL}}' + "/" + encodeURIComponent(shareID); if (shareScope == '1') { $('#expiredShare').hide(); @@ -236,20 +242,20 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). $('#readShare').show(); $('#readLink').attr("href", shareURL + "/download"); $('#readLink').attr("title", shareURL + "/download"); - $('#readLinkCopy').attr("data-clipboard-text",getCurrentURI()+shareURL + "/download"); + $('#readLinkCopy').attr("data-clipboard-text",baseURL+shareURL + "/download"); $('#readUncompressedLink').attr("href", shareURL + "/download?compress=false"); $('#readUncompressedLink').attr("title", shareURL + "/download?compress=false"); - $('#readUncompressedLinkCopy').attr("data-clipboard-text",getCurrentURI()+shareURL + "/download?compress=false"); + $('#readUncompressedLinkCopy').attr("data-clipboard-text",baseURL+shareURL + "/download?compress=false"); $('#readBrowseLink').attr("href", shareURL + "/browse"); $('#readBrowseLink').attr("title", shareURL + "/browse"); - $('#readBrowseLinkCopy').attr("data-clipboard-text",getCurrentURI()+shareURL + "/browse"); + $('#readBrowseLinkCopy').attr("data-clipboard-text",baseURL+shareURL + "/browse"); } else { $('#expiredShare').hide(); $('#writeShare').show(); $('#readShare').hide(); $('#writePageLink').attr("href", shareURL + "/upload"); $('#writePageLink').attr("title", shareURL + "/upload"); - $('#writePageLinkCopy').attr("data-clipboard-text",getCurrentURI()+shareURL + "/upload"); + $('#writePageLinkCopy').attr("data-clipboard-text",baseURL+shareURL + "/upload"); } } $('#link_modal').modal('show');