diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe1152b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..7bb2a5a --- /dev/null +++ b/.env.sample @@ -0,0 +1,21 @@ +# Database Config +MARIADB_DATABASE=qtcnet +MARIADB_USER=qtcnet +MARIADB_PASSWORD=CHANGEME +MARIADB_RANDOM_ROOT_PASSWORD=true + +# ASP.NET Config +ASPNETCORE_ENVIRONMENT="Production" +ASPNETCORE_HTTP_PORTS=8080 + +# Database Connection Config +DB_CONNECTION_STRING="Server=db;Database=qtcnet;User Id=qtcnet;Password=CHANGEME" +DB_PROVIDER="MySQL" +JWT_KEY="CHANGEME" + +# Traefik Config (remove if not using) +TRAEFIK_HOST=qtc.alanmoon.net +TRAEFIK_CERT_PROVIDER=cloudflare + +# Redis Connection Config +REDIS_CONNECTIONSTRING=redis \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ff0c42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5220b68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,372 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd +/qtc-net-server/qtcdev.db +/qtc-net-server/qtcdev.db-shm +/qtc-net-server/qtcdev.db-wal +/qtc-net-server/run.Development.bat +/qtc-net-server/appsettings.json +/qtc-net-server/user-content/ +/qtc-net-server/Properties/launchSettings.json +/qtc-net-server/appsettings.Development.json +/qtc-net-server/Resources/store.Development.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..de087e0 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# QtC.NET Server +This is a from scratch SignalR based chat server that has the following features: +- Rooms +- Direct Messaging +- Precense +- Contacts +- Profiles +- Currency +- Stock Market +- Multiplayer Tic-Tac-Toe +- Email Verification +- Profile Cosmetics Store + +# How To Build +In order to build, you need the .NET 9 SDK. + +1. Clone This Repository +2. Run ``dotnet build`` Within The Folder That Contains The .csproj File +3. Configure And Enjoy! + +# Docker Container (Recommended Self-Hosting Solution) +This Server Does Have A Docker Container You Can Create That Contains Your Config And User Content. Please Refer To `docker-compose.yml` + +# JWT Keys +JWT Keys should be generated from scatch and added to your `docker-compose.yml` environment variables. + +Anything Can Be Put Here, But For Security, It Should Be Within 32-Characters Long And Should Be Secure. An Example Key Is Provided, And Should Not Be Used In Production. + +# .env File +The environment variable file is used to store sensitive info and settings, such as db passwords, keys, etc. +Most things are configured here, however some things are configured in `appsettings.json` + +Please refer to `.env.sample` for more info. \ No newline at end of file diff --git a/docker-compose.dcproj b/docker-compose.dcproj new file mode 100644 index 0000000..2ea109b --- /dev/null +++ b/docker-compose.dcproj @@ -0,0 +1,16 @@ + + + + 2.1 + Linux + False + b1b3d2c9-92e6-4711-842e-c940a40a9a2e + LaunchBrowser + {Scheme}://localhost:{ServicePort}/swagger + qtc-net-server + + + + + + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..599e6be --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +services: + qtc-net-server: + container_name: qtc-server + env_file: .env + build: + context: . + dockerfile: qtc-net-server/Dockerfile + volumes: + - "qtc-user-content:/app/user-content" + ports: # comment me out if your using traefik + - "8080:8080" + networks: + - qtc-backend + depends_on: + db: + condition: service_healthy + restart: true + redis: + condition: service_started + restart: unless-stopped + stdin_open: true + tty: false + # Traefik Config Example + # labels: + # - "traefik.enable=true" + # - "traefik.http.routers.qtcnet.rule=Host(`${TRAEFIK_HOST}`)" + # - "traefik.http.routers.qtcnet.tls=true" + # - "traefik.http.routers.qtcnet.tls.certresolver=${TRAEFIK_CERT_PROVIDER}" + # - "traefik.http.services.qtcnet.loadbalancer.server.port=8080" + + db: + container_name: qtc-db + env_file: .env + image: mariadb + volumes: + - "qtc-data:/var/lib/mariadb" + networks: + - qtc-backend + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect"] + retries: 3 + interval: 10s + timeout: 30s + restart: always + + redis: + container_name: qtc-cache + image: redis + networks: + - qtc-backend + ports: + - "6379:6379" # for dev access + restart: always + +volumes: + qtc-data: + qtc-user-content: + +networks: + qtc-backend: \ No newline at end of file diff --git a/launchSettings.json b/launchSettings.json new file mode 100644 index 0000000..996a0bc --- /dev/null +++ b/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Docker Compose": { + "commandName": "DockerCompose", + "commandVersion": "1.0", + "serviceActions": { + "qtc-net-server": "StartDebugging" + } + } + } +} \ No newline at end of file diff --git a/qtc-net-server.sln b/qtc-net-server.sln new file mode 100644 index 0000000..9877585 --- /dev/null +++ b/qtc-net-server.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33414.496 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "qtc-net-server", "qtc-net-server\qtc-net-server.csproj", "{AE9BEB1A-340C-4EE4-90D1-0B16456DDE6A}" +EndProject +Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{B1B3D2C9-92E6-4711-842E-C940A40A9A2E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AE9BEB1A-340C-4EE4-90D1-0B16456DDE6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE9BEB1A-340C-4EE4-90D1-0B16456DDE6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE9BEB1A-340C-4EE4-90D1-0B16456DDE6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE9BEB1A-340C-4EE4-90D1-0B16456DDE6A}.Release|Any CPU.Build.0 = Release|Any CPU + {B1B3D2C9-92E6-4711-842E-C940A40A9A2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1B3D2C9-92E6-4711-842E-C940A40A9A2E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1B3D2C9-92E6-4711-842E-C940A40A9A2E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1B3D2C9-92E6-4711-842E-C940A40A9A2E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {1B6C7F89-981F-4871-860E-D961CB6A204A} + EndGlobalSection +EndGlobal diff --git a/qtc-net-server/Controllers/AuthController.cs b/qtc-net-server/Controllers/AuthController.cs new file mode 100644 index 0000000..859de3a --- /dev/null +++ b/qtc-net-server/Controllers/AuthController.cs @@ -0,0 +1,308 @@ +using qtc_api.Services.EmailService; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text.Json; + +namespace qtc_api.Controllers +{ + [Route("api/auth")] + [ApiController] + public class AuthController : ControllerBase + { + private readonly IUserService _userService; + private readonly IEmailService _emailService; + private readonly ITokenService _tokenService; + private readonly IHubContext _chatGWContext; + private readonly IConfiguration _configuration; + + private readonly ServerConfig? serverConfig; + private readonly DataContext dataContext; + + public AuthController(IUserService userService, ITokenService tokenService, IHubContext chatGWContext, DataContext dataContext, IConfiguration configuration, IEmailService emailService) + { + _userService = userService; + _tokenService = tokenService; + _chatGWContext = chatGWContext; + _configuration = configuration; + _emailService = emailService; + + serverConfig = JsonSerializer.Deserialize(JsonDocument.Parse(System.IO.File.ReadAllText("./Resources/ServerConfig.json"))); + this.dataContext = dataContext; + } + + [HttpPost("register")] + public async Task>> Register(UserDto userDto) + { + if (userDto != null) + { + var response = await _userService.AddUser(userDto); + await _chatGWContext.Clients.All.SendAsync("RefreshUserLists"); + if (response.Success != false && response.Data != null) + { + // send confirmation email (shouldn't do anything if email confirmation is disabled) + var confirmationToken = _tokenService.GenerateEmailConfirmationToken(response.Data); + var confirmationUrl = $"{Request.Scheme}://{Request.Host}/api/auth/verify-email?token={confirmationToken.Data}"; + + await _emailService.SendConfirmationEmail(response.Data.Email, response.Data.Username, confirmationUrl); + + return Ok(response); + } + else + { + return StatusCode(500, response.Message); + } + } + else + { + return BadRequest(); + } + } + + [HttpPost("login")] + public async Task>> Login(UserLoginDto request) + { + var dbUser = await _userService.GetUserByEmail(request.Email); + + if (dbUser.Data == null) + { + return Ok(new ServiceResponse + { + Message = "User not found.", + Success = false + }); + } + else if (!BCrypt.Net.BCrypt.Verify(request.Password, dbUser.Data.PasswordHash)) + { + return Ok(new ServiceResponse + { + Message = "Incorrect password.", + Success = false + }); + } + + if (!dbUser.Data.IsEmailVerified && _configuration.GetValue("EmailConfig:EmailConfirmationRequired")) + { + return Ok(new ServiceResponse + { + Message = "You need to verify your email on this server. Check your inbox or spam. If you have not received an email, click 'Resend Verification Email'", + Success = false + }); + } + + if (dbUser.Data.Id == serverConfig?.AdminUserId && dbUser.Data.Role != "Admin") + { + dbUser.Data.Role = "Admin"; + } + + var token = await _tokenService.GenerateAccessTokenAndRefreshToken(dbUser.Data, true, request.RememberMe); + + // check if the new user tag can be removed (14 days or 2 weeks) + var now = DateTime.UtcNow; + if (now.Date > dbUser.Data.CreatedAt.Date.AddDays(14) && dbUser.Data.Tags.Contains("new_user")) + { + List _tagsList = [.. dbUser.Data.Tags]; + _tagsList.Remove("new_user"); + dbUser.Data.Tags = [.. _tagsList]; + } + + await dataContext.SaveChangesAsync(); + return Ok(token); + } + + [HttpPost("refresh")] + public async Task>> RefreshLogin(string token) + { + var response = await _tokenService.ValidateRefreshToken(token); + return Ok(response); + } + + [HttpPost("resend-verification-email")] + public async Task>> ResendVerificationEmail(string email) + { + var user = await _userService.GetUserByEmail(email); + if (user != null && user.Success && user.Data != null) + { + var confirmationToken = _tokenService.GenerateEmailConfirmationToken(user.Data); + var confirmationUrl = $"{Request.Scheme}://{Request.Host}/api/auth/verify-email?token={confirmationToken.Data}"; + + await _emailService.SendConfirmationEmail(user.Data.Email, user.Data.Username, confirmationUrl); + + return Ok(new ServiceResponse { Success = true, Data = true }); + } + + return Ok(new ServiceResponse { Success = false }); + } + + [HttpPost("request-password-reset")] + [Authorize] + public async Task>> SendPasswordResetEmail(string email) + { + var user = await _userService.GetUserByEmail(email); + if (user != null && user.Success && user.Data != null) + { + var resetToken = _tokenService.GeneratePasswordResetConfirmationToken(user.Data); // we can probably use the same JWT structure for password resets + var resetUrl = $"{Request.Scheme}://{Request.Host}/api/auth/start-password-reset?token={resetToken.Data}"; + + await _emailService.SendPasswordResetEmail(user.Data.Email, user.Data.Username, resetUrl); + + return Ok(new ServiceResponse { Success = true, Data = true }); + } + + return Ok(new ServiceResponse { Success = false }); + } + + [HttpPost("reset-password")] + public async Task>> ResetPassword(UserPasswordResetDto request) + { + try + { + var handler = new JwtSecurityTokenHandler() + { + InboundClaimTypeMap = new Dictionary() + }; + + var jwtValidationResult = await handler.ValidateTokenAsync(request.Token, _tokenService.GetValidationParams().Data); + if (!jwtValidationResult.IsValid) return Ok(new ServiceResponse { Success = false, Message = "Token Invalid. You may need to be sent another reset link." }); + var jwtClaims = jwtValidationResult.ClaimsIdentity; + var jwtValidTo = jwtValidationResult.SecurityToken.ValidTo; + + if (!jwtClaims.HasClaim("TokenType", "client-password-reset")) return Ok(new ServiceResponse { Success = false, Message = "Token Invalid. You may need to be sent another reset link." }); + + if (jwtClaims != null) + { + var email = jwtClaims.Claims.FirstOrDefault(e => e.Type == ClaimTypes.Email); + var id = jwtClaims.Claims.FirstOrDefault(e => e.Type == ClaimTypes.NameIdentifier); + + if (email != null && id != null) + { + // get the user from id claim + var user = await _userService.GetUserById(id.Value); + if (user != null && user.Success && user.Data != null) + { + var now = DateTime.UtcNow; + if (user.Data.Email == email.Value && now < jwtValidTo.ToUniversalTime()) + { + user.Data.PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password); + await dataContext.SaveChangesAsync(); + + return Ok(new ServiceResponse { Success = true, Data = true }); + } + } + else + { + return Ok(new ServiceResponse { Success = false, Message = "The User This Reset Token Is Associated With No Longer Exists." }); + } + } + } + + return Ok(new ServiceResponse { Success = false, Message = "Token Invalid. You may need to be sent another reset link." }); + } + catch (SecurityTokenMalformedException) + { + return Ok(new ServiceResponse { Success = false, Message = "Token Invalid. You may need to be sent another reset link." }); + } + } + + [HttpGet("verify-email")] + public async Task> VerifyEmail(string token) + { + try + { + var handler = new JwtSecurityTokenHandler() + { + InboundClaimTypeMap = new Dictionary() + }; + + var jwtValidationResult = await handler.ValidateTokenAsync(token, _tokenService.GetValidationParams().Data); + if (!jwtValidationResult.IsValid) return Ok("Token Invalid. You may need to be sent another reset link."); + var jwtClaims = jwtValidationResult.ClaimsIdentity; + var jwtValidTo = jwtValidationResult.SecurityToken.ValidTo; + + if (!jwtClaims.HasClaim("TokenType", "email-confirmation")) return Ok("Token Invalid. You may need to be sent another reset link."); + + if (jwtClaims != null) + { + var email = jwtClaims.Claims.FirstOrDefault(e => e.Type == ClaimTypes.Email); + var id = jwtClaims.Claims.FirstOrDefault(e => e.Type == ClaimTypes.NameIdentifier); + + if (email != null && id != null) + { + // get the user from id claim + var user = await _userService.GetUserById(id.Value); + if (user != null && user.Success && user.Data != null) + { + var now = DateTime.UtcNow; + if (user.Data.Email == email.Value && now < jwtValidTo.ToUniversalTime()) + { + user.Data.IsEmailVerified = true; + await dataContext.SaveChangesAsync(); + + return Ok("Email Verified! You may now login on the client."); + } + } + else + { + return Ok("The User This Confirmation Link Is Associated With No Longer Exists."); + } + } + } + + return Ok("Token Invalid. You may need to be sent another confirmation link. You can do this by clicking 'Resend Verification Email' in the client."); + } + catch (SecurityTokenMalformedException) + { + return Ok("Token Invalid. You may need to be sent another confirmation link. You can do this by clicking 'Resend Verification Email' in the client."); + } + } + + [HttpGet("start-password-reset")] + public async Task> StartPasswordReset(string token) + { + try + { + var handler = new JwtSecurityTokenHandler() + { + InboundClaimTypeMap = new Dictionary() + }; + + var jwtValidationResult = await handler.ValidateTokenAsync(token, _tokenService.GetValidationParams().Data); + if (!jwtValidationResult.IsValid) return Ok("Token Invalid. You may need to be sent another reset link."); + var jwtClaims = jwtValidationResult.ClaimsIdentity; + var jwtValidTo = jwtValidationResult.SecurityToken.ValidTo; + + if (!jwtClaims.HasClaim("TokenType", "password-reset")) return Ok("Token Invalid. You may need to be sent another reset link."); + + if (jwtClaims != null) + { + var email = jwtClaims.Claims.FirstOrDefault(e => e.Type == ClaimTypes.Email); + var id = jwtClaims.Claims.FirstOrDefault(e => e.Type == ClaimTypes.NameIdentifier); + + if (email != null && id != null) + { + // get the user from id claim + var user = await _userService.GetUserById(id.Value); + if (user != null && user.Success && user.Data != null) + { + var now = DateTime.UtcNow; + if (user.Data.Email == email.Value && now < jwtValidTo.ToUniversalTime()) + { + var clientToken = _tokenService.GeneratePasswordResetConfirmationToken(user.Data); + return Ok($"To Reset Your Password, Paste The Following Code Into Your Client And Enter A New Password\n{clientToken.Data}\n\nNOTE: This code is only valid for one hour."); + } + } + else + { + return Ok("The User This Reset Link Is Associated With No Longer Exists."); + } + } + } + + return Ok("Token Invalid. You may need to be sent another reset link."); + } + catch (SecurityTokenMalformedException) + { + return Ok("Token Invalid. You may need to be sent another reset link."); + } + } + } +} diff --git a/qtc-net-server/Controllers/ContactController.cs b/qtc-net-server/Controllers/ContactController.cs new file mode 100644 index 0000000..d07ca30 --- /dev/null +++ b/qtc-net-server/Controllers/ContactController.cs @@ -0,0 +1,96 @@ +using qtc_api.Services.ContactService; +using System.Security.Claims; + +namespace qtc_api.Controllers +{ + [Route("api/contacts")] + [ApiController] + public class ContactController : ControllerBase + { + private IContactService _contactService; + private IHubContext _chatGwContext; + + public ContactController(IContactService contactService, IHubContext chatGwContext) + { + _contactService = contactService; + _chatGwContext = chatGwContext; + } + + [HttpPost("add-contact")] + [Authorize] + public async Task>> AddContact(string userId) + { + var identity = HttpContext.User.Identity as ClaimsIdentity; + + if (identity != null) + { + IEnumerable claims = identity.Claims; + var ownerId = claims.First().Value; + + var result = await _contactService.CreateContact(ownerId, userId); + + // refresh contacts list for both users + await _chatGwContext.Clients.User(userId).SendAsync("RefreshContactsList"); + await _chatGwContext.Clients.User(ownerId).SendAsync("RefreshContactsList"); + + return Ok(result); + } + + return Unauthorized(); + } + + [HttpPost("approve-contact")] + [Authorize] + public async Task>> ApproveContact(string userId) + { + var identity = HttpContext.User.Identity as ClaimsIdentity; + + if (identity != null) + { + IEnumerable claims = identity.Claims; + var ownerId = claims.First().Value; + + var result = await _contactService.UpdateContactStatus(ownerId, userId, Contact.ContactStatus.Accepted, Contact.ContactStatus.Accepted); + + // refresh contacts list for both users + await _chatGwContext.Clients.User(userId).SendAsync("RefreshContactsList"); + await _chatGwContext.Clients.User(ownerId).SendAsync("RefreshContactsList"); + + return Ok(result); + } + + return Unauthorized(); + } + + [HttpDelete("remove-contact")] + [Authorize] + public async Task>> RemoveContact(string userId) + { + var identity = HttpContext.User.Identity as ClaimsIdentity; + + if (identity != null) + { + IEnumerable claims = identity.Claims; + var ownerId = claims.First().Value; + + var response = await _contactService.DeleteContact(ownerId, userId); + + // refresh contacts list for both users + await _chatGwContext.Clients.User(userId).SendAsync("RefreshContactsList"); + await _chatGwContext.Clients.User(ownerId).SendAsync("RefreshContactsList"); + + return Ok(response); + } + + return Unauthorized(); + } + + [HttpGet("get-user-contacts")] + [Authorize] + public async Task>>> GetUserContacts(User user) + { + var result = await _contactService.GetUserContacts(user); + return Ok(result); + } + } +} diff --git a/qtc-net-server/Controllers/CurrencyGamesController.cs b/qtc-net-server/Controllers/CurrencyGamesController.cs new file mode 100644 index 0000000..084bbd3 --- /dev/null +++ b/qtc-net-server/Controllers/CurrencyGamesController.cs @@ -0,0 +1,113 @@ +using qtc_api.Services.CurrencyGamesService; +using System.Security.Claims; +using static qtc_api.Services.CurrencyGamesService.CurrencyGamesService; + +namespace qtc_api.Controllers +{ + [Route("api/games")] + [ApiController] + public class CurrencyGamesController : ControllerBase + { + private IUserService _userService; + private CurrencyGamesService _currencyGamesService; + public CurrencyGamesController(IUserService userService, CurrencyGamesService currencyGamesService) + { + _userService = userService; + _currencyGamesService = currencyGamesService; + } + + // Stock Market + + [HttpGet("stock-market/current-price")] + public ActionResult> GetCurrentPricePerStock() + { + var price = _currencyGamesService.GetCurrentPricePerStock(); + + if (price != null && price.Success) + { + return Ok(new ServiceResponse + { + Success = true, + Data = price.Data + }); + } + else return Ok(new ServiceResponse + { + Success = false, + Message = "Service Indicated Failiure" + }); + } + + [HttpPost("stock-market/buy-stock")] + [Authorize] + public async Task>> BuyStockFromMarket(int amount) + { + var identity = HttpContext.User.Identity as ClaimsIdentity; + + if (identity != null) + { + IEnumerable claims = identity.Claims; + var id = claims.First().Value; + + var user = await _userService.GetUserById(id); + + if (user != null && user.Success && user.Data != null) + { + var stocks = await _currencyGamesService.BuyStock(user.Data, amount); + + if (stocks.Success) + { + return Ok(new ServiceResponse { Success = true, Data = stocks.Data }); + } + else return Ok(new ServiceResponse { Success = false, Message = stocks.Message }); + } + else return Ok(new ServiceResponse { Success = false, Message = "Identity User Not Found" }); + } + else return Ok(new ServiceResponse { Success = false, Message = "No Identity" }); + } + + [HttpPost("stock-market/sell-stock")] + [Authorize] + public async Task>> SellStockToMarket(int amount) + { + var identity = HttpContext.User.Identity as ClaimsIdentity; + + if (identity != null) + { + IEnumerable claims = identity.Claims; + var id = claims.First().Value; + + var user = await _userService.GetUserById(id); + + if (user != null && user.Success && user.Data != null) + { + var stocks = await _currencyGamesService.SellStock(user.Data, amount); + + if (stocks.Success) + { + return Ok(new ServiceResponse { Success = true, Data = stocks.Data }); + } + else return Ok(new ServiceResponse { Success = false, Message = stocks.Message }); + } + else return Ok(new ServiceResponse { Success = false, Message = "Identity User Not Found" }); + } + else return Ok(new ServiceResponse { Success = false, Message = "No Identity" }); + } + + // Number Guess + + [HttpGet("number-guess/get-number")] + [Authorize] + public ActionResult> GetRandomNumber() + { + return Ok(_currencyGamesService.GetRandomNumber()); + } + + [HttpGet("number-guess/guess-number")] + [Authorize] + public ActionResult> GuessNumber(int original, int guess) + { + return Ok(_currencyGamesService.GuessRandomNumber(original, guess)); + } + } +} diff --git a/qtc-net-server/Controllers/GeneralController.cs b/qtc-net-server/Controllers/GeneralController.cs new file mode 100644 index 0000000..0a49922 --- /dev/null +++ b/qtc-net-server/Controllers/GeneralController.cs @@ -0,0 +1,17 @@ +namespace qtc_api.Controllers +{ + [Route("api/general")] + [ApiController] + public class GeneralController : ControllerBase + { + private readonly ILogger logger; + public GeneralController(ILogger logger) => this.logger = logger; + + [HttpGet("ping")] + public ActionResult PingAsync() + { + logger.LogInformation($"Ping Received From Client"); + return Ok("Pong!"); + } + } +} diff --git a/qtc-net-server/Controllers/RoomsController.cs b/qtc-net-server/Controllers/RoomsController.cs new file mode 100644 index 0000000..9d6888c --- /dev/null +++ b/qtc-net-server/Controllers/RoomsController.cs @@ -0,0 +1,45 @@ +using qtc_api.Services.RoomService; + +namespace qtc_api.Controllers +{ + [Route("api/rooms")] + [ApiController] + public class RoomsController : ControllerBase + { + public IRoomService _roomService; + public IHubContext _hubContext; + + public RoomsController(IRoomService roomService, IHubContext hubContext) + { + _roomService = roomService; + _hubContext = hubContext; + } + + [HttpPost("create-room")] + [Authorize(Roles = "Admin")] + public async Task>> CreateRoom(string userId, RoomDto request) + { + var response = await _roomService.AddRoom(userId, request); + await _hubContext.Clients.All.SendAsync("RefreshRoomList"); + return Ok(response); + } + + [HttpDelete("delete-room")] + [Authorize(Roles = "Admin")] + public async Task>> DeleteRoom(string roomId) + { + var response = await _roomService.DeleteRoom(roomId); + await _hubContext.Clients.Group(roomId).SendAsync("RoomDeleted"); + await _hubContext.Clients.All.SendAsync("RefreshRoomList"); + return Ok(response); + } + + [HttpGet("get-all-rooms")] + [Authorize] + public async Task>>> GetAllRooms() + { + var rooms = await _roomService.GetAllRooms(); + return Ok(rooms); + } + } +} diff --git a/qtc-net-server/Controllers/StoreController.cs b/qtc-net-server/Controllers/StoreController.cs new file mode 100644 index 0000000..cb5dfbd --- /dev/null +++ b/qtc-net-server/Controllers/StoreController.cs @@ -0,0 +1,95 @@ +using qtc_api.Services.StoreService; +using System.Security.Claims; + +namespace qtc_api.Controllers +{ + [Route("api/store")] + [ApiController] + public class StoreController : ControllerBase + { + private readonly StoreService _storeService; + public StoreController(StoreService storeService) + { + _storeService = storeService; + } + + [HttpGet] + [Route("all-items")] + public ActionResult>> GetAllItems() + { + return Ok(_storeService.GetStoreItems()); + } + + [HttpGet] + [Route("item")] + public ActionResult> GetItem(int id) + { + return Ok(_storeService.GetStoreItem(id)); + } + + [HttpGet] + [Route("bought-items")] + public ActionResult>> GetBoughtStoreItems() + { + var identity = HttpContext.User.Identity as ClaimsIdentity; + + if (identity != null) + { + IEnumerable claims = identity.Claims; + var userId = claims.First().Value; + + if (userId != null) + { + var result = _storeService.GetBoughtStoreItemsFromUser(userId); + return Ok(result); + } + else return Ok(new ServiceResponse> { Success = false, Message = "No UserId In Auth Header" }); + } + else return Ok(new ServiceResponse> { Success = false, Message = "No Auth Header" }); + } + + [HttpGet] + [Route("bought-item")] + public ActionResult> GetBoughtItem(int id) + { + var identity = HttpContext.User.Identity as ClaimsIdentity; + + if (identity != null) + { + IEnumerable claims = identity.Claims; + var userId = claims.First().Value; + + if (userId != null) + { + var result = _storeService.GetBoughtStoreItemFromUser(userId, id); + return Ok(result); + } + else return Ok(new ServiceResponse> { Success = false, Message = "No UserId In Auth Header" }); + } + else return Ok(new ServiceResponse> { Success = false, Message = "No Auth Header" }); + } + + [HttpPost] + [Route("buy-item")] + [Authorize] + public async Task>> BuyStoreItem(int id) + { + var identity = HttpContext.User.Identity as ClaimsIdentity; + + if (identity != null) + { + IEnumerable claims = identity.Claims; + var userId = claims.First().Value; + + if (userId != null) + { + var result = await _storeService.BuyStoreItem(userId, id); + + return Ok(result); + } + else return Ok(new ServiceResponse { Success = false, Message = "No UserId In Auth Header" }); + } + else return Ok(new ServiceResponse { Success = false, Message = "No Auth Header" }); + } + } +} diff --git a/qtc-net-server/Controllers/TagController.cs b/qtc-net-server/Controllers/TagController.cs new file mode 100644 index 0000000..38f411f --- /dev/null +++ b/qtc-net-server/Controllers/TagController.cs @@ -0,0 +1,29 @@ +using qtc_api.Services.BucketService; + +namespace qtc_api.Controllers +{ + [ApiController] + [Route("api/tags")] + public class TagController : ControllerBase + { + private readonly IBucketService _bucketService; + public TagController(IBucketService bucketService) + { + _bucketService = bucketService; + } + + [HttpGet] + [Route("{tagName}")] + [Authorize] + public async Task GetTagPicBytes(string tagName) + { + var _s3Res = await _bucketService.GetTagImageBytes(tagName); + if (_s3Res != null) + return new FileContentResult(_s3Res, "image/jpeg"); + else + { + return NotFound(); // TODO - code the legacy way of finding images here (mostly for self-hosters) + } + } + } +} diff --git a/qtc-net-server/Controllers/UsersController.cs b/qtc-net-server/Controllers/UsersController.cs new file mode 100644 index 0000000..56e9087 --- /dev/null +++ b/qtc-net-server/Controllers/UsersController.cs @@ -0,0 +1,217 @@ +using Microsoft.Extensions.Caching.Distributed; +using qtc_api.Extensions; +using System.Security.Claims; + +namespace qtc_api.Controllers +{ + [Route("api/users")] + [ApiController] + public class UsersController : ControllerBase + { + private readonly IUserService _userService; + private readonly IDistributedCache _cache; + private readonly IHubContext _chatGWContext; + public UsersController(IUserService userService, IDistributedCache distributedCache, IHubContext chatGWContext) + { + _userService = userService; + _cache = distributedCache; + _chatGWContext = chatGWContext; + } + + [HttpGet("all")] + [Authorize] + public async Task>>> GetAllUsers() + { + var users = await _userService.GetAllUsers(); + return Ok(users); + } + + [HttpGet("user-info")] + [Authorize] + public async Task>> GetUserInformation(string id) + { + var user = await _userService.GetUserInformationById(id); + return Ok(user); + } + + [HttpGet("user-authorized")] + [Authorize] + public async Task>> UserFromAuthorizeHead() + { + var identity = HttpContext.User.Identity as ClaimsIdentity; + + if (identity != null) + { + IEnumerable claims = identity.Claims; + var id = claims.First().Value; + + if (id != null) + { + var user = await _userService.GetUserById(id); + return Ok(user); + } + else + { + return BadRequest("Token did not contain an ID."); + } + } + else + { + return BadRequest("Header not found."); + } + } + + [HttpPut("update")] + [Authorize] + public async Task>> UpdateUserInformation(UserUpdateInformationDto user) + { + var identity = HttpContext.User.Identity as ClaimsIdentity; + + if (identity != null) + { + IEnumerable claims = identity.Claims; + var id = claims.First().Value; + + if (id != null && id == user.Id) + { + var updatedUser = await _userService.UpdateUserInfo(user); + if (updatedUser.Success && updatedUser.Data != null) + { + await _chatGWContext.Clients.All.SendAsync("UpdateUser", user); + await _chatGWContext.Clients.User(updatedUser.Data.Id).SendAsync("UpdateCurrentUser"); + + return Ok(updatedUser); + } + else return Ok(); + } + else + { + return Unauthorized("You are not authorized to edit that user."); + } + } + else + { + return BadRequest("Session Expired."); + } + } + + [HttpPost("upload-profile-pic")] + [Authorize] + public async Task>> UploadOrUpdateProfilePic(string userId, IFormFile file) + { + var identity = HttpContext.User.Identity as ClaimsIdentity; + + if (identity != null) + { + IEnumerable claims = identity.Claims; + var id = claims.First().Value; + + if (id != null && id == userId) + { + if (file.Length > 3000000) + { + return BadRequest("File Is Above Limit."); + } + + var response = await _userService.UpdateUserPic(userId, file); + + var res = await _userService.GetUserById(id); + User? user = res.Data; + + UserInformationDto dto = new(); + if (user != null) + dto.MapToUser(user); + + await _chatGWContext.Clients.All.SendAsync("UpdateUser", dto); + + // always try to overwrite cache when updating pfp + 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)); + + await _chatGWContext.Clients.User(id).SendAsync("UpdateCurrentUser"); + return Ok(response); + } + else + { + return BadRequest("You are not permitted to edit that user."); + } + } + else + { + return BadRequest("No Identity."); + } + } + + [HttpGet("profile-pic/{userId}")] + [Authorize] + public async Task GetUserProfilePicture(string userId) + { + string recordId = $"UserPfp_{userId}"; + byte[]? pfpBytes = await _cache.GetImageAsync(recordId); + + var result = new ServiceResponse(); + if (pfpBytes == null) + { + result = await _userService.GetUserPic(userId); + if (result != null && result.Success && result.Data != null) + { + pfpBytes = result.Data.FileContents; + await _cache.SetImageAsync(recordId, pfpBytes, TimeSpan.FromHours(1)); + } + } + else + { + // explicitly set from cache + result.Success = true; + result.Data = new FileContentResult(pfpBytes, "image/jpeg"); + } + + if (result != null && result.Success != false) + { + return result.Data!; + } + else if (result!.Message == "User Does Not Have A Profile Picture." || result!.Message == "User Content Folder Does Not Exist Yet.") + { + return BadRequest("User has no profile picture."); + } + else + { + return BadRequest("Failed To Get Profile Picture."); + } + } + + [HttpDelete("delete-user")] + [Authorize(Roles = "Admin")] + public async Task>> DeleteUserById(string id) + { + var result = await _userService.DeleteUser(id); + await _chatGWContext.Clients.All.SendAsync("RefreshUserLists"); + if (result != null && result.Success && result.Data != null) await _chatGWContext.Clients.User(result.Data.Id).SendAsync("ForceSignOut"); + return Ok(result); + } + + [HttpPost("update-user-currency")] + [Authorize] + public async Task>> UpdateUserCurrency(int amount, bool isSpinClaim) + { + var identity = HttpContext.User.Identity as ClaimsIdentity; + + if (identity != null) + { + IEnumerable claims = identity.Claims; + var id = claims.First().Value; + var _res = await _userService.AddCurrencyToUser(id, amount, isSpinClaim); + if (_res.Success) + { + await _chatGWContext.Clients.User(id).SendAsync("UpdateCurrentUser"); + return Ok(_res); + } + else return Ok(new ServiceResponse { Success = false, Message = "Identity Has No User ID" }); + } + else return Ok(new ServiceResponse { Success = false, Message = "No Identity" }); + } + } +} diff --git a/qtc-net-server/Data/DataContext.cs b/qtc-net-server/Data/DataContext.cs new file mode 100644 index 0000000..45e8a2e --- /dev/null +++ b/qtc-net-server/Data/DataContext.cs @@ -0,0 +1,75 @@ +namespace qtc_api.Data +{ + public class DataContext : DbContext + { + public DataContext(DbContextOptions options) : base(options) + { + } + + public DbSet Users { get; set; } + public DbSet Rooms { get; set; } + public DbSet ValidRefreshTokens { get; set; } + public DbSet Contacts { get; set; } + public DbSet OwnedStoreItems { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + var dbProvider = Environment.GetEnvironmentVariable("DB_PROVIDER"); + var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING"); + + if (dbProvider != null && connectionString != null) + { + switch (dbProvider) + { + case "MySQL": + options.UseMySQL(connectionString); + break; + case "SQLite": + options.UseSqlite(connectionString); + break; + default: throw new Exception("Unsupported Database Provider. Please Check 'DB_PROVIDER' Environment Variable."); + } + } + else throw new Exception("Cannot Find Environment Variables 'DB_PROVIDER' And 'DB_CONNECTION_STRING'. Please Check Environment."); + } + + protected override void OnModelCreating(ModelBuilder builder) + { + // Users + + builder.Entity().HasMany(e => e.ContactsList); + builder.Entity().HasMany(e => e.ContactsMade); + builder.Entity().HasMany(e => e.OwnedStoreItems); + + builder.Entity() + .Property("_tagString") + .HasColumnName("Tags"); + + // Rooms (no relations) + + builder.Entity(); + + // Refresh Tokens + + builder.Entity().HasOne(e => e.User) + .WithMany(e => e.RefreshTokens) + .HasForeignKey(e => e.UserID); + + // Contacts + + builder.Entity().HasOne(e => e.Owner) + .WithMany(e => e.ContactsMade) + .HasForeignKey(e => e.OwnerId); + + builder.Entity().HasOne(e => e.User) + .WithMany(e => e.ContactsList) + .HasForeignKey(e => e.UserId); + + // Purchased Store Items + + builder.Entity().HasOne(e => e.User) + .WithMany(e => e.OwnedStoreItems) + .HasForeignKey(e => e.UserId); + } + } +} diff --git a/qtc-net-server/Dockerfile b/qtc-net-server/Dockerfile new file mode 100644 index 0000000..37ee1f7 --- /dev/null +++ b/qtc-net-server/Dockerfile @@ -0,0 +1,21 @@ +#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. + +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src +COPY ["qtc-net-server/qtc-net-server.csproj", "qtc-net-server/"] +RUN dotnet restore "qtc-net-server/qtc-net-server.csproj" +COPY . . +WORKDIR "/src/qtc-net-server" +RUN dotnet build "qtc-net-server.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "qtc-net-server.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "qtc-net-server.dll"] \ No newline at end of file diff --git a/qtc-net-server/Dtos/Room/RoomDto.cs b/qtc-net-server/Dtos/Room/RoomDto.cs new file mode 100644 index 0000000..6c24370 --- /dev/null +++ b/qtc-net-server/Dtos/Room/RoomDto.cs @@ -0,0 +1,8 @@ +namespace qtc_api.Dtos.Room +{ + public class RoomDto + { + public string Name { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = new DateTime(); + } +} diff --git a/qtc-net-server/Dtos/User/UserConnectionDto.cs b/qtc-net-server/Dtos/User/UserConnectionDto.cs new file mode 100644 index 0000000..bb44c23 --- /dev/null +++ b/qtc-net-server/Dtos/User/UserConnectionDto.cs @@ -0,0 +1,8 @@ +namespace qtc_api.Dtos.User +{ + public class UserConnectionDto + { + public Models.User? ConnectedUser { get; set; } + public string? ConnectionId { get; set; } + } +} diff --git a/qtc-net-server/Dtos/User/UserDto.cs b/qtc-net-server/Dtos/User/UserDto.cs new file mode 100644 index 0000000..1d51b7a --- /dev/null +++ b/qtc-net-server/Dtos/User/UserDto.cs @@ -0,0 +1,10 @@ +namespace qtc_api.Dtos.User +{ + public class UserDto + { + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public DateTime DateOfBirth { get; set; } = new DateTime(); + } +} diff --git a/qtc-net-server/Dtos/User/UserInformationDto.cs b/qtc-net-server/Dtos/User/UserInformationDto.cs new file mode 100644 index 0000000..592c2b8 --- /dev/null +++ b/qtc-net-server/Dtos/User/UserInformationDto.cs @@ -0,0 +1,40 @@ +namespace qtc_api.Dtos.User +{ + public class UserInformationDto + { + public string Id { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string ProfilePicture { get; set; } = string.Empty; + public string Bio { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; + public DateTime DateOfBirth { get; set; } = new DateTime(); + public DateTime CreatedAt { get; set; } = new DateTime(); + public DateTime LastLogin { get; set; } = new DateTime(); + public int Status { get; set; } = 0; + public int CurrencyAmount { get; set; } = 0; + public int ProfileCosmeticId { get; set; } = 0; + public string TextStatus { get; set; } = string.Empty; + public string[] Tags { get; set; } = []; + + public UserInformationDto MapToUser(Models.User user) + { + // map what is needed for info + Id = user.Id; + Username = user.Username; + ProfilePicture = user.ProfilePicture; + Bio = user.Bio; + Role = user.Role; + DateOfBirth = user.DateOfBirth; + CreatedAt = user.CreatedAt; + LastLogin = user.LastLogin; + Status = user.Status; + CurrencyAmount = user.CurrencyAmount; + ProfileCosmeticId = user.ActiveProfileCosmetic; + TextStatus = user.TextStatus; + Tags = user.Tags; + + // return it + return this; + } + } +} diff --git a/qtc-net-server/Dtos/User/UserLoginDto.cs b/qtc-net-server/Dtos/User/UserLoginDto.cs new file mode 100644 index 0000000..bd72684 --- /dev/null +++ b/qtc-net-server/Dtos/User/UserLoginDto.cs @@ -0,0 +1,9 @@ +namespace qtc_api.Dtos.User +{ + public class UserLoginDto + { + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public bool RememberMe { get; set; } = false; + } +} diff --git a/qtc-net-server/Dtos/User/UserPasswordResetDto.cs b/qtc-net-server/Dtos/User/UserPasswordResetDto.cs new file mode 100644 index 0000000..0845278 --- /dev/null +++ b/qtc-net-server/Dtos/User/UserPasswordResetDto.cs @@ -0,0 +1,8 @@ +namespace qtc_api.Dtos.User +{ + public class UserPasswordResetDto + { + public string Token { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + } +} diff --git a/qtc-net-server/Dtos/User/UserStatusDto.cs b/qtc-net-server/Dtos/User/UserStatusDto.cs new file mode 100644 index 0000000..591f9c1 --- /dev/null +++ b/qtc-net-server/Dtos/User/UserStatusDto.cs @@ -0,0 +1,8 @@ +namespace qtc_api.Dtos.User +{ + public class UserStatusDto + { + public string Id { get; set; } = string.Empty; + public int Status { get; set; } = 0; + } +} diff --git a/qtc-net-server/Dtos/User/UserStockActionResultDto.cs b/qtc-net-server/Dtos/User/UserStockActionResultDto.cs new file mode 100644 index 0000000..943c3d6 --- /dev/null +++ b/qtc-net-server/Dtos/User/UserStockActionResultDto.cs @@ -0,0 +1,8 @@ +namespace qtc_api.Dtos.User +{ + public class UserStockActionResultDto + { + public int StockAmount { get; set; } + public int CurrencyAmount { get; set; } + } +} diff --git a/qtc-net-server/Dtos/User/UserUpdateInformationDto.cs b/qtc-net-server/Dtos/User/UserUpdateInformationDto.cs new file mode 100644 index 0000000..d61a5ad --- /dev/null +++ b/qtc-net-server/Dtos/User/UserUpdateInformationDto.cs @@ -0,0 +1,12 @@ +namespace qtc_api.Dtos.User +{ + public class UserUpdateInformationDto + { + public string Id { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string Bio { get; set; } = string.Empty; + public DateTime DateOfBirth { get; set; } = new DateTime(); + public int ProfileCosmeticId { get; set; } = 0; + public string TextStatus { get; set; } = string.Empty; + } +} diff --git a/qtc-net-server/Enums/GameStatus.cs b/qtc-net-server/Enums/GameStatus.cs new file mode 100644 index 0000000..6839476 --- /dev/null +++ b/qtc-net-server/Enums/GameStatus.cs @@ -0,0 +1,13 @@ +namespace qtc_api.Enums +{ + public enum GameStatus + { + WaitingForPlayer, + SelectingSymbol, + Ongoing, + P1Win, + P2Win, + NoWin, + PlayerDisconnected + } +} diff --git a/qtc-net-server/Enums/StoreItemType.cs b/qtc-net-server/Enums/StoreItemType.cs new file mode 100644 index 0000000..26359c2 --- /dev/null +++ b/qtc-net-server/Enums/StoreItemType.cs @@ -0,0 +1,8 @@ +namespace qtc_api.Enums +{ + public enum StoreItemType + { + ProfileCosmetic = 1, + ClientCosmetic = 2 + } +} diff --git a/qtc-net-server/Enums/TicTacToeSymbol.cs b/qtc-net-server/Enums/TicTacToeSymbol.cs new file mode 100644 index 0000000..4576a51 --- /dev/null +++ b/qtc-net-server/Enums/TicTacToeSymbol.cs @@ -0,0 +1,9 @@ +namespace qtc_api.Enums +{ + public enum TicTacToeSymbol + { + X, + O, + Blank + } +} diff --git a/qtc-net-server/Extensions/DistributedCacheExtensions.cs b/qtc-net-server/Extensions/DistributedCacheExtensions.cs new file mode 100644 index 0000000..cfd8f49 --- /dev/null +++ b/qtc-net-server/Extensions/DistributedCacheExtensions.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Caching.Distributed; +using System.Text.Json; + +namespace qtc_api.Extensions +{ + public static class DistributedCacheExtensions + { + 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); + + 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) + { + var options = new DistributedCacheEntryOptions(); + + options.AbsoluteExpirationRelativeToNow = absoluteExpireTime ?? TimeSpan.FromMinutes(30); + + await cache.SetAsync(recordId, data, options); + } + + public static async Task GetRecordAsync(this IDistributedCache cache, string recordId) + { + var jsonData = await cache.GetStringAsync(recordId); + + if (jsonData == null) + { + return default; + } + + return JsonSerializer.Deserialize(jsonData); + } + + public static async Task GetImageAsync(this IDistributedCache cache, string recordId) + { + var bytes = await cache.GetAsync(recordId); + + if (bytes == null) + { + return default; + } + + return bytes; + } + } +} diff --git a/qtc-net-server/Hubs/ChatHub.cs b/qtc-net-server/Hubs/ChatHub.cs new file mode 100644 index 0000000..26eb212 --- /dev/null +++ b/qtc-net-server/Hubs/ChatHub.cs @@ -0,0 +1,233 @@ +using qtc_api.Services.RoomService; +using System.Text.Json; + +namespace qtc_api.Hubs +{ + public class ChatHub : Hub + { + private IUserService _userService; + private IRoomService _roomService; + private ILogger _logger; + private DataContext _dataContext; + private ServerConfig _serverConfig; + + public static readonly Dictionary> GroupUsers = []; + public static readonly Dictionary> ConnectedUsers = []; + public static readonly List OnlineUsers = []; + + private static User ServerUser = new() + { + Id = "0", + Username = "Server", + }; + + public ChatHub(IUserService userService, IRoomService roomService, ILogger logger, DataContext dataContext, ServerConfig serverConfig) + { + _userService = userService; + _roomService = roomService; + _logger = logger; + _dataContext = dataContext; + _serverConfig = serverConfig; + } + + public async override Task OnConnectedAsync() + { + Log("Client Connected To Hub"); + + var uid = Context.UserIdentifier; + if (uid != null) + { + var user = await _userService.GetUserById(uid); + if (user != null && user.Success && user.Data != null) + { + var userId = user.Data.Id; + + lock (ConnectedUsers) + { + if (!ConnectedUsers.TryGetValue(userId, out List? value)) + { + value = []; + ConnectedUsers[userId] = value; + } + + value.Add(Context.ConnectionId); + } + + if (!OnlineUsers.Any(e => e.Id == userId)) + { + Log("Setting User Online..."); + OnlineUsers.Add(user.Data); + if (user.Data.Status == 0) await UpdateStatusAsync(user.Data, 1); + + await Clients.Client(Context.ConnectionId).SendAsync("RefreshRoomList"); + await Clients.Client(Context.ConnectionId).SendAsync("ReceiveServerConfig", _serverConfig); + } + } + } + } + + public override async Task OnDisconnectedAsync(Exception? ex) + { + Log("Client Disconnected From Hub"); + + var uid = Context.UserIdentifier; + if (uid != null) + { + var user = await _userService.GetUserById(uid); + if (user != null && user.Success && user.Data != null) + { + var userId = user.Data.Id; + + lock (ConnectedUsers) + { + if (ConnectedUsers.TryGetValue(userId, out List? value)) + { + value.Remove(Context.ConnectionId); + } + } + + if (ConnectedUsers[userId].Count == 0) + { + ConnectedUsers.Remove(userId); + if (OnlineUsers.Any(e => e.Id == userId)) + { + // set user offline if there aren't any more connections + var onlineUser = OnlineUsers.FirstOrDefault(e => e.Id == userId); + if (onlineUser != null) + { + Log("User Has No More Connections. Setting Offline..."); + OnlineUsers.Remove(onlineUser); + if (user.Data.Status >= 1) + await UpdateStatusAsync(onlineUser, 0); + } + + // also remove the user from any rooms + var room = await _roomService.GetRoom(user.Data.CurrentRoomId); + if (room.Data != null) + await LeaveRoomAsync(user.Data, room.Data); + } + } + } + } + } + + [HubMethodName("UpdateStatus")] + [Authorize] + public async Task UpdateStatusAsync(User user, int status) + { + var statusDto = new UserStatusDto { Id = user.Id, Status = status }; + + Log($"Updating Status\n{JsonSerializer.Serialize(statusDto)}"); + + var res = await _userService.UpdateStatus(statusDto); + + if (res != null && res.Success && res.Data != null) + { + // send UserUpdate to all clients + UserInformationDto userInfoDto = new(); + userInfoDto.MapToUser(user); + + await Clients.All.SendAsync("UserUpdate", userInfoDto); + + Log($"Status Was Set To {res.Data.Status} On User {user.Username}"); + await Clients.Client(Context.ConnectionId).SendAsync("UpdateCurrentUser"); + } + else + Log($"Something Went Wrong Setting The Status On User {user.Username}"); + } + + [HubMethodName("JoinRoom")] + [Authorize] + public async Task JoinRoomAsync(User user, Room room) + { + await Groups.AddToGroupAsync(Context.ConnectionId, room.Id); + + await Clients.Group(room.Id).SendAsync("RoomMessage", ServerUser, $"User {user.Username} Has Joined {room.Name}", room); + + if (!GroupUsers.TryGetValue(room.Id, out _)) { GroupUsers.Add(room.Id, new List()); } + GroupUsers[room.Id].Add(user); + + await Clients.Group(room.Id).SendAsync("RoomUserList", room, GroupUsers[room.Id]); + Log($"User {user.Username} Has Joined {room.Name}"); + + user.CurrentRoomId = room.Id; + + ServiceResponse dbRoomRes = await _roomService.GetRoom(room.Id); + Room? dbRoom = dbRoomRes?.Data; + + if (dbRoom != null) dbRoom.UserCount += 1; + + await _dataContext.SaveChangesAsync(); + await Clients.All.SendAsync("RefreshRoomList"); + } + + [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("RoomMessage", ServerUser, $"Guest User {username} Has Joined {room.Data.Name}", room); + + room.Data.UserCount += 1; + await _dataContext.SaveChangesAsync(); + + await Clients.All.SendAsync("RefreshRoomList"); + } + } + + [HubMethodName("LeaveRoom")] + [Authorize] + public async Task LeaveRoomAsync(User user, Room room) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, room.Id); + + await Clients.Group(room.Id).SendAsync("RoomMessage", ServerUser, $"User {user.Username} Has Left {room.Name}", room); + + if (GroupUsers.TryGetValue(room.Id, out _)) GroupUsers[room.Id].Remove(GroupUsers[room.Id].FirstOrDefault(e => e.Id == user.Id)!); + + await Clients.Group(room.Id).SendAsync("RoomUserList", room, GroupUsers[room.Id]); + Log($"User {user.Username} Has Left {room.Name}"); + + user.CurrentRoomId = string.Empty; + + ServiceResponse dbRoomRes = await _roomService.GetRoom(room.Id); + Room? dbRoom = dbRoomRes?.Data; + + if (dbRoom != null) dbRoom.UserCount -= 1; + + await _dataContext.SaveChangesAsync(); + await Clients.All.SendAsync("RefreshRoomList"); + } + + [HubMethodName("SendMessage")] + [Authorize] + public async Task SendMessageAsync(User user, Message message, Room room) + { + await Clients.Group(room.Id).SendAsync("RoomMessage", user, $"{message.Content}", room); + } + + [HubMethodName("SendDirectMessage")] + [Authorize] + public async Task SendDirectMessageAsync(User user, UserInformationDto userToMsg, Message message) + { + // send direct message directly to connected user + if (ConnectedUsers.TryGetValue(userToMsg.Id, out List? value)) + { + var connection = value.FirstOrDefault(); + if (connection != null) + { + UserInformationDto userInformationDto = new UserInformationDto { Id = user.Id, Username = user.Username, Bio = user.Bio, Role = user.Role, Status = user.Status, CreatedAt = user.CreatedAt, DateOfBirth = user.DateOfBirth, ProfilePicture = user.ProfilePicture, ProfileCosmeticId = user.ActiveProfileCosmetic }; + await Clients.Client(connection).SendAsync("ReceiveDirectMessage", message, userInformationDto); + return; + } + } + } + + private void Log(string message) => _logger.LogInformation(message); + } +} diff --git a/qtc-net-server/Hubs/TicTacToeHub.cs b/qtc-net-server/Hubs/TicTacToeHub.cs new file mode 100644 index 0000000..3972f1f --- /dev/null +++ b/qtc-net-server/Hubs/TicTacToeHub.cs @@ -0,0 +1,272 @@ +using qtc_api.Enums; +using qtc_api.Services.GameRoomService; + +namespace qtc_api.Hubs +{ + [Authorize] + public class TicTacToeHub : Hub + { + private IUserService _userService; + private ILogger _logger; + private GameRoomService _gameRoomService; + public TicTacToeHub(ILogger logger, IUserService userService, GameRoomService gameRoomService) + { + _userService = userService; + _gameRoomService = gameRoomService; + _logger = logger; + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + // find any existing room user was in + var room = _gameRoomService.GameRooms.FirstOrDefault(e => (e.Player1?.Id == Context.UserIdentifier) || (e.Player2?.Id == Context.UserIdentifier)); + if (room != null) + { + // just end the game + room.Status = GameStatus.PlayerDisconnected; + + string usernameDisconnected = string.Empty; + + // determine which player to null out + if (room.Player1?.Id == Context.UserIdentifier) { usernameDisconnected = room.Player1!.Username; room.Player1 = null; } + else { usernameDisconnected = room.Player2!.Username; room.Player2 = null; } + + bool isWin = room.Status == GameStatus.P1Win || room.Status == GameStatus.P2Win || room.Status == GameStatus.NoWin; + if (!isWin) + await Clients.Group(room.Id).SendAsync("GameEnd", room, usernameDisconnected); + + // remove the room + _gameRoomService.RemoveRoom(room); + } + } + + [HubMethodName("FindRoom")] + public async Task FindRoom() + { + // find user from useridentifier + var user = await _userService.GetUserById(Context.UserIdentifier!); + + if (user != null && user.Success && user.Data != null) + { + // find any room waiting on a player + var roomWOP = _gameRoomService.GameRooms.FirstOrDefault(e => e.Status == GameStatus.WaitingForPlayer && e.Player2 == null && e.Player1?.Id != Context.UserIdentifier); + if (roomWOP != null) + { + // add player to said group + await Groups.AddToGroupAsync(Context.ConnectionId, roomWOP.Id); + + // set player two + roomWOP.Player2 = user.Data; + + // this game can now start symbol selection + roomWOP.Status = GameStatus.SelectingSymbol; + await Clients.User(roomWOP.Player1!.Id).SendAsync("SelectSymbol", roomWOP); + await Clients.Group(roomWOP.Id).SendAsync("SelectingSymbol", roomWOP); + + return; + } + + // find any room who's player one matches the user and is stuck in 'WaitingForPlayer' status + var roomWFPP1 = _gameRoomService.GameRooms.FirstOrDefault(e => e.Player1?.Id == user.Data.Id && e.Status == GameStatus.WaitingForPlayer); + if (roomWFPP1 != null) + // just remove it + _gameRoomService.RemoveRoom(roomWFPP1); + + // if all if statements above don't match, make a new room + var gameRoom = new GameRoom { Id = Guid.NewGuid().ToString(), Player1 = user.Data, Status = GameStatus.WaitingForPlayer }; + _gameRoomService.AddRoom(gameRoom); + + // add user to group + await Groups.AddToGroupAsync(Context.ConnectionId, gameRoom.Id); + + // waiting for player + await Clients.Group(gameRoom.Id).SendAsync("WaitingForPlayer", gameRoom); + } + } + + [HubMethodName("SetSymbol")] + public async Task SetPlayerSymbol(string roomId, TicTacToeSymbol symbol) + { + // find the room + var room = _gameRoomService.GameRooms.FirstOrDefault(e => e.Id == roomId); + + if (room != null) + { + // set player one symbol + room.P1Symbol = symbol; + + // set player two symbol based on what player one picked + switch (room.P1Symbol) + { + case TicTacToeSymbol.X: + room.P2Symbol = TicTacToeSymbol.O; + break; + case TicTacToeSymbol.O: + room.P2Symbol = TicTacToeSymbol.X; + break; + } + + // the game can now start + room.Status = GameStatus.Ongoing; + await Clients.Group(room.Id).SendAsync("GameStart", room); + + // start player ones turn + await Clients.User(room.Player1!.Id).SendAsync("StartTurn", room); + await Clients.User(room.Player2!.Id).SendAsync("EndTurn", room); + } + } + + [HubMethodName("MakeMove")] + public async Task MakeMove(string roomId, TicTacToeMove move) + { + // find the room + var room = _gameRoomService.GameRooms.FirstOrDefault(e => e.Id == roomId); + + if (room != null) + { + // update board based on move (TOOD - figure out a better way to do this) + if (room.Player1?.Id == move.User.Id) + { + switch (move.Point) + { + case 1: + room.Board.Square1 = room.P1Symbol; + break; + case 2: + room.Board.Square2 = room.P1Symbol; + break; + case 3: + room.Board.Square3 = room.P1Symbol; + break; + case 4: + room.Board.Square4 = room.P1Symbol; + break; + case 5: + room.Board.Square5 = room.P1Symbol; + break; + case 6: + room.Board.Square6 = room.P1Symbol; + break; + case 7: + room.Board.Square7 = room.P1Symbol; + break; + case 8: + room.Board.Square8 = room.P1Symbol; + break; + case 9: + room.Board.Square9 = room.P1Symbol; + break; + } + } + else + { + switch (move.Point) + { + case 1: + room.Board.Square1 = room.P2Symbol; + break; + case 2: + room.Board.Square2 = room.P2Symbol; + break; + case 3: + room.Board.Square3 = room.P2Symbol; + break; + case 4: + room.Board.Square4 = room.P2Symbol; + break; + case 5: + room.Board.Square5 = room.P2Symbol; + break; + case 6: + room.Board.Square6 = room.P2Symbol; + break; + case 7: + room.Board.Square7 = room.P2Symbol; + break; + case 8: + room.Board.Square8 = room.P2Symbol; + break; + case 9: + room.Board.Square9 = room.P2Symbol; + break; + } + } + + // send board update + await Clients.Group(roomId).SendAsync("UpdateBoard", room, move); + + // check for draw + if (room.Board.IsDraw()) + { + room.Status = GameStatus.NoWin; + await Clients.Group(room.Id).SendAsync("GameEnd", room, string.Empty); + return; + } + + // check for winner + var winningSymbol = room.Board.GetWinner(); + if (winningSymbol != TicTacToeSymbol.Blank) + { + if (winningSymbol == room.P1Symbol) + { + // player one has won the game + room.Status = GameStatus.P1Win; + await Clients.Group(room.Id).SendAsync("GameEnd", room, string.Empty); + } + else + { + // player two has won the game + room.Status = GameStatus.P2Win; + await Clients.Group(room.Id).SendAsync("GameEnd", room, string.Empty); + } + + return; + } + + // switch turns + if (move.User.Id == room.Player1?.Id) + { + await Clients.User(room.Player1!.Id).SendAsync("EndTurn", room); + await Clients.User(room.Player2!.Id).SendAsync("StartTurn", room); + } + else + { + await Clients.User(room.Player2!.Id).SendAsync("EndTurn", room); + await Clients.User(room.Player1!.Id).SendAsync("StartTurn", room); + } + } + } + + [HubMethodName("RestartGame")] + public async Task RestartGame(string roomId) + { + // find the room + var room = _gameRoomService.GameRooms.FirstOrDefault(e => e.Id == roomId); + if (room != null) + { + // room still exists and the game can be restarted + room.Status = GameStatus.Ongoing; + room.Board = new TicTacToeBoard(); // clears the board + + await Clients.Group(room.Id).SendAsync("RestartGame", room); + + // have player one start their turn + await Clients.User(room.Player1!.Id).SendAsync("StartTurn", room); + await Clients.User(room.Player2!.Id).SendAsync("EndTurn", room); + } + else + { + // the user trying to restart the game needs to be informed that the other user has left + await Clients.Client(Context.ConnectionId).SendAsync("CannotRestart"); + } + } + + [HubMethodName("SendRoomMessage")] + public async Task SendRoomMessage(string roomId, string message) + { + // find the room + var room = _gameRoomService.GameRooms.FirstOrDefault(e => e.Id == roomId); + if (room != null) await Clients.Group(room.Id).SendAsync("ReceiveMessage", message); + } + } +} diff --git a/qtc-net-server/Models/Contact.cs b/qtc-net-server/Models/Contact.cs new file mode 100644 index 0000000..caf4b5b --- /dev/null +++ b/qtc-net-server/Models/Contact.cs @@ -0,0 +1,16 @@ +namespace qtc_api.Models +{ + public class Contact + { + public string Id { get; set; } = string.Empty; + public string OwnerId { get; set; } = string.Empty; + public string UserId { get; set; } = string.Empty; + public ContactStatus OwnerStatus { get; set; } = ContactStatus.AwaitingApprovalFromOther; + public ContactStatus UserStatus { get; set; } = ContactStatus.AwaitingApprovalFromSelf; + + public virtual User? Owner { get; } + public virtual User? User { get; } + + public enum ContactStatus { AwaitingApprovalFromOther = 0, AwaitingApprovalFromSelf = 1, Accepted = 2 } + } +} diff --git a/qtc-net-server/Models/Message.cs b/qtc-net-server/Models/Message.cs new file mode 100644 index 0000000..477ec5e --- /dev/null +++ b/qtc-net-server/Models/Message.cs @@ -0,0 +1,10 @@ +namespace qtc_api.Models +{ + public class Message + { + public string Id { get; set; } = string.Empty; + public string AuthorId { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = new DateTime(); + } +} diff --git a/qtc-net-server/Models/OwnedStoreItem.cs b/qtc-net-server/Models/OwnedStoreItem.cs new file mode 100644 index 0000000..31c973d --- /dev/null +++ b/qtc-net-server/Models/OwnedStoreItem.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace qtc_api.Models +{ + public class OwnedStoreItem + { + [Key] + public int Id { get; set; } + public string UserId { get; set; } = string.Empty; + public int StoreItemId { get; set; } + + public virtual User? User { get; } + } +} diff --git a/qtc-net-server/Models/RefreshToken.cs b/qtc-net-server/Models/RefreshToken.cs new file mode 100644 index 0000000..804dfe0 --- /dev/null +++ b/qtc-net-server/Models/RefreshToken.cs @@ -0,0 +1,12 @@ +namespace qtc_api.Models +{ + public class RefreshToken + { + public string ID { get; set; } = string.Empty; + public string UserID { get; set; } = string.Empty; + public string Token { get; set; } = string.Empty; + public DateTime Expires { get; set; } + + public virtual User User { get; } = null!; + } +} diff --git a/qtc-net-server/Models/Room.cs b/qtc-net-server/Models/Room.cs new file mode 100644 index 0000000..ac80f9f --- /dev/null +++ b/qtc-net-server/Models/Room.cs @@ -0,0 +1,11 @@ +namespace qtc_api.Models +{ + public class Room + { + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string CreatorId { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = new DateTime(); + public int UserCount { get; set; } = 0; + } +} diff --git a/qtc-net-server/Models/ServerConfig.cs b/qtc-net-server/Models/ServerConfig.cs new file mode 100644 index 0000000..190b5f2 --- /dev/null +++ b/qtc-net-server/Models/ServerConfig.cs @@ -0,0 +1,11 @@ +namespace qtc_api.Models +{ + public class ServerConfig + { + public string? Name { get; set; } + public string? Description { get; set; } + public string? AdminUserId { get; set; } + public bool IsDown { get; set; } + public string? IsDownMessage { get; set; } + } +} diff --git a/qtc-net-server/Models/ServiceResponse.cs b/qtc-net-server/Models/ServiceResponse.cs new file mode 100644 index 0000000..6fca883 --- /dev/null +++ b/qtc-net-server/Models/ServiceResponse.cs @@ -0,0 +1,9 @@ +namespace qtc_api.Models +{ + public class ServiceResponse + { + public T? Data { get; set; } + public bool Success { get; set; } = false; + public string Message { get; set; } = string.Empty; + } +} diff --git a/qtc-net-server/Models/User.cs b/qtc-net-server/Models/User.cs new file mode 100644 index 0000000..0041492 --- /dev/null +++ b/qtc-net-server/Models/User.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace qtc_api.Models +{ + public class User + { + public string Id { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string ProfilePicture { get; set; } = string.Empty; + public string Bio { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public bool IsEmailVerified { get; set; } = false; + public DateTime DateOfBirth { get; set; } + public DateTime CreatedAt { get; set; } + public int Status { get; set; } = 0; + public int CurrencyAmount { get; set; } = 0; + public int StockAmount { get; set; } = 0; + public DateTime LastCurrencySpin { get; set; } + public int ActiveProfileCosmetic { get; set; } = 0; + public string CurrentRoomId { get; set; } = string.Empty; + public DateTime LastLogin { get; set; } + public string TextStatus { get; set; } = string.Empty; + + private string? _tagString; + + [NotMapped] + public string[] Tags + { + get => _tagString?.Split(',') ?? []; + set => _tagString = string.Join(',', value ?? []); + } + + public virtual IEnumerable? RefreshTokens { get; } + public virtual IEnumerable? ContactsMade { get; } + public virtual IEnumerable? ContactsList { get; } + public virtual IEnumerable? OwnedStoreItems { get; } + } +} diff --git a/qtc-net-server/Program.cs b/qtc-net-server/Program.cs new file mode 100644 index 0000000..10242b9 --- /dev/null +++ b/qtc-net-server/Program.cs @@ -0,0 +1,98 @@ +global using Microsoft.AspNetCore.Authorization; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.SignalR; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.IdentityModel.Tokens; +global using qtc_api.Data; +global using qtc_api.Dtos.Room; +global using qtc_api.Dtos.User; +global using qtc_api.Hubs; +global using qtc_api.Models; +global using qtc_api.Schema; +global using qtc_api.Services.TokenService; +global using qtc_api.Services.UserService; +global using System.Text; +using qtc_api.Services.BucketService; +using qtc_api.Services.ContactService; +using qtc_api.Services.CurrencyGamesService; +using qtc_api.Services.EmailService; +using qtc_api.Services.GameRoomService; +using qtc_api.Services.RoomService; +using qtc_api.Services.StoreService; +using System.Text.Json; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddDbContext(); +builder.Services.AddSignalR().AddHubOptions(options => +{ + options.EnableDetailedErrors = true; +}); + +builder.Services.AddStackExchangeRedisCache(options => +{ + var redisConnectionString = Environment.GetEnvironmentVariable("REDIS_CONNECTIONSTRING"); + if (redisConnectionString != null) + options.Configuration = redisConnectionString; + + if (!builder.Environment.IsProduction()) + options.InstanceName = "QtCNetServerDev_"; + else + options.InstanceName = "QtCNetServer_"; +}); + +builder.Services.AddAuthentication().AddJwtBearer(); + +builder.Services.AddTransient(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(provider => GetServerConfig()); + +ServerConfig GetServerConfig() +{ + if (!File.Exists("./Resources/ServerConfig.json")) + throw new Exception("Server Config Doesn't Exist. Please Refer To The Repo For Examples."); + else + { + try + { + var _jsonText = File.ReadAllText("./Resources/ServerConfig.json"); + ServerConfig? _config = JsonSerializer.Deserialize(_jsonText); + if (_config != null) + return _config; + else throw new Exception("Invalid Server Config. Please Refer To The Repo For Examples."); + } + catch (JsonException) + { + throw new Exception("Invalid Server Config. Please Refer To The Repo For Examples."); + } + } +} + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddHostedService(provider => provider.GetService()!); + +var app = builder.Build(); + +using var scope = app.Services.CreateScope(); + +await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + +app.MapHub("/chat"); +app.MapHub("/tttgame"); + +app.Run(); diff --git a/qtc-net-server/Resources/ServerConfig.json b/qtc-net-server/Resources/ServerConfig.json new file mode 100644 index 0000000..fb35ef6 --- /dev/null +++ b/qtc-net-server/Resources/ServerConfig.json @@ -0,0 +1,7 @@ +{ + "Name": "QtC.NET Server", + "Description": "This is a QtC.NET Server.", + "AdminUserId": "523736357658921388", + "IsDown": false, + "IsDownMessage": "This server is currently down. Please try again later." +} \ No newline at end of file diff --git a/qtc-net-server/Resources/store.json b/qtc-net-server/Resources/store.json new file mode 100644 index 0000000..9b3aa2c --- /dev/null +++ b/qtc-net-server/Resources/store.json @@ -0,0 +1,11 @@ +[ + { + "Id": 1, + "Type": 1, + "Price": 100, + "Name": "Example", + "Description": "Change Me!", + "AssetUrl": "https://assets.alanmoon.net/qtc/cosmetics/test/test.gif", + "ThumbnailUrl": "https://assets.alanmoon.net/qtc/cosmetics/test/thumbnail.jpg" + } +] diff --git a/qtc-net-server/Schema/GameRoom.cs b/qtc-net-server/Schema/GameRoom.cs new file mode 100644 index 0000000..a26d514 --- /dev/null +++ b/qtc-net-server/Schema/GameRoom.cs @@ -0,0 +1,15 @@ +using qtc_api.Enums; + +namespace qtc_api.Schema +{ + public class GameRoom + { + public string Id { get; set; } = string.Empty; + public GameStatus Status { get; set; } + public TicTacToeBoard Board { get; set; } = new(); + public User? Player1 { get; set; } + public TicTacToeSymbol P1Symbol { get; set; } = TicTacToeSymbol.Blank; + public User? Player2 { get; set; } + public TicTacToeSymbol P2Symbol { get; set; } = TicTacToeSymbol.Blank; + } +} diff --git a/qtc-net-server/Schema/StoreItem.cs b/qtc-net-server/Schema/StoreItem.cs new file mode 100644 index 0000000..b7a5dc5 --- /dev/null +++ b/qtc-net-server/Schema/StoreItem.cs @@ -0,0 +1,23 @@ +using qtc_api.Enums; +using System.Text.Json.Serialization; + +namespace qtc_api.Schema +{ + public class StoreItem + { + [JsonPropertyName("Id")] + public int Id { get; set; } + [JsonPropertyName("Type")] + public StoreItemType Type { get; set; } + [JsonPropertyName("Price")] + public int Price { get; set; } + [JsonPropertyName("Name")] + public string Name { get; set; } = string.Empty; + [JsonPropertyName("Description")] + public string Description { get; set; } = string.Empty; + [JsonPropertyName("AssetUrl")] + public string AssetUrl { get; set; } = string.Empty; + [JsonPropertyName("ThumbnailUrl")] + public string ThumbnailUrl { get; set; } = string.Empty; + } +} diff --git a/qtc-net-server/Schema/TicTacToeBoard.cs b/qtc-net-server/Schema/TicTacToeBoard.cs new file mode 100644 index 0000000..b1f45c2 --- /dev/null +++ b/qtc-net-server/Schema/TicTacToeBoard.cs @@ -0,0 +1,64 @@ +using qtc_api.Enums; + +namespace qtc_api.Schema +{ + public class TicTacToeBoard + { + public TicTacToeSymbol Square1 { get; set; } = TicTacToeSymbol.Blank; + public TicTacToeSymbol Square2 { get; set; } = TicTacToeSymbol.Blank; + public TicTacToeSymbol Square3 { get; set; } = TicTacToeSymbol.Blank; + public TicTacToeSymbol Square4 { get; set; } = TicTacToeSymbol.Blank; + public TicTacToeSymbol Square5 { get; set; } = TicTacToeSymbol.Blank; + public TicTacToeSymbol Square6 { get; set; } = TicTacToeSymbol.Blank; + public TicTacToeSymbol Square7 { get; set; } = TicTacToeSymbol.Blank; + public TicTacToeSymbol Square8 { get; set; } = TicTacToeSymbol.Blank; + public TicTacToeSymbol Square9 { get; set; } = TicTacToeSymbol.Blank; + + public TicTacToeSymbol GetWinner() + { + // winning possibilities + var lines = new List<(TicTacToeSymbol, TicTacToeSymbol, TicTacToeSymbol)> + { + (Square1, Square2, Square3), + (Square4, Square5, Square6), + (Square7, Square8, Square9), + (Square1, Square4, Square7), + (Square2, Square5, Square8), + (Square3, Square6, Square9), + (Square1, Square5, Square9), + (Square3, Square5, Square7) + }; + + foreach (var (a, b, c) in lines) + { + if (a != TicTacToeSymbol.Blank && a == b && b == c) + { + return a; // winner + } + } + + return TicTacToeSymbol.Blank; + } + + public bool IsDraw() + { + // If there is a winner, it's not a draw + if (GetWinner() != TicTacToeSymbol.Blank) + return false; + + // Check if all squares are filled + bool allFilled = + Square1 != TicTacToeSymbol.Blank && + Square2 != TicTacToeSymbol.Blank && + Square3 != TicTacToeSymbol.Blank && + Square4 != TicTacToeSymbol.Blank && + Square5 != TicTacToeSymbol.Blank && + Square6 != TicTacToeSymbol.Blank && + Square7 != TicTacToeSymbol.Blank && + Square8 != TicTacToeSymbol.Blank && + Square9 != TicTacToeSymbol.Blank; + + return allFilled; + } + } +} diff --git a/qtc-net-server/Schema/TicTacToeMove.cs b/qtc-net-server/Schema/TicTacToeMove.cs new file mode 100644 index 0000000..63c4bf1 --- /dev/null +++ b/qtc-net-server/Schema/TicTacToeMove.cs @@ -0,0 +1,8 @@ +namespace qtc_api.Schema +{ + public class TicTacToeMove + { + public User User { get; set; } = new(); + public int Point { get; set; } + } +} diff --git a/qtc-net-server/Services/BucketService/BucketService.cs b/qtc-net-server/Services/BucketService/BucketService.cs new file mode 100644 index 0000000..2ac9315 --- /dev/null +++ b/qtc-net-server/Services/BucketService/BucketService.cs @@ -0,0 +1,157 @@ +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; + +namespace qtc_api.Services.BucketService +{ + public class BucketService : IBucketService + { + public string ServiceUrl { get; private set; } = string.Empty; + public string AccessKey { get; private set; } = string.Empty; + public string SecretKey { get; private set; } = string.Empty; + public string ProfileImagesBucketName { get; private set; } = string.Empty; + public string CDNBucketName { get; private set; } = string.Empty; + public string ImagesBucketName { get; private set; } = string.Empty; + + private static AmazonS3Client S3Client = new(); + 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 cdnBucket = _config["S3Config:S3CDNBucket"]; + 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 (cdnBucket != null) CDNBucketName = cdnBucket; + 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 GetTagImageBytes(string tagName) + { + if (!Enabled) + { + _logger.LogWarning("Not Using S3 Bucket. Performance May Be Degraded."); + return default; + } + + try + { + var response = await S3Client.GetObjectAsync(CDNBucketName, $@"tags/{tagName}.png"); + + 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..cf5276f --- /dev/null +++ b/qtc-net-server/Services/BucketService/IBucketService.cs @@ -0,0 +1,15 @@ +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 GetTagImageBytes(string tagName); + public Task DeleteProfileImage(string userId, string imageName); + } +} diff --git a/qtc-net-server/Services/ContactService/ContactService.cs b/qtc-net-server/Services/ContactService/ContactService.cs new file mode 100644 index 0000000..31aec6b --- /dev/null +++ b/qtc-net-server/Services/ContactService/ContactService.cs @@ -0,0 +1,119 @@ +namespace qtc_api.Services.ContactService +{ + public class ContactService : IContactService + { + private readonly DataContext _dataContext; + + public ContactService(DataContext dataContext) + { + _dataContext = dataContext; + } + + public async Task> CreateContact(string ownerId, string userId) + { + var serviceResponse = new ServiceResponse(); + var rnd = LongRandom(1, 900000000000000000, new Random()); + + var contact = new Contact() + { + Id = rnd.ToString(), + OwnerId = ownerId, + UserId = userId, + OwnerStatus = Contact.ContactStatus.AwaitingApprovalFromOther, + UserStatus = Contact.ContactStatus.AwaitingApprovalFromSelf, + }; + + await _dataContext.Contacts.AddAsync(contact); + await _dataContext.SaveChangesAsync(); + + serviceResponse.Success = true; + serviceResponse.Data = contact; + + return serviceResponse; + } + + public async Task> UpdateContactStatus(string ownerId, string userId, Contact.ContactStatus ownerStatus, Contact.ContactStatus userStatus) + { + var serviceResponse = new ServiceResponse(); + + var contact = await _dataContext.Contacts.FirstOrDefaultAsync(e => (e.OwnerId == ownerId && e.UserId == userId) || (e.OwnerId == userId && e.UserId == ownerId)); + + if (contact != null) + { + contact.OwnerStatus = ownerStatus; + contact.UserStatus = userStatus; + serviceResponse.Success = true; + } + else serviceResponse.Success = false; + + await _dataContext.SaveChangesAsync(); + + return serviceResponse; + } + + public async Task> DeleteContact(string ownerId, string userId) + { + var serviceResponse = new ServiceResponse(); + var contact = await _dataContext.Contacts.FirstOrDefaultAsync(e => (e.OwnerId == ownerId && e.UserId == userId) || (e.OwnerId == userId && e.UserId == ownerId)); + + if (contact != null) + { + var result = _dataContext.Contacts.Remove(contact); + await _dataContext.SaveChangesAsync(); + + serviceResponse.Success = true; + serviceResponse.Data = result.Entity; + + return serviceResponse; + } + else + { + serviceResponse.Success = false; + serviceResponse.Data = null; + serviceResponse.Message = "Contact Not Found"; + + return serviceResponse; + } + } + + public async Task> GetContactById(string id) + { + var serviceResponse = new ServiceResponse(); + + var contact = await _dataContext.Contacts.FirstOrDefaultAsync(x => x.Id == id); + + if (contact == null) { serviceResponse.Success = false; serviceResponse.Message = "Contact Does Not Exist."; } + else { serviceResponse.Success = true; serviceResponse.Data = contact; } + + return serviceResponse; + } + + public async Task>> GetUserContacts(User user) + { + var serviceResponse = new ServiceResponse>(); + + var contactsMade = await _dataContext.Contacts.Where(x => x.OwnerId == user.Id).ToListAsync(); + var contactsList = await _dataContext.Contacts.Where(x => x.UserId == user.Id).ToListAsync(); + + var contactsCombined = new List(); + + foreach (var contact in contactsMade) + contactsCombined.Add(contact); // all contacts the user has made + foreach (var contact in contactsList) + contactsCombined.Add(contact); // all contacts the user has received + + if (contactsCombined.Count == 0) { serviceResponse.Success = true; serviceResponse.Message = "User Has No Contacts."; } + else { serviceResponse.Success = true; serviceResponse.Data = contactsCombined; } + + return serviceResponse; + } + + private long LongRandom(long min, long max, Random rnd) + { + long result = rnd.Next((int)(min >> 32), (int)(max >> 32)); + result = result << 32; + result = result | (long)rnd.Next((int)min, (int)max); + return result; + } + } +} diff --git a/qtc-net-server/Services/ContactService/IContactService.cs b/qtc-net-server/Services/ContactService/IContactService.cs new file mode 100644 index 0000000..bcbe441 --- /dev/null +++ b/qtc-net-server/Services/ContactService/IContactService.cs @@ -0,0 +1,11 @@ +namespace qtc_api.Services.ContactService +{ + public interface IContactService + { + public Task> CreateContact(string ownerId, string userId); + public Task> UpdateContactStatus(string ownerId, string userId, Contact.ContactStatus ownerStatus, Contact.ContactStatus userStatus); + public Task>> GetUserContacts(User user); + public Task> GetContactById(string id); + public Task> DeleteContact(string ownerId, string userId); + } +} diff --git a/qtc-net-server/Services/CurrencyGamesService/CurrencyGamesService.cs b/qtc-net-server/Services/CurrencyGamesService/CurrencyGamesService.cs new file mode 100644 index 0000000..a996589 --- /dev/null +++ b/qtc-net-server/Services/CurrencyGamesService/CurrencyGamesService.cs @@ -0,0 +1,120 @@ +namespace qtc_api.Services.CurrencyGamesService +{ + public class CurrencyGamesService : IHostedService, IDisposable + { + private Timer? Timer { get; set; } + private int CurrentPricePerStock { get; set; } + + private IServiceScopeFactory _scopeFactory; + private ILogger logger; + public CurrencyGamesService(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + this.logger = logger; + } + + // Stock Market + + public Task StartAsync(CancellationToken cancellationToken) + { + Timer = new Timer(Timer_Elapsed, null, TimeSpan.Zero, TimeSpan.FromMinutes(10)); + + logger.LogInformation("Stock Market Loop Started"); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + Timer?.Change(Timeout.Infinite, 0); + + logger.LogInformation("Stock Market Loop Stopped"); + + return Task.CompletedTask; + } + + public void Dispose() + { + Timer?.Dispose(); + } + + public ServiceResponse GetCurrentPricePerStock() { return new ServiceResponse { Success = true, Data = CurrentPricePerStock }; } + + public async Task> BuyStock(User user, int amount) + { + using (var scope = _scopeFactory.CreateScope()) + { + var userServiceScoped = scope.ServiceProvider.GetRequiredService(); + + int total = 0; + for (int i = 0; i < amount; i++) + { + total += CurrentPricePerStock; + } + + if (total > user.CurrencyAmount) return new ServiceResponse { Success = false, Message = "Cannot Afford Stock(s)" }; + + var currencyResult = await userServiceScoped.RemoveCurrencyFromUser(user.Id, total); + var stockResult = await userServiceScoped.AddStockToUser(user.Id, amount); + + return new ServiceResponse { Success = true, Data = new UserStockActionResultDto { StockAmount = stockResult.Data, CurrencyAmount = currencyResult.Data } }; + } + } + + public async Task> SellStock(User user, int amount) + { + using (var scope = _scopeFactory.CreateScope()) + { + var userServiceScoped = scope.ServiceProvider.GetRequiredService(); + + if (amount > user.StockAmount) return new ServiceResponse { Success = false, Message = "Not Enough Stocks" }; + + int total = 0; + for (int i = 0; i < amount; i++) + { + total += CurrentPricePerStock; + } + + var currencyResult = await userServiceScoped.AddCurrencyToUser(user.Id, total, false); + var stockResult = await userServiceScoped.RemoveStockFromUser(user.Id, amount); + + return new ServiceResponse { Success = true, Data = new UserStockActionResultDto { StockAmount = stockResult.Data, CurrencyAmount = currencyResult.Data } }; + } + } + + // Number Guess + + public ServiceResponse GetRandomNumber() + { + Random rnd = new Random(); + return new ServiceResponse { Success = true, Data = rnd.Next(1, 100) }; + } + + public ServiceResponse GuessRandomNumber(int original, int guess) + { + var response = new ServiceResponse { Success = true }; + + if (original - guess == 10) response.Data = NumberGuessResult.Lower; + else if (guess - original == 10) response.Data = NumberGuessResult.Higher; + else if (guess == original) response.Data = NumberGuessResult.Correct; + else response.Data = NumberGuessResult.Incorrect; + + return response; + } + + private void Timer_Elapsed(object? state) + { + Random rnd = new Random(); + CurrentPricePerStock = rnd.Next(5, 200); + logger.LogInformation($"Current Price Per Stock - {CurrentPricePerStock}"); + } + + public enum NumberGuessResult + { + Higher, + Lower, + Correct, + Incorrect + } + } +} diff --git a/qtc-net-server/Services/EmailService/EmailService.cs b/qtc-net-server/Services/EmailService/EmailService.cs new file mode 100644 index 0000000..3a1563f --- /dev/null +++ b/qtc-net-server/Services/EmailService/EmailService.cs @@ -0,0 +1,135 @@ + +using System.Net; +using System.Net.Mail; + +namespace qtc_api.Services.EmailService +{ + public class EmailService : IEmailService + { + private IConfiguration _configuration; + private ILogger _logger; + public EmailService(IConfiguration configuration, ILogger logger) + { + _configuration = configuration; + _logger = logger; + } + + public async Task SendConfirmationEmail(string email, string name, string confirmUrl) + { + // get config entries + bool confirmationRequired = _configuration.GetValue("EmailConfig:EmailConfirmationRequired"); + string? host = _configuration.GetValue("EmailConfig:SMTPServer"); + int? port = _configuration.GetValue("EmailConfig:SMTPPort"); + string? username = _configuration.GetValue("EmailConfig:SMTPUsername"); + string? password = _configuration.GetValue("EmailConfig:SMTPPassword"); + string? senderAddress = _configuration.GetValue("EmailConfig:SMTPSenderAddress"); + + if (!confirmationRequired) + { + _logger.LogInformation("Email Confirmation Is Disabled. For Better Security Of Your Instance, Consider Setting Up An Email Sender Service."); + return; + } + else if (host == null || username == null || password == null || senderAddress == null) + { + _logger.LogInformation("Email Confirmation Is Enabled But No SMTP Settings Were Set."); + return; + } + + // set email subject + string emailSubject = "QtC.NET Email Confirmation"; + + // build confirmation email body + StringBuilder emailBody = new(); + emailBody.AppendLine($"

