diff --git a/README.md b/README.md index 40ba9784..5a03751d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) Fully featured and highly configurable SFTP server with optional FTP/S and WebDAV support, written in Go. -It can serve local filesystem, S3 or Google Cloud Storage. +It can serve local filesystem, S3 (compatible) Object Storage, Google Cloud Storage and Azure Blob Storage. ## Features @@ -173,6 +173,10 @@ Each user can be mapped to the whole bucket or to a bucket virtual folder. This Each user can be mapped with a Google Cloud Storage bucket or a bucket virtual folder. This way, the mapped bucket/virtual folder is exposed over SFTP/SCP/FTP/WebDAV. More information about Google Cloud Storage integration can be found [here](./docs/google-cloud-storage.md). +### Azure Blob Storage backend + +Each user can be mapped with an Azure Blob Storage container or a container virtual folder. This way, the mapped container/virtual folder is exposed over SFTP/SCP/FTP/WebDAV. More information about Azure Blob Storage integration can be found [here](./docs/azure-blob-storage.md). + ### Other Storage backends Adding new storage backends is quite easy: diff --git a/common/actions.go b/common/actions.go index 014246e0..24f84b95 100644 --- a/common/actions.go +++ b/common/actions.go @@ -84,6 +84,13 @@ func newActionNotification( endpoint = user.FsConfig.S3Config.Endpoint } else if user.FsConfig.Provider == dataprovider.GCSFilesystemProvider { bucket = user.FsConfig.GCSConfig.Bucket + } else if user.FsConfig.Provider == dataprovider.AzureBlobFilesystemProvider { + bucket = user.FsConfig.AzBlobConfig.Container + if user.FsConfig.AzBlobConfig.SASURL != "" { + endpoint = user.FsConfig.AzBlobConfig.SASURL + } else { + endpoint = user.FsConfig.AzBlobConfig.Endpoint + } } if err == ErrQuotaExceeded { diff --git a/common/actions_test.go b/common/actions_test.go index e35d0b5a..63d3b339 100644 --- a/common/actions_test.go +++ b/common/actions_test.go @@ -28,6 +28,11 @@ func TestNewActionNotification(t *testing.T) { user.FsConfig.GCSConfig = vfs.GCSFsConfig{ Bucket: "gcsbucket", } + user.FsConfig.AzBlobConfig = vfs.AzBlobFsConfig{ + Container: "azcontainer", + SASURL: "azsasurl", + Endpoint: "azendpoint", + } a := newActionNotification(user, operationDownload, "path", "target", "", ProtocolSFTP, 123, errors.New("fake error")) assert.Equal(t, user.Username, a.Username) assert.Equal(t, 0, len(a.Bucket)) @@ -45,6 +50,18 @@ func TestNewActionNotification(t *testing.T) { assert.Equal(t, "gcsbucket", a.Bucket) assert.Equal(t, 0, len(a.Endpoint)) assert.Equal(t, 2, a.Status) + + user.FsConfig.Provider = dataprovider.AzureBlobFilesystemProvider + a = newActionNotification(user, operationDownload, "path", "target", "", ProtocolSCP, 123, nil) + assert.Equal(t, "azcontainer", a.Bucket) + assert.Equal(t, "azsasurl", a.Endpoint) + assert.Equal(t, 1, a.Status) + + user.FsConfig.AzBlobConfig.SASURL = "" + a = newActionNotification(user, operationDownload, "path", "target", "", ProtocolSCP, 123, nil) + assert.Equal(t, "azcontainer", a.Bucket) + assert.Equal(t, "azendpoint", a.Endpoint) + assert.Equal(t, 1, a.Status) } func TestActionHTTP(t *testing.T) { diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index bea725ba..0e0318f5 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -995,7 +995,7 @@ func validateFilesystemConfig(user *User) error { if err != nil { return &ValidationError{err: fmt.Sprintf("could not validate s3config: %v", err)} } - if len(user.FsConfig.S3Config.AccessSecret) > 0 { + if user.FsConfig.S3Config.AccessSecret != "" { vals := strings.Split(user.FsConfig.S3Config.AccessSecret, "$") if !strings.HasPrefix(user.FsConfig.S3Config.AccessSecret, "$aes$") || len(vals) != 4 { accessSecret, err := utils.EncryptData(user.FsConfig.S3Config.AccessSecret) @@ -1012,10 +1012,27 @@ func validateFilesystemConfig(user *User) error { return &ValidationError{err: fmt.Sprintf("could not validate GCS config: %v", err)} } return nil + } else if user.FsConfig.Provider == AzureBlobFilesystemProvider { + err := vfs.ValidateAzBlobFsConfig(&user.FsConfig.AzBlobConfig) + if err != nil { + return &ValidationError{err: fmt.Sprintf("could not validate Azure Blob config: %v", err)} + } + if user.FsConfig.AzBlobConfig.AccountKey != "" { + vals := strings.Split(user.FsConfig.AzBlobConfig.AccountKey, "$") + if !strings.HasPrefix(user.FsConfig.AzBlobConfig.AccountKey, "$aes$") || len(vals) != 4 { + accountKey, err := utils.EncryptData(user.FsConfig.AzBlobConfig.AccountKey) + if err != nil { + return &ValidationError{err: fmt.Sprintf("could not encrypt Azure blob account key: %v", err)} + } + user.FsConfig.AzBlobConfig.AccountKey = accountKey + } + } + return nil } user.FsConfig.Provider = LocalFilesystemProvider user.FsConfig.S3Config = vfs.S3FsConfig{} user.FsConfig.GCSConfig = vfs.GCSFsConfig{} + user.FsConfig.AzBlobConfig = vfs.AzBlobFsConfig{} return nil } @@ -1248,6 +1265,8 @@ func HideUserSensitiveData(user *User) User { user.FsConfig.S3Config.AccessSecret = utils.RemoveDecryptionKey(user.FsConfig.S3Config.AccessSecret) } else if user.FsConfig.Provider == GCSFilesystemProvider { user.FsConfig.GCSConfig.Credentials = nil + } else if user.FsConfig.Provider == AzureBlobFilesystemProvider { + user.FsConfig.AzBlobConfig.AccountKey = utils.RemoveDecryptionKey(user.FsConfig.AzBlobConfig.AccountKey) } return *user } diff --git a/dataprovider/user.go b/dataprovider/user.go index ee349168..b3001818 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -124,16 +124,18 @@ type FilesystemProvider int // supported values for FilesystemProvider const ( - LocalFilesystemProvider FilesystemProvider = iota // Local - S3FilesystemProvider // Amazon S3 compatible - GCSFilesystemProvider // Google Cloud Storage + LocalFilesystemProvider FilesystemProvider = iota // Local + S3FilesystemProvider // Amazon S3 compatible + GCSFilesystemProvider // Google Cloud Storage + AzureBlobFilesystemProvider // Azure Blob Storage ) // Filesystem defines cloud storage filesystem details type Filesystem struct { - Provider FilesystemProvider `json:"provider"` - S3Config vfs.S3FsConfig `json:"s3config,omitempty"` - GCSConfig vfs.GCSFsConfig `json:"gcsconfig,omitempty"` + Provider FilesystemProvider `json:"provider"` + S3Config vfs.S3FsConfig `json:"s3config,omitempty"` + GCSConfig vfs.GCSFsConfig `json:"gcsconfig,omitempty"` + AzBlobConfig vfs.AzBlobFsConfig `json:"azblobconfig,omitempty"` } // User defines a SFTPGo user @@ -196,6 +198,8 @@ func (u *User) GetFilesystem(connectionID string) (vfs.Fs, error) { config := u.FsConfig.GCSConfig config.CredentialFile = u.getGCSCredentialsFilePath() return vfs.NewGCSFs(connectionID, u.GetHomeDir(), config) + } else if u.FsConfig.Provider == AzureBlobFilesystemProvider { + return vfs.NewAzBlobFs(connectionID, u.GetHomeDir(), u.FsConfig.AzBlobConfig) } return vfs.NewOsFs(connectionID, u.GetHomeDir(), u.VirtualFolders), nil } @@ -626,6 +630,8 @@ func (u *User) GetInfoString() string { result += "Storage: S3 " } else if u.FsConfig.Provider == GCSFilesystemProvider { result += "Storage: GCS " + } else if u.FsConfig.Provider == AzureBlobFilesystemProvider { + result += "Storage: Azure " } if len(u.PublicKeys) > 0 { result += fmt.Sprintf("Public keys: %v ", len(u.PublicKeys)) @@ -725,6 +731,17 @@ func (u *User) getACopy() User { StorageClass: u.FsConfig.GCSConfig.StorageClass, KeyPrefix: u.FsConfig.GCSConfig.KeyPrefix, }, + AzBlobConfig: vfs.AzBlobFsConfig{ + Container: u.FsConfig.AzBlobConfig.Container, + AccountName: u.FsConfig.AzBlobConfig.AccountName, + AccountKey: u.FsConfig.AzBlobConfig.AccountKey, + Endpoint: u.FsConfig.AzBlobConfig.Endpoint, + SASURL: u.FsConfig.AzBlobConfig.SASURL, + KeyPrefix: u.FsConfig.AzBlobConfig.KeyPrefix, + UploadPartSize: u.FsConfig.AzBlobConfig.UploadPartSize, + UploadConcurrency: u.FsConfig.AzBlobConfig.UploadConcurrency, + UseEmulator: u.FsConfig.AzBlobConfig.UseEmulator, + }, } return User{ diff --git a/docs/account.md b/docs/account.md index 5ffc6a77..a25ed3eb 100644 --- a/docs/account.md +++ b/docs/account.md @@ -45,7 +45,7 @@ For each account, the following properties can be configured: - `allowed_extensions`, list of, case insensitive, allowed files extension. Shell like expansion is not supported so you have to specify `.jpg` and not `*.jpg`. Any file that does not end with this suffix will be denied - `denied_extensions`, list of, case insensitive, denied files extension. Denied file extensions are evaluated before the allowed ones - `path`, SFTP/SCP path, if no other specific filter is defined, the filter apply for sub directories too. For example if filters are defined for the paths `/` and `/sub` then the filters for `/` are applied for any file outside the `/sub` directory -- `fs_provider`, filesystem to serve via SFTP. Local filesystem and S3 Compatible Object Storage are supported +- `fs_provider`, filesystem to serve via SFTP. Local filesystem, S3 Compatible Object Storage, Google Cloud Storage and Azure Blob Storage are supported - `s3_bucket`, required for S3 filesystem - `s3_region`, required for S3 filesystem. Must match the region for your bucket. You can find here the list of available [AWS regions](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-available-regions). For example if your bucket is at `Frankfurt` you have to set the region to `eu-central-1` - `s3_access_key` @@ -60,6 +60,15 @@ For each account, the following properties can be configured: - `gcs_automatic_credentials`, integer. Set to 1 to use Application Default Credentials strategy or set to 0 to use explicit credentials via `gcs_credentials` - `gcs_storage_class` - `gcs_key_prefix`, allows to restrict access to the folder identified by this prefix and its contents +- `az_container`, Azure Blob Storage container +- `az_account_name`, Azure account name. leave blank to use SAS URL +- `az_account_key`, Azure account key. leave blank to use SAS URL. If provided it is stored encrypted (AES-256-GCM) +- `az_sas_url`, Azure shared access signature URL +- `az_endpoint`, Default is "blob.core.windows.net". If you use the emulator the endpoint must include the protocol, for example "http://127.0.0.1:10000" +- `az_upload_part_size`, the buffer size for multipart uploads (MB). Zero means the default (4 MB) +- `az_upload_concurrency`, how many parts are uploaded in parallel. Zero means the default (2) +- `az_key_prefix`, allows to restrict access to the folder identified by this prefix and its contents +- `az_use_emulator`, boolean These properties are stored inside the data provider. diff --git a/docs/azure-blob-storage.md b/docs/azure-blob-storage.md new file mode 100644 index 00000000..e3e7aabc --- /dev/null +++ b/docs/azure-blob-storage.md @@ -0,0 +1,20 @@ +# Azure Blob Storage backend + +To connect SFTPGo to Azure Blob Storage, you need to specify the access credentials. Azure Blob Storage has different options for credentials, we support: + +1. Providing an account name and account key. +2. Providing a shared access signature (SAS). + +If you authenticate using account and key you also need to specify a container. The endpoint can generally be left blank, the default is `blob.core.windows.net`. + +If you provide a SAS URL the container is optional and if given it must match the one inside the shared access signature. + +If you want to connect to an emulator such as [Azurite](https://github.com/Azure/Azurite) you need to provide the account name/key pair and an endpoint prefixed with the protocol, for example `http://127.0.0.1:10000`. + +Specifying a different `key_prefix`, you can assign different "folders" of the same container to different users. This is similar to a chroot directory for local filesystem. Each SFTPGo user can only access the assigned folder and its contents. The folder identified by `key_prefix` does not need to be pre-created. + +For multipart uploads you can customize the parts size and the upload concurrency. Please note that if the upload bandwidth between the client and SFTPGo is greater than the upload bandwidth between SFTPGo and the Azure Blob service then the client should wait for the last parts to be uploaded to Azure after finishing uploading the file to SFTPGo, and it may time out. Keep this in mind if you customize these parameters. + +The configured container must exist. + +This backend is very similar to the [S3](./s3.md) backend, and it has the same limitations. diff --git a/docs/build-from-source.md b/docs/build-from-source.md index de8ebb83..ddbde318 100644 --- a/docs/build-from-source.md +++ b/docs/build-from-source.md @@ -14,6 +14,7 @@ The following build tags are available: - `nogcs`, disable Google Cloud Storage backend, default enabled - `nos3`, disable S3 Compabible Object Storage backends, default enabled +- `noazblob`, disable Azure Blob Storage backend, default enabled - `nobolt`, disable Bolt data provider, default enabled - `nomysql`, disable MySQL data provider, default enabled - `nopgsql`, disable PostgreSQL data provider, default enabled diff --git a/docs/custom-actions.md b/docs/custom-actions.md index 981db685..c8ea7a0a 100644 --- a/docs/custom-actions.md +++ b/docs/custom-actions.md @@ -23,9 +23,9 @@ The external program can also read the following environment variables: - `SFTPGO_ACTION_TARGET`, non-empty for `rename` `SFTPGO_ACTION` - `SFTPGO_ACTION_SSH_CMD`, non-empty for `ssh_cmd` `SFTPGO_ACTION` - `SFTPGO_ACTION_FILE_SIZE`, non-empty for `upload`, `download` and `delete` `SFTPGO_ACTION` -- `SFTPGO_ACTION_FS_PROVIDER`, `0` for local filesystem, `1` for S3 backend, `2` for Google Cloud Storage (GCS) backend -- `SFTPGO_ACTION_BUCKET`, non-empty for S3 and GCS backends -- `SFTPGO_ACTION_ENDPOINT`, non-empty for S3 backend if configured +- `SFTPGO_ACTION_FS_PROVIDER`, `0` for local filesystem, `1` for S3 backend, `2` for Google Cloud Storage (GCS) backend, `3` for Azure Blob Storage backend +- `SFTPGO_ACTION_BUCKET`, non-empty for S3, GCS and Azure backends +- `SFTPGO_ACTION_ENDPOINT`, non-empty for S3 and Azure backend if configured. For Azure this is the SAS URL, if configured otherwise the endpoint - `SFTPGO_ACTION_STATUS`, integer. 0 means a generic error occurred. 1 means no error, 2 means quota exceeded error - `SFTPGO_ACTION_PROTOCOL`, string. Possible values are `SSH`, `SFTP`, `SCP`, `FTP`, `DAV` @@ -40,9 +40,9 @@ If the `hook` defines an HTTP URL then this URL will be invoked as HTTP POST. Th - `target_path`, not null for `rename` action - `ssh_cmd`, not null for `ssh_cmd` action - `file_size`, not null for `upload`, `download`, `delete` actions -- `fs_provider`, `0` for local filesystem, `1` for S3 backend, `2` for Google Cloud Storage (GCS) backend -- `bucket`, not null for S3 and GCS backends -- `endpoint`, not null for S3 backend if configured +- `fs_provider`, `0` for local filesystem, `1` for S3 backend, `2` for Google Cloud Storage (GCS) backend, `3` for Azure Blob Storage backend +- `bucket`, not null for S3, GCS and Azure backends +- `endpoint`, not null for S3 and Azure backend if configured. For Azure this is the SAS URL, if configured otherwise the endpoint - `status`, integer. 0 means a generic error occurred. 1 means no error, 2 means quota exceeded error - `protocol`, string. Possible values are `SSH`, `FTP`, `DAV` diff --git a/docs/google-cloud-storage.md b/docs/google-cloud-storage.md index 772f1d23..681bee7c 100644 --- a/docs/google-cloud-storage.md +++ b/docs/google-cloud-storage.md @@ -8,6 +8,4 @@ You can optionally specify a [storage class](https://cloud.google.com/storage/do The configured bucket must exist. -Google Cloud Storage is exposed over HTTPS so if you are running SFTPGo as docker image please be sure to uncomment the line that installs `ca-certificates`, inside your `Dockerfile`, to be able to properly verify certificate authorities. - This backend is very similar to the [S3](./s3.md) backend, and it has the same limitations. diff --git a/docs/s3.md b/docs/s3.md index e7932a1d..992ce37c 100644 --- a/docs/s3.md +++ b/docs/s3.md @@ -10,13 +10,11 @@ AWS SDK has different options for credentials. [More Detail](https://docs.aws.am So, you need to provide access keys to activate option 1, or leave them blank to use the other ways to specify credentials. -Most S3 backends require HTTPS connections so if you are running SFTPGo as docker image please be sure to uncomment the line that installs `ca-certificates`, inside your `Dockerfile`, to be able to properly verify certificate authorities. - Specifying a different `key_prefix`, you can assign different "folders" of the same bucket to different users. This is similar to a chroot directory for local filesystem. Each SFTP/SCP user can only access the assigned folder and its contents. The folder identified by `key_prefix` does not need to be pre-created. SFTPGo uses multipart uploads and parallel downloads for storing and retrieving files from S3. -For multipart uploads you can customize the parts size and the upload concurrency. Please note that if the upload bandwidth between the SFTP client and SFTPGo is greater than the upload bandwidth between SFTPGo and S3 then the SFTP client have to wait for the upload of the last parts to S3 after it ends the file upload to SFTPGo, and it may time out. Keep this in mind if you customize these parameters. +For multipart uploads you can customize the parts size and the upload concurrency. Please note that if the upload bandwidth between the client and SFTPGo is greater than the upload bandwidth between SFTPGo and S3 then the client should wait for the last parts to be uploaded to S3 after finishing uploading the file to SFTPGo, and it may time out. Keep this in mind if you customize these parameters. The configured bucket must exist. @@ -32,7 +30,7 @@ Some SFTP commands don't work over S3: Other notes: - `rename` is a two step operation: server-side copy and then deletion. So, it is not atomic as for local filesystem. -- We don't support renaming non empty directories since we should rename all the contents too and this could take a long time: think about directories with thousands of files; for each file we should do an AWS API call. +- We don't support renaming non empty directories since we should rename all the contents too and this could take a long time: think about directories with thousands of files: for each file we should do an AWS API call. - For server side encryption, you have to configure the mapped bucket to automatically encrypt objects. - A local home directory is still required to store temporary files. - Clients that require advanced filesystem-like features such as `sshfs` are not supported. diff --git a/examples/rest-api-cli/sftpgo_api_cli b/examples/rest-api-cli/sftpgo_api_cli index 673acad7..56136bc3 100755 --- a/examples/rest-api-cli/sftpgo_api_cli +++ b/examples/rest-api-cli/sftpgo_api_cli @@ -82,7 +82,9 @@ class SFTPGoApiRequests: s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[], denied_extensions=[], allowed_extensions=[], s3_upload_part_size=0, s3_upload_concurrency=0, - max_upload_file_size=0, denied_protocols=[]): + max_upload_file_size=0, denied_protocols=[], az_container="", az_account_name="", az_account_key="", + az_sas_url="", az_endpoint="", az_upload_part_size=0, az_upload_concurrency=0, az_key_prefix="", + az_use_emulator=False): user = {'id':user_id, 'username':username, 'uid':uid, 'gid':gid, 'max_sessions':max_sessions, 'quota_size':quota_size, 'quota_files':quota_files, 'upload_bandwidth':upload_bandwidth, 'download_bandwidth':download_bandwidth, @@ -106,7 +108,10 @@ class SFTPGoApiRequests: user.update({'filesystem':self.buildFsConfig(fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket, gcs_key_prefix, gcs_storage_class, gcs_credentials_file, - gcs_automatic_credentials, s3_upload_part_size, s3_upload_concurrency)}) + gcs_automatic_credentials, s3_upload_part_size, s3_upload_concurrency, + az_container, az_account_name, az_account_key, az_sas_url, + az_endpoint, az_upload_part_size, az_upload_concurrency, az_key_prefix, + az_use_emulator)}) return user def buildVirtualFolders(self, vfolders): @@ -228,7 +233,9 @@ class SFTPGoApiRequests: def buildFsConfig(self, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket, gcs_key_prefix, gcs_storage_class, - gcs_credentials_file, gcs_automatic_credentials, s3_upload_part_size, s3_upload_concurrency): + gcs_credentials_file, gcs_automatic_credentials, s3_upload_part_size, s3_upload_concurrency, + az_container, az_account_name, az_account_key, az_sas_url, az_endpoint, az_upload_part_size, + az_upload_concurrency, az_key_prefix, az_use_emulator): fs_config = {'provider':0} if fs_provider == 'S3': s3config = {'bucket':s3_bucket, 'region':s3_region, 'access_key':s3_access_key, 'access_secret': @@ -246,6 +253,12 @@ class SFTPGoApiRequests: gcsconfig.update({'credentials':base64.b64encode(creds.read().encode('UTF-8')).decode('UTF-8'), 'automatic_credentials':0}) fs_config.update({'provider':2, 'gcsconfig':gcsconfig}) + elif fs_provider == "AzureBlob": + azureconfig = {"container":az_container, "account_name":az_account_name, "account_key":az_account_key, + "sas_url":az_sas_url, "endpoint":az_endpoint, "upload_part_size":az_upload_part_size, + "upload_concurrency":az_upload_concurrency, "key_prefix":az_key_prefix, "use_emulator": + az_use_emulator} + fs_config.update({'provider':3, 'azblobconfig':azureconfig}) return fs_config def getUsers(self, limit=100, offset=0, order='ASC', username=''): @@ -263,13 +276,17 @@ class SFTPGoApiRequests: s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='', s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[], denied_extensions=[], allowed_extensions=[], - s3_upload_part_size=0, s3_upload_concurrency=0, max_upload_file_size=0, denied_protocols=[]): + s3_upload_part_size=0, s3_upload_concurrency=0, max_upload_file_size=0, denied_protocols=[], az_container="", + az_account_name="", az_account_key="", az_sas_url="", az_endpoint="", az_upload_part_size=0, + az_upload_concurrency=0, az_key_prefix="", az_use_emulator=False): u = self.buildUserObject(0, username, password, public_keys, home_dir, uid, gid, max_sessions, quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth, status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket, gcs_key_prefix, gcs_storage_class, gcs_credentials_file, gcs_automatic_credentials, denied_login_methods, virtual_folders, denied_extensions, - allowed_extensions, s3_upload_part_size, s3_upload_concurrency, max_upload_file_size, denied_protocols) + allowed_extensions, s3_upload_part_size, s3_upload_concurrency, max_upload_file_size, denied_protocols, + az_container, az_account_name, az_account_key, az_sas_url, az_endpoint, az_upload_part_size, + az_upload_concurrency, az_key_prefix, az_use_emulator) r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify) self.printResponse(r) @@ -280,13 +297,17 @@ class SFTPGoApiRequests: s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[], denied_extensions=[], allowed_extensions=[], s3_upload_part_size=0, s3_upload_concurrency=0, max_upload_file_size=0, - denied_protocols=[], disconnect=0): + denied_protocols=[], disconnect=0, az_container="", + az_account_name="", az_account_key="", az_sas_url="", az_endpoint="", az_upload_part_size=0, + az_upload_concurrency=0, az_key_prefix="", az_use_emulator=False): u = self.buildUserObject(user_id, username, password, public_keys, home_dir, uid, gid, max_sessions, quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth, status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket, gcs_key_prefix, gcs_storage_class, gcs_credentials_file, gcs_automatic_credentials, denied_login_methods, virtual_folders, denied_extensions, - allowed_extensions, s3_upload_part_size, s3_upload_concurrency, max_upload_file_size, denied_protocols) + allowed_extensions, s3_upload_part_size, s3_upload_concurrency, max_upload_file_size, denied_protocols, + az_container, az_account_name, az_account_key, az_sas_url, az_endpoint, az_upload_part_size, + az_upload_concurrency, az_key_prefix, az_use_emulator) r = requests.put(urlparse.urljoin(self.userPath, 'user/' + str(user_id)), params={'disconnect':disconnect}, json=u, auth=self.auth, verify=self.verify) self.printResponse(r) @@ -593,7 +614,7 @@ def addCommonUserArguments(parser): parser.add_argument('--allowed-extensions', type=str, nargs='*', default=[], help='Allowed file extensions case insensitive. ' +'The format is /dir::ext1,ext2. For example: "/somedir::.jpg,.png" "/otherdir/subdir::.zip,.rar". ' + 'Default: %(default)s') - parser.add_argument('--fs', type=str, default='local', choices=['local', 'S3', 'GCS'], + parser.add_argument('--fs', type=str, default='local', choices=['local', 'S3', 'GCS', "AzureBlob"], help='Filesystem provider. Default: %(default)s') parser.add_argument('--s3-bucket', type=str, default='', help='Default: %(default)s') parser.add_argument('--s3-key-prefix', type=str, default='', help='Virtual root directory. If non empty only this ' + @@ -616,6 +637,19 @@ def addCommonUserArguments(parser): parser.add_argument('--gcs-credentials-file', type=str, default='', help='Default: %(default)s') parser.add_argument('--gcs-automatic-credentials', type=str, default='automatic', choices=['explicit', 'automatic'], help='If you provide a credentials file this argument will be setted to "explicit". Default: %(default)s') + parser.add_argument('--az-container', type=str, default='', help='Default: %(default)s') + parser.add_argument('--az-account-name', type=str, default='', help='Default: %(default)s') + parser.add_argument('--az-account-key', type=str, default='', help='Default: %(default)s') + parser.add_argument('--az-sas-url', type=str, default='', help='Shared access signature URL. Default: %(default)s') + parser.add_argument('--az-endpoint', type=str, default='', help='Default: %(default)s') + parser.add_argument('--az-upload-part-size', type=int, default=0, help='The buffer size for multipart uploads (MB). ' + + 'Zero means the default (1 MB). Default: %(default)s') + parser.add_argument('--az-upload-concurrency', type=int, default=0, help='How many parts are uploaded in parallel. ' + + 'Zero means the default (1). Default: %(default)s') + parser.add_argument('--az-key-prefix', type=str, default='', help='Virtual root directory. If non empty only this ' + + 'directory and its contents will be available. Cannot start with "/". For example "folder/subfolder/".' + + ' Default: %(default)s') + parser.add_argument('--az-use-emulator', type=bool, default=False, help='Default: %(default)s') if __name__ == '__main__': @@ -769,7 +803,9 @@ if __name__ == '__main__': args.s3_endpoint, args.s3_storage_class, args.s3_key_prefix, args.gcs_bucket, args.gcs_key_prefix, args.gcs_storage_class, args.gcs_credentials_file, args.gcs_automatic_credentials, args.denied_login_methods, args.virtual_folders, args.denied_extensions, args.allowed_extensions, - args.s3_upload_part_size, args.s3_upload_concurrency, args.max_upload_file_size, args.denied_protocols) + args.s3_upload_part_size, args.s3_upload_concurrency, args.max_upload_file_size, args.denied_protocols, + args.az_container, args.az_account_name, args.az_account_key, args.az_sas_url, args.az_endpoint, + args.az_upload_part_size, args.az_upload_concurrency, args.az_key_prefix, args.az_use_emulator) elif args.command == 'update-user': api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid, args.max_sessions, args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth, @@ -779,7 +815,9 @@ if __name__ == '__main__': args.s3_key_prefix, args.gcs_bucket, args.gcs_key_prefix, args.gcs_storage_class, args.gcs_credentials_file, args.gcs_automatic_credentials, args.denied_login_methods, args.virtual_folders, args.denied_extensions, args.allowed_extensions, args.s3_upload_part_size, - args.s3_upload_concurrency, args.max_upload_file_size, args.denied_protocols, args.disconnect) + args.s3_upload_concurrency, args.max_upload_file_size, args.denied_protocols, args.disconnect, + args.az_container, args.az_account_name, args.az_account_key, args.az_sas_url, args.az_endpoint, + args.az_upload_part_size, args.az_upload_concurrency, args.az_key_prefix, args.az_use_emulator) elif args.command == 'delete-user': api.deleteUser(args.id) elif args.command == 'get-users': diff --git a/ftpd/transfer.go b/ftpd/transfer.go index 54b7acfd..833d3367 100644 --- a/ftpd/transfer.go +++ b/ftpd/transfer.go @@ -45,38 +45,34 @@ func newTransfer(baseTransfer *common.BaseTransfer, pipeWriter *vfs.PipeWriter, // Read reads the contents to downloads. func (t *transfer) Read(p []byte) (n int, err error) { t.Connection.UpdateLastActivity() - var readed int - var e error - readed, e = t.reader.Read(p) - atomic.AddInt64(&t.BytesSent, int64(readed)) + n, err = t.reader.Read(p) + atomic.AddInt64(&t.BytesSent, int64(n)) - if e != nil && e != io.EOF { - t.TransferError(e) - return readed, e + if err != nil && err != io.EOF { + t.TransferError(err) + return } t.HandleThrottle() - return readed, e + return } // Write writes the uploaded contents. func (t *transfer) Write(p []byte) (n int, err error) { t.Connection.UpdateLastActivity() - var written int - var e error - written, e = t.writer.Write(p) - atomic.AddInt64(&t.BytesReceived, int64(written)) + n, err = t.writer.Write(p) + atomic.AddInt64(&t.BytesReceived, int64(n)) - if t.MaxWriteSize > 0 && e == nil && atomic.LoadInt64(&t.BytesReceived) > t.MaxWriteSize { - e = common.ErrQuotaExceeded + if t.MaxWriteSize > 0 && err == nil && atomic.LoadInt64(&t.BytesReceived) > t.MaxWriteSize { + err = common.ErrQuotaExceeded } - if e != nil { - t.TransferError(e) - return written, e + if err != nil { + t.TransferError(err) + return } t.HandleThrottle() - return written, e + return } // Seek sets the offset to resume an upload or a download diff --git a/go.mod b/go.mod index 613d3d3a..e6e962e8 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.14 require ( cloud.google.com/go v0.69.1 // indirect cloud.google.com/go/storage v1.12.0 + github.com/Azure/azure-storage-blob-go v0.10.0 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 github.com/alexedwards/argon2id v0.0.0-20200802152012-2464efd3196b github.com/aws/aws-sdk-go v1.35.9 diff --git a/go.sum b/go.sum index 85e6e44d..d295a1a5 100644 --- a/go.sum +++ b/go.sum @@ -37,6 +37,20 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 cloud.google.com/go/storage v1.12.0 h1:4y3gHptW1EHVtcPAVE0eBBlFuGqEejTTG3KdIE0lUX4= cloud.google.com/go/storage v1.12.0/go.mod h1:fFLk2dp2oAhDz8QFKwqrjdJvxSp/W2g7nillojlL5Ho= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/Azure/azure-pipeline-go v0.2.2 h1:6oiIS9yaG6XCCzhgAgKFfIWyo4LLCiDhZot6ltoThhY= +github.com/Azure/azure-pipeline-go v0.2.2/go.mod h1:4rQ/NZncSvGqNkkOsNpOU1tgoNuIlp9AfUH5G1tvCHc= +github.com/Azure/azure-storage-blob-go v0.10.0 h1:evCwGreYo3XLeBV4vSxLbLiYb6e0SzsJiXQVRGsRXxs= +github.com/Azure/azure-storage-blob-go v0.10.0/go.mod h1:ep1edmW+kNQx4UfWM9heESNmQdijykocJ0YOxmMX8SE= +github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= +github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= +github.com/Azure/go-autorest/autorest/adal v0.8.3/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q= +github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= +github.com/Azure/go-autorest/autorest/date v0.2.0/go.mod h1:vcORJHLJEh643/Ioh9+vPmf1Ij9AEBM5FuBIXLmIy0g= +github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.3.0/go.mod h1:a8FDP3DYzQ4RYfVAxAN3SVSiiO77gL2j2ronKKP0syM= +github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= +github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw= @@ -212,6 +226,8 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= @@ -294,6 +310,8 @@ github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czP github.com/magiconair/properties v1.8.4 h1:8KGKTcQQGm0Kv7vEbKFErAoAOFyyacLStRtQSeYtvkY= github.com/magiconair/properties v1.8.4/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-ieproxy v0.0.0-20190702010315-6dee0af9227d h1:oNAwILwmgWKFpuU+dXvI6dl9jG2mAWAZLX3r9s0PPiw= +github.com/mattn/go-ieproxy v0.0.0-20190702010315-6dee0af9227d/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= diff --git a/httpd/api_user.go b/httpd/api_user.go index 29581bc3..5c257335 100644 --- a/httpd/api_user.go +++ b/httpd/api_user.go @@ -118,9 +118,13 @@ func updateUser(w http.ResponseWriter, r *http.Request) { } currentPermissions := user.Permissions currentS3AccessSecret := "" + currentAzAccountKey := "" if user.FsConfig.Provider == dataprovider.S3FilesystemProvider { currentS3AccessSecret = user.FsConfig.S3Config.AccessSecret } + if user.FsConfig.Provider == dataprovider.AzureBlobFilesystemProvider { + currentAzAccountKey = user.FsConfig.AzBlobConfig.AccountKey + } user.Permissions = make(map[string][]string) err = render.DecodeJSON(r.Body, &user) if err != nil { @@ -131,13 +135,8 @@ func updateUser(w http.ResponseWriter, r *http.Request) { if len(user.Permissions) == 0 { user.Permissions = currentPermissions } - // we use the new access secret if different from the old one and not empty - if user.FsConfig.Provider == dataprovider.S3FilesystemProvider { - if utils.RemoveDecryptionKey(currentS3AccessSecret) == user.FsConfig.S3Config.AccessSecret || - (len(user.FsConfig.S3Config.AccessSecret) == 0 && len(user.FsConfig.S3Config.AccessKey) > 0) { - user.FsConfig.S3Config.AccessSecret = currentS3AccessSecret - } - } + updateEncryptedSecrets(&user, currentS3AccessSecret, currentAzAccountKey) + if user.ID != userID { sendAPIResponse(w, r, err, "user ID in request body does not match user ID in path parameter", http.StatusBadRequest) return @@ -181,3 +180,19 @@ func disconnectUser(username string) { } } } + +func updateEncryptedSecrets(user *dataprovider.User, currentS3AccessSecret, currentAzAccountKey string) { + // we use the new access secret if different from the old one and not empty + if user.FsConfig.Provider == dataprovider.S3FilesystemProvider { + if utils.RemoveDecryptionKey(currentS3AccessSecret) == user.FsConfig.S3Config.AccessSecret || + (user.FsConfig.S3Config.AccessSecret == "" && user.FsConfig.S3Config.AccessKey != "") { + user.FsConfig.S3Config.AccessSecret = currentS3AccessSecret + } + } + if user.FsConfig.Provider == dataprovider.AzureBlobFilesystemProvider { + if utils.RemoveDecryptionKey(currentAzAccountKey) == user.FsConfig.AzBlobConfig.AccountKey || + (user.FsConfig.AzBlobConfig.AccountKey == "" && user.FsConfig.AzBlobConfig.AccountName != "") { + user.FsConfig.AzBlobConfig.AccountKey = currentAzAccountKey + } + } +} diff --git a/httpd/api_utils.go b/httpd/api_utils.go index 2777df24..91d2c98d 100644 --- a/httpd/api_utils.go +++ b/httpd/api_utils.go @@ -620,6 +620,9 @@ func compareUserFsConfig(expected *dataprovider.User, actual *dataprovider.User) if err := compareGCSConfig(expected, actual); err != nil { return err } + if err := compareAzBlobConfig(expected, actual); err != nil { + return err + } return nil } @@ -633,8 +636,8 @@ func compareS3Config(expected *dataprovider.User, actual *dataprovider.User) err if expected.FsConfig.S3Config.AccessKey != actual.FsConfig.S3Config.AccessKey { return errors.New("S3 access key mismatch") } - if err := checkS3AccessSecret(expected.FsConfig.S3Config.AccessSecret, actual.FsConfig.S3Config.AccessSecret); err != nil { - return err + if err := checkEncryptedSecret(expected.FsConfig.S3Config.AccessSecret, actual.FsConfig.S3Config.AccessSecret); err != nil { + return fmt.Errorf("S3 access secret mismatch: %v", err) } if expected.FsConfig.S3Config.Endpoint != actual.FsConfig.S3Config.Endpoint { return errors.New("S3 endpoint mismatch") @@ -672,29 +675,61 @@ func compareGCSConfig(expected *dataprovider.User, actual *dataprovider.User) er return nil } -func checkS3AccessSecret(expectedAccessSecret, actualAccessSecret string) error { +func compareAzBlobConfig(expected *dataprovider.User, actual *dataprovider.User) error { + if expected.FsConfig.AzBlobConfig.Container != actual.FsConfig.AzBlobConfig.Container { + return errors.New("Azure Blob container mismatch") + } + if expected.FsConfig.AzBlobConfig.AccountName != actual.FsConfig.AzBlobConfig.AccountName { + return errors.New("Azure Blob account name mismatch") + } + if err := checkEncryptedSecret(expected.FsConfig.AzBlobConfig.AccountKey, actual.FsConfig.AzBlobConfig.AccountKey); err != nil { + return fmt.Errorf("Azure Blob account key mismatch: %v", err) + } + if expected.FsConfig.AzBlobConfig.Endpoint != actual.FsConfig.AzBlobConfig.Endpoint { + return errors.New("Azure Blob endpoint mismatch") + } + if expected.FsConfig.AzBlobConfig.SASURL != actual.FsConfig.AzBlobConfig.SASURL { + return errors.New("Azure Blob SASL URL mismatch") + } + if expected.FsConfig.AzBlobConfig.UploadPartSize != actual.FsConfig.AzBlobConfig.UploadPartSize { + return errors.New("Azure Blob upload part size mismatch") + } + if expected.FsConfig.AzBlobConfig.UploadConcurrency != actual.FsConfig.AzBlobConfig.UploadConcurrency { + return errors.New("Azure Blob upload concurrency mismatch") + } + if expected.FsConfig.AzBlobConfig.KeyPrefix != actual.FsConfig.AzBlobConfig.KeyPrefix && + expected.FsConfig.AzBlobConfig.KeyPrefix+"/" != actual.FsConfig.AzBlobConfig.KeyPrefix { + return errors.New("Azure Blob key prefix mismatch") + } + if expected.FsConfig.AzBlobConfig.UseEmulator != actual.FsConfig.AzBlobConfig.UseEmulator { + return errors.New("Azure Blob use emulator mismatch") + } + return nil +} + +func checkEncryptedSecret(expectedAccessSecret, actualAccessSecret string) error { if len(expectedAccessSecret) > 0 { vals := strings.Split(expectedAccessSecret, "$") if strings.HasPrefix(expectedAccessSecret, "$aes$") && len(vals) == 4 { expectedAccessSecret = utils.RemoveDecryptionKey(expectedAccessSecret) if expectedAccessSecret != actualAccessSecret { - return fmt.Errorf("S3 access secret mismatch, expected: %v", expectedAccessSecret) + return fmt.Errorf("secret mismatch, expected: %v", expectedAccessSecret) } } else { // here we check that actualAccessSecret is aes encrypted without the nonce parts := strings.Split(actualAccessSecret, "$") if !strings.HasPrefix(actualAccessSecret, "$aes$") || len(parts) != 3 { - return errors.New("Invalid S3 access secret") + return errors.New("invalid secret") } if len(parts) == len(vals) { if expectedAccessSecret != actualAccessSecret { - return errors.New("S3 encrypted access secret mismatch") + return errors.New("encrypted secret mismatch") } } } } else { if expectedAccessSecret != actualAccessSecret { - return errors.New("S3 access secret mismatch") + return errors.New("secret mismatch") } } return nil diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index eff43b42..cf986ded 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -433,6 +433,23 @@ func TestAddUserInvalidFsConfig(t *testing.T) { u.FsConfig.GCSConfig.Credentials = invalidBase64{} _, _, err = httpd.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) + + u = getTestUser() + u.FsConfig.Provider = dataprovider.AzureBlobFilesystemProvider + u.FsConfig.AzBlobConfig.SASURL = "http://foo\x7f.com/" + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + assert.NoError(t, err) + u.FsConfig.AzBlobConfig.SASURL = "" + u.FsConfig.AzBlobConfig.AccountName = "name" + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + assert.NoError(t, err) + u.FsConfig.AzBlobConfig.Container = "container" + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + assert.NoError(t, err) + u.FsConfig.AzBlobConfig.AccountKey = "key" + u.FsConfig.AzBlobConfig.KeyPrefix = "/amedir/subdir/" + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + assert.NoError(t, err) } func TestAddUserInvalidVirtualFolders(t *testing.T) { @@ -1024,6 +1041,50 @@ func TestUserGCSConfig(t *testing.T) { assert.NoError(t, err) } +func TestUserAzureBlobConfig(t *testing.T) { + user, _, err := httpd.AddUser(getTestUser(), http.StatusOK) + assert.NoError(t, err) + user.FsConfig.Provider = dataprovider.AzureBlobFilesystemProvider + user.FsConfig.AzBlobConfig.Container = "test" + user.FsConfig.AzBlobConfig.AccountName = "Server-Account-Name" + user.FsConfig.AzBlobConfig.AccountKey = "Server-Account-Key" + user.FsConfig.AzBlobConfig.Endpoint = "http://127.0.0.1:9000" + user.FsConfig.AzBlobConfig.UploadPartSize = 8 + user, _, err = httpd.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + user.Password = defaultPassword + user.ID = 0 + secret, _ := utils.EncryptData("Server-Account-Key") + user.FsConfig.AzBlobConfig.AccountKey = secret + user, _, err = httpd.AddUser(user, http.StatusOK) + assert.NoError(t, err) + user.FsConfig.Provider = dataprovider.AzureBlobFilesystemProvider + user.FsConfig.AzBlobConfig.Container = "test-container" + user.FsConfig.AzBlobConfig.AccountKey = "Server-Account-Key1" + user.FsConfig.AzBlobConfig.Endpoint = "http://localhost:9001" + user.FsConfig.AzBlobConfig.KeyPrefix = "somedir/subdir" + user.FsConfig.AzBlobConfig.UploadConcurrency = 5 + user, _, err = httpd.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + user.FsConfig.Provider = dataprovider.LocalFilesystemProvider + user.FsConfig.AzBlobConfig = vfs.AzBlobFsConfig{} + user, _, err = httpd.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + // test user without access key and access secret (sas) + user.FsConfig.Provider = dataprovider.AzureBlobFilesystemProvider + + user.FsConfig.AzBlobConfig.SASURL = "https://myaccount.blob.core.windows.net/pictures/profile.jpg?sv=2012-02-12&st=2009-02-09&se=2009-02-10&sr=c&sp=r&si=YWJjZGVmZw%3d%3d&sig=dD80ihBh5jfNpymO5Hg1IdiJIEvHcJpCMiCMnN%2fRnbI%3d" + user.FsConfig.AzBlobConfig.KeyPrefix = "somedir/subdir" + user.FsConfig.AzBlobConfig.UploadPartSize = 6 + user.FsConfig.AzBlobConfig.UploadConcurrency = 4 + user, _, err = httpd.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) +} + func TestUpdateUserNoCredentials(t *testing.T) { user, _, err := httpd.AddUser(getTestUser(), http.StatusOK) assert.NoError(t, err) @@ -2736,6 +2797,96 @@ func TestWebUserGCSMock(t *testing.T) { err = os.Remove(credentialsFilePath) assert.NoError(t, err) } +func TestWebUserAzureBlobMock(t *testing.T) { + user := getTestUser() + userAsJSON := getUserAsJSON(t, user) + req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON)) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + err := render.DecodeJSON(rr.Body, &user) + assert.NoError(t, err) + user.FsConfig.Provider = dataprovider.AzureBlobFilesystemProvider + user.FsConfig.AzBlobConfig.Container = "container" + user.FsConfig.AzBlobConfig.AccountName = "aname" + user.FsConfig.AzBlobConfig.AccountKey = "access-skey" + user.FsConfig.AzBlobConfig.Endpoint = "http://127.0.0.1:9000/path?b=c" + user.FsConfig.AzBlobConfig.KeyPrefix = "somedir/subdir/" + user.FsConfig.AzBlobConfig.UploadPartSize = 5 + user.FsConfig.AzBlobConfig.UploadConcurrency = 4 + user.FsConfig.AzBlobConfig.UseEmulator = true + form := make(url.Values) + form.Set("username", user.Username) + form.Set("home_dir", user.HomeDir) + form.Set("uid", "0") + form.Set("gid", strconv.FormatInt(int64(user.GID), 10)) + form.Set("max_sessions", strconv.FormatInt(int64(user.MaxSessions), 10)) + form.Set("quota_size", strconv.FormatInt(user.QuotaSize, 10)) + form.Set("quota_files", strconv.FormatInt(int64(user.QuotaFiles), 10)) + form.Set("upload_bandwidth", "0") + form.Set("download_bandwidth", "0") + form.Set("permissions", "*") + form.Set("sub_dirs_permissions", "") + form.Set("status", strconv.Itoa(user.Status)) + form.Set("expiration_date", "2020-01-01 00:00:00") + form.Set("allowed_ip", "") + form.Set("denied_ip", "") + form.Set("fs_provider", "3") + form.Set("az_container", user.FsConfig.AzBlobConfig.Container) + form.Set("az_account_name", user.FsConfig.AzBlobConfig.AccountName) + form.Set("az_account_key", user.FsConfig.AzBlobConfig.AccountKey) + form.Set("az_sas_url", user.FsConfig.AzBlobConfig.SASURL) + form.Set("az_endpoint", user.FsConfig.AzBlobConfig.Endpoint) + form.Set("az_key_prefix", user.FsConfig.AzBlobConfig.KeyPrefix) + form.Set("az_use_emulator", "checked") + form.Set("allowed_extensions", "/dir1::.jpg,.png") + form.Set("denied_extensions", "/dir2::.zip") + form.Set("max_upload_file_size", "0") + // test invalid az_upload_part_size + form.Set("az_upload_part_size", "a") + b, contentType, _ := getMultipartFormData(form, "", "") + req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + // test invalid az_upload_concurrency + form.Set("az_upload_part_size", strconv.FormatInt(user.FsConfig.AzBlobConfig.UploadPartSize, 10)) + form.Set("az_upload_concurrency", "a") + b, contentType, _ = getMultipartFormData(form, "", "") + req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + // now add the user + form.Set("az_upload_concurrency", strconv.Itoa(user.FsConfig.AzBlobConfig.UploadConcurrency)) + b, contentType, _ = getMultipartFormData(form, "", "") + req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr.Code) + req, _ = http.NewRequest(http.MethodGet, userPath+"?limit=1&offset=0&order=ASC&username="+user.Username, nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + var users []dataprovider.User + err = render.DecodeJSON(rr.Body, &users) + assert.NoError(t, err) + assert.Equal(t, 1, len(users)) + updateUser := users[0] + assert.Equal(t, int64(1577836800000), updateUser.ExpirationDate) + assert.Equal(t, updateUser.FsConfig.AzBlobConfig.Container, user.FsConfig.AzBlobConfig.Container) + assert.Equal(t, updateUser.FsConfig.AzBlobConfig.AccountName, user.FsConfig.AzBlobConfig.AccountName) + assert.Equal(t, updateUser.FsConfig.AzBlobConfig.Endpoint, user.FsConfig.AzBlobConfig.Endpoint) + assert.Equal(t, updateUser.FsConfig.AzBlobConfig.SASURL, user.FsConfig.AzBlobConfig.SASURL) + assert.Equal(t, updateUser.FsConfig.AzBlobConfig.KeyPrefix, user.FsConfig.AzBlobConfig.KeyPrefix) + assert.Equal(t, updateUser.FsConfig.AzBlobConfig.UploadPartSize, user.FsConfig.AzBlobConfig.UploadPartSize) + assert.Equal(t, updateUser.FsConfig.AzBlobConfig.UploadConcurrency, user.FsConfig.AzBlobConfig.UploadConcurrency) + assert.Equal(t, 2, len(updateUser.Filters.FileExtensions)) + if !strings.HasPrefix(updateUser.FsConfig.AzBlobConfig.AccountKey, "$aes$") { + t.Error("azure account secret is not encrypted") + } + req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) +} func TestAddWebFoldersMock(t *testing.T) { mappedPath := filepath.Clean(os.TempDir()) diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 4ea0273b..9a21daf8 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -353,6 +353,47 @@ func TestCompareUserGCSConfig(t *testing.T) { expected.FsConfig.GCSConfig.AutomaticCredentials = 0 } +func TestCompareUserAzureConfig(t *testing.T) { + expected := &dataprovider.User{} + actual := &dataprovider.User{} + expected.FsConfig.AzBlobConfig.Container = "a" + err := compareUserFsConfig(expected, actual) + assert.Error(t, err) + expected.FsConfig.AzBlobConfig.Container = "" + expected.FsConfig.AzBlobConfig.AccountName = "aname" + err = compareUserFsConfig(expected, actual) + assert.Error(t, err) + expected.FsConfig.AzBlobConfig.AccountName = "" + expected.FsConfig.AzBlobConfig.AccountKey = "akey" + err = compareUserFsConfig(expected, actual) + assert.Error(t, err) + expected.FsConfig.AzBlobConfig.AccountKey = "" + expected.FsConfig.AzBlobConfig.Endpoint = "endpt" + err = compareUserFsConfig(expected, actual) + assert.Error(t, err) + expected.FsConfig.AzBlobConfig.Endpoint = "" + expected.FsConfig.AzBlobConfig.SASURL = "url" + err = compareUserFsConfig(expected, actual) + assert.Error(t, err) + expected.FsConfig.AzBlobConfig.SASURL = "" + expected.FsConfig.AzBlobConfig.UploadPartSize = 1 + err = compareUserFsConfig(expected, actual) + assert.Error(t, err) + expected.FsConfig.AzBlobConfig.UploadPartSize = 0 + expected.FsConfig.AzBlobConfig.UploadConcurrency = 1 + err = compareUserFsConfig(expected, actual) + assert.Error(t, err) + expected.FsConfig.AzBlobConfig.UploadConcurrency = 0 + expected.FsConfig.AzBlobConfig.KeyPrefix = "prefix/" + err = compareUserFsConfig(expected, actual) + assert.Error(t, err) + expected.FsConfig.AzBlobConfig.KeyPrefix = "" + expected.FsConfig.AzBlobConfig.UseEmulator = true + err = compareUserFsConfig(expected, actual) + assert.Error(t, err) + expected.FsConfig.AzBlobConfig.UseEmulator = false +} + func TestGCSWebInvalidFormFile(t *testing.T) { form := make(url.Values) form.Set("username", "test_username") diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index 2b16cdec..390bdea2 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -925,10 +925,8 @@ components: minLength: 1 access_key: type: string - minLength: 1 access_secret: type: string - minLength: 1 description: the access secret is stored encrypted (AES-256-GCM) endpoint: type: string @@ -980,6 +978,37 @@ components: - bucket nullable: true description: Google Cloud Storage configuration details + AzureBlobFsConfig: + type: object + properties: + container: + type: string + account_name: + type: string + description: Storage Account Name, leave blank to use SAS URL + account_key: + type: string + description: Storage Account Key leave blank to use SAS URL. The access key is stored encrypted (AES-256-GCM) + sas_url: + type: string + description: Shared access signature URL, leave blank if using account/key + endpoint: + type: string + description: optional endpoint. Default is "blob.core.windows.net". If you use the emulator the endpoint must include the protocol, for example "http://127.0.0.1:10000" + upload_part_size: + type: integer + description: the buffer size (in MB) to use for multipart uploads. If this value is set to zero, the default value (4MB) will be used. + upload_concurrency: + type: integer + description: the number of parts to upload in parallel. If this value is set to zero, the default value (2) will be used + key_prefix: + type: string + description: key_prefix is similar to a chroot directory for a local filesystem. If specified the user will only see contents that starts with this prefix and so you can restrict access to a specific virtual folder. The prefix, if not empty, must not start with "/" and must end with "/". If empty the whole container contents will be available + example: folder/subfolder/ + use_emulator: + type: boolean + nullable: true + description: Azure Blob Storage configuration details FilesystemConfig: type: object properties: @@ -989,15 +1018,19 @@ components: - 0 - 1 - 2 + - 3 description: > Providers: - * `0` - local filesystem + * `0` - Local filesystem * `1` - S3 Compatible Object Storage * `2` - Google Cloud Storage + * `3` - Azure Blob Storage s3config: $ref: '#/components/schemas/S3Config' gcsconfig: $ref: '#/components/schemas/GCSConfig' + azblobconfig: + $ref: '#/components/schemas/AzureBlobFsConfig' description: Storage filesystem details BaseVirtualFolder: type: object diff --git a/httpd/web.go b/httpd/web.go index 7fc5c44e..a59d5aa9 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -431,6 +431,22 @@ func getFsConfigFromUserPostFields(r *http.Request) (dataprovider.Filesystem, er } fs.GCSConfig.Credentials = fileBytes fs.GCSConfig.AutomaticCredentials = 0 + } else if fs.Provider == dataprovider.AzureBlobFilesystemProvider { + fs.AzBlobConfig.Container = r.Form.Get("az_container") + fs.AzBlobConfig.AccountName = r.Form.Get("az_account_name") + fs.AzBlobConfig.AccountKey = r.Form.Get("az_account_key") + fs.AzBlobConfig.SASURL = r.Form.Get("az_sas_url") + fs.AzBlobConfig.Endpoint = r.Form.Get("az_endpoint") + fs.AzBlobConfig.KeyPrefix = r.Form.Get("az_key_prefix") + fs.AzBlobConfig.UseEmulator = len(r.Form.Get("az_use_emulator")) > 0 + fs.AzBlobConfig.UploadPartSize, err = strconv.ParseInt(r.Form.Get("az_upload_part_size"), 10, 64) + if err != nil { + return fs, err + } + fs.AzBlobConfig.UploadConcurrency, err = strconv.Atoi(r.Form.Get("az_upload_concurrency")) + if err != nil { + return fs, err + } } return fs, nil } diff --git a/pkgs/build.sh b/pkgs/build.sh index 6717f015..0c6a04e9 100755 --- a/pkgs/build.sh +++ b/pkgs/build.sh @@ -42,8 +42,8 @@ provides: description: | Fully featured and highly configurable SFTP server SFTPGo has optional FTP/S and WebDAV support. - It can serve local filesystem, S3 (Compatible) Object Storages - and Google Cloud Storage + It can serve local filesystem, S3 (Compatible) Object Storage, + Google Cloud Storage and Azure Blob Storage. vendor: "SFTPGo" homepage: "https://github.com/drakkan/sftpgo" license: "GPL-3.0" diff --git a/pkgs/debian/control b/pkgs/debian/control index bbf00661..7df9ddf3 100644 --- a/pkgs/debian/control +++ b/pkgs/debian/control @@ -13,5 +13,5 @@ Depends: ${shlibs:Depends}, ${misc:Depends} Recommends: bash-completion, python3-requests, python3-pygments, mime-support Description: Fully featured and highly configurable SFTP server SFTPGo has optional FTP/S and WebDAV support. - It can serve local filesystem, S3 (Compatible) Object Storages - and Google Cloud Storage + It can serve local filesystem, S3 (Compatible) Object Storage, + Google Cloud Storage and Azure Blob Storage. diff --git a/sftpd/transfer.go b/sftpd/transfer.go index 7afc087f..6d200036 100644 --- a/sftpd/transfer.go +++ b/sftpd/transfer.go @@ -82,20 +82,18 @@ func newTransfer(baseTransfer *common.BaseTransfer, pipeWriter *vfs.PipeWriter, // It handles download bandwidth throttling too func (t *transfer) ReadAt(p []byte, off int64) (n int, err error) { t.Connection.UpdateLastActivity() - var readed int - var e error - readed, e = t.readerAt.ReadAt(p, off) - atomic.AddInt64(&t.BytesSent, int64(readed)) + n, err = t.readerAt.ReadAt(p, off) + atomic.AddInt64(&t.BytesSent, int64(n)) - if e != nil && e != io.EOF { + if err != nil && err != io.EOF { if t.GetType() == common.TransferDownload { - t.TransferError(e) + t.TransferError(err) } - return readed, e + return } t.HandleThrottle() - return readed, e + return } // WriteAt writes len(p) bytes to the uploaded file starting at byte offset off and updates the bytes received. @@ -107,21 +105,19 @@ func (t *transfer) WriteAt(p []byte, off int64) (n int, err error) { t.TransferError(err) return 0, err } - var written int - var e error - written, e = t.writerAt.WriteAt(p, off) - atomic.AddInt64(&t.BytesReceived, int64(written)) + n, err = t.writerAt.WriteAt(p, off) + atomic.AddInt64(&t.BytesReceived, int64(n)) - if t.MaxWriteSize > 0 && e == nil && atomic.LoadInt64(&t.BytesReceived) > t.MaxWriteSize { - e = common.ErrQuotaExceeded + if t.MaxWriteSize > 0 && err == nil && atomic.LoadInt64(&t.BytesReceived) > t.MaxWriteSize { + err = common.ErrQuotaExceeded } - if e != nil { - t.TransferError(e) - return written, e + if err != nil { + t.TransferError(err) + return } t.HandleThrottle() - return written, e + return } // Close it is called when the transfer is completed. diff --git a/templates/user.html b/templates/user.html index 812b5c04..b1a6400e 100644 --- a/templates/user.html +++ b/templates/user.html @@ -278,6 +278,7 @@ + @@ -399,6 +400,81 @@ +
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+ + + The buffer size for multipart uploads. Zero means the default (4 MB) + +
+
+ +
+ + + How many parts are uploaded in parallel. Zero means the default (2) + +
+
+ +
+ +
+ + + Similar to a chroot for local filesystem. Cannot start with "/". Example: "somedir/subdir/". + +
+
+ +
+
+ + +
+
+ {{if not .IsAdd}}
@@ -461,15 +537,27 @@ if (val == '1'){ $('.form-group.row.gcs').hide(); $('.form-group.gcs').hide(); + $('.form-group.row.azblob').hide(); + $('.form-group.azblob').hide(); $('.form-group.row.s3').show(); } else if (val == '2'){ $('.form-group.row.gcs').show(); $('.form-group.gcs').show(); + $('.form-group.row.azblob').hide(); + $('.form-group.azblob').hide(); + $('.form-group.row.s3').hide(); + } else if (val == '3'){ + $('.form-group.row.azblob').show(); + $('.form-group.azblob').show(); + $('.form-group.row.gcs').hide(); + $('.form-group.gcs').hide(); $('.form-group.row.s3').hide(); } else { $('.form-group.row.gcs').hide(); $('.form-group.gcs').hide(); $('.form-group.row.s3').hide(); + $('.form-group.row.azblob').hide(); + $('.form-group.azblob').hide(); } } diff --git a/vfs/azblobfs.go b/vfs/azblobfs.go new file mode 100644 index 00000000..4828602a --- /dev/null +++ b/vfs/azblobfs.go @@ -0,0 +1,724 @@ +// +build !noazblob + +package vfs + +import ( + "context" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/Azure/azure-storage-blob-go/azblob" + "github.com/eikenb/pipeat" + + "github.com/drakkan/sftpgo/logger" + "github.com/drakkan/sftpgo/utils" + "github.com/drakkan/sftpgo/version" +) + +const azureDefaultEndpoint = "blob.core.windows.net" + +// max time of an azure web request response window (whether or not data is flowing) +// this is the same value used in rclone +var maxTryTimeout = time.Hour * 24 * 365 + +// AzureBlobFs is a Fs implementation for Azure Blob storage. +type AzureBlobFs struct { + connectionID string + localTempDir string + config AzBlobFsConfig + svc *azblob.ServiceURL + containerURL azblob.ContainerURL + ctxTimeout time.Duration + ctxLongTimeout time.Duration +} + +func init() { + version.AddFeature("+azblob") +} + +// NewAzBlobFs returns an AzBlobFs object that allows to interact with Azure Blob storage +func NewAzBlobFs(connectionID, localTempDir string, config AzBlobFsConfig) (Fs, error) { + fs := AzureBlobFs{ + connectionID: connectionID, + localTempDir: localTempDir, + config: config, + ctxTimeout: 30 * time.Second, + ctxLongTimeout: 300 * time.Second, + } + if err := ValidateAzBlobFsConfig(&fs.config); err != nil { + return fs, err + } + if fs.config.AccountKey != "" { + accountKey, err := utils.DecryptData(fs.config.AccountKey) + if err != nil { + return fs, err + } + fs.config.AccountKey = accountKey + } + setConfigDefaults(&fs) + + if fs.config.SASURL != "" { + u, err := url.Parse(fs.config.SASURL) + if err != nil { + return fs, fmt.Errorf("invalid credentials: %v", err) + } + pipeline := azblob.NewPipeline(azblob.NewAnonymousCredential(), azblob.PipelineOptions{ + Retry: azblob.RetryOptions{ + TryTimeout: maxTryTimeout, + }, + Telemetry: azblob.TelemetryOptions{ + Value: "SFTPGo", + }, + }) + // Check if we have container level SAS or account level SAS + parts := azblob.NewBlobURLParts(*u) + if parts.ContainerName != "" { + if fs.config.Container != "" && fs.config.Container != parts.ContainerName { + return fs, fmt.Errorf("Container name in SAS URL %#v and container provided %#v do not match", + parts.ContainerName, fs.config.Container) + } + fs.svc = nil + fs.containerURL = azblob.NewContainerURL(*u, pipeline) + } else { + if fs.config.Container == "" { + return fs, errors.New("container is required with this SAS URL") + } + serviceURL := azblob.NewServiceURL(*u, pipeline) + fs.svc = &serviceURL + fs.containerURL = fs.svc.NewContainerURL(fs.config.Container) + } + return fs, nil + } + + credential, err := azblob.NewSharedKeyCredential(fs.config.AccountName, fs.config.AccountKey) + if err != nil { + return fs, fmt.Errorf("invalid credentials: %v", err) + } + var u *url.URL + if fs.config.UseEmulator { + // for the emulator we expect the endpoint prefixed with the protocol, for example: + // http://127.0.0.1:10000 + u, err = url.Parse(fmt.Sprintf("%s/%s", fs.config.Endpoint, fs.config.AccountName)) + } else { + u, err = url.Parse(fmt.Sprintf("https://%s.%s", fs.config.AccountName, fs.config.Endpoint)) + } + if err != nil { + return fs, fmt.Errorf("invalid credentials: %v", err) + } + pipeline := azblob.NewPipeline(credential, azblob.PipelineOptions{ + Retry: azblob.RetryOptions{ + TryTimeout: maxTryTimeout, + }, + Telemetry: azblob.TelemetryOptions{ + Value: "SFTPGo", + }, + }) + serviceURL := azblob.NewServiceURL(*u, pipeline) + fs.svc = &serviceURL + fs.containerURL = fs.svc.NewContainerURL(fs.config.Container) + return fs, nil +} + +func setConfigDefaults(fs *AzureBlobFs) { + if fs.config.Endpoint == "" { + fs.config.Endpoint = azureDefaultEndpoint + } + if fs.config.UploadPartSize == 0 { + fs.config.UploadPartSize = 4 + } + fs.config.UploadPartSize *= 1024 * 1024 + if fs.config.UploadConcurrency == 0 { + fs.config.UploadConcurrency = 2 + } +} + +// Name returns the name for the Fs implementation +func (fs AzureBlobFs) Name() string { + if fs.config.SASURL != "" { + return fmt.Sprintf("Azure Blob SAS URL %#v", fs.config.Container) + } + return fmt.Sprintf("Azure Blob container %#v", fs.config.Container) +} + +// ConnectionID returns the connection ID associated to this Fs implementation +func (fs AzureBlobFs) ConnectionID() string { + return fs.connectionID +} + +// Stat returns a FileInfo describing the named file +func (fs AzureBlobFs) Stat(name string) (os.FileInfo, error) { + if name == "" || name == "." { + err := fs.checkIfBucketExists() + if err != nil { + return nil, err + } + return NewFileInfo(name, true, 0, time.Now(), false), nil + } + if fs.config.KeyPrefix == name+"/" { + return NewFileInfo(name, true, 0, time.Now(), false), nil + } + prefix := fs.getPrefixForStat(name) + ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout)) + defer cancelFn() + + for marker := (azblob.Marker{}); marker.NotDone(); { + listBlob, err := fs.containerURL.ListBlobsHierarchySegment(ctx, marker, "/", azblob.ListBlobsSegmentOptions{ + Details: azblob.BlobListingDetails{ + Copy: false, + Metadata: false, + Snapshots: false, + UncommittedBlobs: false, + Deleted: false, + }, + Prefix: prefix, + }) + if err != nil { + return nil, err + } + marker = listBlob.NextMarker + for _, blobPrefix := range listBlob.Segment.BlobPrefixes { + if fs.isEqual(blobPrefix.Name, name) { + return NewFileInfo(name, true, 0, time.Now(), false), nil + } + } + for _, blobInfo := range listBlob.Segment.BlobItems { + if fs.isEqual(blobInfo.Name, name) { + isDir := false + if blobInfo.Properties.ContentType != nil { + isDir = (*blobInfo.Properties.ContentType == dirMimeType) + } + size := int64(0) + if blobInfo.Properties.ContentLength != nil { + size = *blobInfo.Properties.ContentLength + } + return NewFileInfo(name, isDir, size, blobInfo.Properties.LastModified, false), nil + } + } + } + + return nil, errors.New("404 no such file or directory") +} + +// Lstat returns a FileInfo describing the named file +func (fs AzureBlobFs) Lstat(name string) (os.FileInfo, error) { + return fs.Stat(name) +} + +// Open opens the named file for reading +func (fs AzureBlobFs) Open(name string, offset int64) (*os.File, *pipeat.PipeReaderAt, func(), error) { + r, w, err := pipeat.PipeInDir(fs.localTempDir) + if err != nil { + return nil, nil, nil, err + } + blobBlockURL := fs.containerURL.NewBlockBlobURL(name) + ctx, cancelFn := context.WithCancel(context.Background()) + blobDownloadResponse, err := blobBlockURL.Download(ctx, offset, azblob.CountToEnd, azblob.BlobAccessConditions{}, false) + if err != nil { + r.Close() + w.Close() + cancelFn() + return nil, nil, nil, err + } + body := blobDownloadResponse.Body(azblob.RetryReaderOptions{ + MaxRetryRequests: 3, + }) + + go func() { + defer cancelFn() + defer body.Close() + + n, err := io.Copy(w, body) + w.CloseWithError(err) //nolint:errcheck + fsLog(fs, logger.LevelDebug, "download completed, path: %#v size: %v, err: %v", name, n, err) + }() + + return nil, r, cancelFn, nil +} + +// Create creates or opens the named file for writing +func (fs AzureBlobFs) Create(name string, flag int) (*os.File, *PipeWriter, func(), error) { + r, w, err := pipeat.PipeInDir(fs.localTempDir) + if err != nil { + return nil, nil, nil, err + } + p := NewPipeWriter(w) + blobBlockURL := fs.containerURL.NewBlockBlobURL(name) + ctx, cancelFn := context.WithCancel(context.Background()) + + headers := azblob.BlobHTTPHeaders{} + var contentType string + if flag == -1 { + contentType = dirMimeType + } else { + contentType = mime.TypeByExtension(path.Ext(name)) + } + if contentType != "" { + headers.ContentType = contentType + } + + go func() { + defer cancelFn() + + uploadOptions := azblob.UploadStreamToBlockBlobOptions{ + BufferSize: int(fs.config.UploadPartSize), + BlobHTTPHeaders: headers, + MaxBuffers: fs.config.UploadConcurrency, + } + _, err := azblob.UploadStreamToBlockBlob(ctx, r, blobBlockURL, uploadOptions) + r.CloseWithError(err) //nolint:errcheck + p.Done(err) + fsLog(fs, logger.LevelDebug, "upload completed, path: %#v, readed bytes: %v, err: %v", name, r.GetReadedBytes(), err) + }() + + return nil, p, cancelFn, nil +} + +// Rename renames (moves) source to target. +// We don't support renaming non empty directories since we should +// rename all the contents too and this could take long time: think +// about directories with thousands of files, for each file we should +// execute a StartCopyFromURL call. +func (fs AzureBlobFs) Rename(source, target string) error { + if source == target { + return nil + } + fi, err := fs.Stat(source) + if err != nil { + return err + } + if fi.IsDir() { + contents, err := fs.ReadDir(source) + if err != nil { + return err + } + if len(contents) > 0 { + return fmt.Errorf("Cannot rename non empty directory: %#v", source) + } + } + dstBlobURL := fs.containerURL.NewBlobURL(target) + srcURL := fs.containerURL.NewBlobURL(source).URL() + + md := azblob.Metadata{} + mac := azblob.ModifiedAccessConditions{} + bac := azblob.BlobAccessConditions{} + ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout)) + defer cancelFn() + + resp, err := dstBlobURL.StartCopyFromURL(ctx, srcURL, md, mac, bac) + if err != nil { + return err + } + copyStatus := resp.CopyStatus() + nErrors := 0 + for copyStatus == azblob.CopyStatusPending { + // Poll until the copy is complete. + time.Sleep(500 * time.Millisecond) + propertiesResp, err := dstBlobURL.GetProperties(ctx, azblob.BlobAccessConditions{}) + if err != nil { + // A GetProperties failure may be transient, so allow a couple + // of them before giving up. + nErrors++ + if ctx.Err() != nil || nErrors == 3 { + return err + } + } else { + copyStatus = propertiesResp.CopyStatus() + } + } + if copyStatus != azblob.CopyStatusSuccess { + return fmt.Errorf("Copy failed with status: %s", copyStatus) + } + return fs.Remove(source, fi.IsDir()) +} + +// Remove removes the named file or (empty) directory. +func (fs AzureBlobFs) Remove(name string, isDir bool) error { + if isDir { + contents, err := fs.ReadDir(name) + if err != nil { + return err + } + if len(contents) > 0 { + return fmt.Errorf("Cannot remove non empty directory: %#v", name) + } + } + blobBlockURL := fs.containerURL.NewBlockBlobURL(name) + ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout)) + defer cancelFn() + + _, err := blobBlockURL.Delete(ctx, azblob.DeleteSnapshotsOptionNone, azblob.BlobAccessConditions{}) + return err +} + +// Mkdir creates a new directory with the specified name and default permissions +func (fs AzureBlobFs) Mkdir(name string) error { + _, err := fs.Stat(name) + if !fs.IsNotExist(err) { + return err + } + _, w, _, err := fs.Create(name, -1) + if err != nil { + return err + } + return w.Close() +} + +// Symlink creates source as a symbolic link to target. +func (AzureBlobFs) Symlink(source, target string) error { + return errors.New("403 symlinks are not supported") +} + +// Readlink returns the destination of the named symbolic link +func (AzureBlobFs) Readlink(name string) (string, error) { + return "", errors.New("403 readlink is not supported") +} + +// Chown changes the numeric uid and gid of the named file. +// Silently ignored. +func (AzureBlobFs) Chown(name string, uid int, gid int) error { + return nil +} + +// Chmod changes the mode of the named file to mode. +// Silently ignored. +func (AzureBlobFs) Chmod(name string, mode os.FileMode) error { + return nil +} + +// Chtimes changes the access and modification times of the named file. +// Silently ignored. +func (AzureBlobFs) Chtimes(name string, atime, mtime time.Time) error { + return errors.New("403 chtimes is not supported") +} + +// Truncate changes the size of the named file. +// Truncate by path is not supported, while truncating an opened +// file is handled inside base transfer +func (AzureBlobFs) Truncate(name string, size int64) error { + return errors.New("403 truncate is not supported") +} + +// ReadDir reads the directory named by dirname and returns +// a list of directory entries. +func (fs AzureBlobFs) ReadDir(dirname string) ([]os.FileInfo, error) { + var result []os.FileInfo + // dirname must be already cleaned + prefix := "" + if dirname != "" && dirname != "." { + prefix = strings.TrimPrefix(dirname, "/") + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + } + + prefixes := make(map[string]bool) + + for marker := (azblob.Marker{}); marker.NotDone(); { + ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout)) + defer cancelFn() + + listBlob, err := fs.containerURL.ListBlobsHierarchySegment(ctx, marker, "/", azblob.ListBlobsSegmentOptions{ + Details: azblob.BlobListingDetails{ + Copy: false, + Metadata: false, + Snapshots: false, + UncommittedBlobs: false, + Deleted: false, + }, + Prefix: prefix, + }) + if err != nil { + return nil, err + } + marker = listBlob.NextMarker + for _, blobPrefix := range listBlob.Segment.BlobPrefixes { + // we don't support prefixes == "/" this will be sent if a key starts with "/" + if blobPrefix.Name == "/" { + continue + } + name := strings.TrimPrefix(blobPrefix.Name, prefix) + result = append(result, NewFileInfo(name, true, 0, time.Now(), false)) + prefixes[strings.TrimSuffix(name, "/")] = true + } + for _, blobInfo := range listBlob.Segment.BlobItems { + name := strings.TrimPrefix(blobInfo.Name, prefix) + size := int64(0) + if blobInfo.Properties.ContentLength != nil { + size = *blobInfo.Properties.ContentLength + } + isDir := false + if blobInfo.Properties.ContentType != nil { + isDir = (*blobInfo.Properties.ContentType == dirMimeType) + if isDir { + // check if the dir is already included, it will be sent as blob prefix if it contains at least one item + if _, ok := prefixes[name]; ok { + continue + } + } + } + result = append(result, NewFileInfo(name, isDir, size, blobInfo.Properties.LastModified, false)) + } + } + + return result, nil +} + +// IsUploadResumeSupported returns true if upload resume is supported. +// Upload Resume is not supported on Azure Blob +func (AzureBlobFs) IsUploadResumeSupported() bool { + return false +} + +// IsAtomicUploadSupported returns true if atomic upload is supported. +// Azure Blob uploads are already atomic, we don't need to upload to a temporary +// file +func (AzureBlobFs) IsAtomicUploadSupported() bool { + return false +} + +// IsNotExist returns a boolean indicating whether the error is known to +// report that a file or directory does not exist +func (AzureBlobFs) IsNotExist(err error) bool { + if err == nil { + return false + } + + if storageErr, ok := err.(azblob.StorageError); ok { + if storageErr.Response().StatusCode == http.StatusNotFound { //nolint:bodyclose + return true + } + if storageErr.ServiceCode() == azblob.ServiceCodeContainerNotFound || + storageErr.ServiceCode() == azblob.ServiceCodeBlobNotFound { + return true + } + } + + return strings.Contains(err.Error(), "404") +} + +// IsPermission returns a boolean indicating whether the error is known to +// report that permission is denied. +func (AzureBlobFs) IsPermission(err error) bool { + if err == nil { + return false + } + if storageErr, ok := err.(azblob.StorageError); ok { + code := storageErr.Response().StatusCode //nolint:bodyclose + if code == http.StatusForbidden || code == http.StatusUnauthorized { + return true + } + if storageErr.ServiceCode() == azblob.ServiceCodeInsufficientAccountPermissions || + storageErr.ServiceCode() == azblob.ServiceCodeInvalidAuthenticationInfo || + storageErr.ServiceCode() == azblob.ServiceCodeUnauthorizedBlobOverwrite { + return true + } + } + return strings.Contains(err.Error(), "403") +} + +// CheckRootPath creates the specified local root directory if it does not exists +func (fs AzureBlobFs) CheckRootPath(username string, uid int, gid int) bool { + // we need a local directory for temporary files + osFs := NewOsFs(fs.ConnectionID(), fs.localTempDir, nil) + return osFs.CheckRootPath(username, uid, gid) +} + +// ScanRootDirContents returns the number of files contained in the bucket, +// and their size +func (fs AzureBlobFs) ScanRootDirContents() (int, int64, error) { + numFiles := 0 + size := int64(0) + + for marker := (azblob.Marker{}); marker.NotDone(); { + ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout)) + defer cancelFn() + + listBlob, err := fs.containerURL.ListBlobsFlatSegment(ctx, marker, azblob.ListBlobsSegmentOptions{ + Details: azblob.BlobListingDetails{ + Copy: false, + Metadata: false, + Snapshots: false, + UncommittedBlobs: false, + Deleted: false, + }, + Prefix: fs.config.KeyPrefix, + }) + if err != nil { + return numFiles, size, err + } + marker = listBlob.NextMarker + for _, blobInfo := range listBlob.Segment.BlobItems { + isDir := false + if blobInfo.Properties.ContentType != nil { + isDir = (*blobInfo.Properties.ContentType == dirMimeType) + } + blobSize := int64(0) + if blobInfo.Properties.ContentLength != nil { + blobSize = *blobInfo.Properties.ContentLength + } + if isDir && blobSize == 0 { + continue + } + numFiles++ + size += blobSize + } + } + + return numFiles, size, nil +} + +// GetDirSize returns the number of files and the size for a folder +// including any subfolders +func (AzureBlobFs) GetDirSize(dirname string) (int, int64, error) { + return 0, 0, errUnsupported +} + +// GetAtomicUploadPath returns the path to use for an atomic upload. +// Azure Blob Storage uploads are already atomic, we never call this method +func (AzureBlobFs) GetAtomicUploadPath(name string) string { + return "" +} + +// GetRelativePath returns the path for a file relative to the user's home dir. +// This is the path as seen by SFTPGo users +func (fs AzureBlobFs) GetRelativePath(name string) string { + rel := path.Clean(name) + if rel == "." { + rel = "" + } + if !path.IsAbs(rel) { + rel = "/" + rel + } + if len(fs.config.KeyPrefix) > 0 { + if !strings.HasPrefix(rel, "/"+fs.config.KeyPrefix) { + rel = "/" + } + rel = path.Clean("/" + strings.TrimPrefix(rel, "/"+fs.config.KeyPrefix)) + } + return rel +} + +// Walk walks the file tree rooted at root, calling walkFn for each file or +// directory in the tree, including root +func (fs AzureBlobFs) Walk(root string, walkFn filepath.WalkFunc) error { + prefix := "" + if root != "" && root != "." { + prefix = strings.TrimPrefix(root, "/") + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + } + for marker := (azblob.Marker{}); marker.NotDone(); { + ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout)) + defer cancelFn() + + listBlob, err := fs.containerURL.ListBlobsFlatSegment(ctx, marker, azblob.ListBlobsSegmentOptions{ + Details: azblob.BlobListingDetails{ + Copy: false, + Metadata: false, + Snapshots: false, + UncommittedBlobs: false, + Deleted: false, + }, + Prefix: prefix, + }) + if err != nil { + return err + } + marker = listBlob.NextMarker + for _, blobInfo := range listBlob.Segment.BlobItems { + isDir := false + if blobInfo.Properties.ContentType != nil { + isDir = (*blobInfo.Properties.ContentType == dirMimeType) + } + name := path.Clean(blobInfo.Name) + if len(name) == 0 { + continue + } + blobSize := int64(0) + if blobInfo.Properties.ContentLength != nil { + blobSize = *blobInfo.Properties.ContentLength + } + err = walkFn(blobInfo.Name, NewFileInfo(name, isDir, blobSize, blobInfo.Properties.LastModified, false), nil) + if err != nil { + return err + } + } + } + + return walkFn(root, NewFileInfo(root, true, 0, time.Now(), false), nil) +} + +// Join joins any number of path elements into a single path +func (AzureBlobFs) Join(elem ...string) string { + return strings.TrimPrefix(path.Join(elem...), "/") +} + +// HasVirtualFolders returns true if folders are emulated +func (AzureBlobFs) HasVirtualFolders() bool { + return true +} + +// ResolvePath returns the matching filesystem path for the specified sftp path +func (fs AzureBlobFs) ResolvePath(virtualPath string) (string, error) { + if !path.IsAbs(virtualPath) { + virtualPath = path.Clean("/" + virtualPath) + } + return fs.Join(fs.config.KeyPrefix, strings.TrimPrefix(virtualPath, "/")), nil +} + +// GetMimeType implements MimeTyper interface +func (fs AzureBlobFs) GetMimeType(name string) (string, error) { + ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout)) + defer cancelFn() + + blobBlockURL := fs.containerURL.NewBlockBlobURL(name) + response, err := blobBlockURL.GetProperties(ctx, azblob.BlobAccessConditions{}) + if err != nil { + return "", err + } + return response.ContentType(), nil +} + +func (fs *AzureBlobFs) isEqual(key string, virtualName string) bool { + if key == virtualName { + return true + } + if key == virtualName+"/" { + return true + } + if key+"/" == virtualName { + return true + } + return false +} + +func (fs *AzureBlobFs) checkIfBucketExists() error { + ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout)) + defer cancelFn() + + _, err := fs.containerURL.GetProperties(ctx, azblob.LeaseAccessConditions{}) + return err +} + +func (fs *AzureBlobFs) getPrefixForStat(name string) string { + prefix := path.Dir(name) + if prefix == "/" || prefix == "." || prefix == "" { + prefix = "" + } else { + prefix = strings.TrimPrefix(prefix, "/") + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + } + return prefix +} diff --git a/vfs/azblobfs_disabled.go b/vfs/azblobfs_disabled.go new file mode 100644 index 00000000..e262c481 --- /dev/null +++ b/vfs/azblobfs_disabled.go @@ -0,0 +1,18 @@ +// +build noazblob + +package vfs + +import ( + "errors" + + "github.com/drakkan/sftpgo/version" +) + +func init() { + version.AddFeature("-azblob") +} + +// NewAzBlobFs returns an error, Azure Blob storage is disabled +func NewAzBlobFs(connectionID, localTempDir string, config AzBlobFsConfig) (Fs, error) { + return nil, errors.New("Azure Blob Storage disabled at build time") +} diff --git a/vfs/gcsfs.go b/vfs/gcsfs.go index 64e0d403..88438f93 100644 --- a/vfs/gcsfs.go +++ b/vfs/gcsfs.go @@ -70,10 +70,10 @@ func NewGCSFs(connectionID, localTempDir string, config GCSFsConfig) (Fs, error) // Name returns the name for the Fs implementation func (fs GCSFs) Name() string { - return fmt.Sprintf("GCSFs bucket: %#v", fs.config.Bucket) + return fmt.Sprintf("GCSFs bucket %#v", fs.config.Bucket) } -// ConnectionID returns the SSH connection ID associated to this Fs implementation +// ConnectionID returns the connection ID associated to this Fs implementation func (fs GCSFs) ConnectionID() string { return fs.connectionID } @@ -82,7 +82,7 @@ func (fs GCSFs) ConnectionID() string { func (fs GCSFs) Stat(name string) (os.FileInfo, error) { var result FileInfo var err error - if len(name) == 0 || name == "." { + if name == "" || name == "." { err := fs.checkIfBucketExists() if err != nil { return result, err @@ -111,7 +111,7 @@ func (fs GCSFs) Stat(name string) (os.FileInfo, error) { metrics.GCSListObjectsCompleted(err) return result, err } - if len(attrs.Prefix) > 0 { + if attrs.Prefix != "" { if fs.isEqual(attrs.Prefix, name) { result = NewFileInfo(name, true, 0, time.Now(), false) break @@ -128,7 +128,7 @@ func (fs GCSFs) Stat(name string) (os.FileInfo, error) { } } metrics.GCSListObjectsCompleted(nil) - if len(result.Name()) == 0 { + if result.Name() == "" { err = errors.New("404 no such file or directory") } return result, err @@ -181,7 +181,12 @@ func (fs GCSFs) Create(name string, flag int) (*os.File, *PipeWriter, func(), er obj := bkt.Object(name) ctx, cancelFn := context.WithCancel(context.Background()) objectWriter := obj.NewWriter(ctx) - contentType := mime.TypeByExtension(path.Ext(name)) + var contentType string + if flag == -1 { + contentType = dirMimeType + } else { + contentType = mime.TypeByExtension(path.Ext(name)) + } if contentType != "" { objectWriter.ObjectAttrs.ContentType = contentType } @@ -274,7 +279,7 @@ func (fs GCSFs) Mkdir(name string) error { if !strings.HasSuffix(name, "/") { name += "/" } - _, w, _, err := fs.Create(name, 0) + _, w, _, err := fs.Create(name, -1) if err != nil { return err } @@ -322,7 +327,7 @@ func (fs GCSFs) ReadDir(dirname string) ([]os.FileInfo, error) { var result []os.FileInfo // dirname must be already cleaned prefix := "" - if len(dirname) > 0 && dirname != "." { + if dirname != "" && dirname != "." { prefix = strings.TrimPrefix(dirname, "/") if !strings.HasSuffix(prefix, "/") { prefix += "/" @@ -346,7 +351,7 @@ func (fs GCSFs) ReadDir(dirname string) ([]os.FileInfo, error) { metrics.GCSListObjectsCompleted(err) return result, err } - if len(attrs.Prefix) > 0 { + if attrs.Prefix != "" { name, _ := fs.resolve(attrs.Prefix, prefix) result = append(result, NewFileInfo(name, true, 0, time.Now(), false)) } else { @@ -442,6 +447,10 @@ func (fs GCSFs) ScanRootDirContents() (int, int64, error) { if !attrs.Deleted.IsZero() { continue } + isDir := strings.HasSuffix(attrs.Name, "/") + if isDir && attrs.Size == 0 { + continue + } numFiles++ size += attrs.Size } @@ -456,13 +465,13 @@ func (GCSFs) GetDirSize(dirname string) (int, int64, error) { } // GetAtomicUploadPath returns the path to use for an atomic upload. -// S3 uploads are already atomic, we never call this method for S3 +// GCS uploads are already atomic, we never call this method for GCS func (GCSFs) GetAtomicUploadPath(name string) string { return "" } // GetRelativePath returns the path for a file relative to the user's home dir. -// This is the path as seen by SFTP users +// This is the path as seen by SFTPGo users func (fs GCSFs) GetRelativePath(name string) string { rel := path.Clean(name) if rel == "." { @@ -484,7 +493,7 @@ func (fs GCSFs) GetRelativePath(name string) string { // directory in the tree, including root func (fs GCSFs) Walk(root string, walkFn filepath.WalkFunc) error { prefix := "" - if len(root) > 0 && root != "." { + if root != "" && root != "." { prefix = strings.TrimPrefix(root, "/") if !strings.HasSuffix(prefix, "/") { prefix += "/" @@ -522,7 +531,7 @@ func (fs GCSFs) Walk(root string, walkFn filepath.WalkFunc) error { } err = walkFn(attrs.Name, NewFileInfo(name, isDir, attrs.Size, attrs.Updated, false), nil) if err != nil { - break + return err } } @@ -541,12 +550,12 @@ func (GCSFs) HasVirtualFolders() bool { return true } -// ResolvePath returns the matching filesystem path for the specified sftp path -func (fs GCSFs) ResolvePath(sftpPath string) (string, error) { - if !path.IsAbs(sftpPath) { - sftpPath = path.Clean("/" + sftpPath) +// ResolvePath returns the matching filesystem path for the specified virtual path +func (fs GCSFs) ResolvePath(virtualPath string) (string, error) { + if !path.IsAbs(virtualPath) { + virtualPath = path.Clean("/" + virtualPath) } - return fs.Join(fs.config.KeyPrefix, strings.TrimPrefix(sftpPath, "/")), nil + return fs.Join(fs.config.KeyPrefix, strings.TrimPrefix(virtualPath, "/")), nil } func (fs *GCSFs) resolve(name string, prefix string) (string, bool) { @@ -558,14 +567,14 @@ func (fs *GCSFs) resolve(name string, prefix string) (string, bool) { return result, isDir } -func (fs *GCSFs) isEqual(key string, sftpName string) bool { - if key == sftpName { +func (fs *GCSFs) isEqual(key string, virtualName string) bool { + if key == virtualName { return true } - if key == sftpName+"/" { + if key == virtualName+"/" { return true } - if key+"/" == sftpName { + if key+"/" == virtualName { return true } return false @@ -582,7 +591,7 @@ func (fs *GCSFs) checkIfBucketExists() error { func (fs *GCSFs) getPrefixForStat(name string) string { prefix := path.Dir(name) - if prefix == "/" || prefix == "." || len(prefix) == 0 { + if prefix == "/" || prefix == "." || prefix == "" { prefix = "" } else { prefix = strings.TrimPrefix(prefix, "/") diff --git a/vfs/s3fs.go b/vfs/s3fs.go index 95bbba43..a606e599 100644 --- a/vfs/s3fs.go +++ b/vfs/s3fs.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "mime" "os" "path" "path/filepath" @@ -55,11 +56,11 @@ func NewS3Fs(connectionID, localTempDir string, config S3FsConfig) (Fs, error) { } awsConfig := aws.NewConfig() - if len(fs.config.Region) > 0 { + if fs.config.Region != "" { awsConfig.WithRegion(fs.config.Region) } - if len(fs.config.AccessSecret) > 0 { + if fs.config.AccessSecret != "" { accessSecret, err := utils.DecryptData(fs.config.AccessSecret) if err != nil { return fs, err @@ -68,7 +69,7 @@ func NewS3Fs(connectionID, localTempDir string, config S3FsConfig) (Fs, error) { awsConfig.Credentials = credentials.NewStaticCredentials(fs.config.AccessKey, fs.config.AccessSecret, "") } - if len(fs.config.Endpoint) > 0 { + if fs.config.Endpoint != "" { awsConfig.Endpoint = aws.String(fs.config.Endpoint) awsConfig.S3ForcePathStyle = aws.Bool(true) } @@ -96,10 +97,10 @@ func NewS3Fs(connectionID, localTempDir string, config S3FsConfig) (Fs, error) { // Name returns the name for the Fs implementation func (fs S3Fs) Name() string { - return fmt.Sprintf("S3Fs bucket: %#v", fs.config.Bucket) + return fmt.Sprintf("S3Fs bucket %#v", fs.config.Bucket) } -// ConnectionID returns the SSH connection ID associated to this Fs implementation +// ConnectionID returns the connection ID associated to this Fs implementation func (fs S3Fs) ConnectionID() string { return fs.connectionID } @@ -151,7 +152,7 @@ func (fs S3Fs) Stat(name string) (os.FileInfo, error) { return true }) metrics.S3ListObjectsCompleted(err) - if err == nil && len(result.Name()) == 0 { + if err == nil && result.Name() == "" { err = errors.New("404 no such file or directory") } return result, err @@ -201,11 +202,18 @@ func (fs S3Fs) Create(name string, flag int) (*os.File, *PipeWriter, func(), err go func() { defer cancelFn() key := name + var contentType string + if flag == -1 { + contentType = dirMimeType + } else { + contentType = mime.TypeByExtension(path.Ext(name)) + } response, err := uploader.UploadWithContext(ctx, &s3manager.UploadInput{ - Bucket: aws.String(fs.config.Bucket), - Key: aws.String(key), - Body: r, - StorageClass: utils.NilIfEmpty(fs.config.StorageClass), + Bucket: aws.String(fs.config.Bucket), + Key: aws.String(key), + Body: r, + StorageClass: utils.NilIfEmpty(fs.config.StorageClass), + ContentEncoding: utils.NilIfEmpty(contentType), }, func(u *s3manager.Uploader) { u.Concurrency = fs.config.UploadConcurrency u.PartSize = fs.config.UploadPartSize @@ -300,7 +308,7 @@ func (fs S3Fs) Mkdir(name string) error { if !strings.HasSuffix(name, "/") { name += "/" } - _, w, _, err := fs.Create(name, 0) + _, w, _, err := fs.Create(name, -1) if err != nil { return err } @@ -446,6 +454,10 @@ func (fs S3Fs) ScanRootDirContents() (int, int64, error) { Prefix: aws.String(fs.config.KeyPrefix), }, func(page *s3.ListObjectsV2Output, lastPage bool) bool { for _, fileObject := range page.Contents { + isDir := strings.HasSuffix(*fileObject.Key, "/") + if isDir && *fileObject.Size == 0 { + continue + } numFiles++ size += *fileObject.Size } @@ -468,7 +480,7 @@ func (S3Fs) GetAtomicUploadPath(name string) string { } // GetRelativePath returns the path for a file relative to the user's home dir. -// This is the path as seen by SFTP users +// This is the path as seen by SFTPGo users func (fs S3Fs) GetRelativePath(name string) string { rel := path.Clean(name) if rel == "." { @@ -533,12 +545,12 @@ func (S3Fs) HasVirtualFolders() bool { return true } -// ResolvePath returns the matching filesystem path for the specified sftp path -func (fs S3Fs) ResolvePath(sftpPath string) (string, error) { - if !path.IsAbs(sftpPath) { - sftpPath = path.Clean("/" + sftpPath) +// ResolvePath returns the matching filesystem path for the specified virtual path +func (fs S3Fs) ResolvePath(virtualPath string) (string, error) { + if !path.IsAbs(virtualPath) { + virtualPath = path.Clean("/" + virtualPath) } - return fs.Join("/", fs.config.KeyPrefix, sftpPath), nil + return fs.Join("/", fs.config.KeyPrefix, virtualPath), nil } func (fs *S3Fs) resolve(name *string, prefix string) (string, bool) { @@ -555,14 +567,14 @@ func (fs *S3Fs) resolve(name *string, prefix string) (string, bool) { return result, isDir } -func (fs *S3Fs) isEqual(s3Key *string, sftpName string) bool { - if *s3Key == sftpName { +func (fs *S3Fs) isEqual(s3Key *string, virtualName string) bool { + if *s3Key == virtualName { return true } - if "/"+*s3Key == sftpName { + if "/"+*s3Key == virtualName { return true } - if "/"+*s3Key == sftpName+"/" { + if "/"+*s3Key == virtualName+"/" { return true } return false diff --git a/vfs/vfs.go b/vfs/vfs.go index 119fa2b1..38eec35d 100644 --- a/vfs/vfs.go +++ b/vfs/vfs.go @@ -4,6 +4,7 @@ package vfs import ( "errors" "fmt" + "net/url" "os" "path" "path/filepath" @@ -16,6 +17,8 @@ import ( "github.com/drakkan/sftpgo/logger" ) +const dirMimeType = "inode/directory" + // Fs defines the interface for filesystem backends type Fs interface { Name() string @@ -126,6 +129,41 @@ type GCSFsConfig struct { StorageClass string `json:"storage_class,omitempty"` } +// AzBlobFsConfig defines the configuration for Azure Blob Storage based filesystem +type AzBlobFsConfig struct { + Container string `json:"container,omitempty"` + // Storage Account Name, leave blank to use SAS URL + AccountName string `json:"account_name,omitempty"` + // Storage Account Key leave blank to use SAS URL. + // The access key is stored encrypted (AES-256-GCM) + AccountKey string `json:"account_key,omitempty"` + // Optional endpoint. Default is "blob.core.windows.net". + // If you use the emulator the endpoint must include the protocol, + // for example "http://127.0.0.1:10000" + Endpoint string `json:"endpoint,omitempty"` + // Shared access signature URL, leave blank if using account/key + SASURL string `json:"sas_url,omitempty"` + // KeyPrefix is similar to a chroot directory for local filesystem. + // If specified then the SFTPGo userd will only see objects that starts + // with this prefix and so you can restrict access to a specific + // folder. The prefix, if not empty, must not start with "/" and must + // end with "/". + // If empty the whole bucket contents will be available + KeyPrefix string `json:"key_prefix,omitempty"` + // The buffer size (in MB) to use for multipart uploads. + // If this value is set to zero, the default value (1MB) for the Azure SDK will be used. + // Please note that if the upload bandwidth between the SFTPGo client and SFTPGo server is + // greater than the upload bandwidth between SFTPGo and Azure then the SFTP client have + // to wait for the upload of the last parts to Azure after it ends the file upload to SFTPGo, + // and it may time out. + // Keep this in mind if you customize these parameters. + UploadPartSize int64 `json:"upload_part_size,omitempty"` + // How many parts are uploaded in parallel + UploadConcurrency int `json:"upload_concurrency,omitempty"` + // Set to true if you use an Azure emulator such as Azurite + UseEmulator bool `json:"use_emulator,omitempty"` +} + // PipeWriter defines a wrapper for pipeat.PipeWriterAt. type PipeWriter struct { writer *pipeat.PipeWriterAt @@ -194,7 +232,7 @@ func ValidateS3FsConfig(config *S3FsConfig) error { if len(config.AccessSecret) == 0 && len(config.AccessKey) > 0 { return errors.New("access_secret cannot be empty with access_key not empty") } - if len(config.KeyPrefix) > 0 { + if config.KeyPrefix != "" { if strings.HasPrefix(config.KeyPrefix, "/") { return errors.New("key_prefix cannot start with /") } @@ -214,10 +252,10 @@ func ValidateS3FsConfig(config *S3FsConfig) error { // ValidateGCSFsConfig returns nil if the specified GCS config is valid, otherwise an error func ValidateGCSFsConfig(config *GCSFsConfig, credentialsFilePath string) error { - if len(config.Bucket) == 0 { + if config.Bucket == "" { return errors.New("bucket cannot be empty") } - if len(config.KeyPrefix) > 0 { + if config.KeyPrefix != "" { if strings.HasPrefix(config.KeyPrefix, "/") { return errors.New("key_prefix cannot start with /") } @@ -238,6 +276,36 @@ func ValidateGCSFsConfig(config *GCSFsConfig, credentialsFilePath string) error return nil } +// ValidateAzBlobFsConfig returns nil if the specified Azure Blob config is valid, otherwise an error +func ValidateAzBlobFsConfig(config *AzBlobFsConfig) error { + if config.SASURL != "" { + _, err := url.Parse(config.SASURL) + return err + } + if config.Container == "" { + return errors.New("container cannot be empty") + } + if config.AccountName == "" || config.AccountKey == "" { + return errors.New("credentials cannot be empty") + } + if config.KeyPrefix != "" { + if strings.HasPrefix(config.KeyPrefix, "/") { + return errors.New("key_prefix cannot start with /") + } + config.KeyPrefix = path.Clean(config.KeyPrefix) + if !strings.HasSuffix(config.KeyPrefix, "/") { + config.KeyPrefix += "/" + } + } + if config.UploadPartSize < 0 { + return fmt.Errorf("invalid upload part size: %v", config.UploadPartSize) + } + if config.UploadConcurrency < 0 { + return fmt.Errorf("invalid upload concurrency: %v", config.UploadConcurrency) + } + return nil +} + // SetPathPermissions calls fs.Chown. // It does nothing for local filesystem on windows func SetPathPermissions(fs Fs, path string, uid int, gid int) { diff --git a/webdavd/file.go b/webdavd/file.go index 2462d9cc..45629eaf 100644 --- a/webdavd/file.go +++ b/webdavd/file.go @@ -122,29 +122,27 @@ func (f *webDavFile) Read(p []byte) (n int, err error) { f.TransferError(common.ErrOpUnsupported) return 0, common.ErrOpUnsupported } - _, r, cancelFn, err := f.Fs.Open(f.GetFsPath(), 0) + _, r, cancelFn, e := f.Fs.Open(f.GetFsPath(), 0) f.Lock() f.reader = r - f.ErrTransfer = err + f.ErrTransfer = e f.BaseTransfer.SetCancelFn(cancelFn) f.startOffset = 0 f.Unlock() - if err != nil { - return 0, err + if e != nil { + return 0, e } } - var readed int - var e error - readed, e = f.reader.Read(p) - atomic.AddInt64(&f.BytesSent, int64(readed)) + n, err = f.reader.Read(p) + atomic.AddInt64(&f.BytesSent, int64(n)) - if e != nil && e != io.EOF { - f.TransferError(e) - return readed, e + if err != nil && err != io.EOF { + f.TransferError(err) + return } f.HandleThrottle() - return readed, e + return } // Write writes the uploaded contents. @@ -154,21 +152,19 @@ func (f *webDavFile) Write(p []byte) (n int, err error) { } f.Connection.UpdateLastActivity() - var written int - var e error - written, e = f.writer.Write(p) - atomic.AddInt64(&f.BytesReceived, int64(written)) + n, err = f.writer.Write(p) + atomic.AddInt64(&f.BytesReceived, int64(n)) - if f.MaxWriteSize > 0 && e == nil && atomic.LoadInt64(&f.BytesReceived) > f.MaxWriteSize { - e = common.ErrQuotaExceeded + if f.MaxWriteSize > 0 && err == nil && atomic.LoadInt64(&f.BytesReceived) > f.MaxWriteSize { + err = common.ErrQuotaExceeded } - if e != nil { - f.TransferError(e) - return written, e + if err != nil { + f.TransferError(err) + return } f.HandleThrottle() - return written, e + return } // Seek sets the offset for the next Read or Write on the writer to offset,