diff --git a/go.mod b/go.mod index 8c24244c..2151c7d0 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 78214b57..81c38166 100644 --- a/go.sum +++ b/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= diff --git a/internal/common/connection.go b/internal/common/connection.go index ae6905cd..d20592ee 100644 --- a/internal/common/connection.go +++ b/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 { diff --git a/internal/common/connection_test.go b/internal/common/connection_test.go index cc5a8e1c..6ff39b26 100644 --- a/internal/common/connection_test.go +++ b/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) diff --git a/internal/common/protocol_test.go b/internal/common/protocol_test.go index cc43ca51..2ed28c4e 100644 --- a/internal/common/protocol_test.go +++ b/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) { diff --git a/internal/httpd/api_http_user.go b/internal/httpd/api_http_user.go index e5c69ddc..c85f5391 100644 --- a/internal/httpd/api_http_user.go +++ b/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) -} diff --git a/internal/httpd/httpd.go b/internal/httpd/httpd.go index 44f70ff7..c49e2e95 100644 --- a/internal/httpd/httpd.go +++ b/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) diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index 08112b0e..f4ac721d 100644 --- a/internal/httpd/httpd_test.go +++ b/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) diff --git a/internal/httpd/server.go b/internal/httpd/server.go index b3ecea53..8146804d 100644 --- a/internal/httpd/server.go +++ b/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, diff --git a/internal/httpd/webclient.go b/internal/httpd/webclient.go index 8861f3e5..291eef3d 100644 --- a/internal/httpd/webclient.go +++ b/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), diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index b8ce00b7..0d18f3ea 100644 --- a/openapi/openapi.yaml +++ b/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': diff --git a/pkgs/build.sh b/pkgs/build.sh index 77e2a076..444bea62 100755 --- a/pkgs/build.sh +++ b/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 diff --git a/templates/webclient/files.html b/templates/webclient/files.html index e9a96576..5f2ac3e6 100644 --- a/templates/webclient/files.html +++ b/templates/webclient/files.html @@ -126,6 +126,45 @@ along with this program. If not, see . + +