Skip to content

Commit

Permalink
web client: add support for integrating external viewers/editors
Browse files Browse the repository at this point in the history
  • Loading branch information
drakkan committed Dec 3, 2021
1 parent 6092b66 commit bedc8e2
Show file tree
Hide file tree
Showing 14 changed files with 417 additions and 68 deletions.
52 changes: 42 additions & 10 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 := ".."
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions docs/full-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions examples/webclient-integrations/test.html
Original file line number Diff line number Diff line change
@@ -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>
35 changes: 25 additions & 10 deletions httpd/httpd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion httpd/httpd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
28 changes: 23 additions & 5 deletions httpd/internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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")

Expand Down
4 changes: 2 additions & 2 deletions httpd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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).
Expand Down
Loading

0 comments on commit bedc8e2

Please sign in to comment.