From c7cc39e914ed801aa3ef28cddb84d88d635170b6 Mon Sep 17 00:00:00 2001 From: Moonbase Date: Fri, 14 Nov 2025 13:32:16 -0800 Subject: [PATCH 1/3] 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 @@ + -- 2.47.2 From 3c4f0dacb34b2c5df33a0f6e480997319e725c58 Mon Sep 17 00:00:00 2001 From: Moonbase Date: Fri, 14 Nov 2025 17:03:43 -0800 Subject: [PATCH 2/3] Improve Caching --- qtc-net-server/Controllers/UsersController.cs | 23 +++++++------------ .../Extensions/DistributedCacheExtensions.cs | 8 +++---- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/qtc-net-server/Controllers/UsersController.cs b/qtc-net-server/Controllers/UsersController.cs index 956faa1..d49f6a7 100644 --- a/qtc-net-server/Controllers/UsersController.cs +++ b/qtc-net-server/Controllers/UsersController.cs @@ -110,17 +110,11 @@ namespace qtc_api.Controllers await _chatGWContext.Clients.All.SendAsync("RefreshContactsList"); // always try to overwrite cache when updating pfp - string recordId = $"UserPfp_{userId}_{DateTime.Now.ToString("yyyyMMdd_hhmm")}"; - using(var stream = file.OpenReadStream()) - { - using(var ms = new MemoryStream()) - { - stream.CopyTo(ms); - await _cache.SetImageAsync(recordId, ms.ToArray()); - ms.Dispose(); - } - stream.Dispose(); - } + string recordId = $"UserPfp_{userId}"; + using var stream = file.OpenReadStream(); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + await _cache.SetImageAsync(recordId, ms.ToArray(), TimeSpan.FromHours(1)); return Ok(response); } else @@ -137,8 +131,8 @@ namespace qtc_api.Controllers [Authorize] public async Task GetUserProfilePicture(string userId) { - string recordId = $"UserPfp_{userId}_{DateTime.Now.ToString("yyyyMMdd_hhmm")}"; - byte[] pfpBytes = await _cache.GetImageAsync(recordId); + string recordId = $"UserPfp_{userId}"; + byte[]? pfpBytes = await _cache.GetImageAsync(recordId); var result = new ServiceResponse(); if (pfpBytes == null) @@ -147,7 +141,7 @@ namespace qtc_api.Controllers if (result != null && result.Success && result.Data != null) { pfpBytes = result.Data.FileContents; - await _cache.SetImageAsync(recordId, pfpBytes); + await _cache.SetImageAsync(recordId, pfpBytes, TimeSpan.FromHours(1)); } } else @@ -155,7 +149,6 @@ namespace qtc_api.Controllers // explicitly set from cache result.Success = true; result.Data = new FileContentResult(pfpBytes, "image/jpeg"); - result.Message = $"{userId}.pfp"; } if (result != null && result.Success != false) diff --git a/qtc-net-server/Extensions/DistributedCacheExtensions.cs b/qtc-net-server/Extensions/DistributedCacheExtensions.cs index a928441..761cd01 100644 --- a/qtc-net-server/Extensions/DistributedCacheExtensions.cs +++ b/qtc-net-server/Extensions/DistributedCacheExtensions.cs @@ -5,23 +5,21 @@ namespace qtc_api.Extensions { public static class DistributedCacheExtensions { - public static async Task SetRecordAsync (this IDistributedCache cache, string recordId, T data, TimeSpan? absoluteExpireTime = null, TimeSpan? unusuedExpireTime = null) + public static async Task SetRecordAsync (this IDistributedCache cache, string recordId, T data, TimeSpan? absoluteExpireTime = null) { var options = new DistributedCacheEntryOptions(); options.AbsoluteExpirationRelativeToNow = absoluteExpireTime ?? TimeSpan.FromSeconds(15); - options.SlidingExpiration = unusuedExpireTime; var jsonData = JsonSerializer.Serialize(data); await cache.SetStringAsync(recordId, jsonData, options); } - public static async Task SetImageAsync(this IDistributedCache cache, string recordId, byte[] data, TimeSpan? absoluteExpireTime = null, TimeSpan? unusuedExpireTime = null) + public static async Task SetImageAsync(this IDistributedCache cache, string recordId, byte[] data, TimeSpan? absoluteExpireTime = null) { var options = new DistributedCacheEntryOptions(); - options.AbsoluteExpirationRelativeToNow = absoluteExpireTime ?? TimeSpan.FromMinutes(5); - options.SlidingExpiration = unusuedExpireTime; + options.AbsoluteExpirationRelativeToNow = absoluteExpireTime ?? TimeSpan.FromMinutes(30); await cache.SetAsync(recordId, data, options); } -- 2.47.2 From 8eb344a655691b70bd964cda6779a08ddb71d879 Mon Sep 17 00:00:00 2001 From: Moonbase Date: Fri, 14 Nov 2025 18:35:12 -0800 Subject: [PATCH 3/3] Treat Lobby As A Normal Room --- qtc-net-server/Hubs/ChatHub.cs | 62 +++++++++------------------------- 1 file changed, 16 insertions(+), 46 deletions(-) diff --git a/qtc-net-server/Hubs/ChatHub.cs b/qtc-net-server/Hubs/ChatHub.cs index 72ad6a5..eb0ea8e 100644 --- a/qtc-net-server/Hubs/ChatHub.cs +++ b/qtc-net-server/Hubs/ChatHub.cs @@ -91,20 +91,6 @@ namespace qtc_api.Hubs } } - [HubMethodName("JoinRoomGuest")] - public async Task JoinRoomGuestAsync(string roomId, string username) - { - // here we can just add the client to the room group and call it a day since the user isn't authenticated - var room = await _roomService.GetRoom(roomId); - - if(room != null && room.Success && room.Data != null) - { - await Groups.AddToGroupAsync(Context.ConnectionId, room.Data.Id); - - await Clients.Group(room.Data.Id).SendAsync("GuestJoin", username); - } - } - [HubMethodName("UpdateStatus")] [Authorize] public async Task UpdateStatusAsync(User user, int status) @@ -124,35 +110,6 @@ namespace qtc_api.Hubs Log($"Something Went Wrong Setting The Status On User {user.Username}"); } - [HubMethodName("JoinLobby")] - [Authorize] - public async Task JoinLobbyAsync(User user) - { - await Groups.AddToGroupAsync(Context.ConnectionId, "LOBBY"); - - await Clients.Group("LOBBY").SendAsync("RoomMessage", $"[SERVER] User {user.Username} Has Joined The Lobby"); - - if (!GroupUsers.TryGetValue("LOBBY", out _)) { GroupUsers.Add("LOBBY", new List()); } - GroupUsers["LOBBY"].Add(user); - - await Clients.Groups("LOBBY").SendAsync("RoomUserList", GroupUsers["LOBBY"]); - Log($"User {user.Username} Has Joined The Lobby"); - } - - [HubMethodName("LeaveLobby")] - [Authorize] - public async Task LeaveLobbyAsync(User user) - { - await Groups.RemoveFromGroupAsync(Context.ConnectionId, "LOBBY"); - - await Clients.Group("LOBBY").SendAsync("RoomMessage", $"[SERVER] User {user.Username} Has Left The Lobby"); - - if (GroupUsers.TryGetValue("LOBBY", out _)) GroupUsers["LOBBY"].Remove(GroupUsers["LOBBY"].FirstOrDefault(e => e.Id == user.Id)!); - - await Clients.Client("LOBBY").SendAsync("RoomUserList", GroupUsers["LOBBY"]); - Log($"User {user.Username} Has Left The Lobby"); - } - [HubMethodName("JoinRoom")] [Authorize] public async Task JoinRoomAsync(User user, Room room) @@ -168,6 +125,20 @@ namespace qtc_api.Hubs Log($"User {user.Username} Has Joined {room.Name}"); } + [HubMethodName("JoinRoomGuest")] + public async Task JoinRoomGuestAsync(string roomId, string username) + { + // here we can just add the client to the room group and call it a day since the user isn't authenticated + var room = await _roomService.GetRoom(roomId); + + if (room != null && room.Success && room.Data != null) + { + await Groups.AddToGroupAsync(Context.ConnectionId, room.Data.Id); + + await Clients.Group(room.Data.Id).SendAsync("GuestJoin", username); + } + } + [HubMethodName("LeaveRoom")] [Authorize] public async Task LeaveRoomAsync(User user, Room room) @@ -184,10 +155,9 @@ namespace qtc_api.Hubs [HubMethodName("SendMessage")] [Authorize] - public async Task SendMessageAsync(User user, Message message, bool IsLobbyMsg, Room room = null!) + public async Task SendMessageAsync(User user, Message message, Room room) { - if(IsLobbyMsg == true) { await Clients.Group("LOBBY").SendAsync("RoomMessage", $"[{user.Username}] {message.Content}"); return; } - await Clients.Group(room.Id).SendAsync("RoomMessage", $"[{user.Username}] {message.Content}"); + await Clients.Group(room.Id).SendAsync("RoomMessage", $"{user.Username}: {message.Content}"); } [HubMethodName("SendDirectMessage")] -- 2.47.2