diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..034e848 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3069850 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "05:00" + timezone: "UTC" + + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "daily" + time: "05:00" + timezone: "UTC" + + + diff --git a/.github/workflows/ci-Ruya.Services.CloudStorage.Azure.yml b/.github/workflows/ci-Ruya.Services.CloudStorage.Azure.yml new file mode 100644 index 0000000..5316e05 --- /dev/null +++ b/.github/workflows/ci-Ruya.Services.CloudStorage.Azure.yml @@ -0,0 +1,219 @@ +name: Ruya.Services.CloudStorage.Azure + +on: + workflow_dispatch: + inputs: + environment: + type: environment + description: 'Environment' + required: false + rollingDeployment: + type: boolean + description: 'initiate rolling deployment' + default: false + repository_dispatch: + types: [Ruya.Services.CloudStorage.Abstractions] + push: + branches: [master, release-preview, release-qa, develop] + paths: + - '.github/workflows/Ruya.Services.CloudStorage.Azure.yml' + - 'src/Ruya.Services.CloudStorage.Azure/**' + +env: + version: '7.0' + productionBranch: 'master' + stagingBranch: 'release-preview' + testingBranch: 'release-qa' + integrationBranch: 'develop' + hasSha: ${{contains(toJson(github.event.client_payload), '"sha"')}} + +defaults: + run: + shell: bash + +concurrency: + group: ${{github.workflow}}-${{github.ref}} + cancel-in-progress: true + +jobs: + build: + env: + DOTNET_NOLOGO: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + buildConfiguration: 'Release' + packageOutputPath: './artifacts/packages' + publishOutputPath: './artifacts/staging' + projectFile: './src/Ruya.Services.CloudStorage.Azure/Ruya.Services.CloudStorage.Azure.csproj' + projectTestFile: './test/Ruya.Services.CloudStorage.Azure.Tests/Ruya.Services.CloudStorage.Azure.Tests.csproj' + repositoryDispatchEventType: 'Ruya.Services.CloudStorage.Azure' + if: contains(toJson(github.event.commits), '[skip ci]') == false + timeout-minutes: 3 + runs-on: ubuntu-latest + environment: ${{github.event.inputs.environment}} + outputs: + version: ${{env.version}} + repositoryDispatchEventType: ${{env.repositoryDispatchEventType}} + repository: ${{steps.extract_metadata.outputs.repository}} + branch: ${{steps.extract_metadata.outputs.branch}} + isProduction: ${{steps.extract_metadata_environment.outputs.isProduction}} + isStaging: ${{steps.extract_metadata_environment.outputs.isStaging}} + isTesting: ${{steps.extract_metadata_environment.outputs.isTesting}} + isIntegration: ${{steps.extract_metadata_environment.outputs.isIntegration}} + isDevelopment: ${{steps.extract_metadata_environment.outputs.isDevelopment}} + + steps: + - if: env.hasSha=='false' + name: Checkout + uses: actions/checkout@v3 + + - if: env.hasSha=='true' + name: CheckoutRepositoryDispatch + uses: actions/checkout@v3 + with: + ref: ${{github.event.client_payload.branch}} + + - name: Extract metadata + id: extract_metadata + shell: pwsh + run: | + if ($env:hasSha -eq $true) { + $branch = "${{github.event.client_payload.branch}}" + } else { + $branch = "$env:GITHUB_REF" -replace 'refs/heads/', '' + }; + Add-Content $env:GITHUB_OUTPUT "branch=$branch" + Add-Content $env:GITHUB_OUTPUT "branchsafe=$($branch -replace '/', '-')" + Add-Content $env:GITHUB_OUTPUT "repository=$($env:GITHUB_REPOSITORY -replace '.*/', '')" + Add-Content $env:GITHUB_ENV "version=$env:version.$env:GITHUB_RUN_NUMBER" + + - name: Extract metadata environment + id: extract_metadata_environment + shell: pwsh + run: | + $isProduction = 0 + $isStaging = 0 + $isTesting = 0 + $isIntegration = 0 + $isDevelopment = 0 + $branch = "${{steps.extract_metadata.outputs.branch}}" + + if ($branch -eq "${{env.productionBranch}}" -or "${{github.event.inputs.environment}}" -eq "production") { + $isProduction = 1 + } + elseif ($branch -eq "${{env.stagingBranch}}" -or "${{github.event.inputs.environment}}" -eq "staging") { + $isStaging = 1 + $versionSuffix = "preview" + } + elseif ($branch -eq "${{env.testingBranch}}" -or "${{github.event.inputs.environment}}" -eq "testing") { + $isTesting = 1 + $versionSuffix = "qa" + } + elseif ($branch -eq "${{env.integrationBranch}}" -or "${{github.event.inputs.environment}}" -eq "integration") { + $isIntegration = 1 + $versionSuffix = "develop" + } + else { + $isDevelopment = 1 + $versionSuffix = "dev" + } + + if ($versionSuffix) { + $version = "$env:version-$versionSuffix" + } else { + $version = $env:version + } + + Add-Content $env:GITHUB_OUTPUT "isProduction=$isProduction" + Add-Content $env:GITHUB_OUTPUT "isStaging=$isStaging" + Add-Content $env:GITHUB_OUTPUT "isTesting=$isTesting" + Add-Content $env:GITHUB_OUTPUT "isIntegration=$isIntegration" + Add-Content $env:GITHUB_OUTPUT "isDevelopment=$isDevelopment" + Add-Content $env:GITHUB_ENV "version=$version" + + - name: Add PackageManager + run: | + dotnet nuget add source https://nuget.pkg.github.com/cilerler/index.json -n github -u ${{github.actor}} -p ${{secrets.PAT}} --store-password-in-clear-text + + - name: Restore + run: dotnet restore $projectFile -p:Version=$version + + - name: Build + run: dotnet build $projectFile --configuration $buildConfiguration -p:Version=$version + + - name: Test + run: dotnet test $projectTestFile --configuration $buildConfiguration -p:CollectCoverage=true + continue-on-error: true + + - name: Publish + run: dotnet publish $projectFile --configuration $buildConfiguration --no-restore --no-build --output ${{env.publishOutputPath}} + + - name: Pack + run: dotnet pack $projectFile --configuration $buildConfiguration --no-restore --no-build --output ${{env.packageOutputPath}} + + - name: Publish artifacts + uses: actions/upload-artifact@v3 + with: + name: packages + path: ${{env.packageOutputPath}}/*.nupkg + if-no-files-found: error + retention-days: 1 + + - name: Create tag + if: steps.extract_metadata_environment.outputs.isProduction == false + uses: actions/github-script@v6 + with: + script: | + github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'refs/tags/v${{env.version}}', + sha: context.sha + }) + + - name: Create Release + if: steps.extract_metadata_environment.outputs.isProduction == true + id: create_release + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + with: + tag_name: v${{env.version}} + name: ${{github.workflow}}.${{env.version}} + draft: false + prerelease: false + files: | + ${{env.packageOutputPath}}/*.nupkg + body: | + [Changelog](https://github.com/${{github.repository}}/blob/${{steps.extract_metadata.outputs.branch}}/CHANGELOG.md) + + - name: Push to GitHub Packages + run: dotnet nuget push '${{env.packageOutputPath}}/*.nupkg' --skip-duplicate --source "github" --api-key ${{secrets.GITHUB_TOKEN}} + + - name: Push to Nuget + if: steps.extract_metadata_environment.outputs.isProduction == true + run: dotnet nuget push '${{env.packageOutputPath}}/*.nupkg' --skip-duplicate --source "nuget.org" --api-key ${{secrets.NUGET_API_KEY}} + + - name: Publish artifacts (output) + uses: actions/upload-artifact@v3 + if: failure() + with: + name: output + path: ./ + retention-days: 1 + + dispatch: + needs: build + if: (contains(toJson(github.event.commits), '[rolling deployment]') || contains(toJson(github.event.client_payload), '"sha"') || github.event.inputs.rollingDeployment == 'true' ) + strategy: + matrix: + repo: ['cilerler/ruya','cilerler/burcin'] + runs-on: ubuntu-latest + steps: + - name: Repository Dispatch + uses: peter-evans/repository-dispatch@v2 + with: + token: ${{secrets.PAT}} + repository: ${{matrix.repo}} + event-type: ${{needs.build.outputs.repositoryDispatchEventType}} + client-payload: '{"sha": "${{github.sha}}", "version": "${{needs.build.outputs.version}}", "repository": "${{needs.build.outputs.repository}}", "branch": "${{needs.build.outputs.branch}}", "isProduction": "${{needs.build.outputs.isProduction}}", "isStaging": "${{needs.build.outputs.isStaging}}", "isTesting": "${{needs.build.outputs.isTesting}}", "isIntegration": "${{needs.build.outputs.isIntegration}}", "isDevelopment": "${{needs.build.outputs.isDevelopment}}"}' diff --git a/.gitignore b/.gitignore index 5031c4c..f3e2c71 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,7 @@ $tf/ _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user +.idea/ # TeamCity is a build add-in _TeamCity* diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..88479e9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dotnet.defaultSolution": "Ruya.sln" +} diff --git a/src/Ruya.Services.CloudStorage.Azure/Client.cs b/src/Ruya.Services.CloudStorage.Azure/Client.cs new file mode 100644 index 0000000..05548fd --- /dev/null +++ b/src/Ruya.Services.CloudStorage.Azure/Client.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text.Json; + +using Azure; +using Azure.Identity; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Azure.Storage.Sas; + +using HeyRed.Mime; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Ruya.Services.CloudStorage.Abstractions; + +namespace Ruya.Services.CloudStorage.Azure; + +public class Client : ICloudFileService +{ + private readonly ILogger _logger; + private readonly Setting _options; + + private readonly BlobContainerClient _storageClient; + + // ReSharper disable once SuggestBaseTypeForParameter + public Client(IConfiguration configuration, ILogger logger, IOptions options) + { + _logger = logger; + _options = options.Value; + var connectionString = configuration.GetConnectionString(_options.ConnectionStringKey) ?? throw new ArgumentNullException(nameof(_options.ConnectionStringKey)); + _storageClient = new BlobContainerClient(connectionString, _options.Container); + _storageClient.CreateIfNotExists(); + } + + public void SetBucket(string input) + { + throw new NotSupportedException(); + } + + public ICloudFileMetadata GetFileMetadata(string fileName, string bucketName = "") + { + EnsureContainerExist(bucketName); + try + { + BlobClient blobClient = _storageClient.GetBlobClient(fileName); + BlobProperties properties = blobClient.GetProperties(); + + var output = new CloudFileMetadata + { + Bucket = blobClient.BlobContainerName, + Size = (ulong?)properties.ContentLength, + Name = fileName, + LastModified = properties.LastModified.UtcDateTime, + ContentType = properties.ContentType, + SignedUrl = blobClient.GenerateSasUri(BlobSasPermissions.Read, DateTimeOffset.UtcNow.AddHours(1)).ToString() + }; + return output; + } + catch (RequestFailedException ex) when (ex.Status == (int)HttpStatusCode.NotFound) + { + _logger.Log(LogLevel.Warning, "Not Found - {fileName} in container {containerName}", fileName, _options.Container); + throw new ArgumentException($"Not Found - {fileName} in container {_options.Container}", ex); + } + catch (RequestFailedException ex) + { + _logger.Log(LogLevel.Error, ex, ex.Message); + throw; + } + } + + public ICloudFileMetadata UploadFile(string sourcePath, string targetPath, string bucketName = "") + { + string contentType = null; + try + { + contentType = MimeTypesMap.GetMimeType(sourcePath); + } + catch (Exception e) + { + _logger.Log(LogLevel.Warning, e, "An error occured while trying to retrieve MimeType"); + } + + BlobClient blob = _storageClient.GetBlobClient(targetPath); + using FileStream fileStream = File.OpenRead(sourcePath); + return UploadStream(fileStream, targetPath, bucketName: bucketName); + } + + public ICloudFileMetadata UploadStream(Stream source, string targetPath, string contentType = "application/octet-stream", string bucketName = "") + { + EnsureContainerExist(bucketName); + + string fileName = Path.GetFileName(targetPath); + string directoryName = Path.GetDirectoryName(targetPath); + string correctedDirectoryName = directoryName.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Trim(Path.AltDirectorySeparatorChar); + string destinationFileName = (correctedDirectoryName + Path.AltDirectorySeparatorChar + fileName).TrimStart(Path.AltDirectorySeparatorChar); + + var progress = new Progress(p => _logger.LogTrace("destination as://{container}/{destinationFileName}, progress: {progress}", _options.Container, destinationFileName, p)); + BlobClient blobClient = _storageClient.GetBlobClient(targetPath); + source.Seek(0, SeekOrigin.Begin); + BlobContentInfo upload; + try + { + upload = blobClient.Upload(source, new BlobUploadOptions { ProgressHandler = progress }).Value; + } + catch (Exception e) + { + _logger.Log(LogLevel.Error, e, "Encountered an error while uploading file stream. {ContainerName} {FileName}", _options.Container, fileName); + throw; + } + + BlobProperties properties = blobClient.GetProperties().Value; + var output = new CloudFileMetadata + { + Bucket = blobClient.BlobContainerName, + Size = (ulong?)properties.ContentLength, + Name = destinationFileName, + LastModified = properties.LastModified.UtcDateTime, + ContentType = properties.ContentType, + }; + return output; + } + + public void DownloadFile(string fileName, Stream destinationStream, string bucketName = "") + { + EnsureContainerExist(bucketName); + + try + { + _storageClient.GetBlobClient(fileName).DownloadTo(destinationStream); + } + catch (Exception e) + { + _logger.Log(LogLevel.Error, e, "Encountered an error while dowloading file. {ContainerName} {FileName}", _options.Container, fileName); + throw; + } + + destinationStream.Seek(0, SeekOrigin.Begin); + } + + public void DeleteFile(string fileName, string bucketName = "") + { + EnsureContainerExist(bucketName); + + try + { + _storageClient.GetBlobClient(fileName).DeleteIfExists(); + } + catch (Exception e) + { + _logger.Log(LogLevel.Error, e, "Encountered an error while deleting file. {ContainerName} {FileName}", _options.Container, fileName); + throw; + } + } + + public void CopyFile(string sourceBucketName, string sourceFileName, string destinationBucketName, string destinationFileName) + { + throw new NotImplementedException(); + } + + public List GetFileList(string prefix = null, string bucketName = "") + { + EnsureContainerExist(bucketName); + + var output = new List(); + try + { + var blobItems = _storageClient.GetBlobs(prefix: prefix); + foreach (var blobItem in blobItems) + { + output.Add(new CloudFileMetadata + { + Bucket = _options.Container, + Size = (ulong?)blobItem.Properties.ContentLength, + Name = blobItem.Name, + LastModified = blobItem.Properties.LastModified.Value.UtcDateTime, + ContentType = blobItem.Properties.ContentType + }); + } + } + catch (Exception e) + { + _logger.Log(LogLevel.Error, e, "Encountered an error while getting file list. {Prefix} {ContainerName}", prefix, _options.Container); + throw; + } + + return output; + } + + private static void EnsureContainerExist(string containerName) + { + if (!string.IsNullOrWhiteSpace(containerName)) + { + throw new NotSupportedException(); + } + } + + public string GetSignedUploadUrl(string filename, string contentType, string bucketName, int expirationMinutes = 60) + { + throw new NotImplementedException(); + } +} diff --git a/src/Ruya.Services.CloudStorage.Azure/CloudFileMetadata.cs b/src/Ruya.Services.CloudStorage.Azure/CloudFileMetadata.cs new file mode 100644 index 0000000..79d2c52 --- /dev/null +++ b/src/Ruya.Services.CloudStorage.Azure/CloudFileMetadata.cs @@ -0,0 +1,14 @@ +using System; +using Ruya.Services.CloudStorage.Abstractions; + +namespace Ruya.Services.CloudStorage.Azure; + +public class CloudFileMetadata : ICloudFileMetadata +{ + public string Bucket { get; set; } + public string Name { get; set; } + public ulong? Size { get; set; } + public DateTime? LastModified { get; set; } + public string ContentType { get; set; } + public string SignedUrl { get; set; } +} diff --git a/src/Ruya.Services.CloudStorage.Azure/Ruya.Services.CloudStorage.Azure.csproj b/src/Ruya.Services.CloudStorage.Azure/Ruya.Services.CloudStorage.Azure.csproj new file mode 100644 index 0000000..815ddc1 --- /dev/null +++ b/src/Ruya.Services.CloudStorage.Azure/Ruya.Services.CloudStorage.Azure.csproj @@ -0,0 +1,48 @@ + + + + net7.0 + latest + enable + disable + true + + + + x64 + + + + x64 + true + + + + 0.0.0.0 + dev + Cengiz Ilerler + Ruya.Services.CloudStorage.Azure + https://github.com/cilerler/ruya + + + + + + + + + + + + + + + NU1604 + + + + + + + + diff --git a/src/Ruya.Services.CloudStorage.Azure/Setting.cs b/src/Ruya.Services.CloudStorage.Azure/Setting.cs new file mode 100644 index 0000000..4e7e64d --- /dev/null +++ b/src/Ruya.Services.CloudStorage.Azure/Setting.cs @@ -0,0 +1,18 @@ +using System; +using System.Text.Json; + +namespace Ruya.Services.CloudStorage.Azure; + +public class Setting +{ + public const string ConfigurationSectionName = "CloudStorage_Azure"; + + public string ConnectionStringKey { get; set; } + + private string _container; + public string Container + { + get => _container; + set => _container = value.ToLower(); + } +} diff --git a/src/Ruya.Services.CloudStorage.Azure/StartupExtensions.cs b/src/Ruya.Services.CloudStorage.Azure/StartupExtensions.cs new file mode 100644 index 0000000..b695fe1 --- /dev/null +++ b/src/Ruya.Services.CloudStorage.Azure/StartupExtensions.cs @@ -0,0 +1,22 @@ +using System; +using System.Linq; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Ruya.Services.CloudStorage.Abstractions; +using Ruya.Services.CloudStorage.Azure; + +namespace Ruya +{ + public static partial class StartupExtensions + { + public static IServiceCollection AddAzureStorageService(this IServiceCollection serviceCollection, IConfiguration configuration) + { + if (serviceCollection == null) throw new ArgumentNullException(nameof(serviceCollection)); + if (configuration == null) throw new ArgumentNullException(nameof(configuration)); + + serviceCollection.Configure(configuration.GetSection(Setting.ConfigurationSectionName)); + return serviceCollection.AddTransient(); + } + } +}