Browse Source

WebClient: add copy action

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 2 years ago
parent
commit
15ad31da54

+ 3 - 3
go.mod

@@ -15,7 +15,7 @@ require (
 	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.46
 	github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.26
 	github.com/aws/aws-sdk-go-v2/service/s3 v1.29.6
-	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.16.11
+	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.17.0
 	github.com/aws/aws-sdk-go-v2/service/sts v1.17.7
 	github.com/cockroachdb/cockroach-go/v2 v2.2.19
 	github.com/coreos/go-oidc/v3 v3.4.0
@@ -120,7 +120,7 @@ require (
 	github.com/jackc/pgpassfile v1.0.0 // indirect
 	github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
-	github.com/klauspost/cpuid/v2 v2.2.2 // indirect
+	github.com/klauspost/cpuid/v2 v2.2.3 // indirect
 	github.com/kr/fs v0.1.0 // indirect
 	github.com/lestrrat-go/blackmagic v1.0.1 // indirect
 	github.com/lestrrat-go/httpcc v1.0.1 // indirect
@@ -131,7 +131,7 @@ require (
 	github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect
 	github.com/magiconair/properties v1.8.7 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
-	github.com/mattn/go-isatty v0.0.16 // indirect
+	github.com/mattn/go-isatty v0.0.17 // indirect
 	github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
 	github.com/miekg/dns v1.1.50 // indirect
 	github.com/minio/sha256-simd v1.0.0 // indirect

+ 6 - 4
go.sum

@@ -275,8 +275,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.27.2/go.mod h1:u+566cosFI+d+motIz3USX
 github.com/aws/aws-sdk-go-v2/service/s3 v1.29.6 h1:W8pLcSn6Uy0eXgDBUUl8M8Kxv7JCoP68ZKTD04OXLEA=
 github.com/aws/aws-sdk-go-v2/service/s3 v1.29.6/go.mod h1:L2l2/q76teehcW7YEsgsDjqdsDTERJeX3nOMIFlgGUE=
 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.14/go.mod h1:xakbH8KMsQQKqzX87uyyzTHshc/0/Df8bsTneTS5pFU=
-github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.16.11 h1:77V7vnw/NC4DORHVgA97+Ky2p1ri0+ZVYXh6ordUZU0=
-github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.16.11/go.mod h1:jAeo/PdIJZuDSwsvxJS94G4d6h8tStj7WXVuKwLHWU8=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.17.0 h1:6W6BLZcXytRJsVvc2gGwxKE4wbMSlWqdxZivBP/E+ys=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.17.0/go.mod h1:jAeo/PdIJZuDSwsvxJS94G4d6h8tStj7WXVuKwLHWU8=
 github.com/aws/aws-sdk-go-v2/service/sns v1.17.10/go.mod h1:uITsRNVMeCB3MkWpXxXw0eDz8pW4TYLzj+eyQtbhSxM=
 github.com/aws/aws-sdk-go-v2/service/sqs v1.19.1/go.mod h1:A94o564Gj+Yn+7QO1eLFeI7UVv3riy/YBFOfICVqFvU=
 github.com/aws/aws-sdk-go-v2/service/ssm v1.27.6/go.mod h1:fiFzQgj4xNOg4/wqmAiPvzgDMXPD+cUEplX/CYn+0j0=
@@ -1067,8 +1067,9 @@ github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e
 github.com/klauspost/compress v1.15.13 h1:NFn1Wr8cfnenSJSA46lLq4wHCcBzKTSjnBIexDMMOV0=
 github.com/klauspost/compress v1.15.13/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM=
 github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
-github.com/klauspost/cpuid/v2 v2.2.2 h1:xPMwiykqNK9VK0NYC3+jTMYv9I6Vl3YdjZgPZKG3zO0=
 github.com/klauspost/cpuid/v2 v2.2.2/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
+github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
+github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
 github.com/kolo/xmlrpc v0.0.0-20201022064351-38db28db192b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -1154,8 +1155,9 @@ github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcME
 github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
-github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
 github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
 github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=

+ 14 - 11
internal/common/connection.go

@@ -530,7 +530,15 @@ func (c *BaseConnection) RemoveAll(virtualPath string) error {
 	return c.RemoveFile(fs, fsPath, virtualPath, fi)
 }
 
-func (c *BaseConnection) checkCopyFolder(srcInfo, dstInfo os.FileInfo, virtualSource, virtualTarget string) error {
+func (c *BaseConnection) checkCopy(srcInfo, dstInfo os.FileInfo, virtualSource, virtualTarget string) error {
+	_, fsSourcePath, err := c.GetFsAndResolvedPath(virtualSource)
+	if err != nil {
+		return err
+	}
+	_, fsTargetPath, err := c.GetFsAndResolvedPath(virtualTarget)
+	if err != nil {
+		return err
+	}
 	if srcInfo.IsDir() {
 		if dstInfo != nil && !dstInfo.IsDir() {
 			return fmt.Errorf("cannot overwrite file %q with dir %q: %w", virtualTarget, virtualSource, c.GetOpUnsupportedError())
@@ -538,14 +546,6 @@ func (c *BaseConnection) checkCopyFolder(srcInfo, dstInfo os.FileInfo, virtualSo
 		if util.IsDirOverlapped(virtualSource, virtualTarget, true, "/") {
 			return fmt.Errorf("nested copy %q => %q is not supported: %w", virtualSource, virtualTarget, c.GetOpUnsupportedError())
 		}
-		_, fsSourcePath, err := c.GetFsAndResolvedPath(virtualSource)
-		if err != nil {
-			return err
-		}
-		_, fsTargetPath, err := c.GetFsAndResolvedPath(virtualTarget)
-		if err != nil {
-			return err
-		}
 		if util.IsDirOverlapped(fsSourcePath, fsTargetPath, true, c.User.FsConfig.GetPathSeparator()) {
 			c.Log(logger.LevelWarn, "nested fs copy %q => %q not allowed", fsSourcePath, fsTargetPath)
 			return fmt.Errorf("nested fs copy is not supported: %w", c.GetOpUnsupportedError())
@@ -555,6 +555,9 @@ func (c *BaseConnection) checkCopyFolder(srcInfo, dstInfo os.FileInfo, virtualSo
 	if dstInfo != nil && dstInfo.IsDir() {
 		return fmt.Errorf("cannot overwrite file %q with dir %q: %w", virtualSource, virtualTarget, c.GetOpUnsupportedError())
 	}
+	if fsSourcePath == fsTargetPath {
+		return fmt.Errorf("the copy source and target cannot be the same: %w", c.GetOpUnsupportedError())
+	}
 	return nil
 }
 
@@ -605,7 +608,7 @@ func (c *BaseConnection) doRecursiveCopy(virtualSourcePath, virtualTargetPath st
 			if err != nil && !c.IsNotExistError(err) {
 				return err
 			}
-			if err := c.checkCopyFolder(info, targetInfo, sourcePath, targetPath); err != nil {
+			if err := c.checkCopy(info, targetInfo, sourcePath, targetPath); err != nil {
 				return err
 			}
 			if err := c.doRecursiveCopy(sourcePath, targetPath, info, true); err != nil {
@@ -657,7 +660,7 @@ func (c *BaseConnection) Copy(virtualSourcePath, virtualTargetPath string) error
 	if dstInfo != nil && dstInfo.IsDir() {
 		createTargetDir = false
 	}
-	if err := c.checkCopyFolder(srcInfo, dstInfo, virtualSourcePath, destPath); err != nil {
+	if err := c.checkCopy(srcInfo, dstInfo, virtualSourcePath, destPath); err != nil {
 		return err
 	}
 	if err := c.CheckParentDirs(path.Dir(destPath)); err != nil {

+ 3 - 3
internal/common/connection_test.go

@@ -590,16 +590,16 @@ func TestErrorResolvePath(t *testing.T) {
 	conn := NewBaseConnection("", ProtocolSFTP, "", "", u)
 	err := conn.doRecursiveRemoveDirEntry("/vpath", nil)
 	assert.Error(t, err)
-	err = conn.checkCopyFolder(vfs.NewFileInfo("name", true, 0, time.Unix(0, 0), false), nil, "/source", "/target")
+	err = conn.checkCopy(vfs.NewFileInfo("name", true, 0, time.Unix(0, 0), false), nil, "/source", "/target")
 	assert.Error(t, err)
 	sourceFile := filepath.Join(os.TempDir(), "f", "source")
 	err = os.MkdirAll(filepath.Dir(sourceFile), os.ModePerm)
 	assert.NoError(t, err)
 	err = os.WriteFile(sourceFile, []byte(""), 0666)
 	assert.NoError(t, err)
-	err = conn.checkCopyFolder(vfs.NewFileInfo("name", true, 0, time.Unix(0, 0), false), nil, "/f/source", "/target")
+	err = conn.checkCopy(vfs.NewFileInfo("name", true, 0, time.Unix(0, 0), false), nil, "/f/source", "/target")
 	assert.Error(t, err)
-	err = conn.checkCopyFolder(vfs.NewFileInfo("", false, 0, time.Unix(0, 0), false), vfs.NewFileInfo("", true, 0, time.Unix(0, 0), false), "", "")
+	err = conn.checkCopy(vfs.NewFileInfo("source", false, 0, time.Unix(0, 0), false), vfs.NewFileInfo("target", true, 0, time.Unix(0, 0), false), "/f/source", "/f/target")
 	assert.Error(t, err)
 	err = os.RemoveAll(filepath.Dir(sourceFile))
 	assert.NoError(t, err)

+ 3 - 0
internal/common/protocol_test.go

@@ -6920,6 +6920,9 @@ func TestCopyAndRemoveSSHCommands(t *testing.T) {
 		testFileNameCopy := testFileName + "_copy"
 		out, err := runSSHCommand(fmt.Sprintf("sftpgo-copy %s %s", testFileName, testFileNameCopy), user)
 		assert.NoError(t, err, string(out))
+		// the resolved destination path match the source path
+		out, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %s %s", testFileName, path.Dir(testFileName)), user)
+		assert.Error(t, err, string(out))
 
 		info, err := client.Stat(testFileNameCopy)
 		if assert.NoError(t, err) {

+ 54 - 31
internal/httpd/api_http_user.go

@@ -24,6 +24,7 @@ import (
 	"os"
 	"path"
 	"strconv"
+	"strings"
 
 	"github.com/go-chi/render"
 	"github.com/rs/xid"
@@ -105,11 +106,6 @@ func createUserDir(w http.ResponseWriter, r *http.Request) {
 	sendAPIResponse(w, r, nil, fmt.Sprintf("Directory %#v created", name), http.StatusCreated)
 }
 
-func renameUserDir(w http.ResponseWriter, r *http.Request) {
-	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	renameItem(w, r)
-}
-
 func deleteUserDir(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	connection, err := getUserConnection(w, r)
@@ -127,6 +123,56 @@ func deleteUserDir(w http.ResponseWriter, r *http.Request) {
 	sendAPIResponse(w, r, nil, fmt.Sprintf("Directory %q deleted", name), http.StatusOK)
 }
 
+func renameUserFsEntry(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+
+	connection, err := getUserConnection(w, r)
+	if err != nil {
+		return
+	}
+	defer common.Connections.Remove(connection.GetID())
+
+	oldName := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
+	newName := connection.User.GetCleanedPath(r.URL.Query().Get("target"))
+	err = connection.Rename(oldName, newName)
+	if err != nil {
+		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to rename %q -> %q", oldName, newName),
+			getMappedStatusCode(err))
+		return
+	}
+	sendAPIResponse(w, r, nil, fmt.Sprintf("%q renamed to %q", oldName, newName), http.StatusOK)
+}
+
+func copyUserFsEntry(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+
+	connection, err := getUserConnection(w, r)
+	if err != nil {
+		return
+	}
+	defer common.Connections.Remove(connection.GetID())
+
+	source := r.URL.Query().Get("path")
+	target := r.URL.Query().Get("target")
+	copyFromSource := strings.HasSuffix(source, "/")
+	copyInTarget := strings.HasSuffix(target, "/")
+	source = connection.User.GetCleanedPath(source)
+	target = connection.User.GetCleanedPath(target)
+	if copyFromSource {
+		source += "/"
+	}
+	if copyInTarget {
+		target += "/"
+	}
+	err = connection.Copy(source, target)
+	if err != nil {
+		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to copy %q -> %q", source, target),
+			getMappedStatusCode(err))
+		return
+	}
+	sendAPIResponse(w, r, nil, fmt.Sprintf("%q copied to %q", source, target), http.StatusOK)
+}
+
 func getUserFile(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	connection, err := getUserConnection(w, r)
@@ -330,11 +376,6 @@ func doUploadFiles(w http.ResponseWriter, r *http.Request, connection *Connectio
 	return uploaded
 }
 
-func renameUserFile(w http.ResponseWriter, r *http.Request) {
-	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	renameItem(w, r)
-}
-
 func deleteUserFile(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	connection, err := getUserConnection(w, r)
@@ -359,13 +400,13 @@ func deleteUserFile(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if fi.IsDir() && fi.Mode()&os.ModeSymlink == 0 {
-		connection.Log(logger.LevelDebug, "cannot remove %#v is not a file/symlink", p)
-		sendAPIResponse(w, r, err, fmt.Sprintf("Unable delete %#v, it is not a file/symlink", name), http.StatusBadRequest)
+		connection.Log(logger.LevelDebug, "cannot remove %q is not a file/symlink", p)
+		sendAPIResponse(w, r, err, fmt.Sprintf("Unable delete %q, it is not a file/symlink", name), http.StatusBadRequest)
 		return
 	}
 	err = connection.RemoveFile(fs, p, name, fi)
 	if err != nil {
-		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to delete file %#v", name), getMappedStatusCode(err))
+		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to delete file %q", name), getMappedStatusCode(err))
 		return
 	}
 	sendAPIResponse(w, r, nil, fmt.Sprintf("File %#v deleted", name), http.StatusOK)
@@ -520,21 +561,3 @@ func setModificationTimeFromHeader(r *http.Request, c *Connection, filePath stri
 		}
 	}
 }
-
-func renameItem(w http.ResponseWriter, r *http.Request) {
-	connection, err := getUserConnection(w, r)
-	if err != nil {
-		return
-	}
-	defer common.Connections.Remove(connection.GetID())
-
-	oldName := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
-	newName := connection.User.GetCleanedPath(r.URL.Query().Get("target"))
-	err = connection.Rename(oldName, newName)
-	if err != nil {
-		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to rename %#v -> %#v", oldName, newName),
-			getMappedStatusCode(err))
-		return
-	}
-	sendAPIResponse(w, r, nil, fmt.Sprintf("%#v renamed to %#v", oldName, newName), http.StatusOK)
-}

+ 4 - 0
internal/httpd/httpd.go

@@ -66,6 +66,7 @@ const (
 	userPwdPath                           = "/api/v2/user/changepwd"
 	userDirsPath                          = "/api/v2/user/dirs"
 	userFilesPath                         = "/api/v2/user/files"
+	userFileActionsPath                   = "/api/v2/user/file-actions"
 	userStreamZipPath                     = "/api/v2/user/streamzip"
 	userUploadFilePath                    = "/api/v2/user/files/upload"
 	userFilesDirsMetadataPath             = "/api/v2/user/files/metadata"
@@ -148,6 +149,7 @@ const (
 	webClientTwoFactorRecoveryPathDefault = "/web/client/twofactor-recovery"
 	webClientFilesPathDefault             = "/web/client/files"
 	webClientFilePathDefault              = "/web/client/file"
+	webClientFileActionsPathDefault       = "/web/client/file-actions"
 	webClientSharesPathDefault            = "/web/client/shares"
 	webClientSharePathDefault             = "/web/client/share"
 	webClientEditFilePathDefault          = "/web/client/editfile"
@@ -239,6 +241,7 @@ var (
 	webClientTwoFactorRecoveryPath string
 	webClientFilesPath             string
 	webClientFilePath              string
+	webClientFileActionsPath       string
 	webClientSharesPath            string
 	webClientSharePath             string
 	webClientEditFilePath          string
@@ -963,6 +966,7 @@ func updateWebClientURLs(baseURL string) {
 	webClientTwoFactorRecoveryPath = path.Join(baseURL, webClientTwoFactorRecoveryPathDefault)
 	webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault)
 	webClientFilePath = path.Join(baseURL, webClientFilePathDefault)
+	webClientFileActionsPath = path.Join(baseURL, webClientFileActionsPathDefault)
 	webClientSharesPath = path.Join(baseURL, webClientSharesPathDefault)
 	webClientPubSharesPath = path.Join(baseURL, webClientPubSharesPathDefault)
 	webClientSharePath = path.Join(baseURL, webClientSharePathDefault)

+ 49 - 12
internal/httpd/httpd_test.go

@@ -101,6 +101,7 @@ const (
 	userPwdPath                    = "/api/v2/user/changepwd"
 	userDirsPath                   = "/api/v2/user/dirs"
 	userFilesPath                  = "/api/v2/user/files"
+	userFileActionsPath            = "/api/v2/user/file-actions"
 	userStreamZipPath              = "/api/v2/user/streamzip"
 	userUploadFilePath             = "/api/v2/user/files/upload"
 	userFilesDirsMetadataPath      = "/api/v2/user/files/metadata"
@@ -14109,7 +14110,13 @@ func TestWebDirsAPI(t *testing.T) {
 	assert.Len(t, contents, 0)
 
 	// rename a missing folder
-	req, err = http.NewRequest(http.MethodPatch, userDirsPath+"?path="+testDir+"&target="+testDir+"new", nil)
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/move?path="+testDir+"&target="+testDir+"new", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+	// copy a missing folder
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/copy?path="+testDir+"%2F&target="+testDir+"new%2F", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -14139,13 +14146,20 @@ func TestWebDirsAPI(t *testing.T) {
 		assert.Equal(t, testDir, contents[0]["name"])
 	}
 	// rename a dir with the same source and target name
-	req, err = http.NewRequest(http.MethodPatch, userDirsPath+"?path="+testDir+"&target="+testDir, nil)
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/move?path="+testDir+"&target="+testDir, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	assert.Contains(t, rr.Body.String(), "operation unsupported")
-	req, err = http.NewRequest(http.MethodPatch, userDirsPath+"?path="+testDir+"&target=%2F"+testDir+"%2F", nil)
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/move?path="+testDir+"&target=%2F"+testDir+"%2F", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "operation unsupported")
+	// copy a dir with the same source and target name
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/copy?path="+testDir+"&target="+testDir, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -14163,8 +14177,14 @@ func TestWebDirsAPI(t *testing.T) {
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusCreated, rr)
+	// copy the dir
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/copy?path="+testDir+"&target="+testDir+"copy", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
 	// rename the dir
-	req, err = http.NewRequest(http.MethodPatch, userDirsPath+"?path="+testDir+"&target="+testDir+"new", nil)
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/move?path="+testDir+"&target="+testDir+"new", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -14175,6 +14195,11 @@ func TestWebDirsAPI(t *testing.T) {
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
+	req, err = http.NewRequest(http.MethodDelete, userDirsPath+"?path="+testDir+"copy", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
 	// the root dir cannot be created
 	req, err = http.NewRequest(http.MethodPost, userDirsPath, nil)
 	assert.NoError(t, err)
@@ -14204,7 +14229,13 @@ func TestWebDirsAPI(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 
-	req, err = http.NewRequest(http.MethodPatch, userDirsPath+"?path="+testDir+"&target="+testDir+"new", nil)
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/move?path="+testDir+"&target="+testDir+"new", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/copy?path="+testDir+"&target="+testDir+"new", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -14474,20 +14505,26 @@ func TestWebFilesAPI(t *testing.T) {
 	err = json.NewDecoder(rr.Body).Decode(&contents)
 	assert.NoError(t, err)
 	assert.Len(t, contents, 2)
+	// copy a file
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/copy?path=file1.txt&target=%2Ftdir%2Ffile_copy.txt", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
 	// rename a file
-	req, err = http.NewRequest(http.MethodPatch, userFilesPath+"?path=file1.txt&target=%2Ftdir%2Ffile3.txt", nil)
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/move?path=file1.txt&target=%2Ftdir%2Ffile3.txt", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	// rename a missing file
-	req, err = http.NewRequest(http.MethodPatch, userFilesPath+"?path=file1.txt&target=%2Ftdir%2Ffile3.txt", nil)
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/move?path=file1.txt&target=%2Ftdir%2Ffile3.txt", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 	// rename a file with target name equal to source name
-	req, err = http.NewRequest(http.MethodPatch, userFilesPath+"?path=file1.txt&target=file1.txt", nil)
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/move?path=file1.txt&target=file1.txt", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -14557,7 +14594,7 @@ func TestWebFilesAPI(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 
-	req, err = http.NewRequest(http.MethodPatch, userFilesPath+"?path=file1.txt&target=%2Ftdir%2Ffile3.txt", nil)
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/move?path=file1.txt&target=%2Ftdir%2Ffile3.txt", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -14622,7 +14659,7 @@ func TestStartDirectory(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusCreated, rr)
 
-	req, err = http.NewRequest(http.MethodPatch, userDirsPath+"?path=testdir&target=testdir1", nil)
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/move?path=testdir&target=testdir1", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -15129,7 +15166,7 @@ func TestWebAPIWritePermission(t *testing.T) {
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
-	req, err = http.NewRequest(http.MethodPatch, userFilesPath+"?path=a&target=b", nil)
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/move?path=a&target=b", nil)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -15164,7 +15201,7 @@ func TestWebAPIWritePermission(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
-	req, err = http.NewRequest(http.MethodPatch, userDirsPath+"?path=dir&target=dir1", nil)
+	req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/move?path=dir&target=dir1", nil)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)

+ 10 - 6
internal/httpd/server.go

@@ -1339,16 +1339,20 @@ func (s *httpdServer) initializeRouter() {
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
 				Post(userDirsPath, createUserDir)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
-				Patch(userDirsPath, renameUserDir)
+				Patch(userDirsPath, renameUserFsEntry)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
 				Delete(userDirsPath, deleteUserDir)
 			router.With(s.checkAuthRequirements).Get(userFilesPath, getUserFile)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
 				Post(userFilesPath, uploadUserFiles)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
-				Patch(userFilesPath, renameUserFile)
+				Patch(userFilesPath, renameUserFsEntry)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
 				Delete(userFilesPath, deleteUserFile)
+			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
+				Post(userFileActionsPath+"/move", renameUserFsEntry)
+			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
+				Post(userFileActionsPath+"/copy", copyUserFsEntry)
 			router.With(s.checkAuthRequirements).Post(userStreamZipPath, getUserFilesAsZipStream)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
 				Get(userSharesPath, getShares)
@@ -1460,18 +1464,18 @@ func (s *httpdServer) setupWebClientRoutes() {
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Post(webClientFilePath, uploadUserFile)
 			router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientEditFilePath, s.handleClientEditFile)
-			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
-				Patch(webClientFilesPath, renameUserFile)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Delete(webClientFilesPath, deleteUserFile)
 			router.With(s.checkAuthRequirements, compressor.Handler, s.refreshCookie).
 				Get(webClientDirsPath, s.handleClientGetDirContents)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Post(webClientDirsPath, createUserDir)
-			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
-				Patch(webClientDirsPath, renameUserDir)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Delete(webClientDirsPath, deleteUserDir)
+			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+				Post(webClientFileActionsPath+"/move", renameUserFsEntry)
+			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+				Post(webClientFileActionsPath+"/copy", copyUserFsEntry)
 			router.With(s.checkAuthRequirements, s.refreshCookie).
 				Get(webClientDownloadZipPath, s.handleWebClientDownloadZip)
 			router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientProfilePath,

+ 2 - 0
internal/httpd/webclient.go

@@ -141,6 +141,7 @@ type filesPage struct {
 	baseClientPage
 	CurrentDir      string
 	DirsURL         string
+	FileActionsURL  string
 	DownloadURL     string
 	ViewPDFURL      string
 	FileURL         string
@@ -573,6 +574,7 @@ func (s *httpdServer) renderFilesPage(w http.ResponseWriter, r *http.Request, di
 		ViewPDFURL:      webClientViewPDFPath,
 		DirsURL:         webClientDirsPath,
 		FileURL:         webClientFilePath,
+		FileActionsURL:  webClientFileActionsPath,
 		CanAddFiles:     user.CanAddFilesFromWeb(dirName),
 		CanCreateDirs:   user.CanAddDirsFromWeb(dirName),
 		CanRename:       user.CanRenameFromWeb(dirName, dirName),

+ 85 - 33
openapi/openapi.yaml

@@ -128,9 +128,7 @@ paths:
           content:
             application/json; charset=utf-8:
               schema:
-                type: array
-                items:
-                  $ref: '#/components/schemas/ApiResponse'
+                $ref: '#/components/schemas/ApiResponse'
         '400':
           $ref: '#/components/responses/BadRequest'
         '401':
@@ -294,9 +292,7 @@ paths:
           content:
             application/json; charset=utf-8:
               schema:
-                type: array
-                items:
-                  $ref: '#/components/schemas/ApiResponse'
+                $ref: '#/components/schemas/ApiResponse'
         '400':
           $ref: '#/components/responses/BadRequest'
         '401':
@@ -3963,6 +3959,76 @@ paths:
           $ref: '#/components/responses/InternalServerError'
         default:
           $ref: '#/components/responses/DefaultResponse'
+  /user/file-actions/copy:
+    parameters:
+      - in: query
+        name: path
+        description: Path to the file/folder to copy. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir"
+        schema:
+          type: string
+        required: true
+      - in: query
+        name: target
+        description: New name. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir"
+        schema:
+          type: string
+        required: true
+    post:
+      tags:
+        - user APIs
+      summary: 'Copy a file or a directory'
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json; charset=utf-8:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /user/file-actions/move:
+    parameters:
+      - in: query
+        name: path
+        description: Path to the file/folder to rename. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir"
+        schema:
+          type: string
+        required: true
+      - in: query
+        name: target
+        description: New name. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir"
+        schema:
+          type: string
+        required: true
+    post:
+      tags:
+        - user APIs
+      summary: 'Move (rename) a file or a directory'
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json; charset=utf-8:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /user/dirs:
     get:
       tags:
@@ -4020,9 +4086,7 @@ paths:
           content:
             application/json; charset=utf-8:
               schema:
-                type: array
-                items:
-                  $ref: '#/components/schemas/ApiResponse'
+                $ref: '#/components/schemas/ApiResponse'
         '400':
           $ref: '#/components/responses/BadRequest'
         '401':
@@ -4036,7 +4100,8 @@ paths:
     patch:
       tags:
         - user APIs
-      summary: Rename a directory
+      deprecated: true
+      summary: 'Rename a directory. Deprecated, use "file-actions/move"'
       description: Rename a directory for the logged in user. The rename is allowed for empty directory or for non empty local directories, with no virtual folders inside
       operationId: rename_user_dir
       parameters:
@@ -4058,9 +4123,7 @@ paths:
           content:
             application/json; charset=utf-8:
               schema:
-                type: array
-                items:
-                  $ref: '#/components/schemas/ApiResponse'
+                $ref: '#/components/schemas/ApiResponse'
         '400':
           $ref: '#/components/responses/BadRequest'
         '401':
@@ -4075,7 +4138,7 @@ paths:
       tags:
         - user APIs
       summary: Delete a directory
-      description: Delete a directory for the logged in user. Only empty directories can be deleted
+      description: Delete a directory and any children it contains for the logged in user
       operationId: delete_user_dir
       parameters:
         - in: query
@@ -4090,9 +4153,7 @@ paths:
           content:
             application/json; charset=utf-8:
               schema:
-                type: array
-                items:
-                  $ref: '#/components/schemas/ApiResponse'
+                $ref: '#/components/schemas/ApiResponse'
         '400':
           $ref: '#/components/responses/BadRequest'
         '401':
@@ -4186,9 +4247,7 @@ paths:
           content:
             application/json; charset=utf-8:
               schema:
-                type: array
-                items:
-                  $ref: '#/components/schemas/ApiResponse'
+                $ref: '#/components/schemas/ApiResponse'
         '400':
           $ref: '#/components/responses/BadRequest'
         '401':
@@ -4204,8 +4263,9 @@ paths:
     patch:
       tags:
         - user APIs
+      deprecated: true
       summary: Rename a file
-      description: Rename a file for the logged in user
+      description: 'Rename a file for the logged in user. Deprecated, use "file-actions/move"'
       operationId: rename_user_file
       parameters:
         - in: query
@@ -4226,9 +4286,7 @@ paths:
           content:
             application/json; charset=utf-8:
               schema:
-                type: array
-                items:
-                  $ref: '#/components/schemas/ApiResponse'
+                $ref: '#/components/schemas/ApiResponse'
         '400':
           $ref: '#/components/responses/BadRequest'
         '401':
@@ -4258,9 +4316,7 @@ paths:
           content:
             application/json; charset=utf-8:
               schema:
-                type: array
-                items:
-                  $ref: '#/components/schemas/ApiResponse'
+                $ref: '#/components/schemas/ApiResponse'
         '400':
           $ref: '#/components/responses/BadRequest'
         '401':
@@ -4325,9 +4381,7 @@ paths:
           content:
             application/json; charset=utf-8:
               schema:
-                type: array
-                items:
-                  $ref: '#/components/schemas/ApiResponse'
+                $ref: '#/components/schemas/ApiResponse'
         '400':
           $ref: '#/components/responses/BadRequest'
         '401':
@@ -4370,9 +4424,7 @@ paths:
           content:
             application/json; charset=utf-8:
               schema:
-                type: array
-                items:
-                  $ref: '#/components/schemas/ApiResponse'
+                $ref: '#/components/schemas/ApiResponse'
         '400':
           $ref: '#/components/responses/BadRequest'
         '401':

+ 1 - 1
pkgs/build.sh

@@ -1,6 +1,6 @@
 #!/bin/bash
 
-NFPM_VERSION=2.22.2
+NFPM_VERSION=2.23.0
 NFPM_ARCH=${NFPM_ARCH:-amd64}
 if [ -z ${SFTPGO_VERSION} ]
 then

+ 182 - 68
templates/webclient/files.html

@@ -126,6 +126,45 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     </div>
 </div>
 
+<div class="modal fade" id="copyModal" tabindex="-1" role="dialog" aria-labelledby="copyModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="copyModalLabel">
+                    Copy the selected item
+                </h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <form id="copy_form" action="" method="POST">
+                <div class="modal-body">
+                    <div class="form-group">
+                        <label for="copy_old_name" class="col-form-label">Source</label>
+                        <input type="text" class="form-control" id="copy_old_name" readonly>
+                    </div>
+                    <div class="form-group">
+                        <label for="copy_new_dir" class="col-form-label">New base dir</label>
+                        <input type="text" class="form-control" id="copy_new_dir" required aria-describedby="copyNewDirHelpBlock">
+                        <small id="copyNewDirHelpBlock" class="form-text text-muted">
+                            Setting a directory other than the current one will copy the item there. This directory will be created if it doesn't exist
+                        </small>
+                    </div>
+                    <div class="form-group">
+                        <label for="copy_new_name" class="col-form-label">Target</label>
+                        <input type="text" class="form-control" id="copy_new_name" required>
+                    </div>
+                </div>
+                <div class="modal-footer">
+                    <button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
+                    <button type="submit" class="btn btn-primary">Submit</button>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
+
 <div class="modal fade" id="renameModal" tabindex="-1" role="dialog" aria-labelledby="renameModalLabel"
     aria-hidden="true">
     <div class="modal-dialog" role="document">
@@ -447,7 +486,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
 			return escapeHTML(d);
 		}
 
-        var shortened = d.substr(0, cutoff-1);
+        let shortened = d.substr(0, cutoff-1);
         return escapeHTML(shortened)+'&#8230;';
     }
 
@@ -463,7 +502,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     }
 
     function getIconForFile(filename) {
-        var extension = filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase();
+        let extension = filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase();
         switch (extension) {
             case "doc":
             case "docx":
@@ -573,13 +612,13 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     }
 
     function deleteAction() {
-        var table = $('#dataTable').DataTable();
+        let table = $('#dataTable').DataTable();
         table.button('delete:name').enable(false);
 
-        var selectedItems = table.column(0).checkboxes.selected()
-        var has_errors = false;
-        var index = 0;
-        var success = 0;
+        let selectedItems = table.column(0).checkboxes.selected()
+        let has_errors = false;
+        let index = 0;
+        let success = 0;
         spinnerDone = false;
 
         $('#deleteModal').modal('hide');
@@ -596,14 +635,14 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                 }
                 return;
             }
-            var selected = selectedItems[index];
-            var itemType = getTypeFromMeta(selected);
-            var itemName = getNameFromMeta(selected);
-            var path;
-            var reqTimeout = 15000;
+            let selected = selectedItems[index];
+            let itemType = getTypeFromMeta(selected);
+            let itemName = getNameFromMeta(selected);
+            let path;
+            let reqTimeout = 15000;
             if (itemType == "1"){
                 path = '{{.DirsURL}}';
-                reqTimeout = 90000
+                reqTimeout = 120000
             } else {
                 path = '{{.FilesURL}}';
             }
@@ -623,12 +662,12 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                 error: function ($xhr, textStatus, errorThrown) {
                     index++;
                     has_errors = true;
-                    var txt = "Unable to delete the selected item/s";
+                    let txt = "Unable to delete the selected item/s";
                     if (success > 0){
                         txt = "Not all the selected items have been deleted, please reload the page";
                     }
                     if ($xhr) {
-                        var json = $xhr.responseJSON;
+                        let json = $xhr.responseJSON;
                         if (json) {
                             if (json.message) {
                                 txt = json.message;
@@ -750,8 +789,8 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
         $("#create_dir_form").submit(function (event) {
             event.preventDefault();
             $('#createDirModal').modal('hide');
-            var dirName = replaceSlash($("#directory_name").val());
-            var path = '{{.DirsURL}}?path={{.CurrentDir}}' + encodeURIComponent("/"+dirName);
+            let dirName = replaceSlash($("#directory_name").val());
+            let path = '{{.DirsURL}}?path={{.CurrentDir}}' + encodeURIComponent("/"+dirName);
             $.ajax({
                 url: path,
                 type: 'POST',
@@ -762,9 +801,9 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                     location.reload();
                 },
                 error: function ($xhr, textStatus, errorThrown) {
-                    var txt = "Unable to create the requested directory";
+                    let txt = "Unable to create the requested directory";
                     if ($xhr) {
-                        var json = $xhr.responseJSON;
+                        let json = $xhr.responseJSON;
                         if (json) {
                             if (json.message) {
                                 txt = json.message;
@@ -778,7 +817,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                     $('#errorMsg').show();
                     setTimeout(function () {
                         $('#errorMsg').hide();
-                    }, 5000);
+                    }, 8000);
                 }
             });
         });
@@ -811,12 +850,12 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
                 async function saveFile() {
                     //console.log("save file, index: "+index);
-                    var errorMessage = "Error uploading files";
+                    let errorMessage = "Error uploading files";
                     let response;
                     try {
-                        var f = files[index].file;
-                        var uploadPath = '{{.FileURL}}?path={{.CurrentDir}}'+encodeURIComponent("/"+f.name);
-                        var lastModified;
+                        let f = files[index].file;
+                        let uploadPath = '{{.FileURL}}?path={{.CurrentDir}}'+encodeURIComponent("/"+f.name);
+                        let lastModified;
                         try {
                             lastModified = f.lastModified;
                         } catch (e) {
@@ -874,13 +913,12 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
         $("#rename_form").submit(function (event){
             event.preventDefault();
-            var table = $('#dataTable').DataTable();
+            let table = $('#dataTable').DataTable();
             table.button('rename:name').enable(false);
-            var selected = table.column(0).checkboxes.selected()[0];
-            var itemType = getTypeFromMeta(selected);
-            var itemName = getNameFromMeta(selected);
-            var targetName = replaceSlash($("#rename_new_name").val());
-            var targetDir = $("#rename_new_dir").val();
+            let selected = table.column(0).checkboxes.selected()[0];
+            let itemName = getNameFromMeta(selected);
+            let targetName = replaceSlash($("#rename_new_name").val());
+            let targetDir = $("#rename_new_dir").val();
             if (targetDir != "/") {
                 targetDir = targetDir.endsWith('/') ? targetDir.slice(0, -1) : targetDir;
             }
@@ -889,17 +927,12 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
             } else {
                 targetDir = encodeURIComponent(targetDir);
             }
-            var path;
-            if (itemType == "1"){
-                path = '{{.DirsURL}}';
-            } else {
-                path = '{{.FilesURL}}';
-            }
+            let path = '{{.FileActionsURL}}/move';
             path+='?path={{.CurrentDir}}'+encodeURIComponent("/"+itemName)+'&target='+targetDir+encodeURIComponent("/"+targetName);
             $('#renameModal').modal('hide');
             $.ajax({
                 url: path,
-                type: 'PATCH',
+                type: 'POST',
                 dataType: 'json',
                 headers: { 'X-CSRF-TOKEN': '{{.CSRFToken}}' },
                 timeout: 15000,
@@ -907,9 +940,9 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                     location.reload();
                 },
                 error: function ($xhr, textStatus, errorThrown) {
-                    var txt = "Error renaming item";
+                    let txt = "Error renaming item";
                     if ($xhr) {
-                        var json = $xhr.responseJSON;
+                        let json = $xhr.responseJSON;
                         if (json) {
                             if (json.message) {
                                 txt = json.message;
@@ -924,12 +957,72 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                     setTimeout(function () {
                         $('#errorMsg').hide();
                     }, 8000);
-                    var selectedItems = table.column(0).checkboxes.selected().length;
+                    let selectedItems = table.column(0).checkboxes.selected().length;
                     table.button('rename:name').enable(selectedItems == 1);
                 }
             });
         });
 
+        $("#copy_form").submit(function (event){
+            event.preventDefault();
+            let table = $('#dataTable').DataTable();
+            table.button('copy:name').enable(false);
+            let selected = table.column(0).checkboxes.selected()[0];
+            let itemName = getNameFromMeta(selected);
+            let targetName = $("#copy_new_name").val();
+            let targetDir = $("#copy_new_dir").val();
+            if (targetDir != "/") {
+                targetDir = targetDir.endsWith('/') ? targetDir.slice(0, -1) : targetDir;
+            }
+            if (targetDir.trim() == ""){
+                targetDir = "{{.CurrentDir}}";
+            } else {
+                targetDir = encodeURIComponent(targetDir);
+            }
+            let path = '{{.FileActionsURL}}/copy';
+            path+='?path={{.CurrentDir}}'+encodeURIComponent("/"+itemName)+'&target='+targetDir+encodeURIComponent("/"+targetName);
+
+            spinnerDone = false;
+            $('#copyModal').modal('hide');
+            $('#spinnerModal').modal('show');
+
+            $.ajax({
+                url: path,
+                type: 'POST',
+                dataType: 'json',
+                headers: { 'X-CSRF-TOKEN': '{{.CSRFToken}}' },
+                timeout: 120000,
+                success: function (result) {
+                    $('#spinnerModal').modal('hide');
+                    spinnerDone = true;
+                    location.reload();
+                },
+                error: function ($xhr, textStatus, errorThrown) {
+                    let txt = "Error copying item";
+                    if ($xhr) {
+                        let json = $xhr.responseJSON;
+                        if (json) {
+                            if (json.message) {
+                                txt = json.message;
+                            }
+                            if (json.error) {
+                                txt += ": " + json.error;
+                            }
+                        }
+                    }
+                    $('#errorTxt').text(txt);
+                    $('#errorMsg').show();
+                    setTimeout(function () {
+                        $('#errorMsg').hide();
+                    }, 10000);
+                    $('#spinnerModal').modal('hide');
+                    spinnerDone = true;
+                    let selectedItems = table.column(0).checkboxes.selected().length;
+                    table.button('copy:name').enable(selectedItems == 1);
+                }
+            });
+        });
+
         $.fn.dataTable.ext.buttons.refresh = {
             text: '<i class="fas fa-sync-alt"></i>',
             name: 'refresh',
@@ -944,15 +1037,15 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
             name: 'download',
             titleAttr: "Download Zip",
             action: function (e, dt, node, config) {
-                var filesArray = [];
-                var selected = dt.column(0).checkboxes.selected();
+                let filesArray = [];
+                let selected = dt.column(0).checkboxes.selected();
                 for (i = 0; i < selected.length; i++) {
                     filesArray.push(getNameFromMeta(selected[i]));
                 }
-                var files = encodeURIComponent(JSON.stringify(filesArray));
-                var downloadURL = '{{.DownloadURL}}';
-                var currentDir = '{{.CurrentDir}}';
-                var ts = new Date().getTime().toString();
+                let files = encodeURIComponent(JSON.stringify(filesArray));
+                let downloadURL = '{{.DownloadURL}}';
+                let currentDir = '{{.CurrentDir}}';
+                let ts = new Date().getTime().toString();
                 window.open(`${downloadURL}?path=${currentDir}&files=${files}&_=${ts}`);
             },
             enabled: false
@@ -985,8 +1078,8 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
             name: 'rename',
             titleAttr: "Rename",
             action: function (e, dt, node, config) {
-                var selected = table.column(0).checkboxes.selected()[0];
-                var itemName = getNameFromMeta(selected);
+                let selected = table.column(0).checkboxes.selected()[0];
+                let itemName = getNameFromMeta(selected);
                 $("#rename_old_name").val(itemName);
                 $("#rename_new_dir").val(decodeURIComponent("{{.CurrentDir}}".replace(/\+/g, '%20')));
                 $("#rename_new_name").val("");
@@ -995,6 +1088,21 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
             enabled: false
         };
 
+        $.fn.dataTable.ext.buttons.copy = {
+            text: '<i class="fas fa-copy"></i>',
+            name: 'copy',
+            titleAttr: "Copy",
+            action: function (e, dt, node, config) {
+                let selected = table.column(0).checkboxes.selected()[0];
+                let itemName = getNameFromMeta(selected);
+                $("#copy_old_name").val(itemName);
+                $("#copy_new_dir").val(decodeURIComponent("{{.CurrentDir}}".replace(/\+/g, '%20')));
+                $("#copy_new_name").val("");
+                $('#copyModal').modal('show');
+            },
+            enabled: false
+        };
+
         $.fn.dataTable.ext.buttons.delete = {
             text: '<i class="fas fa-trash"></i>',
             name: 'delete',
@@ -1010,29 +1118,29 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
             name: 'share',
             titleAttr: "Share",
             action: function (e, dt, node, config) {
-                var filesArray = [];
-                var selected = dt.column(0).checkboxes.selected();
+                let filesArray = [];
+                let selected = dt.column(0).checkboxes.selected();
                 for (i = 0; i < selected.length; i++) {
                     filesArray.push(getNameFromMeta(selected[i]));
                 }
-                var files = encodeURIComponent(JSON.stringify(filesArray));
-                var shareURL = '{{.ShareURL}}';
-                var currentDir = '{{.CurrentDir}}';
-                var ts = new Date().getTime().toString();
+                let files = encodeURIComponent(JSON.stringify(filesArray));
+                let shareURL = '{{.ShareURL}}';
+                let currentDir = '{{.CurrentDir}}';
+                let ts = new Date().getTime().toString();
                 window.open(`${shareURL}?path=${currentDir}&files=${files}&_=${ts}`,'_blank');
             },
             enabled: false
         };
 
-        var table = $('#dataTable').DataTable({
+        let table = $('#dataTable').DataTable({
             "ajax": {
                 "url": "{{.DirsURL}}?path={{.CurrentDir}}",
                 "dataSrc": "",
                 "error": function ($xhr, textStatus, errorThrown) {
                     $(".dataTables_processing").hide();
-                    var txt = "Failed to get directory listing";
+                    let txt = "Failed to get directory listing";
                     if ($xhr) {
-                        var json = $xhr.responseJSON;
+                        let json = $xhr.responseJSON;
                         if (json) {
                             if (json.message){
                                 txt += ": " + json.message;
@@ -1070,9 +1178,9 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                     "data": "name",
                     "render": function (data, type, row) {
                         if (type === 'display') {
-                            var title = "";
-                            var cssClass = "";
-                            var shortened = shortenData(data, 70);
+                            let title = "";
+                            let cssClass = "";
+                            let shortened = shortenData(data, 70);
                             data = escapeHTML(data);
                             if (shortened != data){
                                 title = data;
@@ -1085,7 +1193,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                             if (row["size"] == "") {
                                 return `<i class="fas fa-external-link-alt"></i>&nbsp;<a class="${cssClass}" href="${row['url']}" title="${title}">${shortened}</a>`;
                             }
-                            var icon = getIconForFile(data);
+                            let icon = getIconForFile(data);
                             return `<i class="${icon}"></i>&nbsp;<a class="${cssClass}" href="${row['url']}" title="${title}">${shortened}</a>`;
                         }
                         return data;
@@ -1096,8 +1204,8 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                 { "data": "edit_url",
                     "render": function (data, type, row) {
                         if (type === 'display') {
-                            var filename = escapeHTML(row["name"]);
-                            var extension = filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase();
+                            let filename = escapeHTML(row["name"]);
+                            let extension = filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase();
                             if (data){
                                 if (extension == "csv" || extension == "bat" || CodeMirror.findModeByExtension(extension) != null){
                                     {{if .CanAddFiles}}
@@ -1117,7 +1225,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                                     case "bmp":
                                     case "svg":
                                     case "ico":
-                                        var title = escapeHTMLForceSafe(row["name"])
+                                        let title = escapeHTMLForceSafe(row["name"])
                                         return `<a href="${row['url']}" data-lightbox="image-gallery" data-title="${title}"><i class="fas fa-eye"></i></a>`;
                                     case "mp4":
                                     case "mov":
@@ -1132,7 +1240,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                                         return `<a href="#" onclick="openVideoPlayer('${name}}', '${row['url']}', 'video/ogg');"><i class="fas fa-eye"></i></a>`;
                                     case "pdf":
                                         if (PDFObject.supportsPDFs){
-                                            var view_url = row['url'];
+                                            let view_url = row['url'];
                                             view_url = view_url.replace('{{.FilesURL}}','{{.ViewPDFURL}}');
                                             return `<a href="${view_url}" target="_blank"><i class="fas fa-eye"></i></a>`;
                                         }
@@ -1147,7 +1255,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                         {{if .HasIntegrations}}
                         if (type === 'display') {
                             if (data){
-                                var name = b64EncodeUnicode(escapeHTML(row["name"]));
+                                let name = b64EncodeUnicode(escapeHTML(row["name"]));
                                 return `<a href="#" onclick="openExternalURL('${data}', '${row["ext_link"]}', '${name}');"><i class="fas fa-external-link-alt"></i></a>`;
                             }
                         }
@@ -1163,8 +1271,8 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                     "targets": [0],
                     "checkboxes": {
                         "selectCallback": function (nodes, selected) {
-                            var selectedItems = table.column(0).checkboxes.selected().length;
-                            var selectedText = "";
+                            let selectedItems = table.column(0).checkboxes.selected().length;
+                            let selectedText = "";
                             if (selectedItems == 1) {
                                 selectedText = "1 item selected";
                             } else if (selectedItems > 1) {
@@ -1176,6 +1284,9 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                             {{if .CanRename}}
                             table.button('rename:name').enable(selectedItems == 1);
                             {{end}}
+                            {{if .CanAddFiles}}
+                            table.button('copy:name').enable(selectedItems == 1);
+                            {{end}}
                             {{if .CanDelete}}
                             table.button('delete:name').enable(selectedItems > 0);
                             {{end}}
@@ -1223,6 +1334,9 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                 {{if .CanDelete}}
                 table.button().add(0, 'delete');
                 {{end}}
+                {{if .CanAddFiles}}
+                table.button().add(0, 'copy');
+                {{end}}
                 {{if .CanRename}}
                 table.button().add(0, 'rename');
                 {{end}}