S3 Bucket Support And Lobby Rework #9

Merged
Moonbase merged 3 commits from s3-support into master 2025-11-15 12:31:43 -08:00
9 changed files with 240 additions and 92 deletions

View File

@ -110,17 +110,11 @@ namespace qtc_api.Controllers
await _chatGWContext.Clients.All.SendAsync("RefreshContactsList"); await _chatGWContext.Clients.All.SendAsync("RefreshContactsList");
// always try to overwrite cache when updating pfp // always try to overwrite cache when updating pfp
string recordId = $"UserPfp_{userId}_{DateTime.Now.ToString("yyyyMMdd_hhmm")}"; string recordId = $"UserPfp_{userId}";
using(var stream = file.OpenReadStream()) using var stream = file.OpenReadStream();
{ using var ms = new MemoryStream();
using(var ms = new MemoryStream()) stream.CopyTo(ms);
{ await _cache.SetImageAsync(recordId, ms.ToArray(), TimeSpan.FromHours(1));
stream.CopyTo(ms);
await _cache.SetImageAsync(recordId, ms.ToArray());
ms.Dispose();
}
stream.Dispose();
}
return Ok(response); return Ok(response);
} else } else
@ -137,8 +131,8 @@ namespace qtc_api.Controllers
[Authorize] [Authorize]
public async Task<ActionResult> GetUserProfilePicture(string userId) public async Task<ActionResult> GetUserProfilePicture(string userId)
{ {
string recordId = $"UserPfp_{userId}_{DateTime.Now.ToString("yyyyMMdd_hhmm")}"; string recordId = $"UserPfp_{userId}";
byte[] pfpBytes = await _cache.GetImageAsync(recordId); byte[]? pfpBytes = await _cache.GetImageAsync(recordId);
var result = new ServiceResponse<FileContentResult>(); var result = new ServiceResponse<FileContentResult>();
if (pfpBytes == null) if (pfpBytes == null)
@ -147,7 +141,7 @@ namespace qtc_api.Controllers
if (result != null && result.Success && result.Data != null) if (result != null && result.Success && result.Data != null)
{ {
pfpBytes = result.Data.FileContents; pfpBytes = result.Data.FileContents;
await _cache.SetImageAsync(recordId, pfpBytes); await _cache.SetImageAsync(recordId, pfpBytes, TimeSpan.FromHours(1));
} }
} }
else else
@ -155,7 +149,6 @@ namespace qtc_api.Controllers
// explicitly set from cache // explicitly set from cache
result.Success = true; result.Success = true;
result.Data = new FileContentResult(pfpBytes, "image/jpeg"); result.Data = new FileContentResult(pfpBytes, "image/jpeg");
result.Message = $"{userId}.pfp";
} }
if (result != null && result.Success != false) if (result != null && result.Success != false)

View File

@ -5,23 +5,21 @@ namespace qtc_api.Extensions
{ {
public static class DistributedCacheExtensions public static class DistributedCacheExtensions
{ {
public static async Task SetRecordAsync<T> (this IDistributedCache cache, string recordId, T data, TimeSpan? absoluteExpireTime = null, TimeSpan? unusuedExpireTime = null) public static async Task SetRecordAsync<T> (this IDistributedCache cache, string recordId, T data, TimeSpan? absoluteExpireTime = null)
{ {
var options = new DistributedCacheEntryOptions(); var options = new DistributedCacheEntryOptions();
options.AbsoluteExpirationRelativeToNow = absoluteExpireTime ?? TimeSpan.FromSeconds(15); options.AbsoluteExpirationRelativeToNow = absoluteExpireTime ?? TimeSpan.FromSeconds(15);
options.SlidingExpiration = unusuedExpireTime;
var jsonData = JsonSerializer.Serialize(data); var jsonData = JsonSerializer.Serialize(data);
await cache.SetStringAsync(recordId, jsonData, options); 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(); var options = new DistributedCacheEntryOptions();
options.AbsoluteExpirationRelativeToNow = absoluteExpireTime ?? TimeSpan.FromMinutes(5); options.AbsoluteExpirationRelativeToNow = absoluteExpireTime ?? TimeSpan.FromMinutes(30);
options.SlidingExpiration = unusuedExpireTime;
await cache.SetAsync(recordId, data, options); await cache.SetAsync(recordId, data, options);
} }

View File

@ -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")] [HubMethodName("UpdateStatus")]
[Authorize] [Authorize]
public async Task UpdateStatusAsync(User user, int status) 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}"); 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<User>()); }
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")] [HubMethodName("JoinRoom")]
[Authorize] [Authorize]
public async Task JoinRoomAsync(User user, Room room) public async Task JoinRoomAsync(User user, Room room)
@ -168,6 +125,20 @@ namespace qtc_api.Hubs
Log($"User {user.Username} Has Joined {room.Name}"); 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")] [HubMethodName("LeaveRoom")]
[Authorize] [Authorize]
public async Task LeaveRoomAsync(User user, Room room) public async Task LeaveRoomAsync(User user, Room room)
@ -184,10 +155,9 @@ namespace qtc_api.Hubs
[HubMethodName("SendMessage")] [HubMethodName("SendMessage")]
[Authorize] [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")] [HubMethodName("SendDirectMessage")]

View File

@ -18,6 +18,7 @@ using qtc_api.Services.CurrencyGamesService;
using qtc_api.Services.GameRoomService; using qtc_api.Services.GameRoomService;
using qtc_api.Services.StoreService; using qtc_api.Services.StoreService;
using qtc_api.Services.EmailService; using qtc_api.Services.EmailService;
using qtc_api.Services.BucketService;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -66,6 +67,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IRoomService, RoomService>(); builder.Services.AddScoped<IRoomService, RoomService>();
builder.Services.AddScoped<IContactService, ContactService>(); builder.Services.AddScoped<IContactService, ContactService>();
builder.Services.AddScoped<StoreService>(); builder.Services.AddScoped<StoreService>();
builder.Services.AddScoped<IBucketService, BucketService>();
builder.Services.AddSingleton<CurrencyGamesService>(); builder.Services.AddSingleton<CurrencyGamesService>();
builder.Services.AddSingleton<GameRoomService>(); builder.Services.AddSingleton<GameRoomService>();

View File

@ -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<BucketService> _logger;
private bool Enabled { get; set; }
public BucketService(IConfiguration config, ILogger<BucketService> 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<bool>("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<bool> 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<byte[]?> 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<bool> 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;
}
}
}
}

View File

@ -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<bool> PutProfileImage(string userId, string imageName, byte[] imageBytes);
public Task<byte[]?> GetProfileImageBytes(string userId, string imageName);
public Task<bool> DeleteProfileImage(string userId, string imageName);
}
}

