diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 6a1ac48..09c5595 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -16,7 +16,7 @@ on: env: APP_NAME: 'OneCloud.S3.API' - DOTNET_VERSION: '7.0.x' + DOTNET_VERSION: '8.0.x' jobs: build-and-publish: @@ -52,7 +52,7 @@ jobs: - name: Build Container Image working-directory: ./${{env.APP_NAME}}/ - run: dotnet publish -p:PublishProfile=DefaultContainer -p:ContainerImageName=ghcr.io/${{ github.repository }} -c Release + run: dotnet publish --os linux --arch x64 --self-contained true -c Release -p:PublishProfile=DefaultContainer -p:PublishSingleFile=true -p:ContainerImageName=ghcr.io/${{ github.repository }} - name: Push Image to Container Registry run: docker push ghcr.io/${{ github.repository }} --all-tags diff --git a/Directory.Packages.props b/Directory.Packages.props index 81a6b8a..45b7957 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,11 +1,11 @@ - - true - - - - - - - - + + true + + + + + + + + \ No newline at end of file diff --git a/OneCloud.S3.API/Controllers/BucketsController.cs b/OneCloud.S3.API/Controllers/BucketsController.cs deleted file mode 100644 index 7fdfbf2..0000000 --- a/OneCloud.S3.API/Controllers/BucketsController.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using OneCloud.S3.API.Infrastructure.Interfaces; -using OneCloud.S3.API.Models.Dto; -using System.Net.Mime; - -namespace OneCloud.S3.API.Controllers; - -/// -/// Buckets controller -/// -[ApiController] -[ApiConventionType(typeof(DefaultApiConventions))] -[Route("api/storage/buckets")] -[Produces(MediaTypeNames.Application.Json)] -public class BucketsController(IStorageBucketRepository storageRepository) : ControllerBase -{ - private readonly IStorageBucketRepository _storageRepository = storageRepository; - - /// - /// Get list of buckets - /// - /// - /// - [HttpGet(Name = "GetBuckets")] - [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Get))] - public async Task GetBuckets(CancellationToken cancellationToken) - { - var buckets = await _storageRepository.ListBucketsAsync(cancellationToken); - if(!buckets.Any()) - return NotFound(); - var result = buckets.Select(s => new BucketDto - { - BucketName = s.BucketName, - CreationDate = s.CreationDate, - }); - return Ok(result); - } - - /// - /// List of bucket objects - /// - /// Bucket name - /// - /// - [HttpGet("content/{bucket}", Name = "GetBucketContent")] - [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Get))] - public async Task GetBucketContent(string bucket, CancellationToken cancellationToken) - { - var result = await _storageRepository.ListBucketContentAsync(bucket, cancellationToken); - return Ok(result.Select(s => new ObjectDto - { - BucketName = s.BucketName, - Key = s.Key, - ETag = s.ETag, - Size = s.Size, - LastModified = s.LastModified, - })); - } - - /// - /// Create bucket - /// - /// Bucket name - /// - /// - [HttpPost("{bucket}", Name = "CreateBucket")] - [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Post))] - public async Task PostBucket(string bucket, CancellationToken cancellationToken) - { - await _storageRepository.PutBucketAsync(bucket, cancellationToken); - return CreatedAtAction("GetBucketContent", new { bucket }); - } - - /// - /// Delete bucket - /// - /// Bucket name - /// - /// - [HttpDelete("{bucket}", Name = "DeleteBucket")] - [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Delete))] - public async Task DeleteBucket(string bucket, CancellationToken cancellationToken) - { - await _storageRepository.DeleteBucketAsync(bucket, cancellationToken); - return Ok(); - } -} diff --git a/OneCloud.S3.API/Controllers/ObjectsController.cs b/OneCloud.S3.API/Controllers/ObjectsController.cs deleted file mode 100644 index b7fd48f..0000000 --- a/OneCloud.S3.API/Controllers/ObjectsController.cs +++ /dev/null @@ -1,119 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using OneCloud.S3.API.Infrastructure.Interfaces; -using OneCloud.S3.API.Models.Dto; -using System.ComponentModel.DataAnnotations; -using System.Net.Mime; - -namespace OneCloud.S3.API.Controllers; - -/// -/// Object controller -/// -[ApiController] -[Route("api/storage/objects")] -[Produces(MediaTypeNames.Application.Json)] -public class ObjectsController(IStorageObjectRepository storageRepository) : ControllerBase -{ - private readonly IStorageObjectRepository _storageRepository = storageRepository; - - /// - /// Get object - /// - /// Bucket name - /// Object key - /// Object MIME-type - /// - /// - [HttpGet("{bucket}/{filePath}", Name = "GetObject")] - [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Get))] - public async Task GetObject(string bucket, string filePath, [Required] string contentType, CancellationToken cancellationToken) - { - var result = await _storageRepository.GetObjectAsync(bucket, filePath, cancellationToken); - if(result.Length == 0) - return NotFound(); - return File(result, contentType, Path.GetFileName(filePath)); - } - - /// - /// Get temporary public link to object - /// - /// Bucket name - /// Object key - /// Date link expires - /// - [HttpGet("url/{bucket}/{filePath}", Name = "GetObjectUrl")] - [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Get))] - public IActionResult GetObjectUrl(string bucket, string filePath, [Required] DateTime expires) - { - var result = _storageRepository.GetPreSignedUrl(bucket, filePath, expires); - return Ok(new ObjectUrlDto - { - Url = result, - Expires = expires, - }); - } - - /// - /// Upload object - /// - /// Target bucket name - /// Target object key - /// - /// - /// - [HttpPost("{bucket}/{filePath}", Name = "CreateObject")] - [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Post))] - public async Task PostObject(string bucket, string filePath, [Required] IFormFile file, CancellationToken cancellationToken) - { - await _storageRepository.PutObjectAsync(bucket, filePath, file, cancellationToken); - return CreatedAtAction("GetObject", new { bucket, filePath, file.ContentType }); - } - - /// - /// Change object permissions - /// - /// Bucket name - /// Object key - /// Is object public? - /// - /// - [HttpPut("permission/{bucket}/{filePath}", Name = "ChangeObjectPermissions")] - [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Put))] - public async Task PutObjectPermission(string bucket, string filePath, bool isPublicRead, CancellationToken cancellationToken) - { - await _storageRepository.PutAclAsync(bucket, filePath, isPublicRead, cancellationToken); - return Ok(); - } - - /// - /// Copy object - /// - /// Source bucket name - /// Source object key - /// Target bucket name - /// Target object key - /// - /// - [HttpPut("copy/{srcBucket}/{srcFilePath}", Name = "CopyObject")] - [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Put))] - public async Task PutObjectCopy(string srcBucket, string srcFilePath, [Required] string destBucket, [Required] string destFilePath, CancellationToken cancellationToken) - { - await _storageRepository.CopyObjectAsync(srcBucket, srcFilePath, destBucket, destFilePath, cancellationToken); - return Ok(); - } - - /// - /// Delete object - /// - /// Bucket name - /// Object key - /// - /// - [HttpDelete("{bucket}/{filePath}", Name = "DeleteObject")] - [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Delete))] - public async Task DeleteObject(string bucket, string filePath, CancellationToken cancellationToken) - { - await _storageRepository.DeleteObjectAsync(bucket, filePath, cancellationToken); - return Ok(); - } -} diff --git a/OneCloud.S3.API/EndPoints/BucketsEndPoints.cs b/OneCloud.S3.API/EndPoints/BucketsEndPoints.cs new file mode 100644 index 0000000..a0eed6f --- /dev/null +++ b/OneCloud.S3.API/EndPoints/BucketsEndPoints.cs @@ -0,0 +1,59 @@ +using Amazon.S3; +using Amazon.S3.Model; +using Microsoft.AspNetCore.Http.HttpResults; +using OneCloud.S3.API.Models; +using System.Net; + +namespace OneCloud.S3.API.EndPoints; + +public static class BucketsEndPoints +{ + public static IEndpointRouteBuilder UseBucketsEndpoints(this IEndpointRouteBuilder builder) + { + var buckets = builder + .MapGroup("storage/buckets") + .WithTags("Buckets"); + + buckets.MapGet("", async (AmazonS3Client client, CancellationToken cancellationToken) => + await client.ListBucketsAsync(cancellationToken) + is { HttpStatusCode: HttpStatusCode.OK } response + ? TypedResults.Ok(response.Buckets.Select(s => new BucketDto(s.BucketName, s.CreationDate))) + : Results.NotFound()) + .Produces>() + .Produces(StatusCodes.Status404NotFound) + .WithName("GetBuckets") + .WithSummary("Buckets list"); + + buckets.MapGet("{bucketName}", async (AmazonS3Client client, string bucketName, CancellationToken cancellationToken) => + await client.ListObjectsAsync(new ListObjectsRequest { BucketName = bucketName }, cancellationToken) + is { HttpStatusCode: HttpStatusCode.OK } response + ? TypedResults.Ok(response.S3Objects.Select(s => new ObjectDto(s.BucketName, s.Key, s.ETag, s.LastModified, s.Size))) + : Results.NotFound()) + .Produces>() + .Produces(StatusCodes.Status404NotFound) + .WithName("GetBucketContent") + .WithSummary("Bucket content"); + + buckets.MapPost("{bucketName}", async (AmazonS3Client client, string bucketName, CancellationToken cancellationToken) => + await client.PutBucketAsync(bucketName, cancellationToken) + is { HttpStatusCode: HttpStatusCode.OK } + ? TypedResults.CreatedAtRoute("GetBucketContent", new { bucketName }) + : Results.BadRequest()) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status403Forbidden) + .WithName("CreateBucket") + .WithSummary("Create bucket"); + + buckets.MapDelete("{bucketName}", async (AmazonS3Client client, string bucketName, CancellationToken cancellationToken) => + await client.DeleteBucketAsync(bucketName, cancellationToken) + is { HttpStatusCode: HttpStatusCode.OK } + ? TypedResults.NoContent() + : Results.NotFound()) + .Produces() + .Produces(StatusCodes.Status404NotFound) + .WithName("DeleteBucket") + .WithSummary("Delete bucket"); + + return builder; + } +} diff --git a/OneCloud.S3.API/EndPoints/ObjectsEndPoints.cs b/OneCloud.S3.API/EndPoints/ObjectsEndPoints.cs new file mode 100644 index 0000000..af6da3b --- /dev/null +++ b/OneCloud.S3.API/EndPoints/ObjectsEndPoints.cs @@ -0,0 +1,83 @@ +using Amazon.S3; +using Amazon.S3.Model; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Net.Http.Headers; +using OneCloud.S3.API.Models; +using System.Net; + +namespace OneCloud.S3.API.EndPoints; + +public static class ObjectsEndPoints +{ + public static IEndpointRouteBuilder UseObjectsEndpoints(this IEndpointRouteBuilder builder) + { + var objects = builder + .MapGroup("storage/objects") + .WithTags("Objects"); + + objects.MapGet("{bucketName}", async (AmazonS3Client client, string bucketName, string filePath, CancellationToken cancellationToken) => + await client.GetObjectAsync(new GetObjectRequest { BucketName = bucketName, Key = filePath }, cancellationToken) + is { HttpStatusCode: HttpStatusCode.OK } response + ? TypedResults.Stream(response.ResponseStream, response.Headers.ContentType, response.Key, response.LastModified, EntityTagHeaderValue.Parse(response.ETag), true) + : Results.NotFound()) + .Produces() + .Produces(StatusCodes.Status404NotFound) + .WithName("GetObject") + .WithSummary("Get object"); + + objects.MapGet("{bucketName}/url", (AmazonS3Client client, string bucketName, string filePath, DateTime expires) => + client.GetPreSignedURL(new GetPreSignedUrlRequest { BucketName = bucketName, Key = filePath, Expires = expires, Protocol = Protocol.HTTPS }) + is { } url + ? TypedResults.Created(url, new ObjectUrlDto(url, expires)) + : Results.NotFound()) + .Produces() + .Produces(StatusCodes.Status404NotFound) + .WithName("GetObjectUrl") + .WithSummary("Temporary public URL to object"); + + objects.MapPost("{bucketName}", async (AmazonS3Client client, string bucketName, string filePath, IFormFile file, CancellationToken cancellationToken) => + { + await using var stream = file.OpenReadStream(); + return await client.PutObjectAsync(new PutObjectRequest { BucketName = bucketName, Key = filePath, ContentType = file.ContentType, InputStream = stream, AutoCloseStream = true, UseChunkEncoding = false }, cancellationToken) + is { HttpStatusCode: HttpStatusCode.OK } + ? TypedResults.CreatedAtRoute("GetObject", new { bucketName, filePath, file.ContentType }) + : Results.BadRequest(); + }) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status403Forbidden) + .WithName("CreateObject") + .WithSummary("Create object"); + + objects.MapPut("{bucketName}/permission", async (AmazonS3Client client, string bucketName, string filePath, bool isPublicRead, CancellationToken cancellationToken) => + await client.PutACLAsync(new PutACLRequest { BucketName = bucketName, Key = filePath, CannedACL = isPublicRead ? S3CannedACL.PublicRead : S3CannedACL.Private }, cancellationToken) + is { HttpStatusCode: HttpStatusCode.OK } + ? TypedResults.NoContent() + : Results.NotFound()) + .Produces() + .Produces(StatusCodes.Status404NotFound) + .WithName("ChangeObjectPermissions") + .WithSummary("Change object permissions"); + + objects.MapPut("{bucketName}/copy", async (AmazonS3Client client, string bucketName, string filePath, string destBucket, string destFilePath, CancellationToken cancellationToken) => + await client.CopyObjectAsync(new CopyObjectRequest { SourceBucket = bucketName, SourceKey = filePath, DestinationBucket = destBucket, DestinationKey = destFilePath }, cancellationToken) + is { HttpStatusCode: HttpStatusCode.OK } + ? TypedResults.NoContent() + : Results.NotFound()) + .Produces() + .Produces(StatusCodes.Status404NotFound) + .WithName("CopyObject") + .WithSummary("Copy object"); + + objects.MapDelete("{bucketName}", async (AmazonS3Client client, string bucketName, string filePath, CancellationToken cancellationToken) => + await client.DeleteObjectAsync(new DeleteObjectRequest { BucketName = bucketName, Key = filePath }, cancellationToken) + is { HttpStatusCode: HttpStatusCode.OK } + ? TypedResults.NoContent() + : Results.NotFound()) + .Produces() + .Produces(StatusCodes.Status404NotFound) + .WithName("DeleteObject") + .WithSummary("Delete object"); + + return builder; + } +} diff --git a/OneCloud.S3.API/EndPoints/StorageEndPoints.cs b/OneCloud.S3.API/EndPoints/StorageEndPoints.cs new file mode 100644 index 0000000..44cf48b --- /dev/null +++ b/OneCloud.S3.API/EndPoints/StorageEndPoints.cs @@ -0,0 +1,40 @@ +using OneCloud.S3.API.Models; + +namespace OneCloud.S3.API.EndPoints; + +public static class StorageEndPoints +{ + public static IEndpointRouteBuilder UseStorageEndPoints(this RouteGroupBuilder builder) + { + var storage = builder.MapGroup("storage").WithTags("Storage"); + + storage.MapPost("", async (IHttpClientFactory httpClientFactory, CancellationToken cancellationToken) => + { + using var client = httpClientFactory.CreateClient("api"); + using var request = await client.PostAsync("storage", null, cancellationToken); + return await request.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + is { } response + ? TypedResults.Ok(response) + : Results.BadRequest(); + }) + .Produces() + .Produces(StatusCodes.Status403Forbidden) + .WithName("StorageActivate") + .WithSummary("Activate storage"); + + storage.MapDelete("", async (IHttpClientFactory httpClientFactory, CancellationToken cancellationToken) => + { + using var client = httpClientFactory.CreateClient("api"); + using var request = await client.DeleteAsync("storage", cancellationToken); + return await request.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + is { } response + ? TypedResults.Ok(response) + : Results.BadRequest(); + }) + .Produces(StatusCodes.Status403Forbidden) + .WithName("StorageDeactivate") + .WithSummary("Deactivate storage"); + + return builder; + } +} diff --git a/OneCloud.S3.API/EndPoints/UsersEndPoints.cs b/OneCloud.S3.API/EndPoints/UsersEndPoints.cs new file mode 100644 index 0000000..a3d50cd --- /dev/null +++ b/OneCloud.S3.API/EndPoints/UsersEndPoints.cs @@ -0,0 +1,114 @@ +using OneCloud.S3.API.Models; + +namespace OneCloud.S3.API.EndPoints; + +public static class UsersEndPoints +{ + public static IEndpointRouteBuilder UseUsersEndPoints(this IEndpointRouteBuilder builder) + { + var users = builder + .MapGroup("storage/users") + .WithTags("Users"); + + users.MapGet("", async (IHttpClientFactory httpClientFactory, CancellationToken cancellationToken) => + { + using var client = httpClientFactory.CreateClient("api"); + return await client.GetFromJsonAsync("storage/users", cancellationToken) + is { } response + ? TypedResults.Ok(response) + : Results.NotFound(); + }) + .Produces() + .Produces(StatusCodes.Status404NotFound) + .WithName("GetUsers") + .WithSummary("List of storage users"); + + users.MapGet("{id:int}", async (IHttpClientFactory httpClientFactory, int id, CancellationToken cancellationToken) => + { + using var client = httpClientFactory.CreateClient("api"); + return await client.GetFromJsonAsync($"storage/users/{id}", cancellationToken) + is { } response + ? TypedResults.Ok(response) + : Results.NotFound(); + }) + .Produces() + .Produces(StatusCodes.Status404NotFound) + .WithName("GetUser") + .WithSummary("Storage user by Id"); + + users.MapPost("", async (IHttpClientFactory httpClientFactory, string userName, bool persistPassword, CancellationToken cancellationToken) => + { + using var client = httpClientFactory.CreateClient("api"); + using var request = await client.PostAsJsonAsync("storage/users", + new { UserName = userName, PersistPassword = persistPassword }, cancellationToken); + return await request.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + is { } response + ? TypedResults.Ok(response) + : Results.NotFound(); + }) + .Produces() + .Produces(StatusCodes.Status404NotFound) + .WithName("CreateUser") + .WithSummary("Create new user"); + + users.MapPost("{id:int}/block", async (IHttpClientFactory httpClientFactory, int id, CancellationToken cancellationToken) => + { + using var client = httpClientFactory.CreateClient("api"); + using var request = await client.PostAsync($"storage/users/{id}/block", null, cancellationToken); + return await request.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + is { } response + ? TypedResults.Ok(response) + : Results.NotFound(); + }) + .Produces() + .Produces(StatusCodes.Status404NotFound) + .WithName("BlockUser") + .WithSummary("Block storage user"); + + users.MapPost("{id:int}/unblock", async (IHttpClientFactory httpClientFactory, int id, CancellationToken cancellationToken) => + { + using var client = httpClientFactory.CreateClient("api"); + using var request = await client.PostAsync($"storage/users/{id}/unblock", null, cancellationToken); + return await request.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + is { } response + ? TypedResults.Ok(response) + : Results.NotFound(); + }) + .Produces() + .Produces(StatusCodes.Status404NotFound) + .WithName("UnblockUser") + .WithSummary("Unblock storage user"); + + users.MapDelete("{id:int}", async (IHttpClientFactory httpClientFactory, int id, CancellationToken cancellationToken) => + { + using var client = httpClientFactory.CreateClient("api"); + using var request = await client.DeleteAsync($"storage/users/{id}", cancellationToken); + return await request.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + is { } response + ? TypedResults.Ok(response) + : Results.NotFound(); + }) + .Produces() + .Produces(StatusCodes.Status404NotFound) + .WithName("DeleteUser") + .WithSummary("Delete storage user"); + + users.MapPost("{id:int}/change-password", + async (IHttpClientFactory httpClientFactory, int id, bool persistPassword, CancellationToken cancellationToken) => + { + using var client = httpClientFactory.CreateClient("api"); + using var request = await client.PostAsync($"storage/users/{id}/change-password", + JsonContent.Create(new { PersistPassword = persistPassword }), cancellationToken); + return await request.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + is { } response + ? TypedResults.Ok(response) + : Results.NotFound(); + }) + .Produces() + .Produces(StatusCodes.Status404NotFound) + .WithName("UserChangePassword") + .WithSummary("Reset password of storage user"); + + return builder; + } +} diff --git a/OneCloud.S3.API/Extensions/EndpointsExtensions.cs b/OneCloud.S3.API/Extensions/EndpointsExtensions.cs new file mode 100644 index 0000000..a7b82e5 --- /dev/null +++ b/OneCloud.S3.API/Extensions/EndpointsExtensions.cs @@ -0,0 +1,19 @@ +using OneCloud.S3.API.EndPoints; + +namespace OneCloud.S3.API.Extensions; + +public static class EndpointsExtensions +{ + public static IApplicationBuilder UseStorageEndpoints(this IApplicationBuilder builder) + { + var app = builder as WebApplication ?? throw new NullReferenceException(); + + app.MapGroup("api").WithOpenApi() + .UseStorageEndPoints() + .UseUsersEndPoints() + .UseObjectsEndpoints() + .UseBucketsEndpoints(); + + return app; + } +} diff --git a/OneCloud.S3.API/Extensions/JsonOptionsExtensions.cs b/OneCloud.S3.API/Extensions/JsonOptionsExtensions.cs new file mode 100644 index 0000000..ba8321f --- /dev/null +++ b/OneCloud.S3.API/Extensions/JsonOptionsExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Http.Json; + +namespace OneCloud.S3.API.Extensions; + +public static class JsonOptionsExtensions +{ + public static IServiceCollection AddJsonOptionsConfiguration(this IServiceCollection services) + { + services.Configure(opt => + { + opt.SerializerOptions.PropertyNamingPolicy = null; + opt.SerializerOptions.DictionaryKeyPolicy = null; + }); + + return services; + } +} diff --git a/OneCloud.S3.API/Extensions/SecurityHeadersExtensions.cs b/OneCloud.S3.API/Extensions/SecurityHeadersExtensions.cs new file mode 100644 index 0000000..27e9e82 --- /dev/null +++ b/OneCloud.S3.API/Extensions/SecurityHeadersExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace OneCloud.S3.API.Extensions; + +public static class SecurityHeadersExtensions +{ + public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app) + { + app.Use(async (context, next) => + { + context.Response.Headers.Add(new KeyValuePair(HeaderNames.XXSSProtection, "1; mode=block")); + context.Response.Headers.Add(new KeyValuePair(HeaderNames.ContentSecurityPolicy, "default-src 'none'; script-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'")); + context.Response.Headers.Add(new KeyValuePair(HeaderNames.XFrameOptions, "DENY")); + context.Response.Headers.Add(new KeyValuePair(HeaderNames.XContentTypeOptions, "nosniff")); + context.Response.Headers.Add(new KeyValuePair(HeaderNames.StrictTransportSecurity, "max-age=31536000;includeSubDomains;preload")); + context.Response.Headers.Add(new KeyValuePair(HeaderNames.Server, AppDomain.CurrentDomain.FriendlyName)); + await next(); + }); + + return app; + } +} diff --git a/OneCloud.S3.API/Extensions/StorageClientsExtensions.cs b/OneCloud.S3.API/Extensions/StorageClientsExtensions.cs new file mode 100644 index 0000000..8577a3d --- /dev/null +++ b/OneCloud.S3.API/Extensions/StorageClientsExtensions.cs @@ -0,0 +1,35 @@ +using Amazon.S3; +using Microsoft.Net.Http.Headers; + +namespace OneCloud.S3.API.Extensions; + +public static class StorageClientsExtensions +{ + public static IServiceCollection AddStorageClient(this IServiceCollection services, IConfiguration configuration) + { + ArgumentException.ThrowIfNullOrEmpty(configuration["SERVICE_API_URL"]); + ArgumentException.ThrowIfNullOrEmpty(configuration["SERVICE_API_KEY"]); + ArgumentException.ThrowIfNullOrEmpty(configuration["S3_SERVICE_URL"]); + ArgumentException.ThrowIfNullOrEmpty(configuration["S3_ACCESS_KEY"]); + ArgumentException.ThrowIfNullOrEmpty(configuration["S3_SECRET_KEY"]); + + services.AddHttpClient("api", client => + { + client.BaseAddress = new Uri(configuration["SERVICE_API_URL"]!); + client.DefaultRequestHeaders.Add(HeaderNames.Accept, System.Net.Mime.MediaTypeNames.Application.Json); + client.DefaultRequestHeaders.Add(HeaderNames.Authorization, $"Bearer {configuration["SERVICE_API_KEY"]}"); + client.DefaultRequestHeaders.Add(HeaderNames.UserAgent, AppDomain.CurrentDomain.FriendlyName); + }); + + services.AddScoped(_ => new AmazonS3Client( + configuration["S3_ACCESS_KEY"], + configuration["S3_SECRET_KEY"], + new AmazonS3Config + { + ServiceURL = configuration["S3_SERVICE_URL"], + ForcePathStyle = true + })); + + return services; + } +} diff --git a/OneCloud.S3.API/Infrastructure/Interfaces/IStorageBucketRepository.cs b/OneCloud.S3.API/Infrastructure/Interfaces/IStorageBucketRepository.cs deleted file mode 100644 index 2ed2f30..0000000 --- a/OneCloud.S3.API/Infrastructure/Interfaces/IStorageBucketRepository.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Amazon.S3.Model; - -namespace OneCloud.S3.API.Infrastructure.Interfaces; - -/// -/// Buckets repository -/// -public interface IStorageBucketRepository -{ - /// - /// Get list of buckets - /// - /// - /// - Task> ListBucketsAsync(CancellationToken cancellationToken); - - /// - /// Get bucket objects list - /// - /// Bucket name - /// - /// - Task> ListBucketContentAsync(string bucket, CancellationToken cancellationToken); - - /// - /// Create bucket - /// - /// Bucket name - /// - /// - Task PutBucketAsync(string bucket, CancellationToken cancellationToken); - - /// - /// Delete bucket (need to be empty) - /// - /// Bucket name - /// - /// - Task DeleteBucketAsync(string bucket, CancellationToken cancellationToken); -} diff --git a/OneCloud.S3.API/Infrastructure/Interfaces/IStorageObjectRepository.cs b/OneCloud.S3.API/Infrastructure/Interfaces/IStorageObjectRepository.cs deleted file mode 100644 index 58b982c..0000000 --- a/OneCloud.S3.API/Infrastructure/Interfaces/IStorageObjectRepository.cs +++ /dev/null @@ -1,75 +0,0 @@ -namespace OneCloud.S3.API.Infrastructure.Interfaces; - -/// -/// Object repository -/// -public interface IStorageObjectRepository -{ - /// - /// Get object - /// - /// Bucket name - /// Object key - /// - /// - Task GetObjectAsync(string bucket, string filePath, CancellationToken cancellationToken); - - /// - /// Get object and write to file - /// - /// Source bucket name - /// Source object key - /// Target local file path - /// - /// - Task GetObjectToFileAsync(string bucket, string filePath, string localPath, CancellationToken cancellationToken); - - /// - /// Upload object - /// - /// File - /// Bucket name - /// Object key - /// - /// - Task PutObjectAsync(string bucket, string filePath, IFormFile file, CancellationToken cancellationToken); - - /// - /// Delete object - /// - /// Bucket name - /// Object key - /// - /// - Task DeleteObjectAsync(string bucket, string filePath, CancellationToken cancellationToken); - - /// - /// Copy object - /// - /// Source bucket name - /// Source object key - /// Target bucket name - /// Target object key - /// - /// - Task CopyObjectAsync(string srcBucket, string srcFilePath, string destBucket, string destFilePath, CancellationToken cancellationToken); - - /// - /// Get object public link - /// - /// Bucket name - /// Object key - /// Date link expires - /// - string GetPreSignedUrl(string bucket, string filePath, DateTime expires); - - /// - /// Change object permissions - /// - /// Bucket name - /// Object key - /// Is object public - /// - /// - Task PutAclAsync(string bucket, string filePath, bool isPublicRead, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/OneCloud.S3.API/Infrastructure/Interfaces/IStorageRepository.cs b/OneCloud.S3.API/Infrastructure/Interfaces/IStorageRepository.cs deleted file mode 100644 index 7031a7e..0000000 --- a/OneCloud.S3.API/Infrastructure/Interfaces/IStorageRepository.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace OneCloud.S3.API.Infrastructure.Interfaces; - -/// -/// Generalized storage repository -/// -public interface IStorageRepository : IStorageBucketRepository, IStorageObjectRepository; \ No newline at end of file diff --git a/OneCloud.S3.API/Infrastructure/StorageRepository.cs b/OneCloud.S3.API/Infrastructure/StorageRepository.cs deleted file mode 100644 index 76b16f6..0000000 --- a/OneCloud.S3.API/Infrastructure/StorageRepository.cs +++ /dev/null @@ -1,174 +0,0 @@ -using Amazon.S3; -using Amazon.S3.Model; -using OneCloud.S3.API.Infrastructure.Interfaces; - -namespace OneCloud.S3.API.Infrastructure; - -public class StorageRepository : IStorageRepository -{ - private readonly IAmazonS3 _client; - - public StorageRepository(IConfiguration configuration) - { - ArgumentNullException.ThrowIfNull(configuration, nameof(configuration)); - ArgumentException.ThrowIfNullOrEmpty(configuration["S3_ACCESS_KEY"]); - ArgumentException.ThrowIfNullOrEmpty(configuration["S3_SECRET_KEY"]); - ArgumentException.ThrowIfNullOrEmpty(configuration["S3_SERVICE_URL"]); - - _client = new AmazonS3Client(configuration["S3_ACCESS_KEY"], configuration["S3_SECRET_KEY"], new AmazonS3Config - { - ServiceURL = configuration["S3_SERVICE_URL"], - ForcePathStyle = true, // HACK: Don't work without this property! - }); - } - - #region IStorageBucketRepository - - public async Task> ListBucketsAsync(CancellationToken cancellationToken) - { - var result = await _client.ListBucketsAsync(cancellationToken); - return result.Buckets; - } - - public async Task> ListBucketContentAsync(string bucket, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrEmpty(bucket, nameof(bucket)); - - var request = new ListObjectsRequest { BucketName = bucket }; - var response = await _client.ListObjectsAsync(request, cancellationToken); - return response.S3Objects; - } - - public async Task PutBucketAsync(string bucket, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrEmpty(bucket, nameof(bucket)); - - var request = new PutBucketRequest { BucketName = bucket }; - var response = await _client.PutBucketAsync(request, cancellationToken); - return response.HttpStatusCode == System.Net.HttpStatusCode.OK; - } - - public async Task DeleteBucketAsync(string bucket, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrEmpty(bucket, nameof(bucket)); - var request = new DeleteBucketRequest { BucketName = bucket }; - var response = await _client.DeleteBucketAsync(request, cancellationToken); - return response.HttpStatusCode == System.Net.HttpStatusCode.OK; - } - - #endregion - - #region IStorageObjectRepository - - public async Task GetObjectAsync(string bucket, string objectKey, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrEmpty(bucket, nameof(bucket)); - ArgumentException.ThrowIfNullOrEmpty(objectKey, nameof(objectKey)); - - var request = new GetObjectRequest { BucketName = bucket, Key = objectKey }; - var response = await _client.GetObjectAsync(request, cancellationToken); - return response.ResponseStream; - } - - public async Task GetObjectToFileAsync(string bucket, string objectKey, string localFilePath, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrEmpty(bucket, nameof(bucket)); - ArgumentException.ThrowIfNullOrEmpty(objectKey, nameof(objectKey)); - ArgumentException.ThrowIfNullOrEmpty(localFilePath, nameof(localFilePath)); - if(!Path.IsPathFullyQualified(localFilePath)) - throw new ArgumentException("Path must be Qualified", nameof(localFilePath)); - - var request = new GetObjectRequest { BucketName = bucket, Key = objectKey }; - var response = await _client.GetObjectAsync(request, cancellationToken); - await response.WriteResponseStreamToFileAsync(localFilePath, true, cancellationToken); - return true; - } - - public async Task PutObjectAsync(string bucket, string objectKey, IFormFile file, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrEmpty(bucket, nameof(bucket)); - ArgumentException.ThrowIfNullOrEmpty(objectKey, nameof(objectKey)); - ArgumentNullException.ThrowIfNull(file, nameof(file)); - - await using var stream = file.OpenReadStream(); - - var request = new PutObjectRequest - { - BucketName = bucket, - Key = objectKey, - ContentType = file.ContentType, - InputStream = stream, - AutoCloseStream = true, - UseChunkEncoding = false, - }; - - var response = await _client.PutObjectAsync(request, cancellationToken); - return response.HttpStatusCode == System.Net.HttpStatusCode.OK; - } - - public async Task DeleteObjectAsync(string bucket, string filePath, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrEmpty(bucket, nameof(bucket)); - ArgumentException.ThrowIfNullOrEmpty(filePath, nameof(filePath)); - - var request = new DeleteObjectRequest { BucketName = bucket, Key = filePath }; - var response = await _client.DeleteObjectAsync(request, cancellationToken); - return response.HttpStatusCode == System.Net.HttpStatusCode.OK; - } - - public async Task CopyObjectAsync(string srcBucket, string srcFilePath, string destBucket, string destFilePath, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrEmpty(srcBucket, nameof(srcBucket)); - ArgumentException.ThrowIfNullOrEmpty(srcFilePath, nameof(srcFilePath)); - ArgumentException.ThrowIfNullOrEmpty(destBucket, nameof(destBucket)); - ArgumentException.ThrowIfNullOrEmpty(destFilePath, nameof(destFilePath)); - - var request = new CopyObjectRequest - { - SourceBucket = srcBucket, - SourceKey = srcFilePath, - DestinationBucket = destBucket, - DestinationKey = destFilePath, - }; - - var response = await _client.CopyObjectAsync(request, cancellationToken); - return response.HttpStatusCode == System.Net.HttpStatusCode.OK; - } - - public string GetPreSignedUrl(string bucket, string filePath, DateTime expires) - { - ArgumentException.ThrowIfNullOrEmpty(bucket, nameof(bucket)); - ArgumentException.ThrowIfNullOrEmpty(filePath, nameof(filePath)); - - var request = new GetPreSignedUrlRequest - { - BucketName = bucket, - Key = filePath, - Expires = expires, - Protocol = Protocol.HTTPS - }; - - var response = _client.GetPreSignedURL(request); - return response; - } - - public async Task PutAclAsync(string bucket, string filePath, bool isPublicRead, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrEmpty(bucket, nameof(bucket)); - ArgumentException.ThrowIfNullOrEmpty(filePath, nameof(filePath)); - - var request = new PutACLRequest - { - BucketName = bucket, - Key = filePath, - CannedACL = isPublicRead - ? S3CannedACL.PublicRead - : S3CannedACL.Private, - }; - - var response = await _client.PutACLAsync(request, cancellationToken); - return response.HttpStatusCode == System.Net.HttpStatusCode.OK; - } - - #endregion -} \ No newline at end of file diff --git a/OneCloud.S3.API/Models/BucketDto.cs b/OneCloud.S3.API/Models/BucketDto.cs new file mode 100644 index 0000000..26dbd5c --- /dev/null +++ b/OneCloud.S3.API/Models/BucketDto.cs @@ -0,0 +1,3 @@ +namespace OneCloud.S3.API.Models; + +public record BucketDto(string BucketName, DateTime CreationDate); diff --git a/OneCloud.S3.API/Models/Dto/BucketDto.cs b/OneCloud.S3.API/Models/Dto/BucketDto.cs deleted file mode 100644 index aa9f447..0000000 --- a/OneCloud.S3.API/Models/Dto/BucketDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace OneCloud.S3.API.Models.Dto; - -public class BucketDto -{ - public string BucketName { get; set; } = null!; - public DateTime CreationDate { get; set; } -} diff --git a/OneCloud.S3.API/Models/Dto/ObjectDto.cs b/OneCloud.S3.API/Models/Dto/ObjectDto.cs deleted file mode 100644 index da2ccc4..0000000 --- a/OneCloud.S3.API/Models/Dto/ObjectDto.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace OneCloud.S3.API.Models.Dto; - -public class ObjectDto -{ - public string BucketName { get; set; } = null!; - public string Key { get; set; } = null!; - public string ETag { get; set; } = null!; - public DateTime LastModified { get; set; } - public long Size { get; set; } -} diff --git a/OneCloud.S3.API/Models/Dto/ObjectUrlDto.cs b/OneCloud.S3.API/Models/Dto/ObjectUrlDto.cs deleted file mode 100644 index 2881fe1..0000000 --- a/OneCloud.S3.API/Models/Dto/ObjectUrlDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace OneCloud.S3.API.Models.Dto; - -public class ObjectUrlDto -{ - public string Url { get; set; } = null!; - public DateTime Expires { get; set; } -} diff --git a/OneCloud.S3.API/Models/ObjectDto.cs b/OneCloud.S3.API/Models/ObjectDto.cs new file mode 100644 index 0000000..6e32432 --- /dev/null +++ b/OneCloud.S3.API/Models/ObjectDto.cs @@ -0,0 +1,3 @@ +namespace OneCloud.S3.API.Models; + +public record ObjectDto(string BucketName, string Key, string ETag, DateTime LastModified, long Size); diff --git a/OneCloud.S3.API/Models/ObjectUrlDto.cs b/OneCloud.S3.API/Models/ObjectUrlDto.cs new file mode 100644 index 0000000..aa088f4 --- /dev/null +++ b/OneCloud.S3.API/Models/ObjectUrlDto.cs @@ -0,0 +1,3 @@ +namespace OneCloud.S3.API.Models; + +public record ObjectUrlDto(string Url, DateTime Expires); diff --git a/OneCloud.S3.API/Models/StorageApiDto.cs b/OneCloud.S3.API/Models/StorageApiDto.cs new file mode 100644 index 0000000..c6f3639 --- /dev/null +++ b/OneCloud.S3.API/Models/StorageApiDto.cs @@ -0,0 +1,3 @@ +namespace OneCloud.S3.API.Models; + +public record StorageApiDto(int Id, string ExternalId, string Name, string Password, object SwiftApiConnection, object S3Connection, object FtpConnection, string State, object Tasks); diff --git a/OneCloud.S3.API/OneCloud.S3.API.csproj b/OneCloud.S3.API/OneCloud.S3.API.csproj index 551bc7b..6817a78 100644 --- a/OneCloud.S3.API/OneCloud.S3.API.csproj +++ b/OneCloud.S3.API/OneCloud.S3.API.csproj @@ -1,7 +1,7 @@  - net7.0 + net8.0 OneCloud.S3.API 39db8a47-cc3a-459f-a9c5-b7a938ca41ff @@ -9,8 +9,12 @@ Container true - mcr.microsoft.com/dotnet/nightly/aspnet:7.0-bookworm-slim - 1.0.0;latest + mcr.microsoft.com/dotnet/runtime-deps:8.0-jammy-chiseled + linux-x64 + 2.1.0;latest + true + true + true @@ -20,18 +24,20 @@ + + - + - + - - + + diff --git a/OneCloud.S3.API/Program.cs b/OneCloud.S3.API/Program.cs index aa2c7c3..851271c 100644 --- a/OneCloud.S3.API/Program.cs +++ b/OneCloud.S3.API/Program.cs @@ -1,17 +1,16 @@ -using OneCloud.S3.API.Infrastructure; -using OneCloud.S3.API.Infrastructure.Interfaces; +using OneCloud.S3.API.Extensions; +using System.Net.Mime; +using System.Text; var builder = WebApplication.CreateBuilder(args); + builder.WebHost.CaptureStartupErrors(true); // Add services to the container. builder.Services.AddProblemDetails(); - -builder.Services - .AddScoped() - .AddScoped(); - -builder.Services.AddControllers(); +builder.Services.AddResponseCompression(); +builder.Services.AddJsonOptionsConfiguration(); +builder.Services.AddStorageClient(builder.Configuration); if(builder.Environment.IsDevelopment() || builder.Environment.IsStaging()) { @@ -22,6 +21,9 @@ var app = builder.Build(); // Configure the HTTP request pipeline. +app.UseResponseCompression(); +app.UseSecurityHeaders(); + if(app.Environment.IsDevelopment() || app.Environment.IsStaging()) { @@ -36,6 +38,19 @@ app.UseStatusCodePages(); -app.MapControllers(); +if(app.Environment.IsDevelopment() || app.Environment.IsStaging()) +{ + app.MapGet("/", () => + Results.Redirect("/swagger/", false, false)) + .ExcludeFromDescription(); +} +else +{ + app.MapGet("/", () => + Results.Content("Service is healthy", MediaTypeNames.Text.Plain, Encoding.UTF8, StatusCodes.Status200OK)) + .ExcludeFromDescription(); +} + +app.UseStorageEndpoints(); app.Run(); diff --git a/OneCloud.S3.API/Properties/launchSettings.json b/OneCloud.S3.API/Properties/launchSettings.json index f5733a3..75bcae5 100644 --- a/OneCloud.S3.API/Properties/launchSettings.json +++ b/OneCloud.S3.API/Properties/launchSettings.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { - "OneCloud.S3.API Development": { + "http": { "commandName": "Project", "launchBrowser": true, "launchUrl": "swagger", @@ -9,9 +9,11 @@ "ASPNETCORE_ENVIRONMENT": "Development", "S3_SERVICE_URL": "https://1cloud.store", "S3_ACCESS_KEY": "", - "S3_SECRET_KEY": "" + "S3_SECRET_KEY": "", + "SERVICE_API_URL": "https://api.1cloud.ru", + "SERVICE_API_KEY": "" }, - "applicationUrl": "http://+:5212", + "applicationUrl": "http://localhost:5212", "dotnetRunMessages": true } } diff --git a/README.md b/README.md index dd570af..fde46f9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 1cloud S3 API -An example of interaction with cloud object storage from [1cloud.ru](https://1cloud.ru/ref/339507) using the Amazon S3 protocol, using C# and .NET 7. +Easy to use REST API Gateway for [1cloud.ru](https://1cloud.ru/ref/339507) object storage (S3). Based on Microsoft.NET.Sdk.Web, Amazon.S3.SDK, Swagger, OpenApi. ## Configuration @@ -8,8 +8,10 @@ An example of interaction with cloud object storage from [1cloud.ru](https://1cl ``` S3_SERVICE_URL=https://1cloud.store -S3_ACCESS_KEY={YourAccesKey} +S3_ACCESS_KEY={YourAccessKey} S3_SECRET_KEY={YourSecretKey} +SERVICE_API_URL=https://api.1cloud.ru", +SERVICE_API_KEY={YourApiKey} ``` ### Local development in IDE @@ -20,16 +22,26 @@ Configure Environment variables at  `"environmentVariables"` section in file ` "environmentVariables": {     "ASPNETCORE_ENVIRONMENT": "Development",     "S3_SERVICE_URL": "https://1cloud.store", -    "S3_ACCESS_KEY": "{YourDevAccesKey}", -    "S3_SECRET_KEY": "{YourDevSecretKey}" +    "S3_ACCESS_KEY": "{YourAccessKey}", +    "S3_SECRET_KEY": "{YourSecretKey}", + "SERVICE_API_URL": "https://api.1cloud.ru", + "SERVICE_API_KEY": "{YourApiKey}" } ``` -## Docker image (linux-x64) +## Docker image -| Container image repository | Last Build | -| -- | -- | -| [ghcr.io/hackuna/one-cloud-s3-api](https://github.com/hackuna/one-cloud-s3-api/pkgs/container/one-cloud-s3-api) | [![Build & Publish](https://github.com/hackuna/one-cloud-s3-api/actions/workflows/dotnet.yml/badge.svg)](https://github.com/hackuna/one-cloud-s3-api/actions/workflows/dotnet.yml) | +Based on [dotnet/runtime-deps:8.0-jammy-chiseled](https://mcr.microsoft.com/en-us/product/dotnet/runtime-deps/about) (Ubuntu 22.04, ×64) + +### Build container image command + +``` +dotnet publish --os linux --arch x64 -p:PublishProfile=DefaultContainer -c Release --self-contained true -p:PublishSingleFile=true +``` + +| Container Image | Last Build | [CodeQL](https://codeql.github.com/) | Size | +| -- | -- | -- | -- | +| 🐳 [ghcr.io/hackuna/one-cloud-s3-api:latest](https://github.com/hackuna/one-cloud-s3-api/pkgs/container/one-cloud-s3-api) | [![Build & Publish](https://github.com/hackuna/one-cloud-s3-api/actions/workflows/dotnet.yml/badge.svg)](https://github.com/hackuna/one-cloud-s3-api/actions/workflows/dotnet.yml) | [![CodeQL](https://github.com/hackuna/one-cloud-s3-api/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/hackuna/one-cloud-s3-api/actions/workflows/github-code-scanning/codeql) | 51.11 Mb | Pull image command: @@ -45,11 +57,13 @@ docker run -d \ --name storage-api \ -p 5000:8080 \ --volume=storage-secrets:/root/.aspnet/DataProtection-Keys \ ---env=ASPNETCORE_ENVIRONMENT=Production \ +--env=ASPNETCORE_ENVIRONMENT=Development \ --env=ASPNETCORE_URLS=http://+:8080 \ --env=S3_SERVICE_URL=https://1cloud.store \ ---env=S3_ACCESS_KEY={YourAccesKey} \ +--env=S3_ACCESS_KEY={YourAccessKey} \ --env=S3_SECRET_KEY={YourSecretKey} \ +--env=SERVICE_API_URL=https://api.1cloud.ru \ +--env=SERVICE_API_KEY={YourApiKey} \ ghcr.io/hackuna/one-cloud-s3-api:latest ``` @@ -63,4 +77,4 @@ Remove container command: ``` docker rm storage-api -``` \ No newline at end of file +``` diff --git a/one-cloud-s3-api.sln b/one-cloud-s3-api.sln index f962193..463872a 100644 --- a/one-cloud-s3-api.sln +++ b/one-cloud-s3-api.sln @@ -10,8 +10,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .gitattributes = .gitattributes .gitignore = .gitignore Directory.Build.props = Directory.Build.props - LICENSE.txt = LICENSE.txt Directory.Packages.props = Directory.Packages.props + LICENSE.txt = LICENSE.txt README.md = README.md EndProjectSection EndProject