web client: add support for integrating external viewers/editors

This commit is contained in:
Nicola Murino 2021-12-03 18:33:08 +01:00
parent 6092b6628e
commit bedc8e288b
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
14 changed files with 417 additions and 68 deletions

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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