httpd: add base URL configuration

Allow overriding the browser URL when generating share links.

Fixes #1858

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2026-01-23 19:22:05 +01:00
commit e44ff487e5
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
9 changed files with 142 additions and 8 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -283,6 +283,7 @@
"client_ip_header_depth": 0,
"hide_login_url": 0,
"render_openapi": true,
"base_url": "",
"languages": [
"en"
],

View file

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