web client: add support for integrating external viewers/editors
This commit is contained in:
parent
6092b6628e
commit
bedc8e288b
14 changed files with 417 additions and 68 deletions
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
examples/webclient-integrations/test.html
Normal file
101
examples/webclient-integrations/test.html
Normal file
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -121,7 +121,7 @@
|
|||
cm.setOption("mode", mode.mode);
|
||||
}
|
||||
cm.setValue("{{.Data}}");
|
||||
setInterval(keepAlive, 90000);
|
||||
setInterval(keepAlive, 180000);
|
||||
});
|
||||
|
||||
function keepAlive() {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue