From c7cc39e914ed801aa3ef28cddb84d88d635170b6 Mon Sep 17 00:00:00 2001 From: Moonbase Date: Fri, 14 Nov 2025 13:32:16 -0800 Subject: [PATCH] Initial S3 Implementation --- qtc-net-server/Program.cs | 2 + .../Services/BucketService/BucketService.cs | 127 ++++++++++++++++++ .../Services/BucketService/IBucketService.cs | 14 ++ .../Services/UserService/UserService.cs | 87 ++++++++---- qtc-net-server/appsettings.json | 8 ++ qtc-net-server/qtc-net-server.csproj | 1 + 6 files changed, 213 insertions(+), 26 deletions(-) create mode 100644 qtc-net-server/Services/BucketService/BucketService.cs create mode 100644 qtc-net-server/Services/BucketService/IBucketService.cs diff --git a/qtc-net-server/Program.cs b/qtc-net-server/Program.cs index 66f9273..00b6e58 100644 --- a/qtc-net-server/Program.cs +++ b/qtc-net-server/Program.cs @@ -18,6 +18,7 @@ using qtc_api.Services.CurrencyGamesService; using qtc_api.Services.GameRoomService; using qtc_api.Services.StoreService; using qtc_api.Services.EmailService; +using qtc_api.Services.BucketService; var builder = WebApplication.CreateBuilder(args); @@ -66,6 +67,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/qtc-net-server/Services/BucketService/BucketService.cs b/qtc-net-server/Services/BucketService/BucketService.cs new file mode 100644 index 0000000..3dcf41f --- /dev/null +++ b/qtc-net-server/Services/BucketService/BucketService.cs @@ -0,0 +1,127 @@ +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using System.Threading.Tasks; + +namespace qtc_api.Services.BucketService +{ + public class BucketService : IBucketService + { + public string ServiceUrl { get; private set; } + public string AccessKey { get; private set; } + public string SecretKey { get; private set; } + public string ProfileImagesBucketName { get; private set; } + public string ImagesBucketName { get; private set; } + + private static AmazonS3Client S3Client; + private IConfiguration _config; + private ILogger _logger; + + private bool Enabled { get; set; } + public BucketService(IConfiguration config, ILogger logger) + { + _config = config; + _logger = logger; + + var serviceUrl = _config["S3Config:S3ServiceUrl"]; + var accessKey = _config["S3Config:S3AccessKey"]; + var secretKey = _config["S3Config:S3SecretKey"]; + var profileImagesBucket = _config["S3Config:S3ProfileImagesBucket"]; + var imagesBucket = _config["S3Config:S3ImagesBucket"]; + var enabled = _config.GetValue("S3Config:S3Enabled"); + + if (serviceUrl != null) ServiceUrl = serviceUrl; + if (accessKey != null) AccessKey = accessKey; + if (secretKey != null) SecretKey = secretKey; + if (profileImagesBucket != null) ProfileImagesBucketName = profileImagesBucket; + if (imagesBucket != null) ImagesBucketName = imagesBucket; + + Enabled = enabled; + + var credentials = new BasicAWSCredentials(AccessKey, SecretKey); + S3Client = new AmazonS3Client(credentials, new AmazonS3Config + { + ServiceURL = ServiceUrl, + }); + _logger = logger; + } + + public async Task PutProfileImage(string userId, string imageName, byte[] imageBytes) + { + if (!Enabled) + { + _logger.LogWarning("Not Using S3 Bucket. Performance May Be Degraded."); + return false; + } + + try + { + using var stream = new MemoryStream(imageBytes); + var request = new PutObjectRequest() + { + Key = $@"{userId}/{imageName}", + BucketName = ProfileImagesBucketName, + InputStream = stream, + DisablePayloadSigning = true, + DisableDefaultChecksumValidation = true + }; + + var response = await S3Client.PutObjectAsync(request); + return response.HttpStatusCode == System.Net.HttpStatusCode.OK || response.HttpStatusCode == System.Net.HttpStatusCode.Accepted; + } catch (AmazonS3Exception ex) + { + _logger.LogError($"S3 Bucket Threw An Exception\n{ex.Message}\n{ex.StackTrace}"); + return false; + } + } + + public async Task GetProfileImageBytes(string userId, string imageName) + { + if (!Enabled) + { + _logger.LogWarning("Not Using S3 Bucket. Performance May Be Degraded."); + return default; + } + + try + { + var response = await S3Client.GetObjectAsync(ProfileImagesBucketName, $@"{userId}/{imageName}"); + + if (response != null) + { + using (var stream = response.ResponseStream) + using (var ms = new MemoryStream()) + { + stream.CopyTo(ms); + return ms.ToArray(); + } + } + else return default; + } catch (AmazonS3Exception ex) + { + _logger.LogError($"S3 Bucket Threw An Exception\n{ex.Message}\n{ex.StackTrace}"); + return default; + } + } + + public async Task DeleteProfileImage(string userId, string imageName) + { + if (!Enabled) + { + _logger.LogWarning("Not Using S3 Bucket. Performance May Be Degraded."); + return false; + } + + try + { + var response = await S3Client.DeleteObjectAsync(ProfileImagesBucketName, $@"{userId}/{imageName}"); + return response.HttpStatusCode == System.Net.HttpStatusCode.OK || response.HttpStatusCode == System.Net.HttpStatusCode.Accepted; + } + catch (AmazonS3Exception ex) + { + _logger.LogError($"S3 Bucket Threw An Exception\n{ex.Message}\n{ex.StackTrace}"); + return false; + } + } + } +} diff --git a/qtc-net-server/Services/BucketService/IBucketService.cs b/qtc-net-server/Services/BucketService/IBucketService.cs new file mode 100644 index 0000000..eaa0d72 --- /dev/null +++ b/qtc-net-server/Services/BucketService/IBucketService.cs @@ -0,0 +1,14 @@ +namespace qtc_api.Services.BucketService +{ + public interface IBucketService + { + public string ServiceUrl { get; } + public string AccessKey { get; } + public string SecretKey { get; } + public string ProfileImagesBucketName { get; } + public string ImagesBucketName { get; } + public Task PutProfileImage(string userId, string imageName, byte[] imageBytes); + public Task GetProfileImageBytes(string userId, string imageName); + public Task DeleteProfileImage(string userId, string imageName); + } +} diff --git a/qtc-net-server/Services/UserService/UserService.cs b/qtc-net-server/Services/UserService/UserService.cs index 47c7ddf..1780360 100644 --- a/qtc-net-server/Services/UserService/UserService.cs +++ b/qtc-net-server/Services/UserService/UserService.cs @@ -1,18 +1,21 @@ -using qtc_api.Services.EmailService; +using qtc_api.Services.BucketService; +using qtc_api.Services.EmailService; namespace qtc_api.Services.UserService { public class UserService : IUserService { private readonly IConfiguration _configuration; + private readonly IBucketService _bucketService; private readonly DataContext _dataContext; private long idMax = 900000000000000000; - public UserService(IConfiguration configuration, DataContext dataContext) + public UserService(IConfiguration configuration, IBucketService bucketService, DataContext dataContext) { _configuration = configuration; _dataContext = dataContext; + _bucketService = bucketService; } public async Task> AddUser(UserDto userReq) @@ -259,20 +262,40 @@ namespace qtc_api.Services.UserService if (!Directory.Exists(cdnPath)) Directory.CreateDirectory(cdnPath!); if (!Directory.Exists($"{cdnPath}/{userId}")) Directory.CreateDirectory($"{cdnPath}/{userId}"); - var fileName = $"{userId}.pfp"; - var filePath = Path.Combine(cdnPath ?? "./user-content", userId, fileName); + if (userToUpdate.ProfilePicture != null) + await _bucketService.DeleteProfileImage(userToUpdate.Id, userToUpdate.ProfilePicture); - using (var stream = File.Create(filePath)) + var fileName = $"{Guid.NewGuid()}.{file.FileName.Split('.')[1]}"; + using var ms = new MemoryStream(); + file.CopyTo(ms); + var result = await _bucketService.PutProfileImage(userId, fileName, ms.ToArray()); + if (result) { - await file.CopyToAsync(stream); + userToUpdate.ProfilePicture = fileName; + + await _dataContext.SaveChangesAsync(); + + serviceResponse.Success = true; + serviceResponse.Data = fileName; } + else + { + // fallback to using local cdn - userToUpdate.ProfilePicture = fileName; + var filePath = Path.Combine(cdnPath ?? "./user-content", userId, fileName); - await _dataContext.SaveChangesAsync(); + using (var stream = File.Create(filePath)) + { + await file.CopyToAsync(stream); + } - serviceResponse.Success = true; - serviceResponse.Data = fileName; + userToUpdate.ProfilePicture = fileName; + + await _dataContext.SaveChangesAsync(); + + serviceResponse.Success = true; + serviceResponse.Data = fileName; + } } else { @@ -294,26 +317,38 @@ namespace qtc_api.Services.UserService { if (user.ProfilePicture != null) { - if (!Directory.Exists(cdnPath)) + var response = await _bucketService.GetProfileImageBytes(user.Id, user.ProfilePicture); + if (response != null) { - serviceResponse.Success = false; - serviceResponse.Message = "User Content Folder Does Not Exist Yet."; - return serviceResponse; + serviceResponse.Success = true; + serviceResponse.Message = user.ProfilePicture; + serviceResponse.Data = new FileContentResult(response, "image/jpeg"); } - - var pic = Path.Combine(cdnPath, userId, user.ProfilePicture); - - if (!File.Exists(pic)) + else { - serviceResponse.Success = false; - serviceResponse.Message = "User Does Not Have A Profile Picture."; - return serviceResponse; + // try local cdn + + if (!Directory.Exists(cdnPath)) + { + serviceResponse.Success = false; + serviceResponse.Message = "User Content Folder Does Not Exist Yet."; + return serviceResponse; + } + + var pic = Path.Combine(cdnPath, userId, user.ProfilePicture); + + if (!File.Exists(pic)) + { + serviceResponse.Success = false; + serviceResponse.Message = "User Does Not Have A Profile Picture."; + return serviceResponse; + } + + serviceResponse.Success = true; + serviceResponse.Message = user.ProfilePicture; + + serviceResponse.Data = new FileContentResult(File.ReadAllBytes(pic), "image/jpeg"); } - - serviceResponse.Success = true; - serviceResponse.Message = user.ProfilePicture; - - serviceResponse.Data = new FileContentResult(File.ReadAllBytes(pic), "image/jpeg"); } else { serviceResponse.Success = false; diff --git a/qtc-net-server/appsettings.json b/qtc-net-server/appsettings.json index 3fe0b91..3e468d0 100644 --- a/qtc-net-server/appsettings.json +++ b/qtc-net-server/appsettings.json @@ -14,6 +14,14 @@ "SMTPPassword": "", "SMTPSenderAddress": "" }, + "S3Config": { + "S3Enabled": false, + "S3ServiceUrl": "", + "S3AccessKey": "", + "S3SecretKey": "", + "S3ProfileImagesBucket": "qtcnet-profileimages", + "S3ImagesBucket": "qtcnet-images" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/qtc-net-server/qtc-net-server.csproj b/qtc-net-server/qtc-net-server.csproj index be824cd..10cb17a 100644 --- a/qtc-net-server/qtc-net-server.csproj +++ b/qtc-net-server/qtc-net-server.csproj @@ -10,6 +10,7 @@ +