Browse Source

web UI: add support for upload, create dirs, rename, delete

Nicola Murino 4 years ago
parent
commit
3a22aae34f

+ 43 - 1
dataprovider/user.go

@@ -697,11 +697,53 @@ func (u *User) isFilePatternAllowed(virtualPath string) bool {
 }
 
 // CanManagePublicKeys return true if this user is allowed to manage public keys
-// from the web client
+// from the web client. Used in web client UI
 func (u *User) CanManagePublicKeys() bool {
 	return !util.IsStringInSlice(sdk.WebClientPubKeyChangeDisabled, u.Filters.WebClient)
 }
 
+// CanAddFilesFromWeb returns true if the client can add files from the web UI.
+// The specified target is the directory where the files must be uploaded
+func (u *User) CanAddFilesFromWeb(target string) bool {
+	if util.IsStringInSlice(sdk.WebClientWriteDisabled, u.Filters.WebClient) {
+		return false
+	}
+	return u.HasPerm(PermUpload, target) || u.HasPerm(PermOverwrite, target)
+}
+
+// CanAddDirsFromWeb returns true if the client can add directories from the web UI.
+// The specified target is the directory where the new directory must be created
+func (u *User) CanAddDirsFromWeb(target string) bool {
+	if util.IsStringInSlice(sdk.WebClientWriteDisabled, u.Filters.WebClient) {
+		return false
+	}
+	return u.HasPerm(PermCreateDirs, target)
+}
+
+// CanRenameFromWeb returns true if the client can rename objects from the web UI.
+// The specified src and dest are the source and target directories for the rename.
+func (u *User) CanRenameFromWeb(src, dest string) bool {
+	if util.IsStringInSlice(sdk.WebClientWriteDisabled, u.Filters.WebClient) {
+		return false
+	}
+	if u.HasPerm(PermRename, src) && u.HasPerm(PermRename, dest) {
+		return true
+	}
+	if !u.HasPerm(PermDelete, src) {
+		return false
+	}
+	return u.HasPerm(PermUpload, dest) || u.HasPerm(PermCreateDirs, dest)
+}
+
+// CanDeleteFromWeb returns true if the client can delete objects from the web UI.
+// The specified target is the parent directory for the object to delete
+func (u *User) CanDeleteFromWeb(target string) bool {
+	if util.IsStringInSlice(sdk.WebClientWriteDisabled, u.Filters.WebClient) {
+		return false
+	}
+	return u.HasPerm(PermDelete, target)
+}
+
 // GetSignature returns a signature for this admin.
 // It could change after an update
 func (u *User) GetSignature() string {

+ 5 - 3
httpd/httpd.go

@@ -57,7 +57,9 @@ const (
 	userPwdPath                     = "/api/v2/user/changepwd"
 	userPublicKeysPath              = "/api/v2/user/publickeys"
 	userFolderPath                  = "/api/v2/user/folder"
+	userDirsPath                    = "/api/v2/user/dirs"
 	userFilePath                    = "/api/v2/user/file"
+	userFilesPath                   = "/api/v2/user/files"
 	userStreamZipPath               = "/api/v2/user/streamzip"
 	healthzPath                     = "/healthz"
 	webRootPathDefault              = "/"
@@ -87,7 +89,7 @@ const (
 	webDefenderHostsPathDefault     = "/web/admin/defender/hosts"
 	webClientLoginPathDefault       = "/web/client/login"
 	webClientFilesPathDefault       = "/web/client/files"
-	webClientDirContentsPathDefault = "/web/client/listdir"
+	webClientDirsPathDefault        = "/web/client/dirs"
 	webClientDownloadZipPathDefault = "/web/client/downloadzip"
 	webClientCredentialsPathDefault = "/web/client/credentials"
 	webChangeClientPwdPathDefault   = "/web/client/changepwd"
@@ -136,7 +138,7 @@ var (
 	webDefenderHostsPath     string
 	webClientLoginPath       string
 	webClientFilesPath       string
-	webClientDirContentsPath string
+	webClientDirsPath        string
 	webClientDownloadZipPath string
 	webClientCredentialsPath string
 	webChangeClientPwdPath   string
@@ -444,7 +446,7 @@ func updateWebClientURLs(baseURL string) {
 	webBaseClientPath = path.Join(baseURL, webBasePathClientDefault)
 	webClientLoginPath = path.Join(baseURL, webClientLoginPathDefault)
 	webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault)
-	webClientDirContentsPath = path.Join(baseURL, webClientDirContentsPathDefault)
+	webClientDirsPath = path.Join(baseURL, webClientDirsPathDefault)
 	webClientDownloadZipPath = path.Join(baseURL, webClientDownloadZipPathDefault)
 	webClientCredentialsPath = path.Join(baseURL, webClientCredentialsPathDefault)
 	webChangeClientPwdPath = path.Join(baseURL, webChangeClientPwdPathDefault)

+ 91 - 91
httpd/httpd_test.go

@@ -78,8 +78,8 @@ const (
 	logoutPath                      = "/api/v2/logout"
 	userPwdPath                     = "/api/v2/user/changepwd"
 	userPublicKeysPath              = "/api/v2/user/publickeys"
-	userFolderPath                  = "/api/v2/user/folder"
-	userFilePath                    = "/api/v2/user/file"
+	userDirsPath                    = "/api/v2/user/dirs"
+	userFilesPath                   = "/api/v2/user/files"
 	userStreamZipPath               = "/api/v2/user/streamzip"
 	healthzPath                     = "/healthz"
 	webBasePath                     = "/web"
@@ -104,7 +104,7 @@ const (
 	webBasePathClient               = "/web/client"
 	webClientLoginPath              = "/web/client/login"
 	webClientFilesPath              = "/web/client/files"
-	webClientDirContentsPath        = "/web/client/listdir"
+	webClientDirsPath               = "/web/client/dirs"
 	webClientDownloadZipPath        = "/web/client/downloadzip"
 	webClientCredentialsPath        = "/web/client/credentials"
 	webChangeClientPwdPath          = "/web/client/changepwd"
@@ -4882,14 +4882,14 @@ func TestWebAPILoginMock(t *testing.T) {
 	webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.NoError(t, err)
 	// a web token is not valid for API usage
-	req, err := http.NewRequest(http.MethodGet, userFolderPath, nil)
+	req, err := http.NewRequest(http.MethodGet, userDirsPath, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webToken)
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusUnauthorized, rr)
 	assert.Contains(t, rr.Body.String(), "Your token audience is not valid")
 
-	req, err = http.NewRequest(http.MethodGet, userFolderPath+"/?path=%2F", nil)
+	req, err = http.NewRequest(http.MethodGet, userDirsPath+"/?path=%2F", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, apiToken)
 	rr = executeRequest(req)
@@ -4977,7 +4977,7 @@ func TestWebClientLoginMock(t *testing.T) {
 	checkResponseCode(t, http.StatusNotFound, rr)
 	assert.Contains(t, rr.Body.String(), "Unable to retrieve your user")
 
-	req, _ = http.NewRequest(http.MethodGet, webClientDirContentsPath, nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientDirsPath, nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
@@ -4989,13 +4989,13 @@ func TestWebClientLoginMock(t *testing.T) {
 	checkResponseCode(t, http.StatusNotFound, rr)
 	assert.Contains(t, rr.Body.String(), "Unable to retrieve your user")
 
-	req, _ = http.NewRequest(http.MethodGet, userFolderPath, nil)
+	req, _ = http.NewRequest(http.MethodGet, userDirsPath, nil)
 	setBearerForReq(req, apiUserToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 	assert.Contains(t, rr.Body.String(), "Unable to retrieve your user")
 
-	req, _ = http.NewRequest(http.MethodGet, userFilePath, nil)
+	req, _ = http.NewRequest(http.MethodGet, userFilesPath, nil)
 	setBearerForReq(req, apiUserToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
@@ -5468,7 +5468,7 @@ func TestPreDownloadHook(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 	assert.Equal(t, testFileContents, rr.Body.Bytes())
 
-	req, err = http.NewRequest(http.MethodGet, userFilePath+"?path="+testFileName, nil)
+	req, err = http.NewRequest(http.MethodGet, userFilesPath+"?path="+testFileName, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -5484,7 +5484,7 @@ func TestPreDownloadHook(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 	assert.Contains(t, rr.Body.String(), "permission denied")
 
-	req, err = http.NewRequest(http.MethodGet, userFilePath+"?path="+testFileName, nil)
+	req, err = http.NewRequest(http.MethodGet, userFilesPath+"?path="+testFileName, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -5530,7 +5530,7 @@ func TestPreUploadHook(t *testing.T) {
 	reader := bytes.NewReader(body.Bytes())
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err := http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err := http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -5541,7 +5541,7 @@ func TestPreUploadHook(t *testing.T) {
 	assert.NoError(t, err)
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -5586,7 +5586,7 @@ func TestWebGetFiles(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 
-	req, _ = http.NewRequest(http.MethodGet, webClientDirContentsPath+"?path="+testDir, nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientDirsPath+"?path="+testDir, nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
@@ -5595,7 +5595,7 @@ func TestWebGetFiles(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Len(t, dirContents, 1)
 
-	req, _ = http.NewRequest(http.MethodGet, userFolderPath+"?path="+testDir, nil)
+	req, _ = http.NewRequest(http.MethodGet, userDirsPath+"?path="+testDir, nil)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
@@ -5636,7 +5636,7 @@ func TestWebGetFiles(t *testing.T) {
 	checkResponseCode(t, http.StatusInternalServerError, rr)
 	assert.Contains(t, rr.Body.String(), "Unable to get files list")
 
-	req, _ = http.NewRequest(http.MethodGet, webClientDirContentsPath+"?path=/", nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientDirsPath+"?path=/", nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
@@ -5645,7 +5645,7 @@ func TestWebGetFiles(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Len(t, dirContents, len(extensions)+1)
 
-	req, _ = http.NewRequest(http.MethodGet, userFolderPath+"?path=/", nil)
+	req, _ = http.NewRequest(http.MethodGet, userDirsPath+"?path=/", nil)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
@@ -5654,13 +5654,13 @@ func TestWebGetFiles(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Len(t, dirEntries, len(extensions)+1)
 
-	req, _ = http.NewRequest(http.MethodGet, webClientDirContentsPath+"?path=/missing", nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientDirsPath+"?path=/missing", nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 	assert.Contains(t, rr.Body.String(), "Unable to get directory contents")
 
-	req, _ = http.NewRequest(http.MethodGet, userFolderPath+"?path=missing", nil)
+	req, _ = http.NewRequest(http.MethodGet, userDirsPath+"?path=missing", nil)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
@@ -5672,25 +5672,25 @@ func TestWebGetFiles(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 	assert.Equal(t, testFileContents, rr.Body.Bytes())
 
-	req, _ = http.NewRequest(http.MethodGet, userFilePath+"?path="+testFileName, nil)
+	req, _ = http.NewRequest(http.MethodGet, userFilesPath+"?path="+testFileName, nil)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	assert.Equal(t, testFileContents, rr.Body.Bytes())
 
-	req, _ = http.NewRequest(http.MethodGet, userFilePath+"?path=", nil)
+	req, _ = http.NewRequest(http.MethodGet, userFilesPath+"?path=", nil)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	assert.Contains(t, rr.Body.String(), "Please set the path to a valid file")
 
-	req, _ = http.NewRequest(http.MethodGet, userFilePath+"?path="+testDir, nil)
+	req, _ = http.NewRequest(http.MethodGet, userFilesPath+"?path="+testDir, nil)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	assert.Contains(t, rr.Body.String(), "is a directory")
 
-	req, _ = http.NewRequest(http.MethodGet, userFilePath+"?path=notafile", nil)
+	req, _ = http.NewRequest(http.MethodGet, userFilesPath+"?path=notafile", nil)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
@@ -5703,7 +5703,7 @@ func TestWebGetFiles(t *testing.T) {
 	checkResponseCode(t, http.StatusPartialContent, rr)
 	assert.Equal(t, testFileContents[2:], rr.Body.Bytes())
 
-	req, _ = http.NewRequest(http.MethodGet, userFilePath+"?path="+testFileName, nil)
+	req, _ = http.NewRequest(http.MethodGet, userFilesPath+"?path="+testFileName, nil)
 	req.Header.Set("Range", "bytes=2-")
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -5729,7 +5729,7 @@ func TestWebGetFiles(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusRequestedRangeNotSatisfiable, rr)
 
-	req, _ = http.NewRequest(http.MethodGet, userFilePath+"?path="+testFileName, nil)
+	req, _ = http.NewRequest(http.MethodGet, userFilesPath+"?path="+testFileName, nil)
 	req.Header.Set("Range", "bytes=2b-")
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -5767,7 +5767,7 @@ func TestWebGetFiles(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusPreconditionFailed, rr)
 
-	req, _ = http.NewRequest(http.MethodHead, userFilePath+"?path="+testFileName, nil)
+	req, _ = http.NewRequest(http.MethodHead, userFilesPath+"?path="+testFileName, nil)
 	req.Header.Set("If-Unmodified-Since", time.Now().UTC().Add(-120*time.Second).Format(http.TimeFormat))
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -5788,17 +5788,17 @@ func TestWebGetFiles(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
-	req, _ = http.NewRequest(http.MethodGet, webClientDirContentsPath+"?path=/", nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientDirsPath+"?path=/", nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
-	req, _ = http.NewRequest(http.MethodGet, userFilePath+"?path="+testFileName, nil)
+	req, _ = http.NewRequest(http.MethodGet, userFilesPath+"?path="+testFileName, nil)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
-	req, _ = http.NewRequest(http.MethodGet, userFolderPath+"?path="+testDir, nil)
+	req, _ = http.NewRequest(http.MethodGet, userDirsPath+"?path="+testDir, nil)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
@@ -5826,7 +5826,7 @@ func TestWebGetFiles(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
-	req, _ = http.NewRequest(http.MethodGet, userFolderPath+"?path="+testDir, nil)
+	req, _ = http.NewRequest(http.MethodGet, userDirsPath+"?path="+testDir, nil)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
@@ -5844,7 +5844,7 @@ func TestWebDirsAPI(t *testing.T) {
 	assert.NoError(t, err)
 	testDir := "testdir"
 
-	req, err := http.NewRequest(http.MethodGet, userFolderPath, nil)
+	req, err := http.NewRequest(http.MethodGet, userDirsPath, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr := executeRequest(req)
@@ -5855,25 +5855,25 @@ func TestWebDirsAPI(t *testing.T) {
 	assert.Len(t, contents, 0)
 
 	// rename a missing folder
-	req, err = http.NewRequest(http.MethodPatch, userFolderPath+"?path="+testDir+"&target="+testDir+"new", nil)
+	req, err = http.NewRequest(http.MethodPatch, userDirsPath+"?path="+testDir+"&target="+testDir+"new", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 	// delete a missing folder
-	req, err = http.NewRequest(http.MethodDelete, userFolderPath+"?path="+testDir, nil)
+	req, err = http.NewRequest(http.MethodDelete, userDirsPath+"?path="+testDir, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 	// create a dir
-	req, err = http.NewRequest(http.MethodPost, userFolderPath+"?path="+testDir, nil)
+	req, err = http.NewRequest(http.MethodPost, userDirsPath+"?path="+testDir, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusCreated, rr)
 	// check the dir was created
-	req, err = http.NewRequest(http.MethodGet, userFolderPath, nil)
+	req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -5885,19 +5885,19 @@ func TestWebDirsAPI(t *testing.T) {
 		assert.Equal(t, testDir, contents[0]["name"])
 	}
 	// rename the dir
-	req, err = http.NewRequest(http.MethodPatch, userFolderPath+"?path="+testDir+"&target="+testDir+"new", nil)
+	req, err = http.NewRequest(http.MethodPatch, userDirsPath+"?path="+testDir+"&target="+testDir+"new", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	// delete the dir
-	req, err = http.NewRequest(http.MethodDelete, userFolderPath+"?path="+testDir+"new", nil)
+	req, err = http.NewRequest(http.MethodDelete, userDirsPath+"?path="+testDir+"new", 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, userFolderPath, nil)
+	req, err = http.NewRequest(http.MethodPost, userDirsPath, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -5907,7 +5907,7 @@ func TestWebDirsAPI(t *testing.T) {
 	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 	assert.NoError(t, err)
 	// the user has no more the permission to create the directory
-	req, err = http.NewRequest(http.MethodPost, userFolderPath+"?path="+testDir, nil)
+	req, err = http.NewRequest(http.MethodPost, userDirsPath+"?path="+testDir, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -5919,19 +5919,19 @@ func TestWebDirsAPI(t *testing.T) {
 	assert.NoError(t, err)
 
 	// the user is deleted, any API call should fail
-	req, err = http.NewRequest(http.MethodPost, userFolderPath+"?path="+testDir, nil)
+	req, err = http.NewRequest(http.MethodPost, userDirsPath+"?path="+testDir, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 
-	req, err = http.NewRequest(http.MethodPatch, userFolderPath+"?path="+testDir+"&target="+testDir+"new", nil)
+	req, err = http.NewRequest(http.MethodPatch, userDirsPath+"?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.MethodDelete, userFolderPath+"?path="+testDir+"new", nil)
+	req, err = http.NewRequest(http.MethodDelete, userDirsPath+"?path="+testDir+"new", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -5958,7 +5958,7 @@ func TestWebFilesAPI(t *testing.T) {
 	assert.NoError(t, err)
 	reader := bytes.NewReader(body.Bytes())
 
-	req, err := http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err := http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr := executeRequest(req)
@@ -5967,14 +5967,14 @@ func TestWebFilesAPI(t *testing.T) {
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
 	// set the proper content type
-	req, err = http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusCreated, rr)
 	// check we have 2 files
-	req, err = http.NewRequest(http.MethodGet, userFolderPath, nil)
+	req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -5986,13 +5986,13 @@ func TestWebFilesAPI(t *testing.T) {
 	// overwrite the existing files
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusCreated, rr)
-	req, err = http.NewRequest(http.MethodGet, userFolderPath, nil)
+	req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -6003,20 +6003,20 @@ func TestWebFilesAPI(t *testing.T) {
 	assert.Len(t, contents, 2)
 	// now create a dir and upload to that dir
 	testDir := "tdir"
-	req, err = http.NewRequest(http.MethodPost, userFolderPath+"?path="+testDir, nil)
+	req, err = http.NewRequest(http.MethodPost, userDirsPath+"?path="+testDir, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusCreated, rr)
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath+"?path="+testDir, reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath+"?path="+testDir, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusCreated, rr)
-	req, err = http.NewRequest(http.MethodGet, userFolderPath, nil)
+	req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -6025,7 +6025,7 @@ func TestWebFilesAPI(t *testing.T) {
 	err = json.NewDecoder(rr.Body).Decode(&contents)
 	assert.NoError(t, err)
 	assert.Len(t, contents, 3)
-	req, err = http.NewRequest(http.MethodGet, userFolderPath+"?path="+testDir, nil)
+	req, err = http.NewRequest(http.MethodGet, userDirsPath+"?path="+testDir, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -6035,31 +6035,31 @@ func TestWebFilesAPI(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Len(t, contents, 2)
 	// rename a file
-	req, err = http.NewRequest(http.MethodPatch, userFilePath+"?path=file1.txt&target=%2Ftdir%2Ffile3.txt", nil)
+	req, err = http.NewRequest(http.MethodPatch, userFilesPath+"?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, userFilePath+"?path=file1.txt&target=%2Ftdir%2Ffile3.txt", nil)
+	req, err = http.NewRequest(http.MethodPatch, userFilesPath+"?path=file1.txt&target=%2Ftdir%2Ffile3.txt", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 	// delete a file
-	req, err = http.NewRequest(http.MethodDelete, userFilePath+"?path=file2.txt", nil)
+	req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path=file2.txt", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	// delete a missing file
-	req, err = http.NewRequest(http.MethodDelete, userFilePath+"?path=file2.txt", nil)
+	req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path=file2.txt", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 	// delete a directory
-	req, err = http.NewRequest(http.MethodDelete, userFilePath+"?path=tdir", nil)
+	req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path=tdir", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -6070,7 +6070,7 @@ func TestWebFilesAPI(t *testing.T) {
 	assert.NoError(t, err)
 	err = os.Symlink(extPath, filepath.Join(user.GetHomeDir(), "file"))
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodDelete, userFilePath+"?path=file", nil)
+	req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path=file", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -6083,14 +6083,14 @@ func TestWebFilesAPI(t *testing.T) {
 	assert.NoError(t, err)
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath+"?path=tdir", reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath+"?path=tdir", reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
-	req, err = http.NewRequest(http.MethodDelete, userFilePath+"?path=%2Ftdir%2Ffile1.txt", nil)
+	req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path=%2Ftdir%2Ffile1.txt", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -6103,20 +6103,20 @@ func TestWebFilesAPI(t *testing.T) {
 	// the user is deleted, any API call should fail
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 
-	req, err = http.NewRequest(http.MethodPatch, userFilePath+"?path=file1.txt&target=%2Ftdir%2Ffile3.txt", nil)
+	req, err = http.NewRequest(http.MethodPatch, userFilesPath+"?path=file1.txt&target=%2Ftdir%2Ffile3.txt", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 
-	req, err = http.NewRequest(http.MethodDelete, userFilePath+"?path=file2.txt", nil)
+	req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path=file2.txt", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -6155,7 +6155,7 @@ func TestWebUploadErrors(t *testing.T) {
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
 	// zip file are not allowed within sub2
-	req, err := http.NewRequest(http.MethodPost, userFilePath+"?path=sub2", reader)
+	req, err := http.NewRequest(http.MethodPost, userFilesPath+"?path=sub2", reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6165,7 +6165,7 @@ func TestWebUploadErrors(t *testing.T) {
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
 	// we have no upload permissions within sub1
-	req, err = http.NewRequest(http.MethodPost, userFilePath+"?path=sub1", reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath+"?path=sub1", reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6173,7 +6173,7 @@ func TestWebUploadErrors(t *testing.T) {
 	checkResponseCode(t, http.StatusForbidden, rr)
 
 	// create a dir and try to overwrite it with a file
-	req, err = http.NewRequest(http.MethodPost, userFolderPath+"?path=file.zip", nil)
+	req, err = http.NewRequest(http.MethodPost, userDirsPath+"?path=file.zip", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -6181,7 +6181,7 @@ func TestWebUploadErrors(t *testing.T) {
 
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6191,14 +6191,14 @@ func TestWebUploadErrors(t *testing.T) {
 	// try to upload to a missing parent directory
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath+"?path=missingdir", reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath+"?path=missingdir", reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 
-	req, err = http.NewRequest(http.MethodDelete, userFolderPath+"?path=file.zip", nil)
+	req, err = http.NewRequest(http.MethodDelete, userDirsPath+"?path=file.zip", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -6206,7 +6206,7 @@ func TestWebUploadErrors(t *testing.T) {
 	// upload will work now
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6215,7 +6215,7 @@ func TestWebUploadErrors(t *testing.T) {
 	// overwrite the file
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6226,7 +6226,7 @@ func TestWebUploadErrors(t *testing.T) {
 
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6234,7 +6234,7 @@ func TestWebUploadErrors(t *testing.T) {
 	checkResponseCode(t, http.StatusNotFound, rr)
 
 	if runtime.GOOS != osWindows {
-		req, err = http.NewRequest(http.MethodDelete, userFilePath+"?path=file.zip", reader)
+		req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path=file.zip", reader)
 		assert.NoError(t, err)
 		setBearerForReq(req, webAPIToken)
 		rr = executeRequest(req)
@@ -6246,7 +6246,7 @@ func TestWebUploadErrors(t *testing.T) {
 
 		_, err = reader.Seek(0, io.SeekStart)
 		assert.NoError(t, err)
-		req, err = http.NewRequest(http.MethodPost, userFilePath, reader)
+		req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
 		assert.NoError(t, err)
 		req.Header.Add("Content-Type", writer.FormDataContentType())
 		setBearerForReq(req, webAPIToken)
@@ -6268,7 +6268,7 @@ func TestWebUploadErrors(t *testing.T) {
 	reader = bytes.NewReader(body.Bytes())
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath+"?path=sub2", reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath+"?path=sub2", reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6315,7 +6315,7 @@ func TestWebAPIVFolder(t *testing.T) {
 	assert.NoError(t, err)
 	reader := bytes.NewReader(body.Bytes())
 
-	req, err := http.NewRequest(http.MethodPost, userFilePath+"?path=vdir", reader)
+	req, err := http.NewRequest(http.MethodPost, userFilesPath+"?path=vdir", reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6332,7 +6332,7 @@ func TestWebAPIVFolder(t *testing.T) {
 
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath+"?path=vdir", reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath+"?path=vdir", reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6375,56 +6375,56 @@ func TestWebAPIWritePermission(t *testing.T) {
 	assert.NoError(t, err)
 	reader := bytes.NewReader(body.Bytes())
 
-	req, err := http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err := http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
-	req, err = http.NewRequest(http.MethodPatch, userFilePath+"?path=a&target=b", nil)
+	req, err = http.NewRequest(http.MethodPatch, userFilesPath+"?path=a&target=b", nil)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
-	req, err = http.NewRequest(http.MethodDelete, userFilePath+"?path=a", nil)
+	req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path=a", nil)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
-	req, err = http.NewRequest(http.MethodGet, userFilePath+"?path=a.txt", nil)
+	req, err = http.NewRequest(http.MethodGet, userFilesPath+"?path=a.txt", nil)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 
-	req, err = http.NewRequest(http.MethodGet, userFolderPath, nil)
+	req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 
-	req, err = http.NewRequest(http.MethodPost, userFolderPath+"?path=dir", nil)
+	req, err = http.NewRequest(http.MethodPost, userDirsPath+"?path=dir", nil)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
-	req, err = http.NewRequest(http.MethodPatch, userFolderPath+"?path=dir&target=dir1", nil)
+	req, err = http.NewRequest(http.MethodPatch, userDirsPath+"?path=dir&target=dir1", nil)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
-	req, err = http.NewRequest(http.MethodDelete, userFolderPath+"?path=dir", nil)
+	req, err = http.NewRequest(http.MethodDelete, userDirsPath+"?path=dir", nil)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6457,7 +6457,7 @@ func TestWebAPICryptFs(t *testing.T) {
 	assert.NoError(t, err)
 	reader := bytes.NewReader(body.Bytes())
 
-	req, err := http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err := http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6466,7 +6466,7 @@ func TestWebAPICryptFs(t *testing.T) {
 
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6503,7 +6503,7 @@ func TestWebUploadSFTP(t *testing.T) {
 	assert.NoError(t, err)
 	reader := bytes.NewReader(body.Bytes())
 
-	req, err := http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err := http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6523,7 +6523,7 @@ func TestWebUploadSFTP(t *testing.T) {
 	// we are now overquota on overwrite
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6531,7 +6531,7 @@ func TestWebUploadSFTP(t *testing.T) {
 	checkResponseCode(t, http.StatusInternalServerError, rr)
 	assert.Contains(t, rr.Body.String(), "denying write due to space limit")
 	// delete the file
-	req, err = http.NewRequest(http.MethodDelete, userFilePath+"?path=file.txt", nil)
+	req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path=file.txt", nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
@@ -6539,7 +6539,7 @@ func TestWebUploadSFTP(t *testing.T) {
 
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPost, userFilePath, reader)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	setBearerForReq(req, webAPIToken)
@@ -6563,7 +6563,7 @@ func TestWebUploadMultipartFormReadError(t *testing.T) {
 	webAPIToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.NoError(t, err)
 
-	req, err := http.NewRequest(http.MethodPost, userFilePath, nil)
+	req, err := http.NewRequest(http.MethodPost, userFilesPath, nil)
 	assert.NoError(t, err)
 
 	mpartForm := &multipart.Form{
@@ -6735,7 +6735,7 @@ func TestClientUserClose(t *testing.T) {
 		err = writer.Close()
 		assert.NoError(t, err)
 		reader := bytes.NewReader(body.Bytes())
-		req, err := http.NewRequest(http.MethodPost, userFilePath, reader)
+		req, err := http.NewRequest(http.MethodPost, userFilesPath, reader)
 		assert.NoError(t, err)
 		req.Header.Add("Content-Type", writer.FormDataContentType())
 		setBearerForReq(req, webAPIToken)

+ 1 - 1
httpd/internal_test.go

@@ -1580,7 +1580,7 @@ func TestGetFilesInvalidClaims(t *testing.T) {
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 
 	rr = httptest.NewRecorder()
-	req, _ = http.NewRequest(http.MethodGet, webClientDirContentsPath, nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientDirsPath, nil)
 	req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
 	handleClientGetDirContents(rr, req)
 	assert.Equal(t, http.StatusForbidden, rr.Code)

+ 78 - 5
httpd/schema/openapi.yaml

@@ -1763,8 +1763,41 @@ paths:
       tags:
         - users API
       summary: Read folders contents
-      description: Returns the contents of the specified folder for the logged in user
+      description: Returns the contents of the specified folder for the logged in user. Please use '/user/dirs' instead
       operationId: get_user_folder_contents
+      deprecated: true
+      parameters:
+        - in: query
+          name: path
+          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the root folder is assumed
+          schema:
+            type: string
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/DirEntry'
+        '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:
+        - users API
+      summary: Read directory contents
+      description: Returns the contents of the specified directory for the logged in user
+      operationId: get_user_dir_contents
       parameters:
         - in: query
           name: path
@@ -1795,7 +1828,7 @@ paths:
         - users API
       summary: Create a directory
       description: Create a directory for the logged in user
-      operationId: create_user_folder
+      operationId: create_user_dir
       parameters:
         - in: query
           name: path
@@ -1827,7 +1860,7 @@ paths:
         - users API
       summary: Rename a directory
       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_folder
+      operationId: rename_user_dir
       parameters:
         - in: query
           name: path
@@ -1865,7 +1898,7 @@ paths:
         - users API
       summary: Delete a directory
       description: Delete a directory for the logged in user. Only empty directories can be deleted
-      operationId: delete_user_folder
+      operationId: delete_user_dir
       parameters:
         - in: query
           name: path
@@ -1897,8 +1930,48 @@ paths:
       tags:
         - users API
       summary: Download a single file
-      description: Returns the file contents as response body
+      description: Returns the file contents as response body. Please use '/user/files' instead
       operationId: get_user_file
+      deprecated: true
+      parameters:
+        - in: query
+          name: path
+          required: true
+          description: Path to the file to download. It must be URL encoded, for example the path "my dir/àdir/file.txt" must be sent as "my%20dir%2F%C3%A0dir%2Ffile.txt"
+          schema:
+            type: string
+      responses:
+        '200':
+          description: successful operation
+          content:
+            '*/*':
+              schema:
+                type: string
+                format: binary
+        '206':
+          description: successful operation
+          content:
+            '*/*':
+              schema:
+                type: string
+                format: binary
+        '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/files:
+    get:
+      tags:
+        - users API
+      summary: Download a single file
+      description: Returns the file contents as response body
+      operationId: download_user_file
       parameters:
         - in: query
           name: path

+ 24 - 8
httpd/server.go

@@ -631,14 +631,18 @@ func (s *httpdServer) initializeRouter() {
 		router.Put(userPwdPath, changeUserPassword)
 		router.With(checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).Get(userPublicKeysPath, getUserPublicKeys)
 		router.With(checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).Put(userPublicKeysPath, setUserPublicKeys)
-		router.Get(userFolderPath, readUserFolder)
-		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Post(userFolderPath, createUserDir)
-		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Patch(userFolderPath, renameUserDir)
-		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Delete(userFolderPath, deleteUserDir)
+		// compatibility layer to remove in v2.3
+		router.With(compressor.Handler).Get(userFolderPath, readUserFolder)
 		router.Get(userFilePath, getUserFile)
-		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Post(userFilePath, uploadUserFiles)
-		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Patch(userFilePath, renameUserFile)
-		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Delete(userFilePath, deleteUserFile)
+
+		router.With(compressor.Handler).Get(userDirsPath, readUserFolder)
+		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Post(userDirsPath, createUserDir)
+		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Patch(userDirsPath, renameUserDir)
+		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Delete(userDirsPath, deleteUserDir)
+		router.Get(userFilesPath, getUserFile)
+		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Post(userFilesPath, uploadUserFiles)
+		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Patch(userFilesPath, renameUserFile)
+		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Delete(userFilesPath, deleteUserFile)
 		router.Post(userStreamZipPath, getUserFilesAsZipStream)
 	})
 
@@ -677,7 +681,19 @@ func (s *httpdServer) initializeRouter() {
 
 			router.Get(webClientLogoutPath, handleWebClientLogout)
 			router.With(s.refreshCookie).Get(webClientFilesPath, handleClientGetFiles)
-			router.With(compressor.Handler, s.refreshCookie).Get(webClientDirContentsPath, handleClientGetDirContents)
+			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+				Post(webClientFilesPath, uploadUserFiles)
+			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+				Patch(webClientFilesPath, renameUserFile)
+			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+				Delete(webClientFilesPath, deleteUserFile)
+			router.With(compressor.Handler, s.refreshCookie).Get(webClientDirsPath, handleClientGetDirContents)
+			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+				Post(webClientDirsPath, createUserDir)
+			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+				Patch(webClientDirsPath, renameUserDir)
+			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+				Delete(webClientDirsPath, deleteUserDir)
 			router.With(s.refreshCookie).Get(webClientDownloadZipPath, handleWebClientDownloadZip)
 			router.With(s.refreshCookie).Get(webClientCredentialsPath, handleClientGetCredentials)
 			router.Post(webChangeClientPwdPath, handleWebClientChangePwdPost)

+ 19 - 10
httpd/webclient.go

@@ -73,11 +73,15 @@ type dirMapping struct {
 
 type filesPage struct {
 	baseClientPage
-	CurrentDir  string
-	ReadDirURL  string
-	DownloadURL string
-	Error       string
-	Paths       []dirMapping
+	CurrentDir    string
+	DirsURL       string
+	DownloadURL   string
+	CanAddFiles   bool
+	CanCreateDirs bool
+	CanRename     bool
+	CanDelete     bool
+	Error         string
+	Paths         []dirMapping
 }
 
 type clientMessagePage struct {
@@ -207,13 +211,17 @@ func renderClientNotFoundPage(w http.ResponseWriter, r *http.Request, err error)
 	renderClientMessagePage(w, r, page404Title, page404Body, http.StatusNotFound, err, "")
 }
 
-func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string) {
+func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, user dataprovider.User) {
 	data := filesPage{
 		baseClientPage: getBaseClientPageData(pageClientFilesTitle, webClientFilesPath, r),
 		Error:          error,
 		CurrentDir:     url.QueryEscape(dirName),
 		DownloadURL:    webClientDownloadZipPath,
-		ReadDirURL:     webClientDirContentsPath,
+		DirsURL:        webClientDirsPath,
+		CanAddFiles:    user.CanAddFilesFromWeb(dirName),
+		CanCreateDirs:  user.CanAddDirsFromWeb(dirName),
+		CanRename:      user.CanRenameFromWeb(dirName, dirName),
+		CanDelete:      user.CanDeleteFromWeb(dirName),
 	}
 	paths := []dirMapping{}
 	if dirName != "/" {
@@ -359,6 +367,7 @@ func handleClientGetDirContents(w http.ResponseWriter, r *http.Request) {
 				res["size"] = util.ByteCountIEC(info.Size())
 			}
 		}
+		res["type_name"] = fmt.Sprintf("%v_%v", res["type"], info.Name())
 		res["name"] = info.Name()
 		res["last_modified"] = getFileObjectModTime(info.ModTime())
 		res["url"] = getFileObjectURL(name, info.Name())
@@ -406,11 +415,11 @@ func handleClientGetFiles(w http.ResponseWriter, r *http.Request) {
 		info, err = connection.Stat(name, 0)
 	}
 	if err != nil {
-		renderFilesPage(w, r, path.Dir(name), fmt.Sprintf("unable to stat file %#v: %v", name, err))
+		renderFilesPage(w, r, path.Dir(name), fmt.Sprintf("unable to stat file %#v: %v", name, err), user)
 		return
 	}
 	if info.IsDir() {
-		renderFilesPage(w, r, name, "")
+		renderFilesPage(w, r, name, "", user)
 		return
 	}
 	if status, err := downloadFile(w, r, connection, name, info); err != nil && status != 0 {
@@ -419,7 +428,7 @@ func handleClientGetFiles(w http.ResponseWriter, r *http.Request) {
 				renderClientMessagePage(w, r, http.StatusText(status), "", status, err, "")
 				return
 			}
-			renderFilesPage(w, r, path.Dir(name), err.Error())
+			renderFilesPage(w, r, path.Dir(name), err.Error(), user)
 		}
 	}
 }

+ 1 - 1
templates/webadmin/admins.html

@@ -65,7 +65,7 @@
                     Confirmation required
                 </h5>
                 <button class="close" type="button" data-dismiss="modal" aria-label="Close">
-                    <span aria-hidden="true">×</span>
+                    <span aria-hidden="true">&times;</span>
                 </button>
             </div>
             <div class="modal-body">Do you want to delete the selected admin?</div>

+ 2 - 2
templates/webadmin/base.html

@@ -10,7 +10,7 @@
     <meta name="description" content="">
     <meta name="author" content="">
 
-    <title>SFTPGo - {{template "title" .}}</title>
+    <title>SFTPGo Admin - {{template "title" .}}</title>
 
     <link rel="shortcut icon" href="{{.StaticURL}}/favicon.ico" />
 
@@ -227,7 +227,7 @@
                 <div class="modal-header">
                     <h5 class="modal-title" id="modalLabel">Ready to Leave?</h5>
                     <button class="close" type="button" data-dismiss="modal" aria-label="Close">
-                        <span aria-hidden="true">×</span>
+                        <span aria-hidden="true">&times;</span>
                     </button>
                 </div>
                 <div class="modal-body">Select "Logout" below if you are ready to end your current session.</div>

+ 1 - 1
templates/webadmin/connections.html

@@ -58,7 +58,7 @@
                     Confirmation required
                 </h5>
                 <button class="close" type="button" data-dismiss="modal" aria-label="Close">
-                    <span aria-hidden="true">×</span>
+                    <span aria-hidden="true">&times;</span>
                 </button>
             </div>
             <div class="modal-body">Do you want to close the selected connection?</div>

+ 1 - 1
templates/webadmin/defender.html

@@ -45,7 +45,7 @@
                     Confirmation required
                 </h5>
                 <button class="close" type="button" data-dismiss="modal" aria-label="Close">
-                    <span aria-hidden="true">×</span>
+                    <span aria-hidden="true">&times;</span>
                 </button>
             </div>
             <div class="modal-body">Do you want to remoce the selected blocklist entry?</div>

+ 1 - 1
templates/webadmin/folders.html

@@ -63,7 +63,7 @@
                     Confirmation required
                 </h5>
                 <button class="close" type="button" data-dismiss="modal" aria-label="Close">
-                    <span aria-hidden="true">×</span>
+                    <span aria-hidden="true">&times;</span>
                 </button>
             </div>
             <div class="modal-body">Do you want to delete the selected virtual folder and any users mapping?</div>

+ 1 - 1
templates/webadmin/login.html

@@ -88,7 +88,7 @@
                             <div class="col-lg-12">
                                 <div class="p-5">
                                     <div class="text-center">
-                                        <h1 class="h4 text-gray-900 mb-4">SFTPGo - {{.Version}}</h1>
+                                        <h1 class="h4 text-gray-900 mb-4">SFTPGo Admin - {{.Version}}</h1>
                                     </div>
                                     {{if .Error}}
                                     <div class="card mb-4 border-left-warning">

+ 1 - 1
templates/webadmin/users.html

@@ -66,7 +66,7 @@
                     Confirmation required
                 </h5>
                 <button class="close" type="button" data-dismiss="modal" aria-label="Close">
-                    <span aria-hidden="true">×</span>
+                    <span aria-hidden="true">&times;</span>
                 </button>
             </div>
             <div class="modal-body">Do you want to delete the selected user?</div>

+ 5 - 1
templates/webclient/base.html

@@ -178,7 +178,7 @@
                 <div class="modal-header">
                     <h5 class="modal-title" id="modalLabel">Ready to Leave?</h5>
                     <button class="close" type="button" data-dismiss="modal" aria-label="Close">
-                        <span aria-hidden="true">×</span>
+                        <span aria-hidden="true">&times;</span>
                     </button>
                 </div>
                 <div class="modal-body">Select "Logout" below if you are ready to end your current session.</div>
@@ -209,6 +209,10 @@
                 return '%' + c.charCodeAt(0).toString(16);
             });
         }
+
+        function replaceSlash(str){
+            return str.replace(/\//g,'\u2215');
+        }
     </script>
 
     <!-- Page level plugins -->

+ 351 - 3
templates/webclient/files.html

@@ -48,6 +48,118 @@
 </div>
 {{end}}
 
+{{define "dialog"}}
+<div class="modal fade" id="createDirModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="deleteModalLabel">
+                    Create a new directory
+                </h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <form id="create_dir_form" action="" method="POST">
+                <div class="modal-body">
+                    <div class="form-group">
+                        <label for="directory_name" class="col-form-label">Name</label>
+                        <input type="text" class="form-control" id="directory_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="uploadFilesModal" tabindex="-1" role="dialog" aria-labelledby="uploadFilesModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="uploadFilesModalLabel">
+                    Upload one or more files
+                </h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <form id="upload_files_form" action="" method="POST" enctype="multipart/form-data">
+                <div class="modal-body">
+                    <input type="file" class="form-control-file" id="files_name" name="filename" required multiple>
+                </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">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="renameModalLabel">
+                    Rename 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="rename_form" action="" method="POST">
+                <div class="modal-body">
+                    <div class="form-group">
+                        <label for="rename_old_name" class="col-form-label">Old name</label>
+                        <input type="text" class="form-control" id="rename_old_name" readonly>
+                    </div>
+                    <div class="form-group">
+                        <label for="rename_new_name" class="col-form-label">New name</label>
+                        <input type="text" class="form-control" id="rename_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="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="deleteModalLabel">
+                    Confirmation required
+                </h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">Do you want to delete the selected item?</div>
+            <div class="modal-footer">
+                <button class="btn btn-secondary" type="button" data-dismiss="modal">
+                    Cancel
+                </button>
+                <a class="btn btn-warning" href="#" onclick="deleteAction()">
+                    Delete
+                </a>
+            </div>
+        </div>
+    </div>
+</div>
+{{end}}
+
 {{define "extra_js"}}
 <script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
 <script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
@@ -159,7 +271,180 @@
         }
     }
 
+    function getNameFromTypeName(typeName) {
+        return typeName.split('_').slice(1).join('_');
+    }
+
+    function getTypeFromTypeName(typeName) {
+        return typeName.split('_')[0];
+    }
+
+    function deleteAction() {
+        var table = $('#dataTable').DataTable();
+        table.button('delete:name').enable(false);
+        var selected = table.column(0).checkboxes.selected()[0];
+        var itemType = getTypeFromTypeName(selected);
+        var itemName = getNameFromTypeName(selected);
+        var path;
+        if (itemType == "1"){
+            path = '{{.DirsURL}}';
+        } else {
+            path = '{{.FilesURL}}';
+        }
+        path+='?path={{.CurrentDir}}'+fixedEncodeURIComponent("/"+itemName);
+        $('#deleteModal').modal('hide');
+        $.ajax({
+            url: path,
+            type: 'DELETE',
+            dataType: 'json',
+            headers: { 'X-CSRF-TOKEN': '{{.CSRFToken}}' },
+            timeout: 15000,
+            success: function (result) {
+                location.reload();
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Unable to delete the selected item";
+                if ($xhr) {
+                    var 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();
+                }, 5000);
+            }
+        });
+    }
+
     $(document).ready(function () {
+        $("#create_dir_form").submit(function (event) {
+            event.preventDefault();
+            $('#createDirModal').modal('hide');
+            var dirName = replaceSlash($("#directory_name").val());
+            var path = '{{.DirsURL}}?path={{.CurrentDir}}' + fixedEncodeURIComponent("/"+dirName);
+            $.ajax({
+                url: path,
+                type: 'POST',
+                dataType: 'json',
+                headers: { 'X-CSRF-TOKEN': '{{.CSRFToken}}' },
+                timeout: 15000,
+                success: function (result) {
+                    location.reload();
+                },
+                error: function ($xhr, textStatus, errorThrown) {
+                    var txt = "Unable to create the requested directory";
+                    if ($xhr) {
+                        var 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();
+                    }, 5000);
+                }
+            });
+        });
+
+        $("#upload_files_form").submit(function (event){
+            event.preventDefault();
+            $('uploadFilesModal').modal('hide');
+            var path = '{{.FilesURL}}?path={{.CurrentDir}}';
+            $.ajax({
+                url: path,
+                type: 'POST',
+                data: new FormData(this),
+                processData: false,
+                contentType: false,
+                headers: { 'X-CSRF-TOKEN': '{{.CSRFToken}}' },
+                timeout: 15000,
+                success: function (result) {
+                    location.reload();
+                },
+                error: function ($xhr, textStatus, errorThrown) {
+                    var txt = "Error uploading files";
+                    if ($xhr) {
+                        var 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();
+                    }, 5000);
+                }
+            });
+        });
+
+        $("#rename_form").submit(function (event){
+            event.preventDefault();
+            var table = $('#dataTable').DataTable();
+            table.button('rename:name').enable(false);
+            var selected = table.column(0).checkboxes.selected()[0];
+            var itemType = getTypeFromTypeName(selected);
+            var itemName = getNameFromTypeName(selected);
+            var targetName = replaceSlash($("#rename_new_name").val());
+            var path;
+            if (itemType == "1"){
+                path = '{{.DirsURL}}';
+            } else {
+                path = '{{.FilesURL}}';
+            }
+            path+='?path={{.CurrentDir}}'+fixedEncodeURIComponent("/"+itemName)+'&target={{.CurrentDir}}'+fixedEncodeURIComponent("/"+targetName);
+            $('renameModal').modal('hide');
+            $.ajax({
+                url: path,
+                type: 'PATCH',
+                dataType: 'json',
+                headers: { 'X-CSRF-TOKEN': '{{.CSRFToken}}' },
+                timeout: 15000,
+                success: function (result) {
+                    location.reload();
+                },
+                error: function ($xhr, textStatus, errorThrown) {
+                    var txt = "Error renaming item";
+                    if ($xhr) {
+                        var 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();
+                    }, 5000);
+                }
+            });
+        });
+
         $.fn.dataTable.ext.buttons.refresh = {
             text: '<i class="fas fa-sync-alt"></i>',
             name: 'refresh',
@@ -177,7 +462,7 @@
                 var filesArray = [];
                 var selected = dt.column(0).checkboxes.selected();
                 for (i = 0; i < selected.length; i++) {
-                    filesArray.push(selected[i]);
+                    filesArray.push(getNameFromTypeName(selected[i]));
                 }
                 var files = fixedEncodeURIComponent(JSON.stringify(filesArray));
                 var downloadURL = '{{.DownloadURL}}';
@@ -187,9 +472,54 @@
             enabled: false
         };
 
+        $.fn.dataTable.ext.buttons.addFiles = {
+            text: '<i class="fas fa-file-upload"></i>',
+            name: 'addFiles',
+            titleAttr: "Upload files",
+            action: function (e, dt, node, config) {
+                $('#uploadFilesModal').modal('show');
+            },
+            enabled: true
+        };
+
+        $.fn.dataTable.ext.buttons.addDirectory = {
+            text: '<i class="fas fa-folder-plus"></i>',
+            name: 'addDirectory',
+            titleAttr: "Add directory",
+            action: function (e, dt, node, config) {
+                $("#directory_name").val("");
+                $('#createDirModal').modal('show');
+            },
+            enabled: true
+        };
+
+        $.fn.dataTable.ext.buttons.rename = {
+            text: '<i class="fas fa-edit"></i>',
+            name: 'rename',
+            titleAttr: "Rename",
+            action: function (e, dt, node, config) {
+                var selected = table.column(0).checkboxes.selected()[0];
+                var itemName = getNameFromTypeName(selected);
+                $("#rename_old_name").val(itemName);
+                $("#rename_new_name").val("");
+                $('#renameModal').modal('show');
+            },
+            enabled: false
+        };
+
+        $.fn.dataTable.ext.buttons.delete = {
+            text: '<i class="fas fa-trash"></i>',
+            name: 'delete',
+            titleAttr: "Delete",
+            action: function (e, dt, node, config) {
+                $('#deleteModal').modal('show');
+            },
+            enabled: false
+        };
+
         var table = $('#dataTable').DataTable({
             "ajax": {
-                "url": "{{.ReadDirURL}}?path={{.CurrentDir}}",
+                "url": "{{.DirsURL}}?path={{.CurrentDir}}",
                 "dataSrc": "",
                 "error": function ($xhr, textStatus, errorThrown) {
                     $(".dataTables_processing").hide();
@@ -214,7 +544,7 @@
             "deferRender": true,
             "processing": true,
             "columns": [
-                { "data": "name" },
+                { "data": "type_name" },
                 { "data": "type" },
                 {
                     "data": "name",
@@ -250,6 +580,12 @@
                                 selectedText = `${selectedItems} items selected`;
                             }
                             table.button('download:name').enable(selectedItems > 0);
+                            {{if .CanRename}}
+                            table.button('rename:name').enable(selectedItems == 1);
+                            {{end}}
+                            {{if .CanDelete}}
+                            table.button('delete:name').enable(selectedItems == 1);
+                            {{end}}
                             $('#dataTable_info').find('span').remove();
                             $("#dataTable_info").append('<span class="selected-info"><span class="selected-item">' + selectedText + '</span></span>');
                         }
@@ -279,6 +615,18 @@
                 table.button().add(0, 'refresh');
                 table.button().add(0, 'pageLength');
                 table.button().add(0, 'download');
+                {{if .CanDelete}}
+                table.button().add(0, 'delete');
+                {{end}}
+                {{if .CanRename}}
+                table.button().add(0, 'rename');
+                {{end}}
+                {{if .CanCreateDirs}}
+                table.button().add(0, 'addDirectory');
+                {{end}}
+                {{if .CanAddFiles}}
+                table.button().add(0, 'addFiles');
+                {{end}}
                 table.buttons().container().appendTo('#dataTable_wrapper .col-md-6:eq(0)');
             },
             "orderFixed": [1, 'asc'],