Browse Source

web client: add support for integrating external viewers/editors

Nicola Murino 3 years ago
parent
commit
bedc8e288b

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

+ 11 - 0
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) {

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

+ 101 - 0
examples/webclient-integrations/test.html

@@ -0,0 +1,101 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+
+    <title>SFTPGo WebClient - External integration test</title>
+</head>
+
+<body>
+    <textarea id="textarea_test" name="textarea_test" rows="6" cols="80">The text here will be sent to SFTPGo as blob</textarea>
+    <br>
+    <button onclick="saveBlob(false);">Save</button>
+    <br>
+    <button onclick="saveBlob(true);">Save binary file</button>
+
+    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
+    <script type="text/javascript">
+        var fileName;
+        var sftpgoUser;
+
+        // in real world usage set the origin when you call postMessage, we use `*` for testing purpose here
+        $(document).ready(function () {
+            if (window.opener == null || window.opener.closed) {
+                console.log("windows opener gone!");
+                return;
+            }
+            // notify SFTPGo that the page is ready to receive the file
+            window.opener.postMessage({type: 'ready'},"*");
+        });
+
+        window.addEventListener('message', (event) => {
+            if (window.opener == null || window.opener.closed) {
+                console.log("windows opener gone!");
+                return;
+            }
+            // you should check the origin before continuing
+            console.log("new message: "+JSON.stringify(event.data));
+            switch (event.data.type){
+                case 'readyResponse':
+                    // after sending the ready request SFTPGo will reply with this response
+                    // now you know the file name and the SFTPGo user
+                    fileName = event.data.file_name;
+                    sftpgoUser = event.data.user;
+                    console.log("ready response received, file name: " + fileName+" SFTPGo user: "+sftpgoUser);
+                    // you can initialize your viewer/editor based on the file extension and request the blob
+                    window.opener.postMessage({type: 'sendBlob'}, "*");
+                    break;
+                case 'blobDownloadStart':
+                    // SFTPGo may take a while to read the file, just before it starts reading it will send this message.
+                    // You can initialize a spinner if required for this file or simply ignore this message
+                    console.log("blob download start received, file name: " + fileName+" SFTPGo user: "+sftpgoUser);
+                    break;
+                case 'blob':
+                    // we received the file as blob
+                    var extension = fileName.slice((fileName.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase();
+                    console.log("blob received, file name: " + fileName+" extension: "+extension+" SFTPGo user: "+sftpgoUser);
+                    if (extension == "txt"){
+                        event.data.file.text().then(function(text){
+                            $("#textarea_test").val(text);
+                        });
+                    }
+                    break;
+                case 'blobSaveResult':
+                    // event.data.status is OK or KO, if KO message is not empty
+                    console.log("blob save status: "+event.data.status+", message: "+event.data.message);
+                    if (event.data.status == "OK"){
+                        console.log("blob saved, I'm useless now, close me");
+                    }
+                    break;
+                default:
+                    console.log("Unsupported message: " + JSON.stringify(event.data));
+            }
+        });
+
+        function saveBlob(binary){
+            // if we have modified the file we can send it back to SFTPGo as a blob for saving
+            console.log("save blob, binary? "+binary);
+            if (binary){
+                // we download and save the SFTPGo logo
+                fetch('https://raw.githubusercontent.com/drakkan/sftpgo/main/docs/howto/img/logo.png')
+                    .then(response => response.blob())
+                    .then(function(responseBlob){
+                        var blob = new File([responseBlob], fileName);
+                        window.opener.postMessage({
+                            type: 'saveBlob',
+                            file: blob
+                        },"*");
+                    });
+            } else {
+                var blob = new Blob([$("#textarea_test").val()]);
+                window.opener.postMessage({
+                    type: 'saveBlob',
+                    file: blob
+                },"*");
+            }
+        }
+    </script>
+</body>

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

+ 3 - 1
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()

+ 23 - 5
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")
 

+ 2 - 2
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).

+ 45 - 33
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)
 		}
 	}
 }

+ 2 - 1
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",

+ 1 - 1
templates/webclient/editfile.html

@@ -121,7 +121,7 @@
             cm.setOption("mode", mode.mode);
         }
         cm.setValue("{{.Data}}");
