diff --git a/Directory.Packages.props b/Directory.Packages.props
index f4732ee..a2b4869 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,6 +8,7 @@
+
diff --git a/src/Application/AzureBlobStorage/Constants/CommonFileNames.cs b/src/Application/AzureBlobStorage/Constants/CommonFileNames.cs
new file mode 100644
index 0000000..7eabb9a
--- /dev/null
+++ b/src/Application/AzureBlobStorage/Constants/CommonFileNames.cs
@@ -0,0 +1,6 @@
+namespace Hutopy.Application.AzureBlobStorage.Constants;
+
+public static class CommonFileNames
+{
+ public static string ProfilePicture = "profilePicture";
+}
diff --git a/src/Application/AzureBlobStorage/Constants/ContainerNames.cs b/src/Application/AzureBlobStorage/Constants/ContainerNames.cs
new file mode 100644
index 0000000..5bab776
--- /dev/null
+++ b/src/Application/AzureBlobStorage/Constants/ContainerNames.cs
@@ -0,0 +1,6 @@
+namespace Hutopy.Application.AzureBlobStorage.Constants;
+
+public static class ContainerNames
+{
+ public static string Users = "users";
+}
diff --git a/src/Application/AzureBlobStorage/Constants/SubDirectoryNames.cs b/src/Application/AzureBlobStorage/Constants/SubDirectoryNames.cs
new file mode 100644
index 0000000..6ee43ea
--- /dev/null
+++ b/src/Application/AzureBlobStorage/Constants/SubDirectoryNames.cs
@@ -0,0 +1,7 @@
+namespace Hutopy.Application.AzureBlobStorage.Constants;
+
+public static class SubDirectoryNames
+{
+ public static string Profile = "profile";
+ public static string Posts = "posts";
+}
diff --git a/src/Application/Common/Interfaces/IAzureBlobStorageService.cs b/src/Application/Common/Interfaces/IAzureBlobStorageService.cs
new file mode 100644
index 0000000..c8bdd20
--- /dev/null
+++ b/src/Application/Common/Interfaces/IAzureBlobStorageService.cs
@@ -0,0 +1,7 @@
+namespace Hutopy.Application.Common.Interfaces;
+
+public interface IAzureBlobStorageService
+{
+ Task UploadFileAsync(string containerName, string blobName, Stream fileStream);
+ Task DownloadFileAsync(string containerName, string blobName);
+}
diff --git a/src/Application/Users/Commands/UploadProfilePicture.cs b/src/Application/Users/Commands/UploadProfilePicture.cs
new file mode 100644
index 0000000..08746d9
--- /dev/null
+++ b/src/Application/Users/Commands/UploadProfilePicture.cs
@@ -0,0 +1,25 @@
+using Hutopy.Application.AzureBlobStorage.Constants;
+using Hutopy.Application.Common.Interfaces;
+
+namespace Hutopy.Application.Users.Commands;
+
+public class UploadProfilePictureCommand : IRequest
+{
+ public required Stream ProfilePicture { get; init; }
+}
+
+public class UploadProfilePictureCommandHandler(IIdentityService identityService, IAzureBlobStorageService azureBlobStorageService) : IRequestHandler
+{
+ public async Task Handle(UploadProfilePictureCommand request, CancellationToken cancellationToken)
+ {
+ var identityUser = await identityService.GetCurrentUserAsync();
+ var currentUserId = new Guid(identityUser?.Id ?? "").ToString();
+
+ var blobName = $"{currentUserId}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}";
+
+ var url = await azureBlobStorageService.UploadFileAsync(ContainerNames.Users, blobName, request.ProfilePicture);
+
+ return url;
+ }
+}
+
diff --git a/src/Application/Users/Queries/GetCurrentUser/GetCurrentUserProfilePicture.cs b/src/Application/Users/Queries/GetCurrentUser/GetCurrentUserProfilePicture.cs
new file mode 100644
index 0000000..cb4e6b4
--- /dev/null
+++ b/src/Application/Users/Queries/GetCurrentUser/GetCurrentUserProfilePicture.cs
@@ -0,0 +1,23 @@
+using Hutopy.Application.AzureBlobStorage.Constants;
+using Hutopy.Application.Common.Interfaces;
+
+namespace Hutopy.Application.Users.Queries.GetCurrentUser;
+
+public record GetCurrentUserProfilePictureQuery : IRequest;
+
+public class GetCurrentUserProfilePictureQueryHandler(
+ IIdentityService identityService,
+ IAzureBlobStorageService azureBlobStorageService
+ )
+ : IRequestHandler
+{
+ public async Task Handle(GetCurrentUserProfilePictureQuery request, CancellationToken cancellationToken)
+ {
+ var identityUser = await identityService.GetCurrentUserAsync();
+ var currentUserId = new Guid(identityUser?.Id ?? "");
+
+ var blobName = $"{currentUserId.ToString()}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}";
+
+ return await azureBlobStorageService.DownloadFileAsync(ContainerNames.Users, blobName);
+ }
+}
diff --git a/src/Application/Users/Queries/GetCurrentUser/UserDto.cs b/src/Application/Users/Queries/GetCurrentUser/UserDto.cs
index 0e2af25..c7d121d 100644
--- a/src/Application/Users/Queries/GetCurrentUser/UserDto.cs
+++ b/src/Application/Users/Queries/GetCurrentUser/UserDto.cs
@@ -8,7 +8,6 @@ public class UserDto
public string UserName { get; init; } = String.Empty;
public List UserTransactions { get; init; } = [];
public IList UserRoles { get; init; } = [];
-
public required decimal TotalBalance { get; init; }
}
diff --git a/src/Infrastructure/AzureBlob/AzureBlobStorageService.cs b/src/Infrastructure/AzureBlob/AzureBlobStorageService.cs
new file mode 100644
index 0000000..f54f96b
--- /dev/null
+++ b/src/Infrastructure/AzureBlob/AzureBlobStorageService.cs
@@ -0,0 +1,74 @@
+using Azure.Storage.Blobs;
+using Azure.Storage.Blobs.Models;
+using Hutopy.Application.Common.Interfaces;
+using Microsoft.Extensions.Configuration;
+
+namespace Hutopy.Infrastructure.AzureBlob;
+
+public class AzureBlobStorageService : IAzureBlobStorageService
+{
+ private readonly BlobServiceClient _blobServiceClient;
+
+ public AzureBlobStorageService(IConfiguration configuration)
+ {
+ var connectionString = configuration["Azure-Blob-Connection-String"] ?? "";
+ _blobServiceClient = new BlobServiceClient(connectionString);
+
+ }
+
+ ///
+ /// Upload a file to microsoft azure blob storage.
+ ///
+ /// The blob name (path within the container, include the file name).
+ /// The name of the container where the file is stored.
+ /// The stream.
+ ///
+ public async Task UploadFileAsync(string containerName, string blobName, Stream fileStream)
+ {
+ // Get a reference to a container
+ var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
+
+ // Create the container if it does not exist
+ await containerClient.CreateIfNotExistsAsync();
+
+ // Get a reference to a blob
+ var blobClient = containerClient.GetBlobClient(blobName);
+
+ // Upload the file
+ await blobClient.UploadAsync(fileStream, true);
+
+ // Return the URI of the uploaded blob
+ return blobClient.Uri.ToString();
+ }
+
+ ///
+ /// Download a file to microsoft azure blob storage.
+ ///
+ /// The blob name (path within the container).
+ /// The name of the container where the file is stored. (users)
+ ///
+ public async Task DownloadFileAsync(string containerName, string blobName)
+ {
+ // Get a reference to a container
+ var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
+
+ // Get a reference to a blob
+ var blobClient = containerClient.GetBlobClient(blobName);
+
+ try
+ {
+ // Download the blob to a stream
+ BlobDownloadInfo download = await blobClient.DownloadAsync();
+ MemoryStream memoryStream = new();
+ await download.Content.CopyToAsync(memoryStream);
+ memoryStream.Position = 0; // Ensure the stream is at the beginning
+
+ return memoryStream;
+ }
+ catch (Exception ex)
+ {
+ // Log and handle the exception as needed
+ throw new ApplicationException("Error downloading file from Azure Blob Storage.", ex);
+ }
+ }
+}
diff --git a/src/Infrastructure/AzureBlob/BlobStructure.txt b/src/Infrastructure/AzureBlob/BlobStructure.txt
new file mode 100644
index 0000000..5410c95
--- /dev/null
+++ b/src/Infrastructure/AzureBlob/BlobStructure.txt
@@ -0,0 +1,33 @@
+users/
+│
+├── userId1/
+│ ├── profile/
+│ │ └── profilePicture.jpg
+│ │ └── data.json
+│ │
+│ ├── posts/
+│ │ ├── post1/
+│ │ │ ├── image1.jpg
+│ │ │ ├── video1.mp4
+│ │ │ └── audio1.mp3
+│ │ ├── post2/
+│ │ │ ├── image2.jpg
+│ │ │ └── video2.mp4
+│ │ └── ...
+│
+├── userId2/
+│ ├── profile/
+│ │ └── profilePicture.jpg
+│ │ └── data.json
+│ │
+│ ├── posts/
+│ │ ├── post1/
+│ │ │ ├── image1.jpg
+│ │ │ ├── video1.mp4
+│ │ │ └── audio1.mp3
+│ │ ├── post2/
+│ │ │ ├── image2.jpg
+│ │ │ └── video2.mp4
+│ │ └── ...
+│
+└── ...
diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs
index 42ca0c3..e03ce4c 100644
--- a/src/Infrastructure/DependencyInjection.cs
+++ b/src/Infrastructure/DependencyInjection.cs
@@ -1,5 +1,6 @@
using Hutopy.Application.Common.Interfaces;
using Hutopy.Domain.Constants;
+using Hutopy.Infrastructure.AzureBlob;
using Hutopy.Infrastructure.Data;
using Hutopy.Infrastructure.Data.Interceptors;
using Hutopy.Infrastructure.Identity;
@@ -63,8 +64,14 @@ public static class DependencyInjection
.AddSignInManager>()
.AddDefaultTokenProviders();
+ // Singleton services
services.AddSingleton(TimeProvider.System);
+ services.AddSingleton();
+
+ // Scoped services
services.AddScoped();
+
+ // Transient services
services.AddTransient();
services.AddAuthorization(options =>
diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj
index 108c958..dd3cb64 100644
--- a/src/Infrastructure/Infrastructure.csproj
+++ b/src/Infrastructure/Infrastructure.csproj
@@ -4,6 +4,7 @@
Hutopy.Infrastructure
+
diff --git a/src/Web/Endpoints/GetMyUser.cs b/src/Web/Endpoints/GetMyUser.cs
index feb5b50..444cbe9 100644
--- a/src/Web/Endpoints/GetMyUser.cs
+++ b/src/Web/Endpoints/GetMyUser.cs
@@ -9,11 +9,17 @@ public class GetMyUser : EndpointGroupBase
{
app.MapGroup(this)
.RequireAuthorization()
- .MapGet(GetCurrentUser);
+ .MapGet(GetCurrentUser)
+ .MapGet(GetCurrentUserProfilePicture, "profile-picture");
}
private static async Task GetCurrentUser(ISender sender, [AsParameters] GetCurrentUserQuery query)
{
return await sender.Send(query);
}
+
+ private static async Task GetCurrentUserProfilePicture(ISender sender, [AsParameters] GetCurrentUserProfilePictureQuery query)
+ {
+ return await sender.Send(query);
+ }
}
diff --git a/src/Web/Endpoints/Users.cs b/src/Web/Endpoints/Users.cs
index 39f784a..b2a15c5 100644
--- a/src/Web/Endpoints/Users.cs
+++ b/src/Web/Endpoints/Users.cs
@@ -10,6 +10,7 @@ public class Users : EndpointGroupBase
app.MapGroup(this)
.MapPost(CreateUser)
.MapPost(Login, "/login")
+ .MapPost(UploadProfilePicture, "/upload-profile-picture")
.MapGet(GetMinimalUser);
}
@@ -27,4 +28,13 @@ public class Users : EndpointGroupBase
{
return await sender.Send(command);
}
+
+ private static async Task UploadProfilePicture(ISender sender, Stream stream)
+ {
+ var command = new UploadProfilePictureCommand
+ {
+ ProfilePicture = stream
+ };
+ return await sender.Send(command);
+ }
}
diff --git a/src/Web/wwwroot/api/specification.json b/src/Web/wwwroot/api/specification.json
index 93ac3dc..bf1f109 100644
--- a/src/Web/wwwroot/api/specification.json
+++ b/src/Web/wwwroot/api/specification.json
@@ -31,6 +31,56 @@
]
}
},
+ "/api/GetMyUser/profile-picture": {
+ "get": {
+ "tags": [
+ "GetMyUser"
+ ],
+ "operationId": "GetCurrentUserProfilePicture",
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Stream"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "JWT": []
+ }
+ ]
+ }
+ },
+ "/api/GetMyUser/profile-picture-2": {
+ "patch": {
+ "tags": [
+ "GetMyUser"
+ ],
+ "operationId": "PatchApiGetMyUserProfilePicture2",
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Stream"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "JWT": []
+ }
+ ]
+ }
+ },
"/api/JoinUs": {
"get": {
"tags": [
@@ -313,6 +363,40 @@
}
}
},
+ "/api/Users/upload-profile-picture": {
+ "post": {
+ "tags": [
+ "Users"
+ ],
+ "operationId": "UploadProfilePicture",
+ "requestBody": {
+ "x-name": "stream",
+ "content": {
+ "application/octet-stream": {
+ "schema": {
+ "type": "string",
+ "format": "binary",
+ "nullable": false
+ }
+ }
+ },
+ "required": true,
+ "x-position": 1
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/WeatherForecasts": {
"get": {
"tags": [
@@ -448,6 +532,53 @@
}
}
},
+ "Stream": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/MarshalByRefObject"
+ },
+ {
+ "type": "object",
+ "x-abstract": true,
+ "additionalProperties": false,
+ "properties": {
+ "canRead": {
+ "type": "boolean"
+ },
+ "canWrite": {
+ "type": "boolean"
+ },
+ "canSeek": {
+ "type": "boolean"
+ },
+ "canTimeout": {
+ "type": "boolean"
+ },
+ "length": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "position": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "readTimeout": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "writeTimeout": {
+ "type": "integer",
+ "format": "int32"
+ }
+ }
+ }
+ ]
+ },
+ "MarshalByRefObject": {
+ "type": "object",
+ "x-abstract": true,
+ "additionalProperties": false
+ },
"PaginatedListOfFutureCreatorListDto": {
"type": "object",
"additionalProperties": false,