Hello {name},

"); + emailBody.AppendLine("

Your receiving this message because you made a QtC.NET Account on a server that requires email confirmation.
"); + emailBody.AppendLine(@$"You can confirm your account by clicking here.
"); + emailBody.AppendLine("NOTE: This Link Is Only Valid For 24 Hours.

"); + emailBody.AppendLine("If you did not create a QtC.NET account on any server, you may simply ignore this email.

"); + + // create new client + using var client = new SmtpClient(host, port ?? 587) + { + Credentials = new NetworkCredential(username, password), + EnableSsl = true, + }; + + // send email + MailMessage mailMessage = new(); + + MailAddress.TryCreate(senderAddress, out MailAddress? from); + if (from == null) + return; + + MailAddress.TryCreate(email, out MailAddress? to); + if (to == null) + return; + + mailMessage.IsBodyHtml = true; + mailMessage.To.Add(to); + mailMessage.From = from; + mailMessage.Subject = emailSubject; + mailMessage.Body = emailBody.ToString(); + + await client.SendMailAsync(mailMessage); + } + + public async Task SendPasswordResetEmail(string email, string name, string passwordResetUrl) + { + // get config entries + bool confirmationRequired = _configuration.GetValue("EmailConfig:EmailConfirmationRequired"); + string? host = _configuration.GetValue("EmailConfig:SMTPServer"); + int? port = _configuration.GetValue("EmailConfig:SMTPPort"); + string? username = _configuration.GetValue("EmailConfig:SMTPUsername"); + string? password = _configuration.GetValue("EmailConfig:SMTPPassword"); + string? senderAddress = _configuration.GetValue("EmailConfig:SMTPSenderAddress"); + + if (!confirmationRequired) + { + _logger.LogInformation("Email Confirmation Is Disabled. For Better Security Of Your Instance, Consider Setting Up An Email Sender Service."); + return; + } + else if (host == null || username == null || password == null || senderAddress == null) + { + _logger.LogInformation("Email Confirmation Is Enabled But No SMTP Settings Were Set."); + return; + } + + // set email subject + string emailSubject = "QtC.NET Email Confirmation"; + + // build confirmation email body + StringBuilder emailBody = new(); + emailBody.AppendLine($"