-        setInterval(keepAlive, 90000);
+        setInterval(keepAlive, 180000);
     });
 
     function keepAlive() {

+ 156 - 3
templates/webclient/files.html

@@ -42,6 +42,7 @@
                         <th>Size</th>
                         <th>Last modified</th>
                         <th></th>
+                        <th></th>
                     </tr>
                 </thead>
             </table>
@@ -183,6 +184,145 @@
 <script src="{{.StaticURL}}/vendor/pdfobject/pdfobject.min.js"></script>
 <script src="{{.StaticURL}}/vendor/codemirror/codemirror.js"></script>
 <script src="{{.StaticURL}}/vendor/codemirror/meta.js"></script>
+{{if .HasIntegrations}}
+<script type="text/javascript">
+    var childReference = null;
+    var checkerStarted = false;
+    const childProps = new Map();
+
+    function openExternalURL(url, fileLink, fileName){
+        if (childReference == null || childReference.closed) {
+            childProps.set('link', fileLink);
+            childProps.set('url', url);
+            childProps.set('file_name', fileName);
+            childReference = window.open(url, '_blank');
+            if (!checkerStarted){
+                keepAlive();
+                setInterval(checkExternalWindow, 180000);
+                checkerStarted = true;
+            }
+        } else {
+            $('#errorTxt').text('An external window is already open, please close it before trying to open a new one');
+            $('#errorMsg').show();
+            setTimeout(function () {
+                $('#errorMsg').hide();
+            }, 8000);
+        }
+    }
+
+    function notifySave(status, message){
+        if (childReference == null || childReference.closed) {
+            console.log("external windows null or closed, cannot notify save");
+            return;
+        }
+
+        childReference.postMessage({
+            type: 'blobSaveResult',
+            status: status,
+            message: message
+        }, childProps.get('url'));
+    }
+
+    window.addEventListener('message', (event) => {
+        var url = childProps.get('url');
+        if (!url || !url.startsWith(event.origin)){
+            console.log("origin: "+event.origin+" does not match the expected one: "+url+" refusing message");
+            return;
+        }
+        if (childReference == null || childReference.closed) {
+            console.log("external windows null or closed, refusing message");
+            return;
+        }
+        switch (event.data.type){
+            case 'ready':
+                // the child is ready send some details
+                childReference.postMessage({
+                    type: 'readyResponse',
+                    user: '{{.LoggedUser.Username}}',
+                    file_name: childProps.get('file_name')
+                }, childProps.get('url'));
+                break;
+            case 'sendBlob':
+                // we have to download the blob, this can require some time so
+                // we first send a blobDownloadStart message so the child can
+                // show a spinner or something similar
+                childReference.postMessage({
+                    type: 'blobDownloadStart'
+                }, childProps.get('url'));
+                // download the file and send as blob to the child window
+                fetch(childProps.get('link'))
+                    .then(response => response.blob())
+                    .then(function(responseBlob){
+                        let fileBlob = new File([responseBlob], childProps.get('file_name'), {type: responseBlob.type, lastModified: ""});
+                        childReference.postMessage({
+                            type: 'blob',
+                            file: fileBlob
+                        }, childProps.get('url'));
+                    });
+                break;
+            case 'saveBlob':
+                // get the blob from the message and save it
+                var path = '{{.FilesURL}}?path={{.CurrentDir}}';
+                spinnerDone = false;
+                var file = new File([event.data.file], childProps.get('file_name'));
+                var data = new FormData();
+                data.append('filenames', file);
+
+                $.ajax({
+                    url: path,
+                    type: 'POST',
+                    data: data,
+                    processData: false,
+                    contentType: false,
+                    headers: { 'X-CSRF-TOKEN': '{{.CSRFToken}}' },
+                    timeout: 0,
+                    beforeSend: function () {
+                        $('#spinnerModal').modal('show');
+                    },
+                    success: function (result) {
+                        $('#spinnerModal').modal('hide');
+                        notifySave("OK", "");
+                        setTimeout(function () {
+                            location.reload();
+                        }, 2000);
+                    },
+                    error: function ($xhr, textStatus, errorThrown) {
+                        $('#spinnerModal').modal('hide');
+                        var txt = "Error saving external file";
+                        if ($xhr) {
+                            var json = $xhr.responseJSON;
+                            if (json) {
+                                if (json.message) {
+                                    txt = json.message;
+                                }
+                                if (json.error) {
+                                    txt += ": " + json.error;
+                                }
+                            }
+                        }
+                        notifySave("KO", txt);
+                        $('#errorTxt').text(txt);
+                        $('#errorMsg').show();
+                        setTimeout(function () {
+                            $('#errorMsg').hide();
+                        }, 5000);
+                    }
+                });
+
+                break;
+            default:
+                console.log("Unsupported message: "+JSON.stringify(event.data));
+        }
+    });
+
+    function checkExternalWindow() {
+        if (childReference == null || childReference.closed) {
+            return;
+        }
+        keepAlive();
+    }
+</script>
+{{end}}
 <script type="text/javascript">
     var spinnerDone = false;
 
@@ -420,7 +560,8 @@
 
         $("#upload_files_form").submit(function (event){
             event.preventDefault();
-            var keepAliveTimer = setInterval(keepAlive, 90000);
+            keepAlive();
+            var keepAliveTimer = setInterval(keepAlive, 180000);
             var path = '{{.FilesURL}}?path={{.CurrentDir}}';
 
             var files = $("#files_name")[0].files;
@@ -683,7 +824,7 @@
                         if (type === 'display') {
                             var filename = row["name"];
                             var extension = filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase();
-                            if (data != ""){
+                            if (data){
                                 if (extension == "csv" || extension == "bat" || CodeMirror.findModeByExtension(extension) != null){
                                     {{if .CanAddFiles}}
                                     return `<a href="${data}"><i class="fas fa-edit"></i></a>`;
@@ -715,6 +856,18 @@
                         }
                         return "";
                     }
+                },
+                { "data": "ext_url",
+                    "render": function (data, type, row) {
+                        {{if .HasIntegrations}}
+                        if (type === 'display') {
+                            if (data){
+                                return `<a href="#" onclick="openExternalURL('${data}', '${row["url"]}', '${row["name"]}');"><i class="fas fa-external-link-alt"></i></a>`;
+                            }
+                        }
+                        {{end}}
+                        return "";
+                    }
                 }
             ],
             "buttons": [],
@@ -760,7 +913,7 @@
                     "searchable": false
                 },
                 {
-                    "targets": [5],
+                    "targets": [5, 6],
                     "orderable": false,
                     "searchable": false
                 }

+ 2 - 2
windows-installer/README.txt

@@ -9,13 +9,13 @@ and complete the initial setup.
 
 The SFTP service is available, by default, on port 2022.
 
-If SFTPGo does not start, make sure that TCP ports 2022 and 8080 are not used by other services or change the SFTPGo configuration to suit your needs.
+If the SFTPGo service does not start, make sure that TCP ports 2022 and 8080 are not used by other services or change the SFTPGo configuration to suit your needs.
 
 Default data location:
 
 C:\ProgramData\SFTPGo
 
-Default configuration file location:
+Configuration file location:
 
 C:\ProgramData\SFTPGo\sftpgo.json
 

+ 1 - 0
windows-installer/sftpgo.iss

@@ -64,6 +64,7 @@ Source: "{#MyAppDir}\sftpgo.exe"; DestDir: "{app}"; Flags: ignoreversion signonc
 Source: "{#MyAppDir}\sftpgo.db"; DestDir: "{commonappdata}\{#MyAppName}"; Flags: onlyifdoesntexist uninsneveruninstall
 Source: "{#MyAppDir}\LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion
 Source: "{#MyAppDir}\sftpgo.json"; DestDir: "{commonappdata}\{#MyAppName}"; Flags: onlyifdoesntexist uninsneveruninstall
+Source: "{#MyAppDir}\sftpgo.json"; DestDir: "{commonappdata}\{#MyAppName}"; DestName: "sftpgo.json.default"; Flags: ignoreversion
 Source: "{#MyAppDir}\templates\*"; DestDir: "{commonappdata}\{#MyAppName}\templates"; Flags: ignoreversion recursesubdirs createallsubdirs
 Source: "{#MyAppDir}\static\*"; DestDir: "{commonappdata}\{#MyAppName}\static"; Flags: ignoreversion recursesubdirs createallsubdirs
 Source: "{#MyAppDir}\openapi\*"; DestDir: "{commonappdata}\{#MyAppName}\openapi"; Flags: ignoreversion recursesubdirs createallsubdirs