View File

@ -1,18 +1,21 @@
using qtc_api.Services.EmailService; using qtc_api.Services.BucketService;
using qtc_api.Services.EmailService;
namespace qtc_api.Services.UserService namespace qtc_api.Services.UserService
{ {
public class UserService : IUserService public class UserService : IUserService
{ {
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IBucketService _bucketService;
private readonly DataContext _dataContext; private readonly DataContext _dataContext;
private long idMax = 900000000000000000; private long idMax = 900000000000000000;
public UserService(IConfiguration configuration, DataContext dataContext) public UserService(IConfiguration configuration, IBucketService bucketService, DataContext dataContext)
{ {
_configuration = configuration; _configuration = configuration;
_dataContext = dataContext; _dataContext = dataContext;
_bucketService = bucketService;
} }
public async Task<ServiceResponse<User>> AddUser(UserDto userReq) public async Task<ServiceResponse<User>> AddUser(UserDto userReq)
@ -259,20 +262,40 @@ namespace qtc_api.Services.UserService
if (!Directory.Exists(cdnPath)) Directory.CreateDirectory(cdnPath!); if (!Directory.Exists(cdnPath)) Directory.CreateDirectory(cdnPath!);
if (!Directory.Exists($"{cdnPath}/{userId}")) Directory.CreateDirectory($"{cdnPath}/{userId}"); if (!Directory.Exists($"{cdnPath}/{userId}")) Directory.CreateDirectory($"{cdnPath}/{userId}");
var fileName = $"{userId}.pfp"; if (userToUpdate.ProfilePicture != null)
var filePath = Path.Combine(cdnPath ?? "./user-content", userId, fileName); 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; userToUpdate.ProfilePicture = fileName;
serviceResponse.Data = fileName;
await _dataContext.SaveChangesAsync();
serviceResponse.Success = true;
serviceResponse.Data = fileName;
}
} }
else else
{ {
@ -294,26 +317,38 @@ namespace qtc_api.Services.UserService
{ {
if (user.ProfilePicture != null) if (user.ProfilePicture != null)
{ {
if (!Directory.Exists(cdnPath)) var response = await _bucketService.GetProfileImageBytes(user.Id, user.ProfilePicture);
if (response != null)
{ {
serviceResponse.Success = false; serviceResponse.Success = true;
serviceResponse.Message = "User Content Folder Does Not Exist Yet."; serviceResponse.Message = user.ProfilePicture;
return serviceResponse; serviceResponse.Data = new FileContentResult(response, "image/jpeg");
} }
else
var pic = Path.Combine(cdnPath, userId, user.ProfilePicture);
if (!File.Exists(pic))
{ {
serviceResponse.Success = false; // try local cdn
serviceResponse.Message = "User Does Not Have A Profile Picture.";
return serviceResponse; 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 } else
{ {
serviceResponse.Success = false; serviceResponse.Success = false;

View File

@ -14,6 +14,14 @@
"SMTPPassword": "", "SMTPPassword": "",
"SMTPSenderAddress": "" "SMTPSenderAddress": ""
}, },
"S3Config": {
"S3Enabled": false,
"S3ServiceUrl": "",
"S3AccessKey": "",
"S3SecretKey": "",
"S3ProfileImagesBucket": "qtcnet-profileimages",
"S3ImagesBucket": "qtcnet-images"
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",

View File

@ -10,6 +10,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.11.2" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="MailKit" Version="4.13.0" /> <PackageReference Include="MailKit" Version="4.13.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7" />