Hello {name},

"); + emailBody.AppendLine("

Your receiving this message because you requested a password reset on a QtC.NET Account.
"); + emailBody.AppendLine(@$"You can reset your password by clicking here.
"); + emailBody.AppendLine("NOTE: This Link Is Only Valid For 24 Hours.

"); + emailBody.AppendLine("If you did not request a password reset on any QtC.NET account on any server, you may simply ignore this email.

"); + + // create new client + using var client = new SmtpClient(host, port ?? 587) + { + Credentials = new NetworkCredential(username, password), + EnableSsl = true, + }; + + // send email + MailMessage mailMessage = new(); + + MailAddress.TryCreate(senderAddress, out MailAddress? from); + if (from == null) + return; + + MailAddress.TryCreate(email, out MailAddress? to); + if (to == null) + return; + + mailMessage.IsBodyHtml = true; + mailMessage.To.Add(to); + mailMessage.From = from; + mailMessage.Subject = emailSubject; + mailMessage.Body = emailBody.ToString(); + + await client.SendMailAsync(mailMessage); + } + } +} diff --git a/qtc-net-server/Services/EmailService/IEmailService.cs b/qtc-net-server/Services/EmailService/IEmailService.cs new file mode 100644 index 0000000..015da9b --- /dev/null +++ b/qtc-net-server/Services/EmailService/IEmailService.cs @@ -0,0 +1,8 @@ +namespace qtc_api.Services.EmailService +{ + public interface IEmailService + { + public Task SendConfirmationEmail(string email, string name, string confirmUrl); + public Task SendPasswordResetEmail(string email, string name, string passwordResetUrl); + } +} diff --git a/qtc-net-server/Services/GameRoomService/GameRoomService.cs b/qtc-net-server/Services/GameRoomService/GameRoomService.cs new file mode 100644 index 0000000..5fbe8ac --- /dev/null +++ b/qtc-net-server/Services/GameRoomService/GameRoomService.cs @@ -0,0 +1,10 @@ +namespace qtc_api.Services.GameRoomService +{ + public class GameRoomService + { + public List GameRooms { get; set; } = []; + + public void AddRoom(GameRoom room) => GameRooms.Add(room); + public void RemoveRoom(GameRoom room) => GameRooms.Remove(room); + } +} diff --git a/qtc-net-server/Services/RoomService/IRoomService.cs b/qtc-net-server/Services/RoomService/IRoomService.cs new file mode 100644 index 0000000..36e3338 --- /dev/null +++ b/qtc-net-server/Services/RoomService/IRoomService.cs @@ -0,0 +1,11 @@ +namespace qtc_api.Services.RoomService +{ + public interface IRoomService + { + public Task> GetRoom(string id); + public Task>> GetAllRooms(); + public Task> AddRoom(string userId, RoomDto room); + public Task> UpdateRoom(Room room); + public Task> DeleteRoom(string id); + } +} diff --git a/qtc-net-server/Services/RoomService/RoomService.cs b/qtc-net-server/Services/RoomService/RoomService.cs new file mode 100644 index 0000000..bc41357 --- /dev/null +++ b/qtc-net-server/Services/RoomService/RoomService.cs @@ -0,0 +1,110 @@ +namespace qtc_api.Services.RoomService +{ + public class RoomService : IRoomService + { + private readonly DataContext _dataContext; + + private long idMax = 900000000000000000; + + public RoomService(DataContext dataContext) + { + _dataContext = dataContext; + } + + public async Task> AddRoom(string userId, RoomDto room) + { + var serviceResponse = new ServiceResponse(); + var roomList = await _dataContext.Rooms.ToListAsync(); + + var roomToAdd = new Room(); + Random rnd = new Random(); + + roomToAdd.Id = LongRandom(1, idMax, rnd).ToString(); + roomToAdd.Name = room.Name; + roomToAdd.CreatorId = userId; + roomToAdd.CreatedAt = DateTime.UtcNow; + + var cRoom = await _dataContext.Rooms.FirstOrDefaultAsync(x => x.Name == roomToAdd.Name); + + if (cRoom == null) + { + await _dataContext.Rooms.AddAsync(roomToAdd); + await _dataContext.SaveChangesAsync(); + + serviceResponse.Success = true; + serviceResponse.Data = roomToAdd; + } + else + { + serviceResponse.Success = false; + serviceResponse.Message = "Room already exists."; + } + + return serviceResponse; + } + + public async Task> DeleteRoom(string id) + { + var serviceResponse = new ServiceResponse(); + var room = await _dataContext.Rooms.FirstOrDefaultAsync(x => x.Id == id); + + if (room != null) + { + _dataContext.Rooms.Remove(room); + await _dataContext.SaveChangesAsync(); + + serviceResponse.Success = true; + serviceResponse.Data = room; + } + else + { + serviceResponse.Success = false; + serviceResponse.Message = "Room not found."; + } + + return serviceResponse; + } + + public async Task>> GetAllRooms() + { + var serviceResponse = new ServiceResponse>(); + var rooms = await _dataContext.Rooms.ToListAsync(); + + serviceResponse.Success = true; + serviceResponse.Data = rooms; + return serviceResponse; + } + + public async Task> GetRoom(string id) + { + var serviceResponse = new ServiceResponse(); + var room = await _dataContext.Rooms.FirstOrDefaultAsync(x => x.Id == id); + + if (room != null) + { + serviceResponse.Success = true; + serviceResponse.Data = room; + } + else + { + serviceResponse.Success = false; + serviceResponse.Message = "Room not found."; + } + + return serviceResponse; + } + + public Task> UpdateRoom(Room room) + { + throw new NotImplementedException(); + } + + private long LongRandom(long min, long max, Random rnd) + { + long result = rnd.Next((int)(min >> 32), (int)(max >> 32)); + result = result << 32; + result = result | (long)rnd.Next((int)min, (int)max); + return result; + } + } +} diff --git a/qtc-net-server/Services/StoreService/StoreService.cs b/qtc-net-server/Services/StoreService/StoreService.cs new file mode 100644 index 0000000..d849482 --- /dev/null +++ b/qtc-net-server/Services/StoreService/StoreService.cs @@ -0,0 +1,95 @@ +using System.Text.Json; + +namespace qtc_api.Services.StoreService +{ + public class StoreService + { + public static List StoreItems { get; set; } = []; + + private readonly DataContext _ctx; + private readonly IUserService _userService; + public StoreService(DataContext ctx, IUserService userService) + { + _ctx = ctx; + _userService = userService; + + string storeJsonLocation = "./Resources/store.json"; + + if (System.Diagnostics.Debugger.IsAttached) + storeJsonLocation = "./Resources/store.Development.json"; + + StoreItem[]? storeItems = JsonSerializer.Deserialize(File.ReadAllText(storeJsonLocation)); + if (storeItems != null && storeItems.Length > 0) + { + StoreItems = storeItems.ToList(); + } + } + + public ServiceResponse> GetStoreItems() + { + return new ServiceResponse> { Success = true, Data = StoreItems }; + } + + public ServiceResponse GetStoreItem(int id) + { + var item = StoreItems.FirstOrDefault(e => e.Id == id); + if (item != null) + return new ServiceResponse { Success = true, Data = item }; + else return new ServiceResponse { Success = false, Message = "Item Not Found" }; + } + + public ServiceResponse GetBoughtStoreItemFromUser(string userId, int itemId) + { + // find item owned by user + var item = _ctx.OwnedStoreItems.FirstOrDefault(e => e.UserId == userId && e.StoreItemId == itemId); + if (item != null) + return new ServiceResponse { Success = true, Data = item }; + else return new ServiceResponse { Success = false, Message = "Item Not Yet Purchased" }; + } + + public ServiceResponse> GetBoughtStoreItemsFromUser(string userId) + { + // find items owned by user + var items = _ctx.OwnedStoreItems.Where(e => e.UserId == userId).ToList(); + if (items != null && items.Count > 0) + return new ServiceResponse> { Success = true, Data = items }; + else return new ServiceResponse> { Success = false, Message = "User Owns No Items" }; + } + + public async Task> BuyStoreItem(string userId, int id) + { + // find item in store + var item = StoreItems.FirstOrDefault(e => e.Id == id); + if (item != null) + { + // deduct currency from user + var user = await _userService.GetUserById(userId); + if (user != null && user.Success && user.Data != null) + { + if (user.Data.CurrencyAmount >= item.Price) + { + // remove currency from user + await _userService.RemoveCurrencyFromUser(userId, item.Price); + + // create owned item + OwnedStoreItem ownedStoreItem = new OwnedStoreItem + { + StoreItemId = item.Id, + UserId = userId + }; + + // add to table + _ctx.OwnedStoreItems.Add(ownedStoreItem); + await _ctx.SaveChangesAsync(); + + // return successful service response + return new ServiceResponse { Success = true, Data = true }; + } + else return new ServiceResponse { Success = false, Message = "Insufficient Currency" }; + } + else return new ServiceResponse { Success = false, Message = "User Not Found" }; + } + else return new ServiceResponse { Success = false, Message = "Item Not Found" }; + } + } +} diff --git a/qtc-net-server/Services/TokenService/ITokenService.cs b/qtc-net-server/Services/TokenService/ITokenService.cs new file mode 100644 index 0000000..df7b1ed --- /dev/null +++ b/qtc-net-server/Services/TokenService/ITokenService.cs @@ -0,0 +1,12 @@ +namespace qtc_api.Services.TokenService +{ + public interface ITokenService + { + public Task> GenerateAccessTokenAndRefreshToken(User user, bool generateRefToken = true, bool remember = false); + public ServiceResponse GenerateEmailConfirmationToken(User user); + public ServiceResponse GeneratePasswordResetConfirmationToken(User user); + public Task> ValidateAccessToken(string accessToken); + public Task> ValidateRefreshToken(string refreshToken); + public ServiceResponse GetValidationParams(); + } +} diff --git a/qtc-net-server/Services/TokenService/TokenService.cs b/qtc-net-server/Services/TokenService/TokenService.cs new file mode 100644 index 0000000..4b93d8f --- /dev/null +++ b/qtc-net-server/Services/TokenService/TokenService.cs @@ -0,0 +1,285 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; + +namespace qtc_api.Services.TokenService +{ + public class TokenService : ITokenService + { + private readonly IConfiguration _configuration; + private readonly DataContext _dataContext; + + public TokenService(IConfiguration configuration, DataContext dataContext) + { + _configuration = configuration; + _dataContext = dataContext; + } + + public async Task> GenerateAccessTokenAndRefreshToken(User user, bool generateRefToken, bool remember) + { + var serviceResponse = new ServiceResponse(); + + // Generate JWT Access Token + + List claims = new List() + { + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Name, user.Username), + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Role, user.Role), + new Claim("TokenType", "access") + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration.GetSection("Jwt:Key").Value ?? Environment.GetEnvironmentVariable("JWT_KEY")!)); + var issuer = _configuration["Jwt:Issuer"]; + var audience = _configuration["Jwt:Audience"]; + + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: claims, + expires: DateTime.UtcNow.AddHours(1), + signingCredentials: creds + ); + + var jwt = new JwtSecurityTokenHandler().WriteToken(token); + + serviceResponse.Data = jwt; + + // Generate and Store Refresh Token + + if (generateRefToken) + { + var random = new byte[32]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(random); + } + + RefreshToken refToken = new RefreshToken() + { + ID = LongRandom(1, 900000000000000000, new Random()).ToString(), + UserID = user.Id, + Token = Convert.ToBase64String(random) + }; + + if (remember) refToken.Expires = DateTime.UtcNow.AddMonths(1); + else refToken.Expires = DateTime.UtcNow.AddDays(3); + + _dataContext.ValidRefreshTokens.Add(refToken); + + await _dataContext.SaveChangesAsync(); + + serviceResponse.Message = refToken.Token; + } + + serviceResponse.Success = true; + return serviceResponse; + } + + public ServiceResponse GenerateEmailConfirmationToken(User user) + { + var serviceResponse = new ServiceResponse(); + + // Generate JWT Access Token + + List claims = new List() + { + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Email, user.Email), + new Claim("TokenType", "email-confirmation") + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration.GetSection("Jwt:Key").Value ?? Environment.GetEnvironmentVariable("JWT_KEY")!)); + var issuer = _configuration["Jwt:Issuer"]; + var audience = _configuration["Jwt:Audience"]; + + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: claims, + expires: DateTime.UtcNow.AddHours(24), + signingCredentials: creds + ); + + var jwt = new JwtSecurityTokenHandler().WriteToken(token); + + serviceResponse.Success = true; + serviceResponse.Data = jwt; + + return serviceResponse; + } + + public ServiceResponse GeneratePasswordResetConfirmationToken(User user) + { + var serviceResponse = new ServiceResponse(); + + // Generate JWT Access Token + + List claims = new List() + { + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Email, user.Email), + new Claim("TokenType", "password-reset") + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration.GetSection("Jwt:Key").Value ?? Environment.GetEnvironmentVariable("JWT_KEY")!)); + var issuer = _configuration["Jwt:Issuer"]; + var audience = _configuration["Jwt:Audience"]; + + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: claims, + expires: DateTime.UtcNow.AddHours(1), + signingCredentials: creds + ); + + var jwt = new JwtSecurityTokenHandler().WriteToken(token); + + serviceResponse.Success = true; + serviceResponse.Data = jwt; + + return serviceResponse; + } + + public ServiceResponse GenerateClientPasswordResetConfirmationToken(User user) + { + var serviceResponse = new ServiceResponse(); + + // Generate JWT Access Token + + List claims = new List() + { + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Email, user.Email), + new Claim("TokenType", "client-password-reset") + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration.GetSection("Jwt:Key").Value ?? Environment.GetEnvironmentVariable("JWT_KEY")!)); + var issuer = _configuration["Jwt:Issuer"]; + var audience = _configuration["Jwt:Audience"]; + + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: claims, + expires: DateTime.UtcNow.AddHours(1), + signingCredentials: creds + ); + + var jwt = new JwtSecurityTokenHandler().WriteToken(token); + + serviceResponse.Success = true; + serviceResponse.Data = jwt; + + return serviceResponse; + } + + public async Task> ValidateRefreshToken(string refreshToken) + { + var serviceResponse = new ServiceResponse(); + + var dbRefresh = await _dataContext.ValidRefreshTokens.FirstOrDefaultAsync(x => x.Token == refreshToken); + + if (dbRefresh != null) + { + if (DateTime.Compare(DateTime.UtcNow, dbRefresh.Expires) > 0) + { + serviceResponse.Success = false; + serviceResponse.Message = "Refresh Token Expired."; + + // Handle Expired Refresh Token + + _dataContext.ValidRefreshTokens.Remove(dbRefresh); + await _dataContext.SaveChangesAsync(); + + return serviceResponse; + } + + var user = await _dataContext.Users.FirstOrDefaultAsync(x => x.Id == dbRefresh.UserID); + + if (user != null && dbRefresh.UserID == user.Id) + { + var token = await GenerateAccessTokenAndRefreshToken(user, true, false); + + if (token != null) + { + serviceResponse.Success = true; + serviceResponse.Message = token.Message; + serviceResponse.Data = token.Data; + } + } + else + { + serviceResponse.Success = false; + serviceResponse.Message = "Requesting User ID and the associated Refresh Token's User ID does not match."; + } + } + else + { + serviceResponse.Success = false; + serviceResponse.Message = "Invalid Refresh Token."; + } + + return serviceResponse; + } + + public async Task> ValidateAccessToken(string accessToken) + { + var serviceResponse = new ServiceResponse(); + + var tokenHandler = new JwtSecurityTokenHandler(); + var validationParams = GetValidationParams(); + + TokenValidationResult result = await tokenHandler.ValidateTokenAsync(accessToken, validationParams.Data); + + if (result.IsValid) + { + serviceResponse.Success = true; + serviceResponse.Data = true; + return serviceResponse; + } + else + { + serviceResponse.Success = true; + serviceResponse.Data = false; + return serviceResponse; + } + } + + public ServiceResponse GetValidationParams() + { + var serviceResponse = new ServiceResponse(); + + serviceResponse.Data = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = _configuration["Jwt:Issuer"], + ValidAudience = _configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]!)) + }; + + return serviceResponse; + } + + private long LongRandom(long min, long max, Random rnd) + { + long result = rnd.Next((int)(min >> 32), (int)(max >> 32)); + result = result << 32; + result = result | (long)rnd.Next((int)min, (int)max); + return result; + } + } +} diff --git a/qtc-net-server/Services/UserService/IUserService.cs b/qtc-net-server/Services/UserService/IUserService.cs new file mode 100644 index 0000000..76ce558 --- /dev/null +++ b/qtc-net-server/Services/UserService/IUserService.cs @@ -0,0 +1,21 @@ +namespace qtc_api.Services.UserService +{ + public interface IUserService + { + public Task> GetUserById(string id); + public Task> GetUserInformationById(string id); + public Task> GetUserByEmail(string email); + public Task>> GetAllUsers(); + public Task>> GetAllOnlineUsers(); + public Task> AddUser(UserDto userReq); + public Task> UpdateUserInfo(UserUpdateInformationDto request); + public Task> UpdateUserPic(string userId, IFormFile file); + public Task> GetUserPic(string userId); + public Task> UpdateStatus(UserStatusDto request); + public Task> DeleteUser(string id); + public Task> AddCurrencyToUser(string id, int amount, bool isSpinClaim); + public Task> RemoveCurrencyFromUser(string id, int amount); + public Task> AddStockToUser(string id, int amount); + public Task> RemoveStockFromUser(string id, int amount); + } +} diff --git a/qtc-net-server/Services/UserService/UserService.cs b/qtc-net-server/Services/UserService/UserService.cs new file mode 100644 index 0000000..270e667 --- /dev/null +++ b/qtc-net-server/Services/UserService/UserService.cs @@ -0,0 +1,507 @@ +using qtc_api.Services.BucketService; + +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, IBucketService bucketService, DataContext dataContext) + { + _configuration = configuration; + _dataContext = dataContext; + _bucketService = bucketService; + } + + public async Task> AddUser(UserDto userReq) + { + var serviceResponse = new ServiceResponse(); + var userList = await _dataContext.Users.ToListAsync(); + + var user = new User(); + Random rnd = new Random(); + var id = LongRandom(1, idMax, rnd); + + user.Id = id.ToString(); + user.Username = userReq.Username; + user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(userReq.Password); + user.Email = userReq.Email; + user.DateOfBirth = userReq.DateOfBirth; + user.Role = _configuration["Jwt:DefaultUserRole"]!; + user.CreatedAt = DateTime.UtcNow; + + // add new user tag + user.Tags = ["new_user"]; + + try + { + var cUser = userList.FirstOrDefault(x => x.Email == user.Email); + + if (cUser != null) + { + serviceResponse.Success = false; + serviceResponse.Message = "User with that email already exists."; + return serviceResponse; + } + else + { + await _dataContext.Users.AddAsync(user); + await _dataContext.SaveChangesAsync(); + userList = await _dataContext.Users.ToListAsync(); + + if (userList.Contains(user)) + { + var createdUser = await _dataContext.Users.FindAsync(user.Id); + + serviceResponse.Success = true; + serviceResponse.Data = createdUser; + return serviceResponse; + } + else + { + serviceResponse.Success = false; + serviceResponse.Message = "User not created."; + return serviceResponse; + } + } + } + catch (Exception ex) + { + serviceResponse.Success = false; + serviceResponse.Message = ex.Message; + return serviceResponse; + } + } + + public async Task> DeleteUser(string id) + { + var serviceResponse = new ServiceResponse(); + var user = _dataContext.Users.FirstOrDefault(x => x.Id == id)!; + + if (user != null) + { + _dataContext.Users.Remove(user); + await _dataContext.SaveChangesAsync(); + + serviceResponse.Success = true; + serviceResponse.Data = user; + } + else + { + serviceResponse.Success = false; + serviceResponse.Message = "User not found."; + } + + return serviceResponse; + } + + public async Task>> GetAllUsers() + { + var serviceResponse = new ServiceResponse>(); + var userList = await _dataContext.Users.ToListAsync(); + + var userInfoList = new List(); + foreach (var user in userList) + { + var x = new UserInformationDto(); + + x.Id = user.Id; + x.Username = user.Username; + x.ProfilePicture = user.ProfilePicture; + x.Role = user.Role; + x.Bio = user.Bio; + x.DateOfBirth = user.DateOfBirth; + x.CurrencyAmount = user.CurrencyAmount; + x.Status = user.Status; + x.CreatedAt = user.CreatedAt; + x.ProfileCosmeticId = user.ActiveProfileCosmetic; + x.LastLogin = user.LastLogin; + + userInfoList.Add(x); + } + + serviceResponse.Success = true; + serviceResponse.Data = userInfoList; + return serviceResponse; + } + + public async Task>> GetAllOnlineUsers() + { + var serviceResponse = new ServiceResponse>(); + var onlineUsers = new List(); + + await _dataContext.Users.ForEachAsync(user => + { + if (user.Status == 1 || user.Status == 2 || user.Status == 3) + { + var x = new UserInformationDto(); + + x.Id = user.Id; + x.Username = user.Username; + x.ProfilePicture = user.ProfilePicture; + x.Role = user.Role; + x.Bio = user.Bio; + x.Status = user.Status; + x.DateOfBirth = user.DateOfBirth; + x.CreatedAt = user.CreatedAt; + x.CurrencyAmount = user.CurrencyAmount; + x.CreatedAt = user.CreatedAt; + x.ProfileCosmeticId = user.ActiveProfileCosmetic; + x.LastLogin = user.LastLogin; + + onlineUsers.Add(x); + } + }); + + serviceResponse.Success = true; + serviceResponse.Data = onlineUsers; + return serviceResponse; + } + + public async Task> GetUserById(string id) + { + var serviceResponse = new ServiceResponse(); + var user = await _dataContext.Users.FirstOrDefaultAsync(x => x.Id == id); + serviceResponse.Success = true; + serviceResponse.Data = user; + return serviceResponse; + } + + public async Task> GetUserInformationById(string id) + { + var serviceResponse = new ServiceResponse(); + var user = await _dataContext.Users.FirstOrDefaultAsync(x => x.Id == id); + + if (user != null) + { + var dto = new UserInformationDto + { + Id = user.Id, + Username = user.Username, + ProfilePicture = user.ProfilePicture, + Role = user.Role, + Bio = user.Bio, + DateOfBirth = user.DateOfBirth, + CreatedAt = user.CreatedAt, + Status = user.Status, + CurrencyAmount = user.CurrencyAmount, + ProfileCosmeticId = user.ActiveProfileCosmetic, + LastLogin = user.LastLogin, + TextStatus = user.TextStatus, + Tags = user.Tags + }; + + // always add admin tag if user is admin + if (user.Role == "Admin") + { + List _tagList = [.. dto.Tags]; + _tagList.Add("admin"); + dto.Tags = [.. _tagList]; + } + + serviceResponse.Success = true; + serviceResponse.Data = dto; + return serviceResponse; + } + else + { + serviceResponse.Success = false; + serviceResponse.Message = "User not found."; + return serviceResponse; + } + } + + public async Task> GetUserByEmail(string email) + { + var serviceResponse = new ServiceResponse(); + var user = await _dataContext.Users.FirstOrDefaultAsync(x => x.Email == email); + serviceResponse.Success = true; + serviceResponse.Data = user; + return serviceResponse; + } + + public async Task> UpdateUserInfo(UserUpdateInformationDto request) + { + var serviceResponse = new ServiceResponse(); + var dbUser = await _dataContext.Users.FirstOrDefaultAsync(x => x.Id == request.Id); + + if (dbUser != null) + { + dbUser.Username = request.Username; + dbUser.Bio = request.Bio; + dbUser.DateOfBirth = request.DateOfBirth; + dbUser.ActiveProfileCosmetic = request.ProfileCosmeticId; + dbUser.TextStatus = request.TextStatus; + + await _dataContext.SaveChangesAsync(); + + var infoDto = new UserInformationDto + { + Id = dbUser.Id, + Username = request.Username, + ProfilePicture = dbUser.ProfilePicture, + Bio = request.Bio, + Role = dbUser.Role, + DateOfBirth = request.DateOfBirth, + CreatedAt = dbUser.CreatedAt, + Status = dbUser.Status, + CurrencyAmount = dbUser.CurrencyAmount, + ProfileCosmeticId = request.ProfileCosmeticId, + TextStatus = request.TextStatus + }; + + serviceResponse.Success = true; + serviceResponse.Data = infoDto; + } + else + { + serviceResponse.Success = false; + serviceResponse.Message = "User not found."; + } + + return serviceResponse; + } + + public async Task> UpdateUserPic(string userId, IFormFile file) + { + var serviceResponse = new ServiceResponse(); + + var userToUpdate = await _dataContext.Users.FirstOrDefaultAsync(x => x.Id == userId); + var cdnPath = _configuration["GeneralConfig:CDNPath"]; + + if (userToUpdate == null) + { + serviceResponse.Success = false; + serviceResponse.Message = "User Not Found."; + return serviceResponse; + } + + if (file != null && file.Length > 0) + { + if (!Directory.Exists(cdnPath)) Directory.CreateDirectory(cdnPath!); + if (!Directory.Exists($"{cdnPath}/{userId}")) Directory.CreateDirectory($"{cdnPath}/{userId}"); + + if (userToUpdate.ProfilePicture != null) + await _bucketService.DeleteProfileImage(userToUpdate.Id, userToUpdate.ProfilePicture); + + 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) + { + userToUpdate.ProfilePicture = fileName; + + await _dataContext.SaveChangesAsync(); + + serviceResponse.Success = true; + serviceResponse.Data = fileName; + } + else + { + // fallback to using local cdn + + var filePath = Path.Combine(cdnPath ?? "./user-content", userId, fileName); + + using (var stream = File.Create(filePath)) + { + await file.CopyToAsync(stream); + } + + userToUpdate.ProfilePicture = fileName; + + await _dataContext.SaveChangesAsync(); + + serviceResponse.Success = true; + serviceResponse.Data = fileName; + } + } + else + { + serviceResponse.Success = false; + serviceResponse.Message = "Empty File."; + } + + return serviceResponse; + } + + public async Task> GetUserPic(string userId) + { + var serviceResponse = new ServiceResponse(); + + var user = await _dataContext.Users.FirstOrDefaultAsync(x => x.Id == userId); + var cdnPath = _configuration["GeneralConfig:CDNPath"]; + + if (user != null) + { + if (user.ProfilePicture != null) + { + var response = await _bucketService.GetProfileImageBytes(user.Id, user.ProfilePicture); + if (response != null) + { + serviceResponse.Success = true; + serviceResponse.Message = user.ProfilePicture; + serviceResponse.Data = new FileContentResult(response, "image/jpeg"); + } + else + { + // 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"); + } + } + else + { + serviceResponse.Success = false; + serviceResponse.Message = "User Does Not Have A Profile Picture."; + } + } + else + { + serviceResponse.Success = false; + serviceResponse.Message = "User Not Found."; + } + + return serviceResponse; + } + + public async Task> UpdateStatus(UserStatusDto request) + { + var serviceResponse = new ServiceResponse(); + var user = _dataContext.Users.FirstOrDefault(x => x.Id == request.Id)!; + + user.Status = request.Status; + + if (user.Status == 0) + { + user.LastLogin = DateTime.UtcNow; + } + + await _dataContext.SaveChangesAsync(); + + serviceResponse.Success = true; + serviceResponse.Data = request; + + return serviceResponse; + } + + public async Task> AddCurrencyToUser(string id, int amount, bool isSpinClaim) + { + var response = new ServiceResponse(); + var user = _dataContext.Users.FirstOrDefault(e => e.Id == id); + + if (user != null) + { + user.CurrencyAmount += amount; + if (isSpinClaim) user.LastCurrencySpin = DateTime.UtcNow; + await _dataContext.SaveChangesAsync(); + + response.Success = true; + response.Data = user.CurrencyAmount; + return response; + } + else + { + response.Success = false; + response.Message = "User Not Found"; + return response; + } + } + + public async Task> RemoveCurrencyFromUser(string id, int amount) + { + var response = new ServiceResponse(); + var user = _dataContext.Users.FirstOrDefault(e => e.Id == id); + + if (user != null) + { + user.CurrencyAmount -= amount; + await _dataContext.SaveChangesAsync(); + + response.Success = true; + response.Data = user.CurrencyAmount; + return response; + } + else + { + response.Success = false; + response.Message = "User Not Found"; + return response; + } + } + public async Task> AddStockToUser(string id, int amount) + { + var response = new ServiceResponse(); + var user = _dataContext.Users.FirstOrDefault(e => e.Id == id); + + if (user != null) + { + user.StockAmount += amount; + await _dataContext.SaveChangesAsync(); + + response.Success = true; + response.Data = user.StockAmount; + return response; + } + else + { + response.Success = false; + response.Message = "User Not Found"; + return response; + } + } + + public async Task> RemoveStockFromUser(string id, int amount) + { + var response = new ServiceResponse(); + var user = _dataContext.Users.FirstOrDefault(e => e.Id == id); + + if (user != null) + { + user.StockAmount -= amount; + await _dataContext.SaveChangesAsync(); + + response.Success = true; + response.Data = user.StockAmount; + return response; + } + else + { + response.Success = false; + response.Message = "User Not Found"; + return response; + } + } + + private long LongRandom(long min, long max, Random rnd) + { + long result = rnd.Next((int)(min >> 32), (int)(max >> 32)); + result = result << 32; + result = result | (long)rnd.Next((int)min, (int)max); + return result; + } + } +} diff --git a/qtc-net-server/libman.json b/qtc-net-server/libman.json new file mode 100644 index 0000000..ceee271 --- /dev/null +++ b/qtc-net-server/libman.json @@ -0,0 +1,5 @@ +{ + "version": "1.0", + "defaultProvider": "cdnjs", + "libraries": [] +} \ No newline at end of file diff --git a/qtc-net-server/qtc-net-server.csproj b/qtc-net-server/qtc-net-server.csproj new file mode 100644 index 0000000..869b9bb --- /dev/null +++ b/qtc-net-server/qtc-net-server.csproj @@ -0,0 +1,48 @@ + + + + net9.0 + enable + enable + qtc_api + Linux + ..\docker-compose.dcproj + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + Always + + + Always + + + Always + + + +