diff --git a/config/config.go b/config/config.go index 4d594e0f..0efb2cff 100644 --- a/config/config.go +++ b/config/config.go @@ -70,16 +70,17 @@ var ( ProxyAllowed: nil, } defaultHTTPDBinding = httpd.Binding{ - Address: "127.0.0.1", - Port: 8080, - EnableWebAdmin: true, - EnableWebClient: true, - EnableHTTPS: false, - ClientAuthType: 0, - TLSCipherSuites: nil, - ProxyAllowed: nil, - HideLoginURL: 0, - RenderOpenAPI: true, + Address: "127.0.0.1", + Port: 8080, + EnableWebAdmin: true, + EnableWebClient: true, + EnableHTTPS: false, + ClientAuthType: 0, + TLSCipherSuites: nil, + ProxyAllowed: nil, + HideLoginURL: 0, + RenderOpenAPI: true, + WebClientIntegrations: nil, } defaultRateLimiter = common.RateLimiterConfig{ Average: 0, @@ -1022,6 +1023,31 @@ func getWebDAVDBindingFromEnv(idx int) { } } +func getHTTPDWebClientIntegrationsFromEnv(idx int) []httpd.WebClientIntegration { + var integrations []httpd.WebClientIntegration + + for subIdx := 0; subIdx < 10; subIdx++ { + var integration httpd.WebClientIntegration + + url, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__WEB_CLIENT_INTEGRATIONS__%v__URL", idx, subIdx)) + if ok { + integration.URL = url + } + + extensions, ok := lookupStringListFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__WEB_CLIENT_INTEGRATIONS__%v__FILE_EXTENSIONS", + idx, subIdx)) + if ok { + integration.FileExtensions = extensions + } + + if url != "" && len(extensions) > 0 { + integrations = append(integrations, integration) + } + } + + return integrations +} + func getHTTPDBindingFromEnv(idx int) { binding := httpd.Binding{ EnableWebAdmin: true, @@ -1064,6 +1090,12 @@ func getHTTPDBindingFromEnv(idx int) { isSet = true } + webClientIntegrations := getHTTPDWebClientIntegrationsFromEnv(idx) + if len(webClientIntegrations) > 0 { + binding.WebClientIntegrations = webClientIntegrations + isSet = true + } + enableHTTPS, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__ENABLE_HTTPS", idx)) if ok { binding.EnableHTTPS = enableHTTPS diff --git a/config/config_test.go b/config/config_test.go index 95f9799b..122f70f5 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -770,6 +770,10 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { os.Setenv("SFTPGO_HTTPD__BINDINGS__2__TLS_CIPHER_SUITES", " TLS_AES_256_GCM_SHA384 , TLS_CHACHA20_POLY1305_SHA256") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__PROXY_ALLOWED", " 192.168.9.1 , 172.16.25.0/24") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__HIDE_LOGIN_URL", "3") + os.Setenv("SFTPGO_HTTPD__BINDINGS__2__WEB_CLIENT_INTEGRATIONS__1__URL", "http://127.0.0.1/") + os.Setenv("SFTPGO_HTTPD__BINDINGS__2__WEB_CLIENT_INTEGRATIONS__1__FILE_EXTENSIONS", ".pdf, .txt") + os.Setenv("SFTPGO_HTTPD__BINDINGS__2__WEB_CLIENT_INTEGRATIONS__2__URL", "http://127.0.1.1/") + os.Setenv("SFTPGO_HTTPD__BINDINGS__2__WEB_CLIENT_INTEGRATIONS__3__FILE_EXTENSIONS", ".jpg, .txt") t.Cleanup(func() { os.Unsetenv("SFTPGO_HTTPD__BINDINGS__0__ADDRESS") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__0__PORT") @@ -788,6 +792,10 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__TLS_CIPHER_SUITES") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__PROXY_ALLOWED") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__HIDE_LOGIN_URL") + os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__WEB_CLIENT_INTEGRATIONS__1__URL") + os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__WEB_CLIENT_INTEGRATIONS__1__FILE_EXTENSIONS") + os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__WEB_CLIENT_INTEGRATIONS__2__URL") + os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__WEB_CLIENT_INTEGRATIONS__3__FILE_EXTENSIONS") }) configDir := ".." @@ -827,6 +835,9 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { require.Equal(t, "192.168.9.1", bindings[2].ProxyAllowed[0]) require.Equal(t, "172.16.25.0/24", bindings[2].ProxyAllowed[1]) require.Equal(t, 3, bindings[2].HideLoginURL) + require.Len(t, bindings[2].WebClientIntegrations, 1) + require.Equal(t, "http://127.0.0.1/", bindings[2].WebClientIntegrations[0].URL) + require.Equal(t, []string{".pdf", ".txt"}, bindings[2].WebClientIntegrations[0].FileExtensions) } func TestHTTPClientCertificatesFromEnv(t *testing.T) { diff --git a/docs/full-configuration.md b/docs/full-configuration.md index fabc02da..9434eba7 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -226,6 +226,9 @@ The configuration file contains the following sections: - `proxy_allowed`, list of IP addresses and IP ranges allowed to set `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto`, `CF-Connecting-IP`, `True-Client-IP` headers. Any of the indicated headers, if set on requests from a connection address not in this list, will be silently ignored. Default: empty. - `hide_login_url`, integer. If both web admin and web client are enabled each login page will show a link to the other one. This setting allows to hide this link. 0 means that the login links are displayed on both admin and client login page. This is the default. 1 means that the login link to the web client login page is hidden on admin login page. 2 means that the login link to the web admin login page is hidden on client login page. The flags can be combined, for example 3 will disable both login links. - `render_openapi`, boolean. Set to `false` to disable serving of the OpenAPI schema and renderer. Default `true`. + - `web_client_integrations`, list of struct. The SFTPGo web client allows to send the files with the specified extensions to the configured URL using the [postMessage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). This way you can integrate your own file viewer or editor. Take a look at the commentented example [here](../examples/webclient-integrations/test.html) to understand how to use this feature. Each struct has the following fields: + - `file_extensions`, list of strings. File extensions must be specified with the leading dot, for example `.pdf`. + - `url`, string. URL to open for the configured file extensions. The url will open in a new tab. - `templates_path`, string. Path to the HTML web templates. This can be an absolute path or a path relative to the config dir - `static_files_path`, string. Path to the static files for the web interface. This can be an absolute path or a path relative to the config dir. If both `templates_path` and `static_files_path` are empty the built-in web interface will be disabled - `backups_path`, string. Path to the backup directory. This can be an absolute path or a path relative to the config dir. We don't allow backups in arbitrary paths for security reasons diff --git a/examples/webclient-integrations/test.html b/examples/webclient-integrations/test.html new file mode 100644 index 00000000..cae527ef --- /dev/null +++ b/examples/webclient-integrations/test.html @@ -0,0 +1,101 @@ + + + + + + + + + SFTPGo WebClient - External integration test + + + + +
+ +
+ + + + + diff --git a/httpd/httpd.go b/httpd/httpd.go index 53acd419..775dc05f 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -223,6 +223,14 @@ func init() { updateWebClientURLs("") } +// WebClientIntegration defines the configuration for an external Web Client integration +type WebClientIntegration struct { + // Files with these extensions can be sent to the configured URL + FileExtensions []string `json:"file_extensions" mapstructure:"file_extensions"` + // URL that will receive the files + URL string `json:"url" mapstructure:"url"` +} + // Binding defines the configuration for a network listener type Binding struct { // The address to listen on. A blank value means listen on all available network interfaces. @@ -262,8 +270,21 @@ type Binding struct { // The flags can be combined, for example 3 will disable both login links. HideLoginURL int `json:"hide_login_url" mapstructure:"hide_login_url"` // Enable the built-in OpenAPI renderer - RenderOpenAPI bool `json:"render_openapi" mapstructure:"render_openapi"` - allowHeadersFrom []func(net.IP) bool + RenderOpenAPI bool `json:"render_openapi" mapstructure:"render_openapi"` + // Enabling web client integrations you can render or modify the files with the specified + // extensions using an external tool. + WebClientIntegrations []WebClientIntegration `json:"web_client_integrations" mapstructure:"web_client_integrations"` + allowHeadersFrom []func(net.IP) bool +} + +func (b *Binding) checkWebClientIntegrations() { + var integrations []WebClientIntegration + for _, integration := range b.WebClientIntegrations { + if integration.URL != "" && len(integration.FileExtensions) > 0 { + integrations = append(integrations, integration) + } + } + b.WebClientIntegrations = integrations } func (b *Binding) parseAllowedProxy() error { @@ -477,6 +498,7 @@ func (c *Conf) Initialize(configDir string) error { if err := binding.parseAllowedProxy(); err != nil { return err } + binding.checkWebClientIntegrations() go func(b Binding) { server := newHttpdServer(b, staticFilesPath, c.SigningPassphrase, c.Cors, openAPIPath) @@ -618,14 +640,7 @@ func updateWebAdminURLs(baseURL string) { } // GetHTTPRouter returns an HTTP handler suitable to use for test cases -func GetHTTPRouter() http.Handler { - b := Binding{ - Address: "", - Port: 8080, - EnableWebAdmin: true, - EnableWebClient: true, - RenderOpenAPI: true, - } +func GetHTTPRouter(b Binding) http.Handler { server := newHttpdServer(b, "../static", "", CorsConfig{}, "../openapi") server.initializeRouter() return server.router diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 52e2a154..4d7f8174 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -262,6 +262,8 @@ func TestMain(m *testing.M) { os.Setenv("SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN", "1") os.Setenv("SFTPGO_DEFAULT_ADMIN_USERNAME", "admin") os.Setenv("SFTPGO_DEFAULT_ADMIN_PASSWORD", "password") + os.Setenv("SFTPGO_HTTPD__BINDINGS__0__WEB_CLIENT_INTEGRATIONS__0__URL", "http://127.0.0.1/test.html") + os.Setenv("SFTPGO_HTTPD__BINDINGS__0__WEB_CLIENT_INTEGRATIONS__0__FILE_EXTENSIONS", ".pdf,.txt") err := config.LoadConfig(configDir, "") if err != nil { logger.WarnToConsole("error loading configuration: %v", err) @@ -393,7 +395,7 @@ func TestMain(m *testing.M) { waitTCPListening(httpdConf.Bindings[0].GetAddress()) httpd.ReloadCertificateMgr() //nolint:errcheck - testServer = httptest.NewServer(httpd.GetHTTPRouter()) + testServer = httptest.NewServer(httpd.GetHTTPRouter(httpdConf.Bindings[0])) defer testServer.Close() exitCode := m.Run() diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 6039d97b..f33c243b 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -664,7 +664,13 @@ func TestCSRFToken(t *testing.T) { assert.Contains(t, err.Error(), "form token is not valid") } - r := GetHTTPRouter() + r := GetHTTPRouter(Binding{ + Address: "", + Port: 8080, + EnableWebAdmin: true, + EnableWebClient: true, + RenderOpenAPI: true, + }) fn := verifyCSRFHeader(r) rr := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodDelete, path.Join(userPath, "username"), nil) @@ -883,7 +889,13 @@ func TestCreateTokenError(t *testing.T) { } func TestAPIKeyAuthForbidden(t *testing.T) { - r := GetHTTPRouter() + r := GetHTTPRouter(Binding{ + Address: "", + Port: 8080, + EnableWebAdmin: true, + EnableWebClient: true, + RenderOpenAPI: true, + }) fn := forbidAPIKeyAuthentication(r) rr := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, versionPath, nil) @@ -900,7 +912,13 @@ func TestJWTTokenValidation(t *testing.T) { token, _, err := tokenAuth.Encode(claims) assert.NoError(t, err) - r := GetHTTPRouter() + r := GetHTTPRouter(Binding{ + Address: "", + Port: 8080, + EnableWebAdmin: true, + EnableWebClient: true, + RenderOpenAPI: true, + }) fn := jwtAuthenticatorAPI(r) rr := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, userPath, nil) @@ -1912,14 +1930,14 @@ func TestWebUserInvalidClaims(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil) req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"])) - handleClientGetFiles(rr, req) + server.handleClientGetFiles(rr, req) assert.Equal(t, http.StatusForbidden, rr.Code) assert.Contains(t, rr.Body.String(), "Invalid token claims") rr = httptest.NewRecorder() req, _ = http.NewRequest(http.MethodGet, webClientDirsPath, nil) req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"])) - handleClientGetDirContents(rr, req) + server.handleClientGetDirContents(rr, req) assert.Equal(t, http.StatusForbidden, rr.Code) assert.Contains(t, rr.Body.String(), "invalid token claims") diff --git a/httpd/server.go b/httpd/server.go index 88abc69b..852243fe 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -1222,7 +1222,7 @@ func (s *httpdServer) initializeRouter() { router.Use(jwtAuthenticatorWebClient) router.Get(webClientLogoutPath, handleWebClientLogout) - router.With(s.refreshCookie).Get(webClientFilesPath, handleClientGetFiles) + router.With(s.refreshCookie).Get(webClientFilesPath, s.handleClientGetFiles) router.With(s.refreshCookie).Get(webClientViewPDFPath, handleClientViewPDF) router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader). Post(webClientFilesPath, uploadUserFiles) @@ -1231,7 +1231,7 @@ func (s *httpdServer) initializeRouter() { Patch(webClientFilesPath, renameUserFile) router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader). Delete(webClientFilesPath, deleteUserFile) - router.With(compressor.Handler, s.refreshCookie).Get(webClientDirsPath, handleClientGetDirContents) + router.With(compressor.Handler, s.refreshCookie).Get(webClientDirsPath, s.handleClientGetDirContents) router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader). Post(webClientDirsPath, createUserDir) router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader). diff --git a/httpd/webclient.go b/httpd/webclient.go index f430d1a6..e6b2e49f 100644 --- a/httpd/webclient.go +++ b/httpd/webclient.go @@ -117,18 +117,19 @@ type editFilePage struct { type filesPage struct { baseClientPage - CurrentDir string - DirsURL string - DownloadURL string - ViewPDFURL string - CanAddFiles bool - CanCreateDirs bool - CanRename bool - CanDelete bool - CanDownload bool - CanShare bool - Error string - Paths []dirMapping + CurrentDir string + DirsURL string + DownloadURL string + ViewPDFURL string + CanAddFiles bool + CanCreateDirs bool + CanRename bool + CanDelete bool + CanDownload bool + CanShare bool + Error string + Paths []dirMapping + HasIntegrations bool } type clientMessagePage struct { @@ -436,20 +437,23 @@ func renderAddUpdateSharePage(w http.ResponseWriter, r *http.Request, share *dat renderClientTemplate(w, templateClientShare, data) } -func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, user dataprovider.User) { +func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, user dataprovider.User, + hasIntegrations bool, +) { data := filesPage{ - baseClientPage: getBaseClientPageData(pageClientFilesTitle, webClientFilesPath, r), - Error: error, - CurrentDir: url.QueryEscape(dirName), - DownloadURL: webClientDownloadZipPath, - ViewPDFURL: webClientViewPDFPath, - DirsURL: webClientDirsPath, - CanAddFiles: user.CanAddFilesFromWeb(dirName), - CanCreateDirs: user.CanAddDirsFromWeb(dirName), - CanRename: user.CanRenameFromWeb(dirName, dirName), - CanDelete: user.CanDeleteFromWeb(dirName), - CanDownload: user.HasPerm(dataprovider.PermDownload, dirName), - CanShare: user.CanManageShares(), + baseClientPage: getBaseClientPageData(pageClientFilesTitle, webClientFilesPath, r), + Error: error, + CurrentDir: url.QueryEscape(dirName), + DownloadURL: webClientDownloadZipPath, + ViewPDFURL: webClientViewPDFPath, + DirsURL: webClientDirsPath, + CanAddFiles: user.CanAddFilesFromWeb(dirName), + CanCreateDirs: user.CanAddDirsFromWeb(dirName), + CanRename: user.CanRenameFromWeb(dirName, dirName), + CanDelete: user.CanDeleteFromWeb(dirName), + CanDownload: user.HasPerm(dataprovider.PermDownload, dirName), + CanShare: user.CanManageShares(), + HasIntegrations: hasIntegrations, } paths := []dirMapping{} if dirName != "/" { @@ -552,7 +556,7 @@ func handleWebClientDownloadZip(w http.ResponseWriter, r *http.Request) { renderCompressedFiles(w, connection, name, filesList, nil) } -func handleClientGetDirContents(w http.ResponseWriter, r *http.Request) { +func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) claims, err := getTokenClaims(r) if err != nil || claims.Username == "" { @@ -595,7 +599,6 @@ func handleClientGetDirContents(w http.ResponseWriter, r *http.Request) { for _, info := range contents { res := make(map[string]string) res["url"] = getFileObjectURL(name, info.Name()) - editURL := "" if info.IsDir() { res["type"] = "1" res["size"] = "" @@ -606,21 +609,29 @@ func handleClientGetDirContents(w http.ResponseWriter, r *http.Request) { } else { res["size"] = util.ByteCountIEC(info.Size()) if info.Size() < httpdMaxEditFileSize { - editURL = strings.Replace(res["url"], webClientFilesPath, webClientEditFilePath, 1) + res["edit_url"] = strings.Replace(res["url"], webClientFilesPath, webClientEditFilePath, 1) + } + if len(s.binding.WebClientIntegrations) > 0 { + extension := path.Ext(info.Name()) + for idx := range s.binding.WebClientIntegrations { + if util.IsStringInSlice(extension, s.binding.WebClientIntegrations[idx].FileExtensions) { + res["ext_url"] = s.binding.WebClientIntegrations[idx].URL + break + } + } } } } res["meta"] = fmt.Sprintf("%v_%v", res["type"], info.Name()) res["name"] = info.Name() res["last_modified"] = getFileObjectModTime(info.ModTime()) - res["edit_url"] = editURL results = append(results, res) } render.JSON(w, r, results) } -func handleClientGetFiles(w http.ResponseWriter, r *http.Request) { +func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) claims, err := getTokenClaims(r) if err != nil || claims.Username == "" { @@ -659,11 +670,12 @@ func handleClientGetFiles(w http.ResponseWriter, r *http.Request) { info, err = connection.Stat(name, 0) } if err != nil { - renderFilesPage(w, r, path.Dir(name), fmt.Sprintf("unable to stat file %#v: %v", name, err), user) + renderFilesPage(w, r, path.Dir(name), fmt.Sprintf("unable to stat file %#v: %v", name, err), + user, len(s.binding.WebClientIntegrations) > 0) return } if info.IsDir() { - renderFilesPage(w, r, name, "", user) + renderFilesPage(w, r, name, "", user, len(s.binding.WebClientIntegrations) > 0) return } inline := r.URL.Query().Get("inline") != "" @@ -673,7 +685,7 @@ func handleClientGetFiles(w http.ResponseWriter, r *http.Request) { renderClientMessagePage(w, r, http.StatusText(status), "", status, err, "") return } - renderFilesPage(w, r, path.Dir(name), err.Error(), user) + renderFilesPage(w, r, path.Dir(name), err.Error(), user, len(s.binding.WebClientIntegrations) > 0) } } } diff --git a/sftpgo.json b/sftpgo.json index 99526d5a..00e3d123 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -211,7 +211,8 @@ "tls_cipher_suites": [], "proxy_allowed": [], "hide_login_url": 0, - "render_openapi": true + "render_openapi": true, + "web_client_integrations": [] } ], "templates_path": "templates", diff --git a/templates/webclient/editfile.html b/templates/webclient/editfile.html index eed629fb..2eef6f74 100644 --- a/templates/webclient/editfile.html +++ b/templates/webclient/editfile.html @@ -121,7 +121,7 @@ cm.setOption("mode", mode.mode); } cm.setValue("{{.Data}}"); - setInterval(keepAlive, 90000); + setInterval(keepAlive, 180000); }); function keepAlive() { diff --git a/templates/webclient/files.html b/templates/webclient/files.html index f3121d55..7110a3d0 100644 --- a/templates/webclient/files.html +++ b/templates/webclient/files.html @@ -42,6 +42,7 @@ Size Last modified + @@ -183,6 +184,145 @@ +{{if .HasIntegrations}} + +{{end}}